Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e7df8a8
feat(workspace): add workspace scoping to all resource handlers and CLI
derekwaynecarr Jul 13, 2026
7bba756
feat(sdk): add workspace support to Python SDK
derekwaynecarr Jul 14, 2026
852d83e
fix(workspace): address review findings from workspace model
derekwaynecarr Jul 14, 2026
b089a20
fix(workspace): thread workspace to flush tasks and TUI provider actions
derekwaynecarr Jul 14, 2026
87bf928
fix(workspace): include SSH sessions in workspace deletion blockers
derekwaynecarr Jul 14, 2026
f103ffb
feat(workspace): add profile_workspace to Provider for explicit profi…
derekwaynecarr Jul 15, 2026
28921ef
fix(workspace): use session workspace in SSH session revocation write…
derekwaynecarr Jul 15, 2026
e87ce06
fix(workspace): resolve workspace in lint provider profiles handler
derekwaynecarr Jul 15, 2026
15206ac
fix(workspace): address review findings from workspace model
derekwaynecarr Jul 15, 2026
09bcd7e
fix(workspace): address review findings from workspace model
derekwaynecarr Jul 15, 2026
1785722
fix(workspace): fix review findings and sync RFC with implementation
derekwaynecarr Jul 15, 2026
a2994af
fix(workspace): address PR review findings and fix clippy errors
derekwaynecarr Jul 15, 2026
dbe01f5
test(workspace): add profile scope isolation tests and fix e2e worksp…
derekwaynecarr Jul 15, 2026
4778314
fix(workspace): fix workspace mismatches in reconciler and e2e tests
derekwaynecarr Jul 15, 2026
32d736a
fix(workspace): fix sandbox cleanup, platform profile scans, and DNS …
derekwaynecarr Jul 16, 2026
9d04eff
fix(workspace): key SandboxIndex by (workspace, name) to prevent cros…
derekwaynecarr Jul 16, 2026
334f546
fix(workspace): plumb workspace into policy.local, fix profile scope …
derekwaynecarr Jul 16, 2026
abb71e7
fix(workspace): use profile_workspace for provider profile pre-check …
derekwaynecarr Jul 16, 2026
cd40c84
docs(workspace): note that --all-workspaces overrides --workspace in …
derekwaynecarr Jul 16, 2026
64459b0
fix(workspace): add workspace to JSON/names output and completers
derekwaynecarr Jul 16, 2026
7ff69d9
fix(workspace): filter unmanaged sandbox CRs in K8s driver
derekwaynecarr Jul 16, 2026
6e073b5
fix(workspace): address cross-cutting review findings
derekwaynecarr Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 69 additions & 34 deletions crates/openshell-bootstrap/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,31 +275,46 @@ pub fn load_active_gateway() -> Option<String> {
}

/// Save the last-used sandbox name for a gateway to persistent storage.
pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> {
///
/// The workspace is stored alongside the name so that `load_last_sandbox`
/// only returns a sandbox that belongs to the requested workspace.
pub fn save_last_sandbox(gateway: &str, workspace: &str, sandbox: &str) -> Result<()> {
let path = last_sandbox_path(gateway)?;
ensure_parent_dir_restricted(&path)?;
std::fs::write(&path, sandbox)
let content = format!("{workspace}\n{sandbox}");
std::fs::write(&path, content)
.into_diagnostic()
.wrap_err_with(|| format!("failed to write last sandbox to {}", path.display()))?;
Ok(())
}

/// Load the last-used sandbox name for a gateway from persistent storage.
///
/// Returns `None` if no last sandbox has been set.
pub fn load_last_sandbox(gateway: &str) -> Option<String> {
last_sandbox_path(gateway)
/// Returns the sandbox name only if the stored workspace matches the
/// requested workspace. Returns `None` if no last sandbox has been set
/// or the workspace does not match.
pub fn load_last_sandbox(gateway: &str, workspace: &str) -> Option<String> {
let content = last_sandbox_path(gateway)
.ok()
.as_deref()
.and_then(read_nonempty_trimmed)
.and_then(read_nonempty_trimmed)?;
let mut lines = content.lines();
let stored_workspace = lines.next()?.trim();
let sandbox = lines.next().map(str::trim).filter(|s| !s.is_empty())?;
if stored_workspace == workspace {
Some(sandbox.to_string())
} else {
None
}
}

/// Clear the last-used sandbox record for a gateway if it matches the given name.
/// Clear the last-used sandbox record for a gateway if it matches the given
/// workspace and name.
///
/// This should be called after a sandbox is deleted so that subsequent commands
/// don't try to connect to a sandbox that no longer exists.
pub fn clear_last_sandbox_if_matches(gateway: &str, sandbox: &str) {
if let Some(current) = load_last_sandbox(gateway)
pub fn clear_last_sandbox_if_matches(gateway: &str, workspace: &str, sandbox: &str) {
if let Some(current) = load_last_sandbox(gateway, workspace)
&& current == sandbox
&& let Ok(path) = last_sandbox_path(gateway)
{
Expand Down Expand Up @@ -507,51 +522,55 @@ mod tests {
fn save_and_load_last_sandbox_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("mygateway", "dev-box").unwrap();
assert_eq!(load_last_sandbox("mygateway"), Some("dev-box".to_string()));
save_last_sandbox("mygateway", "default", "dev-box").unwrap();
assert_eq!(
load_last_sandbox("mygateway", "default"),
Some("dev-box".to_string())
);
});
}

#[test]
fn load_last_sandbox_returns_none_when_not_set() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
assert_eq!(load_last_sandbox("no-such-gateway"), None);
assert_eq!(load_last_sandbox("no-such-gateway", "default"), None);
});
}

#[test]
fn save_last_sandbox_overwrites_previous() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("g1", "first").unwrap();
save_last_sandbox("g1", "second").unwrap();
assert_eq!(load_last_sandbox("g1"), Some("second".to_string()));
save_last_sandbox("g1", "default", "first").unwrap();
save_last_sandbox("g1", "default", "second").unwrap();
assert_eq!(
load_last_sandbox("g1", "default"),
Some("second".to_string())
);
});
}

#[test]
fn save_last_sandbox_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
// The gateway subdir doesn't exist yet — save should create it.
save_last_sandbox("brand-new-gateway", "sb1").unwrap();
save_last_sandbox("brand-new-gateway", "default", "sb1").unwrap();
assert_eq!(
load_last_sandbox("brand-new-gateway"),
load_last_sandbox("brand-new-gateway", "default"),
Some("sb1".to_string())
);
});
}

#[test]
fn load_last_sandbox_ignores_whitespace() {
fn load_last_sandbox_returns_none_for_legacy_single_line_file() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
// Write the file manually with surrounding whitespace.
let path = last_sandbox_path("ws-gateway").unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, " my-sb \n").unwrap();
assert_eq!(load_last_sandbox("ws-gateway"), Some("my-sb".to_string()));
assert_eq!(load_last_sandbox("ws-gateway", "default"), None);
});
}

Expand All @@ -562,27 +581,40 @@ mod tests {
let path = last_sandbox_path("empty-gateway").unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, " \n").unwrap();
assert_eq!(load_last_sandbox("empty-gateway"), None);
assert_eq!(load_last_sandbox("empty-gateway", "default"), None);
});
}

#[test]
fn last_sandbox_is_per_gateway() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("gateway-a", "sandbox-a").unwrap();
save_last_sandbox("gateway-b", "sandbox-b").unwrap();
save_last_sandbox("gateway-a", "default", "sandbox-a").unwrap();
save_last_sandbox("gateway-b", "default", "sandbox-b").unwrap();
assert_eq!(
load_last_sandbox("gateway-a"),
load_last_sandbox("gateway-a", "default"),
Some("sandbox-a".to_string())
);
assert_eq!(
load_last_sandbox("gateway-b"),
load_last_sandbox("gateway-b", "default"),
Some("sandbox-b".to_string())
);
});
}

#[test]
fn last_sandbox_is_workspace_scoped() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
save_last_sandbox("gw", "alpha", "sb-alpha").unwrap();
assert_eq!(
load_last_sandbox("gw", "alpha"),
Some("sb-alpha".to_string())
);
assert_eq!(load_last_sandbox("gw", "beta"), None);
});
}

// ── system gateway dir fallback ───────────────────────────────────

/// Helper: hold the shared XDG test lock, point `XDG_CONFIG_HOME` at
Expand Down Expand Up @@ -635,9 +667,12 @@ mod tests {
with_tmp_xdg_and_system(user.path(), system.path(), || {
write_system_metadata(&system.path().join("gateways"), "shared", "https://system");

save_last_sandbox("shared", "sb-123").unwrap();
save_last_sandbox("shared", "default", "sb-123").unwrap();

assert_eq!(load_last_sandbox("shared"), Some("sb-123".to_string()));
assert_eq!(
load_last_sandbox("shared", "default"),
Some("sb-123".to_string())
);
assert_eq!(
gateway_metadata_source("shared").unwrap(),
Some(GatewayMetadataSource::System)
Expand Down Expand Up @@ -671,12 +706,12 @@ mod tests {
.to_path_buf();
assert!(!user_gateway_dir.exists());

save_last_sandbox("shared", "sb-123").unwrap();
save_last_sandbox("shared", "default", "sb-123").unwrap();

assert!(user_gateway_dir.is_dir());
assert_eq!(
std::fs::read_to_string(user_gateway_dir.join("last_sandbox")).unwrap(),
"sb-123"
"default\nsb-123"
);
assert!(!user_gateway_dir.join("metadata.json").exists());
assert_eq!(
Expand All @@ -692,11 +727,11 @@ mod tests {
let system = tempfile::tempdir().unwrap();
with_tmp_xdg_and_system(user.path(), system.path(), || {
write_system_metadata(&system.path().join("gateways"), "shared", "https://system");
save_last_sandbox("shared", "sb-123").unwrap();
save_last_sandbox("shared", "default", "sb-123").unwrap();

clear_last_sandbox_if_matches("shared", "sb-123");
clear_last_sandbox_if_matches("shared", "default", "sb-123");

assert_eq!(load_last_sandbox("shared"), None);
assert_eq!(load_last_sandbox("shared", "default"), None);
let gateways = list_gateways_with_source().unwrap();
assert_eq!(gateways.len(), 1);
assert_eq!(gateways[0].metadata.name, "shared");
Expand Down Expand Up @@ -924,7 +959,7 @@ mod tests {
};
assert!(store_gateway_metadata("../escape", &meta).is_err());
assert!(load_gateway_metadata("../escape").is_err());
assert!(save_last_sandbox("../escape", "sb-123").is_err());
assert!(save_last_sandbox("../escape", "default", "sb-123").is_err());
assert!(save_active_gateway("../escape").is_err());
});
}
Expand Down
44 changes: 43 additions & 1 deletion crates/openshell-cli/src/completers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metad
use openshell_core::ObjectName;
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest};
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest};
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;

Expand Down Expand Up @@ -39,6 +39,8 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
limit: 200,
offset: 0,
label_selector: String::new(),
workspace: workspace_from_args(),
all_workspaces: false,
})
.await
.ok()?;
Expand All @@ -62,6 +64,8 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
.list_providers(ListProvidersRequest {
limit: 200,
offset: 0,
workspace: workspace_from_args(),
all_workspaces: false,
})
.await
.ok()?;
Expand All @@ -76,6 +80,44 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
})
}

/// Complete workspace names by querying the active gateway.
pub fn complete_workspace_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
blocking_complete(async {
let (endpoint, gateway_name) = resolve_active_gateway()?;
let mut client = completion_grpc_client(&endpoint, &gateway_name).await?;
let response = client
.list_workspaces(ListWorkspacesRequest {
limit: 200,
offset: 0,
label_selector: String::new(),
})
.await
.ok()?;
Some(
response
.into_inner()
.workspaces
.into_iter()
.map(|w| CompletionCandidate::new(w.object_name()))
.collect(),
)
})
}

fn workspace_from_args() -> String {
let args: Vec<String> = std::env::args().collect();
for (i, arg) in args.iter().enumerate() {
if arg == "--workspace" {
if let Some(val) = args.get(i + 1) {
return val.clone();
}
} else if let Some(val) = arg.strip_prefix("--workspace=") {
return val.to_string();
}
}
std::env::var("OPENSHELL_WORKSPACE").unwrap_or_else(|_| "default".to_string())
}

fn resolve_active_gateway() -> Option<(String, String)> {
let name = std::env::var("OPENSHELL_GATEWAY")
.ok()
Expand Down
Loading
Loading