diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a421b418ae..90a44defec 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -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; @@ -39,6 +39,9 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec { limit: 200, offset: 0, label_selector: String::new(), + workspace: std::env::var("OPENSHELL_WORKSPACE") + .unwrap_or_else(|_| "default".to_string()), + all_workspaces: false, }) .await .ok()?; @@ -62,6 +65,9 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { .list_providers(ListProvidersRequest { limit: 200, offset: 0, + workspace: std::env::var("OPENSHELL_WORKSPACE") + .unwrap_or_else(|_| "default".to_string()), + all_workspaces: false, }) .await .ok()?; @@ -76,6 +82,30 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { }) } +/// Complete workspace names by querying the active gateway. +pub fn complete_workspace_names(_prefix: &OsStr) -> Vec { + 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 resolve_active_gateway() -> Option<(String, String)> { let name = std::env::var("OPENSHELL_GATEWAY") .ok() diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index b32e7fe9f9..3bb6a231b4 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -360,6 +360,16 @@ const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell provider delete openai "; +const WORKSPACE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m + ws + +\x1b[1mEXAMPLES\x1b[0m + $ openshell workspace create --name staging + $ openshell workspace list + $ openshell workspace get staging + $ openshell workspace delete staging +"; + const GATEWAY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m gw @@ -422,6 +432,17 @@ struct Cli { )] gateway_insecure: bool, + /// Workspace scope for resource operations. + #[arg( + long, + global = true, + env = "OPENSHELL_WORKSPACE", + default_value = "default", + help_heading = "GLOBAL FLAGS", + add = ArgValueCompleter::new(completers::complete_workspace_names) + )] + workspace: String, + /// Increase verbosity (-v, -vv, -vvv). #[arg(short, long, action = clap::ArgAction::Count, global = true, help_heading = "GLOBAL FLAGS")] verbose: u8, @@ -521,6 +542,13 @@ enum Commands { command: Option, }, + /// Manage workspaces. + #[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Workspace { + #[command(subcommand)] + command: Option, + }, + // =================================================================== // GATEWAY COMMANDS // =================================================================== @@ -782,6 +810,11 @@ enum ProviderCommands { /// Provider config key/value pair. #[arg(long = "config", value_name = "KEY=VALUE")] config: Vec, + + /// Use a platform-scoped (global) provider profile instead of + /// a workspace-scoped one. + #[arg(long)] + global_profile: bool, }, /// Manage provider credential refresh. @@ -814,6 +847,10 @@ enum ProviderCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "names")] output: OutputFormat, + + /// List providers across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// List available provider profiles. @@ -952,6 +989,10 @@ enum ProviderProfileCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Yaml)] output: OutputFormat, + + /// Target platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Import provider profiles from a file or directory. @@ -964,6 +1005,10 @@ enum ProviderProfileCommands { /// Directory containing profile files to import. #[arg(long = "from", value_hint = ValueHint::DirPath)] from: Option, + + /// Import as platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, }, /// Update an existing custom provider profile from a file. @@ -975,6 +1020,10 @@ enum ProviderProfileCommands { /// Profile file to update. #[arg(short = 'f', long = "file", value_hint = ValueHint::FilePath)] file: PathBuf, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, /// Validate provider profile files without registering them. @@ -987,6 +1036,10 @@ enum ProviderProfileCommands { /// Directory containing profile files to lint. #[arg(long = "from", value_hint = ValueHint::DirPath)] from: Option, + + /// Lint against platform scope (ignores --workspace). + #[arg(long)] + global: bool, }, /// Delete a custom provider profile. @@ -994,6 +1047,10 @@ enum ProviderProfileCommands { Delete { /// Provider profile id. id: String, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, } @@ -1132,7 +1189,7 @@ enum GatewayCommands { #[derive(Subcommand, Debug)] enum InferenceCommands { - /// Set gateway-level inference provider and model. + /// Set workspace-level inference provider and model. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Set { /// Provider name. @@ -1158,7 +1215,7 @@ enum InferenceCommands { timeout: u64, }, - /// Update gateway-level inference configuration (partial update). + /// Update workspace-level inference configuration (partial update). #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Update { /// Provider name (unchanged if omitted). @@ -1182,7 +1239,7 @@ enum InferenceCommands { timeout: Option, }, - /// Get gateway-level inference provider and model. + /// Get workspace-level inference provider and model. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { /// Show the system inference route instead of the user-facing route. @@ -1385,6 +1442,10 @@ enum SandboxCommands { /// Output format. #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["ids", "names"])] output: OutputFormat, + + /// List sandboxes across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// Delete a sandbox by name. @@ -1916,6 +1977,10 @@ enum ServiceCommands { /// Number of endpoints to skip. #[arg(long, default_value_t = 0)] offset: u32, + + /// List services across all workspaces. + #[arg(long)] + all_workspaces: bool, }, /// Show one exposed sandbox service endpoint. @@ -1941,6 +2006,108 @@ enum ServiceCommands { }, } +#[derive(Subcommand, Debug)] +enum WorkspaceCommands { + /// Create a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Create { + /// Workspace name (DNS-1123 label: lowercase alphanumeric and hyphens, 1-63 chars). + #[arg(long)] + name: String, + + /// Labels to attach to the workspace (KEY=VALUE). + #[arg(long = "label", value_name = "KEY=VALUE")] + labels: Vec, + }, + + /// Fetch a workspace by name. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Workspace name. + #[arg(add = ArgValueCompleter::new(completers::complete_workspace_names))] + name: String, + }, + + /// List workspaces. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Maximum number of workspaces to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the workspace list. + #[arg(long, default_value_t = 0)] + offset: u32, + + /// Filter by label selector (e.g. "env=staging"). + #[arg(long)] + label_selector: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// Delete a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Workspace name(s) to delete. + #[arg(required = true, add = ArgValueCompleter::new(completers::complete_workspace_names))] + names: Vec, + }, + + /// Manage workspace members. + #[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Member(WorkspaceMemberCommands), +} + +#[derive(Subcommand, Debug)] +enum WorkspaceMemberCommands { + /// Add a member to a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Add { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// OIDC subject claim identifying the principal. + #[arg(long)] + subject: String, + + /// Role to assign (user or admin). + #[arg(long)] + role: String, + }, + + /// Remove a member from a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Remove { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// OIDC subject claim identifying the principal. + #[arg(long)] + subject: String, + }, + + /// List members of a workspace. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Workspace name. + #[arg(long, add = ArgValueCompleter::new(completers::complete_workspace_names))] + workspace: String, + + /// Maximum number of members to return. + #[arg(long, default_value_t = 100)] + limit: u32, + + /// Offset into the member list. + #[arg(long, default_value_t = 0)] + offset: u32, + }, +} + #[tokio::main] #[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level. async fn main() -> Result<()> { @@ -2203,6 +2370,7 @@ async fn main() -> Result<()> { &target_host, target_port, &tls, + &cli.workspace, ) .await?; } @@ -2216,7 +2384,15 @@ async fn main() -> Result<()> { let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_forward(&ctx.endpoint, &name, &spec, background, &tls).await?; + run::sandbox_forward( + &ctx.endpoint, + &name, + &spec, + background, + &tls, + &cli.workspace, + ) + .await?; if background { eprintln!( "{} Forwarding port {} to sandbox {name} in the background", @@ -2245,24 +2421,42 @@ async fn main() -> Result<()> { target_port, } => { let service = service.unwrap_or_default(); - run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls) - .await?; + run::service_expose( + &ctx.endpoint, + &sandbox, + &service, + target_port, + &cli.workspace, + &tls, + ) + .await?; } ServiceCommands::List { sandbox, limit, offset, + all_workspaces, } => { - run::service_list(&ctx.endpoint, sandbox.as_deref(), limit, offset, &tls) - .await?; + run::service_list( + &ctx.endpoint, + sandbox.as_deref(), + limit, + offset, + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; } ServiceCommands::Get { sandbox, service } => { let service = service.unwrap_or_default(); - run::service_get(&ctx.endpoint, &sandbox, &service, &tls).await?; + run::service_get(&ctx.endpoint, &sandbox, &service, &cli.workspace, &tls) + .await?; } ServiceCommands::Delete { sandbox, service } => { let service = service.unwrap_or_default(); - run::service_delete(&ctx.endpoint, &sandbox, &service, &tls).await?; + run::service_delete(&ctx.endpoint, &sandbox, &service, &cli.workspace, &tls) + .await?; } } } @@ -2289,6 +2483,7 @@ async fn main() -> Result<()> { since.as_deref(), &source, &level, + &cli.workspace, &tls, ) .await?; @@ -2347,13 +2542,22 @@ async fn main() -> Result<()> { yes, wait, timeout, + &cli.workspace, &tls, ) .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls) - .await?; + run::sandbox_policy_set( + &ctx.endpoint, + &name, + &policy, + wait, + timeout, + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Update { @@ -2383,6 +2587,7 @@ async fn main() -> Result<()> { dry_run, wait, timeout, + &cli.workspace, &tls, ) .await?; @@ -2402,6 +2607,7 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2413,6 +2619,7 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2424,10 +2631,12 @@ async fn main() -> Result<()> { global, } => { if global { - run::sandbox_policy_list_global(&ctx.endpoint, limit, &tls).await?; + run::sandbox_policy_list_global(&ctx.endpoint, limit, &cli.workspace, &tls) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; + run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -2436,7 +2645,8 @@ async fn main() -> Result<()> { "sandbox policy delete is not supported; use --global to remove global policy lock" )); } - run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &cli.workspace, &tls) + .await?; } PolicyCommands::Prove { .. } => unreachable!(), } @@ -2463,7 +2673,8 @@ async fn main() -> Result<()> { run::gateway_settings_get(&ctx.endpoint, json, &tls).await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_settings_get(&ctx.endpoint, &name, json, &tls).await?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &cli.workspace, &tls) + .await?; } } SettingsCommands::Set { @@ -2474,10 +2685,26 @@ async fn main() -> Result<()> { yes, } => { if global { - run::gateway_setting_set(&ctx.endpoint, &key, &value, yes, &tls).await?; + run::gateway_setting_set( + &ctx.endpoint, + &key, + &value, + yes, + &cli.workspace, + &tls, + ) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?; + run::sandbox_setting_set( + &ctx.endpoint, + &name, + &key, + &value, + &cli.workspace, + &tls, + ) + .await?; } } SettingsCommands::Delete { @@ -2487,10 +2714,18 @@ async fn main() -> Result<()> { yes, } => { if global { - run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, &key, yes, &cli.workspace, &tls) + .await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_setting_delete(&ctx.endpoint, &name, &key, &tls).await?; + run::sandbox_setting_delete( + &ctx.endpoint, + &name, + &key, + &cli.workspace, + &tls, + ) + .await?; } } } @@ -2508,11 +2743,25 @@ async fn main() -> Result<()> { match draft_cmd { DraftCommands::Get { name, status } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_get(&ctx.endpoint, &name, status.as_deref(), &tls).await?; + run::sandbox_draft_get( + &ctx.endpoint, + &name, + status.as_deref(), + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Approve { name, chunk_id } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_approve(&ctx.endpoint, &name, &chunk_id, &tls).await?; + run::sandbox_draft_approve( + &ctx.endpoint, + &name, + &chunk_id, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Reject { name, @@ -2520,8 +2769,15 @@ async fn main() -> Result<()> { reason, } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_reject(&ctx.endpoint, &name, &chunk_id, &reason, &tls) - .await?; + run::sandbox_draft_reject( + &ctx.endpoint, + &name, + &chunk_id, + &reason, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::ApproveAll { name, @@ -2532,6 +2788,7 @@ async fn main() -> Result<()> { &ctx.endpoint, &name, include_security_flagged, + &cli.workspace, &tls, ) .await?; @@ -2539,11 +2796,11 @@ async fn main() -> Result<()> { DraftCommands::Clear { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_clear(&ctx.endpoint, &name, &tls).await?; + run::sandbox_draft_clear(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } DraftCommands::History { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_history(&ctx.endpoint, &name, &tls).await?; + run::sandbox_draft_history(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } } } @@ -2568,7 +2825,14 @@ async fn main() -> Result<()> { } => { let route_name = if system { "sandbox-system" } else { "" }; run::gateway_inference_set( - endpoint, &provider, &model, route_name, no_verify, timeout, &tls, + endpoint, + &provider, + &model, + route_name, + no_verify, + timeout, + &cli.workspace, + &tls, ) .await?; } @@ -2587,13 +2851,14 @@ async fn main() -> Result<()> { route_name, no_verify, timeout, + &cli.workspace, &tls, ) .await?; } InferenceCommands::Get { system } => { let route_name = if system { Some("sandbox-system") } else { None }; - run::gateway_inference_get(endpoint, route_name, &tls).await?; + run::gateway_inference_get(endpoint, route_name, &cli.workspace, &tls).await?; } } } @@ -2714,6 +2979,7 @@ async fn main() -> Result<()> { environment: env_map, approval_mode: &approval_mode, }, + &cli.workspace, &tls, )) .await?; @@ -2747,6 +3013,7 @@ async fn main() -> Result<()> { local, sandbox_dest, &tls, + &cli.workspace, ) .await?; eprintln!("{} Upload complete", "✓".green().bold()); @@ -2758,7 +3025,15 @@ async fn main() -> Result<()> { local.display(), ); } - run::sandbox_sync_up(&ctx.endpoint, &name, local, sandbox_dest, &tls).await?; + run::sandbox_sync_up( + &ctx.endpoint, + &name, + local, + sandbox_dest, + &tls, + &cli.workspace, + ) + .await?; eprintln!("{} Upload complete", "✓".green().bold()); } SandboxCommands::Download { @@ -2771,8 +3046,15 @@ async fn main() -> Result<()> { apply_auth(&mut tls, &ctx.name); let local_dest = dest.as_deref().unwrap_or("."); eprintln!("Downloading sandbox:{sandbox_path} -> {local_dest}"); - run::sandbox_sync_down(&ctx.endpoint, &name, &sandbox_path, local_dest, &tls) - .await?; + run::sandbox_sync_down( + &ctx.endpoint, + &name, + &sandbox_path, + local_dest, + &tls, + &cli.workspace, + ) + .await?; eprintln!("{} Download complete", "✓".green().bold()); } other => { @@ -2788,7 +3070,8 @@ async fn main() -> Result<()> { } SandboxCommands::Get { name, policy_only } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_get(endpoint, &name, policy_only, &tls).await?; + run::sandbox_get(endpoint, &name, policy_only, &cli.workspace, &tls) + .await?; } SandboxCommands::List { limit, @@ -2797,6 +3080,7 @@ async fn main() -> Result<()> { names, selector, output, + all_workspaces, } => { run::sandbox_list( endpoint, @@ -2806,22 +3090,37 @@ async fn main() -> Result<()> { names, selector.as_deref(), output.as_str(), + &cli.workspace, + all_workspaces, &tls, ) .await?; } SandboxCommands::Delete { names, all } => { - run::sandbox_delete(endpoint, &names, all, &tls, &ctx.name).await?; + run::sandbox_delete( + endpoint, + &names, + all, + &cli.workspace, + &tls, + &ctx.name, + ) + .await?; } SandboxCommands::Connect { name, editor } => { let name = resolve_sandbox_name(name, &ctx.name)?; if let Some(editor) = editor.map(Into::into) { run::sandbox_connect_editor( - endpoint, &ctx.name, &name, editor, &tls, + endpoint, + &ctx.name, + &name, + editor, + &tls, + &cli.workspace, ) .await?; } else { - run::sandbox_connect(endpoint, &name, &tls).await?; + run::sandbox_connect(endpoint, &name, &tls, &cli.workspace).await?; } let _ = save_last_sandbox(&ctx.name, &name); } @@ -2853,6 +3152,7 @@ async fn main() -> Result<()> { tty_override, &env_map, &tls, + &cli.workspace, ) .await?; let _ = save_last_sandbox(&ctx.name, &name); @@ -2862,26 +3162,101 @@ async fn main() -> Result<()> { } SandboxCommands::SshConfig { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::print_ssh_config(&ctx.name, &name); + run::print_ssh_config(&ctx.name, &name, &cli.workspace); } SandboxCommands::Provider(command) => match command { SandboxProviderCommands::List { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_provider_list(endpoint, &name, &tls).await?; + run::sandbox_provider_list(endpoint, &name, &cli.workspace, &tls) + .await?; } SandboxProviderCommands::Attach { name, provider } => { - run::sandbox_provider_attach(endpoint, &name, &provider, &tls) - .await?; + run::sandbox_provider_attach( + endpoint, + &name, + &provider, + &cli.workspace, + &tls, + ) + .await?; } SandboxProviderCommands::Detach { name, provider } => { - run::sandbox_provider_detach(endpoint, &name, &provider, &tls) - .await?; + run::sandbox_provider_detach( + endpoint, + &name, + &provider, + &cli.workspace, + &tls, + ) + .await?; } }, } } } } + + // ----------------------------------------------------------- + // Workspace commands + // ----------------------------------------------------------- + Some(Commands::Workspace { + command: Some(command), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let endpoint = &ctx.endpoint; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name); + + match command { + WorkspaceCommands::Create { name, labels } => { + run::workspace_create(endpoint, &name, &labels, &tls).await?; + } + WorkspaceCommands::Get { name } => { + run::workspace_get(endpoint, &name, &tls).await?; + } + WorkspaceCommands::List { + limit, + offset, + label_selector, + output, + } => { + run::workspace_list( + endpoint, + limit, + offset, + label_selector.as_deref().unwrap_or(""), + output.as_str(), + &tls, + ) + .await?; + } + WorkspaceCommands::Delete { names } => { + run::workspace_delete(endpoint, &names, &tls).await?; + } + WorkspaceCommands::Member(command) => match command { + WorkspaceMemberCommands::Add { + workspace, + subject, + role, + } => { + run::workspace_member_add(endpoint, &workspace, &subject, &role, &tls) + .await?; + } + WorkspaceMemberCommands::Remove { workspace, subject } => { + run::workspace_member_remove(endpoint, &workspace, &subject, &tls).await?; + } + WorkspaceMemberCommands::List { + workspace, + limit, + offset, + } => { + run::workspace_member_list(endpoint, &workspace, limit, offset, &tls) + .await?; + } + }, + } + } + Some(Commands::Provider { command: Some(command), }) => { @@ -2899,7 +3274,9 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, config, + global_profile, } => { + let profile_ws = if global_profile { "" } else { &cli.workspace }; run::provider_create_with_options( endpoint, &name, @@ -2909,6 +3286,8 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, &config, + &cli.workspace, + profile_ws, &tls, ) .await?; @@ -2922,6 +3301,7 @@ async fn main() -> Result<()> { endpoint, &name, credential_key.as_deref(), + &cli.workspace, &tls, ) .await?; @@ -2946,6 +3326,7 @@ async fn main() -> Result<()> { secret_material_keys: &secret_material_keys, credential_expires_at_ms: credential_expires_at, }, + &cli.workspace, &tls, ) .await?; @@ -2954,60 +3335,110 @@ async fn main() -> Result<()> { name, credential_key, } => { - run::provider_rotate(endpoint, &name, &credential_key, &tls).await?; + run::provider_rotate( + endpoint, + &name, + &credential_key, + &cli.workspace, + &tls, + ) + .await?; } ProviderRefreshCommands::Delete { name, credential_key, } => { - run::provider_refresh_delete(endpoint, &name, &credential_key, &tls) - .await?; + run::provider_refresh_delete( + endpoint, + &name, + &credential_key, + &cli.workspace, + &tls, + ) + .await?; } }, ProviderCommands::Get { name } => { - run::provider_get(endpoint, &name, &tls).await?; + run::provider_get(endpoint, &name, &cli.workspace, &tls).await?; } ProviderCommands::List { limit, offset, names, output, + all_workspaces, } => { - run::provider_list(endpoint, limit, offset, names, output.as_str(), &tls) - .await?; + run::provider_list( + endpoint, + limit, + offset, + names, + output.as_str(), + &cli.workspace, + all_workspaces, + &tls, + ) + .await?; } ProviderCommands::ListProfiles { output } => { - run::provider_list_profiles(endpoint, output.as_str(), &tls).await?; - } - ProviderCommands::Profile(command) => match command { - ProviderProfileCommands::Export { id, output } => { - run::provider_profile_export(endpoint, &id, output.as_str(), &tls).await?; - } - ProviderProfileCommands::Import { file, from } => { - run::provider_profile_import( - endpoint, - file.as_deref(), - from.as_deref(), - &tls, - ) - .await?; - } - ProviderProfileCommands::Update { id, file } => { - run::provider_profile_update(endpoint, &id, &file, &tls).await?; - } - ProviderProfileCommands::Lint { file, from } => { - run::provider_profile_lint( - endpoint, - file.as_deref(), - from.as_deref(), - &tls, - ) + run::provider_list_profiles(endpoint, output.as_str(), &cli.workspace, &tls) .await?; + } + ProviderCommands::Profile(command) => { + let profile_workspace = + |global: bool| -> &str { if global { "" } else { &cli.workspace } }; + match command { + ProviderProfileCommands::Export { id, output, global } => { + run::provider_profile_export( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Import { file, from, global } => { + run::provider_profile_import( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Update { id, file, global } => { + run::provider_profile_update( + endpoint, + &id, + &file, + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Lint { file, from, global } => { + run::provider_profile_lint( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + &tls, + ) + .await?; + } + ProviderProfileCommands::Delete { id, global } => { + run::provider_profile_delete( + endpoint, + &id, + profile_workspace(global), + &tls, + ) + .await?; + } } - ProviderProfileCommands::Delete { id } => { - run::provider_profile_delete(endpoint, &id, &tls).await?; - } - }, + } ProviderCommands::Update { name, from_existing, @@ -3022,12 +3453,13 @@ async fn main() -> Result<()> { &credentials, &config, &credential_expires_at, + &cli.workspace, &tls, ) .await?; } ProviderCommands::Delete { names } => { - run::provider_delete(endpoint, &names, &tls).await?; + run::provider_delete(endpoint, &names, &cli.workspace, &tls).await?; } } } @@ -3090,11 +3522,11 @@ async fn main() -> Result<()> { }; let mut tls = tls.with_gateway_name(&g); apply_auth(&mut tls, &g); - run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; + run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls, &cli.workspace).await?; } // Legacy name mode with --server only (no --gateway-name). (_, _, _, Some(srv), None, Some(n)) => { - run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?; + run::sandbox_ssh_proxy_by_name(&srv, &n, &tls, &cli.workspace).await?; } _ => { return Err(miette::miette!( @@ -3140,6 +3572,13 @@ async fn main() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Workspace { command: None }) => { + Cli::command() + .find_subcommand_mut("workspace") + .expect("workspace subcommand exists") + .print_help() + .expect("Failed to print help"); + } Some(Commands::Provider { command: None }) => { Cli::command() .find_subcommand_mut("provider") @@ -3850,7 +4289,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { id, - output: OutputFormat::Yaml + output: OutputFormat::Yaml, + .. })) }) if id == "custom-api" )); @@ -3889,7 +4329,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { id, - file: _ + file: _, + .. })) }) if id == "custom-api" )); @@ -3901,7 +4342,8 @@ mod tests { delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id + id, + .. })) }) if id == "custom-api" )); @@ -4853,6 +5295,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox.as_deref(), Some("my-sandbox")); @@ -4872,6 +5315,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox, None); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 924edf6f3c..ea8523d8de 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -39,25 +39,24 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetGatewayInfoRequest, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, - GpuResourceRequirements, HealthRequest, ImportProviderProfilesRequest, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, - ListServicesRequest, PlatformEvent, PolicySource, PolicyStatus, Provider, - ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderProfile, - ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, - ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, - SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, - ServiceStatus, SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, - TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, - UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, - tcp_forward_init, + GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetInferenceRouteRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, + GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, + HealthRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest, + ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, + ListSandboxProvidersRequest, ListSandboxesRequest, ListServicesRequest, PlatformEvent, + PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, + ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, + ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, + RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, + SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, + SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, + UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, + setting_value, tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; -use openshell_core::{ObjectId, ObjectName}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use openshell_providers::{ ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, @@ -1916,6 +1915,7 @@ async fn finalize_sandbox_create_session( sandbox_name: &str, persist: bool, session_result: Result<()>, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -1924,7 +1924,7 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, tls, gateway).await { + if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { if session_result.is_ok() { return Err(err); } @@ -1991,6 +1991,7 @@ pub async fn sandbox_create( server: &str, gateway_name: &str, config: SandboxCreateConfig<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let SandboxCreateConfig { @@ -2065,6 +2066,7 @@ pub async fn sandbox_create( providers, &inferred_types, auto_providers_override, + workspace, ) .await?; @@ -2098,7 +2100,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, - annotations: HashMap::new(), + workspace: workspace.to_string(), }; let response = match client.create_sandbox(request).await { @@ -2143,7 +2145,11 @@ pub async fn sandbox_create( name: sandbox_name.clone(), setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), setting_value: Some(setting), - ..Default::default() + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await { @@ -2392,6 +2398,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2407,6 +2414,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2417,6 +2425,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2434,6 +2443,7 @@ pub async fn sandbox_create( spec, true, // background &effective_tls, + workspace, ) .await?; eprintln!( @@ -2456,6 +2466,7 @@ pub async fn sandbox_create( &sandbox_name, editor, &effective_tls, + workspace, ) .await?; return Ok(()); @@ -2463,12 +2474,14 @@ pub async fn sandbox_create( if command.is_empty() { let connect_result = if persist { - sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await + sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace) + .await } else { crate::ssh::sandbox_connect_without_exec( &effective_server, &sandbox_name, &effective_tls, + workspace, ) .await }; @@ -2478,6 +2491,7 @@ pub async fn sandbox_create( &sandbox_name, persist, connect_result, + workspace, &effective_tls, gateway_name, ) @@ -2496,6 +2510,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await } else { @@ -2505,6 +2520,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await }; @@ -2514,6 +2530,7 @@ pub async fn sandbox_create( &sandbox_name, persist, exec_result, + workspace, &effective_tls, gateway_name, ) @@ -2739,6 +2756,7 @@ pub async fn sandbox_sync_command( down: Option<&str>, dest: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { match (up, down) { (Some(local_path), None) => { @@ -2751,13 +2769,13 @@ pub async fn sandbox_sync_command( } let dest_display = dest.unwrap_or("~"); eprintln!("Syncing {} -> sandbox:{}", local.display(), dest_display); - sandbox_sync_up(server, name, local, dest, tls).await?; + sandbox_sync_up(server, name, local, dest, tls, workspace).await?; eprintln!("{} Sync complete", "✓".green().bold()); } (None, Some(sandbox_path)) => { let local_dest = dest.unwrap_or("."); eprintln!("Syncing sandbox:{sandbox_path} -> {local_dest}"); - sandbox_sync_down(server, name, sandbox_path, local_dest, tls).await?; + sandbox_sync_down(server, name, sandbox_path, local_dest, tls, workspace).await?; eprintln!("{} Sync complete", "✓".green().bold()); } _ => { @@ -2778,6 +2796,7 @@ pub async fn sandbox_get( server: &str, name: &str, policy_only: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -2785,6 +2804,7 @@ pub async fn sandbox_get( let response = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -2913,6 +2933,7 @@ pub async fn sandbox_exec_grpc( tty_override: Option, environment: &HashMap, tls: &TlsOptions, + workspace: &str, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -2920,6 +2941,7 @@ pub async fn sandbox_exec_grpc( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3028,11 +3050,12 @@ pub async fn service_forward_tcp( target_host: &str, target_port: u16, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?; let mut client = grpc_client(server, tls).await?; - let sandbox = fetch_ready_sandbox_for_forward(&mut client, name).await?; + let sandbox = fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port)) .await @@ -3062,7 +3085,7 @@ pub async fn service_forward_tcp( } _ = health_check.tick() => { - fetch_ready_sandbox_for_forward(&mut client, name).await?; + fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; } accepted = listener.accept() => { @@ -3126,10 +3149,12 @@ async fn create_forward_session_token( async fn fetch_ready_sandbox_for_forward( client: &mut crate::tls::GrpcClient, name: &str, + workspace: &str, ) -> Result { let response = match client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -3523,6 +3548,8 @@ pub async fn sandbox_list( names_only: bool, label_selector: Option<&str>, output: &str, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3532,6 +3559,12 @@ pub async fn sandbox_list( limit, offset, label_selector: label_selector.unwrap_or("").to_string(), + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, }) .await .into_diagnostic()?; @@ -3571,14 +3604,34 @@ pub async fn sandbox_list( .unwrap_or(4) .max(4); let created_width = 19; // "YYYY-MM-DD HH:MM:SS" + let ws_width = if all_workspaces { + sandboxes + .iter() + .map(|s| s.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; // Print header - println!( - "{: phase.to_string(), }; let created = format_epoch_ms(sandbox.metadata.as_ref().map_or(0, |m| m.created_at_ms)); - println!( - "{: serde_json::Value { }) } -pub async fn sandbox_provider_list(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_provider_list( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .list_sandbox_providers(ListSandboxProvidersRequest { sandbox_name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -3644,6 +3713,7 @@ pub async fn sandbox_provider_attach( server: &str, name: &str, provider: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3652,6 +3722,7 @@ pub async fn sandbox_provider_attach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3666,6 +3737,7 @@ pub async fn sandbox_provider_attach( sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, + workspace: workspace.to_string(), }) .await { @@ -3697,6 +3769,7 @@ pub async fn sandbox_provider_detach( server: &str, name: &str, provider: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3705,6 +3778,7 @@ pub async fn sandbox_provider_detach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3719,6 +3793,7 @@ pub async fn sandbox_provider_detach( sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, + workspace: workspace.to_string(), }) .await { @@ -3811,6 +3886,7 @@ pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -3823,6 +3899,8 @@ pub async fn sandbox_delete( limit: 1000, offset: 0, label_selector: String::new(), + workspace: workspace.to_string(), + all_workspaces: false, }) .await .into_diagnostic()?; @@ -3851,7 +3929,10 @@ pub async fn sandbox_delete( } let response = client - .delete_sandbox(DeleteSandboxRequest { name: name.clone() }) + .delete_sandbox(DeleteSandboxRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; @@ -3888,6 +3969,7 @@ pub async fn ensure_required_providers( explicit_names: &[String], inferred_types: &[String], auto_providers_override: Option, + workspace: &str, ) -> Result> { if explicit_names.is_empty() && inferred_types.is_empty() { return Ok(Vec::new()); @@ -3906,7 +3988,12 @@ pub async fn ensure_required_providers( let limit = 100_u32; loop { let response = client - .list_providers(ListProvidersRequest { limit, offset }) + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: workspace.to_string(), + all_workspaces: false, + }) .await .into_diagnostic()?; let providers = response.into_inner().providers; @@ -3943,6 +4030,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; // Record the type mapping so the inferred-types pass below @@ -3983,6 +4071,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; } @@ -4004,6 +4093,7 @@ async fn auto_create_provider( auto_providers_override: Option, seen_names: &mut HashSet, configured_names: &mut Vec, + workspace: &str, ) -> Result<()> { eprintln!("Missing provider: {provider_type}"); @@ -4043,7 +4133,7 @@ async fn auto_create_provider( return Ok(()); } - let discovered = discover_existing_provider_data(client, provider_type) + let discovered = discover_existing_provider_data(client, provider_type, workspace) .await .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; let Some(discovered) = discovered else { @@ -4066,13 +4156,15 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), }), + workspace: workspace.to_string(), }; let response = client.create_provider(request).await.map_err(|status| { @@ -4109,13 +4201,15 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), }), + workspace: workspace.to_string(), }; match client.create_provider(request).await { @@ -4351,6 +4445,7 @@ pub async fn service_expose( sandbox: &str, service: &str, target_port: u16, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4360,6 +4455,7 @@ pub async fn service_expose( service: service.to_string(), target_port: u32::from(target_port), domain: true, + workspace: workspace.to_string(), }) .await .map_err(service_expose_status_error)? @@ -4397,6 +4493,8 @@ pub async fn service_list( sandbox: Option<&str>, limit: u32, offset: u32, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4405,6 +4503,12 @@ pub async fn service_list( sandbox: sandbox.unwrap_or_default().to_string(), limit, offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, }) .await .map_err(|status| service_status_error("list services", "sandbox:read", status))? @@ -4419,7 +4523,7 @@ pub async fn service_list( return Ok(()); } - print_service_endpoint_table(&response.services, server); + print_service_endpoint_table(&response.services, server, all_workspaces); Ok(()) } @@ -4427,6 +4531,7 @@ pub async fn service_get( server: &str, sandbox: &str, service: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4434,12 +4539,13 @@ pub async fn service_get( .get_service(GetServiceRequest { sandbox: sandbox.to_string(), service: service.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("get service", "sandbox:read", status))? .into_inner(); - print_service_endpoint_table(&[response], server); + print_service_endpoint_table(&[response], server, false); Ok(()) } @@ -4447,6 +4553,7 @@ pub async fn service_delete( server: &str, sandbox: &str, service: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -4454,6 +4561,7 @@ pub async fn service_delete( .delete_service(DeleteServiceRequest { sandbox: sandbox.to_string(), service: service.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("delete service", "sandbox:write", status))? @@ -4500,11 +4608,19 @@ fn service_status_error(action: &str, required_scope: &str, status: Status) -> m } } -fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_endpoint: &str) { +fn print_service_endpoint_table( + services: &[ServiceEndpointResponse], + gateway_endpoint: &str, + all_workspaces: bool, +) { let rows = services .iter() .filter_map(|response| { let endpoint = response.endpoint.as_ref()?; + let workspace = endpoint + .metadata + .as_ref() + .map_or("", |m| m.workspace.as_str()); let service = service_display_name(&endpoint.service_name).to_string(); let target = format!("127.0.0.1:{}", endpoint.target_port); let url = if response.url.is_empty() { @@ -4512,7 +4628,13 @@ fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_en } else { service_url_for_gateway(&response.url, gateway_endpoint) }; - Some((endpoint.sandbox_name.clone(), service, target, url)) + Some(( + workspace.to_string(), + endpoint.sandbox_name.clone(), + service, + target, + url, + )) }) .collect::>(); @@ -4520,37 +4642,63 @@ fn print_service_endpoint_table(services: &[ServiceEndpointResponse], gateway_en return; } + let ws_width = if all_workspaces { + rows.iter() + .map(|(ws, _, _, _, _)| ws.len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; let sandbox_width = rows .iter() - .map(|(sandbox, _, _, _)| sandbox.len()) + .map(|(_, sandbox, _, _, _)| sandbox.len()) .max() .unwrap_or(7) .max(7); let service_width = rows .iter() - .map(|(_, service, _, _)| service.len()) + .map(|(_, _, service, _, _)| service.len()) .max() .unwrap_or(7) .max(7); let target_width = rows .iter() - .map(|(_, _, target, _)| target.len()) + .map(|(_, _, _, target, _)| target.len()) .max() .unwrap_or(6) .max(6); - println!( - "{: Result<()> { match client .delete_provider(DeleteProviderRequest { name: provider_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -4723,10 +4873,12 @@ async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Re async fn fetch_provider_profile( client: &mut crate::tls::GrpcClient, provider_type: &str, + workspace: &str, ) -> Result { let response = client .get_provider_profile(GetProviderProfileRequest { id: provider_type.to_string(), + workspace: workspace.to_string(), }) .await .map_err(|status| { @@ -4748,9 +4900,10 @@ async fn fetch_provider_profile( async fn discover_existing_provider_data( client: &mut crate::tls::GrpcClient, provider_type: &str, + workspace: &str, ) -> Result> { if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type).await?; + let profile = fetch_provider_profile(client, provider_type, workspace).await?; let profile = ProviderTypeProfile::from_proto(&profile); let mut discovered = discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { @@ -4821,6 +4974,7 @@ pub async fn provider_create( credentials: &[String], from_gcloud_adc: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { provider_create_with_options( @@ -4832,6 +4986,8 @@ pub async fn provider_create( from_gcloud_adc, false, config, + workspace, + workspace, tls, ) .await @@ -4847,6 +5003,8 @@ pub async fn provider_create_with_options( from_gcloud_adc: bool, runtime_credentials: bool, config: &[String], + workspace: &str, + profile_workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { @@ -4877,6 +5035,7 @@ pub async fn provider_create_with_options( let response = client .get_provider_profile(GetProviderProfileRequest { id: profile_id.to_string(), + workspace: workspace.to_string(), }) .await; match response { @@ -4929,7 +5088,8 @@ pub async fn provider_create_with_options( let mut config_map = parse_key_value_pairs(config, "--config")?; if from_existing { - let discovered = discover_existing_provider_data(&mut client, &provider_type).await?; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -4953,10 +5113,10 @@ pub async fn provider_create_with_options( } let allows_empty_credentials = if runtime_credentials { provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type).await?, + &fetch_provider_profile(&mut client, &provider_type, workspace).await?, ) } else { - fetch_provider_profile(&mut client, &provider_type) + fetch_provider_profile(&mut client, &provider_type, workspace) .await .ok() .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) @@ -4991,13 +5151,15 @@ pub async fn provider_create_with_options( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: provider_type.clone(), credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), + profile_workspace: profile_workspace.to_string(), }), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5027,6 +5189,7 @@ pub async fn provider_create_with_options( "refresh_token".to_string(), ], expires_at_ms: None, + workspace: workspace.to_string(), }) .await { @@ -5035,6 +5198,7 @@ pub async fn provider_create_with_options( &provider_name, "configure", &configure_err, + workspace, ) .await; } @@ -5043,6 +5207,7 @@ pub async fn provider_create_with_options( .rotate_provider_credential(RotateProviderCredentialRequest { provider: provider_name.clone(), credential_key: adc_credential_key, + workspace: workspace.to_string(), }) .await { @@ -5051,6 +5216,7 @@ pub async fn provider_create_with_options( &provider_name, "mint the initial access token for", &rotate_err, + workspace, ) .await; } @@ -5068,11 +5234,17 @@ fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() } -pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn provider_get( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5171,17 +5343,29 @@ fn provider_to_json(provider: &Provider) -> serde_json::Value { serde_json::Value::Object(obj) } +#[allow(clippy::too_many_arguments)] pub async fn provider_list( server: &str, limit: u32, offset: u32, names_only: bool, output: &str, + workspace: &str, + all_workspaces: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .list_providers(ListProvidersRequest { limit, offset }) + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + }) .await .into_diagnostic()?; let providers = response.into_inner().providers; @@ -5205,6 +5389,16 @@ pub async fn provider_list( return Ok(()); } + let ws_width = if all_workspaces { + providers + .iter() + .map(|p| p.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; let name_width = providers .iter() .map(|provider| provider.object_name().len()) @@ -5218,33 +5412,61 @@ pub async fn provider_list( .unwrap_or(4) .max(4); - println!( - "{: Result<()> { +pub async fn provider_list_profiles( + server: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .list_provider_profiles(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5292,9 +5514,10 @@ pub async fn provider_profile_export( server: &str, id: &str, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { - let rendered = provider_profile_export_text(server, id, output, tls).await?; + let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; if output == "json" { println!("{rendered}"); } else { @@ -5307,11 +5530,15 @@ pub async fn provider_profile_export_text( server: &str, id: &str, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result { let mut client = grpc_client(server, tls).await?; let response = client - .get_provider_profile(GetProviderProfileRequest { id: id.to_string() }) + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; let profile = response @@ -5334,6 +5561,7 @@ pub async fn provider_profile_import( server: &str, file: Option<&Path>, from: Option<&Path>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (items, mut diagnostics) = load_profile_import_items(file, from)?; @@ -5348,7 +5576,10 @@ pub async fn provider_profile_import( let mut client = grpc_client(server, tls).await?; if !items.is_empty() { let response = client - .import_provider_profiles(ImportProviderProfilesRequest { profiles: items }) + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5375,6 +5606,7 @@ pub async fn provider_profile_update( server: &str, id: &str, file: &Path, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; @@ -5397,6 +5629,7 @@ pub async fn provider_profile_update( profile: Some(item), expected_resource_version, id: id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5416,6 +5649,7 @@ pub async fn provider_profile_lint( server: &str, file: Option<&Path>, from: Option<&Path>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let (items, mut diagnostics) = load_profile_import_items(file, from)?; @@ -5426,7 +5660,10 @@ pub async fn provider_profile_lint( if !items.is_empty() { let mut client = grpc_client(server, tls).await?; let response = client - .lint_provider_profiles(LintProviderProfilesRequest { profiles: items }) + .lint_provider_profiles(LintProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5442,10 +5679,18 @@ pub async fn provider_profile_lint( Ok(()) } -pub async fn provider_profile_delete(server: &str, id: &str, tls: &TlsOptions) -> Result<()> { +pub async fn provider_profile_delete( + server: &str, + id: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .delete_provider_profile(DeleteProviderProfileRequest { id: id.to_string() }) + .delete_provider_profile(DeleteProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()? .into_inner(); @@ -5461,6 +5706,7 @@ pub async fn provider_refresh_status( server: &str, name: &str, credential_key: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5468,6 +5714,7 @@ pub async fn provider_refresh_status( .get_provider_refresh_status(GetProviderRefreshStatusRequest { provider: name.to_string(), credential_key: credential_key.unwrap_or_default().to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5518,6 +5765,7 @@ pub struct ProviderRefreshConfigInput<'a> { pub async fn provider_refresh_config( server: &str, input: ProviderRefreshConfigInput<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let strategy = provider_refresh_strategy(input.strategy)?; @@ -5545,6 +5793,7 @@ pub async fn provider_refresh_config( material, secret_material_keys, expires_at_ms: input.credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5565,6 +5814,7 @@ pub async fn provider_rotate( server: &str, name: &str, credential_key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5572,6 +5822,7 @@ pub async fn provider_rotate( .rotate_provider_credential(RotateProviderCredentialRequest { provider: name.to_string(), credential_key: credential_key.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5600,6 +5851,7 @@ pub async fn provider_refresh_delete( server: &str, name: &str, credential_key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -5607,6 +5859,7 @@ pub async fn provider_refresh_delete( .delete_provider_refresh(DeleteProviderRefreshRequest { provider: name.to_string(), credential_key: credential_key.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5880,6 +6133,7 @@ fn truncate_display(value: &str, max_width: usize) -> String { truncated } +#[allow(clippy::too_many_arguments)] pub async fn provider_update( server: &str, name: &str, @@ -5887,6 +6141,7 @@ pub async fn provider_update( credentials: &[String], config: &[String], credential_expires_at: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if from_existing && !credentials.is_empty() { @@ -5906,6 +6161,7 @@ pub async fn provider_update( let existing = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5914,7 +6170,8 @@ pub async fn provider_update( .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; let provider_type = existing.r#type; - let discovered = discover_existing_provider_data(&mut client, &provider_type).await?; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; let Some(discovered) = discovered else { return Err(miette::miette!( "no existing local credentials/config found for provider type '{provider_type}'" @@ -5938,14 +6195,16 @@ pub async fn provider_update( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.to_string(), }), r#type: String::new(), credentials: credential_map, config: config_map, credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), }), credential_expires_at_ms, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5963,11 +6222,19 @@ pub async fn provider_update( Ok(()) } -pub async fn provider_delete(server: &str, names: &[String], tls: &TlsOptions) -> Result<()> { +pub async fn provider_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; for name in names { let response = client - .delete_provider(DeleteProviderRequest { name: name.clone() }) + .delete_provider(DeleteProviderRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) .await .into_diagnostic()?; if response.into_inner().deleted { @@ -5979,6 +6246,334 @@ pub async fn provider_delete(server: &str, names: &[String], tls: &TlsOptions) - Ok(()) } +// --------------------------------------------------------------------------- +// Workspace commands +// --------------------------------------------------------------------------- + +pub async fn workspace_create( + server: &str, + name: &str, + label_args: &[String], + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::CreateWorkspaceRequest; + + let labels = label_args + .iter() + .filter_map(|arg| { + let (k, v) = arg.split_once('=')?; + Some((k.to_string(), v.to_string())) + }) + .collect::>(); + + let mut client = grpc_client(server, tls).await?; + let response = client + .create_workspace(CreateWorkspaceRequest { + name: name.to_string(), + labels, + }) + .await + .into_diagnostic()?; + + let workspace = response + .into_inner() + .workspace + .ok_or_else(|| miette!("workspace missing from response"))?; + + println!( + "{} Created workspace {}", + "✓".green().bold(), + workspace.object_name().bold() + ); + + Ok(()) +} + +pub async fn workspace_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { + use openshell_core::proto::GetWorkspaceRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .get_workspace(GetWorkspaceRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()?; + + let workspace = response + .into_inner() + .workspace + .ok_or_else(|| miette!("workspace missing from response"))?; + + println!("{}", "Workspace:".cyan().bold()); + println!(); + println!(" {} {}", "Name:".dimmed(), workspace.object_name()); + if let Some(meta) = &workspace.metadata { + println!(" {} {}", "Id:".dimmed(), meta.id); + println!( + " {} {}", + "Resource version:".dimmed(), + meta.resource_version + ); + if meta.created_at_ms != 0 { + println!( + " {} {}", + "Created:".dimmed(), + format_epoch_ms(meta.created_at_ms) + ); + } + if !meta.labels.is_empty() { + println!( + " {} {}", + "Labels:".dimmed(), + meta.labels + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + ); + } + } + + Ok(()) +} + +pub async fn workspace_list( + server: &str, + limit: u32, + offset: u32, + label_selector: &str, + output: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::ListWorkspacesRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .list_workspaces(ListWorkspacesRequest { + limit, + offset, + label_selector: label_selector.to_string(), + }) + .await + .into_diagnostic()?; + let workspaces = response.into_inner().workspaces; + + if crate::output::print_output_collection(output, &workspaces, workspace_to_json)? { + return Ok(()); + } + + if workspaces.is_empty() { + println!("No workspaces found."); + return Ok(()); + } + + let name_width = workspaces + .iter() + .map(|w| w.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + + println!( + "{:>() + .join(", ") + }); + println!( + "{: Result<()> { + use openshell_core::proto::DeleteWorkspaceRequest; + + let mut client = grpc_client(server, tls).await?; + for name in names { + let response = client + .delete_workspace(DeleteWorkspaceRequest { name: name.clone() }) + .await + .into_diagnostic()?; + if response.into_inner().deleted { + println!("{} Deleted workspace {name}", "✓".green().bold()); + } else { + println!("{} Workspace {name} not found", "!".yellow()); + } + } + Ok(()) +} + +pub async fn workspace_member_add( + server: &str, + workspace: &str, + subject: &str, + role: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::{AddWorkspaceMemberRequest, WorkspaceRole}; + + let role_val = match role.to_lowercase().as_str() { + "user" => WorkspaceRole::User, + "admin" => WorkspaceRole::Admin, + _ => { + return Err(miette!( + "invalid role '{}': must be 'user' or 'admin'", + role + )); + } + }; + + let mut client = grpc_client(server, tls).await?; + let response = client + .add_workspace_member(AddWorkspaceMemberRequest { + workspace: workspace.to_string(), + principal_subject: subject.to_string(), + role: role_val.into(), + }) + .await + .into_diagnostic()?; + + let member = response + .into_inner() + .member + .ok_or_else(|| miette!("member missing from response"))?; + + println!( + "{} Added {} to workspace {} as {}", + "✓".green().bold(), + member.principal_subject.bold(), + workspace.bold(), + role, + ); + + Ok(()) +} + +pub async fn workspace_member_remove( + server: &str, + workspace: &str, + subject: &str, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::RemoveWorkspaceMemberRequest; + + let mut client = grpc_client(server, tls).await?; + let response = client + .remove_workspace_member(RemoveWorkspaceMemberRequest { + workspace: workspace.to_string(), + principal_subject: subject.to_string(), + }) + .await + .into_diagnostic()?; + + if response.into_inner().removed { + println!( + "{} Removed {} from workspace {}", + "✓".green().bold(), + subject.bold(), + workspace.bold(), + ); + } else { + println!( + "{} Member {} not found in workspace {}", + "!".yellow(), + subject, + workspace, + ); + } + + Ok(()) +} + +pub async fn workspace_member_list( + server: &str, + workspace: &str, + limit: u32, + offset: u32, + tls: &TlsOptions, +) -> Result<()> { + use openshell_core::proto::{ListWorkspaceMembersRequest, WorkspaceRole}; + + let mut client = grpc_client(server, tls).await?; + let response = client + .list_workspace_members(ListWorkspaceMembersRequest { + workspace: workspace.to_string(), + limit, + offset, + }) + .await + .into_diagnostic()?; + let members = response.into_inner().members; + + if members.is_empty() { + println!("No members found in workspace {workspace}."); + return Ok(()); + } + + let subject_width = members + .iter() + .map(|m| m.principal_subject.len()) + .max() + .unwrap_or(7) + .max(7); + + println!("{: "admin", + Ok(WorkspaceRole::User) => "user", + _ => "unknown", + }; + println!("{: serde_json::Value { + let mut obj = serde_json::Map::new(); + if let Some(meta) = &workspace.metadata { + obj.insert("name".to_string(), serde_json::json!(meta.name)); + obj.insert("id".to_string(), serde_json::json!(meta.id)); + obj.insert( + "resource_version".to_string(), + serde_json::json!(meta.resource_version), + ); + if meta.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(meta.created_at_ms)), + ); + } + if !meta.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(meta.labels)); + } + } + serde_json::Value::Object(obj) +} + +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_set( server: &str, provider_name: &str, @@ -5986,6 +6581,7 @@ pub async fn gateway_inference_set( route_name: &str, no_verify: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let progress = if std::io::stdout().is_terminal() { @@ -6003,13 +6599,14 @@ pub async fn gateway_inference_set( let mut client = grpc_inference_client(server, tls).await?; let response = client - .set_cluster_inference(SetClusterInferenceRequest { + .set_inference_route(SetInferenceRouteRequest { provider_name: provider_name.to_string(), model_id: model_id.to_string(), route_name: route_name.to_string(), verify: false, no_verify, timeout_secs, + workspace: workspace.to_string(), }) .await; @@ -6023,10 +6620,11 @@ pub async fn gateway_inference_set( let label = if configured.route_name == "sandbox-system" { "System inference configured:" } else { - "Gateway inference configured:" + "Inference configured:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Route:".dimmed(), configured.route_name); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); @@ -6041,6 +6639,7 @@ pub async fn gateway_inference_set( Ok(()) } +#[allow(clippy::too_many_arguments)] pub async fn gateway_inference_update( server: &str, provider_name: Option<&str>, @@ -6048,6 +6647,7 @@ pub async fn gateway_inference_update( route_name: &str, no_verify: bool, timeout_secs: Option, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if provider_name.is_none() && model_id.is_none() && timeout_secs.is_none() { @@ -6060,8 +6660,9 @@ pub async fn gateway_inference_update( // Fetch current config to use as base for the partial update. let current = client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: route_name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6085,13 +6686,14 @@ pub async fn gateway_inference_update( }; let response = client - .set_cluster_inference(SetClusterInferenceRequest { + .set_inference_route(SetInferenceRouteRequest { provider_name: provider.to_string(), model_id: model.to_string(), route_name: route_name.to_string(), verify: false, no_verify, timeout_secs: timeout, + workspace: workspace.to_string(), }) .await; @@ -6105,10 +6707,11 @@ pub async fn gateway_inference_update( let label = if configured.route_name == "sandbox-system" { "System inference updated:" } else { - "Gateway inference updated:" + "Inference updated:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Route:".dimmed(), configured.route_name); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); @@ -6126,6 +6729,7 @@ pub async fn gateway_inference_update( pub async fn gateway_inference_get( server: &str, route_name: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_inference_client(server, tls).await?; @@ -6133,8 +6737,9 @@ pub async fn gateway_inference_get( if let Some(name) = route_name { // Show a single route (--system was specified). let response = client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6143,19 +6748,20 @@ pub async fn gateway_inference_get( let label = if name == "sandbox-system" { "System inference:" } else { - "Gateway inference:" + "Inference:" }; println!("{}", label.cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); print_timeout(configured.timeout_secs); } else { // Show both routes by default. - print_inference_route(&mut client, "Gateway inference", "").await; + print_inference_route(&mut client, "Inference", "", workspace).await; println!(); - print_inference_route(&mut client, "System inference", "sandbox-system").await; + print_inference_route(&mut client, "System inference", "sandbox-system", workspace).await; } Ok(()) } @@ -6164,10 +6770,12 @@ async fn print_inference_route( client: &mut crate::tls::GrpcInferenceClient, label: &str, route_name: &str, + workspace: &str, ) { match client - .get_cluster_inference(GetClusterInferenceRequest { + .get_inference_route(GetInferenceRouteRequest { route_name: route_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -6175,6 +6783,7 @@ async fn print_inference_route( let configured = response.into_inner(); println!("{}", format!("{label}:").cyan().bold()); println!(); + println!(" {} {}", "Workspace:".dimmed(), configured.workspace); println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); @@ -6532,6 +7141,7 @@ pub async fn sandbox_policy_set_global( yes: bool, wait: bool, _timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if wait { @@ -6551,7 +7161,9 @@ pub async fn sandbox_policy_set_global( name: String::new(), policy: Some(policy), global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6574,12 +7186,14 @@ pub async fn sandbox_settings_get( server: &str, name: &str, json: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6735,6 +7349,7 @@ pub async fn gateway_setting_set( key: &str, value: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -6747,7 +7362,9 @@ pub async fn gateway_setting_set( setting_key: key.to_string(), setting_value: Some(setting_value), global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6768,6 +7385,7 @@ pub async fn sandbox_setting_set( name: &str, key: &str, value: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -6778,7 +7396,11 @@ pub async fn sandbox_setting_set( name: name.to_string(), setting_key: key.to_string(), setting_value: Some(setting_value), - ..Default::default() + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6799,6 +7421,7 @@ pub async fn gateway_setting_delete( server: &str, key: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { confirm_global_setting_delete(key, yes)?; @@ -6810,7 +7433,9 @@ pub async fn gateway_setting_delete( setting_key: key.to_string(), delete_setting: true, global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6833,6 +7458,7 @@ pub async fn sandbox_setting_delete( server: &str, name: &str, key: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -6841,7 +7467,10 @@ pub async fn sandbox_setting_delete( name: name.to_string(), setting_key: key.to_string(), delete_setting: true, - ..Default::default() + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6872,6 +7501,7 @@ pub async fn sandbox_policy_set( policy_path: &str, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let policy = load_sandbox_policy(Some(policy_path))? @@ -6885,6 +7515,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: 0, global: false, + workspace: workspace.to_string(), }) .await .ok() @@ -6895,7 +7526,13 @@ pub async fn sandbox_policy_set( .update_config(UpdateConfigRequest { name: name.to_string(), policy: Some(policy), - ..Default::default() + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6942,6 +7579,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: resp.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6997,6 +7635,7 @@ pub async fn sandbox_policy_update( dry_run: bool, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if dry_run && wait { @@ -7017,6 +7656,7 @@ pub async fn sandbox_policy_update( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7065,7 +7705,8 @@ pub async fn sandbox_policy_update( .update_config(UpdateConfigRequest { name: name.to_string(), merge_operations: plan.merge_operations, - ..Default::default() + expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7112,6 +7753,7 @@ pub async fn sandbox_policy_update( name: name.to_string(), version: response.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7159,6 +7801,7 @@ pub async fn sandbox_policy_get( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut stdout = Vec::new(); @@ -7169,6 +7812,7 @@ pub async fn sandbox_policy_get( version, view, output, + workspace, tls, (&mut stdout, &mut stderr), ) @@ -7187,12 +7831,14 @@ pub async fn sandbox_policy_get( } #[doc(hidden)] +#[allow(clippy::too_many_arguments)] pub async fn sandbox_policy_get_to_writer( server: &str, name: &str, version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, writers: (&mut W, &mut E), ) -> Result<()> @@ -7201,8 +7847,10 @@ where E: Write + Send, { if version == 0 { - return sandbox_policy_get_effective_to_writer(server, name, view, output, tls, writers) - .await; + return sandbox_policy_get_effective_to_writer( + server, name, view, output, workspace, tls, writers, + ) + .await; } let (stdout, stderr) = writers; @@ -7213,6 +7861,7 @@ where name: name.to_string(), version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7280,6 +7929,7 @@ async fn sandbox_policy_get_effective_to_writer( name: &str, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, writers: (&mut W, &mut E), ) -> Result<()> @@ -7293,6 +7943,7 @@ where let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7394,6 +8045,7 @@ pub async fn sandbox_policy_get_global( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7403,6 +8055,7 @@ pub async fn sandbox_policy_get_global( name: String::new(), version, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7531,6 +8184,7 @@ pub async fn sandbox_policy_list( server: &str, name: &str, limit: u32, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7541,6 +8195,7 @@ pub async fn sandbox_policy_list( limit, offset: 0, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7555,7 +8210,12 @@ pub async fn sandbox_policy_list( Ok(()) } -pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_policy_list_global( + server: &str, + limit: u32, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let resp = client @@ -7564,6 +8224,7 @@ pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptio limit, offset: 0, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7619,6 +8280,7 @@ pub async fn sandbox_logs( since: Option<&str>, sources: &[String], level: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7627,6 +8289,7 @@ pub async fn sandbox_logs( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7691,6 +8354,7 @@ pub async fn sandbox_logs( since_ms, sources: source_filter, min_level: level.to_uppercase(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7753,6 +8417,7 @@ pub async fn sandbox_draft_get( server: &str, name: &str, status_filter: Option<&str>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7761,6 +8426,7 @@ pub async fn sandbox_draft_get( .get_draft_policy(GetDraftPolicyRequest { name: name.to_string(), status_filter: status_filter.unwrap_or("").to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7845,6 +8511,7 @@ pub async fn sandbox_draft_approve( server: &str, name: &str, chunk_id: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7853,6 +8520,7 @@ pub async fn sandbox_draft_approve( .approve_draft_chunk(ApproveDraftChunkRequest { name: name.to_string(), chunk_id: chunk_id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7874,6 +8542,7 @@ pub async fn sandbox_draft_reject( name: &str, chunk_id: &str, reason: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7883,6 +8552,7 @@ pub async fn sandbox_draft_reject( name: name.to_string(), chunk_id: chunk_id.to_string(), reason: reason.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7897,6 +8567,7 @@ pub async fn sandbox_draft_approve_all( server: &str, name: &str, include_security_flagged: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -7905,6 +8576,7 @@ pub async fn sandbox_draft_approve_all( .approve_all_draft_chunks(ApproveAllDraftChunksRequest { name: name.to_string(), include_security_flagged, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7922,12 +8594,18 @@ pub async fn sandbox_draft_approve_all( } /// Clear all pending network rules. -pub async fn sandbox_draft_clear(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_draft_clear( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .clear_draft_chunks(ClearDraftChunksRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7943,12 +8621,18 @@ pub async fn sandbox_draft_clear(server: &str, name: &str, tls: &TlsOptions) -> } /// Show network rule history. -pub async fn sandbox_draft_history(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_draft_history( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_draft_history(GetDraftHistoryRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -8374,6 +9058,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }], false, ); @@ -9892,6 +10577,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -9913,6 +10599,7 @@ mod tests { credentials, config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -9950,6 +10637,7 @@ mod tests { credentials: std::collections::HashMap::new(), config, credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -9980,6 +10668,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), // Empty config credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10001,7 +10690,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, - annotations: std::collections::HashMap::new(), + workspace: String::new(), }; let provider = Provider { @@ -10010,6 +10699,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10035,6 +10725,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10064,6 +10755,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms, + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); @@ -10089,6 +10781,7 @@ mod tests { credentials: std::collections::HashMap::new(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; let json = super::provider_to_json(&provider); diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index a5f7b8bcd8..50bc2bbee9 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -76,6 +76,7 @@ async fn ssh_session_config( server: &str, name: &str, tls: &TlsOptions, + workspace: &str, ) -> Result { let mut client = grpc_client(server, tls).await?; @@ -83,6 +84,7 @@ async fn ssh_session_config( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -255,8 +257,9 @@ async fn sandbox_connect_with_mode( name: &str, tls: &TlsOptions, replace_process: bool, + workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = ssh_base_command(&session.proxy_command); command @@ -278,16 +281,22 @@ async fn sandbox_connect_with_mode( } /// Connect to a sandbox via SSH. -pub async fn sandbox_connect(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, true).await +pub async fn sandbox_connect( + server: &str, + name: &str, + tls: &TlsOptions, + workspace: &str, +) -> Result<()> { + sandbox_connect_with_mode(server, name, tls, true, workspace).await } pub(crate) async fn sandbox_connect_without_exec( server: &str, name: &str, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_connect_with_mode(server, name, tls, false).await + sandbox_connect_with_mode(server, name, tls, false, workspace).await } pub async fn sandbox_connect_editor( @@ -296,12 +305,14 @@ pub async fn sandbox_connect_editor( name: &str, editor: Editor, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { // Verify the sandbox exists before writing SSH config / launching the editor. let mut client = grpc_client(server, tls).await?; client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -309,8 +320,8 @@ pub async fn sandbox_connect_editor( .sandbox .ok_or_else(|| miette::miette!("sandbox not found: {name}"))?; - let host_alias = host_alias(name); - install_ssh_config(gateway, name)?; + let host_alias = host_alias(name, workspace); + install_ssh_config(gateway, name, workspace)?; launch_editor(editor, &host_alias)?; eprintln!( "{} Opened {} for sandbox {}", @@ -331,10 +342,11 @@ pub async fn sandbox_forward( spec: &ForwardSpec, background: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { openshell_core::forward::check_port_available(spec)?; - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command)); command @@ -528,12 +540,13 @@ async fn sandbox_exec_with_mode( tty: bool, tls: &TlsOptions, replace_process: bool, + workspace: &str, ) -> Result<()> { if command.is_empty() { return Err(miette::miette!("no command provided")); } - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let mut ssh = ssh_base_command(&session.proxy_command); if tty { @@ -572,8 +585,9 @@ pub async fn sandbox_exec( command: &[String], tty: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, true).await + sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await } pub(crate) async fn sandbox_exec_without_exec( @@ -582,8 +596,9 @@ pub(crate) async fn sandbox_exec_without_exec( command: &[String], tty: bool, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - sandbox_exec_with_mode(server, name, command, tty, tls, false).await + sandbox_exec_with_mode(server, name, command, tty, tls, false, workspace).await } /// What to pack into the tar archive streamed to the sandbox. @@ -754,8 +769,9 @@ async fn ssh_tar_upload( dest_dir: Option<&str>, source: UploadSource, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; // When no explicit destination is given, use the unescaped `$HOME` shell // variable so the remote shell resolves it at runtime. @@ -940,6 +956,7 @@ fn resolve_file_download_target( /// Files are streamed as a tar archive to `ssh ... tar xf - -C ` on /// the sandbox side. When `dest` is `None`, files are uploaded to the /// sandbox user's home directory. +#[allow(clippy::too_many_arguments)] pub async fn sandbox_sync_up_files( server: &str, name: &str, @@ -948,6 +965,7 @@ pub async fn sandbox_sync_up_files( local_path: &Path, dest: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { if files.is_empty() { return Ok(()); @@ -962,6 +980,7 @@ pub async fn sandbox_sync_up_files( archive_prefix: file_list_archive_prefix(local_path), }, tls, + workspace, ) .await } @@ -979,6 +998,7 @@ pub async fn sandbox_sync_up( local_path: &Path, sandbox_path: Option<&str>, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { // When an explicit destination is given and looks like a file path (does // not end with '/'), split into parent directory + target basename so that @@ -1005,6 +1025,7 @@ pub async fn sandbox_sync_up( tar_name: target_name.into(), }, tls, + workspace, ) .await; } @@ -1032,6 +1053,7 @@ pub async fn sandbox_sync_up( tar_name, }, tls, + workspace, ) .await } @@ -1139,9 +1161,10 @@ pub async fn sandbox_sync_down( sandbox_path: &str, dest: &str, tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let sandbox_path = validate_sandbox_source_path(sandbox_path)?; - let session = ssh_session_config(server, name, tls).await?; + let session = ssh_session_config(server, name, tls, workspace).await?; let sandbox_path = resolve_sandbox_source_path(&session, &sandbox_path).await?; let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?; @@ -1417,8 +1440,13 @@ fn grpc_server_from_ssh_gateway_url(gateway_url: &str) -> Result { /// and sandbox name instead of pre-created gateway/token credentials. It is /// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it /// creates a fresh session on every invocation. -pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { - let session = ssh_session_config(server, name, tls).await?; +pub async fn sandbox_ssh_proxy_by_name( + server: &str, + name: &str, + tls: &TlsOptions, + workspace: &str, +) -> Result<()> { + let session = ssh_session_config(server, name, tls, workspace).await?; sandbox_ssh_proxy( &session.gateway_url, &session.sandbox_id, @@ -1428,20 +1456,21 @@ pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOption .await } -fn host_alias(name: &str) -> String { - format!("openshell-{name}") +fn host_alias(name: &str, workspace: &str) -> String { + format!("openshell-{name}.{workspace}") } -fn render_ssh_config(gateway: &str, name: &str) -> String { +fn render_ssh_config(gateway: &str, name: &str, workspace: &str) -> String { let exe = std::env::current_exe().expect("failed to resolve OpenShell executable"); let exe = shell_escape(&exe.to_string_lossy()); let proxy_cmd = format!( - "{exe} ssh-proxy --gateway-name {} --name {}", + "{exe} ssh-proxy --gateway-name {} --name {} --workspace {}", shell_escape(gateway), shell_escape(name), + shell_escape(workspace), ); - let host_alias = host_alias(name); + let host_alias = host_alias(name, workspace); format!( "Host {host_alias}\n User sandbox\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n GlobalKnownHostsFile /dev/null\n LogLevel ERROR\n ServerAliveInterval 15\n ServerAliveCountMax 3\n ProxyCommand {proxy_cmd}\n" ) @@ -1567,7 +1596,7 @@ fn upsert_host_block(contents: &str, alias: &str, block: &str) -> String { rendered } -pub fn install_ssh_config(gateway: &str, name: &str) -> Result { +pub fn install_ssh_config(gateway: &str, name: &str, workspace: &str) -> Result { let managed_config = openshell_ssh_config_path()?; let main_config = user_ssh_config_path()?; ensure_openshell_include(&main_config, &managed_config)?; @@ -1576,8 +1605,8 @@ pub fn install_ssh_config(gateway: &str, name: &str) -> Result { openshell_core::paths::create_dir_restricted(parent)?; } - let alias = host_alias(name); - let block = render_ssh_config(gateway, name); + let alias = host_alias(name, workspace); + let block = render_ssh_config(gateway, name, workspace); let contents = fs::read_to_string(&managed_config).unwrap_or_default(); let updated = upsert_host_block(&contents, &alias, &block); fs::write(&managed_config, updated) @@ -1624,8 +1653,8 @@ fn launch_editor_command(binary: &str, label: &str, remote_target: &str) -> Resu /// The `ProxyCommand` uses `--gateway-name` so that `ssh-proxy` resolves the /// gateway endpoint and TLS certificates from the gateway metadata directory /// (`~/.config/openshell/gateways//mtls/`). -pub fn print_ssh_config(gateway: &str, name: &str) { - print!("{}", render_ssh_config(gateway, name)); +pub fn print_ssh_config(gateway: &str, name: &str, workspace: &str) { + print!("{}", render_ssh_config(gateway, name, workspace)); } #[cfg(test)] @@ -1674,8 +1703,8 @@ mod tests { let user_config = ssh_dir.join("config"); fs::write(&user_config, "Host personal\n HostName example.com\n").unwrap(); - let managed_path = install_ssh_config("openshell", "demo").unwrap(); - install_ssh_config("openshell", "demo").unwrap(); + let managed_path = install_ssh_config("openshell", "demo", "default").unwrap(); + install_ssh_config("openshell", "demo", "default").unwrap(); let main_contents = fs::read_to_string(&user_config).unwrap(); assert!(main_contents.contains("Host personal")); @@ -1686,7 +1715,12 @@ mod tests { assert!(include_idx < host_idx); let managed_contents = fs::read_to_string(&managed_path).unwrap(); - assert_eq!(managed_contents.matches("Host openshell-demo").count(), 1); + assert_eq!( + managed_contents + .matches("Host openshell-demo.default") + .count(), + 1 + ); assert!(managed_contents.contains("ProxyCommand")); unsafe { @@ -1701,6 +1735,25 @@ mod tests { } } + #[test] + fn render_ssh_config_includes_workspace_in_proxy_command() { + let config = render_ssh_config("my-gw", "demo", "beta"); + assert!( + config.contains("Host openshell-demo.beta"), + "host alias should be workspace-qualified: {config}" + ); + assert!( + config.contains("--workspace beta"), + "ProxyCommand should include --workspace: {config}" + ); + } + + #[test] + fn host_alias_includes_workspace() { + assert_eq!(host_alias("demo", "default"), "openshell-demo.default"); + assert_eq!(host_alias("demo", "beta"), "openshell-demo.beta"); + } + #[test] fn launch_editor_returns_friendly_error_when_binary_missing() { let err = launch_editor_command( diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7f9c59651a..9ef0301b5f 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,12 +63,13 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ); } @@ -357,7 +358,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -366,6 +367,7 @@ impl OpenShell for TestOpenShell { existing.credential_expires_at_ms, provider.credential_expires_at_ms, ), + profile_workspace: existing.profile_workspace, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); @@ -592,6 +594,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } // ── test server fixture ────────────────────────────────────────────── @@ -672,6 +723,7 @@ async fn explicit_provider_name_passes_through_when_it_exists() { &["nvidia".to_string()], &[], Some(true), // --auto-providers (should not matter here) + "default", ) .await .expect("should succeed"); @@ -700,6 +752,7 @@ async fn explicit_provider_name_auto_creates_when_valid_type() { &["nvidia".to_string()], &[], Some(true), // --auto-providers to skip interactive prompt + "default", ) .await .expect("should auto-create the provider"); @@ -733,6 +786,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() { &["my-custom-thing".to_string()], &[], Some(true), + "default", ) .await .expect_err("should fail for unrecognised provider name"); @@ -764,6 +818,7 @@ async fn inferred_type_auto_creates_provider() { &[], &["claude-code".to_string()], Some(true), // --auto-providers + "default", ) .await .expect("should auto-create the inferred provider"); @@ -793,6 +848,7 @@ async fn no_auto_providers_skips_missing_explicit_provider() { &["nvidia".to_string()], &[], Some(false), // --no-auto-providers + "default", ) .await .expect("should succeed with empty list"); @@ -828,6 +884,7 @@ async fn explicit_and_inferred_providers_combined() { &["nvidia".to_string()], &["claude-code".to_string()], Some(true), + "default", ) .await .expect("should create both providers"); @@ -859,6 +916,7 @@ async fn explicit_and_inferred_deduplicates() { &["nvidia".to_string()], &["nvidia".to_string()], Some(true), + "default", ) .await .expect("should succeed"); diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 58adf86082..622e3c1170 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -483,6 +483,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } async fn run_server( diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a55951e19..70bd86e5ad 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -136,7 +136,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, - annotations: HashMap::new(), + workspace: String::new(), }), spec: None, status: None, @@ -617,7 +617,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -626,6 +626,7 @@ impl OpenShell for TestOpenShell { existing.credential_expires_at_ms, provider.credential_expires_at_ms, ), + profile_workspace: existing.profile_workspace, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); @@ -996,6 +997,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } /// Test fixture: TLS-enabled server with matching client certs. @@ -1077,17 +1127,27 @@ async fn provider_cli_run_functions_support_full_crud_flow() { &["API_KEY=abc".to_string()], false, &["profile=dev".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); - run::provider_get(&ts.endpoint, "my-claude", &ts.tls) + run::provider_get(&ts.endpoint, "my-claude", "default", &ts.tls) .await .expect("provider get"); - run::provider_list(&ts.endpoint, 100, 0, false, "table", &ts.tls) - .await - .expect("provider list"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "table", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list"); run::provider_update( &ts.endpoint, @@ -1096,12 +1156,13 @@ async fn provider_cli_run_functions_support_full_crud_flow() { &["API_KEY=rotated".to_string()], &["profile=prod".to_string()], &[], + "default", &ts.tls, ) .await .expect("provider update"); - run::provider_delete(&ts.endpoint, &["my-claude".to_string()], &ts.tls) + run::provider_delete(&ts.endpoint, &["my-claude".to_string()], "default", &ts.tls) .await .expect("provider delete"); } @@ -1110,7 +1171,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() { async fn provider_list_profiles_cli_uses_profile_browsing_rpc() { let ts = run_server().await; - run::provider_list_profiles(&ts.endpoint, "table", &ts.tls) + run::provider_list_profiles(&ts.endpoint, "table", "default", &ts.tls) .await .expect("provider list-profiles"); } @@ -1128,19 +1189,34 @@ async fn provider_list_json_output() { &["ANTHROPIC_API_KEY=test-key".to_string()], false, &["region=us-west".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); // Test JSON output (verifies it doesn't error) - run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls) - .await - .expect("provider list json should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "json", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list json should succeed"); - run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls) - .await - .expect("provider delete"); + run::provider_delete( + &ts.endpoint, + &["test-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("provider delete"); } #[tokio::test] @@ -1156,19 +1232,34 @@ async fn provider_list_yaml_output() { &["ANTHROPIC_API_KEY=test-key".to_string()], false, &["region=us-west".to_string()], + "default", &ts.tls, ) .await .expect("provider create"); // Test YAML output (verifies it doesn't error) - run::provider_list(&ts.endpoint, 100, 0, false, "yaml", &ts.tls) - .await - .expect("provider list yaml should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "yaml", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list yaml should succeed"); - run::provider_delete(&ts.endpoint, &["test-provider".to_string()], &ts.tls) - .await - .expect("provider delete"); + run::provider_delete( + &ts.endpoint, + &["test-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("provider delete"); } #[tokio::test] @@ -1176,9 +1267,18 @@ async fn provider_list_json_empty() { let ts = run_server().await; // Test JSON output with no providers (verifies it doesn't error on empty list) - run::provider_list(&ts.endpoint, 100, 0, false, "json", &ts.tls) - .await - .expect("provider list json empty should succeed"); + run::provider_list( + &ts.endpoint, + 100, + 0, + false, + "json", + "default", + false, + &ts.tls, + ) + .await + .expect("provider list json empty should succeed"); } #[tokio::test] @@ -1193,6 +1293,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1209,6 +1310,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { secret_material_keys: &["client_secret".to_string()], credential_expires_at_ms: Some(1_767_225_600_000), }, + "default", &ts.tls, ) .await @@ -1217,16 +1319,29 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &ts.endpoint, "my-graph", Some("MS_GRAPH_ACCESS_TOKEN"), + "default", &ts.tls, ) .await .expect("provider refresh status"); - run::provider_rotate(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls) - .await - .expect("provider refresh rotate"); - run::provider_refresh_delete(&ts.endpoint, "my-graph", "MS_GRAPH_ACCESS_TOKEN", &ts.tls) - .await - .expect("provider refresh delete"); + run::provider_rotate( + &ts.endpoint, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + "default", + &ts.tls, + ) + .await + .expect("provider refresh rotate"); + run::provider_refresh_delete( + &ts.endpoint, + "my-graph", + "MS_GRAPH_ACCESS_TOKEN", + "default", + &ts.tls, + ) + .await + .expect("provider refresh delete"); let requests = ts.state.refresh_requests.lock().await.clone(); assert_eq!( @@ -1267,6 +1382,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { &["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1285,6 +1401,7 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1326,6 +1443,7 @@ async fn provider_refresh_configure_rejects_key_supplied_via_both_material_and_e secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1355,6 +1473,7 @@ async fn provider_refresh_configure_fails_closed_when_secret_material_env_is_uns secret_material_keys: &[], credential_expires_at_ms: None, }, + "default", &ts.tls, ) .await @@ -1397,6 +1516,8 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() false, true, &[], + "default", + "default", &ts.tls, ) .await @@ -1437,6 +1558,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &[], false, &[], + "default", &ts.tls, ) .await @@ -1464,26 +1586,51 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results &["GITHUB_TOKEN=ghp-test".to_string()], false, &[], + "default", &ts.tls, ) .await .expect("provider create"); - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider attach"); - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider attach is idempotent"); - run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", &ts.tls) + run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider attach"); + run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider attach is idempotent"); + run::sandbox_provider_list(&ts.endpoint, "dev-sandbox", "default", &ts.tls) .await .expect("sandbox provider list"); - run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider detach"); - run::sandbox_provider_detach(&ts.endpoint, "dev-sandbox", "work-github", &ts.tls) - .await - .expect("sandbox provider detach is idempotent"); + run::sandbox_provider_detach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider detach"); + run::sandbox_provider_detach( + &ts.endpoint, + "dev-sandbox", + "work-github", + "default", + &ts.tls, + ) + .await + .expect("sandbox provider detach is idempotent"); let requests = ts.state.sandbox_provider_requests.lock().await.clone(); assert_eq!( @@ -1519,10 +1666,15 @@ async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results async fn sandbox_provider_attach_cli_surfaces_server_errors() { let ts = run_server().await; - let err = - run::sandbox_provider_attach(&ts.endpoint, "dev-sandbox", "missing-provider", &ts.tls) - .await - .expect_err("missing provider should fail"); + let err = run::sandbox_provider_attach( + &ts.endpoint, + "dev-sandbox", + "missing-provider", + "default", + &ts.tls, + ) + .await + .expect_err("missing provider should fail"); assert!( err.to_string().contains("provider not found"), @@ -1563,14 +1715,14 @@ binaries: [/usr/bin/custom] ) .unwrap(); - run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_lint(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile lint"); - run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile import"); let exported_yaml = - run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", &ts.tls) + run::provider_profile_export_text(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls) .await .expect("profile export text"); assert!(exported_yaml.contains("resource_version: 1")); @@ -1581,9 +1733,15 @@ binaries: [/usr/bin/custom] ) .replace("host: api.custom.example", "host: api.updated.example"); std::fs::write(&profile_path, updated_yaml).unwrap(); - run::provider_profile_update(&ts.endpoint, "custom-api", &profile_path, &ts.tls) - .await - .expect("profile update"); + run::provider_profile_update( + &ts.endpoint, + "custom-api", + &profile_path, + "default", + &ts.tls, + ) + .await + .expect("profile update"); assert_eq!( ts.state .profiles @@ -1594,10 +1752,10 @@ binaries: [/usr/bin/custom] .map(|endpoint| endpoint.host.as_str()), Some("api.updated.example") ); - run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-api", "yaml", "default", &ts.tls) .await .expect("profile export"); - run::provider_list_profiles(&ts.endpoint, "json", &ts.tls) + run::provider_list_profiles(&ts.endpoint, "json", "default", &ts.tls) .await .expect("provider list-profiles json"); run::provider_create( @@ -1608,6 +1766,7 @@ binaries: [/usr/bin/custom] &["CUSTOM_API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1623,10 +1782,15 @@ binaries: [/usr/bin/custom] .expect("custom provider should be stored"); assert_eq!(provider.r#type, "custom-api"); - run::provider_delete(&ts.endpoint, &["custom-provider".to_string()], &ts.tls) - .await - .expect("custom provider delete"); - run::provider_profile_delete(&ts.endpoint, "custom-api", &ts.tls) + run::provider_delete( + &ts.endpoint, + &["custom-provider".to_string()], + "default", + &ts.tls, + ) + .await + .expect("custom provider delete"); + run::provider_profile_delete(&ts.endpoint, "custom-api", "default", &ts.tls) .await .expect("profile delete"); } @@ -1662,6 +1826,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() &[], false, &[], + "default", &ts.tls, ) .await @@ -1695,6 +1860,7 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( &[], false, &[], + "default", &ts.tls, ) .await @@ -1738,6 +1904,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w &[], false, &[], + "default", &ts.tls, ) .await @@ -1793,6 +1960,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { &[], false, &[], + "default", &ts.tls, ) .await @@ -1836,6 +2004,7 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin &[], false, &[], + "default", &ts.tls, ) .await @@ -1888,13 +2057,23 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update(&ts.endpoint, "custom-update", true, &[], &[], &[], &ts.tls) - .await - .expect("profile-backed provider update --from-existing"); + run::provider_update( + &ts.endpoint, + "custom-update", + true, + &[], + &[], + &[], + "default", + &ts.tls, + ) + .await + .expect("profile-backed provider update --from-existing"); let provider = ts .state @@ -1943,14 +2122,14 @@ binaries: [/usr/bin/yaml-client] .unwrap(); std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap(); - run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls) + run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) .await .expect("profile import --from"); - run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-yaml", "yaml", "default", &ts.tls) .await .expect("custom-yaml should be imported"); - run::provider_profile_export(&ts.endpoint, "custom-json", "json", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-json", "json", "default", &ts.tls) .await .expect("custom-json should be imported"); } @@ -1990,7 +2169,7 @@ binaries: ) .unwrap(); - run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, &ts.tls) + run::provider_profile_import(&ts.endpoint, Some(&profile_path), None, "default", &ts.tls) .await .expect("profile import"); @@ -2000,6 +2179,7 @@ binaries: let profile = client .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: String::new(), }) .await .expect("get provider profile") @@ -2034,15 +2214,16 @@ endpoints: .unwrap(); std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap(); - let err = run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), &ts.tls) - .await - .expect_err("profile import --from should fail on parse errors"); + let err = + run::provider_profile_import(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) + .await + .expect_err("profile import --from should fail on parse errors"); assert!( err.to_string().contains("provider profile import failed"), "unexpected error: {err}" ); - run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls) .await .expect_err("valid profiles should not be partially imported after local parse errors"); } @@ -2065,7 +2246,7 @@ endpoints: .unwrap(); std::fs::write(dir.path().join("broken.yaml"), "id: [\n").unwrap(); - let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), &ts.tls) + let err = run::provider_profile_lint(&ts.endpoint, None, Some(dir.path()), "default", &ts.tls) .await .expect_err("profile lint --from should fail on parse errors"); assert!( @@ -2073,7 +2254,7 @@ endpoints: "unexpected error: {err}" ); - run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", &ts.tls) + run::provider_profile_export(&ts.endpoint, "custom-good", "yaml", "default", &ts.tls) .await .expect_err("lint should not import valid profiles"); } @@ -2090,6 +2271,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() &["INVALID_PAIR".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2115,6 +2297,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { &["NAV_GENERIC_TEST_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2126,6 +2309,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { let response = client .get_provider(GetProviderRequest { name: "my-generic".to_string(), + workspace: String::new(), }) .await .expect("get provider should succeed") @@ -2150,6 +2334,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() { &["API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2174,6 +2359,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2199,6 +2385,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { &["GOOGLE_VERTEX_AI_TOKEN=token".to_string()], true, &[], + "default", &ts.tls, ) .await @@ -2225,6 +2412,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() { &["NAV_EMPTY_ENV_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2250,6 +2438,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { &["NVIDIA_API_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2261,6 +2450,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { let response = client .get_provider(GetProviderRequest { name: "my-nvidia".to_string(), + workspace: String::new(), }) .await .expect("get provider should succeed") @@ -2302,6 +2492,7 @@ async fn provider_create_from_gcloud_adc_happy_path() { &[], // no explicit credentials; refresh bootstrap covers it true, // from_gcloud_adc &[], + "default", &ts.tls, ) .await @@ -2387,6 +2578,7 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2424,6 +2616,7 @@ async fn provider_create_from_gcloud_adc_missing_file() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2457,6 +2650,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred &[], true, &[], + "default", &ts.tls, ) .await @@ -2494,6 +2688,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config &[], true, &[], + "default", &ts.tls, ) .await @@ -2544,6 +2739,7 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_ &[], true, &[], + "default", &ts.tls, ) .await @@ -2592,6 +2788,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate &[], true, &[], + "default", &ts.tls, ) .await @@ -2632,6 +2829,7 @@ async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex &[], false, &[], + "default", &ts.tls, ) .await @@ -2678,6 +2876,7 @@ async fn provider_create_from_gcloud_adc_with_config_keys() { "VERTEX_AI_PROJECT_ID=my-gcp-project".to_string(), "VERTEX_AI_REGION=us-east1".to_string(), ], + "default", &ts.tls, ) .await @@ -2752,6 +2951,7 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2794,6 +2994,7 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() { &[], true, &[], + "default", &ts.tls, ) .await diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 354b5159f0..bde4fd502c 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -94,7 +94,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), ..Sandbox::default() }; @@ -116,7 +116,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), ..Sandbox::default() }; @@ -377,7 +377,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: String::new(), }), ..Sandbox::default() }; @@ -665,6 +665,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } struct TestServer { @@ -1139,6 +1188,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1171,6 +1221,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1238,6 +1289,7 @@ async fn sandbox_create_sends_driver_config_json() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1299,6 +1351,7 @@ async fn sandbox_create_sends_gpu_default_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1333,6 +1386,7 @@ async fn sandbox_create_sends_gpu_count_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1368,6 +1422,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1413,6 +1468,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1454,6 +1510,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1487,6 +1544,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1517,6 +1575,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1551,6 +1610,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1584,6 +1644,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1619,6 +1680,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1646,9 +1708,16 @@ async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails( drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - run::sandbox_forward(&server.endpoint, "owned-forward", &spec, true, &tls) - .await - .expect("background forward should track the owned SSH child without PID discovery"); + run::sandbox_forward( + &server.endpoint, + "owned-forward", + &spec, + true, + &tls, + "default", + ) + .await + .expect("background forward should track the owned SSH child without PID discovery"); let record = openshell_core::forward::read_forward_pid("owned-forward", forward_port) .expect("owned background forward should write a PID file"); @@ -1676,9 +1745,16 @@ async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens() drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward(&server.endpoint, "foreground-forward", &spec, false, &tls) - .await - .expect_err("foreground forward should fail when ssh exits before listener readiness"); + let err = run::sandbox_forward( + &server.endpoint, + "foreground-forward", + &spec, + false, + &tls, + "default", + ) + .await + .expect_err("foreground forward should fail when ssh exits before listener readiness"); let msg = format!("{err}"); assert!( msg.contains("ssh exited before local forward listener opened"), @@ -1699,9 +1775,16 @@ async fn sandbox_forward_background_terminates_owned_child_when_listener_never_o drop(listener); let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward(&server.endpoint, "unreachable-forward", &spec, true, &tls) - .await - .expect_err("background forward should fail when the listener never opens"); + let err = run::sandbox_forward( + &server.endpoint, + "unreachable-forward", + &spec, + true, + &tls, + "default", + ) + .await + .expect_err("background forward should fail when the listener never opens"); let msg = format!("{err}"); assert!( msg.contains("ssh process started but local forward listener was not reachable"), @@ -1758,6 +1841,7 @@ async fn sandbox_create_sends_environment_variables() { ]), ..test_config() }, + "default", &tls, ) .await diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 7fe3c5dc59..dc62797ecb 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -86,7 +86,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + workspace: String::new(), }), ..Default::default() }), @@ -569,6 +569,55 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } + + async fn create_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspaces( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn delete_workspace( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn add_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn remove_workspace_member( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn list_workspace_members( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } } struct TestServer { @@ -636,7 +685,7 @@ async fn run_server() -> TestServer { async fn sandbox_get_sends_correct_name() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", false, &ts.tls) + run::sandbox_get(&ts.endpoint, "my-sandbox", false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -653,7 +702,7 @@ async fn sandbox_get_sends_correct_name() { async fn sandbox_get_policy_only_round_trip() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", true, &ts.tls) + run::sandbox_get(&ts.endpoint, "my-sandbox", true, "default", &ts.tls) .await .expect("sandbox_get with policy_only should succeed"); @@ -679,7 +728,7 @@ async fn sandbox_get_with_persisted_last_sandbox() { assert_eq!(resolved, "persisted-sb"); // Call sandbox_get with the resolved name. - run::sandbox_get(&ts.endpoint, &resolved, false, &ts.tls) + run::sandbox_get(&ts.endpoint, &resolved, false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -703,6 +752,7 @@ async fn policy_get_full_json_cli_prints_policy_payload() { 0, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -751,6 +801,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { 0, run::PolicyGetView::Base, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -792,6 +843,7 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() { 3, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -829,7 +881,7 @@ async fn explicit_name_takes_precedence_over_persisted() { // Persist one name, but supply a different one explicitly. save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed"); - run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, &ts.tls) + run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, "default", &ts.tls) .await .expect("sandbox_get should succeed"); diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 0d59c58589..567a8ad4a3 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -529,12 +529,19 @@ pub struct App { pub gateway_selected: usize, pub pending_gateway_switch: Option, + // Workspace filter + pub current_workspace: String, + pub all_workspaces: bool, + pub workspace_names: Vec, + pub pending_workspace_refresh: bool, + // Provider list pub providers_v2_enabled: bool, pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, pub provider_cred_keys: Vec, + pub provider_workspaces: Vec, pub provider_selected: usize, pub provider_count: usize, @@ -575,8 +582,7 @@ pub struct App { pub sandbox_notes: Vec, /// Formatted labels for each sandbox (e.g., "env=prod,team=platform" or empty string). pub sandbox_labels: Vec, - /// Formatted annotations for each sandbox (e.g., "policy-signature=abc" or empty string). - pub sandbox_annotations: Vec, + pub sandbox_workspaces: Vec, pub sandbox_policy_versions: Vec, pub sandbox_selected: usize, pub sandbox_count: usize, @@ -887,11 +893,16 @@ impl App { confirm_setting_delete: None, pending_setting_set: false, pending_setting_delete: false, + current_workspace: "default".to_string(), + all_workspaces: false, + workspace_names: Vec::new(), + pending_workspace_refresh: false, providers_v2_enabled: false, provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), provider_cred_keys: Vec::new(), + provider_workspaces: Vec::new(), provider_selected: 0, provider_count: 0, create_provider_form: None, @@ -910,7 +921,7 @@ impl App { sandbox_images: Vec::new(), sandbox_notes: Vec::new(), sandbox_labels: Vec::new(), - sandbox_annotations: Vec::new(), + sandbox_workspaces: Vec::new(), sandbox_policy_versions: Vec::new(), sandbox_selected: 0, sandbox_count: 0, @@ -1061,6 +1072,41 @@ impl App { } } + pub fn cycle_workspace(&mut self) { + if self.all_workspaces { + self.all_workspaces = false; + self.current_workspace = "default".to_string(); + } else if self.workspace_names.is_empty() { + self.all_workspaces = true; + } else { + let current_idx = self + .workspace_names + .iter() + .position(|n| n == &self.current_workspace); + match current_idx { + Some(idx) if idx + 1 < self.workspace_names.len() => { + self.current_workspace = self.workspace_names[idx + 1].clone(); + } + _ => { + self.all_workspaces = true; + } + } + } + // Reset selection indices so the cursor doesn't point past the end + // of the new workspace's (potentially shorter) sandbox/provider lists. + self.sandbox_selected = 0; + self.provider_selected = 0; + self.pending_workspace_refresh = true; + } + + pub fn workspace_display(&self) -> &str { + if self.all_workspaces { + "all" + } else { + &self.current_workspace + } + } + pub fn handle_key(&mut self, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.running = false; @@ -1367,6 +1413,9 @@ impl App { self.input_mode = InputMode::Command; self.command_input.clear(); } + KeyCode::Char('w') => { + self.cycle_workspace(); + } KeyCode::Char('j') | KeyCode::Down if self.sandbox_count > 0 => { self.sandbox_selected = (self.sandbox_selected + 1).min(self.sandbox_count - 1); } @@ -2526,6 +2575,25 @@ impl App { .map(String::as_str) } + /// Get the workspace of the currently selected sandbox. + /// + /// In the all-workspaces view, returns the workspace from the selected row + /// rather than the globally active workspace. + pub fn selected_sandbox_workspace(&self) -> String { + self.sandbox_workspaces + .get(self.sandbox_selected) + .cloned() + .unwrap_or_else(|| self.current_workspace.clone()) + } + + /// Get the workspace of the currently selected provider row. + pub fn selected_provider_workspace(&self) -> String { + self.provider_workspaces + .get(self.provider_selected) + .cloned() + .unwrap_or_else(|| self.current_workspace.clone()) + } + /// Get the name of the currently selected provider. pub fn selected_provider_name(&self) -> Option<&str> { self.provider_names @@ -2940,4 +3008,48 @@ mod tests { assert_eq!(gateway.source_label(), "unknown"); } + + // -- selected_sandbox_workspace ---------------------------------------- + + #[test] + fn selected_sandbox_workspace_returns_per_row_value() { + let workspaces = ["default", "beta", "staging"]; + let selected: usize = 1; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "beta"); + } + + #[test] + fn selected_sandbox_workspace_falls_back_to_current() { + let workspaces: &[&str] = &[]; + let selected: usize = 0; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "default"); + } + + // -- selected_provider_workspace ---------------------------------------- + + #[test] + fn selected_provider_workspace_returns_per_row_value() { + let workspaces = ["default", "beta", "staging"]; + let selected: usize = 1; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "beta"); + } + + #[test] + fn selected_provider_workspace_falls_back_to_current() { + let workspaces: &[&str] = &[]; + let selected: usize = 0; + let current = "default"; + + let result = workspaces.get(selected).unwrap_or(¤t); + assert_eq!(*result, "default"); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index bdca8932b8..8f112452da 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -20,7 +20,7 @@ use crossterm::terminal::{ use miette::{IntoDiagnostic, Result}; use openshell_bootstrap::list_gateways_with_source; use openshell_core::auth::EdgeAuthInterceptor; -use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName}; +use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName, ObjectWorkspace}; use openshell_core::proto::SandboxPhase; use openshell_core::proto::open_shell_client::OpenShellClient; use ratatui::Terminal; @@ -158,6 +158,11 @@ pub async fn run( let snapshot = std::mem::take(&mut app.approve_all_confirm_chunks); spawn_draft_approve_all(&app, snapshot, events.sender()); } + if app.pending_workspace_refresh { + app.pending_workspace_refresh = false; + refresh_providers(&mut app).await; + refresh_sandboxes(&mut app).await; + } } Some(Event::LogLines(lines)) => { app.sandbox_log_lines.extend(lines); @@ -625,6 +630,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { }; let mut client = app.client.clone(); + let workspace = app.selected_sandbox_workspace(); let handle = tokio::spawn(async move { // Phase 1: Fetch initial history via unary RPC. @@ -634,6 +640,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { since_ms: 0, sources: vec![], min_level: String::new(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.get_sandbox_logs(req)).await { @@ -734,7 +741,10 @@ async fn handle_sandbox_delete(app: &mut App) { ); } - let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name }; + let req = openshell_core::proto::DeleteSandboxRequest { + name: sandbox_name, + workspace: app.selected_sandbox_workspace(), + }; match app.client.delete_sandbox(req).await { Ok(_) => { app.cancel_log_stream(); @@ -766,6 +776,7 @@ async fn fetch_sandbox_detail(app: &mut App) { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: app.selected_sandbox_workspace(), }; // Step 1: Fetch sandbox metadata (providers, sandbox ID). @@ -851,6 +862,7 @@ async fn handle_shell_connect( let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: app.selected_sandbox_workspace(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1006,6 +1018,7 @@ async fn handle_exec_command( let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.to_string(), + workspace: app.selected_sandbox_workspace(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1347,6 +1360,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { let endpoint = app.endpoint.clone(); let gateway_name = app.gateway_name.clone(); let need_ready = !ports.is_empty() || !app.pending_exec_command.is_empty(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { let has_custom_image = !image.is_empty(); @@ -1379,7 +1393,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), - annotations: HashMap::new(), + workspace: workspace.clone(), }; let sandbox_name = @@ -1420,6 +1434,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.clone(), + workspace: workspace.clone(), }; // Retry on transient errors. if let Ok(resp) = client.get_sandbox(req).await @@ -1626,6 +1641,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { form.name.clone() }; let credentials = form.discovered_credentials.clone().unwrap_or_default(); + let workspace = app.current_workspace.clone(); tokio::spawn(async move { // Try with the chosen name, retry with suffix on collision. @@ -1644,13 +1660,15 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.clone(), }), r#type: ptype.clone(), credentials: credentials.clone(), config: HashMap::default(), credential_expires_at_ms: HashMap::default(), + profile_workspace: workspace.clone(), }), + workspace: workspace.clone(), }; match client.create_provider(req).await { @@ -1688,9 +1706,10 @@ fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.selected_provider_workspace(); tokio::spawn(async move { - let req = openshell_core::proto::GetProviderRequest { name }; + let req = openshell_core::proto::GetProviderRequest { name, workspace }; match tokio::time::timeout(Duration::from_secs(5), client.get_provider(req)).await { Ok(Ok(resp)) => { if let Some(provider) = resp.into_inner().provider { @@ -1724,6 +1743,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); + let workspace = app.selected_provider_workspace(); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1737,14 +1757,16 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, - annotations: HashMap::new(), + workspace: workspace.clone(), }), r#type: ptype, credentials, config: HashMap::default(), credential_expires_at_ms: HashMap::default(), + profile_workspace: String::new(), }), credential_expires_at_ms: HashMap::default(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { @@ -1770,9 +1792,10 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { Some(n) => n.to_string(), None => return, }; + let workspace = app.selected_provider_workspace(); tokio::spawn(async move { - let req = openshell_core::proto::DeleteProviderRequest { name }; + let req = openshell_core::proto::DeleteProviderRequest { name, workspace }; match tokio::time::timeout(Duration::from_secs(5), client.delete_provider(req)).await { Ok(Ok(resp)) => { let _ = tx.send(Event::ProviderDeleteResult(Ok(resp.into_inner().deleted))); @@ -1809,9 +1832,14 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { .draft_chunks .get(abs) .map_or_else(String::new, |c| c.rule_name.clone()); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { - let req = openshell_core::proto::ApproveDraftChunkRequest { name, chunk_id }; + let req = openshell_core::proto::ApproveDraftChunkRequest { + name, + chunk_id, + workspace, + }; match tokio::time::timeout(Duration::from_secs(5), client.approve_draft_chunk(req)).await { Ok(Ok(resp)) => { let inner = resp.into_inner(); @@ -1848,12 +1876,14 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { .draft_chunks .get(abs) .map_or_else(String::new, |c| c.rule_name.clone()); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { let req = openshell_core::proto::RejectDraftChunkRequest { name, chunk_id, reason: String::new(), + workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.reject_draft_chunk(req)).await { Ok(Ok(_)) => { @@ -1889,11 +1919,13 @@ fn spawn_draft_approve_all( Some(n) => n.to_string(), None => return, }; + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { let req = openshell_core::proto::ApproveAllDraftChunksRequest { name, include_security_flagged: false, + workspace, }; match tokio::time::timeout( Duration::from_secs(30), @@ -1935,15 +1967,41 @@ fn spawn_draft_approve_all( async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_global_settings(app).await; + refresh_workspaces(app).await; refresh_providers(app).await; refresh_sandboxes(app).await; } +async fn refresh_workspaces(app: &mut App) { + let req = openshell_core::proto::ListWorkspacesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + }; + match tokio::time::timeout(Duration::from_secs(5), app.client.list_workspaces(req)).await { + Ok(Ok(resp)) => { + app.workspace_names = resp + .into_inner() + .workspaces + .into_iter() + .filter_map(|w| w.metadata.map(|m| m.name)) + .collect(); + } + Ok(Err(e)) => { + tracing::warn!("failed to list workspaces: {}", e.message()); + } + Err(_) => { + tracing::warn!("list workspaces timed out"); + } + } +} + async fn refresh_providers(app: &mut App) { let profiles = if app.providers_v2_enabled { let req = openshell_core::proto::ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: app.current_workspace.clone(), }; match tokio::time::timeout( Duration::from_secs(5), @@ -1973,6 +2031,12 @@ async fn refresh_providers(app: &mut App) { let req = openshell_core::proto::ListProvidersRequest { limit: 100, offset: 0, + workspace: if app.all_workspaces { + String::new() + } else { + app.current_workspace.clone() + }, + all_workspaces: app.all_workspaces, }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await; match result { @@ -2012,6 +2076,10 @@ async fn refresh_providers(app: &mut App) { .unwrap_or_else(|| "-".to_string()) }) .collect(); + app.provider_workspaces = providers + .iter() + .map(|p| p.object_workspace().to_string()) + .collect(); if app.provider_selected >= app.provider_count && app.provider_count > 0 { app.provider_selected = app.provider_count - 1; } @@ -2042,6 +2110,7 @@ async fn refresh_global_settings(app: &mut App) { limit: 1, offset: 0, global: true, + workspace: String::new(), }; if let Ok(Ok(resp)) = tokio::time::timeout( Duration::from_secs(5), @@ -2110,7 +2179,9 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2143,7 +2214,9 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { setting_key: key, delete_setting: true, global: true, - ..Default::default() + merge_operations: vec![], + expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2175,6 +2248,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let raw = edit.input.trim().to_string(); let kind = entry.kind; let mut client = app.client.clone(); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { use openshell_core::proto::{SettingValue, UpdateConfigRequest, setting_value}; @@ -2209,7 +2283,11 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { name, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - ..Default::default() + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2237,6 +2315,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let name = sandbox_name.to_string(); let key = entry.key.clone(); let mut client = app.client.clone(); + let workspace = app.selected_sandbox_workspace(); tokio::spawn(async move { use openshell_core::proto::UpdateConfigRequest; @@ -2245,7 +2324,10 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { name, setting_key: key, delete_setting: true, - ..Default::default() + global: false, + merge_operations: vec![], + expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2288,6 +2370,12 @@ async fn refresh_sandboxes(app: &mut App) { limit: 100, offset: 0, label_selector: String::new(), + workspace: if app.all_workspaces { + String::new() + } else { + app.current_workspace.clone() + }, + all_workspaces: app.all_workspaces, }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_sandboxes(req)).await; match result { @@ -2364,14 +2452,9 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); - app.sandbox_annotations = sandboxes + app.sandbox_workspaces = sandboxes .iter() - .map(|s| { - s.metadata - .as_ref() - .map(|metadata| app::format_annotations(&metadata.annotations)) - .unwrap_or_default() - }) + .map(|s| s.object_workspace().to_string()) .collect(); if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { @@ -2431,6 +2514,7 @@ async fn refresh_draft_chunks(app: &mut App) { let req = openshell_core::proto::GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: app.selected_sandbox_workspace(), }; if let Ok(Ok(resp)) = @@ -2451,11 +2535,17 @@ async fn refresh_draft_chunks(app: &mut App) { /// badges without entering the sandbox detail view. async fn refresh_sandbox_draft_counts(app: &mut App) { let names: Vec = app.sandbox_names.clone(); + let workspaces: Vec = app.sandbox_workspaces.clone(); let mut counts = vec![0usize; names.len()]; for (i, name) in names.iter().enumerate() { + let ws = workspaces + .get(i) + .cloned() + .unwrap_or_else(|| app.current_workspace.clone()); let req = openshell_core::proto::GetDraftPolicyRequest { name: name.clone(), status_filter: "pending".to_string(), + workspace: ws, }; if let Ok(Ok(resp)) = tokio::time::timeout(Duration::from_secs(2), app.client.get_draft_policy(req)).await diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 9200e8f761..8df6dd3470 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -152,6 +152,10 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" | ", t.muted), ]; + parts.push(Span::styled("Workspace: ", t.text)); + parts.push(Span::styled(app.workspace_display(), t.heading)); + parts.push(Span::styled(" | ", t.muted)); + match app.screen { Screen::Splash => unreachable!("splash handled before draw_title_bar"), Screen::Dashboard => { @@ -260,6 +264,9 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" ", t.text), Span::styled("[c]", t.key_hint), Span::styled(" Create Sandbox", t.text), + Span::styled(" ", t.text), + Span::styled("[w]", t.key_hint), + Span::styled(" Workspace", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index 06a092460c..e4f4d2d0ba 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -15,15 +15,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { return; } - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("TYPE", t.muted)), Cell::from(Span::styled("CRED KEY", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = (0..app.provider_count) .map(|i| { + let workspace = app.provider_workspaces.get(i).map_or("", String::as_str); let name = app.provider_names.get(i).map_or("", String::as_str); let ptype = app.provider_types.get(i).map_or("", String::as_str); let cred_key = app.provider_cred_keys.get(i).map_or("", String::as_str); @@ -41,19 +48,34 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { ])) }; - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(ptype, t.muted)), Cell::from(Span::styled(cred_key, t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(40), - Constraint::Percentage(25), - Constraint::Percentage(35), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(30), + Constraint::Percentage(20), + Constraint::Percentage(30), + ] + } else { + vec![ + Constraint::Percentage(40), + Constraint::Percentage(25), + Constraint::Percentage(35), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; @@ -89,20 +111,27 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("PROFILE", t.muted)), Cell::from(Span::styled("CATEGORY", t.muted)), Cell::from(Span::styled("CREDS", t.muted)), Cell::from(Span::styled("POLICY", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = app .provider_entries .iter() .enumerate() .map(|(i, entry)| { + let workspace = app.provider_workspaces.get(i).map_or("", String::as_str); let selected = focused && i == app.provider_selected; let name_cell = if selected { Cell::from(Line::from(vec![ @@ -122,23 +151,40 @@ fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { t.status_warn }; - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(entry.profile_label(), profile_style)), Cell::from(Span::styled(entry.category_label(), t.muted)), Cell::from(Span::styled(entry.credential_summary(), t.muted)), Cell::from(Span::styled(entry.policy_summary(), t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(22), - Constraint::Percentage(24), - Constraint::Percentage(16), - Constraint::Percentage(18), - Constraint::Percentage(20), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(12), + Constraint::Percentage(18), + Constraint::Percentage(20), + Constraint::Percentage(14), + Constraint::Percentage(16), + Constraint::Percentage(20), + ] + } else { + vec![ + Constraint::Percentage(22), + Constraint::Percentage(24), + Constraint::Percentage(16), + Constraint::Percentage(18), + Constraint::Percentage(20), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; let title = if focused && app.confirm_provider_delete { diff --git a/crates/openshell-tui/src/ui/sandboxes.rs b/crates/openshell-tui/src/ui/sandboxes.rs index 6ace580b8e..4d239241b9 100644 --- a/crates/openshell-tui/src/ui/sandboxes.rs +++ b/crates/openshell-tui/src/ui/sandboxes.rs @@ -10,7 +10,13 @@ use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; - let header = Row::new(vec![ + let show_ws = app.all_workspaces; + + let mut header_cells = Vec::new(); + if show_ws { + header_cells.push(Cell::from(Span::styled("WORKSPACE", t.muted))); + } + header_cells.extend([ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("STATUS", t.muted)), Cell::from(Span::styled("CREATED", t.muted)), @@ -18,11 +24,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Cell::from(Span::styled("IMAGE", t.muted)), Cell::from(Span::styled("LABELS", t.muted)), Cell::from(Span::styled("NOTES", t.muted)), - ]) - .bottom_margin(1); + ]); + let header = Row::new(header_cells).bottom_margin(1); let rows: Vec> = (0..app.sandbox_count) .map(|i| { + let workspace = app.sandbox_workspaces.get(i).map_or("", String::as_str); let name = app.sandbox_names.get(i).map_or("", String::as_str); let phase = app.sandbox_phases.get(i).map_or("", String::as_str); let created = app.sandbox_created.get(i).map_or("", String::as_str); @@ -60,7 +67,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let name_cell = Cell::from(Line::from(name_spans)); - Row::new(vec![ + let mut cells: Vec> = Vec::new(); + if show_ws { + cells.push(Cell::from(Span::styled(workspace, t.muted))); + } + cells.extend([ name_cell, Cell::from(Span::styled(phase, phase_style)), Cell::from(Span::styled(created, t.muted)), @@ -68,19 +79,34 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Cell::from(Span::styled(image, t.muted)), Cell::from(Span::styled(labels, t.muted)), Cell::from(Span::styled(notes, t.muted)), - ]) + ]); + + Row::new(cells) }) .collect(); - let widths = [ - Constraint::Percentage(20), - Constraint::Percentage(10), - Constraint::Percentage(15), - Constraint::Percentage(8), - Constraint::Percentage(20), - Constraint::Percentage(15), - Constraint::Percentage(12), - ]; + let widths: Vec = if show_ws { + vec![ + Constraint::Percentage(10), + Constraint::Percentage(16), + Constraint::Percentage(9), + Constraint::Percentage(13), + Constraint::Percentage(7), + Constraint::Percentage(18), + Constraint::Percentage(15), + Constraint::Percentage(12), + ] + } else { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(8), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(12), + ] + }; let border_style = if focused { t.border_focused } else { t.border }; diff --git a/e2e/python/conftest.py b/e2e/python/conftest.py index 07b7feb34b..a5fa03cd99 100644 --- a/e2e/python/conftest.py +++ b/e2e/python/conftest.py @@ -56,6 +56,7 @@ def ensure_sandbox_persistence_ready(sandbox_client: SandboxClient) -> None: def sandbox(cluster_name: str | None) -> Callable[..., Sandbox]: def _create(*, spec: object | None = None, delete_on_exit: bool = True) -> Sandbox: return Sandbox( + workspace="default", cluster=cluster_name, spec=spec, delete_on_exit=delete_on_exit, diff --git a/e2e/python/test_inference_routing.py b/e2e/python/test_inference_routing.py index e4a1dd5497..0e971f5b89 100644 --- a/e2e/python/test_inference_routing.py +++ b/e2e/python/test_inference_routing.py @@ -23,8 +23,8 @@ from collections.abc import Callable, Iterator from openshell import ( - ClusterInferenceConfig, InferenceRouteClient, + InferenceRouteConfig, Sandbox, SandboxClient, ) @@ -78,7 +78,9 @@ def _upsert_managed_inference( for _ in range(5): try: sandbox_client._stub.UpdateProvider( - openshell_pb2.UpdateProviderRequest(provider=provider), + openshell_pb2.UpdateProviderRequest( + provider=provider, workspace="default" + ), timeout=timeout, ) break @@ -88,7 +90,9 @@ def _upsert_managed_inference( try: sandbox_client._stub.CreateProvider( - openshell_pb2.CreateProviderRequest(provider=provider), + openshell_pb2.CreateProviderRequest( + provider=provider, workspace="default" + ), timeout=timeout, ) break @@ -99,31 +103,33 @@ def _upsert_managed_inference( else: raise RuntimeError("failed to upsert managed e2e provider after retries") - inference_client.set_cluster( + inference_client.set_route( + workspace="default", provider_name=provider_name, model_id=model_id, ) -def _current_cluster_inference( +def _current_route( inference_client: InferenceRouteClient, -) -> ClusterInferenceConfig | None: +) -> InferenceRouteConfig | None: try: - return inference_client.get_cluster() + return inference_client.get_route(workspace="default") except grpc.RpcError as exc: if exc.code() == grpc.StatusCode.NOT_FOUND: return None raise -def _restore_cluster_inference( +def _restore_route( inference_client: InferenceRouteClient, - previous: ClusterInferenceConfig | None, + previous: InferenceRouteConfig | None, ) -> None: if previous is None: return - inference_client.set_cluster( + inference_client.set_route( + workspace="default", provider_name=previous.provider_name, model_id=previous.model_id, # Teardown restores prior shared state as-is, even if the previous @@ -148,7 +154,7 @@ def managed_openai_route( sandbox_client: SandboxClient, ) -> Iterator[str]: with _cluster_config_lock(): - previous = _current_cluster_inference(inference_client) + previous = _current_route(inference_client) _upsert_managed_inference( inference_client, sandbox_client, @@ -162,7 +168,7 @@ def managed_openai_route( try: yield _MANAGED_OPENAI_MODEL_ID finally: - _restore_cluster_inference(inference_client, previous) + _restore_route(inference_client, previous) def test_model_discovery_call_routed_to_backend( diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 929728d7c3..46c1106ad8 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -36,7 +36,7 @@ def read(self, path: str) -> str: ) assert all(p.isalpha() and p.islower() for p in parts) - fetched = sandbox_client.get(sb.sandbox.name) + fetched = sandbox_client.get(sb.sandbox.name, workspace="default") assert fetched.id == sb.id ids = set(sandbox_client.list_ids(limit=100)) @@ -73,18 +73,22 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: created: list[str] = [] try: ref_a = sandbox_client.create( - name=job_a, labels={"aiq-test": suffix, "role": "primary"} + workspace="default", + name=job_a, + labels={"aiq-test": suffix, "role": "primary"}, ) created.append(ref_a.name) ref_b = sandbox_client.create( - name=job_b, labels={"aiq-test": suffix, "role": "secondary"} + workspace="default", + name=job_b, + labels={"aiq-test": suffix, "role": "secondary"}, ) created.append(ref_b.name) # Labels round-trip through create and get. assert ref_a.labels["role"] == "primary" - assert dict(sandbox_client.get(job_a).labels)["role"] == "primary" - assert dict(sandbox_client.get(job_b).labels)["role"] == "secondary" + assert dict(sandbox_client.get(job_a, workspace="default").labels)["role"] == "primary" + assert dict(sandbox_client.get(job_b, workspace="default").labels)["role"] == "secondary" # A specific selector filters to exactly the primary sandbox. assert { @@ -97,19 +101,19 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: } # Deleting one removes only it from selector results. - assert sandbox_client.delete(job_a) - sandbox_client.wait_deleted(job_a) + assert sandbox_client.delete(job_a, workspace="default") + sandbox_client.wait_deleted(job_a, workspace="default") created.remove(job_a) assert {s.name for s in sandbox_client.list(label_selector=group_selector)} == { job_b } # Final deletion leaves no matching sandboxes. - assert sandbox_client.delete(job_b) - sandbox_client.wait_deleted(job_b) + assert sandbox_client.delete(job_b, workspace="default") + sandbox_client.wait_deleted(job_b, workspace="default") created.remove(job_b) assert not sandbox_client.list(label_selector=group_selector) finally: for name in created: with contextlib.suppress(Exception): - sandbox_client.delete(name) + sandbox_client.delete(name, workspace="default") diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 083a424921..eac4606060 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -285,7 +285,7 @@ def test_create_sandbox_rejects_unknown_provider( providers=["nonexistent-provider-xyz"], ) with pytest.raises(grpc.RpcError) as exc_info: - sandbox_client.create(spec=spec) + sandbox_client.create(workspace="default", spec=spec) assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION assert "nonexistent-provider-xyz" in (exc_info.value.details() or "") @@ -484,3 +484,78 @@ def test_update_provider_rejects_type_change( assert "type cannot be changed" in exc_info.value.details() finally: _delete_provider(stub, name) + + +# =========================================================================== +# Tests: provider profile platform vs workspace scope isolation +# =========================================================================== + + +def test_provider_profile_platform_vs_workspace_isolation( + sandbox_client: "SandboxClient", +) -> None: + """Platform-scoped (workspace='') and workspace-scoped profiles are isolated.""" + stub = sandbox_client._stub + platform_id = "e2e-platform-profile" + workspace_id = "e2e-workspace-profile" + + def _make_profile(profile_id: str) -> openshell_pb2.ProviderProfileImportItem: + return openshell_pb2.ProviderProfileImportItem( + profile=openshell_pb2.ProviderProfile( + id=profile_id, + display_name=f"{profile_id} display", + category=openshell_pb2.PROVIDER_PROFILE_CATEGORY_OTHER, + ), + source=f"{profile_id}.yaml", + ) + + def _cleanup() -> None: + for pid, ws in [(platform_id, ""), (workspace_id, "default")]: + try: + stub.DeleteProviderProfile( + openshell_pb2.DeleteProviderProfileRequest(id=pid, workspace=ws) + ) + except grpc.RpcError: + pass + + _cleanup() + try: + resp = stub.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest( + profiles=[_make_profile(platform_id)], + workspace="", + ) + ) + assert resp.imported, "platform-scoped import should succeed" + + resp = stub.ImportProviderProfiles( + openshell_pb2.ImportProviderProfilesRequest( + profiles=[_make_profile(workspace_id)], + workspace="default", + ) + ) + assert resp.imported, "workspace-scoped import should succeed" + + platform_list = stub.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(limit=200, workspace="") + ) + platform_ids = [p.id for p in platform_list.profiles] + assert platform_id in platform_ids, ( + "platform profile should appear in platform list" + ) + assert workspace_id not in platform_ids, ( + "workspace profile should NOT appear in platform list" + ) + + workspace_list = stub.ListProviderProfiles( + openshell_pb2.ListProviderProfilesRequest(limit=200, workspace="default") + ) + workspace_ids = [p.id for p in workspace_list.profiles] + assert workspace_id in workspace_ids, ( + "workspace profile should appear in workspace list" + ) + assert platform_id not in workspace_ids, ( + "platform profile should NOT appear in workspace list" + ) + finally: + _cleanup() diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 09cb02a702..f638fa0c9c 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -102,6 +102,11 @@ name = "forward_proxy_jsonrpc_l7" path = "tests/forward_proxy_jsonrpc_l7.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "workspace_lifecycle" +path = "tests/workspace_lifecycle.rs" +required-features = ["e2e"] + [[test]] name = "gpu" path = "tests/gpu.rs" diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs new file mode 100644 index 0000000000..bc9f2b0eb1 --- /dev/null +++ b/e2e/rust/tests/workspace_lifecycle.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E tests for workspace resource lifecycle. +//! +//! Covers: +//! - Workspace CRUD +//! - Provider creation scoped to a workspace +//! - Workspace isolation (resources in one workspace are invisible in another) +//! - `--all-workspaces` listing +//! - Deletion guard (workspace cannot be deleted while resources exist) +//! - Successful deletion after resource cleanup + +use std::process::Stdio; + +use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; +use openshell_e2e::harness::output::strip_ansi; + +const WORKSPACE: &str = "lifecycle-test"; +const PROVIDER: &str = "lifecycle-prov"; + +struct CliResult { + output: String, + success: bool, +} + +async fn run_cli(args: &[&str]) -> CliResult { + let mut cmd = openshell_cmd(); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell command"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + CliResult { + output: strip_ansi(&combined), + success: output.status.success(), + } +} + +struct WorkspaceCleanup; + +impl Drop for WorkspaceCleanup { + fn drop(&mut self) { + let bin = openshell_bin(); + let _ = std::process::Command::new(&bin) + .args([ + "provider", + "delete", + PROVIDER, + "--workspace", + WORKSPACE, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let _ = std::process::Command::new(&bin) + .args(["workspace", "delete", WORKSPACE]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[tokio::test] +async fn workspace_full_crud_lifecycle() { + let _cleanup = WorkspaceCleanup; + + // 1. Create workspace. + let res = run_cli(&["workspace", "create", "--name", WORKSPACE]).await; + assert!(res.success, "workspace create failed: {}", res.output); + + // 2. Verify workspace exists. + let res = run_cli(&["workspace", "get", WORKSPACE]).await; + assert!(res.success, "workspace get failed: {}", res.output); + assert!( + res.output.contains(WORKSPACE), + "workspace get output should contain workspace name: {}", + res.output + ); + + // 3. Create provider in the workspace. + let res = run_cli(&[ + "provider", + "create", + "--name", + PROVIDER, + "--type", + "github", + "--runtime-credentials", + "--workspace", + WORKSPACE, + ]) + .await; + assert!( + res.success, + "provider create in workspace failed: {}", + res.output + ); + + // 4. List providers in workspace — should see our provider. + let res = run_cli(&["provider", "list", "--workspace", WORKSPACE]).await; + assert!(res.success, "provider list failed: {}", res.output); + assert!( + res.output.contains(PROVIDER), + "workspace-scoped list should show the provider: {}", + res.output + ); + + // 5. List providers in default workspace — should NOT see our provider. + let res = run_cli(&["provider", "list"]).await; + assert!(res.success, "provider list (default) failed: {}", res.output); + assert!( + !res.output.contains(PROVIDER), + "default workspace should not contain the workspace-scoped provider: {}", + res.output + ); + + // 6. List providers with --all-workspaces — should see our provider. + let res = run_cli(&["provider", "list", "--all-workspaces"]).await; + assert!( + res.success, + "provider list --all-workspaces failed: {}", + res.output + ); + assert!( + res.output.contains(PROVIDER), + "--all-workspaces should include the workspace-scoped provider: {}", + res.output + ); + + // 7. Attempt workspace deletion — should fail because provider exists. + let res = run_cli(&["workspace", "delete", WORKSPACE]).await; + assert!( + !res.success, + "workspace delete should fail while resources exist: {}", + res.output + ); + assert!( + res.output.contains("still contains resources"), + "error should mention blocking resources: {}", + res.output + ); + + // 8. Delete the provider. + let res = run_cli(&[ + "provider", + "delete", + PROVIDER, + "--workspace", + WORKSPACE, + ]) + .await; + assert!(res.success, "provider delete failed: {}", res.output); + + // 9. Workspace deletion should now succeed. + let res = run_cli(&["workspace", "delete", WORKSPACE]).await; + assert!( + res.success, + "workspace delete should succeed after resources removed: {}", + res.output + ); + + // 10. Verify workspace is gone. + let res = run_cli(&["workspace", "get", WORKSPACE]).await; + assert!( + !res.success, + "workspace get should fail after deletion: {}", + res.output + ); +} diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 94a391d4ef..39200158d8 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -6,10 +6,10 @@ from __future__ import annotations from .sandbox import ( - ClusterInferenceConfig, ExecChunk, ExecResult, InferenceRouteClient, + InferenceRouteConfig, Sandbox, SandboxClient, SandboxError, @@ -27,10 +27,10 @@ __version__ = "0.0.0" __all__ = [ - "ClusterInferenceConfig", "ExecChunk", "ExecResult", "InferenceRouteClient", + "InferenceRouteConfig", "Sandbox", "SandboxClient", "SandboxError", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 9b00ebfb70..0c8cf1d232 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -159,6 +159,7 @@ def __reduce_ex__(self, protocol: SupportsIndex, /) -> str | tuple[Any, ...]: class SandboxRef: id: str name: str + workspace: str status: SandboxStatusRef # Excluded from equality/hash to preserve the original identity while the # immutable mapping remains safe for deepcopy, pickle, and asdict. @@ -197,6 +198,7 @@ class SandboxSession: def __init__(self, client: SandboxClient, sandbox: SandboxRef) -> None: self._client = client self.sandbox = sandbox + self._workspace = sandbox.workspace @property def id(self) -> str: @@ -245,7 +247,7 @@ def exec_python( ) def delete(self) -> bool: - return self._client.delete(self.sandbox.name) + return self._client.delete(self.sandbox.name, workspace=self._workspace) class SandboxClient: @@ -427,6 +429,7 @@ def health(self) -> openshell_pb2.HealthResponse: def create( self, *, + workspace: str, spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, @@ -437,6 +440,7 @@ def create( spec=request_spec, name=name or "", labels=dict(labels) if labels else {}, + workspace=workspace, ), timeout=self._timeout, ) @@ -448,42 +452,56 @@ def create( def create_session( self, *, + workspace: str, spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, ) -> SandboxSession: - return SandboxSession(self, self.create(spec=spec, name=name, labels=labels)) + return SandboxSession( + self, self.create(workspace=workspace, spec=spec, name=name, labels=labels) + ) - def get(self, sandbox_name: str) -> SandboxRef: + def get(self, sandbox_name: str, *, workspace: str) -> SandboxRef: response = self._stub.GetSandbox( - openshell_pb2.GetSandboxRequest(name=sandbox_name), + openshell_pb2.GetSandboxRequest(name=sandbox_name, workspace=workspace), timeout=self._timeout, ) return _sandbox_ref(response.sandbox) - def get_session(self, sandbox_name: str) -> SandboxSession: - return SandboxSession(self, self.get(sandbox_name)) + def get_session(self, sandbox_name: str, *, workspace: str) -> SandboxSession: + return SandboxSession(self, self.get(sandbox_name, workspace=workspace)) def list( self, *, + workspace: str | None = None, limit: int = 100, offset: int = 0, label_selector: str | None = None, ) -> builtins.list[SandboxRef]: - response = self._stub.ListSandboxes( - openshell_pb2.ListSandboxesRequest( + # workspace=None lists across all workspaces (all_workspaces=True). + # Pass an explicit workspace string to scope to a single workspace. + if workspace is not None: + request = openshell_pb2.ListSandboxesRequest( + workspace=workspace, limit=limit, offset=offset, label_selector=label_selector or "", - ), - timeout=self._timeout, - ) + ) + else: + request = openshell_pb2.ListSandboxesRequest( + all_workspaces=True, + limit=limit, + offset=offset, + label_selector=label_selector or "", + ) + response = self._stub.ListSandboxes(request, timeout=self._timeout) return [_sandbox_ref(item) for item in response.sandboxes] def list_ids( self, *, + workspace: str | None = None, limit: int = 100, offset: int = 0, label_selector: str | None = None, @@ -491,22 +509,27 @@ def list_ids( return [ item.id for item in self.list( - limit=limit, offset=offset, label_selector=label_selector + workspace=workspace, + limit=limit, + offset=offset, + label_selector=label_selector, ) ] - def delete(self, sandbox_name: str) -> bool: + def delete(self, sandbox_name: str, *, workspace: str) -> bool: response = self._stub.DeleteSandbox( - openshell_pb2.DeleteSandboxRequest(name=sandbox_name), + openshell_pb2.DeleteSandboxRequest(name=sandbox_name, workspace=workspace), timeout=self._timeout, ) return bool(response.deleted) - def wait_deleted(self, sandbox_name: str, *, timeout_seconds: float = 60.0) -> None: + def wait_deleted( + self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 60.0 + ) -> None: deadline = time.time() + timeout_seconds while time.time() < deadline: try: - self.get(sandbox_name) + self.get(sandbox_name, workspace=workspace) except grpc.RpcError as exc: if ( isinstance(exc, grpc.Call) @@ -518,11 +541,11 @@ def wait_deleted(self, sandbox_name: str, *, timeout_seconds: float = 60.0) -> N raise SandboxError(f"sandbox {sandbox_name} was not deleted within timeout") def wait_ready( - self, sandbox_name: str, *, timeout_seconds: float = 300.0 + self, sandbox_name: str, *, workspace: str, timeout_seconds: float = 300.0 ) -> SandboxRef: deadline = time.time() + timeout_seconds while time.time() < deadline: - sandbox = self.get(sandbox_name) + sandbox = self.get(sandbox_name, workspace=workspace) if sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_READY: return sandbox if sandbox.status.phase == openshell_pb2.SANDBOX_PHASE_ERROR: @@ -646,14 +669,14 @@ def exec_python( @dataclass(frozen=True) -class ClusterInferenceConfig: +class InferenceRouteConfig: provider_name: str model_id: str version: int class InferenceRouteClient: - """gRPC client for cluster-level inference configuration.""" + """gRPC client for workspace-scoped inference route configuration.""" def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None: self._stub = inference_pb2_grpc.InferenceStub(channel) @@ -663,33 +686,35 @@ def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None: def from_sandbox_client(cls, client: SandboxClient) -> InferenceRouteClient: return cls(client._channel, timeout=client._timeout) - def set_cluster( + def set_route( self, *, + workspace: str, provider_name: str, model_id: str, no_verify: bool = False, - ) -> ClusterInferenceConfig: - response = self._stub.SetClusterInference( - inference_pb2.SetClusterInferenceRequest( + ) -> InferenceRouteConfig: + response = self._stub.SetInferenceRoute( + inference_pb2.SetInferenceRouteRequest( + workspace=workspace, provider_name=provider_name, model_id=model_id, no_verify=no_verify, ), timeout=self._timeout, ) - return ClusterInferenceConfig( + return InferenceRouteConfig( provider_name=response.provider_name, model_id=response.model_id, version=response.version, ) - def get_cluster(self) -> ClusterInferenceConfig: - response = self._stub.GetClusterInference( - inference_pb2.GetClusterInferenceRequest(), + def get_route(self, *, workspace: str) -> InferenceRouteConfig: + response = self._stub.GetInferenceRoute( + inference_pb2.GetInferenceRouteRequest(workspace=workspace), timeout=self._timeout, ) - return ClusterInferenceConfig( + return InferenceRouteConfig( provider_name=response.provider_name, model_id=response.model_id, version=response.version, @@ -702,6 +727,7 @@ class Sandbox: def __init__( self, *, + workspace: str, cluster: str | None = None, sandbox: str | SandboxRef | None = None, delete_on_exit: bool = True, @@ -723,6 +749,7 @@ def __init__( OIDC-protected gateways (e.g. passing `insecure=True` for a self-signed dev IdP). Non-OIDC gateways ignore them. """ + self._workspace = workspace self._cluster = cluster self._sandbox_input = sandbox self._delete_on_exit = delete_on_exit @@ -771,15 +798,21 @@ def __enter__(self) -> Sandbox: if self._sandbox_input is None: self._session = client.create_session( - spec=self._spec, name=self._name, labels=self._labels + workspace=self._workspace, + spec=self._spec, + name=self._name, + labels=self._labels, ) elif isinstance(self._sandbox_input, SandboxRef): self._session = SandboxSession(client, self._sandbox_input) else: - self._session = client.get_session(self._sandbox_input) + self._session = client.get_session( + self._sandbox_input, workspace=self._workspace + ) ready = client.wait_ready( self._session.sandbox.name, + workspace=self._workspace, timeout_seconds=self._ready_timeout_seconds, ) self._session = SandboxSession(client, ready) @@ -796,7 +829,10 @@ def __exit__(self, *args: object) -> None: try: deleted = self._session.delete() if deleted: - self._client.wait_deleted(self._session.sandbox.name) + self._client.wait_deleted( + self._session.sandbox.name, + workspace=self._workspace, + ) except grpc.RpcError as exc: if ( not isinstance(exc, grpc.Call) @@ -885,6 +921,7 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: return SandboxRef( id=sandbox.metadata.id if sandbox.metadata else "", name=sandbox.metadata.name if sandbox.metadata else "", + workspace=sandbox.metadata.workspace if sandbox.metadata else "", status=SandboxStatusRef( phase=status.phase if status else 0, current_policy_version=status.current_policy_version if status else 0, diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 700a9d1ed6..d0579af50c 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -55,10 +55,11 @@ def ExecSandbox( class _FakeInferenceStub: def __init__(self) -> None: - self.request = None + self.set_request = None + self.get_request = None - def SetClusterInference(self, request: Any, timeout: float | None = None) -> Any: - self.request = request + def SetInferenceRoute(self, request: Any, timeout: float | None = None) -> Any: + self.set_request = request _ = timeout class _Response: @@ -68,6 +69,17 @@ class _Response: return _Response() + def GetInferenceRoute(self, request: Any, timeout: float | None = None) -> Any: + self.get_request = request + _ = timeout + + class _Response: + provider_name = "openai-dev" + model_id = "gpt-4.1" + version = 2 + + return _Response() + def _client_with_fake_stub(stub: object) -> SandboxClient: client = cast("SandboxClient", object.__new__(SandboxClient)) @@ -1293,6 +1305,7 @@ def fake_from_active_cluster(**kwargs: Any) -> Any: ) sandbox = Sandbox( + workspace="default", cluster="my-gw", timeout=42.0, auto_refresh=False, @@ -1334,27 +1347,44 @@ def fake_from_active_cluster(**kwargs: Any) -> Any: import pytest as _pytest with _pytest.raises(_Sentinel): - Sandbox().__enter__() + Sandbox(workspace="default").__enter__() assert captured["auto_refresh"] is True assert captured["write_back"] is True assert captured["insecure"] is False -def test_inference_set_cluster_forwards_no_verify_flag() -> None: +def test_inference_set_route_forwards_workspace_and_no_verify() -> None: stub = _FakeInferenceStub() client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient)) client._timeout = 30.0 client._stub = cast("Any", stub) - client.set_cluster( + client.set_route( + workspace="production", provider_name="openai-dev", model_id="gpt-4.1", no_verify=True, ) - assert stub.request is not None - assert stub.request.no_verify is True + assert stub.set_request is not None + assert stub.set_request.no_verify is True + assert stub.set_request.workspace == "production" + + +def test_inference_get_route_forwards_workspace() -> None: + stub = _FakeInferenceStub() + client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient)) + client._timeout = 30.0 + client._stub = cast("Any", stub) + + config = client.get_route(workspace="staging") + + assert stub.get_request is not None + assert stub.get_request.workspace == "staging" + assert config.provider_name == "openai-dev" + assert config.model_id == "gpt-4.1" + assert config.version == 2 # --------------------------------------------------------------------------- @@ -1433,10 +1463,12 @@ def _make_sandbox_proto( labels: dict[str, str] | None = None, phase: openshell_pb2.SandboxPhase = openshell_pb2.SANDBOX_PHASE_READY, version: int = 0, + workspace: str = "default", ) -> openshell_pb2.Sandbox: sandbox = openshell_pb2.Sandbox() sandbox.metadata.id = id_ sandbox.metadata.name = name + sandbox.metadata.workspace = workspace for key, value in (labels or {}).items(): sandbox.metadata.labels[key] = value sandbox.status.phase = phase @@ -1448,8 +1480,32 @@ class _FakeSandboxStub: def __init__(self, listed: list[openshell_pb2.Sandbox] | None = None) -> None: self.create_request: openshell_pb2.CreateSandboxRequest | None = None self.list_request: openshell_pb2.ListSandboxesRequest | None = None + self.get_request: openshell_pb2.GetSandboxRequest | None = None + self.delete_request: openshell_pb2.DeleteSandboxRequest | None = None self._listed = listed or [] + def GetSandbox( + self, + request: openshell_pb2.GetSandboxRequest, + timeout: float | None = None, + ) -> Any: + self.get_request = request + _ = timeout + return SimpleNamespace( + sandbox=_make_sandbox_proto( + "sandbox-1", request.name, workspace=request.workspace or "default" + ) + ) + + def DeleteSandbox( + self, + request: openshell_pb2.DeleteSandboxRequest, + timeout: float | None = None, + ) -> Any: + self.delete_request = request + _ = timeout + return SimpleNamespace(deleted=True) + def CreateSandbox( self, request: openshell_pb2.CreateSandboxRequest, @@ -1459,7 +1515,10 @@ def CreateSandbox( _ = timeout return SimpleNamespace( sandbox=_make_sandbox_proto( - "sandbox-1", request.name or "generated", dict(request.labels) + "sandbox-1", + request.name or "generated", + dict(request.labels), + workspace=request.workspace or "default", ) ) @@ -1482,18 +1541,27 @@ def __init__(self) -> None: def create_session( self, *, + workspace: str, spec: Any = None, name: str | None = None, labels: Any = None, ) -> Any: - self.create_kwargs = {"spec": spec, "name": name, "labels": labels} + self.create_kwargs = { + "workspace": workspace, + "spec": spec, + "name": name, + "labels": labels, + } return SimpleNamespace(sandbox=SimpleNamespace(name=name or "generated")) - def wait_ready(self, name: str, *, timeout_seconds: float = 300.0) -> SandboxRef: + def wait_ready( + self, name: str, *, workspace: str, timeout_seconds: float = 300.0 + ) -> SandboxRef: _ = timeout_seconds return SandboxRef( id="sandbox-1", name=name, + workspace=workspace, status=SandboxStatusRef(phase=2, current_policy_version=0), ) @@ -1502,7 +1570,9 @@ def test_create_forwards_name_and_labels() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - ref = client.create(name="job-1", labels={"aiq": "deep-research"}) + ref = client.create( + workspace="default", name="job-1", labels={"aiq": "deep-research"} + ) assert stub.create_request is not None assert stub.create_request.name == "job-1" @@ -1514,11 +1584,12 @@ def test_create_without_args_sends_empty_metadata() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.create() + client.create(workspace="default") assert stub.create_request is not None assert stub.create_request.name == "" assert dict(stub.create_request.labels) == {} + assert stub.create_request.workspace == "default" def test_create_copies_caller_labels() -> None: @@ -1526,7 +1597,7 @@ def test_create_copies_caller_labels() -> None: client = _client_with_fake_stub(stub) caller_labels = {"aiq": "deep-research"} - client.create(labels=caller_labels) + client.create(workspace="default", labels=caller_labels) caller_labels["aiq"] = "mutated" assert stub.create_request is not None @@ -1537,7 +1608,9 @@ def test_create_session_forwards_name_and_labels() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - session = client.create_session(name="job-2", labels={"team": "aiq"}) + session = client.create_session( + workspace="default", name="job-2", labels={"team": "aiq"} + ) assert stub.create_request is not None assert stub.create_request.name == "job-2" @@ -1549,17 +1622,18 @@ def test_list_forwards_label_selector() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list(label_selector="aiq=deep-research") + client.list(workspace="default", label_selector="aiq=deep-research") assert stub.list_request is not None assert stub.list_request.label_selector == "aiq=deep-research" + assert stub.list_request.workspace == "default" def test_list_without_selector_sends_empty_string() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list() + client.list(workspace="default") assert stub.list_request is not None assert stub.list_request.label_selector == "" @@ -1569,7 +1643,7 @@ def test_list_ids_forwards_label_selector() -> None: stub = _FakeSandboxStub(listed=[_make_sandbox_proto("sandbox-1", "job-1")]) client = _client_with_fake_stub(stub) - ids = client.list_ids(label_selector="aiq=deep-research") + ids = client.list_ids(workspace="default", label_selector="aiq=deep-research") assert stub.list_request is not None assert stub.list_request.label_selector == "aiq=deep-research" @@ -1598,6 +1672,7 @@ def test_direct_sandbox_ref_construction_defaults_labels() -> None: ref = SandboxRef( id="sandbox-1", name="job-1", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) @@ -1629,6 +1704,7 @@ def test_default_sandbox_ref_labels_support_standard_serialization() -> None: ref = SandboxRef( id="sandbox-1", name="job-1", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) @@ -1642,6 +1718,7 @@ def test_direct_sandbox_ref_copies_and_freezes_labels() -> None: ref = SandboxRef( id="sandbox-1", name="job-1", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), labels=labels, ) @@ -1663,11 +1740,15 @@ def test_high_level_creation_forwards_name_and_labels( ) sandbox = Sandbox( - name="job-1", labels={"aiq": "deep-research"}, delete_on_exit=False + workspace="staging", + name="job-1", + labels={"aiq": "deep-research"}, + delete_on_exit=False, ) sandbox.__enter__() assert recording.create_kwargs == { + "workspace": "staging", "spec": None, "name": "job-1", "labels": {"aiq": "deep-research"}, @@ -1675,7 +1756,7 @@ def test_high_level_creation_forwards_name_and_labels( def test_high_level_attach_rejects_name() -> None: - sandbox = Sandbox(sandbox="existing-sandbox", name="job-1") + sandbox = Sandbox(workspace="default", sandbox="existing-sandbox", name="job-1") with pytest.raises(SandboxError): sandbox.__enter__() @@ -1685,9 +1766,97 @@ def test_high_level_attach_rejects_labels() -> None: ref = SandboxRef( id="sandbox-1", name="existing", + workspace="default", status=SandboxStatusRef(phase=2, current_policy_version=0), ) - sandbox = Sandbox(sandbox=ref, labels={"aiq": "deep-research"}) + sandbox = Sandbox(workspace="default", sandbox=ref, labels={"aiq": "deep-research"}) with pytest.raises(SandboxError): sandbox.__enter__() + + +# --------------------------------------------------------------------------- +# Workspace support +# --------------------------------------------------------------------------- + + +def test_create_passes_workspace_to_proto() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + ref = client.create(workspace="staging", name="job-1") + + assert stub.create_request is not None + assert stub.create_request.workspace == "staging" + assert ref.workspace == "staging" + + +def test_get_passes_workspace_to_proto() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + ref = client.get("job-1", workspace="production") + + assert stub.get_request is not None + assert stub.get_request.workspace == "production" + assert ref.workspace == "production" + + +def test_delete_passes_workspace_to_proto() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + result = client.delete("job-1", workspace="staging") + + assert result is True + assert stub.delete_request is not None + assert stub.delete_request.workspace == "staging" + + +def test_list_without_workspace_sets_all_workspaces() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + client.list() + + assert stub.list_request is not None + assert stub.list_request.all_workspaces is True + assert stub.list_request.workspace == "" + + +def test_list_with_workspace_passes_workspace() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + client.list(workspace="staging") + + assert stub.list_request is not None + assert stub.list_request.workspace == "staging" + assert stub.list_request.all_workspaces is False + + +def test_sandbox_ref_includes_workspace_from_proto() -> None: + proto = _make_sandbox_proto("sandbox-1", "job-1", workspace="production") + + ref = _sandbox_ref(proto) + + assert ref.workspace == "production" + + +def test_sandbox_session_delete_passes_workspace() -> None: + from openshell.sandbox import SandboxSession + + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + ref = SandboxRef( + id="sandbox-1", + name="job-1", + workspace="staging", + status=SandboxStatusRef(phase=2, current_policy_version=0), + ) + session = SandboxSession(client, ref) + + session.delete() + + assert stub.delete_request is not None + assert stub.delete_request.workspace == "staging" diff --git a/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md new file mode 100644 index 0000000000..376804fb85 --- /dev/null +++ b/rfc/0011-multi-player-design/README.md @@ -0,0 +1,1366 @@ +--- +authors: + - "@derekwaynecarr" +state: draft +links: + - https://github.com/NVIDIA/OpenShell/issues/1977 +--- + +# RFC 0011 - Multi-Player Support + +## Summary + +This RFC proposes adding multi-user support to OpenShell. Today, sandboxes and +providers are gateway-global with no ownership tracking or isolation between +users. This proposal introduces workspaces as hard isolation boundaries, an +expanded role model (Platform Admin, Workspace Admin, User), workspace-scoped +access, per-workspace quota enforcement, and audit trail enhancements. The Sandbox Supervisor remains a separate principal type +with sandbox-scoped authentication distinct from the user role model. A +`default` workspace preserves backwards compatibility for single-player +deployments. + +## Motivation + +OpenShell is currently a single-player experience. Every authenticated user sees +every sandbox and every provider. There is no concept of resource ownership, +tenant isolation, or delegated administration. This blocks several adoption +scenarios: + +- **Enterprise teams** cannot share a gateway without seeing each other's + sandboxes, credentials, and activity. There is no way to scope visibility + or enforce per-team resource limits. + +- **CI/CD and agent orchestration** workflows need machine identities with + workspace-scoped access. Today the only option is full-privilege OIDC tokens + or mTLS certs with no role granularity beyond admin/user. Workspaces provide + the scoping boundary for these identities. + +- **Compliance and incident response** teams need audit trails that attribute + every sandbox and control-plane action to a specific principal. The existing + OCSF infrastructure logs sandbox-level events but does not consistently tag + them with the creating principal. + +- **Cost attribution** is impossible without ownership metadata. Operators cannot + answer "which team is consuming how many active sandboxes." + +The existing codebase provides a foundation: OIDC authentication, a principal +model (User/Sandbox/Anon), two-tier RBAC, OCSF event infrastructure, and labels +on `ObjectMeta`. The gap is the isolation, ownership, and governance layer on +top. + +Leaving the current design unchanged limits OpenShell to single-operator, +single-team deployments, which constrains adoption and forces organizations +to run one gateway per team. + +## Non-goals + +- **Cross-gateway federation.** This RFC scopes multi-player to a single gateway. + Multi-gateway federation (e.g., routing users to regional gateways) is a + separate concern. +- **Fine-grained ABAC or policy language.** The role model uses coarse-grained + roles with workspace scoping, not attribute-based access control or a policy + DSL like OPA/Rego for authorization decisions. +- **UI/dashboard for user management.** This RFC covers the API and data model. + Administrative UIs are a follow-on. +- **Billing integration.** Principal attribution on resources enables cost + attribution; integration with billing systems is out of scope. +- **Sandbox-to-sandbox networking isolation.** Network isolation between + workspaces at the container/pod level is out of scope; this RFC addresses + control-plane isolation only. +- **Multi-provider OIDC.** This RFC assumes a single configured OIDC provider. + Supporting multiple OIDC providers (e.g., corporate SSO for humans and + GitHub Actions for CI/CD simultaneously) requires issuer-based token routing, + provider-qualified subject formats in membership records, and authenticator + chain changes. These are valuable extensions but are not required for the + core workspace and role model. A follow-on RFC can add multi-provider support + without changing the workspace or membership abstractions. + +## Proposal + +### System Roles + +The role model expands from the current two-tier (admin/user) to three user +roles: + +| Role | Description | +|------|-------------| +| **Platform Admin** | Runtime role with full visibility across all workspaces. Creates workspaces, assigns Workspace Admins, and sets gateway-wide default policies. | +| **Workspace Admin** | Manages users, providers, policies, and quotas within a single workspace. Cannot change gateway infra or access other workspaces. | +| **User** | Creates sandboxes and accesses all sandboxes within assigned workspaces. Uses credentials available in those workspaces. Default role for OIDC-authenticated principals, both human and machine. | + +### Sandbox Supervisor + +The Sandbox Supervisor is not a user role — it is a separate principal type +with its own authentication and authorization path. User roles are properties +of a `Principal::User` and are resolved via OIDC claims or workspace membership +records. The Sandbox Supervisor authenticates as a `Principal::Sandbox` via a +gateway-minted JWT whose subject is bound to a single sandbox UUID. + +The supervisor is scoped to a single sandbox, analogous to a Kubernetes kubelet +identity. It authenticates via a gateway-minted JWT or bootstrap certificate +and is restricted to RPCs that operate on its own sandbox. Authorization is +enforced by a static method allowlist (`is_sandbox_callable`) at the router +layer and per-handler scope guards (`ensure_sandbox_principal_scope`) that +verify the JWT's sandbox UUID matches the request target. The supervisor never +goes through the RBAC role check or workspace membership lookup. + +The supervisor learns its workspace from the `GetSandboxConfigResponse.workspace` +field returned by its first settings poll. It caches this value and passes it +in subsequent workspace-scoped RPCs (policy sync, policy analysis, draft +policy queries). This avoids server-side special-casing for sandbox principals +while keeping the supervisor's JWT scoped to a single sandbox UUID. + +### Role-to-RPC Access Matrix + +Access is grouped by domain. Within each domain, the access level (none, read, +read-write) applies to all RPCs in that group unless noted otherwise. All user +roles are scoped to their workspace except Platform Admin, which operates +cross-workspace. The Sandbox Supervisor column is included for completeness — +it uses a separate authentication and authorization path (see Sandbox +Supervisor section above). + +| Domain | Platform Admin | Workspace Admin | User | Sandbox Supervisor | +|--------|---------------|-----------------|------|--------------------| +| Workspace lifecycle (`Create`, `Get`, `List`, `Delete`) | read-write | read (own) | read (own) | none | +| Workspace membership (`Add`, `Remove`, `List`) | read-write | read-write (own ws, no admin assign) | none | none | +| Sandbox lifecycle (`Create`, `Get`, `List`, `Delete`) | read-write | read-write (own ws) | read-write (own ws) | read (own sandbox) | +| Sandbox data-plane (`Exec`, `ForwardTcp`, `CreateSshSession`, `RelayStream`) | full | full (own ws) | full (own ws) | none | +| Sandbox observability (`GetSandboxLogs`, `ListSandboxPolicies`, `GetSandboxPolicyStatus`) | read | read (own ws) | read (own ws) | own sandbox | +| Provider management (`Create`, `Get`, `List`, `Update`, `Delete`) | read-write | read-write (own ws) | read (no creds) | none | +| Provider attachment (`Attach`, `Detach`, `ListSandboxProviders`) | read-write | read-write (own ws) | read (own ws) | none | +| Services (`Expose`, `Get`, `List`, `Delete`) | read-write | read-write (own ws) | read-write (own ws) | none | +| Gateway config (`GetGatewayConfig`, `UpdateConfig`) | read-write | none | none | none | +| Policy drafts (`SubmitPolicyAnalysis`, `Approve`, etc.) | read-write | read-write (own ws) | none | none | +| Supervisor path (`ConnectSupervisor`, `IssueSandboxToken`, `RefreshSandboxToken`, `GetSandboxProviderEnvironment`, `PushSandboxLogs`, `ReportPolicyStatus`) | none | none | none | own sandbox | + +**Control-plane audit log.** Every mutating gRPC call emits an OCSF +`ApiActivity` event recording the principal, action, target resource, and +timestamp. These events are emitted through the existing OCSF infrastructure +and exported as structured JSONL for consumption by external systems (see +Audit Trail section below). + +### Workspaces + +A workspace is a first-class resource and a hard isolation boundary. Sandboxes, +providers, and policies within a workspace are invisible to other workspaces. +Every resource belongs to exactly one workspace. A `default` workspace exists +for single-player backwards compatibility. Workspace creation is admin-only: +Platform Admins create workspaces and assign Workspace Admins. Self-service +workspace creation can be added later as a gateway configuration option. + +The `Workspace` resource uses standard `ObjectMeta` (the `workspace` field in +its own `ObjectMeta` is unused, following the same convention as Kubernetes +Namespace objects). Workspace-level configuration — quota limits, policy +overrides, and Workspace Admin role bindings — are properties on the Workspace +resource. The gateway exposes `CreateWorkspace`, `GetWorkspace`, +`ListWorkspaces`, and `DeleteWorkspace` RPCs, gated to Platform Admins. +Sandbox and provider create operations validate that the referenced workspace +exists, rejecting unknown workspace values. + +`DeleteWorkspace` is rejected if the workspace contains any workspace-scoped +resources — sandboxes, providers, services, or inference routes. All resources +must be removed before the workspace can be deleted. Membership records are +cleaned up as part of deletion; if any member record fails to delete, the +workspace deletion is aborted and the error is returned to the caller. The +`default` workspace cannot be deleted. Gateway startup fails if the `default` +workspace cannot be created or verified — this is a fatal error, not a +best-effort operation. + +Workspace membership is capped at 1000 members per workspace. +`AddWorkspaceMember` rejects additions that would exceed this limit with a +resource-exhausted error. This cap bounds the cleanup cost during workspace +deletion and ensures the member listing used for cleanup is exhaustive. + +`ObjectMeta` gains a `workspace` field referencing a Workspace by name. Within +a workspace, organizational grouping (teams, projects, cost centers) uses the +existing label system with well-known key conventions (e.g., +`openshell.dev/team=infra`, `openshell.dev/project=alpha`) rather than +additional dedicated fields. This: + +- Gives a clear security boundary (workspace) without over-modeling + organizational hierarchy. +- Allows multiple overlapping groupings within a workspace via labels. +- Keeps the proto surface minimal: `workspace` is the only new field on + `ObjectMeta`. + +#### Workspace use cases + +- **Credential segmentation within a team.** Each user gets their own workspace + on a shared gateway, keeping their API keys (e.g., per-user Claude or Codex + keys) isolated from other users. This eliminates the need for a separate + gateway per user while preserving credential isolation. + +- **Shared coding sessions.** All workspace members can access any sandbox in + the workspace, enabling pair programming and collaborative debugging without + additional access grants. + +- **CI/CD and automation.** A workspace scopes sandbox lifecycle to a specific + pipeline or project. Machine workloads authenticate via the configured OIDC + provider and are added as workspace members with the appropriate role. + +- **Agent harness integration.** A single OpenShell gateway can be partitioned + into discrete workspaces so that multiple agent harness instances (e.g., + OpenClaw) can procure sandboxes from the same gateway with proper isolation. + This removes the requirement for a one-to-one association between an agent + harness instance and an OpenShell gateway instance. + +### Ownership and Access Control + +Access control is based on workspace membership. Principal attribution (who +created or modified a resource) is handled by the control-plane audit log, not +by fields on the resource itself. + +#### Role assignment + +Roles fall into two categories: + +- **Global roles** (Platform Admin) are assigned externally via OIDC claims in + the identity provider (e.g., an `openshell-platform-admin` role in the JWT). + This role is a property of the principal, not of any workspace, and grants + cross-workspace access. The gateway evaluates it from the authenticated + token at request time. + +- **Workspace-scoped roles** (Workspace Admin, User) are assigned internally + via workspace membership records stored in the gateway's durable object + store. These roles are properties of a principal's relationship to a + specific workspace. + +The authorization layer combines both: the gateway first evaluates global roles +from the JWT, then resolves workspace-scoped roles from membership records for +the target workspace. A request to a workspace-scoped RPC is authorized if the +principal has the Platform Admin global role or a +workspace membership with a sufficient role. + +Workspace membership is managed through three RPCs: + +- `AddWorkspaceMember(workspace, principal_subject, role)` — Platform Admins + can assign any workspace-scoped role. Workspace Admins can add Users to + their own workspace but cannot assign the Workspace Admin role. +- `RemoveWorkspaceMember(workspace, principal_subject)` — same access pattern. +- `ListWorkspaceMembers(workspace)` — Platform Admins can list any workspace; + Workspace Admins can list their own. + +Principal subjects are the OIDC `sub` claim from the configured identity +provider. The gateway does not maintain a user directory — membership +references OIDC subjects that are resolved at authentication time. Membership +records are persisted in the durable object store, indexed by both workspace +and principal subject for efficient lookup. + +A principal's request to any workspace-scoped RPC is rejected if they are not a +member of the target workspace. Platform Admins bypass membership checks — +their role grants cross-workspace access by definition. + +#### Resource-level access + +Within a workspace, access varies by resource type: + +- **Sandboxes.** All workspace members can list, get, exec into, and access any + sandbox in their workspace. Credential isolation happens at the workspace + boundary — within a workspace, all members share the same trust domain and + the same provider credentials, so there is no security benefit to restricting + sandbox access by owner. Platform Admins can list across workspaces using + `all_workspaces = true` on list RPCs (see Cross-Workspace List Operations + below). + +- **Providers.** Users can list and reference providers by name within their + workspace but cannot create, update, or delete them, and cannot see raw + credential material. Workspace Admins manage provider lifecycle within their + workspace. `ListProviders` is scoped to the caller's workspace. + +- **Services.** Services are child resources of sandboxes, keyed by sandbox + name. They carry the parent sandbox's workspace in their `ObjectMeta` for + consistent filtering, but the workspace is always inherited from the sandbox, + never set independently. All workspace members can expose, list, and delete + services on any sandbox in their workspace. + +- **Provider profiles.** Provider profiles are type definitions that describe + what a provider type needs (credentials, endpoints, filesystem paths). + Profiles have two-tier scoping: platform-scoped profiles are managed by + Platform Admins; workspace-scoped profiles are managed by Workspace Admins + and visible only within their workspace. Built-in profiles (claude-code, + github, nvidia, etc.) are included in both scopes for convenience. Each + scope is listed independently — `ListProviderProfiles` returns profiles + from the requested scope (workspace or platform) plus built-ins, not a + merged view across scopes. This keeps the listing unambiguous: users see + exactly which custom profiles exist in their workspace without conflating + them with platform-level profiles. Import, update, and delete operations + target either platform scope or workspace scope explicitly. + +- **Policies.** Users cannot modify policies directly. Sandbox policy is + derived from attached provider profiles (see Policy Scoping below). + Workspace Admins control policy indirectly by managing which providers are + available in the workspace. `ListSandboxPolicies` is scoped to the caller's + workspace. + +#### Sandbox access within a workspace + +All workspace members have full access to all sandboxes in the workspace. There +is no per-sandbox sharing mechanism within a workspace — the workspace boundary +is the access control surface. Cross-workspace sandbox sharing is deferred to +future work (see Future Work section). + +### Policy Scoping + +Sandbox policy is derived from the providers attached to the sandbox. There is +no separate workspace-level policy to author or maintain — the providers a +Workspace Admin makes available in the workspace define what sandboxes in that +workspace can do. + +| Layer | Scope | Set by | Purpose | +|-------|-------|--------|---------| +| Gateway default | All sandboxes | Platform Admin | Enforcement modes (Landlock) and gateway-wide network deny rules | +| Provider profiles | Per sandbox | Workspace Admin (provider lifecycle) / User (provider attachment) | Network endpoints, filesystem paths, environment variables | + +Each provider carries a profile describing the endpoints and filesystem paths +it requires. When a user attaches providers to a sandbox, the gateway computes +the effective policy as the union of the attached provider profiles, layered on +top of the gateway default. The gateway returns a single resolved +`SandboxPolicy` at `GetSandboxConfig` time — the sandbox sees a flat policy, +not the composition. + +Provider types fall into two categories: + +- **Credential providers** (Anthropic, OpenAI, GitHub) — inject secrets and add + the provider's network endpoints to the sandbox policy. +- **Endpoint providers** — add network endpoints only, no secrets. Used for + internal services (Git servers, artifact registries, custom APIs) that + sandboxes need to reach but that don't require credential injection. + +Both types use the same provider abstraction. The Workspace Admin's policy +decision reduces to: "which providers does this workspace have?" The User's +sandbox-level policy decision is: "which of those providers does my sandbox +use?" + +The gateway default (enforcement modes, deny rules) remains the floor and +cannot be overridden by provider profiles. Deny rules at the gateway level +override provider profile allows. + +**Migration.** Existing global policies map to the gateway default. Existing +per-sandbox policies continue to work through the policy advisor flow, which +generates policy from attached providers. The `restrictive_default_policy()` +fallback applies when no providers are attached — identical to current behavior. + +### Authorization Enforcement + +The gateway's existing authorization pattern — compile-time per-method +metadata, middleware authentication, and per-handler guards — extends to +workspace-scoped enforcement without architectural changes. + +**Proto-driven method metadata.** Authorization rules are declared as custom +options on each proto RPC method, making the proto definition the single +source of truth for the API contract and its access control: + +```proto +import "google/protobuf/descriptor.proto"; + +message AuthorizationRule { + string auth_mode = 1; // "bearer", "sandbox", "dual", "unauthenticated" + string workspace_role = 2; // "user", "admin" + string global_role = 3; // "platform_admin" +} + +extend google.protobuf.MethodOptions { + AuthorizationRule authorization = 50000; +} +``` + +Each RPC carries its authorization requirement: + +```proto +service OpenShell { + rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse) { + option (authorization) = { auth_mode: "bearer", workspace_role: "user" }; + } + rpc CreateProvider(CreateProviderRequest) returns (CreateProviderResponse) { + option (authorization) = { auth_mode: "bearer", workspace_role: "admin" }; + } + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse) { + option (authorization) = { auth_mode: "bearer", global_role: "platform_admin" }; + } + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage) { + option (authorization) = { auth_mode: "sandbox" }; + } +} +``` + +The gateway already compiles a `FileDescriptorSet` at build time and embeds +it in the binary (`openshell_core::FILE_DESCRIPTOR_SET`). Adding +`prost_reflect::DescriptorPool` allows the runtime to resolve custom +extensions natively — no build.rs code generation, no external tooling: + +```rust +static DESCRIPTOR_POOL: LazyLock = LazyLock::new(|| { + DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("decode descriptor pool") +}); +``` + +At startup the middleware walks the pool's methods, reads the +`(authorization)` extension from each `MethodDescriptor::options()`, and +builds the lookup table keyed by gRPC method path. This replaces the current +`#[rpc_authz]` proc macro and per-service `AUTH_METADATA` tables — the proto +definition becomes the single source of truth for both the API contract and +its access control. The middleware calls the same `method_authz::lookup()` +function at request dispatch time; only the source of the table changes. + +The existing exhaustiveness tests switch from `prost_types::FileDescriptorSet` +to `DescriptorPool` and assert that every method in the pool carries a valid +`(authorization)` option, catching missing annotations at `cargo test` time. + +This follows the pattern established by `google.api.http` annotations for +REST gateway generation: the proto carries the metadata, the descriptor pool +resolves it, and the runtime consumes it directly. + +**Workspace on every scoped request.** Since resource names are +unique-within-workspace, every workspace-scoped RPC includes the workspace in +its request message. A `WorkspaceScoped` trait implemented on each request type +provides uniform access: + + trait WorkspaceScoped { + fn workspace(&self) -> &str; + } + +**Single authorization path.** A shared `authorize_workspace` function replaces +per-handler authorization boilerplate. It extracts the principal from request +extensions, checks for Platform Admin global role bypass, resolves +workspace membership from the durable store, and verifies the membership role +meets the method's declared minimum: + + let principal = authorize_workspace( + &request, WorkspaceRole::User, &self.membership, + )?; + +Every workspace-scoped handler uses this one-line call. The middleware layer +is unchanged: it authenticates the caller, inserts the principal into request +extensions, and the handler resolves workspace authorization. + +### Authorization Boundaries (Kubernetes Deployments) + +In Kubernetes deployments, authorization operates at two layers. Kubernetes +RBAC governs control-plane operations: who can create, delete, or manage +sandbox custom resources and other objects in a Kubernetes namespace. The +gateway's role model governs data-plane operations: who can exec into a +running sandbox, stream relay output, or view audit logs. +These are runtime authorization decisions through the gateway's gRPC endpoints +where Kubernetes RBAC has no reach. + +Both layers are needed in Kubernetes deployments with clear boundaries between +them. For non-Kubernetes drivers (Docker, Podman, VM), the gateway's role model +is the sole authorization mechanism. + +### Provider Credential Scoping + +Providers belong to a workspace. A User can only attach providers available in +their workspace when creating a sandbox. No role sees raw credential material +through the API; all roles reference providers by +name. The sandbox supervisor resolves credentials at runtime through an internal +trusted path, not through role-level permissions. + +### Authentication + +The gateway authenticates principals via its existing OIDC provider. The +workspace and role model does not change the authentication mechanism — it +layers authorization on top of the authenticated identity. + +When OIDC is configured, the gateway validates the Bearer token, extracts the +`sub` claim as the principal subject, and evaluates global roles from JWT +claims (e.g., Platform Admin). Workspace-scoped roles are resolved from +membership records keyed by the principal's `sub` claim. + +When OIDC is not configured, every request is treated as a Platform Admin +principal. The full workspace model remains available — workspaces can be +created, members added, resources scoped — but no authentication or +authorization checks are enforced. This preserves the current single-player +experience and allows operators to adopt workspaces for organizational +structure before enabling authentication. + +### Audit Trail + +Multi-player introduces multiple principals acting on shared infrastructure. +The audit trail must attribute every action to a specific principal so that +security teams, compliance reviewers, and operators can answer "who did what, +when, and in which workspace" without needing a gateway role to do so. + +OpenShell already has an OCSF event infrastructure with two output layers: +a shorthand formatter for human-readable logs and a JSONL formatter for +structured machine consumption. Multi-player extends this infrastructure +with principal and workspace attribution on every event. + +**Control-plane events.** Every mutating gRPC call (`CreateSandbox`, +`DeleteSandbox`, `CreateProvider`, `UpdatePolicy`, `AddWorkspaceMember`) +emits an OCSF event with the authenticated principal's subject, the target +workspace, the action, the target resource, and a timestamp. `ApiActivity` +is a new OCSF event class that must be added to the `openshell-ocsf` crate. +`ConfigStateChange` covers policy and configuration mutations. These events +are emitted through the existing OCSF infrastructure — no separate audit +storage or query API is required. + +**Sandbox-level events.** Sandbox activity (network decisions, process +lifecycle, SSH sessions) is already emitted as OCSF events by the sandbox +supervisor. Multi-player adds the creating principal's subject to these +events so security teams can trace sandbox behavior back to the human or +machine principal that created it. + +**Log forwarding.** The gateway writes OCSF events as structured JSONL to +a configurable output (file, stdout). Operators forward this JSONL to their +SIEM or log aggregation system (Splunk, Elastic, Datadog, CloudWatch) using +standard log shipping tools (Fluentd, Vector, Filebeat). The JSONL format +follows OCSF v1.7.0 schema conventions, making it directly ingestible by +SIEM platforms that support OCSF. + +This model follows the Kubernetes pattern: the API server writes audit events +to a log backend, and external tooling handles aggregation, retention, and +querying. Audit consumers do not need a gateway role — they access audit data +through their organization's log infrastructure. Queries like "who created +sandbox X" or "what did user Y do between T1 and T2" are answered in the SIEM, +not through a gateway API. + +### Resource Governance + +- **Per-workspace quotas.** Max concurrent sandboxes, max GPU allocations, max + sandbox lifetime per workspace. Enforced at the gateway before sandbox + creation. Quota limits are hard — sandbox creation is rejected when a quota + is exceeded. Quotas are framed as DoS and abuse protection for the control + and data plane, not as a chargeback mechanism. Quota limits are properties on the + workspace data model; detailed schema design is deferred to implementation. + +### Kubernetes Compute Driver: Workspace Mapping + +OpenShell workspaces are a gateway-level concept. The gateway populates the +workspace on each `DriverSandbox` passed to the compute driver. Drivers consume +the workspace to map it to the appropriate infrastructure-level isolation (K8s +namespace, Docker label, etc.) but do not define or manage workspaces +themselves. When the Kubernetes compute driver renders sandboxes onto a cluster, +it must map each OpenShell workspace to a Kubernetes namespace. The driver +supports two modes, configured per deployment: + +**Managed mode** (default) — the driver creates and deletes Kubernetes +namespaces on demand. The Kubernetes namespace name is derived from the gateway +identifier and the OpenShell workspace: +`openshell-{gateway-id}-{workspace-name}`. For example, if the gateway +identifier is `prod` and the OpenShell workspace is `team-ml`, the Kubernetes +namespace is `openshell-prod-team-ml`. + +The gateway identifier prefix ensures that multiple gateways can operate on a +common Kubernetes cluster without namespace collisions. Each gateway owns its +own set of Kubernetes namespaces and can independently create, watch, and delete +them. The gateway identifier is already part of the gateway's bootstrap +configuration. + +When an OpenShell workspace is deleted and all sandboxes have been removed, the +driver deletes the corresponding Kubernetes namespace. + +Managed mode requires a `ClusterRole` with namespace create/delete permissions. +The Helm chart includes conditional `ClusterRole` and `ClusterRoleBinding` +templates that are enabled by default. Workspace names must be validated at +creation time to ensure the resulting Kubernetes namespace name is DNS-1123 +compliant (lowercase alphanumeric and hyphens, max 63 characters total +including the `openshell-{gateway-id}-` prefix). The gateway rejects workspace +names that would produce invalid or colliding Kubernetes namespace names. + +**Operator mode** — an alternative for environments where the gateway should not +create Kubernetes namespaces. The OpenShell workspace name maps one-to-one to a +Kubernetes namespace of the same name. If a sandbox belongs to OpenShell +workspace `team-ml`, the driver renders it into the Kubernetes namespace +`team-ml`. No mapping configuration is required. The Kubernetes namespaces must +be pre-provisioned — the driver has no permission to create or delete them. +If the target Kubernetes namespace does not exist, the driver lets the +Kubernetes API reject the request and surfaces the error — no pre-validation, +which avoids TOCTOU races. + +This direct identity mapping enables the OpenShell gateway to operate as a +natural Kubernetes-style operator: it receives a desired state (sandbox in +workspace X) and renders it into the corresponding cluster namespace. Platform +teams manage Kubernetes namespaces through their existing tooling (kubectl, +GitOps, Terraform) and OpenShell follows. + +```toml +[openshell.drivers.kubernetes] +workspace_mode = "operator" # opt-in; default is "managed" +``` + +**Watcher strategy.** Today the Kubernetes driver watches a single Kubernetes +namespace via `Api::namespaced_with()`. With multiple workspaces, the driver +shifts to a cluster-wide list/watch filtered by OpenShell labels (e.g., +`openshell.dev/managed-by=gateway`). This follows the standard Kubernetes +operator pattern for multi-namespace controllers. A per-namespace watcher +approach does not scale — it requires O(n) API connections and complicates +dynamic workspace addition/removal. The cluster-wide watch requires a +`ClusterRole` granting list/watch across Kubernetes namespaces (applicable to +both operator and managed modes). + +#### Shared-namespace resource naming + +Before namespace-per-workspace support lands, the Kubernetes driver operates +in shared-namespace mode: all workspaces render sandboxes into the same +Kubernetes namespace. The gateway populates `DriverSandbox.workspace` on +every sandbox dispatched to the compute driver. The driver uses this field to +construct collision-safe Kubernetes resource names and to label and annotate +the resulting objects. + +**Resource names.** Kubernetes resource names follow the format +`{workspace}--{name}`. Two sandboxes named `work` in workspaces `alpha` and +`beta` produce distinct resources `alpha--work` and `beta--work`. The +workspace prefix is always present — there is no bare-name fallback. + +**Labels.** Each sandbox object carries labels for selector-based lookup: + +| Label | Value | Purpose | +|-------|-------|---------| +| `openshell.ai/sandbox-id` | sandbox UUID | Get/delete by ID | +| `openshell.ai/sandbox-name` | bare sandbox name | Lookup by (workspace, name) tuple | +| `openshell.ai/sandbox-workspace` | workspace name | Lookup by (workspace, name) tuple | +| `openshell.ai/managed-by` | `openshell-gateway` | Scope cluster-wide watches | + +**Annotations.** Annotations store authoritative sandbox metadata for +reconstructing `DriverSandbox` from a Kubernetes object. The annotation keys +mirror the label keys (`openshell.ai/sandbox-id`, `openshell.ai/sandbox-name`, +`openshell.ai/sandbox-workspace`). On read, the driver checks annotations +first, then falls back to labels for backwards compatibility with objects +created before annotations were added. If neither source provides a workspace, +the driver returns an error — it never falls back to an empty workspace. + +**Label-based lookup.** Get and delete operations use label selectors instead +of Kubernetes resource name lookup. `get_sandbox(sandbox_id)` lists by +`openshell.ai/sandbox-id={uuid}`. `delete_sandbox(sandbox_id)` lists by the +same label to discover the Kubernetes resource name, then issues the delete. +This decouples the API contract (sandbox UUID) from the Kubernetes resource +name (which encodes the workspace). + +When namespace-per-workspace mode is implemented (Phase 3–4), the driver will +use the workspace to select the target Kubernetes namespace instead of encoding +it in the resource name. The label-based lookup and annotation patterns +established here carry over unchanged. + +**Docker and Podman drivers.** The Docker driver's `sandbox_namespace` label +provides a foundation for workspace mapping, but the driver currently uses a +single configured namespace rather than per-sandbox values. The driver contract +must be updated so that workspace flows through `DriverSandbox` and the driver +applies it as the container label filter. The same applies to Podman and other +local drivers — workspace isolation is enforced at the gateway level and does +not require Kubernetes. + +### Compute Driver Trust Model + +The `ComputeDriver` gRPC service is a gateway-internal contract between the +gateway and its compute backend. Drivers can be in-process (Docker, Podman) or +out-of-process (VM driver subprocess, remote Kubernetes driver). The trust model +for this channel is: + +**The driver is a trusted backend.** The gateway is the sole caller of the +driver service. The driver does not enforce workspace isolation — it operates on +`DriverSandbox` messages keyed by `(id, name, namespace)` and has no concept of +workspaces. Workspace is a gateway-level tenancy boundary that the gateway +resolves before dispatching to the driver. The driver trusts the gateway to have +already authenticated the user, authorized the operation, and resolved the +workspace-to-namespace mapping. + +**Gateway-to-driver authentication.** For in-process drivers, no authentication +is needed — the driver runs in the gateway process. For out-of-process drivers, +the gateway authenticates to the driver via mTLS or a shared credential +configured at deployment time. This is a service-to-service trust boundary, not +a user-facing one. The `RemoteComputeDriver` connects over a gRPC channel; the +channel's transport security governs the trust. + +**Multi-gateway deployments.** When multiple gateways share a compute backend +(e.g., a shared Kubernetes cluster), each gateway independently manages its own +workspace set and namespace mappings. The driver has no mechanism to enforce +cross-gateway workspace isolation — it sees containers/pods from all gateways +indiscriminately. Isolation between gateways relies on the deterministic +namespace naming convention (`openshell-{gateway-id}-{workspace-name}`) ensuring +non-overlapping Kubernetes namespaces, and on OpenShell labels that scope +list/watch results to a specific gateway's resources. + +**The driver does not need workspace-scoped RPCs.** The `ComputeDriver` service +contract remains workspace-unaware. `CreateSandbox` receives a `DriverSandbox` +with the resolved Kubernetes namespace (or Docker label). `ListSandboxes` returns +all platform-observed sandboxes — the gateway correlates them back to workspaces. +Adding workspace awareness to the driver contract would violate the separation +between the gateway's tenancy model and the driver's infrastructure model. + +### Cross-Workspace Infrastructure Operations + +Several gateway-internal operations must query workspace-scoped resources across +all workspaces. These operations run as the gateway process itself — they are +not user-initiated gRPC calls and do not go through the authentication or +workspace authorization path. The gateway is the actor. + +**Affected operations:** + +- **Sandbox reconciliation** (`reconcile_store_with_backend`). The reconciler + periodically compares all stored sandbox records against the compute driver's + inventory to detect orphans (store records with no driver-side resource) and + ghost resources (driver resources with no store record). The driver's + `ListSandboxes` returns all platform-observed sandboxes regardless of + workspace — the driver has no workspace concept. The gateway must query all + stored sandboxes across all workspaces to produce the full set for comparison. + +- **Startup resume** (`resume_persisted_sandboxes`). On gateway startup, the + resume path iterates all stored sandboxes whose phase indicates they should + be running and asks the driver to resume each one. This must cover all + workspaces. + +- **Provider credential refresh** (`refresh_provider_credential`). A background + worker iterates `StoredProviderCredentialRefreshState` records to refresh + expiring credentials. These refresh state records are globally scoped (not + workspace-scoped), but the worker must look up the corresponding `Provider` + resource, which is workspace-scoped. With multiple workspaces, the same + provider name can exist in different workspaces, so the refresh state must + carry the provider's workspace to resolve the correct one unambiguously. + +**Store-level cross-workspace query.** The persistence layer gains a +`list_by_type(object_type, limit, offset)` method that omits the workspace +filter. This is distinct from the workspace-scoped `list(object_type, workspace, +limit, offset)` used by gRPC handlers. The cross-workspace query is used only +by internal infrastructure operations — it is not exposed through any gRPC RPC +directly. + +**Workspace resolution on driver watch events.** When the driver reports a +sandbox that does not exist in the store (a watch event for a sandbox the +gateway has no record of), the gateway creates a new store record. The workspace +for this record is resolved by reverse-mapping the `DriverSandbox.namespace` +field through the workspace-to-namespace configuration: + +- **Managed mode**: the gateway parses the Kubernetes namespace name + (`openshell-{gateway-id}-{workspace-name}`) to extract the workspace. +- **Operator mode**: the gateway looks up which workspace maps to the reported + Kubernetes namespace via the identity mapping. +- **Docker/Podman**: the gateway reads the workspace from the container label. + +If no mapping matches, the sandbox is assigned to the `default` workspace. This +is a best-effort fallback for cases like manually created containers or +resources from a prior gateway configuration. + +### Cross-Workspace List Operations + +Platform Admins need the ability to list resources across all workspaces, +analogous to `kubectl get pods --all-namespaces`. This is an explicit opt-in +on list RPCs, not the default behavior. + +**RPC mechanism.** Workspace-scoped list RPCs (`ListSandboxes`, +`ListProviders`, `ListServices`) gain an `all_workspaces` boolean field. When +`all_workspaces = true`, the handler bypasses workspace scoping and returns +results from all workspaces. The caller must have the Platform Admin global role; +workspace-scoped roles cannot set `all_workspaces`. Results include the +`workspace` field in each resource's `ObjectMeta` so the caller can distinguish +provenance. + +This is distinct from passing an empty workspace string. Empty workspace is +resolved to `"default"` by the gateway's `resolve_workspace()` logic for +backwards compatibility — it does not mean "all workspaces." + +**Store query.** The `all_workspaces` handler path uses the same +`list_by_type(object_type, limit, offset)` store method as the internal +infrastructure operations. The authorization gate (Platform Admin check) is +enforced at the gRPC handler level, not at the store level — the store method +itself is access-control-unaware. + +**CLI surface.** The `--all-workspaces` flag is available on list commands for +Platform Admins: + +```shell +openshell sandbox list --all-workspaces +openshell provider list --all-workspaces +openshell service list --all-workspaces +``` + +### Enterprise Deployment: Multi-Consumer Gateway + +A common enterprise deployment pattern — particularly in regulated industries +like financial services and defense — involves one or two data centers, each +running a handful of Kubernetes clusters. In this environment, organizations +want to minimize the number of OpenShell gateways they need to reason about. +Workspaces enable a single gateway per compute region to serve multiple +independent sandbox consumers with proper isolation between them. + +**Multiple consumers, one gateway.** In a single enterprise OpenShell +deployment, many independent consumers procure sandboxes on demand — agent +harnesses, CI/CD pipelines, internal tooling, and interactive users. Using +OpenClaw as an example agent-harness consumer that needs to procure sandboxes +on demand, an OpenClaw instance adds a single `workspace` field to its +OpenShell plugin config: + +```json5 +// OpenClaw plugin config — workspace scopes all sandbox operations +{ + plugins: { + entries: { + openshell: { + enabled: true, + config: { + from: "openclaw", + mode: "remote", + gateway: "prod", + gatewayEndpoint: "https://openshell.internal:8443", + workspace: "team-capital-markets", + }, + }, + }, + }, +} +``` + +The plugin passes `--workspace` to every `openshell` CLI invocation (`sandbox +get`, `sandbox create`, `sandbox list`). The rest of the OpenClaw integration +— sandbox lifecycle, SSH transport, workspace sync — is unchanged. + +OpenClaw sandboxes for tool execution and agent sessions are provisioned within +the assigned workspace. Other consumers — a different OpenClaw instance for +another department, a separate agent harness, or a CI pipeline — each operate +in their own workspace on the same gateway. Sandbox list operations are +workspace-scoped: a consumer sees only its own sandboxes, never sandboxes +belonging to other consumers. + +This is not OpenShell absorbing multiplayer concerns from its consumers. +OpenClaw and other agent harnesses own their own multi-user models. The +requirement on the OpenShell side is narrower: a single gateway must support +1:N partitioning so that each consumer's sandboxes are properly isolated from +every other consumer's sandboxes, without requiring a dedicated gateway per +consumer. + +**Kubernetes-level isolation chain.** When the gateway renders sandboxes onto +the cluster, each workspace maps to a discrete Kubernetes namespace. This +enables the Kubernetes isolation stack when the cluster is configured for it: + +- **Network policy** can partition traffic between Kubernetes namespaces, + preventing cross-workspace network access between sandboxes (requires a CNI + that enforces NetworkPolicy). +- **UID/GID range allocation** (as enforced on platforms like OpenShift) assigns + each Kubernetes namespace a unique UID/GID range. Every sandbox process runs + under a UID that is unique to its workspace's namespace. +- **SELinux labeling** (on OpenShift and similarly configured platforms) assigns + each Kubernetes namespace a unique SELinux label (MCS category). Kernel-level + mandatory access control constrains processes to their namespace's domain. + +These are platform prerequisites, not features provisioned by OpenShell. The +workspace-to-namespace mapping provides the structure; the cluster must be +configured to enforce isolation at each layer. + +**Sandbox escape threat model.** Container breakout is the dominant concern in +regulated environments. The workspace-to-Kubernetes-namespace mapping means +that even in the event of a sandbox escape, the attacker's process carries the +UID/GID and SELinux label of the originating workspace's Kubernetes namespace. +On platforms that enforce these boundaries at the node level, a compromised +process is constrained by: + +- Kubernetes RBAC and secrets scoped to the originating namespace. +- UID/GID range enforcement preventing access to other namespaces' resources. +- SELinux MCS labels preventing cross-namespace process and file access. + +The combination of gateway-level workspace isolation (control plane) and +Kubernetes namespace isolation (data plane) produces defense in depth: even if +one layer is compromised, the other constrains the blast radius to a single +workspace. + +### CLI Surface + +All sandbox and provider commands accept an optional `--workspace` flag that +scopes operations to a specific workspace. When omitted, the CLI defaults to +the `default` workspace, preserving the single-player experience. + +The workspace can also be set via the `OPENSHELL_WORKSPACE` environment +variable. The explicit `--workspace` flag takes precedence over the environment +variable. + +```shell +openshell sandbox create --workspace team-ml --name my-sandbox +openshell sandbox list --workspace team-ml +openshell provider list --workspace team-ml +``` + +#### Cross-workspace listing + +Platform Admins can list resources across all workspaces using the +`--all-workspaces` flag. This is mutually exclusive with `--workspace`. Output +includes the workspace in each row so the admin can distinguish provenance. + +```shell +openshell sandbox list --all-workspaces +openshell provider list --all-workspaces +openshell service list --all-workspaces +``` + +#### Provider profile scope flag + +Provider profiles use two-tier scoping (see Resource-level access above). +Because `--workspace` defaults to the `default` workspace, a separate +`--global` flag distinguishes platform-scoped operations from workspace-scoped +ones. The two flags are mutually exclusive. + +| Flag | Scope | Who | Behavior | +|------|-------|-----|----------| +| *(neither)* | Workspace (`default`) | Workspace Admin | Operates on workspace-scoped profiles; list returns workspace custom + built-in | +| `--workspace team-ml` | Workspace (`team-ml`) | Workspace Admin | Same, targeting a specific workspace | +| `--global` | Platform | Platform Admin | Operates on platform-scoped profiles; list returns platform custom + built-in | + +```shell +# Platform Admin imports an org-wide custom profile (platform-scoped) +openshell provider profile import --global -f internal-gitlab.yaml + +# Workspace Admin imports a team-specific profile (workspace-scoped) +openshell provider profile import --workspace team-ml -f team-registry.yaml + +# Workspace custom + built-in (does not include platform-scoped) +openshell provider profile list --workspace team-ml + +# Platform custom + built-in (does not include workspace-scoped) +openshell provider profile list --global +``` + +### Python SDK Surface + +The Python SDK requires `workspace` as an explicit keyword-only argument on +all workspace-scoped operations (`create()`, `get()`, `delete()`, `list()`, +etc.). Unlike the CLI, the SDK does not default to `"default"` — programmatic +callers must always specify the target workspace. This is a deliberate design +choice: agents and automation scripts should be explicit about which workspace +they operate on, and a silent default could mask workspace-routing bugs. +Passing `workspace=None` to `list()` uses `all_workspaces=True` for +cross-workspace queries. + +## Implementation plan + +The implementation builds on the existing authentication, RBAC, and OCSF +foundations. The work can be phased to deliver value incrementally: + +- **Phase 1: Workspace and membership model.** Add the `Workspace` resource + with standard `ObjectMeta` and `CreateWorkspace`, `GetWorkspace`, + `ListWorkspaces`, `DeleteWorkspace` RPCs gated to Platform Admins. Add + `workspace` field to `ObjectMeta` for Sandbox and Provider resources, + validated against existing workspaces on create. All workspace-scoped + resources inherit workspace from their parent sandbox or workspace context: + services, SSH sessions, policy revisions, policy drafts, settings, provider + refresh state, inference routes, audit records, and log/watch streams. + Provider profiles use two-tier scoping: platform-scoped profiles are managed + by Platform Admins; workspace-scoped profiles are managed by Workspace Admins + and visible only within their workspace. Each scope is listed independently + — `ListProviderProfiles` returns profiles from the requested scope plus + built-ins, not a merged view across scopes. Built-in profiles are included + in both scopes for convenience. Add `UpdateProviderProfiles` RPC for + in-place updates to existing custom profiles, with optimistic concurrency + control via a `resource_version` field on `ProviderProfile` — updates must + supply the current version to prevent stale overwrites. + The `Provider` datamodel message gains a + `profile_workspace` field that binds a provider to a specific workspace's + profile scope — when set, the provider resolves profiles from that workspace + rather than the platform scope, enabling workspace-scoped provider + configuration without duplicating the provider record itself. + Implement workspace-scoped + storage and filtering in gRPC handlers. Add the membership store with `(workspace, principal_subject) → + role` records and `AddWorkspaceMember`, `RemoveWorkspaceMember`, + `ListWorkspaceMembers` RPCs. Create the `default` workspace on gateway + startup for backwards compatibility. Sandbox name uniqueness shifts from + globally unique to unique-within-workspace. The current global uniqueness + constraint `(object_type, name)` shifts to `(object_type, workspace, name)`. + Existing resources are backfilled to the `default` workspace during + migration. Service endpoint hostnames always include workspace + (`{workspace}--{sandbox}--{service}.{base-domain}`), including the `default` + workspace. Always including the workspace eliminates hostname parsing + ambiguity — with a variable number of `--`-delimited segments, the parser + cannot distinguish `{sandbox}--{service}` from `{workspace}--{sandbox}` + without knowing whether the default workspace was omitted. The consistent + three-segment format makes parsing unambiguous: two segments is + `{workspace}--{sandbox}`, three is `{workspace}--{sandbox}--{service}`. + This is a breaking change: existing two-segment hostnames + (`{sandbox}--{service}.{domain}`) will fail to parse after the upgrade. + Backward compatibility is desirable but not a hard requirement + at this stage — existing users must recreate service endpoints when upgrading. + Add a cross-workspace `list_by_type(object_type, limit, offset)` store method + for internal infrastructure operations (reconciler, resume, provider refresh) + that need to query workspace-scoped resources across all workspaces. Thread + workspace through `StoredProviderCredentialRefreshState` so the provider + refresh worker can unambiguously resolve workspace-scoped providers — with + multiple workspaces, `provider_name` alone is insufficient because different + workspaces can have same-named providers. Add `all_workspaces` field to + workspace-scoped list RPCs for Platform Admin cross-workspace visibility. + Add `ObjectWorkspace::requires_workspace()` trait method and validation in + store write helpers (`put_message`, `put_scoped_message`) that returns an + error when a workspace-scoped resource is persisted with an empty workspace. + Rename inference RPCs from `SetClusterInference`/`GetClusterInference` to + `SetInferenceRoute`/`GetInferenceRoute` (and `ClusterInferenceConfig` to + `InferenceRouteConfig`) to reflect that inference routes are now + workspace-scoped rather than cluster-global. + **Additional breaking changes:** The Docker/Podman container label key + changes from `openshell.sandbox_namespace` to `openshell.workspace` — + existing containers will not be discovered after the upgrade; delete all + sandboxes before upgrading. SSH config host aliases change from + `{sandbox}.openshell` to `{workspace}/{sandbox}.openshell` — existing + SSH configs must be regenerated after the upgrade. + +- **Phase 2: Expanded role model and authorization enforcement.** Extend the + RBAC system from two-tier (admin/user) to three user roles (Platform Admin, + Workspace Admin, User). Add proto-driven authorization metadata via custom + method options and `prost_reflect::DescriptorPool`. Implement + `authorize_workspace()` and `WorkspaceScoped` trait for workspace-scoped + access guards in gRPC handlers. Replace the `#[rpc_authz]` proc macro with + descriptor pool-based lookup. Add Workspace Admin role with per-workspace + management capabilities. + +- **Phase 3: Kubernetes driver — managed mode (default).** The driver creates + Kubernetes namespaces on demand using the naming convention + `openshell-{gateway-id}-{workspace-name}`. The watcher shifts from + single-namespace `Api::namespaced_with()` to cluster-wide list/watch with + OpenShell label filtering. Once all sandboxes in a workspace are deleted, + the driver deletes the corresponding Kubernetes namespace. Helm chart adds + `ClusterRole` and `ClusterRoleBinding` for namespace create/delete and + multi-namespace list/watch permissions (enabled by default). Includes + idempotent create with retry to handle races. The reconciler and watch + event handler use the cross-workspace `list_by_type` store query (from + Phase 1) to compare driver inventory against all stored sandboxes. When + the driver reports a sandbox not in the store, the gateway resolves its + workspace by reverse-mapping `DriverSandbox.namespace` through the + workspace-to-namespace configuration (parsing the managed-mode naming + convention or looking up the operator-mode identity mapping). + +- **Phase 4: Kubernetes driver — operator mode.** Alternative mode where the + OpenShell workspace name maps one-to-one to a pre-existing Kubernetes + namespace. The driver accepts per-sandbox workspaces from the gateway + (populated via `driver_sandbox_from_public()`) and renders sandboxes into the + corresponding Kubernetes namespace. No namespace create/delete permissions + required. Opt-in via `workspace_mode = "operator"` in the driver config. + Shares the cluster-wide watcher infrastructure from Phase 3. + +- **Phase 5: Audit trail enhancements.** Add `ApiActivity` OCSF event type for + control-plane mutations. Tag all sandbox activity events with the + authenticated principal's subject and workspace. Extend OCSF JSONL export + with principal and workspace attribution fields. + +- **Phase 6: Quota enforcement.** Implement per-workspace quota checks at the + gateway. Add quota configuration surface for Platform Admins and Workspace + Admins. Quota limits are stored as workspace properties and usage counters + are tracked in the existing durable object store. + +Phases 1 and 2 are sequential prerequisites — Phase 2 depends on Phase 1. +Phase 4 depends on Phase 3 (shared watcher infrastructure). Phases 3, 5, and 6 +can be reordered relative to each other based on priority. Phases 5 and 6 +depend only on Phase 1. + +## Risks + +- **Migration complexity.** Existing deployments have no workspace concept. The + `default` workspace provides backwards compatibility, but platform teams with + established workflows may need to re-organize resources when adopting + workspaces. Migration tooling and documentation will be needed. + +- **Proto surface growth.** Adding `workspace` and role-related fields to the + proto increases the API surface that must be maintained across versions. The + design intentionally keeps new proto fields minimal (`workspace` on + `ObjectMeta`) and uses labels for soft grouping to limit this. + +- **RBAC complexity.** Three roles with workspace scoping is significantly more + complex than the current two-tier model. Misconfiguration could lead to + privilege escalation or overly restrictive access. Clear defaults, validation, + and documentation are essential. + +- **Performance at scale.** Workspace-scoped filtering and quota enforcement add + per-request overhead. For deployments with many workspaces and users, the + filtering and quota checks must be efficient. Indexing strategies need + consideration during implementation. + +- **Quota enforcement races.** Concurrent sandbox creation within a workspace + could race against quota limits. The quota check and sandbox creation must be + atomic or use optimistic concurrency control with retry. + +- **Kubernetes ClusterRole requirements.** Both operator and managed modes require + a `ClusterRole` for cluster-wide list/watch. Managed mode additionally + requires namespace create/delete permissions. Some clusters restrict these + grants. The Helm chart must make these conditional and clearly documented. + +- **Managed mode race conditions.** Kubernetes namespace creation is async. + Sandbox creation may race against it. The naming convention + (`openshell-{gateway-id}-{workspace-name}`) is deterministic, so concurrent + creates from the same gateway are idempotent. + +- **In-flight sandboxes during workspace deletion.** Workspace deletion is + rejected if active sandboxes exist. Once all sandboxes are removed, the + driver deletes the corresponding Kubernetes namespace. + +- **Multi-gateway coordination.** The `openshell-{gateway-id}-{workspace-name}` + naming convention partitions Kubernetes namespaces by gateway, so multiple + gateways can share a cluster without collisions. However, this means each + gateway manages its own workspace set independently — cross-gateway workspace + visibility requires external coordination. + +- **Cross-workspace store query authorization.** The `list_by_type` store method + has no access-control gate — it is a persistence-layer primitive. Authorization + for cross-workspace queries is enforced at the gRPC handler level (Platform + Admin check for `all_workspaces` on list RPCs) and by code-level access + control for internal operations (only the reconciler, resume, and refresh + worker call it). This relies on internal code discipline rather than an + enforced store-level boundary. A future extension could add a store-level + caller identity parameter if defense-in-depth is desired. + +- **Remote compute driver channel security.** The `RemoteComputeDriver` gRPC + channel does not currently enforce authentication. For deployments where the + driver runs as a separate service (rather than a co-located subprocess), the + channel should use mTLS or a shared credential. Without transport security, + any network-adjacent process could impersonate the gateway to the driver. + +## Future Work + +### Cross-workspace sandbox sharing + +This design treats the workspace as the access control boundary — all workspace +members have full access to all sandboxes in the workspace, and there is no +per-sandbox sharing mechanism within a workspace. + +A future extension could allow sharing a sandbox with a principal who is not a +member of the workspace. The motivating use case: a platform team runs a +"shared-tools" workspace containing sandboxes with internal services (a test +database, a mock API, a reference environment). Engineers in other workspaces +need exec access to specific sandboxes in shared-tools without becoming full +members of that workspace — full membership would grant them access to all +sandboxes and providers in shared-tools, which is broader than needed. + +This would require a scoped access grant that gives the target principal access +to a specific sandbox without conferring workspace membership. The grant would +need to be auditable, revocable, and limited to the specific sandbox — not a +general workspace bypass. Design considerations include whether the sharee can +list other resources in the workspace, how provider credential exposure is +handled (the sandbox environment may already have credentials injected), and +whether the grant survives sandbox recreation. + +### Built-in profile visibility scoping + +Built-in profiles (claude-code, github, nvidia, etc.) are currently included +in both workspace-scoped and platform-scoped profile listings. This is +convenient but may be misleading — a Workspace Admin seeing built-in profiles +in their workspace listing might assume they can modify or override them at +the workspace level. + +A future change could restrict built-in profiles to the platform-scoped +listing only (`--global`). Workspace-scoped listings would show only custom +profiles that have been explicitly imported into that workspace. This would +make the listing semantics cleaner: workspace scope shows what the Workspace +Admin has configured, platform scope shows what the Platform Admin and the +system provide. The trade-off is discoverability — new users listing profiles +in their workspace would see nothing until a Workspace Admin imports profiles, +which could be confusing for single-player deployments. + +### Workspace-scoped settings + +Runtime settings (`providers_v2_enabled`, `ocsf_json_enabled`, +`agent_policy_proposals_enabled`, `proposal_approval_mode`) currently use +two-tier resolution: a gateway-global value overrides per-sandbox values. +A Workspace Admin who wants to enable agent policy proposals or set auto- +approval mode for their workspace must ask a Platform Admin to set it +globally, or configure it on each sandbox individually. + +A workspace-scoped settings tier would sit between gateway-global and +per-sandbox: gateway-global > workspace > sandbox. This would let Workspace +Admins control operational knobs for their workspace without Platform Admin +involvement and without per-sandbox configuration burden. The resolution +chain would check gateway-global first (Platform Admin override), then +workspace (Workspace Admin default for the workspace), then sandbox +(per-sandbox override), then the built-in default. + +Design considerations: + +- Some settings are inherently gateway-wide feature flags + (`providers_v2_enabled`) and may not make sense at workspace scope. The + settings registry could gain a `scopes` field indicating which tiers a + setting participates in. +- The `UpdateConfig` RPC currently distinguishes `global: true` from + sandbox-targeted updates. A workspace tier would need a third targeting + mode in the request, or workspace settings could use a separate RPC. +- Settings resolution already loads both global and sandbox settings on every + `GetSandboxConfig` call. Adding a workspace tier adds one more store read + per config fetch. Caching or batching may be needed at scale. + +### Machine identity via OIDC workload identity + +CI/CD pipelines, agent harnesses, and other machine workloads could +authenticate to the gateway using OIDC workload identity tokens issued by +their platform (GitHub Actions, GitLab CI, GCP, AWS). The gateway would +validate these tokens and resolve the token's `sub` claim to a workspace +membership, following the same `authorize_workspace` path as human principals. +No stored API keys or service account secrets would be required. + +The most common deployment — human users authenticating via corporate SSO +and machine workloads authenticating via CI/CD platform OIDC — requires +multi-provider OIDC support (see Non-goals). The workspace and membership +model introduced in this RFC is designed to support workload identity without +changes: a machine workload's OIDC subject is added as a workspace member +with the User role, and the standard authorization path applies. + +### SandboxStatus portability + +`SandboxStatus` exposes platform-specific coordinates (`sandbox_name`, +`agent_pod`, `agent_fd`, `sandbox_fd`) that are informational and +display-oriented today. These fields are always accessed in the context of a +parent `Sandbox` record that carries the workspace, so they do not need +workspace context themselves. + +As workspace-to-namespace mapping modes evolve (shared namespace, dedicated +namespace per workspace, operator mode with pre-provisioned namespaces), +revisit whether these fields remain portable or need to become abstract and +self-describing regardless of the active mapping method. In particular: + +- `sandbox_name` currently holds the bare user-facing name. In shared-namespace + mode the Kubernetes resource name differs (`{workspace}--{name}`), but the + status field intentionally carries the bare name for display. If + namespace-per-workspace mode is added, this distinction may become moot. +- `agent_pod` is operationally critical — it correlates supervisor connections + to specific pods. Its value is Kubernetes-specific and may need an + abstraction layer for non-Kubernetes drivers. +- `agent_fd` and `sandbox_fd` are currently unused by consumers. Evaluate + whether to remove them rather than carry them forward. + +## Alternatives + +### Flat label-based tenancy (no workspaces) + +Use labels alone for all isolation, without a first-class workspace concept. +Users would filter by label, and access control would use label selectors. + +This was rejected because labels are a soft grouping mechanism with no +enforcement guarantee. A mislabeled resource would be visible across tenant +boundaries. Hard isolation requires a first-class field that the system enforces +at every access point, not a convention that depends on correct labeling. + +### One gateway per team + +Instead of multi-tenancy, deploy separate gateways per team. This provides +complete isolation by default. + +This was rejected because it creates operational overhead (N gateways to +manage), prevents resource sharing across teams, and makes cross-team +collaboration impossible. It also pushes the multi-tenancy problem to the +infrastructure layer without solving it. In practice, even within a single +team, individual members typically have private per-user API keys for services +like Claude or Codex that they cannot share with teammates. This pushes +team-level deployments toward per-user gateways, compounding the operational +cost. The multi-player proposal mitigates this by giving each user their own +workspace on a shared gateway for credential isolation, while allowing teams +to share workspaces for collaboration where appropriate. + +### OPA/Rego for authorization + +Use a policy language like OPA/Rego for fine-grained authorization decisions +instead of role-based access control. + +This was considered but deferred. The current need is coarse-grained role-based +isolation, not attribute-based policy evaluation. OPA/Rego authorization could +be layered on top of the workspace and role model in a future RFC if +fine-grained policies are needed. + +## Prior art + +- **Kubernetes namespaces and RBAC.** The workspace model draws from Kubernetes + conventions: hard isolation boundaries, labels for soft grouping, and RBAC + with role bindings scoped to boundaries. The term "workspace" is intentionally + distinct from "Kubernetes namespace" to avoid conflation — an OpenShell + workspace may map to a Kubernetes namespace, but the concepts are independent. + +- **GitHub organizations and teams.** GitHub's model of organizations + (workspaces) with teams (label-based grouping) and per-repo role assignments + informed the separation between hard boundaries and soft grouping. + +- **AWS IAM.** AWS's account-level isolation with IAM roles and policies within + accounts informed the quota and credential scoping model. The lesson is that + hard account boundaries with delegated administration scales better than + flat permission models. + +## Appendix: End-to-End Workspace Lifecycle + +This walkthrough traces a workspace from creation through sandbox teardown, +showing the authorization check at each step. + +**1. Platform Admin creates a workspace.** + + CreateWorkspace { name: "team-ml" } + → global_role = "platform_admin" → pass + → persist Workspace { name: "team-ml" } + +**2. Platform Admin adds a Workspace Admin.** + + AddWorkspaceMember { workspace: "team-ml", subject: "bob@corp.example.com", role: WORKSPACE_ADMIN } + → global_role = "platform_admin" → bypass membership check → pass + → persist membership ("team-ml", "bob@corp.example.com", Admin) + +**3. Workspace Admin adds a User.** + + AddWorkspaceMember { workspace: "team-ml", subject: "alice@corp.example.com", role: USER } + → authorize_workspace("team-ml", WorkspaceRole::Admin) + → lookup ("team-ml", "bob@corp.example.com") → Admin → pass + → validate: Workspace Admins cannot assign the Admin role → USER is allowed + → persist membership ("team-ml", "alice@corp.example.com", User) + +**4. Workspace Admin adds a provider.** + + CreateProvider { workspace: "team-ml", name: "claude-key", type: "claude", credentials: {...} } + → authorize_workspace("team-ml", WorkspaceRole::Admin) → pass + → persist Provider { workspace: "team-ml", name: "claude-key" } + +**5. User creates a sandbox.** + + CreateSandbox { workspace: "team-ml", name: "my-sandbox", providers: ["claude-key"] } + → authorize_workspace("team-ml", WorkspaceRole::User) + → lookup ("team-ml", "alice@corp.example.com") → User → pass + → validate provider "claude-key" exists in "team-ml" → yes + → resolve effective policy: gateway default + provider profiles for ["claude-key"] + → persist Sandbox { workspace: "team-ml", name: "my-sandbox" } + → dispatch to driver (K8s managed → namespace "openshell-prod-team-ml") + +**6. User deletes the sandbox.** + + DeleteSandbox { workspace: "team-ml", name: "my-sandbox" } + → authorize_workspace("team-ml", WorkspaceRole::User) → pass + → drain and delete sandbox + → dispatch delete to driver + +Membership records are stored in the durable object store as a +`(workspace, principal_subject) → role` mapping, separate from the Workspace +resource itself. This follows the Kubernetes pattern where RoleBindings are +independent resources, not properties of the Namespace object. + +### Sandbox Supervisor Lifecycle + +The sandbox supervisor uses a separate authentication path from user roles. +This walkthrough continues from step 5 above, showing how the supervisor +bootstraps and operates scoped to a single sandbox. + +**7. Gateway mints a sandbox JWT at creation time.** + + CreateSandbox → persist sandbox (uuid-a) → mint JWT: + JWT { sub: "spiffe://openshell/sandbox/uuid-a", sandbox_id: "uuid-a" } + → token injected into container/pod via compute driver + +**8. Supervisor connects to the gateway.** + + ConnectSupervisor (bidirectional stream) + Auth: Bearer + → SandboxJwtAuthenticator → Principal::Sandbox { sandbox_id: "uuid-a" } + → router: is_sandbox_callable("ConnectSupervisor") → yes + → supervisor sends SupervisorHello { sandbox_id: "uuid-a" } + → ensure_sandbox_principal_scope: JWT sandbox_id == hello sandbox_id → pass + → register session, send SessionAccepted, notify driver: sandbox ready + +**9. Supervisor fetches provider credentials.** + + GetSandboxProviderEnvironment { sandbox_id: "uuid-a" } + → enforce_sandbox_scope: JWT sandbox_id == request sandbox_id → pass + → gateway resolves providers for sandbox uuid-a (workspace-internal lookup) + → return { ANTHROPIC_API_KEY: "sk-...", ... } + +**10. Cross-sandbox and cross-principal access is rejected.** + + Supervisor-A → GetSandboxProviderEnvironment { sandbox_id: "uuid-b" } + → enforce_sandbox_scope: "uuid-a" != "uuid-b" → PERMISSION_DENIED + + Supervisor-A → ListSandboxes { workspace: "team-ml" } + → router: is_sandbox_callable("ListSandboxes") → false → PERMISSION_DENIED + +**11. Supervisor learns its workspace from the config response.** + + GetSandboxConfig { sandbox_id: "uuid-a" } + → response includes workspace: "team-ml" + → supervisor caches workspace for subsequent RPCs + +The supervisor discovers its workspace from the `workspace` field in +`GetSandboxConfigResponse`, returned by its first settings poll. It caches +this value and uses it for workspace-scoped RPCs such as `UpdateConfig` +(policy sync), `SubmitPolicyAnalysis`, and `GetDraftPolicy`. The supervisor's +authorization surface remains a single sandbox UUID — the workspace is used +only to scope resource lookups, not for access control. +