diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 851d39de54..fe4c277bd2 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -275,10 +275,14 @@ pub fn load_active_gateway() -> Option { } /// Save the last-used sandbox name for a gateway to persistent storage. -pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> { +/// +/// The workspace is stored alongside the name so that `load_last_sandbox` +/// only returns a sandbox that belongs to the requested workspace. +pub fn save_last_sandbox(gateway: &str, workspace: &str, sandbox: &str) -> Result<()> { let path = last_sandbox_path(gateway)?; ensure_parent_dir_restricted(&path)?; - std::fs::write(&path, sandbox) + let content = format!("{workspace}\n{sandbox}"); + std::fs::write(&path, content) .into_diagnostic() .wrap_err_with(|| format!("failed to write last sandbox to {}", path.display()))?; Ok(()) @@ -286,20 +290,31 @@ pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> { /// Load the last-used sandbox name for a gateway from persistent storage. /// -/// Returns `None` if no last sandbox has been set. -pub fn load_last_sandbox(gateway: &str) -> Option { - last_sandbox_path(gateway) +/// Returns the sandbox name only if the stored workspace matches the +/// requested workspace. Returns `None` if no last sandbox has been set +/// or the workspace does not match. +pub fn load_last_sandbox(gateway: &str, workspace: &str) -> Option { + let content = last_sandbox_path(gateway) .ok() .as_deref() - .and_then(read_nonempty_trimmed) + .and_then(read_nonempty_trimmed)?; + let mut lines = content.lines(); + let stored_workspace = lines.next()?.trim(); + let sandbox = lines.next().map(str::trim).filter(|s| !s.is_empty())?; + if stored_workspace == workspace { + Some(sandbox.to_string()) + } else { + None + } } -/// Clear the last-used sandbox record for a gateway if it matches the given name. +/// Clear the last-used sandbox record for a gateway if it matches the given +/// workspace and name. /// /// This should be called after a sandbox is deleted so that subsequent commands /// don't try to connect to a sandbox that no longer exists. -pub fn clear_last_sandbox_if_matches(gateway: &str, sandbox: &str) { - if let Some(current) = load_last_sandbox(gateway) +pub fn clear_last_sandbox_if_matches(gateway: &str, workspace: &str, sandbox: &str) { + if let Some(current) = load_last_sandbox(gateway, workspace) && current == sandbox && let Ok(path) = last_sandbox_path(gateway) { @@ -507,8 +522,11 @@ mod tests { fn save_and_load_last_sandbox_roundtrip() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("mygateway", "dev-box").unwrap(); - assert_eq!(load_last_sandbox("mygateway"), Some("dev-box".to_string())); + save_last_sandbox("mygateway", "default", "dev-box").unwrap(); + assert_eq!( + load_last_sandbox("mygateway", "default"), + Some("dev-box".to_string()) + ); }); } @@ -516,7 +534,7 @@ mod tests { fn load_last_sandbox_returns_none_when_not_set() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - assert_eq!(load_last_sandbox("no-such-gateway"), None); + assert_eq!(load_last_sandbox("no-such-gateway", "default"), None); }); } @@ -524,9 +542,12 @@ mod tests { fn save_last_sandbox_overwrites_previous() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("g1", "first").unwrap(); - save_last_sandbox("g1", "second").unwrap(); - assert_eq!(load_last_sandbox("g1"), Some("second".to_string())); + save_last_sandbox("g1", "default", "first").unwrap(); + save_last_sandbox("g1", "default", "second").unwrap(); + assert_eq!( + load_last_sandbox("g1", "default"), + Some("second".to_string()) + ); }); } @@ -534,24 +555,22 @@ mod tests { fn save_last_sandbox_creates_parent_dirs() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - // The gateway subdir doesn't exist yet — save should create it. - save_last_sandbox("brand-new-gateway", "sb1").unwrap(); + save_last_sandbox("brand-new-gateway", "default", "sb1").unwrap(); assert_eq!( - load_last_sandbox("brand-new-gateway"), + load_last_sandbox("brand-new-gateway", "default"), Some("sb1".to_string()) ); }); } #[test] - fn load_last_sandbox_ignores_whitespace() { + fn load_last_sandbox_returns_none_for_legacy_single_line_file() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - // Write the file manually with surrounding whitespace. let path = last_sandbox_path("ws-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " my-sb \n").unwrap(); - assert_eq!(load_last_sandbox("ws-gateway"), Some("my-sb".to_string())); + assert_eq!(load_last_sandbox("ws-gateway", "default"), None); }); } @@ -562,7 +581,7 @@ mod tests { let path = last_sandbox_path("empty-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " \n").unwrap(); - assert_eq!(load_last_sandbox("empty-gateway"), None); + assert_eq!(load_last_sandbox("empty-gateway", "default"), None); }); } @@ -570,19 +589,32 @@ mod tests { fn last_sandbox_is_per_gateway() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("gateway-a", "sandbox-a").unwrap(); - save_last_sandbox("gateway-b", "sandbox-b").unwrap(); + save_last_sandbox("gateway-a", "default", "sandbox-a").unwrap(); + save_last_sandbox("gateway-b", "default", "sandbox-b").unwrap(); assert_eq!( - load_last_sandbox("gateway-a"), + load_last_sandbox("gateway-a", "default"), Some("sandbox-a".to_string()) ); assert_eq!( - load_last_sandbox("gateway-b"), + load_last_sandbox("gateway-b", "default"), Some("sandbox-b".to_string()) ); }); } + #[test] + fn last_sandbox_is_workspace_scoped() { + let tmp = tempfile::tempdir().unwrap(); + with_tmp_xdg(tmp.path(), || { + save_last_sandbox("gw", "alpha", "sb-alpha").unwrap(); + assert_eq!( + load_last_sandbox("gw", "alpha"), + Some("sb-alpha".to_string()) + ); + assert_eq!(load_last_sandbox("gw", "beta"), None); + }); + } + // ── system gateway dir fallback ─────────────────────────────────── /// Helper: hold the shared XDG test lock, point `XDG_CONFIG_HOME` at @@ -635,9 +667,12 @@ mod tests { with_tmp_xdg_and_system(user.path(), system.path(), || { write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); - save_last_sandbox("shared", "sb-123").unwrap(); + save_last_sandbox("shared", "default", "sb-123").unwrap(); - assert_eq!(load_last_sandbox("shared"), Some("sb-123".to_string())); + assert_eq!( + load_last_sandbox("shared", "default"), + Some("sb-123".to_string()) + ); assert_eq!( gateway_metadata_source("shared").unwrap(), Some(GatewayMetadataSource::System) @@ -671,12 +706,12 @@ mod tests { .to_path_buf(); assert!(!user_gateway_dir.exists()); - save_last_sandbox("shared", "sb-123").unwrap(); + save_last_sandbox("shared", "default", "sb-123").unwrap(); assert!(user_gateway_dir.is_dir()); assert_eq!( std::fs::read_to_string(user_gateway_dir.join("last_sandbox")).unwrap(), - "sb-123" + "default\nsb-123" ); assert!(!user_gateway_dir.join("metadata.json").exists()); assert_eq!( @@ -692,11 +727,11 @@ mod tests { let system = tempfile::tempdir().unwrap(); with_tmp_xdg_and_system(user.path(), system.path(), || { write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); - save_last_sandbox("shared", "sb-123").unwrap(); + save_last_sandbox("shared", "default", "sb-123").unwrap(); - clear_last_sandbox_if_matches("shared", "sb-123"); + clear_last_sandbox_if_matches("shared", "default", "sb-123"); - assert_eq!(load_last_sandbox("shared"), None); + assert_eq!(load_last_sandbox("shared", "default"), None); let gateways = list_gateways_with_source().unwrap(); assert_eq!(gateways.len(), 1); assert_eq!(gateways[0].metadata.name, "shared"); @@ -924,7 +959,7 @@ mod tests { }; assert!(store_gateway_metadata("../escape", &meta).is_err()); assert!(load_gateway_metadata("../escape").is_err()); - assert!(save_last_sandbox("../escape", "sb-123").is_err()); + assert!(save_last_sandbox("../escape", "default", "sb-123").is_err()); assert!(save_active_gateway("../escape").is_err()); }); } diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index a421b418ae..572d601eaa 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,8 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec { limit: 200, offset: 0, label_selector: String::new(), + workspace: workspace_from_args(), + all_workspaces: false, }) .await .ok()?; @@ -62,6 +64,8 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { .list_providers(ListProvidersRequest { limit: 200, offset: 0, + workspace: workspace_from_args(), + all_workspaces: false, }) .await .ok()?; @@ -76,6 +80,44 @@ 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 workspace_from_args() -> String { + let args: Vec = std::env::args().collect(); + for (i, arg) in args.iter().enumerate() { + if arg == "--workspace" { + if let Some(val) = args.get(i + 1) { + return val.clone(); + } + } else if let Some(val) = arg.strip_prefix("--workspace=") { + return val.to_string(); + } + } + std::env::var("OPENSHELL_WORKSPACE").unwrap_or_else(|_| "default".to_string()) +} + fn resolve_active_gateway() -> Option<(String, String)> { let name = std::env::var("OPENSHELL_GATEWAY") .ok() diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 66484562d4..cccc659897 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -203,16 +203,17 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) { } } -/// Resolve a sandbox name, falling back to the last-used sandbox for the gateway. +/// Resolve a sandbox name, falling back to the last-used sandbox for the +/// gateway and workspace. /// /// When `name` is `None`, looks up the last sandbox recorded for the active -/// gateway. Prints a hint when falling back so the user knows which sandbox -/// was chosen. -fn resolve_sandbox_name(name: Option, gateway: &str) -> Result { +/// gateway and workspace. Prints a hint when falling back so the user knows +/// which sandbox was chosen. +fn resolve_sandbox_name(name: Option, gateway: &str, workspace: &str) -> Result { if let Some(n) = name { return Ok(n); } - let last = load_last_sandbox(gateway).ok_or_else(|| { + let last = load_last_sandbox(gateway, workspace).ok_or_else(|| { miette::miette!( "No sandbox name provided and no last-used sandbox.\n\ Specify a sandbox name or connect to one first: openshell sandbox connect " @@ -360,6 +361,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 +433,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 +543,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 +811,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 +848,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 (overrides --workspace). + #[arg(long)] + all_workspaces: bool, }, /// List available provider profiles. @@ -952,6 +990,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 +1006,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 +1021,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 +1037,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 +1048,10 @@ enum ProviderProfileCommands { Delete { /// Provider profile id. id: String, + + /// Target platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, }, } @@ -1131,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. @@ -1157,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). @@ -1181,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. @@ -1384,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 (overrides --workspace). + #[arg(long)] + all_workspaces: bool, }, /// Delete a sandbox by name. @@ -1915,6 +1977,10 @@ enum ServiceCommands { /// Number of endpoints to skip. #[arg(long, default_value_t = 0)] offset: u32, + + /// List services across all workspaces (overrides --workspace). + #[arg(long)] + all_workspaces: bool, }, /// Show one exposed sandbox service endpoint. @@ -1940,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, max 19 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<()> { @@ -2190,7 +2358,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; let local = local.unwrap_or_else(|| target_port.to_string()); run::service_forward_tcp( &ctx.endpoint, @@ -2199,6 +2367,7 @@ async fn main() -> Result<()> { &target_host, target_port, &tls, + &cli.workspace, ) .await?; } @@ -2211,8 +2380,16 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_forward( + &ctx.endpoint, + &name, + &spec, + background, + &tls, + &cli.workspace, + ) + .await?; if background { eprintln!( "{} Forwarding port {} to sandbox {name} in the background", @@ -2241,24 +2418,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?; } } } @@ -2276,7 +2471,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_logs( &ctx.endpoint, &name, @@ -2285,6 +2480,7 @@ async fn main() -> Result<()> { since.as_deref(), &source, &level, + &cli.workspace, &tls, ) .await?; @@ -2343,13 +2539,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_policy_set( + &ctx.endpoint, + &name, + &policy, + wait, + timeout, + &cli.workspace, + &tls, + ) + .await?; } } PolicyCommands::Update { @@ -2365,7 +2570,7 @@ async fn main() -> Result<()> { wait, timeout, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_policy_update( &ctx.endpoint, &name, @@ -2379,6 +2584,7 @@ async fn main() -> Result<()> { dry_run, wait, timeout, + &cli.workspace, &tls, ) .await?; @@ -2398,17 +2604,19 @@ async fn main() -> Result<()> { rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; } else { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_policy_get( &ctx.endpoint, &name, rev, view, output.as_str(), + &cli.workspace, &tls, ) .await?; @@ -2420,10 +2628,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_policy_list(&ctx.endpoint, &name, limit, &cli.workspace, &tls) + .await?; } } PolicyCommands::Delete { global, yes } => { @@ -2432,7 +2642,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!(), } @@ -2458,8 +2669,9 @@ 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &cli.workspace, &tls) + .await?; } } SettingsCommands::Set { @@ -2470,10 +2682,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_setting_set( + &ctx.endpoint, + &name, + &key, + &value, + &cli.workspace, + &tls, + ) + .await?; } } SettingsCommands::Delete { @@ -2483,10 +2711,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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_setting_delete( + &ctx.endpoint, + &name, + &key, + &cli.workspace, + &tls, + ) + .await?; } } } @@ -2503,43 +2739,65 @@ async fn main() -> Result<()> { apply_auth(&mut tls, &ctx.name); 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_draft_approve( + &ctx.endpoint, + &name, + &chunk_id, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::Reject { name, chunk_id, reason, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_reject(&ctx.endpoint, &name, &chunk_id, &reason, &tls) - .await?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_draft_reject( + &ctx.endpoint, + &name, + &chunk_id, + &reason, + &cli.workspace, + &tls, + ) + .await?; } DraftCommands::ApproveAll { name, include_security_flagged, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; run::sandbox_draft_approve_all( &ctx.endpoint, &name, include_security_flagged, + &cli.workspace, &tls, ) .await?; } DraftCommands::Clear { name } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_draft_clear(&ctx.endpoint, &name, &tls).await?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_draft_history(&ctx.endpoint, &name, &cli.workspace, &tls).await?; } } } @@ -2564,7 +2822,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?; } @@ -2583,13 +2848,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?; } } } @@ -2710,6 +2976,7 @@ async fn main() -> Result<()> { environment: env_map, approval_mode: &approval_mode, }, + &cli.workspace, &tls, )) .await?; @@ -2743,6 +3010,7 @@ async fn main() -> Result<()> { local, sandbox_dest, &tls, + &cli.workspace, ) .await?; eprintln!("{} Upload complete", "✓".green().bold()); @@ -2754,7 +3022,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 { @@ -2767,8 +3043,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 => { @@ -2783,8 +3066,9 @@ async fn main() -> Result<()> { unreachable!() } SandboxCommands::Get { name, policy_only } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_get(endpoint, &name, policy_only, &tls).await?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + run::sandbox_get(endpoint, &name, policy_only, &cli.workspace, &tls) + .await?; } SandboxCommands::List { limit, @@ -2793,6 +3077,7 @@ async fn main() -> Result<()> { names, selector, output, + all_workspaces, } => { run::sandbox_list( endpoint, @@ -2802,24 +3087,39 @@ 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)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; 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); + let _ = save_last_sandbox(&ctx.name, &cli.workspace, &name); } SandboxCommands::Exec { name, @@ -2830,7 +3130,7 @@ async fn main() -> Result<()> { envs, command, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; // Resolve --tty / --no-tty into an Option override. let tty_override = if no_tty { Some(false) @@ -2849,35 +3149,111 @@ async fn main() -> Result<()> { tty_override, &env_map, &tls, + &cli.workspace, ) .await?; - let _ = save_last_sandbox(&ctx.name, &name); + let _ = save_last_sandbox(&ctx.name, &cli.workspace, &name); if exit_code != 0 { std::process::exit(exit_code); } } SandboxCommands::SshConfig { name } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::print_ssh_config(&ctx.name, &name); + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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?; + let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; + 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), }) => { @@ -2895,7 +3271,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, @@ -2905,6 +3283,8 @@ async fn main() -> Result<()> { from_gcloud_adc, runtime_credentials, &config, + &cli.workspace, + profile_ws, &tls, ) .await?; @@ -2918,6 +3298,7 @@ async fn main() -> Result<()> { endpoint, &name, credential_key.as_deref(), + &cli.workspace, &tls, ) .await?; @@ -2942,6 +3323,7 @@ async fn main() -> Result<()> { secret_material_keys: &secret_material_keys, credential_expires_at_ms: credential_expires_at, }, + &cli.workspace, &tls, ) .await?; @@ -2950,60 +3332,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, @@ -3018,12 +3450,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?; } } } @@ -3036,7 +3469,7 @@ async fn main() -> Result<()> { tls.oidc_token.as_deref(), tls.edge_token.as_deref(), )?; - openshell_tui::run(channel, interceptor, &ctx.name, &ctx.endpoint, theme).await?; + openshell_tui::run(channel, interceptor, &ctx.name, &ctx.endpoint, &cli.workspace, theme).await?; } Some(Commands::Completions { shell }) => { let exe = std::env::current_exe() @@ -3086,11 +3519,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!( @@ -3136,6 +3569,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") @@ -3634,7 +4074,7 @@ mod tests { fn resolve_sandbox_name_returns_explicit_name() { // When a name is provided, it should be returned regardless of any // stored last-sandbox state. - let result = resolve_sandbox_name(Some("explicit".to_string()), "any-gateway"); + let result = resolve_sandbox_name(Some("explicit".to_string()), "any-gateway", "default"); assert_eq!(result.unwrap(), "explicit"); } @@ -3642,8 +4082,8 @@ mod tests { fn resolve_sandbox_name_falls_back_to_last_used() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - save_last_sandbox("test-gateway", "remembered-sb").unwrap(); - let result = resolve_sandbox_name(None, "test-gateway"); + save_last_sandbox("test-gateway", "default", "remembered-sb").unwrap(); + let result = resolve_sandbox_name(None, "test-gateway", "default"); assert_eq!(result.unwrap(), "remembered-sb"); }); } @@ -3652,7 +4092,7 @@ mod tests { fn resolve_sandbox_name_errors_without_fallback() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { - let err = resolve_sandbox_name(None, "unknown-gateway").unwrap_err(); + let err = resolve_sandbox_name(None, "unknown-gateway", "default").unwrap_err(); let msg = err.to_string(); assert!( msg.contains("openshell sandbox connect"), @@ -3826,7 +4266,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { id, - output: OutputFormat::Yaml + output: OutputFormat::Yaml, + .. })) }) if id == "custom-api" )); @@ -3865,7 +4306,8 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { id, - file: _ + file: _, + .. })) }) if id == "custom-api" )); @@ -3877,7 +4319,8 @@ mod tests { delete.command, Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - id + id, + .. })) }) if id == "custom-api" )); @@ -4829,6 +5272,7 @@ mod tests { sandbox, limit, offset, + .. }), }) => { assert_eq!(sandbox.as_deref(), Some("my-sandbox")); @@ -4848,6 +5292,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 0182e1b668..a7162fbd23 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -39,8 +39,8 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetInferenceRouteRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, HealthRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest, @@ -50,13 +50,13 @@ use openshell_core::proto::{ ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, - SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, - UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, - exec_sandbox_event, setting_value, tcp_forward_init, + 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, @@ -1746,6 +1746,7 @@ async fn finalize_sandbox_create_session( sandbox_name: &str, persist: bool, session_result: Result<()>, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -1754,7 +1755,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); } @@ -1821,6 +1822,7 @@ pub async fn sandbox_create( server: &str, gateway_name: &str, config: SandboxCreateConfig<'_>, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let SandboxCreateConfig { @@ -1895,6 +1897,7 @@ pub async fn sandbox_create( providers, &inferred_types, auto_providers_override, + workspace, ) .await?; @@ -1928,6 +1931,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, + workspace: workspace.to_string(), }; let response = match client.create_sandbox(request).await { @@ -1956,7 +1960,7 @@ pub async fn sandbox_create( // Record this sandbox as the last-used for the active gateway only when it // is expected to persist beyond the initial session. if persist && let Some(gateway) = effective_tls.gateway_name() { - let _ = save_last_sandbox(gateway, &sandbox_name); + let _ = save_last_sandbox(gateway, workspace, &sandbox_name); } // Persist `--approval-mode` as a sandbox-scoped setting now that the @@ -1977,6 +1981,7 @@ pub async fn sandbox_create( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await { @@ -2225,6 +2230,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2240,6 +2246,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2250,6 +2257,7 @@ pub async fn sandbox_create( local, dest, &effective_tls, + workspace, ) .await?; } @@ -2267,6 +2275,7 @@ pub async fn sandbox_create( spec, true, // background &effective_tls, + workspace, ) .await?; eprintln!( @@ -2289,6 +2298,7 @@ pub async fn sandbox_create( &sandbox_name, editor, &effective_tls, + workspace, ) .await?; return Ok(()); @@ -2296,12 +2306,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 }; @@ -2311,6 +2323,7 @@ pub async fn sandbox_create( &sandbox_name, persist, connect_result, + workspace, &effective_tls, gateway_name, ) @@ -2329,6 +2342,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await } else { @@ -2338,6 +2352,7 @@ pub async fn sandbox_create( command, tty, &effective_tls, + workspace, ) .await }; @@ -2347,6 +2362,7 @@ pub async fn sandbox_create( &sandbox_name, persist, exec_result, + workspace, &effective_tls, gateway_name, ) @@ -2572,6 +2588,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) => { @@ -2584,13 +2601,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()); } _ => { @@ -2611,6 +2628,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?; @@ -2618,6 +2636,7 @@ pub async fn sandbox_get( let response = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -2735,6 +2754,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?; @@ -2742,6 +2762,7 @@ pub async fn sandbox_exec_grpc( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -2850,11 +2871,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 @@ -2884,7 +2906,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() => { @@ -2948,10 +2970,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 { @@ -3345,6 +3369,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?; @@ -3354,6 +3380,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()?; @@ -3379,8 +3411,12 @@ pub async fn sandbox_list( } if names_only { - for sandbox in sandboxes { - println!("{}", sandbox.object_name()); + for sandbox in &sandboxes { + if all_workspaces { + println!("{}/{}", sandbox.object_workspace(), sandbox.object_name()); + } else { + println!("{}", sandbox.object_name()); + } } return Ok(()); } @@ -3393,14 +3429,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 { serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), + "workspace": sandbox.object_workspace(), "labels": labels, "resource_version": meta.map_or(0, |m| m.resource_version), "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), @@ -3438,11 +3505,17 @@ fn sandbox_to_json(sandbox: &Sandbox) -> 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()?; @@ -3461,6 +3534,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?; @@ -3469,6 +3543,7 @@ pub async fn sandbox_provider_attach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3483,6 +3558,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 { @@ -3514,6 +3590,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?; @@ -3522,6 +3599,7 @@ pub async fn sandbox_provider_detach( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -3536,6 +3614,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 { @@ -3628,6 +3707,7 @@ pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { @@ -3640,6 +3720,8 @@ pub async fn sandbox_delete( limit: 1000, offset: 0, label_selector: String::new(), + workspace: workspace.to_string(), + all_workspaces: false, }) .await .into_diagnostic()?; @@ -3668,13 +3750,16 @@ 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()?; let deleted = response.into_inner().deleted; if deleted { - clear_last_sandbox_if_matches(gateway, name); + clear_last_sandbox_if_matches(gateway, workspace, name); println!("{} Deleted sandbox {name}", "✓".green().bold()); } else { println!("{} Sandbox {name} not found", "!".yellow()); @@ -3705,6 +3790,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()); @@ -3723,7 +3809,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; @@ -3760,6 +3851,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 @@ -3800,6 +3892,7 @@ pub async fn ensure_required_providers( auto_providers_override, &mut seen_names, &mut configured_names, + workspace, ) .await?; } @@ -3821,6 +3914,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}"); @@ -3860,7 +3954,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 { @@ -3883,12 +3977,15 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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| { @@ -3925,12 +4022,15 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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 { @@ -4166,6 +4266,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?; @@ -4175,6 +4276,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)? @@ -4212,6 +4314,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?; @@ -4220,6 +4324,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))? @@ -4234,7 +4344,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(()) } @@ -4242,6 +4352,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?; @@ -4249,12 +4360,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(()) } @@ -4262,6 +4374,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?; @@ -4269,6 +4382,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))? @@ -4315,11 +4429,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() { @@ -4327,7 +4449,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::>(); @@ -4335,38 +4463,64 @@ 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!( - "{: &str { @@ -4468,10 +4622,12 @@ async fn rollback_provider_create_after_gcloud_adc_failure( provider_name: &str, stage: &str, source: &Status, + workspace: &str, ) -> Result<()> { match client .delete_provider(DeleteProviderRequest { name: provider_name.to_string(), + workspace: workspace.to_string(), }) .await { @@ -4538,10 +4694,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| { @@ -4563,9 +4721,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| { @@ -4636,6 +4795,7 @@ pub async fn provider_create( credentials: &[String], from_gcloud_adc: bool, config: &[String], + workspace: &str, tls: &TlsOptions, ) -> Result<()> { provider_create_with_options( @@ -4647,6 +4807,8 @@ pub async fn provider_create( from_gcloud_adc, false, config, + workspace, + workspace, tls, ) .await @@ -4662,6 +4824,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) { @@ -4692,6 +4856,7 @@ pub async fn provider_create_with_options( let response = client .get_provider_profile(GetProviderProfileRequest { id: profile_id.to_string(), + workspace: profile_workspace.to_string(), }) .await; match response { @@ -4743,7 +4908,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}'" @@ -4767,10 +4933,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)) @@ -4805,12 +4971,15 @@ pub async fn provider_create_with_options( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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()?; @@ -4840,6 +5009,7 @@ pub async fn provider_create_with_options( "refresh_token".to_string(), ], expires_at_ms: None, + workspace: workspace.to_string(), }) .await { @@ -4848,6 +5018,7 @@ pub async fn provider_create_with_options( &provider_name, "configure", &configure_err, + workspace, ) .await; } @@ -4856,6 +5027,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 { @@ -4864,6 +5036,7 @@ pub async fn provider_create_with_options( &provider_name, "mint the initial access token for", &rotate_err, + workspace, ) .await; } @@ -4881,11 +5054,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()?; @@ -4939,6 +5118,10 @@ fn provider_to_json(provider: &Provider) -> serde_json::Value { "name".to_string(), serde_json::json!(provider.object_name()), ); + obj.insert( + "workspace".to_string(), + serde_json::json!(provider.object_workspace()), + ); obj.insert("type".to_string(), serde_json::json!(provider.r#type)); // Credential keys (NEVER values - security) @@ -4984,17 +5167,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; @@ -5012,12 +5207,26 @@ pub async fn provider_list( } if names_only { - for provider in providers { - println!("{}", provider.object_name()); + for provider in &providers { + if all_workspaces { + println!("{}/{}", provider.object_workspace(), provider.object_name()); + } else { + println!("{}", provider.object_name()); + } } 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()) @@ -5031,33 +5240,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()?; @@ -5105,9 +5342,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 { @@ -5120,11 +5358,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 @@ -5147,6 +5389,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)?; @@ -5161,7 +5404,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(); @@ -5188,6 +5434,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)?; @@ -5210,6 +5457,7 @@ pub async fn provider_profile_update( profile: Some(item), expected_resource_version, id: id.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5229,6 +5477,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)?; @@ -5239,7 +5488,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(); @@ -5255,10 +5507,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(); @@ -5274,6 +5534,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?; @@ -5281,6 +5542,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()? @@ -5331,6 +5593,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)?; @@ -5358,6 +5621,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()? @@ -5378,6 +5642,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?; @@ -5385,6 +5650,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()? @@ -5413,6 +5679,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?; @@ -5420,6 +5687,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()? @@ -5693,6 +5961,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, @@ -5700,6 +5969,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() { @@ -5719,6 +5989,7 @@ pub async fn provider_update( let existing = client .get_provider(GetProviderRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5727,7 +5998,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}'" @@ -5751,13 +6023,16 @@ pub async fn provider_update( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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()?; @@ -5775,11 +6050,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 { @@ -5791,6 +6074,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, @@ -5798,6 +6409,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() { @@ -5815,13 +6427,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; @@ -5835,10 +6448,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); @@ -5853,6 +6467,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>, @@ -5860,6 +6475,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() { @@ -5872,8 +6488,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()? @@ -5897,13 +6514,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; @@ -5917,10 +6535,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); @@ -5938,6 +6557,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?; @@ -5945,8 +6565,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()?; @@ -5955,19 +6576,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(()) } @@ -5976,10 +6598,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 { @@ -5987,6 +6611,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); @@ -6344,6 +6969,7 @@ pub async fn sandbox_policy_set_global( yes: bool, wait: bool, _timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if wait { @@ -6368,6 +6994,7 @@ pub async fn sandbox_policy_set_global( global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6390,12 +7017,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()? @@ -6551,6 +7180,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)?; @@ -6567,6 +7197,7 @@ pub async fn gateway_setting_set( global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6587,6 +7218,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)?; @@ -6602,6 +7234,7 @@ pub async fn sandbox_setting_set( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6622,6 +7255,7 @@ pub async fn gateway_setting_delete( server: &str, key: &str, yes: bool, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { confirm_global_setting_delete(key, yes)?; @@ -6637,6 +7271,7 @@ pub async fn gateway_setting_delete( global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6659,6 +7294,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?; @@ -6672,6 +7308,7 @@ pub async fn sandbox_setting_delete( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6702,6 +7339,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))? @@ -6715,6 +7353,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: 0, global: false, + workspace: workspace.to_string(), }) .await .ok() @@ -6731,6 +7370,7 @@ pub async fn sandbox_policy_set( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6777,6 +7417,7 @@ pub async fn sandbox_policy_set( name: name.to_string(), version: resp.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6832,6 +7473,7 @@ pub async fn sandbox_policy_update( dry_run: bool, wait: bool, timeout_secs: u64, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { if dry_run && wait { @@ -6852,6 +7494,7 @@ pub async fn sandbox_policy_update( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6906,6 +7549,7 @@ pub async fn sandbox_policy_update( global: false, merge_operations: plan.merge_operations, expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -6952,6 +7596,7 @@ pub async fn sandbox_policy_update( name: name.to_string(), version: response.version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -6999,6 +7644,7 @@ pub async fn sandbox_policy_get( version: u32, view: PolicyGetView, output: &str, + workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut stdout = Vec::new(); @@ -7009,6 +7655,7 @@ pub async fn sandbox_policy_get( version, view, output, + workspace, tls, (&mut stdout, &mut stderr), ) @@ -7027,12 +7674,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<()> @@ -7041,8 +7690,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; @@ -7053,6 +7704,7 @@ where name: name.to_string(), version, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7120,6 +7772,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<()> @@ -7133,6 +7786,7 @@ where let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7234,6 +7888,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?; @@ -7243,6 +7898,7 @@ pub async fn sandbox_policy_get_global( name: String::new(), version, global: true, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7368,6 +8024,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?; @@ -7378,6 +8035,7 @@ pub async fn sandbox_policy_list( limit, offset: 0, global: false, + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7392,7 +8050,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 @@ -7401,6 +8064,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()?; @@ -7456,6 +8120,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?; @@ -7464,6 +8129,7 @@ pub async fn sandbox_logs( let sandbox = client .get_sandbox(GetSandboxRequest { name: name.to_string(), + workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -7528,6 +8194,7 @@ pub async fn sandbox_logs( since_ms, sources: source_filter, min_level: level.to_uppercase(), + workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -7590,6 +8257,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?; @@ -7598,6 +8266,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()?; @@ -7682,6 +8351,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?; @@ -7690,6 +8360,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()?; @@ -7711,6 +8382,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?; @@ -7720,6 +8392,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()?; @@ -7734,6 +8407,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?; @@ -7742,6 +8416,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()?; @@ -7759,12 +8434,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()?; @@ -7780,12 +8461,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()?; @@ -8180,6 +8867,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }], false, ); @@ -9615,12 +10303,14 @@ 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); assert_eq!(json["id"], "prov-123"); assert_eq!(json["name"], "test-provider"); + assert_eq!(json["workspace"], ""); assert_eq!(json["type"], "anthropic"); } @@ -9636,6 +10326,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); @@ -9673,6 +10364,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); @@ -9703,6 +10395,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); @@ -9724,6 +10417,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, + workspace: String::new(), }; let provider = Provider { @@ -9732,6 +10426,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); @@ -9757,6 +10452,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); @@ -9786,6 +10482,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); @@ -9811,6 +10508,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 7bf8612b4a..8e095f1335 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,11 +63,13 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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(), }, ); } @@ -349,6 +351,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -357,6 +360,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()); @@ -583,6 +587,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 ────────────────────────────────────────────── @@ -663,6 +716,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"); @@ -691,6 +745,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"); @@ -724,6 +779,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"); @@ -755,6 +811,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"); @@ -784,6 +841,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"); @@ -819,6 +877,7 @@ async fn explicit_and_inferred_providers_combined() { &["nvidia".to_string()], &["claude-code".to_string()], Some(true), + "default", ) .await .expect("should create both providers"); @@ -850,6 +909,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 3f51dd6044..83b38d9ae4 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -476,6 +476,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 53b178acbb..4ddf2f6578 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -129,6 +129,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, + workspace: String::new(), }), spec: None, status: None, @@ -604,6 +605,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + workspace: String::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), @@ -612,6 +614,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()); @@ -982,6 +985,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. @@ -1063,17 +1115,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, @@ -1082,12 +1144,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"); } @@ -1096,7 +1159,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"); } @@ -1114,19 +1177,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] @@ -1142,19 +1220,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] @@ -1162,9 +1255,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] @@ -1179,6 +1281,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1195,6 +1298,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 @@ -1203,16 +1307,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!( @@ -1253,6 +1370,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 @@ -1271,6 +1389,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 @@ -1312,6 +1431,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 @@ -1341,6 +1461,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 @@ -1383,6 +1504,8 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() false, true, &[], + "default", + "default", &ts.tls, ) .await @@ -1423,6 +1546,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &[], false, &[], + "default", &ts.tls, ) .await @@ -1450,26 +1574,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!( @@ -1505,10 +1654,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"), @@ -1549,14 +1703,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")); @@ -1567,9 +1721,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 @@ -1580,10 +1740,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( @@ -1594,6 +1754,7 @@ binaries: [/usr/bin/custom] &["CUSTOM_API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -1609,10 +1770,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"); } @@ -1648,6 +1814,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() &[], false, &[], + "default", &ts.tls, ) .await @@ -1681,6 +1848,7 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( &[], false, &[], + "default", &ts.tls, ) .await @@ -1724,6 +1892,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w &[], false, &[], + "default", &ts.tls, ) .await @@ -1779,6 +1948,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { &[], false, &[], + "default", &ts.tls, ) .await @@ -1822,6 +1992,7 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin &[], false, &[], + "default", &ts.tls, ) .await @@ -1874,13 +2045,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 @@ -1929,14 +2110,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"); } @@ -1976,7 +2157,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"); @@ -1986,6 +2167,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") @@ -2020,15 +2202,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"); } @@ -2051,7 +2234,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!( @@ -2059,7 +2242,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"); } @@ -2076,6 +2259,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() &["INVALID_PAIR".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2101,6 +2285,7 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { &["NAV_GENERIC_TEST_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2112,6 +2297,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") @@ -2136,6 +2322,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() { &["API_KEY=abc".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2160,6 +2347,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2185,6 +2373,7 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { &["GOOGLE_VERTEX_AI_TOKEN=token".to_string()], true, &[], + "default", &ts.tls, ) .await @@ -2211,6 +2400,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() { &["NAV_EMPTY_ENV_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2236,6 +2426,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { &["NVIDIA_API_KEY".to_string()], false, &[], + "default", &ts.tls, ) .await @@ -2247,6 +2438,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") @@ -2288,6 +2480,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 @@ -2373,6 +2566,7 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2410,6 +2604,7 @@ async fn provider_create_from_gcloud_adc_missing_file() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2443,6 +2638,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred &[], true, &[], + "default", &ts.tls, ) .await @@ -2480,6 +2676,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config &[], true, &[], + "default", &ts.tls, ) .await @@ -2530,6 +2727,7 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_ &[], true, &[], + "default", &ts.tls, ) .await @@ -2578,6 +2776,7 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate &[], true, &[], + "default", &ts.tls, ) .await @@ -2618,6 +2817,7 @@ async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex &[], false, &[], + "default", &ts.tls, ) .await @@ -2664,6 +2864,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 @@ -2738,6 +2939,7 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() { &[], true, &[], + "default", &ts.tls, ) .await @@ -2780,6 +2982,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 ec8bd53743..ceed2f5a9a 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -87,6 +87,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Sandbox::default() }; @@ -108,6 +109,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Sandbox::default() }; @@ -368,6 +370,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Sandbox::default() }; @@ -655,6 +658,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 { @@ -1129,6 +1181,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1136,7 +1189,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { assert!(deleted_names(&server).await.is_empty()); assert_eq!( - load_last_sandbox("openshell").as_deref(), + load_last_sandbox("openshell", "default").as_deref(), Some("default-command"), "default sandboxes should be persisted as last-used" ); @@ -1161,6 +1214,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1228,6 +1282,7 @@ async fn sandbox_create_sends_driver_config_json() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1289,6 +1344,7 @@ async fn sandbox_create_sends_gpu_default_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1323,6 +1379,7 @@ async fn sandbox_create_sends_gpu_count_request() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1358,6 +1415,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1403,6 +1461,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1444,6 +1503,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1477,6 +1537,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1507,6 +1568,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1517,7 +1579,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { vec![vec!["ephemeral-command".to_string()]] ); assert_eq!( - load_last_sandbox("openshell"), + load_last_sandbox("openshell", "default"), None, "no-keep sandboxes should not be persisted as last-used" ); @@ -1541,6 +1603,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { tty_override: Some(true), ..test_config() }, + "default", &tls, ) .await @@ -1551,7 +1614,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { vec![vec!["ephemeral-shell".to_string()]] ); assert_eq!( - load_last_sandbox("openshell"), + load_last_sandbox("openshell", "default"), None, "no-keep shell sessions should not be persisted as last-used" ); @@ -1574,6 +1637,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1581,7 +1645,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { assert!(deleted_names(&server).await.is_empty()); assert_eq!( - load_last_sandbox("openshell").as_deref(), + load_last_sandbox("openshell", "default").as_deref(), Some("persistent-keep"), "persistent sandboxes should remain selectable as last-used" ); @@ -1609,6 +1673,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { command: &["echo".into(), "OK".into()], ..test_config() }, + "default", &tls, ) .await @@ -1636,9 +1701,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"); @@ -1666,9 +1738,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"), @@ -1689,9 +1768,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"), @@ -1748,6 +1834,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 8e799f821e..e115c3dada 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -79,6 +79,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: String::new(), }), ..Default::default() }), @@ -561,6 +562,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 { @@ -628,7 +678,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"); @@ -645,7 +695,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"); @@ -662,16 +712,16 @@ async fn sandbox_get_with_persisted_last_sandbox() { let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); // Persist a last-used sandbox for "integration-cluster". - save_last_sandbox("integration-cluster", "persisted-sb") + save_last_sandbox("integration-cluster", "default", "persisted-sb") .expect("save_last_sandbox should succeed"); // Resolve the name (simulates what the CLI does in main.rs). - let resolved = load_last_sandbox("integration-cluster") + let resolved = load_last_sandbox("integration-cluster", "default") .expect("load_last_sandbox should return the saved name"); 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"); @@ -695,6 +745,7 @@ async fn policy_get_full_json_cli_prints_policy_payload() { 0, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -743,6 +794,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), ) @@ -784,6 +836,7 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() { 3, run::PolicyGetView::Full, "json", + "default", &ts.tls, (&mut stdout, &mut stderr), ) @@ -819,9 +872,9 @@ async fn explicit_name_takes_precedence_over_persisted() { let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); // Persist one name, but supply a different one explicitly. - save_last_sandbox("my-cluster", "old-sandbox").expect("save should succeed"); + save_last_sandbox("my-cluster", "default", "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-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a1..e5eba760d8 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -27,6 +27,9 @@ pub const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name"; /// Container/pod label carrying the sandbox namespace. pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; +/// Container/pod label carrying the sandbox workspace. +pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; + // --------------------------------------------------------------------------- /// Path to the sandbox supervisor binary inside the container image. diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 57f6bdd816..7af3734941 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -546,6 +546,36 @@ mod auth_tests { } } +#[cfg(test)] +mod workspace_tests { + use super::*; + + #[test] + fn cached_client_workspace_defaults_to_empty_before_poll() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + assert_eq!(client_ws.get().cloned().unwrap_or_default(), ""); + } + + #[test] + fn cached_client_workspace_returns_learned_value() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + let _ = client_ws.set("beta".to_string()); + assert_eq!(client_ws.get().cloned().unwrap_or_default(), "beta"); + } + + #[test] + fn cached_client_workspace_is_set_once() { + let client_ws: Arc> = Arc::new(tokio::sync::OnceCell::new()); + let _ = client_ws.set("beta".to_string()); + let _ = client_ws.set("gamma".to_string()); + assert_eq!( + client_ws.get().cloned().unwrap_or_default(), + "beta", + "workspace should not change after first poll" + ); + } +} + /// Connect to the `OpenShell` server. async fn connect(endpoint: &str) -> Result> { let channel = connect_channel(endpoint).await?; @@ -622,6 +652,7 @@ async fn sync_policy_with_client( client: &mut OpenShellClient, sandbox: &str, policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result<()> { client .update_config(UpdateConfigRequest { @@ -633,6 +664,7 @@ async fn sync_policy_with_client( global: false, merge_operations: vec![], expected_resource_version: 0, + workspace: workspace.to_string(), }) .await .into_diagnostic() @@ -650,6 +682,7 @@ pub async fn discover_and_sync_policy( sandbox_id: &str, sandbox: &str, discovered_policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { debug!( endpoint = %endpoint, @@ -661,7 +694,7 @@ pub async fn discover_and_sync_policy( let mut client = connect(endpoint).await?; // Sync the discovered policy to the gateway. - sync_policy_with_client(&mut client, sandbox, discovered_policy).await?; + sync_policy_with_client(&mut client, sandbox, discovered_policy, workspace).await?; // Re-fetch from the gateway to get the canonical version/hash. fetch_policy_with_client(&mut client, sandbox_id) @@ -675,10 +708,15 @@ pub async fn discover_and_sync_policy( /// /// Used by the supervisor to push baseline-path-enriched policies so the /// gateway stores the effective policy users see via `openshell sandbox get`. -pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPolicy) -> Result<()> { +pub async fn sync_policy( + endpoint: &str, + sandbox: &str, + policy: &ProtoSandboxPolicy, + workspace: &str, +) -> Result<()> { debug!(endpoint = %endpoint, sandbox = %sandbox, "Syncing enriched policy to gateway"); let mut client = connect(endpoint).await?; - sync_policy_with_client(&mut client, sandbox, policy).await + sync_policy_with_client(&mut client, sandbox, policy, workspace).await } /// Sync an enriched policy and return the authoritative revision snapshot. @@ -687,9 +725,10 @@ pub async fn sync_policy_and_fetch_snapshot( sandbox_id: &str, sandbox: &str, policy: &ProtoSandboxPolicy, + workspace: &str, ) -> Result { let mut client = connect(endpoint).await?; - sync_policy_with_client(&mut client, sandbox, policy).await?; + sync_policy_with_client(&mut client, sandbox, policy, workspace).await?; fetch_settings_snapshot_with_client(&mut client, sandbox_id).await } @@ -729,6 +768,7 @@ pub async fn fetch_provider_environment( #[derive(Clone)] pub struct CachedOpenShellClient { client: OpenShellClient, + workspace: Arc>, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -744,6 +784,8 @@ pub struct SettingsPollResult { /// When `policy_source` is `Global`, the version of the global policy revision. pub global_policy_version: u32, pub provider_env_revision: u64, + /// Workspace the sandbox belongs to. + pub workspace: String, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -757,6 +799,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin settings: inner.settings, global_policy_version: inner.global_policy_version, provider_env_revision: inner.provider_env_revision, + workspace: inner.workspace, } } @@ -771,7 +814,10 @@ impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); let client = connect(endpoint).await?; - Ok(Self { client }) + Ok(Self { + client, + workspace: Arc::new(tokio::sync::OnceCell::new()), + }) } /// Get a clone of the underlying tonic client for direct RPC calls. @@ -790,7 +836,20 @@ impl CachedOpenShellClient { .await .into_diagnostic()?; - Ok(settings_poll_result(response.into_inner())) + let result = settings_poll_result(response.into_inner()); + let _ = self.workspace.set(result.workspace.clone()); + Ok(result) + } + + /// Returns the workspace learned from the server, or empty if not yet polled. + pub fn workspace(&self) -> String { + self.workspace.get().cloned().unwrap_or_default() + } + + /// Pre-seed the workspace without polling. The value is ignored if the + /// workspace was already learned from `poll_settings`. + pub fn set_workspace(&self, workspace: String) { + let _ = self.workspace.set(workspace); } /// Submit denial summaries and/or agent-authored proposals for policy analysis. @@ -816,6 +875,7 @@ impl CachedOpenShellClient { proposed_chunks, network_activity_summaries, analysis_mode: analysis_mode.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; @@ -838,6 +898,7 @@ impl CachedOpenShellClient { .get_draft_policy(GetDraftPolicyRequest { name: sandbox_name.to_string(), status_filter: status_filter.to_string(), + workspace: self.workspace(), }) .await .into_diagnostic()?; diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 3212963691..4eb3817760 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -42,7 +42,9 @@ pub use config::{ TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; -pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; +pub use metadata::{ + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, +}; /// Build version string derived from git metadata. /// diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index af26f73ae0..8794c11d5d 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -7,7 +7,7 @@ use crate::proto::{ InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, + StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, WorkspaceMember, }; use std::collections::HashMap; @@ -36,6 +36,12 @@ pub trait GetResourceVersion { fn get_resource_version(&self) -> u64; } +/// Provides access to the object's workspace for persistence scoping. +pub trait ObjectWorkspace { + fn object_workspace(&self) -> &str; + fn requires_workspace() -> bool; +} + // Implementations for Sandbox impl ObjectId for Sandbox { fn object_id(&self) -> &str { @@ -69,6 +75,15 @@ impl GetResourceVersion for Sandbox { } } +impl ObjectWorkspace for Sandbox { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + impl Sandbox { pub fn phase(&self) -> i32 { self.status.as_ref().map_or(0, |s| s.phase) @@ -89,6 +104,49 @@ impl Sandbox { } } +// Implementations for Workspace +impl ObjectId for Workspace { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for Workspace { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for Workspace { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for Workspace { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for Workspace { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for Workspace { + #[allow(clippy::unnecessary_literal_bound)] + fn object_workspace(&self) -> &str { + "" + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for Provider impl ObjectId for Provider { fn object_id(&self) -> &str { @@ -122,6 +180,15 @@ impl GetResourceVersion for Provider { } } +impl ObjectWorkspace for Provider { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for StoredProviderProfile impl ObjectId for StoredProviderProfile { fn object_id(&self) -> &str { @@ -155,6 +222,15 @@ impl GetResourceVersion for StoredProviderProfile { } } +impl ObjectWorkspace for StoredProviderProfile { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + false + } +} + // Implementations for StoredProviderCredentialRefreshState impl ObjectId for StoredProviderCredentialRefreshState { fn object_id(&self) -> &str { @@ -188,6 +264,15 @@ impl GetResourceVersion for StoredProviderCredentialRefreshState { } } +impl ObjectWorkspace for StoredProviderCredentialRefreshState { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for SshSession impl ObjectId for SshSession { fn object_id(&self) -> &str { @@ -221,6 +306,15 @@ impl GetResourceVersion for SshSession { } } +impl ObjectWorkspace for SshSession { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for ServiceEndpoint impl ObjectId for ServiceEndpoint { fn object_id(&self) -> &str { @@ -254,6 +348,15 @@ impl GetResourceVersion for ServiceEndpoint { } } +impl ObjectWorkspace for ServiceEndpoint { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for InferenceRoute impl ObjectId for InferenceRoute { fn object_id(&self) -> &str { @@ -287,6 +390,57 @@ impl GetResourceVersion for InferenceRoute { } } +impl ObjectWorkspace for InferenceRoute { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + +// Implementations for WorkspaceMember +impl ObjectId for WorkspaceMember { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for WorkspaceMember { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for WorkspaceMember { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for WorkspaceMember { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for WorkspaceMember { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + +impl ObjectWorkspace for WorkspaceMember { + fn object_workspace(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) + } + fn requires_workspace() -> bool { + true + } +} + // Implementations for ObjectForTest (test-only proto type) impl ObjectId for ObjectForTest { fn object_id(&self) -> &str { @@ -318,3 +472,13 @@ impl GetResourceVersion for ObjectForTest { 0 } } + +impl ObjectWorkspace for ObjectForTest { + #[allow(clippy::unnecessary_literal_bound)] + fn object_workspace(&self) -> &str { + "" + } + fn requires_workspace() -> bool { + false + } +} diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index d9bea99e7c..f416623cfa 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -25,7 +25,8 @@ use openshell_core::config::{ use openshell_core::driver_mounts; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, SUPERVISOR_IMAGE_BINARY_PATH, supervisor_image_should_refresh, + LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, + supervisor_image_should_refresh, }; use openshell_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, @@ -1584,6 +1585,7 @@ fn pending_sandbox_snapshot( conditions: vec![condition], deleting, }), + workspace: sandbox.workspace.clone(), } } @@ -2341,6 +2343,10 @@ fn build_container_create_body_with_gpu_devices( ); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); + labels.insert( + LABEL_SANDBOX_WORKSPACE.to_string(), + sandbox.workspace.clone(), + ); // The list/get/find paths filter by `config.sandbox_namespace`, so use // the same value here. `DriverSandbox.namespace` is unset on the request // path (the gateway elides it), and using it would produce containers @@ -2772,6 +2778,10 @@ fn sandbox_from_container_summary( .get(LABEL_SANDBOX_NAMESPACE) .cloned() .unwrap_or_default(); + let workspace = labels + .get(LABEL_SANDBOX_WORKSPACE) + .cloned() + .unwrap_or_default(); let supervisor_connected = readiness.is_supervisor_connected(&id); Some(DriverSandbox { @@ -2784,6 +2794,7 @@ fn sandbox_from_container_summary( &name, supervisor_connected, )), + workspace, }) } @@ -2957,24 +2968,26 @@ const CONTAINER_NAME_PREFIX: &str = "openshell-"; fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String { let id_suffix = sanitize_docker_name(&sandbox.id); + let workspace = sanitize_docker_name(&sandbox.workspace); let name = sanitize_docker_name(&sandbox.name); + + // Format: openshell-{workspace}--{name}-{id} + // The workspace and id are never truncated — they ensure uniqueness. + // Only the sandbox name portion is truncated when the total exceeds + // MAX_CONTAINER_NAME_LEN. + if name.is_empty() { - let mut base = format!("{CONTAINER_NAME_PREFIX}{id_suffix}"); - // The prefix is always < MAX_CONTAINER_NAME_LEN. Truncate the id - // suffix only if the sandbox id itself is pathologically long. + let mut base = format!("{CONTAINER_NAME_PREFIX}{workspace}---{id_suffix}"); if base.len() > MAX_CONTAINER_NAME_LEN { base.truncate(MAX_CONTAINER_NAME_LEN); } - return base; + return trim_container_name_tail(base); } - // Reserve space for the prefix and the `-` tail so the id - // suffix — which is what makes the name unique between sandboxes that - // share a human-readable prefix — is never truncated away. - let reserved = CONTAINER_NAME_PREFIX.len() + 1 + id_suffix.len(); + // Reserve space for fixed parts: prefix + workspace + "--" + "-" + id + let reserved = CONTAINER_NAME_PREFIX.len() + workspace.len() + 2 + 1 + id_suffix.len(); if reserved >= MAX_CONTAINER_NAME_LEN { - // Pathological sandbox id. Fall back to `` and truncate. - let mut base = format!("{CONTAINER_NAME_PREFIX}{id_suffix}"); + let mut base = format!("{CONTAINER_NAME_PREFIX}{workspace}---{id_suffix}"); base.truncate(MAX_CONTAINER_NAME_LEN); return trim_container_name_tail(base); } @@ -2985,7 +2998,7 @@ fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String { } else { name }; - format!("{CONTAINER_NAME_PREFIX}{truncated_name}-{id_suffix}") + format!("{CONTAINER_NAME_PREFIX}{workspace}--{truncated_name}-{id_suffix}") } /// Docker container names may not end with `-`, `.`, or `_`. Truncation can diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index d42d41099d..c0c823d155 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -46,6 +46,7 @@ fn test_sandbox() -> DriverSandbox { sandbox_token: String::new(), }), status: None, + workspace: String::new(), } } @@ -1947,6 +1948,7 @@ fn container_name_preserves_id_suffix_for_long_names() { namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }; let second = DriverSandbox { id: "sbx-second-0987654321".to_string(), @@ -1972,15 +1974,19 @@ fn container_name_preserves_id_suffix_for_long_names() { } #[test] -fn container_name_empty_sandbox_name_uses_id_only() { +fn container_name_empty_sandbox_name_uses_workspace_and_id() { let sandbox = DriverSandbox { id: "sbx-abc".to_string(), name: String::new(), namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }; - assert_eq!(container_name_for_sandbox(&sandbox), "openshell-sbx-abc",); + assert_eq!( + container_name_for_sandbox(&sandbox), + "openshell-default---sbx-abc", + ); } #[test] diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 166f18b1cc..049dcdeec8 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -16,7 +16,8 @@ use kube::core::{DynamicObject, ObjectMeta}; use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; use openshell_core::driver_utils::{ - LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, SUPERVISOR_IMAGE_BINARY_PATH, + LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, + LABEL_SANDBOX_WORKSPACE, SUPERVISOR_IMAGE_BINARY_PATH, }; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::progress::{ @@ -419,6 +420,7 @@ impl KubernetesComputeDriver { pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), tonic::Status> { let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) .map_err(tonic::Status::invalid_argument)?; + validate_kube_resource_name_length(&sandbox.workspace, &sandbox.name)?; let gpu_requirements = sandbox .spec .as_ref() @@ -436,9 +438,9 @@ impl KubernetesComputeDriver { Ok(()) } - pub async fn get_sandbox(&self, name: &str) -> Result, String> { + pub async fn get_sandbox(&self, sandbox_id: &str) -> Result, String> { info!( - sandbox_name = %name, + sandbox_id = %sandbox_id, namespace = %self.config.namespace, "Fetching sandbox from Kubernetes" ); @@ -446,15 +448,19 @@ impl KubernetesComputeDriver { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; - match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.get(name)).await { - Ok(Ok(obj)) => sandbox_from_object(&self.config.namespace, obj).map(Some), - Ok(Err(KubeError::Api(err))) if err.code == 404 => { - debug!(sandbox_name = %name, "Sandbox not found in Kubernetes"); - Ok(None) - } + let selector = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { + Ok(Ok(list)) => list.items.into_iter().next().map_or_else( + || { + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes"); + Ok(None) + }, + |obj| Ok(sandbox_from_object(&self.config.namespace, obj).ok().map(|(_, s)| s)), + ), Ok(Err(err)) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, error = %err, "Failed to fetch sandbox from Kubernetes" ); @@ -462,7 +468,7 @@ impl KubernetesComputeDriver { } Err(_elapsed) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, timeout_secs = KUBE_API_TIMEOUT.as_secs(), "Timed out fetching sandbox from Kubernetes" ); @@ -485,16 +491,21 @@ impl KubernetesComputeDriver { .await?; match tokio::time::timeout( KUBE_API_TIMEOUT, - agent_sandbox_api.api.list(&ListParams::default()), + agent_sandbox_api + .api + .list(&ListParams::default().labels(&format!( + "{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE}" + ))), ) .await { Ok(Ok(list)) => { - let mut sandboxes = list + let mut sandboxes: Vec = list .items .into_iter() - .map(|obj| sandbox_from_object(&self.config.namespace, obj)) - .collect::, _>>()?; + .filter_map(|obj| sandbox_from_object(&self.config.namespace, obj).ok()) + .map(|(_, s)| s) + .collect(); sandboxes.sort_by(|left, right| { left.name .cmp(&right.name) @@ -578,30 +589,25 @@ impl KubernetesComputeDriver { sandbox_gid: resolved_gid, }; - let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource); + let kube_name = kube_resource_name(&sandbox.workspace, name); + let mut obj = DynamicObject::new(&kube_name, &agent_sandbox_api.resource); // Copy only the SCC-related annotations onto the Sandbox CR for // traceability. Copying the full namespace annotation map exposes // unrelated cluster metadata and can fail with oversized annotations. - let scc_annotations: BTreeMap = [ + let mut annotations = sandbox_annotations(sandbox); + for key in [ crate::config::ANNOTATION_SCC_UID_RANGE, crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS, - ] - .iter() - .filter_map(|key| { - ns_annotations - .get(*key) - .map(|v| ((*key).to_string(), v.clone())) - }) - .collect(); + ] { + if let Some(v) = ns_annotations.get(key) { + annotations.insert(key.to_string(), v.clone()); + } + } obj.metadata = ObjectMeta { - name: Some(name.to_string()), + name: Some(kube_name), namespace: Some(self.config.namespace.clone()), labels: Some(sandbox_labels(sandbox)), - annotations: if scc_annotations.is_empty() { - None - } else { - Some(scc_annotations) - }, + annotations: Some(annotations), ..Default::default() }; @@ -644,9 +650,9 @@ impl KubernetesComputeDriver { } } - pub async fn delete_sandbox(&self, name: &str) -> Result { + pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { info!( - sandbox_name = %name, + sandbox_id = %sandbox_id, namespace = %self.config.namespace, "Deleting sandbox from Kubernetes" ); @@ -654,23 +660,65 @@ impl KubernetesComputeDriver { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; + let selector = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + let kube_name = match tokio::time::timeout( + KUBE_API_TIMEOUT, + agent_sandbox_api.api.list(&lp), + ) + .await + { + Ok(Ok(list)) => { + if let Some(obj) = list.items.into_iter().next() { + match obj.metadata.name { + Some(name) => name, + None => return Ok(false), + } + } else { + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted)"); + return Ok(false); + } + } + Ok(Err(err)) => { + warn!( + sandbox_id = %sandbox_id, + error = %err, + "Failed to list sandbox for deletion from Kubernetes" + ); + return Err(err.to_string()); + } + Err(_elapsed) => { + warn!( + sandbox_id = %sandbox_id, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Timed out listing sandbox for deletion from Kubernetes" + ); + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + }; + match tokio::time::timeout( KUBE_API_TIMEOUT, - agent_sandbox_api.api.delete(name, &DeleteParams::default()), + agent_sandbox_api + .api + .delete(&kube_name, &DeleteParams::default()), ) .await { Ok(Ok(_response)) => { - info!(sandbox_name = %name, "Sandbox deleted from Kubernetes"); + info!(sandbox_id = %sandbox_id, "Sandbox deleted from Kubernetes"); Ok(true) } Ok(Err(KubeError::Api(err))) if err.code == 404 => { - debug!(sandbox_name = %name, "Sandbox not found in Kubernetes (already deleted)"); + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes (already deleted)"); Ok(false) } Ok(Err(err)) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, error = %err, "Failed to delete sandbox from Kubernetes" ); @@ -678,7 +726,7 @@ impl KubernetesComputeDriver { } Err(_elapsed) => { warn!( - sandbox_name = %name, + sandbox_id = %sandbox_id, timeout_secs = KUBE_API_TIMEOUT.as_secs(), "Timed out deleting sandbox from Kubernetes" ); @@ -690,13 +738,14 @@ impl KubernetesComputeDriver { } } - pub async fn sandbox_exists(&self, name: &str) -> Result { + pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result { let agent_sandbox_api = self .supported_agent_sandbox_api(self.client.clone()) .await?; - match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.get(name)).await { - Ok(Ok(_)) => Ok(true), - Ok(Err(KubeError::Api(err))) if err.code == 404 => Ok(false), + let selector = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let lp = ListParams::default().labels(&selector); + match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { + Ok(Ok(list)) => Ok(!list.items.is_empty()), Ok(Err(err)) => Err(err.to_string()), Err(_elapsed) => Err(format!( "timed out after {}s waiting for Kubernetes API", @@ -713,8 +762,10 @@ impl KubernetesComputeDriver { .supported_agent_sandbox_api(self.watch_client.clone()) .await?; let event_api: Api = Api::namespaced(self.watch_client.clone(), &namespace); + let watcher_config = watcher::Config::default() + .labels(&format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE}")); let mut sandbox_stream = - watcher::watcher(agent_sandbox_api.api, watcher::Config::default()).boxed(); + watcher::watcher(agent_sandbox_api.api, watcher_config).boxed(); let mut event_stream = watcher::watcher(event_api, watcher::Config::default()).boxed(); let (tx, rx) = mpsc::channel(256); @@ -726,28 +777,21 @@ impl KubernetesComputeDriver { tokio::select! { result = sandbox_stream.try_next() => match result { Ok(Some(Event::Applied(obj))) => { - match sandbox_from_object(&namespace, obj) { - Ok(sandbox) => { - update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox); - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } - )), - }; - if tx.send(Ok(event)).await.is_err() { - break; - } - } - Err(err) => { - if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() { - break; - } + if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { + update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; } } } Ok(Some(Event::Deleted(obj))) => { - match sandbox_id_from_object(&obj) { - Ok(sandbox_id) => { + if is_openshell_managed(&obj) { + if let Ok(sandbox_id) = sandbox_id_from_object(&obj) { remove_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox_id); let event = WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Deleted( @@ -758,31 +802,19 @@ impl KubernetesComputeDriver { break; } } - Err(err) => { - if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() { - break; - } - } } } Ok(Some(Event::Restarted(objs))) => { for obj in objs { - match sandbox_from_object(&namespace, obj) { - Ok(sandbox) => { - update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox); - let event = WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } - )), - }; - if tx.send(Ok(event)).await.is_err() { - return; - } - } - Err(err) => { - if tx.send(Err(KubernetesDriverError::Message(err))).await.is_err() { - return; - } + if let Ok((kube_name, sandbox)) = sandbox_from_object(&namespace, obj) { + update_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &kube_name, &sandbox); + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + return; } } } @@ -854,9 +886,31 @@ fn validate_gpu_request( Ok(()) } +fn kube_resource_name(workspace: &str, name: &str) -> String { + format!("{workspace}--{name}") +} + +const MAX_KUBE_NAME_LEN: usize = 63; + +fn validate_kube_resource_name_length(workspace: &str, name: &str) -> Result<(), tonic::Status> { + let combined = workspace.len() + 2 + name.len(); // "--" separator + if combined > MAX_KUBE_NAME_LEN { + return Err(tonic::Status::invalid_argument(format!( + "combined Kubernetes resource name '{workspace}--{name}' is {combined} characters, \ + exceeding the DNS-1123 limit of {MAX_KUBE_NAME_LEN}" + ))); + } + Ok(()) +} + fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { let mut labels = BTreeMap::new(); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); + labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); + labels.insert( + LABEL_SANDBOX_WORKSPACE.to_string(), + sandbox.workspace.clone(), + ); labels.insert( LABEL_MANAGED_BY.to_string(), LABEL_MANAGED_BY_VALUE.to_string(), @@ -864,24 +918,79 @@ fn sandbox_labels(sandbox: &Sandbox) -> BTreeMap { labels } +fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { + let mut annotations = BTreeMap::new(); + annotations.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); + annotations.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); + annotations.insert( + LABEL_SANDBOX_WORKSPACE.to_string(), + sandbox.workspace.clone(), + ); + annotations +} + fn sandbox_id_from_object(obj: &DynamicObject) -> Result { + if let Some(annotations) = obj.metadata.annotations.as_ref() + && let Some(id) = annotations.get(LABEL_SANDBOX_ID) + { + return Ok(id.clone()); + } if let Some(labels) = obj.metadata.labels.as_ref() && let Some(id) = labels.get(LABEL_SANDBOX_ID) { return Ok(id.clone()); } + Err("sandbox id not found on object".to_string()) +} - let name = obj.metadata.name.clone().unwrap_or_default(); - if let Some(id) = name.strip_prefix("sandbox-") { - return Ok(id.to_string()); - } +fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { + obj.metadata + .annotations + .as_ref() + .and_then(|a| a.get(key)) + .or_else(|| obj.metadata.labels.as_ref().and_then(|l| l.get(key))) + .cloned() +} - Err("sandbox id not found on object".to_string()) +fn is_openshell_managed(obj: &DynamicObject) -> bool { + annotation_or_label(obj, LABEL_MANAGED_BY).as_deref() == Some(LABEL_MANAGED_BY_VALUE) } -fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result { - let id = sandbox_id_from_object(&obj)?; - let name = obj.metadata.name.clone().unwrap_or_default(); +/// Returns `(kube_resource_name, DriverSandbox)`. +/// +/// Returns `Err` in two cases (callers should skip, not fail): +/// - The object is not managed by OpenShell (missing/wrong `managed-by` label). +/// - The object is managed by OpenShell but missing required fields (orphan). +fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result<(String, Sandbox), String> { + let kube_name = obj.metadata.name.clone().unwrap_or_default(); + + if !is_openshell_managed(&obj) { + debug!(object = %kube_name, "skipping sandbox CR not managed by openshell"); + return Err(format!("object {kube_name} not managed by openshell")); + } + + let id = match sandbox_id_from_object(&obj) { + Ok(id) => id, + Err(_) => { + warn!(object = %kube_name, "openshell-managed sandbox CR missing id"); + return Err(format!("object {kube_name} missing sandbox id")); + } + }; + let name = match annotation_or_label(&obj, LABEL_SANDBOX_NAME) { + Some(n) => n, + None => { + warn!(object = %kube_name, "openshell-managed sandbox CR missing name"); + return Err(format!("object {kube_name} missing sandbox name")); + } + }; + let workspace = match annotation_or_label(&obj, LABEL_SANDBOX_WORKSPACE) { + Some(w) => w, + None => { + warn!(object = %kube_name, "openshell-managed sandbox CR missing workspace"); + return Err(format!("object {kube_name} missing sandbox workspace")); + } + }; + let namespace = obj .metadata .namespace @@ -889,22 +998,27 @@ fn sandbox_from_object(namespace: &str, obj: DynamicObject) -> Result, agent_pod_to_id: &mut std::collections::HashMap, + kube_name: &str, sandbox: &Sandbox, ) { - if !sandbox.name.is_empty() { - sandbox_name_to_id.insert(sandbox.name.clone(), sandbox.id.clone()); + if !kube_name.is_empty() { + sandbox_name_to_id.insert(kube_name.to_string(), sandbox.id.clone()); } if let Some(status) = sandbox.status.as_ref() && !status.instance_id.is_empty() @@ -3842,4 +3956,185 @@ mod tests { let storage = &vct[0]["spec"]["resources"]["requests"]["storage"]; assert_eq!(storage, DEFAULT_WORKSPACE_STORAGE_SIZE); } + + #[test] + fn kube_resource_name_qualifies_with_workspace() { + assert_eq!(kube_resource_name("alpha", "work"), "alpha--work"); + assert_eq!( + kube_resource_name("default", "my-sandbox"), + "default--my-sandbox" + ); + } + + #[test] + fn kube_resource_name_different_workspaces_produce_different_names() { + let alpha = kube_resource_name("alpha", "work"); + let beta = kube_resource_name("beta", "work"); + assert_ne!(alpha, beta); + } + + #[test] + fn kube_resource_name_length_validation_accepts_short_names() { + validate_kube_resource_name_length("default", "my-sandbox").unwrap(); + } + + #[test] + fn kube_resource_name_length_validation_rejects_oversized_names() { + let long_ws = "a".repeat(40); + let long_name = "b".repeat(25); + let err = validate_kube_resource_name_length(&long_ws, &long_name).unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("67")); + } + + #[test] + fn sandbox_from_object_reads_annotations() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("alpha--work".to_string()), + namespace: Some("default".to_string()), + annotations: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-123".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()), + ])), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-123".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let (kube_name, sandbox) = sandbox_from_object("default", obj).unwrap(); + assert_eq!(kube_name, "alpha--work"); + assert_eq!(sandbox.name, "work"); + assert_eq!(sandbox.workspace, "alpha"); + assert_eq!(sandbox.id, "uuid-123"); + } + + #[test] + fn sandbox_from_object_falls_back_to_labels() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("alpha--work".to_string()), + namespace: Some("default".to_string()), + annotations: None, + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-456".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + (LABEL_SANDBOX_WORKSPACE.to_string(), "alpha".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let (_, sandbox) = sandbox_from_object("default", obj).unwrap(); + assert_eq!(sandbox.name, "work"); + assert_eq!(sandbox.workspace, "alpha"); + assert_eq!(sandbox.id, "uuid-456"); + } + + #[test] + fn sandbox_from_object_skips_unmanaged_cr() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("foreign-sandbox".to_string()), + namespace: Some("default".to_string()), + labels: Some(BTreeMap::from([ + ("some-other-label".to_string(), "value".to_string()), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let result = sandbox_from_object("default", obj); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not managed by openshell")); + } + + #[test] + fn sandbox_from_object_warns_on_managed_cr_missing_workspace() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("work".to_string()), + namespace: Some("default".to_string()), + labels: Some(BTreeMap::from([ + (LABEL_SANDBOX_ID.to_string(), "uuid-789".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "work".to_string()), + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + ])), + ..Default::default() + }, + data: serde_json::json!({}), + }; + + let result = sandbox_from_object("default", obj); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("missing sandbox workspace")); + } + + #[test] + fn sandbox_labels_includes_workspace_and_name() { + let sandbox = Sandbox { + id: "uuid-1".to_string(), + name: "work".to_string(), + workspace: "alpha".to_string(), + ..Default::default() + }; + let labels = sandbox_labels(&sandbox); + assert_eq!(labels.get(LABEL_SANDBOX_ID).unwrap(), "uuid-1"); + assert_eq!(labels.get(LABEL_SANDBOX_NAME).unwrap(), "work"); + assert_eq!(labels.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "alpha"); + assert_eq!( + labels.get(LABEL_MANAGED_BY).unwrap(), + LABEL_MANAGED_BY_VALUE + ); + } + + #[test] + fn sandbox_annotations_stores_authoritative_values() { + let sandbox = Sandbox { + id: "uuid-2".to_string(), + name: "dev".to_string(), + workspace: "beta".to_string(), + ..Default::default() + }; + let annotations = sandbox_annotations(&sandbox); + assert_eq!(annotations.get(LABEL_SANDBOX_ID).unwrap(), "uuid-2"); + assert_eq!(annotations.get(LABEL_SANDBOX_NAME).unwrap(), "dev"); + assert_eq!(annotations.get(LABEL_SANDBOX_WORKSPACE).unwrap(), "beta"); + } + + #[test] + fn sandbox_id_from_object_errors_without_label() { + let obj = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("some-name".to_string()), + ..Default::default() + }, + data: serde_json::json!({}), + }; + assert!(sandbox_id_from_object(&obj).is_err()); + } } diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index eced74f57b..fccfa9464b 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -57,23 +57,17 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); } let sandbox = self .driver - .get_sandbox(&request.sandbox_name) + .get_sandbox(&request.sandbox_id) .await .map_err(Status::internal)? .ok_or_else(|| Status::not_found("sandbox not found"))?; - if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id { - return Err(Status::failed_precondition( - "sandbox_id did not match the fetched sandbox", - )); - } - Ok(Response::new(GetSandboxResponse { sandbox: Some(sandbox), })) @@ -120,9 +114,12 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } let deleted = self .driver - .delete_sandbox(&request.sandbox_name) + .delete_sandbox(&request.sandbox_id) .await .map_err(Status::internal)?; Ok(Response::new(DeleteSandboxResponse { deleted })) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index ad44fca43e..0eac49b54e 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -36,19 +36,17 @@ fn is_selinux_enabled() -> bool { false } -/// Label key for the sandbox ID. -pub const LABEL_SANDBOX_ID: &str = "openshell.sandbox-id"; -/// Label key for the sandbox name. -pub const LABEL_SANDBOX_NAME: &str = "openshell.sandbox-name"; -/// Label key for the sandbox namespace. -pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.sandbox-namespace"; +pub use openshell_core::driver_utils::{ + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, +}; + /// Label applied to all managed containers. pub const LABEL_MANAGED: &str = "openshell.managed"; /// Label filter string for list/event queries. pub const LABEL_MANAGED_FILTER: &str = "openshell.managed=true"; /// Container name prefix to avoid collisions with user containers. -const CONTAINER_PREFIX: &str = "openshell-sandbox-"; +const CONTAINER_PREFIX: &str = "openshell-"; /// Volume name prefix. const VOLUME_PREFIX: &str = "openshell-sandbox-"; @@ -144,10 +142,12 @@ fn default_true() -> bool { true } -/// Build a Podman container name from the sandbox name. +/// Build a Podman container name from the sandbox workspace, name, and ID. +/// +/// Format: `openshell-{workspace}--{name}-{id}` #[must_use] -pub fn container_name(sandbox_name: &str) -> String { - format!("{CONTAINER_PREFIX}{sandbox_name}") +pub fn container_name(workspace: &str, name: &str, id: &str) -> String { + format!("{CONTAINER_PREFIX}{workspace}--{name}-{id}") } /// Build the workspace volume name from the sandbox ID. @@ -464,6 +464,7 @@ fn build_labels(sandbox: &DriverSandbox) -> BTreeMap { labels.insert(LABEL_SANDBOX_ID.into(), sandbox.id.clone()); labels.insert(LABEL_SANDBOX_NAME.into(), sandbox.name.clone()); labels.insert(LABEL_SANDBOX_NAMESPACE.into(), sandbox.namespace.clone()); + labels.insert(LABEL_SANDBOX_WORKSPACE.into(), sandbox.workspace.clone()); labels.insert(LABEL_MANAGED.into(), "true".into()); labels @@ -832,7 +833,7 @@ pub fn build_container_spec_with_token_and_gpu_devices( gpu_device_ids: Option<&[String]>, ) -> Result { let image = resolve_image(sandbox, config); - let name = container_name(&sandbox.name); + let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); let vol = volume_name(&sandbox.id); let env = build_env(sandbox, config, image); @@ -1277,8 +1278,11 @@ mod tests { } #[test] - fn container_name_is_prefixed() { - assert_eq!(container_name("my-sandbox"), "openshell-sandbox-my-sandbox"); + fn container_name_is_workspace_qualified() { + assert_eq!( + container_name("default", "my-sandbox", "abc-123"), + "openshell-default--my-sandbox-abc-123" + ); } #[test] @@ -1683,13 +1687,13 @@ mod tests { let mut sandbox = test_sandbox("real-id", "real-name"); sandbox.namespace = "real-namespace".to_string(); let mut label_overrides = std::collections::HashMap::new(); - label_overrides.insert("openshell.sandbox-id".to_string(), "spoofed-id".to_string()); + label_overrides.insert("openshell.ai/sandbox-id".to_string(), "spoofed-id".to_string()); label_overrides.insert( - "openshell.sandbox-name".to_string(), + "openshell.ai/sandbox-name".to_string(), "spoofed-name".to_string(), ); label_overrides.insert( - "openshell.sandbox-namespace".to_string(), + "openshell.ai/sandbox-namespace".to_string(), "spoofed-namespace".to_string(), ); sandbox.spec = Some(DriverSandboxSpec { @@ -1707,20 +1711,20 @@ mod tests { .as_object() .expect("labels should be an object"); assert_eq!( - labels.get("openshell.sandbox-id").and_then(|v| v.as_str()), + labels.get("openshell.ai/sandbox-id").and_then(|v| v.as_str()), Some("real-id"), "openshell.sandbox-id must not be overridden by template labels" ); assert_eq!( labels - .get("openshell.sandbox-name") + .get("openshell.ai/sandbox-name") .and_then(|v| v.as_str()), Some("real-name"), "openshell.sandbox-name must not be overridden by template labels" ); assert_eq!( labels - .get("openshell.sandbox-namespace") + .get("openshell.ai/sandbox-namespace") .and_then(|v| v.as_str()), Some("real-namespace"), "openshell.sandbox-namespace must not be overridden by template labels" @@ -1773,6 +1777,7 @@ mod tests { namespace: String::new(), spec: None, status: None, + workspace: String::new(), } } diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index cf639fa265..b606443c98 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -22,7 +22,7 @@ use openshell_core::proto::compute::v1::{ use std::path::Path; use std::sync::Arc; use std::time::Duration; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; impl From for ComputeDriverError { fn from(value: PodmanApiError) -> Self { @@ -62,12 +62,12 @@ struct ValidatedPodmanSandbox<'a> { gpu_requirements: Option<&'a GpuResourceRequirements>, } -/// Construct and validate a container name from a sandbox name. +/// Construct and validate a container name from a sandbox. /// -/// Combines the prefix with the sandbox name and validates the result -/// against Podman's naming rules before any resources are created. -fn validated_container_name(sandbox_name: &str) -> Result { - let name = container::container_name(sandbox_name); +/// Combines the prefix with workspace, name, and ID, then validates the +/// result against Podman's naming rules before any resources are created. +fn validated_container_name(sandbox: &DriverSandbox) -> Result { + let name = container::container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); crate::client::validate_name(&name) .map_err(|e| ComputeDriverError::Precondition(e.to_string()))?; Ok(name) @@ -451,7 +451,7 @@ impl PodmanComputeDriver { // Validate the composed container name early, before creating any // resources (volume), so we don't leave orphans when the name is // invalid. - let name = validated_container_name(&sandbox.name)?; + let name = validated_container_name(sandbox)?; let validated = self.validated_sandbox_create(sandbox).await?; let vol_name = container::volume_name(&sandbox.id); @@ -593,10 +593,29 @@ impl PodmanComputeDriver { Ok(()) } + /// Find the Podman container name for a sandbox by its ID using label lookup. + async fn find_container_name_by_id( + &self, + sandbox_id: &str, + ) -> Result, ComputeDriverError> { + let filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&filter) + .await + .map_err(ComputeDriverError::from)?; + Ok(entries.first().and_then(|e| e.names.first().cloned())) + } + /// Stop a sandbox container without deleting it. - pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), ComputeDriverError> { - let name = validated_container_name(sandbox_name)?; - info!(sandbox_name = %sandbox_name, container = %name, "Stopping sandbox container"); + pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), ComputeDriverError> { + let name = self + .find_container_name_by_id(sandbox_id) + .await? + .ok_or_else(|| { + ComputeDriverError::Precondition("sandbox container not found".into()) + })?; + info!(sandbox_id = %sandbox_id, container = %name, "Stopping sandbox container"); self.client .stop_container(&name, self.config.stop_timeout_secs) @@ -605,52 +624,24 @@ impl PodmanComputeDriver { } /// Delete a sandbox container and its workspace volume. - pub async fn delete_sandbox( - &self, - sandbox_id: &str, - sandbox_name: &str, - ) -> Result { + pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result { if sandbox_id.is_empty() { return Err(ComputeDriverError::Precondition( "sandbox id is required".into(), )); } - let name = validated_container_name(sandbox_name)?; - info!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - container = %name, - "Deleting sandbox container" - ); - // Use the request's stable sandbox ID as the source of truth for - // cleanup. Inspect is only used as a best-effort cross-check so - // cleanup still works if the container is already gone or mislabeled. - match self.client.inspect_container(&name).await { - Ok(inspect) => match inspect.config.labels.get(LABEL_SANDBOX_ID) { - Some(label_id) if label_id != sandbox_id => { - warn!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - container = %name, - label_sandbox_id = %label_id, - "Container label sandbox ID did not match delete request; cleaning up using request sandbox_id" - ); - } - None => { - warn!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - container = %name, - "Container missing '{}' label; cleaning up using request sandbox_id", - LABEL_SANDBOX_ID, - ); - } - Some(_) => {} - }, - Err(PodmanApiError::NotFound(_)) => {} - Err(e) => return Err(ComputeDriverError::from(e)), - } + let Some(name) = self.find_container_name_by_id(sandbox_id).await? else { + debug!(sandbox_id = %sandbox_id, "Sandbox container not found (already deleted)"); + let vol = container::volume_name(sandbox_id); + if let Err(e) = self.client.remove_volume(&vol).await { + warn!(sandbox_id = %sandbox_id, volume = %vol, error = %e, "Failed to remove workspace volume"); + } + cleanup_sandbox_token_secret(&self.client, &container::token_secret_name(sandbox_id)) + .await; + return Ok(false); + }; + info!(sandbox_id = %sandbox_id, container = %name, "Deleting sandbox container"); // Stop (best-effort). let _ = self @@ -659,7 +650,7 @@ impl PodmanComputeDriver { .await; // Remove container. If NotFound, the container was removed between - // inspect and here (TOCTOU race); proceed with volume cleanup + // list and here (TOCTOU race); proceed with volume cleanup // since the workspace volume is idempotent to remove. let container_existed = match self.client.remove_container(&name).await { Ok(()) => true, @@ -672,7 +663,6 @@ impl PodmanComputeDriver { if let Err(e) = self.client.remove_volume(&vol).await { warn!( sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, volume = %vol, error = %e, "Failed to remove workspace volume" @@ -684,25 +674,40 @@ impl PodmanComputeDriver { } /// Check whether a sandbox container exists. - pub async fn sandbox_exists(&self, sandbox_name: &str) -> Result { - let name = container::container_name(sandbox_name); - match self.client.inspect_container(&name).await { - Ok(_) => Ok(true), - Err(PodmanApiError::NotFound(_)) => Ok(false), - Err(e) => Err(ComputeDriverError::from(e)), - } + pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result { + let filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&filter) + .await + .map_err(ComputeDriverError::from)?; + Ok(!entries.is_empty()) } - /// Fetch a single sandbox by name. + /// Fetch a single sandbox by ID. pub async fn get_sandbox( &self, - sandbox_name: &str, + sandbox_id: &str, ) -> Result, ComputeDriverError> { - let name = container::container_name(sandbox_name); - match self.client.inspect_container(&name).await { - Ok(inspect) => Ok(driver_sandbox_from_inspect(&inspect)), - Err(PodmanApiError::NotFound(_)) => Ok(None), - Err(e) => Err(ComputeDriverError::from(e)), + let filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}"); + let entries = self + .client + .list_containers(&filter) + .await + .map_err(ComputeDriverError::from)?; + let Some(entry) = entries.first() else { + return Ok(None); + }; + if entry.state == "running" { + Ok(self + .client + .inspect_container(&entry.id) + .await + .ok() + .and_then(|inspect| driver_sandbox_from_inspect(&inspect)) + .or_else(|| driver_sandbox_from_list_entry(entry))) + } else { + Ok(driver_sandbox_from_list_entry(entry)) } } @@ -1312,6 +1317,7 @@ mod tests { ..Default::default() }), status: None, + workspace: String::new(), } } @@ -1470,24 +1476,22 @@ mod tests { } #[tokio::test] - async fn delete_sandbox_cleans_up_with_request_id_when_container_is_already_gone() { + async fn delete_sandbox_cleans_up_volume_when_container_is_already_gone() { let sandbox_id = "sandbox-123"; - let sandbox_name = "demo"; - let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); let (socket_path, request_log, handle) = spawn_podman_stub( "delete-not-found", vec![ - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), + // list_containers returns empty (container already gone) + StubResponse::new(StatusCode::OK, "[]"), + // remove_volume StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let driver = test_driver(socket_path.clone()); let deleted = driver - .delete_sandbox(sandbox_id, sandbox_name) + .delete_sandbox(sandbox_id) .await .expect("delete should succeed"); @@ -1497,67 +1501,48 @@ mod tests { .lock() .expect("request log lock should not be poisoned") .clone(); + assert!(requests[0].contains("/libpod/containers/json")); assert_eq!( - requests, - vec![ - format!( - "GET {}", - api_path(&format!("/libpod/containers/{container_name}/json")) - ), - format!( - "POST {}", - api_path(&format!( - "/libpod/containers/{container_name}/stop?timeout=10" - )) - ), - format!( - "DELETE {}", - api_path(&format!( - "/libpod/containers/{container_name}?force=true&v=true" - )) - ), - format!( - "DELETE {}", - api_path(&format!("/libpod/volumes/{volume_name}")) - ), - ] + requests[1], + format!( + "DELETE {}", + api_path(&format!("/libpod/volumes/{volume_name}")) + ) ); let _ = fs::remove_file(socket_path); } #[tokio::test] - async fn delete_sandbox_uses_request_id_when_container_label_disagrees() { + async fn delete_sandbox_finds_container_by_label_and_removes() { let sandbox_id = "sandbox-request-id"; - let sandbox_name = "demo"; - let container_name = container::container_name(sandbox_name); + let container_name = "openshell-default--demo-sandbox-request-id"; let volume_name = container::volume_name(sandbox_id); - let inspect_body = serde_json::json!({ + let list_body = serde_json::json!([{ "Id": "container-id", - "Name": format!("/{container_name}"), - "State": { - "Status": "running", - "Running": true - }, - "Config": { - "Labels": { - LABEL_SANDBOX_ID: "sandbox-label-id" - } + "Names": [container_name], + "State": "running", + "Labels": { + LABEL_SANDBOX_ID: sandbox_id } - }) + }]) .to_string(); let (socket_path, request_log, handle) = spawn_podman_stub( - "delete-mismatch", + "delete-label-lookup", vec![ - StubResponse::new(StatusCode::OK, inspect_body), + // list_containers by label + StubResponse::new(StatusCode::OK, list_body), + // stop_container StubResponse::new(StatusCode::NO_CONTENT, ""), + // remove_container StubResponse::new(StatusCode::NO_CONTENT, ""), + // remove_volume StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let driver = test_driver(socket_path.clone()); let deleted = driver - .delete_sandbox(sandbox_id, sandbox_name) + .delete_sandbox(sandbox_id) .await .expect("delete should succeed"); @@ -1567,12 +1552,15 @@ mod tests { .lock() .expect("request log lock should not be poisoned") .clone(); + assert!(requests[0].contains("/libpod/containers/json")); + assert!(requests[1].contains(&format!("/libpod/containers/{container_name}/stop"))); + assert!(requests[2].contains(&format!("/libpod/containers/{container_name}"))); assert_eq!( - requests[3..], - [format!( + requests[3], + format!( "DELETE {}", api_path(&format!("/libpod/volumes/{volume_name}")) - )] + ) ); let _ = fs::remove_file(socket_path); } diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 4840ee2810..1f7cce89a4 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -60,23 +60,17 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); } let sandbox = self .driver - .get_sandbox(&request.sandbox_name) + .get_sandbox(&request.sandbox_id) .await .map_err(Status::from)? .ok_or_else(|| Status::not_found("sandbox not found"))?; - if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id { - return Err(Status::failed_precondition( - "sandbox_id did not match the fetched sandbox", - )); - } - Ok(Response::new(GetSandboxResponse { sandbox: Some(sandbox), })) @@ -110,11 +104,11 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let request = request.into_inner(); - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if request.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); } self.driver - .stop_sandbox(&request.sandbox_name) + .stop_sandbox(&request.sandbox_id) .await .map_err(Status::from)?; Ok(Response::new(StopSandboxResponse {})) @@ -128,12 +122,9 @@ impl ComputeDriver for ComputeDriverService { if request.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } - if request.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); - } let deleted = self .driver - .delete_sandbox(&request.sandbox_id, &request.sandbox_name) + .delete_sandbox(&request.sandbox_id) .await .map_err(Status::from)?; Ok(Response::new(DeleteSandboxResponse { deleted })) @@ -190,24 +181,6 @@ mod tests { format!("/v5.0.0{path}") } - #[tokio::test] - async fn delete_sandbox_rejects_missing_sandbox_name() { - let service = test_service(unique_socket_path("missing-name")); - - let err = ComputeDriver::delete_sandbox( - &service, - Request::new(DeleteSandboxRequest { - sandbox_id: "sandbox-123".to_string(), - sandbox_name: String::new(), - }), - ) - .await - .expect_err("missing sandbox_name should fail"); - - assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert_eq!(err.message(), "sandbox_name is required"); - } - #[tokio::test] async fn delete_sandbox_rejects_missing_sandbox_id() { let service = test_service(unique_socket_path("missing-id")); @@ -229,15 +202,13 @@ mod tests { #[tokio::test] async fn delete_sandbox_forwards_request_sandbox_id_to_driver_cleanup() { let sandbox_id = "sandbox-abc"; - let sandbox_name = "demo"; - let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); let (socket_path, request_log, handle) = spawn_podman_stub( "forward-id", vec![ - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), - StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), + // list_containers returns empty (container already gone) + StubResponse::new(StatusCode::OK, "[]"), + // remove_volume StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); @@ -247,7 +218,7 @@ mod tests { &service, Request::new(DeleteSandboxRequest { sandbox_id: sandbox_id.to_string(), - sandbox_name: sandbox_name.to_string(), + sandbox_name: "demo".to_string(), }), ) .await @@ -263,30 +234,13 @@ mod tests { .lock() .expect("request log lock should not be poisoned") .clone(); + assert!(requests[0].contains("/libpod/containers/json")); assert_eq!( - requests, - vec![ - format!( - "GET {}", - api_path(&format!("/libpod/containers/{container_name}/json")) - ), - format!( - "POST {}", - api_path(&format!( - "/libpod/containers/{container_name}/stop?timeout=10" - )) - ), - format!( - "DELETE {}", - api_path(&format!( - "/libpod/containers/{container_name}?force=true&v=true" - )) - ), - format!( - "DELETE {}", - api_path(&format!("/libpod/volumes/{volume_name}")) - ), - ] + requests[1], + format!( + "DELETE {}", + api_path(&format!("/libpod/volumes/{volume_name}")) + ) ); let _ = std::fs::remove_file(socket_path); } diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 54606ea442..6babdaee04 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -7,7 +7,9 @@ use crate::client::{ ContainerInspect, ContainerListEntry, ContainerState, HealthState, PodmanApiError, PodmanClient, PodmanEvent, }; -use crate::container::{LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, short_id}; +use crate::container::{ + LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, short_id, +}; use futures::Stream; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::{ @@ -215,9 +217,16 @@ async fn map_podman_event( .get(LABEL_SANDBOX_NAME) .cloned() .unwrap_or_default(); + let workspace = event + .actor + .attributes + .get(LABEL_SANDBOX_WORKSPACE) + .cloned() + .unwrap_or_default(); Some(sandbox_event(build_driver_sandbox( sandbox_id.clone(), sandbox_name, + workspace, String::new(), short_id(container_id), DriverCondition { @@ -247,6 +256,7 @@ async fn map_podman_event( fn build_driver_sandbox( sandbox_id: String, sandbox_name: String, + workspace: String, instance_name: String, instance_id: String, condition: DriverCondition, @@ -265,6 +275,7 @@ fn build_driver_sandbox( conditions: vec![condition], deleting, }), + workspace, } } @@ -277,6 +288,12 @@ pub fn driver_sandbox_from_inspect(inspect: &ContainerInspect) -> Option Option Option ( @@ -316,6 +339,7 @@ pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option, ) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{DenialSummary, L7RequestSample}; let client = CachedOpenShellClient::connect(endpoint).await?; + client.set_workspace(workspace.to_string()); let proto_summaries: Vec = summaries .into_iter() @@ -624,12 +651,14 @@ async fn flush_proposals_to_gateway( async fn flush_activity_to_gateway( endpoint: &str, sandbox_name: &str, + workspace: &str, summary: activity_aggregator::FlushableActivitySummary, ) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{DenialGroupCount, NetworkActivitySummary}; let client = CachedOpenShellClient::connect(endpoint).await?; + client.set_workspace(workspace.to_string()); let proto_summary = NetworkActivitySummary { network_activity_count: summary.network_activity_count, @@ -1449,12 +1478,14 @@ async fn load_policy( // Sync and re-fetch over a single connection to avoid extra // TLS handshakes. + let ws = snapshot.workspace.clone(); snapshot = grpc_retry("Policy discovery sync", || { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( endpoint, id, sandbox, &discovered, + &ws, ) }) .await?; @@ -1481,6 +1512,7 @@ async fn load_policy( id, sandbox_name, &sync_policy, + &snapshot.workspace, ) .await { @@ -1947,6 +1979,7 @@ struct PolicyPollLoopContext { ocsf_enabled: Arc, provider_credentials: ProviderCredentialState, policy_local_ctx: Option>, + workspace_tx: tokio::sync::watch::Sender, } async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { @@ -1979,33 +2012,36 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { // policy revision the supervisor actually loaded. A mismatched result is // reconciled below instead of being recorded as already applied. match client.poll_settings(&ctx.sandbox_id).await { - Ok(result) => match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { - InitialPollDisposition::Acknowledge(candidate) => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - current_config_revision = candidate.config_revision; - current_policy_hash.clone_from(&candidate.policy_hash); - current_settings = result.settings; - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::initial_loaded(&candidate), - ); - debug!( - config_revision = current_config_revision, - "Settings poll: initial policy matches loaded revision" - ); - } - InitialPollDisposition::Reconcile => pending_result = Some(result), - InitialPollDisposition::TrackOnly => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - current_config_revision = result.config_revision; - current_policy_hash = result.policy_hash.clone(); - current_settings = result.settings; - debug!( - config_revision = current_config_revision, - "Settings poll: tracking gateway config while preserving local policy override" - ); + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { + InitialPollDisposition::Acknowledge(candidate) => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + current_config_revision = candidate.config_revision; + current_policy_hash.clone_from(&candidate.policy_hash); + current_settings = result.settings; + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::initial_loaded(&candidate), + ); + debug!( + config_revision = current_config_revision, + "Settings poll: initial policy matches loaded revision" + ); + } + InitialPollDisposition::Reconcile => pending_result = Some(result), + InitialPollDisposition::TrackOnly => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash.clone(); + current_settings = result.settings; + debug!( + config_revision = current_config_revision, + "Settings poll: tracking gateway config while preserving local policy override" + ); + } } - }, + } Err(e) => { warn!(error = %e, "Settings poll: failed to fetch initial version, will retry"); } @@ -2018,7 +2054,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } else { tokio::time::sleep(interval).await; match client.poll_settings(&ctx.sandbox_id).await { - Ok(result) => result, + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + result + } Err(e) => { debug!(error = %e, "Settings poll: server unreachable, will retry"); continue; @@ -2465,6 +2504,7 @@ filesystem_policy: settings: std::collections::HashMap::new(), global_policy_version: 0, provider_env_revision: 0, + workspace: String::new(), } } @@ -2616,4 +2656,21 @@ filesystem_policy: ); } } + + #[test] + fn settings_snapshot_carries_workspace_for_policy_sync() { + let mut snapshot = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + snapshot.workspace = "beta".to_string(); + + let revision = LoadedPolicyRevision::from_snapshot(&snapshot); + assert_eq!(revision.version, 1); + assert_eq!( + snapshot.workspace, "beta", + "workspace must survive the snapshot so sync_policy_and_fetch_snapshot receives it" + ); + } } diff --git a/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql new file mode 100644 index 0000000000..a4b4dea97d --- /dev/null +++ b/crates/openshell-server/migrations/postgres/006_add_workspace_column.sql @@ -0,0 +1,15 @@ +-- Add workspace column for multi-tenant isolation. +ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; + +-- Backfill workspace-scoped object types to 'default'. +-- stored_provider_profile is intentionally omitted: profiles use workspace="" +-- for platform scope and are created with an explicit workspace when scoped. +UPDATE objects SET workspace = 'default' + WHERE workspace = '' AND name IS NOT NULL + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); + +-- Replace global name uniqueness with workspace-scoped uniqueness. +DROP INDEX IF EXISTS objects_name_uq; +CREATE UNIQUE INDEX objects_name_uq + ON objects (object_type, workspace, name) + WHERE name IS NOT NULL; diff --git a/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql new file mode 100644 index 0000000000..a4b4dea97d --- /dev/null +++ b/crates/openshell-server/migrations/sqlite/006_add_workspace_column.sql @@ -0,0 +1,15 @@ +-- Add workspace column for multi-tenant isolation. +ALTER TABLE objects ADD COLUMN workspace TEXT NOT NULL DEFAULT ''; + +-- Backfill workspace-scoped object types to 'default'. +-- stored_provider_profile is intentionally omitted: profiles use workspace="" +-- for platform scope and are created with an explicit workspace when scoped. +UPDATE objects SET workspace = 'default' + WHERE workspace = '' AND name IS NOT NULL + AND object_type IN ('sandbox', 'provider', 'service_endpoint', 'inference_route', 'ssh_session', 'provider_credential_refresh_state', 'sandbox_policy', 'draft_policy_chunk', 'sandbox_settings'); + +-- Replace global name uniqueness with workspace-scoped uniqueness. +DROP INDEX IF EXISTS objects_name_uq; +CREATE UNIQUE INDEX objects_name_uq + ON objects (object_type, workspace, name) + WHERE name IS NOT NULL; diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index 76d5e13245..b90841d85a 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -49,10 +49,10 @@ mod tests { "/openshell.v1.OpenShell/ApproveDraftChunk" )); assert!(!is_sandbox_callable( - "/openshell.inference.v1.Inference/GetClusterInference" + "/openshell.inference.v1.Inference/GetInferenceRoute" )); assert!(!is_sandbox_callable( - "/openshell.inference.v1.Inference/SetClusterInference" + "/openshell.inference.v1.Inference/SetInferenceRoute" )); } } diff --git a/crates/openshell-server/src/compute/lease.rs b/crates/openshell-server/src/compute/lease.rs index a2eac70625..1de4de4734 100644 --- a/crates/openshell-server/src/compute/lease.rs +++ b/crates/openshell-server/src/compute/lease.rs @@ -101,6 +101,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MustCreate, @@ -140,6 +141,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MatchResourceVersion(record.resource_version), @@ -181,6 +183,7 @@ impl ReconcilerLease { LEASE_OBJECT_TYPE, LEASE_SINGLETON_ID, LEASE_SINGLETON_NAME, + "", &payload_bytes, None, WriteCondition::MatchResourceVersion(guard.resource_version), diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 3a92cd209a..d95db7838d 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -13,7 +13,10 @@ pub use openshell_driver_podman::PodmanComputeConfig; pub use vm::VmComputeConfig; use crate::grpc::policy::SANDBOX_SETTINGS_OBJECT_TYPE; -use crate::persistence::{ObjectId, ObjectName, ObjectRecord, ObjectType, Store, WriteCondition}; +use crate::persistence::{ + DRAFT_CHUNK_OBJECT_TYPE, ObjectId, ObjectName, ObjectRecord, ObjectType, POLICY_OBJECT_TYPE, + Store, WriteCondition, +}; use crate::sandbox_index::SandboxIndex; use crate::sandbox_watch::SandboxWatchBus; use crate::supervisor_session::SupervisorSessionRegistry; @@ -21,6 +24,7 @@ use crate::tracing_bus::TracingLogBus; use futures::{Stream, StreamExt}; use hyper_util::rt::TokioIo; use openshell_core::ComputeDriverKind; +use openshell_core::ObjectWorkspace; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, DeleteSandboxRequest, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, @@ -32,7 +36,7 @@ use openshell_core::proto::compute::v1::{ }; use openshell_core::proto::{ PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SshSession, + SandboxTemplate, ServiceEndpoint, SshSession, }; use openshell_driver_docker::DockerComputeDriver; use openshell_driver_kubernetes::{ @@ -495,6 +499,7 @@ impl ComputeRuntime { Sandbox::object_type(), &sandbox_id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), None, WriteCondition::MustCreate, @@ -563,13 +568,13 @@ impl ComputeRuntime { } } - pub async fn delete_sandbox(&self, name: &str) -> Result { + pub async fn delete_sandbox(&self, workspace: &str, name: &str) -> Result { let _guard = self.sync_lock.lock().await; // Resolve sandbox ID from name let sandbox = self .store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -583,7 +588,9 @@ impl ComputeRuntime { self.sandbox_index.update_from_sandbox(&sandbox); self.sandbox_watch_bus.notify(&id); - self.cleanup_sandbox_owned_records(&sandbox).await; + self.cleanup_sandbox_owned_records(&sandbox) + .await + .map_err(|e| Status::internal(format!("cleanup owned records for sandbox {id}: {e}")))?; let deleted = self .driver @@ -698,6 +705,7 @@ impl ComputeRuntime { Sandbox::object_type(), &id, &name, + sandbox.object_workspace(), &sandbox.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -754,7 +762,7 @@ impl ComputeRuntime { let records = self .store - .list(Sandbox::object_type(), 1000, 0) + .list_by_type(Sandbox::object_type(), 1000, 0) .await .map_err(|e| e.to_string())?; @@ -1054,7 +1062,7 @@ impl ComputeRuntime { let records = self .store - .list(Sandbox::object_type(), 500, 0) + .list_by_type(Sandbox::object_type(), 500, 0) .await .map_err(|e| e.to_string())?; @@ -1152,6 +1160,7 @@ impl ComputeRuntime { if supervisor_promoted { ensure_supervisor_ready_status(&mut status, &sandbox_name); } + let workspace = incoming.workspace.clone(); let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: incoming.id.clone(), @@ -1159,6 +1168,7 @@ impl ComputeRuntime { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace, }), spec: None, status, @@ -1170,6 +1180,7 @@ impl ComputeRuntime { Sandbox::object_type(), &incoming.id, sandbox.object_name(), + sandbox.object_workspace(), &sandbox.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1356,7 +1367,7 @@ impl ComputeRuntime { .await .map_err(|e| e.to_string())?; if let Some(sandbox) = sandbox.as_ref() { - self.cleanup_sandbox_owned_records(sandbox).await; + self.cleanup_sandbox_owned_records(sandbox).await?; } let _ = self @@ -1370,42 +1381,80 @@ impl ComputeRuntime { Ok(()) } - async fn cleanup_sandbox_owned_records(&self, sandbox: &Sandbox) { - self.cleanup_sandbox_ssh_sessions(sandbox.object_id()).await; + async fn cleanup_sandbox_owned_records(&self, sandbox: &Sandbox) -> Result<(), String> { + self.cleanup_sandbox_ssh_sessions(sandbox.object_id(), sandbox.object_workspace()) + .await?; + self.cleanup_sandbox_service_endpoints(sandbox.object_id(), sandbox.object_workspace()) + .await?; + + self.store + .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_workspace(), sandbox.object_name()) + .await + .map_err(|e| format!("delete sandbox settings: {e}"))?; + + for (object_type, label) in [ + (POLICY_OBJECT_TYPE, "policy revisions"), + (DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunks"), + ] { + self.store + .delete_by_scope(object_type, sandbox.object_id()) + .await + .map_err(|e| format!("delete {label}: {e}"))?; + } + + Ok(()) + } - if let Err(e) = self + async fn cleanup_sandbox_ssh_sessions( + &self, + sandbox_id: &str, + workspace: &str, + ) -> Result<(), String> { + let records = self .store - .delete_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .list(SshSession::object_type(), workspace, 1000, 0) .await - { - warn!( - sandbox_id = %sandbox.object_id(), - sandbox_name = %sandbox.object_name(), - error = %e, - "Failed to delete sandbox settings during cleanup" - ); + .map_err(|e| format!("list SSH sessions: {e}"))?; + + for record in records { + if let Ok(session) = SshSession::decode(record.payload.as_slice()) + && session.sandbox_id == sandbox_id + { + self.store + .delete(SshSession::object_type(), session.object_id()) + .await + .map_err(|e| format!("delete SSH session {}: {e}", session.object_id()))?; + } } + + Ok(()) } - async fn cleanup_sandbox_ssh_sessions(&self, sandbox_id: &str) { - if let Ok(records) = self.store.list(SshSession::object_type(), 1000, 0).await { - for record in records { - if let Ok(session) = SshSession::decode(record.payload.as_slice()) - && session.sandbox_id == sandbox_id - && let Err(e) = self - .store - .delete(SshSession::object_type(), session.object_id()) - .await - { - warn!( - sandbox_id = %sandbox_id, - session_id = %session.object_id(), - error = %e, - "Failed to delete SSH session during sandbox cleanup" - ); - } + async fn cleanup_sandbox_service_endpoints( + &self, + sandbox_id: &str, + workspace: &str, + ) -> Result<(), String> { + let records = self + .store + .list(ServiceEndpoint::object_type(), workspace, 1000, 0) + .await + .map_err(|e| format!("list service endpoints: {e}"))?; + + for record in records { + if let Ok(endpoint) = ServiceEndpoint::decode(record.payload.as_slice()) + && endpoint.sandbox_id == sandbox_id + { + self.store + .delete(ServiceEndpoint::object_type(), endpoint.object_id()) + .await + .map_err(|e| { + format!("delete service endpoint {}: {e}", endpoint.object_id()) + })?; } } + + Ok(()) } fn cleanup_sandbox_state(&self, sandbox_id: &str) { @@ -1561,6 +1610,7 @@ fn driver_sandbox_from_public( .map(|spec| driver_sandbox_spec_from_public(spec, driver_name)) .transpose()?, status: sandbox.status.as_ref().map(driver_status_from_public), + workspace: sandbox.object_workspace().to_string(), }) } @@ -2386,6 +2436,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), ..Default::default() }; @@ -2401,6 +2452,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), sandbox_id: sandbox_id.to_string(), token: format!("token-{id}"), @@ -2814,6 +2866,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }) .await .unwrap(); @@ -2856,6 +2909,7 @@ mod tests { namespace: "default".to_string(), spec: None, status: None, + workspace: "default".to_string(), }) .await .unwrap(); @@ -2904,6 +2958,7 @@ mod tests { "Starting", "VM is starting", ))), + workspace: "default".to_string(), }) .await .unwrap(); @@ -3023,6 +3078,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], current_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), @@ -3043,6 +3099,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], })) .await; @@ -3091,6 +3148,7 @@ mod tests { "DependenciesNotReady", "Pod exists with phase: Pending; Service Exists", ))), + workspace: "default".to_string(), }], current_sandboxes: vec![DriverSandbox { id: "sb-1".to_string(), @@ -3104,6 +3162,7 @@ mod tests { message: "Pod is Ready".to_string(), last_transition_time: String::new(), })), + workspace: "default".to_string(), }], })) .await; @@ -3145,6 +3204,7 @@ mod tests { }], deleting: false, }), + workspace: "default".to_string(), }], ..Default::default() })) @@ -3183,11 +3243,36 @@ mod tests { SANDBOX_SETTINGS_OBJECT_TYPE, "settings-sb-1", sandbox.object_name(), + sandbox.object_workspace(), br#"{"revision":1,"settings":{}}"#, None, ) .await .unwrap(); + runtime + .store + .put( + POLICY_OBJECT_TYPE, + "policy-sb-1", + sandbox.object_id(), + sandbox.object_workspace(), + br#"{"version":1}"#, + None, + ) + .await + .unwrap(); + runtime + .store + .put( + DRAFT_CHUNK_OBJECT_TYPE, + "draft-sb-1", + sandbox.object_id(), + sandbox.object_workspace(), + br#"{"chunk":1}"#, + None, + ) + .await + .unwrap(); let session = ssh_session_record("session-1", sandbox.object_id()); runtime.store.put_message(&session).await.unwrap(); @@ -3209,17 +3294,33 @@ mod tests { assert!( runtime .sandbox_index - .sandbox_id_for_sandbox_name(sandbox.object_name()) + .sandbox_id_for_sandbox_name(sandbox.object_workspace(), sandbox.object_name()) .is_none() ); assert!( runtime .store - .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_name()) + .get_by_name(SANDBOX_SETTINGS_OBJECT_TYPE, sandbox.object_workspace(), sandbox.object_name()) .await .unwrap() .is_none() ); + assert!( + runtime + .store + .list_by_scope(POLICY_OBJECT_TYPE, sandbox.object_id(), 100, 0) + .await + .unwrap() + .is_empty() + ); + assert!( + runtime + .store + .list_by_scope(DRAFT_CHUNK_OBJECT_TYPE, sandbox.object_id(), 100, 0) + .await + .unwrap() + .is_empty() + ); assert!( runtime .store @@ -3490,7 +3591,12 @@ mod tests { runtime.validate_sandbox_create(&sandbox).await.unwrap(); runtime.create_sandbox(sandbox, None).await.unwrap(); - assert!(runtime.delete_sandbox("uds-sandbox").await.unwrap()); + assert!( + runtime + .delete_sandbox("default", "uds-sandbox") + .await + .unwrap() + ); let calls = driver.calls(); assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); @@ -3546,6 +3652,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }); let created = runtime.create_sandbox(sandbox, None).await.unwrap(); @@ -3621,7 +3728,7 @@ mod tests { .expect("should have one successful creation"); let retrieved = runtime .store - .get_message_by_name::("test-concurrent") + .get_message_by_name::("default", "test-concurrent") .await .unwrap(); assert!( @@ -3634,4 +3741,75 @@ mod tests { "retrieved sandbox should match created sandbox" ); } + + #[test] + fn driver_sandbox_from_public_populates_workspace() { + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-1".to_string(), + name: "work".to_string(), + workspace: "alpha".to_string(), + ..Default::default() + }), + ..Default::default() + }; + let driver_sb = driver_sandbox_from_public(&sandbox, "kubernetes").unwrap(); + assert_eq!(driver_sb.workspace, "alpha"); + assert_eq!(driver_sb.name, "work"); + assert_eq!(driver_sb.id, "sb-1"); + } + + #[tokio::test] + async fn apply_sandbox_update_uses_workspace_from_driver() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + runtime + .apply_sandbox_update(DriverSandbox { + id: "sb-w1".to_string(), + name: "work".to_string(), + namespace: "default".to_string(), + spec: None, + status: Some(make_driver_status(make_driver_condition( + "DependenciesNotReady", + "Provisioning", + ))), + workspace: "team-ml".to_string(), + }) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-w1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.object_workspace(), "team-ml"); + } + + #[tokio::test] + async fn apply_sandbox_update_preserves_workspace() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + runtime + .apply_sandbox_update(DriverSandbox { + id: "sb-w2".to_string(), + name: "work".to_string(), + namespace: "default".to_string(), + spec: None, + status: Some(make_driver_status(make_driver_condition( + "DependenciesNotReady", + "Provisioning", + ))), + workspace: "alpha".to_string(), + }) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-w2") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.object_workspace(), "alpha"); + } } diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 88c771bed5..cd8edf63a0 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -200,6 +200,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::default(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c9..4e8465251a 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -9,40 +9,45 @@ pub mod provider; mod sandbox; mod service; mod validation; +pub mod workspace; use openshell_core::proto::{ - ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, - ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, - ClearDraftChunksRequest, ClearDraftChunksResponse, ConfigureProviderRefreshRequest, - ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, - CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderProfileRequest, + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, ApproveAllDraftChunksRequest, + ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, + AttachSandboxProviderRequest, AttachSandboxProviderResponse, ClearDraftChunksRequest, + ClearDraftChunksResponse, ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, + CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, + CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteServiceRequest, DeleteServiceResponse, DetachSandboxProviderRequest, - DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, - GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, - GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + DeleteServiceRequest, DeleteServiceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, + EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, + GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, + GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - GetServiceRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, - ImportProviderProfilesResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, - LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, - ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxProvidersRequest, - ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, - ListServicesResponse, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, - PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, - RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, - ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, - SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, - UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, - WatchSandboxRequest, open_shell_server::OpenShell, + GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, HealthRequest, HealthResponse, + ImportProviderProfilesRequest, ImportProviderProfilesResponse, IssueSandboxTokenRequest, + IssueSandboxTokenResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, + ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, + ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, + ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, + ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, + RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, + RemoveWorkspaceMemberResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, + RotateProviderCredentialResponse, SandboxResponse, SandboxStreamEvent, ServiceEndpointResponse, + ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorMessage, + TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, + UpdateConfigResponse, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, + UpdateProviderRequest, WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -101,6 +106,10 @@ pub fn persistence_error_to_status( /// Maximum length for a sandbox or provider name (Kubernetes name limit). const MAX_NAME_LEN: usize = 253; +/// Maximum length for DNS-routable names (workspace, sandbox, service). +/// Three segments plus two `--` delimiters must fit a 63-char DNS label: +/// 19 + 2 + 19 + 2 + 19 = 61. +const MAX_ROUTABLE_NAME_LEN: usize = 19; /// Maximum number of providers that can be attached to a sandbox. const MAX_PROVIDERS: usize = 32; /// Maximum length for the `log_level` field. @@ -216,6 +225,9 @@ impl OpenShell for OpenShellService { type WatchSandboxStream = ReceiverStream>; + // TODO(phase2): data-plane RPCs do not carry a workspace field. Add + // workspace verification to confirm the sandbox belongs to the caller's + // workspace before proxying. #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn watch_sandbox( &self, @@ -232,6 +244,8 @@ impl OpenShell for OpenShellService { sandbox::handle_get_sandbox(&self.state, request).await } + // TODO(phase2): all_workspaces flag is currently accessible to any + // authenticated user. Restrict to Platform Admin role in Phase 2. #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_sandboxes( &self, @@ -276,6 +290,7 @@ impl OpenShell for OpenShellService { type ExecSandboxStream = ReceiverStream>; + // TODO(phase2): no workspace field — see watch_sandbox comment. #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn exec_sandbox( &self, @@ -287,6 +302,7 @@ impl OpenShell for OpenShellService { type ForwardTcpStream = Pin> + Send + 'static>>; + // TODO(phase2): no workspace field — see watch_sandbox comment. #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn forward_tcp( &self, @@ -307,6 +323,7 @@ impl OpenShell for OpenShellService { // --- SSH sessions --- + // TODO(phase2): no workspace field — see watch_sandbox comment. #[rpc_auth(auth = "bearer", scope = "sandbox:write", role = "user")] async fn create_ssh_session( &self, @@ -331,6 +348,8 @@ impl OpenShell for OpenShellService { service::handle_get_service(&self.state, request).await } + // TODO(phase2): all_workspaces flag is currently accessible to any + // authenticated user. Restrict to Platform Admin role in Phase 2. #[rpc_auth(auth = "bearer", scope = "sandbox:read", role = "user")] async fn list_services( &self, @@ -373,6 +392,8 @@ impl OpenShell for OpenShellService { provider::handle_get_provider(&self.state, request).await } + // TODO(phase2): all_workspaces flag is currently accessible to any + // authenticated user. Restrict to Platform Admin role in Phase 2. #[rpc_auth(auth = "bearer", scope = "provider:read", role = "user")] async fn list_providers( &self, @@ -669,6 +690,64 @@ impl OpenShell for OpenShellService { crate::supervisor_session::handle_relay_stream(&self.state.supervisor_sessions, request) .await } + + // --- Workspace management --- + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn create_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_create_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn get_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_get_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn list_workspaces( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_list_workspaces(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn delete_workspace( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_delete_workspace(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn add_workspace_member( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_add_workspace_member(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:write", role = "admin")] + async fn remove_workspace_member( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_remove_workspace_member(&self.state, request).await + } + + #[rpc_auth(auth = "bearer", scope = "workspace:read", role = "user")] + async fn list_workspace_members( + &self, + request: Request, + ) -> Result, Status> { + workspace::handle_list_workspace_members(&self.state, request).await + } } // --------------------------------------------------------------------------- @@ -696,6 +775,7 @@ pub mod test_support { .await .unwrap(), ); + crate::ensure_default_workspace(&store).await.unwrap(); let compute = new_test_runtime(store.clone()).await; Arc::new(ServerState::new( Config::new(None).with_database_url("sqlite::memory:?cache=shared"), diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc8ff0d2e2..63446dadaa 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,7 +12,9 @@ use crate::ServerState; use crate::auth::principal::Principal; -use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; +use crate::persistence::{ + DraftChunkRecord, ObjectId, ObjectName, ObjectType, ObjectWorkspace, PolicyRecord, Store, +}; use crate::policy_store::PolicyStoreExt; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; @@ -519,13 +521,14 @@ fn run_prover_findings( /// with the merged policy the prover validates against. async fn build_credential_set_for_sandbox( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); for name in provider_names { let Some(provider) = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? else { @@ -545,8 +548,12 @@ async fn build_credential_set_for_sandbox( }; profile.clone() } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? + let Some(profile) = super::provider::get_provider_type_profile( + store, + &provider.profile_workspace, + provider_type, + ) + .await? else { warn!( provider_name = %name, @@ -861,6 +868,7 @@ async fn self_reject_mechanistic_if_already_covered( /// manual. async fn resolve_proposal_approval_mode( store: &Store, + workspace: &str, sandbox_name: &str, ) -> Result<(bool, &'static str), Status> { let global = load_global_settings(store).await?; @@ -870,7 +878,7 @@ async fn resolve_proposal_approval_mode( return Ok((value == "auto", "gateway")); } - let sandbox = load_sandbox_settings(store, sandbox_name).await?; + let sandbox = load_sandbox_settings(store, workspace, sandbox_name).await?; if let Some(StoredSettingValue::String(value)) = sandbox.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY) { @@ -883,6 +891,7 @@ async fn resolve_proposal_approval_mode( async fn auto_approve_chunk( state: &Arc, sandbox_id: &str, + workspace: &str, sandbox_name: &str, chunk_id: &str, source: &str, @@ -910,7 +919,8 @@ async fn auto_approve_chunk( return Ok(()); } - let (version, hash) = merge_chunk_into_policy(state.store.as_ref(), sandbox_id, &chunk).await?; + let (version, hash) = + merge_chunk_into_policy(state.store.as_ref(), sandbox_id, workspace, &chunk).await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -959,6 +969,7 @@ async fn auto_approve_chunk( // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, + workspace: &str, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { @@ -996,7 +1007,8 @@ async fn current_effective_policy_for_sandbox( .map(|spec| spec.providers.clone()) .unwrap_or_default(); let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &provider_names).await?; + profile_provider_policy_layers(state.store.as_ref(), workspace, &provider_names) + .await?; if !provider_layers.is_empty() { policy = compose_effective_policy(&policy, &provider_layers); } @@ -1051,11 +1063,12 @@ fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Statu async fn resolve_sandbox_by_name_for_principal( store: &Store, + workspace: &str, principal: &Principal, name: &str, ) -> Result { let sandbox = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -1102,6 +1115,7 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); let sandbox_provider_names = sandbox .spec .as_ref() @@ -1151,7 +1165,7 @@ pub(super) async fn handle_get_sandbox_config( if let Err(e) = state .store - .put_policy_revision(&policy_id, &sandbox_id, 1, &payload, &hash) + .put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash) .await { warn!( @@ -1183,7 +1197,7 @@ pub(super) async fn handle_get_sandbox_config( let global_settings = load_global_settings(state.store.as_ref()).await?; let sandbox_settings = - load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; + load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; let providers_v2_enabled = bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; @@ -1209,8 +1223,12 @@ pub(super) async fn handle_get_sandbox_config( && !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() { - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; + let provider_layers = profile_provider_policy_layers( + state.store.as_ref(), + &workspace, + &sandbox_provider_names, + ) + .await?; if !provider_layers.is_empty() { let effective_policy = compose_effective_policy(source_policy, &provider_layers); policy_hash = deterministic_policy_hash(&effective_policy); @@ -1221,7 +1239,8 @@ pub(super) async fn handle_get_sandbox_config( let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?; + compute_provider_env_revision(state.store.as_ref(), &workspace, &sandbox_provider_names) + .await?; Ok(Response::new(GetSandboxConfigResponse { policy, @@ -1232,11 +1251,13 @@ pub(super) async fn handle_get_sandbox_config( policy_source: policy_source.into(), global_policy_version, provider_env_revision, + workspace, })) } pub(super) async fn compute_provider_env_revision( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1245,7 +1266,7 @@ pub(super) async fn compute_provider_env_revision( for provider_name in provider_names { hasher.update(provider_name.as_bytes()); match store - .get_by_name(Provider::object_type(), provider_name) + .get_by_name(Provider::object_type(), workspace, provider_name) .await .map_err(|e| { Status::internal(format!("fetch provider '{provider_name}' failed: {e}")) @@ -1258,7 +1279,13 @@ pub(super) async fn compute_provider_env_revision( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(store, &provider.r#type, &mut hasher).await?; + hash_provider_profile_revision( + store, + &provider.profile_workspace, + &provider.r#type, + &mut hasher, + ) + .await?; let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1286,6 +1313,7 @@ pub(super) async fn compute_provider_env_revision( async fn hash_provider_profile_revision( store: &Store, + profile_workspace: &str, provider_type: &str, hasher: &mut Sha256, ) -> Result<(), Status> { @@ -1299,6 +1327,7 @@ async fn hash_provider_profile_revision( match store .get_by_name( openshell_core::proto::StoredProviderProfile::object_type(), + profile_workspace, provider_type, ) .await @@ -1321,13 +1350,14 @@ async fn hash_provider_profile_revision( async fn profile_provider_policy_layers( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); for name in provider_names { let provider = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; @@ -1344,8 +1374,12 @@ async fn profile_provider_policy_layers( }; profile.clone() } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? + let Some(profile) = super::provider::get_provider_type_profile( + store, + &provider.profile_workspace, + provider_type, + ) + .await? else { warn!( provider_name = %name, @@ -1403,6 +1437,7 @@ pub(super) async fn handle_get_sandbox_provider_environment( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let workspace = sandbox.object_workspace().to_string(); let spec = sandbox .spec @@ -1410,10 +1445,13 @@ pub(super) async fn handle_get_sandbox_provider_environment( let provider_names = spec.providers; let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &provider_names).await?; - let provider_environment = - super::provider::resolve_provider_environment(state.store.as_ref(), &provider_names) - .await?; + compute_provider_env_revision(state.store.as_ref(), &workspace, &provider_names).await?; + let provider_environment = super::provider::resolve_provider_environment( + state.store.as_ref(), + &workspace, + &provider_names, + ) + .await?; info!( sandbox_id = %sandbox_id, @@ -1458,10 +1496,13 @@ async fn handle_update_config_inner( sandbox_caller: bool, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( state.store.as_ref(), + &workspace, principal .as_ref() .expect("sandbox_caller implies principal"), @@ -1551,6 +1592,7 @@ async fn handle_update_config_inner( .put_policy_revision( &policy_id, GLOBAL_POLICY_SANDBOX_ID, + "", next_version, &payload, &hash, @@ -1655,7 +1697,7 @@ async fn handle_update_config_inner( // Resolve sandbox by name. let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -1681,12 +1723,14 @@ async fn handle_update_config_inner( } let mut sandbox_settings = - load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; + load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()) + .await?; let removed = sandbox_settings.settings.remove(key).is_some(); if removed { sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( state.store.as_ref(), + &workspace, sandbox.object_name(), &sandbox_settings, ) @@ -1714,12 +1758,13 @@ async fn handle_update_config_inner( let stored = proto_setting_to_stored(key, setting)?; let mut sandbox_settings = - load_sandbox_settings(state.store.as_ref(), sandbox.object_name()).await?; + load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored); if changed { sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( state.store.as_ref(), + &workspace, sandbox.object_name(), &sandbox_settings, ) @@ -1751,6 +1796,7 @@ async fn handle_update_config_inner( let (version, hash) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, + &workspace, spec.policy.as_ref(), &merge_ops, ) @@ -1881,7 +1927,14 @@ async fn handle_update_config_inner( state .store - .put_policy_revision(&policy_id, &sandbox_id, next_version, &payload, &hash) + .put_policy_revision( + &policy_id, + &sandbox_id, + &workspace, + next_version, + &payload, + &hash, + ) .await .map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?; @@ -1917,6 +1970,11 @@ pub(super) async fn handle_get_sandbox_policy_status( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = if req.global { + String::new() + } else { + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await? + }; let (policy_id, active_version) = if req.global { (GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32) @@ -1926,7 +1984,7 @@ pub(super) async fn handle_get_sandbox_policy_status( } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -1968,6 +2026,11 @@ pub(super) async fn handle_list_sandbox_policies( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = if req.global { + String::new() + } else { + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await? + }; let policy_id = if req.global { GLOBAL_POLICY_SANDBOX_ID.to_string() @@ -1977,7 +2040,7 @@ pub(super) async fn handle_list_sandbox_policies( } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2089,6 +2152,10 @@ pub(super) async fn handle_get_sandbox_logs( request: Request, ) -> Result, Status> { let req = request.into_inner(); + // TODO(phase2): workspace is resolved but not used for authorization. + // Verify the sandbox belongs to this workspace before returning logs. + let _workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } @@ -2203,12 +2270,19 @@ pub(super) async fn handle_submit_policy_analysis( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let sandbox = - resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; + let sandbox = resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + &workspace, + &principal, + &req.name, + ) + .await?; let sandbox_id = sandbox.object_id().to_string(); for summary in &req.network_activity_summaries { state @@ -2235,7 +2309,8 @@ pub(super) async fn handle_submit_policy_analysis( // case for the common single-chunk submission shape. If real workloads // surface a problem with batches that interact across chunks, the right // fix is to recompute baseline after each successful auto-approve. - let current_policy = current_effective_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; + let current_policy = + current_effective_policy_for_sandbox(state, &workspace, &sandbox, &sandbox_id).await?; // Auto-approval is an opt-in behavior, sourced from the settings model // (sandbox or gateway scope) so it can be flipped on a running sandbox @@ -2243,7 +2318,8 @@ pub(super) async fn handle_submit_policy_analysis( // exact "auto") preserves OpenShell's default-deny posture: every // proposal lands in `pending` for a human reviewer. let (auto_approve_enabled, resolved_from) = - resolve_proposal_approval_mode(state.store.as_ref(), sandbox.object_name()).await?; + resolve_proposal_approval_mode(state.store.as_ref(), &workspace, sandbox.object_name()) + .await?; // The credential set is stable across all chunks in this batch, so build // it once. v1 captures presence only — no scope modeling — so the prover @@ -2254,8 +2330,12 @@ pub(super) async fn handle_submit_policy_analysis( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let credential_set = - build_credential_set_for_sandbox(state.store.as_ref(), &provider_names_for_creds).await?; + let credential_set = build_credential_set_for_sandbox( + state.store.as_ref(), + &workspace, + &provider_names_for_creds, + ) + .await?; let current_version = state .store @@ -2368,7 +2448,7 @@ pub(super) async fn handle_submit_policy_analysis( .then(|| crate::policy_store::observation_dedup_key(&record)); let effective_id = state .store - .put_draft_chunk(&record, dedup_key.as_deref()) + .put_draft_chunk(&record, dedup_key.as_deref(), &workspace) .await .map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?; accepted += 1; @@ -2422,6 +2502,7 @@ pub(super) async fn handle_submit_policy_analysis( && let Err(err) = auto_approve_chunk( state, &sandbox_id, + &workspace, sandbox.object_name(), &effective_id, &req.analysis_mode, @@ -2469,12 +2550,19 @@ pub(super) async fn handle_get_draft_policy( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let sandbox = - resolve_sandbox_by_name_for_principal(state.store.as_ref(), &principal, &req.name).await?; + let sandbox = resolve_sandbox_by_name_for_principal( + state.store.as_ref(), + &workspace, + &principal, + &req.name, + ) + .await?; let sandbox_id = sandbox.object_id().to_string(); let status_filter = if req.status_filter.is_empty() { @@ -2533,6 +2621,8 @@ async fn handle_approve_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2544,7 +2634,7 @@ async fn handle_approve_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2577,7 +2667,7 @@ async fn handle_approve_draft_chunk_inner( ); let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &chunk).await?; + merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &workspace, &chunk).await?; let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -2633,6 +2723,8 @@ async fn handle_reject_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2642,7 +2734,7 @@ async fn handle_reject_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2678,7 +2770,8 @@ async fn handle_reject_draft_chunk_inner( if was_approved { require_no_global_policy(state).await?; - let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &chunk).await?; + let (version, hash) = + remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; emit_gateway_policy_audit_log( &sandbox_id, sandbox.object_name(), @@ -2730,6 +2823,8 @@ async fn handle_approve_all_draft_chunks_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2738,7 +2833,7 @@ async fn handle_approve_all_draft_chunks_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2789,7 +2884,7 @@ async fn handle_approve_all_draft_chunks_inner( ); let (version, hash) = - merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, chunk).await?; + merge_chunk_into_policy(state.store.as_ref(), &sandbox_id, &workspace, chunk).await?; last_version = version; last_hash = hash; let chunk_summary = summarize_draft_chunk_rule(chunk)?; @@ -2851,6 +2946,8 @@ pub(super) async fn handle_edit_draft_chunk( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2863,7 +2960,7 @@ pub(super) async fn handle_edit_draft_chunk( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2915,6 +3012,8 @@ async fn handle_undo_draft_chunk_inner( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } @@ -2924,7 +3023,7 @@ async fn handle_undo_draft_chunk_inner( let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -2954,7 +3053,7 @@ async fn handle_undo_draft_chunk_inner( "UndoDraftChunk: removing rule from active policy" ); - let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &chunk).await?; + let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; // Clear any prior rejection_reason on the way back to "pending" so an // agent reading the chunk via policy.local cannot see a stale guidance @@ -3000,13 +3099,15 @@ pub(super) async fn handle_clear_draft_chunks( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3036,13 +3137,15 @@ pub(super) async fn handle_get_draft_history( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } let sandbox = state .store - .get_message_by_name::(&req.name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -3505,6 +3608,7 @@ fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status { async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, + workspace: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], ) -> Result<(i64, String), Status> { @@ -3545,7 +3649,14 @@ async fn apply_merge_operations_with_retry( let policy_id = uuid::Uuid::new_v4().to_string(); match store - .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) + .put_policy_revision( + &policy_id, + sandbox_id, + workspace, + next_version, + &payload, + &hash, + ) .await { Ok(()) => { @@ -3592,6 +3703,7 @@ async fn apply_merge_operations_with_retry( pub(super) async fn merge_chunk_into_policy( store: &Store, sandbox_id: &str, + workspace: &str, chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) @@ -3601,17 +3713,19 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; - apply_merge_operations_with_retry(store, sandbox_id, None, &operations).await + apply_merge_operations_with_retry(store, sandbox_id, workspace, None, &operations).await } async fn remove_chunk_from_policy( state: &ServerState, sandbox_id: &str, + workspace: &str, chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { apply_merge_operations_with_retry( state.store.as_ref(), sandbox_id, + workspace, None, &[PolicyMergeOp::RemoveBinary { rule_name: chunk.rule_name.clone(), @@ -3714,7 +3828,7 @@ fn upsert_setting_value( } pub(super) async fn load_global_settings(store: &Store) -> Result { - load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, GLOBAL_SETTINGS_NAME).await + load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, "", GLOBAL_SETTINGS_NAME).await } pub(super) async fn save_global_settings( @@ -3724,6 +3838,7 @@ pub(super) async fn save_global_settings( save_settings_record( store, GLOBAL_SETTINGS_OBJECT_TYPE, + "", GLOBAL_SETTINGS_NAME, settings, ) @@ -3732,32 +3847,41 @@ pub(super) async fn save_global_settings( pub(super) async fn load_sandbox_settings( store: &Store, + workspace: &str, sandbox_name: &str, ) -> Result { - load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, sandbox_name).await + load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, workspace, sandbox_name).await } pub(super) async fn save_sandbox_settings( store: &Store, + workspace: &str, sandbox_name: &str, settings: &StoredSettings, ) -> Result<(), Status> { - save_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, sandbox_name, settings).await + save_settings_record( + store, + SANDBOX_SETTINGS_OBJECT_TYPE, + workspace, + sandbox_name, + settings, + ) + .await } async fn load_settings_record( store: &Store, object_type: &str, + workspace: &str, name: &str, ) -> Result { let record = store - .get_by_name(object_type, name) + .get_by_name(object_type, workspace, name) .await .map_err(|e| Status::internal(format!("fetch settings failed: {e}")))?; if let Some(record) = record { let mut settings = serde_json::from_slice::(&record.payload) .map_err(|e| Status::internal(format!("decode settings payload failed: {e}")))?; - // Populate resource_version from database record for CAS settings.resource_version = record.resource_version; Ok(settings) } else { @@ -3768,6 +3892,7 @@ async fn load_settings_record( async fn save_settings_record( store: &Store, object_type: &str, + workspace: &str, name: &str, settings: &StoredSettings, ) -> Result<(), Status> { @@ -3777,13 +3902,10 @@ async fn save_settings_record( .map_err(|e| Status::internal(format!("encode settings payload failed: {e}")))?; let (id, condition) = if settings.resource_version == 0 { - // Create new settings (resource_version 0 means never persisted) (uuid::Uuid::new_v4().to_string(), WriteCondition::MustCreate) } else { - // Update existing with CAS on the version from when it was loaded - // Fetch the record to get the stable ID let existing = store - .get_by_name(object_type, name) + .get_by_name(object_type, workspace, name) .await .map_err(|e| Status::internal(format!("fetch settings for CAS failed: {e}")))? .ok_or_else(|| Status::not_found("settings disappeared since load"))?; @@ -3794,9 +3916,8 @@ async fn save_settings_record( ) }; - // Single-attempt CAS write store - .put_if(object_type, &id, name, &payload, None, condition) + .put_if(object_type, &id, name, workspace, &payload, None, condition) .await .map_err(|e| match e { crate::persistence::PersistenceError::Conflict { .. } => { @@ -4063,6 +4184,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4096,6 +4218,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4128,6 +4251,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4163,6 +4287,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4177,6 +4302,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "sandbox-b".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4228,6 +4354,7 @@ mod tests { Request::new(GetDraftPolicyRequest { name: "missing-sandbox".to_string(), status_filter: String::new(), + workspace: "default".to_string(), }), "sb-a", ); @@ -4250,6 +4377,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4330,6 +4458,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -4356,12 +4485,14 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -4399,6 +4530,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4448,9 +4580,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()]) + .await + .unwrap(); assert!(layers.is_empty()); } @@ -4470,6 +4603,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), @@ -4491,9 +4625,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()]) + .await + .unwrap(); assert!(layers.is_empty()); } @@ -4514,6 +4649,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -4549,9 +4685,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-custom".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-custom".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_custom"); @@ -4579,6 +4716,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -4600,9 +4738,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-custom".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-custom".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example"); @@ -4616,9 +4755,10 @@ mod tests { .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["work-github".to_string()]) - .await - .unwrap(); + let layers = + profile_provider_policy_layers(&store, "default", &["work-github".to_string()]) + .await + .unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0].rule_name, "_provider_work_github"); @@ -4845,6 +4985,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), @@ -4896,7 +5037,7 @@ mod tests { let mut updated_profile = stored_profile("api.after.example").profile.unwrap(); updated_profile.resource_version = state .store - .get_message_by_name::("custom-policy") + .get_message_by_name::("default", "custom-policy") .await .unwrap() .unwrap() @@ -4913,6 +5054,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-policy".to_string(), + workspace: "default".to_string(), })), ) .await @@ -4937,7 +5079,7 @@ mod tests { let persisted_provider: Provider = state .store - .get_message_by_name("work-custom") + .get_message_by_name::("default", "work-custom") .await .unwrap() .unwrap(); @@ -5129,6 +5271,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-token".to_string(), @@ -5171,10 +5314,13 @@ mod tests { .await .unwrap(); - let first = - compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()]) - .await - .unwrap(); + let first = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); tokio::time::sleep(Duration::from_millis(2)).await; let mut rotated_profile = token_grant_profile("https://auth.example.com/rotated-token") @@ -5182,7 +5328,7 @@ mod tests { .unwrap(); rotated_profile.resource_version = state .store - .get_message_by_name::("custom-token") + .get_message_by_name::("default", "custom-token") .await .unwrap() .unwrap() @@ -5199,15 +5345,19 @@ mod tests { }), expected_resource_version: 0, id: "custom-token".to_string(), + workspace: "default".to_string(), })), ) .await .unwrap(); - let second = - compute_provider_env_revision(state.store.as_ref(), &["work-custom-token".to_string()]) - .await - .unwrap(); + let second = compute_provider_env_revision( + state.store.as_ref(), + "default", + &["work-custom-token".to_string()], + ) + .await + .unwrap(); assert_ne!( first, second, @@ -5265,6 +5415,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await @@ -5301,6 +5452,7 @@ mod tests { sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await @@ -5385,6 +5537,7 @@ mod tests { discovery: None, }), }], + workspace: "default".to_string(), }), ) .await @@ -5397,15 +5550,15 @@ mod tests { state .store .put_message(&test_sandbox( - "sb-custom-attach-lifecycle", - "custom-attach-lifecycle", + "sb-attach-lifecycle", + "attach-lifecycle", test_policy_with_rule("sandbox_only", "sandbox.example.com"), Vec::new(), )) .await .unwrap(); - let baseline_policy = get_sandbox_policy(&state, "sb-custom-attach-lifecycle").await; + let baseline_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await; assert!( !baseline_policy .network_policies @@ -5414,7 +5567,7 @@ mod tests { let baseline_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-custom-attach-lifecycle".to_string(), + sandbox_id: "sb-attach-lifecycle".to_string(), })), ) .await @@ -5424,15 +5577,16 @@ mod tests { handle_attach_sandbox_provider( &state, with_user(Request::new(AttachSandboxProviderRequest { - sandbox_name: "custom-attach-lifecycle".to_string(), + sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), })), ) .await .unwrap(); - let attached_policy = get_sandbox_policy(&state, "sb-custom-attach-lifecycle").await; + let attached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await; let custom_rule = attached_policy .network_policies .get("_provider_work_custom") @@ -5445,7 +5599,7 @@ mod tests { let attached_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-custom-attach-lifecycle".to_string(), + sandbox_id: "sb-attach-lifecycle".to_string(), })), ) .await @@ -5463,15 +5617,16 @@ mod tests { handle_detach_sandbox_provider( &state, Request::new(DetachSandboxProviderRequest { - sandbox_name: "custom-attach-lifecycle".to_string(), + sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, + workspace: "default".to_string(), }), ) .await .unwrap(); - let detached_policy = get_sandbox_policy(&state, "sb-custom-attach-lifecycle").await; + let detached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await; assert!( !detached_policy .network_policies @@ -5480,7 +5635,7 @@ mod tests { let detached_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { - sandbox_id: "sb-custom-attach-lifecycle".to_string(), + sandbox_id: "sb-attach-lifecycle".to_string(), })), ) .await @@ -5530,6 +5685,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -5619,6 +5775,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -5671,7 +5828,7 @@ mod tests { /// settings model, mirroring what `openshell settings set /// proposal_approval_mode ` would do at runtime. async fn seed_sandbox_approval_mode(state: &Arc, sandbox_name: &str, mode: &str) { - let mut settings = load_sandbox_settings(state.store.as_ref(), sandbox_name) + let mut settings = load_sandbox_settings(state.store.as_ref(), "default", sandbox_name) .await .unwrap(); settings.settings.insert( @@ -5679,7 +5836,7 @@ mod tests { StoredSettingValue::String(mode.to_string()), ); settings.revision = settings.revision.wrapping_add(1); - save_sandbox_settings(state.store.as_ref(), sandbox_name, &settings) + save_sandbox_settings(state.store.as_ref(), "default", sandbox_name, &settings) .await .unwrap(); } @@ -5723,6 +5880,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -5779,6 +5937,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -5797,6 +5956,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -5809,6 +5969,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -5826,6 +5987,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -5839,6 +6001,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -5852,6 +6015,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -5864,6 +6028,7 @@ mod tests { &state, Request::new(GetDraftHistoryRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -5879,6 +6044,7 @@ mod tests { limit: 10, offset: 0, global: false, + workspace: "default".to_string(), }), ) .await @@ -5892,6 +6058,7 @@ mod tests { &state, Request::new(ClearDraftChunksRequest { name: sandbox_name.clone(), + workspace: "default".to_string(), }), ) .await @@ -5904,6 +6071,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -5913,7 +6081,10 @@ mod tests { let history_after_clear = handle_get_draft_history( &state, - Request::new(GetDraftHistoryRequest { name: sandbox_name }), + Request::new(GetDraftHistoryRequest { + name: sandbox_name, + workspace: "default".to_string(), + }), ) .await .unwrap() @@ -5938,6 +6109,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -5986,6 +6158,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), + workspace: "default".to_string(), }), ) .await @@ -5996,6 +6169,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6034,6 +6208,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6099,6 +6274,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6149,6 +6325,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6207,6 +6384,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6282,6 +6460,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6352,6 +6531,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6408,6 +6588,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6449,6 +6630,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6508,6 +6690,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6554,6 +6737,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6608,6 +6792,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6647,6 +6832,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6701,6 +6887,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6732,6 +6919,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6785,6 +6973,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6819,6 +7008,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6873,6 +7063,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6906,6 +7097,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6964,6 +7156,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -6998,6 +7191,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7111,7 +7305,7 @@ mod tests { }; state .store - .put_draft_chunk(&chunk, None) + .put_draft_chunk(&chunk, None, "default") .await .expect("draft chunk should persist"); @@ -7120,6 +7314,7 @@ mod tests { with_user(Request::new(ApproveDraftChunkRequest { name: sandbox_name.to_string(), chunk_id: chunk.id.clone(), + workspace: "default".to_string(), })), ) .await @@ -7172,6 +7367,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7225,6 +7421,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7268,6 +7465,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7321,6 +7519,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7353,6 +7552,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7406,6 +7606,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7447,6 +7648,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -7485,6 +7687,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7547,6 +7750,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7607,6 +7811,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7714,6 +7919,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7793,6 +7999,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -7861,6 +8068,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name.clone(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -7882,6 +8090,7 @@ mod tests { name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), + workspace: "default".to_string(), }), ) .await @@ -7907,6 +8116,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -7962,6 +8172,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8007,6 +8218,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8053,6 +8265,7 @@ mod tests { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8063,6 +8276,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8073,6 +8287,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: sandbox_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8083,6 +8298,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_name, status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8121,6 +8337,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8137,6 +8354,7 @@ mod tests { created_at_ms: 1_000_001, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -8187,6 +8405,7 @@ mod tests { with_user(Request::new(GetDraftPolicyRequest { name: sandbox_a.object_name().to_string(), status_filter: String::new(), + workspace: "default".to_string(), })), ) .await @@ -8200,6 +8419,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: other_name.clone(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8212,6 +8432,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), + workspace: "default".to_string(), }), ) .await @@ -8224,6 +8445,7 @@ mod tests { name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), + workspace: "default".to_string(), }), ) .await @@ -8235,6 +8457,7 @@ mod tests { Request::new(ApproveDraftChunkRequest { name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), + workspace: "default".to_string(), }), ) .await @@ -8245,6 +8468,7 @@ mod tests { Request::new(UndoDraftChunkRequest { name: other_name, chunk_id, + workspace: "default".to_string(), }), ) .await @@ -8463,7 +8687,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, "default", &chunk) .await .unwrap(); @@ -8517,6 +8741,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -8559,7 +8784,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk) .await .unwrap(); assert_eq!(version, 2); @@ -8618,6 +8843,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -8660,7 +8886,7 @@ mod tests { rejection_reason: String::new(), }; - let (version, _) = merge_chunk_into_policy(&store, sandbox_id, &chunk) + let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk) .await .unwrap(); assert_eq!(version, 2); @@ -8705,6 +8931,7 @@ mod tests { .put_policy_revision( "p-seed", sandbox_id, + "default", 1, &initial_policy.encode_to_vec(), "seed-hash", @@ -8740,8 +8967,8 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow), - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_allow), + apply_merge_operations_with_retry(&store, sandbox_id, "default", None, &add_deny), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -9411,7 +9638,9 @@ mod tests { #[tokio::test] async fn sandbox_settings_load_returns_default_when_empty() { let store = test_store().await; - let settings = load_sandbox_settings(&store, "nonexistent").await.unwrap(); + let settings = load_sandbox_settings(&store, "default", "nonexistent") + .await + .unwrap(); assert!(settings.settings.is_empty()); assert_eq!(settings.revision, 0); } @@ -9455,11 +9684,13 @@ mod tests { StoredSettingValue::String("auto".to_string()), ); settings.revision = 3; - save_sandbox_settings(&store, sandbox_name, &settings) + save_sandbox_settings(&store, "default", sandbox_name, &settings) .await .unwrap(); - let loaded = load_sandbox_settings(&store, sandbox_name).await.unwrap(); + let loaded = load_sandbox_settings(&store, "default", sandbox_name) + .await + .unwrap(); assert_eq!(loaded.revision, 3); assert_eq!( loaded.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY), @@ -9624,7 +9855,9 @@ mod tests { assert!(!loaded.settings.contains_key("log_level")); let sandbox_name = "test-sandbox"; - let mut sandbox_settings = load_sandbox_settings(&store, sandbox_name).await.unwrap(); + let mut sandbox_settings = load_sandbox_settings(&store, "default", sandbox_name) + .await + .unwrap(); let changed = upsert_setting_value( &mut sandbox_settings.settings, "log_level", @@ -9632,17 +9865,115 @@ mod tests { ); assert!(changed); sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); - save_sandbox_settings(&store, sandbox_name, &sandbox_settings) + save_sandbox_settings(&store, "default", sandbox_name, &sandbox_settings) .await .unwrap(); - let reloaded = load_sandbox_settings(&store, sandbox_name).await.unwrap(); + let reloaded = load_sandbox_settings(&store, "default", sandbox_name) + .await + .unwrap(); assert_eq!( reloaded.settings.get("log_level"), Some(&StoredSettingValue::String("debug".to_string())), ); } + #[tokio::test] + async fn sandbox_settings_are_workspace_isolated() { + let store = test_store().await; + let sandbox_name = "work"; + + let mut alpha_settings = StoredSettings::default(); + alpha_settings.settings.insert( + settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + StoredSettingValue::String("auto".to_string()), + ); + alpha_settings.revision = 1; + save_sandbox_settings(&store, "alpha", sandbox_name, &alpha_settings) + .await + .unwrap(); + + let mut beta_settings = StoredSettings::default(); + beta_settings.settings.insert( + settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + StoredSettingValue::String("manual".to_string()), + ); + beta_settings.revision = 1; + save_sandbox_settings(&store, "beta", sandbox_name, &beta_settings) + .await + .unwrap(); + + let alpha_loaded = load_sandbox_settings(&store, "alpha", sandbox_name) + .await + .unwrap(); + let beta_loaded = load_sandbox_settings(&store, "beta", sandbox_name) + .await + .unwrap(); + + assert_eq!( + alpha_loaded + .settings + .get(settings::PROPOSAL_APPROVAL_MODE_KEY), + Some(&StoredSettingValue::String("auto".to_string())), + ); + assert_eq!( + beta_loaded + .settings + .get(settings::PROPOSAL_APPROVAL_MODE_KEY), + Some(&StoredSettingValue::String("manual".to_string())), + ); + } + + #[tokio::test] + async fn get_sandbox_config_returns_workspace() { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + let state = test_server_state().await; + + for (ws, sb_id, sb_name) in [("alpha", "sb-alpha", "work"), ("beta", "sb-beta", "work")] { + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: sb_id.to_string(), + name: sb_name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: ws.to_string(), + }), + spec: Some(SandboxSpec { + policy: None, + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Provisioning as i32); + state.store.put_message(&sandbox).await.unwrap(); + } + + let alpha_req = with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-alpha".to_string(), + }), + "sb-alpha", + ); + let alpha_resp = handle_get_sandbox_config(&state, alpha_req) + .await + .unwrap() + .into_inner(); + assert_eq!(alpha_resp.workspace, "alpha"); + + let beta_req = with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-beta".to_string(), + }), + "sb-beta", + ); + let beta_resp = handle_get_sandbox_config(&state, beta_req) + .await + .unwrap() + .into_inner(); + assert_eq!(beta_resp.workspace, "beta"); + } + #[test] fn validate_registered_setting_key_rejects_policy() { let err = validate_registered_setting_key("policy").unwrap_err(); @@ -9746,6 +10077,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -9760,7 +10092,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let current = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -9780,6 +10112,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, + workspace: "default".to_string(), }), ) .await @@ -9792,7 +10125,7 @@ mod tests { // Verify the resource_version incremented and policy was backfilled let updated_sandbox = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -9852,6 +10185,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -9865,7 +10199,7 @@ mod tests { let current = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -9905,7 +10239,7 @@ mod tests { let updated_sandbox = state .store - .get_message_by_name::("sync-strip") + .get_message_by_name::("default", "sync-strip") .await .unwrap() .unwrap(); @@ -9955,6 +10289,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -9969,7 +10304,7 @@ mod tests { // Get current version let current = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -9989,6 +10324,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version + workspace: "default".to_string(), }), ) .await @@ -10006,7 +10342,7 @@ mod tests { // Verify the sandbox was not modified (policy still None) let unchanged = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -10036,6 +10372,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { policy: None, @@ -10050,7 +10387,7 @@ mod tests { // All three clients fetch the sandbox and see the same version let initial = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); @@ -10074,6 +10411,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: initial_version, + workspace: "default".to_string(), }), ) .await @@ -10106,7 +10444,7 @@ mod tests { // Final sandbox should have resource_version = initial_version + 1 and policy backfilled let final_sandbox = state .store - .get_message_by_name::("test-sandbox") + .get_message_by_name::("default", "test-sandbox") .await .unwrap() .unwrap(); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c909..17ca132704 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -8,6 +8,7 @@ use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; +use openshell_core::metadata::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCredential, Sandbox, @@ -65,6 +66,7 @@ impl ProviderEnvironment { pub(super) async fn create_provider_record( store: &Store, + workspace: &str, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -78,6 +80,7 @@ pub(super) async fn create_provider_record( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }); } @@ -97,8 +100,18 @@ pub(super) async fn create_provider_record( if provider.r#type.trim().is_empty() { return Err(Status::invalid_argument("provider.type is required")); } + if !provider.profile_workspace.is_empty() && provider.profile_workspace != workspace { + return Err(Status::invalid_argument( + "profile_workspace must be empty (global) or match the provider workspace", + )); + } + let profile_ws = if provider.profile_workspace.is_empty() { + "" + } else { + workspace + }; if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(store, &provider.r#type).await? + && !provider_type_allows_empty_credentials(store, profile_ws, &provider.r#type).await? { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -121,6 +134,7 @@ pub(super) async fn create_provider_record( Provider::object_type(), &provider_id, provider.object_name(), + workspace, &provider.encode_to_vec(), None, WriteCondition::MustCreate, @@ -144,13 +158,17 @@ pub(super) async fn create_provider_record( Ok(redact_provider_credentials(provider)) } -pub(super) async fn get_provider_record(store: &Store, name: &str) -> Result { +pub(super) async fn get_provider_record( + store: &Store, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); } store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found")) @@ -159,11 +177,12 @@ pub(super) async fn get_provider_record(store: &Store, name: &str) -> Result Result, Status> { let providers: Vec = store - .list_messages(limit, offset) + .list_messages(workspace, limit, offset) .await .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; @@ -175,6 +194,7 @@ pub(super) async fn list_provider_records( pub(super) async fn update_provider_record( store: &Store, + workspace: &str, provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -188,7 +208,7 @@ pub(super) async fn update_provider_record( // Resolve provider ID from name for CAS update let existing = store - .get_message_by_name::(provider.object_name()) + .get_message_by_name::(workspace, provider.object_name()) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?; @@ -204,6 +224,13 @@ pub(super) async fn update_provider_record( "provider type cannot be changed; delete and recreate the provider", )); } + if !provider.profile_workspace.is_empty() + && provider.profile_workspace != existing.profile_workspace + { + return Err(Status::invalid_argument( + "profile_workspace cannot be changed; delete and recreate the provider", + )); + } let current_version = existing.metadata.as_ref().map_or(0, |m| m.resource_version); @@ -230,7 +257,7 @@ pub(super) async fn update_provider_record( // #1347. super::validation::validate_object_metadata(candidate.metadata.as_ref(), "provider")?; validate_provider_mutable_fields(&candidate)?; - validate_provider_update_against_attached_sandboxes(store, &candidate).await?; + validate_provider_update_against_attached_sandboxes(store, workspace, &candidate).await?; // Serialize labels for storage let labels_map = candidate.object_labels(); @@ -252,6 +279,7 @@ pub(super) async fn update_provider_record( Provider::object_type(), candidate.object_id(), candidate.object_name(), + workspace, &candidate.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), @@ -281,20 +309,24 @@ pub(super) async fn update_provider_record( Ok(redact_provider_credentials(candidate)) } -pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result { +pub(super) async fn delete_provider_record( + store: &Store, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("name is required")); } let Some(provider) = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { return Ok(false); }; - let blocking_sandboxes = sandboxes_using_provider(store, name).await?; + let blocking_sandboxes = sandboxes_using_provider(store, workspace, name).await?; if !blocking_sandboxes.is_empty() { return Err(Status::failed_precondition(format!( "provider '{name}' is attached to sandbox(es): {}", @@ -306,7 +338,7 @@ pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result< .await?; store - .delete_by_name(Provider::object_type(), name) + .delete_by_name(Provider::object_type(), workspace, name) .await .map_err(|e| Status::internal(format!("delete provider failed: {e}"))) } @@ -316,17 +348,41 @@ pub(super) async fn delete_provider_record(store: &Store, name: &str) -> Result< /// value in the output, `None` skips it. /// /// This is the shared pagination kernel used by all sandbox-scan helpers. -async fn scan_sandboxes(store: &Store, mut f: F) -> Result, Status> +async fn scan_sandboxes(store: &Store, workspace: &str, f: F) -> Result, Status> +where + F: FnMut(Sandbox) -> Option, +{ + scan_sandboxes_inner(store, Some(workspace), f).await +} + +async fn scan_sandboxes_all(store: &Store, f: F) -> Result, Status> +where + F: FnMut(Sandbox) -> Option, +{ + scan_sandboxes_inner(store, None, f).await +} + +async fn scan_sandboxes_inner( + store: &Store, + workspace: Option<&str>, + mut f: F, +) -> Result, Status> where F: FnMut(Sandbox) -> Option, { let mut out = Vec::new(); let mut offset = 0u32; loop { - let records = store - .list(Sandbox::object_type(), 1000, offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; + let records = if let Some(ws) = workspace { + store + .list(Sandbox::object_type(), ws, 1000, offset) + .await + } else { + store + .list_by_type(Sandbox::object_type(), 1000, offset) + .await + } + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; if records.is_empty() { break; } @@ -349,10 +405,11 @@ where async fn sandboxes_using_provider( store: &Store, + workspace: &str, provider_name: &str, ) -> Result, Status> { let provider_name = provider_name.to_string(); - let mut names = scan_sandboxes(store, |sandbox| { + let mut names = scan_sandboxes(store, workspace, |sandbox| { let spec = sandbox.spec.as_ref()?; if spec.providers.iter().any(|n| n == &provider_name) { Some(sandbox.object_name().to_string()) @@ -368,10 +425,11 @@ async fn sandboxes_using_provider( async fn sandboxes_using_provider_records( store: &Store, + workspace: &str, provider_name: &str, ) -> Result, Status> { let provider_name = provider_name.to_string(); - scan_sandboxes(store, |sandbox| { + scan_sandboxes(store, workspace, |sandbox| { let spec = sandbox.spec.as_ref()?; if spec.providers.iter().any(|n| n == &provider_name) { Some(sandbox) @@ -433,6 +491,7 @@ fn merge_i64_map( /// providers so one provider cannot silently overwrite another provider's token. pub(super) async fn resolve_provider_environment( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result { if provider_names.is_empty() { @@ -442,12 +501,13 @@ pub(super) async fn resolve_provider_environment( let mut env = std::collections::HashMap::new(); let mut expires = std::collections::HashMap::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at(store, provider_names, None, now_ms).await?; + validate_provider_environment_keys_unique_at(store, workspace, provider_names, None, now_ms) + .await?; let registry = openshell_providers::ProviderRegistry::new(); for name in provider_names { let provider = store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; @@ -495,7 +555,7 @@ pub(super) async fn resolve_provider_environment( Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials(store, provider_names).await?, + dynamic_credentials: resolve_dynamic_credentials(store, workspace, provider_names).await?, }) } @@ -506,6 +566,7 @@ pub(super) async fn resolve_provider_environment( /// host, port, endpoint path, and provider credential identity. pub(super) async fn resolve_dynamic_credentials( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -516,7 +577,7 @@ pub(super) async fn resolve_dynamic_credentials( for provider_name in provider_names { let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| { Status::internal(format!("failed to fetch provider '{provider_name}': {e}")) @@ -527,7 +588,9 @@ pub(super) async fn resolve_dynamic_credentials( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = + get_provider_type_profile(store, &provider.profile_workspace, profile_id).await? + else { continue; }; @@ -816,10 +879,12 @@ fn endpoint_path_matches(pattern: &str, path: &str) -> bool { pub async fn validate_provider_environment_keys_unique( store: &Store, + workspace: &str, provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, + workspace, provider_names, None, crate::persistence::current_time_ms(), @@ -829,6 +894,7 @@ pub async fn validate_provider_environment_keys_unique( pub async fn validate_provider_credential_key_available_for_attached_sandboxes( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -838,21 +904,23 @@ pub async fn validate_provider_credential_key_available_for_attached_sandboxes( .entry(credential_key.to_string()) .or_insert_with(|| "pending".to_string()); candidate.credential_expires_at_ms.remove(credential_key); - validate_provider_update_against_attached_sandboxes(store, &candidate).await + validate_provider_update_against_attached_sandboxes(store, workspace, &candidate).await } pub async fn validate_provider_update_against_attached_sandboxes( store: &Store, + workspace: &str, provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); - for sandbox in sandboxes_using_provider_records(store, &provider_name).await? { + for sandbox in sandboxes_using_provider_records(store, workspace, &provider_name).await? { let sandbox_name = sandbox.object_name().to_string(); let Some(spec) = sandbox.spec.as_ref() else { continue; }; validate_provider_environment_keys_unique_at( store, + workspace, &spec.providers, Some(provider), crate::persistence::current_time_ms(), @@ -870,6 +938,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( async fn validate_provider_environment_keys_unique_at( store: &Store, + workspace: &str, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -880,7 +949,7 @@ async fn validate_provider_environment_keys_unique_at( let provider = match candidate_provider { Some(candidate) if candidate.object_name() == name.as_str() => candidate.clone(), _ => store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| { @@ -899,7 +968,8 @@ async fn validate_provider_environment_keys_unique_at( seen.insert(key, provider_name.clone()); } } - dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + dynamic_bindings + .extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -921,7 +991,9 @@ async fn dynamic_token_grant_bindings_for_provider( ) -> Result, Status> { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = + get_provider_type_profile(store, &provider.profile_workspace, profile_id).await? + else { return Ok(Vec::new()); }; Ok(dynamic_token_grant_bindings_for_profile( @@ -1152,7 +1224,9 @@ pub(super) async fn handle_create_provider( request: Request, ) -> Result, Status> { let req = request.into_inner(); - let Some(provider) = req.provider else { + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", LifecycleOperation::Create, @@ -1160,8 +1234,11 @@ pub(super) async fn handle_create_provider( ); return Err(Status::invalid_argument("provider is required")); }; + if let Some(metadata) = provider.metadata.as_mut() { + metadata.workspace.clone_from(&workspace); + } let provider_type = provider.r#type.clone(); - let result = create_provider_record(state.store.as_ref(), provider).await; + let result = create_provider_record(state.store.as_ref(), &workspace, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1188,8 +1265,10 @@ pub(super) async fn handle_get_provider( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - let provider = get_provider_record(state.store.as_ref(), &name).await?; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let provider = get_provider_record(state.store.as_ref(), &workspace, &req.name).await?; Ok(Response::new(ProviderResponse { provider: Some(provider), @@ -1201,8 +1280,25 @@ pub(super) async fn handle_list_providers( request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let providers = list_provider_records(state.store.as_ref(), limit, request.offset).await?; + + let providers = if request.all_workspaces { + let all: Vec = state + .store + .list_all_messages(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; + all.into_iter().map(redact_provider_credentials).collect() + } else { + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + list_provider_records(state.store.as_ref(), &workspace, limit, request.offset).await? + }; Ok(Response::new(ListProvidersResponse { providers })) } @@ -1218,9 +1314,12 @@ pub(super) async fn handle_list_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; - let mut profiles = merged_provider_profiles(state.store.as_ref()).await?; + let mut profiles = merged_provider_profiles(state.store.as_ref(), &workspace).await?; profiles.sort_by(|left, right| left.id.cmp(&right.id)); let profiles = profiles .into_iter() @@ -1236,9 +1335,12 @@ pub(super) async fn handle_get_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { - let id = request.into_inner().id; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace).await?; + let id = req.id; let id = normalize_profile_id_request(&id)?; - let profile = get_provider_type_profile(state.store.as_ref(), &id) + let profile = get_provider_type_profile(state.store.as_ref(), &workspace, &id) .await? .ok_or_else(|| Status::not_found("provider profile not found"))? .to_proto(); @@ -1253,14 +1355,24 @@ pub(super) async fn handle_import_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &workspace, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "import").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &workspace, + &profiles, + "import", + ) + .await?, ); } @@ -1274,13 +1386,14 @@ pub(super) async fn handle_import_provider_profiles( let mut imported = Vec::with_capacity(profiles.len()); for (_, profile) in profiles { - let mut stored = stored_provider_profile(profile.to_proto()); + let mut stored = stored_provider_profile_for_workspace(profile.to_proto(), &workspace); let result = state .store .put_if( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1309,12 +1422,16 @@ pub(super) async fn handle_update_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let items = request.profile.into_iter().collect::>(); let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let target_id = normalize_profile_id_request(&request.id)?; diagnostics.extend( - profile_update_target_diagnostics(state.store.as_ref(), &profiles, &target_id).await?, + profile_update_target_diagnostics(state.store.as_ref(), &workspace, &profiles, &target_id) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1342,7 +1459,13 @@ pub(super) async fn handle_update_provider_profiles( }; if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "update").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &workspace, + &profiles, + "update", + ) + .await?, ); } @@ -1361,7 +1484,7 @@ pub(super) async fn handle_update_provider_profiles( .ok_or_else(|| Status::internal("validated provider profile update is missing"))?; let mut stored = state .store - .get_message_by_name::(&target_id) + .get_message_by_name::(&workspace, &target_id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .ok_or_else(|| Status::not_found("provider profile not found"))?; @@ -1390,6 +1513,7 @@ pub(super) async fn handle_update_provider_profiles( StoredProviderProfile::object_type(), stored.object_id(), stored.object_name(), + &workspace, &stored.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(expected_resource_version), @@ -1414,9 +1538,13 @@ pub(super) async fn handle_lint_provider_profiles( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &request.workspace) + .await?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &workspace, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1430,7 +1558,10 @@ pub(super) async fn handle_delete_provider_profile( state: &Arc, request: Request, ) -> Result, Status> { - let id = request.into_inner().id; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_profile_workspace(state.store.as_ref(), &req.workspace).await?; + let id = req.id; let id = normalize_profile_id_request(&id)?; if get_default_profile(&id).is_some() { return Err(Status::failed_precondition( @@ -1441,14 +1572,14 @@ pub(super) async fn handle_delete_provider_profile( let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let existing = state .store - .get_message_by_name::(&id) + .get_message_by_name::(&workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))?; if existing.is_none() { return Err(Status::not_found("provider profile not found")); } - let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &id).await?; + let blocking_sandboxes = sandboxes_using_profile(state.store.as_ref(), &workspace, &id).await?; if !blocking_sandboxes.is_empty() { return Err(Status::failed_precondition(format!( "provider profile '{id}' is in use by sandboxes: {}", @@ -1458,7 +1589,7 @@ pub(super) async fn handle_delete_provider_profile( let deleted = state .store - .delete_by_name(StoredProviderProfile::object_type(), &id) + .delete_by_name(StoredProviderProfile::object_type(), &workspace, &id) .await .map_err(|e| Status::internal(format!("delete provider profile failed: {e}")))?; @@ -1467,6 +1598,7 @@ pub(super) async fn handle_delete_provider_profile( pub(super) async fn get_provider_type_profile( store: &Store, + workspace: &str, id: &str, ) -> Result, Status> { let Some(id) = normalize_profile_id(id) else { @@ -1476,7 +1608,7 @@ pub(super) async fn get_provider_type_profile( return Ok(Some(profile.clone())); } let profile = store - .get_message_by_name::(&id) + .get_message_by_name::(workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .and_then(|stored| { @@ -1496,7 +1628,9 @@ async fn provider_refresh_defaults( provider: &Provider, credential_key: &str, ) -> Result, Status> { - let Some(profile) = get_provider_type_profile(store, &provider.r#type).await? else { + let Some(profile) = + get_provider_type_profile(store, &provider.profile_workspace, &provider.r#type).await? + else { return Ok(None); }; Ok(profile @@ -1539,18 +1673,22 @@ fn validate_refresh_material( async fn provider_type_allows_empty_credentials( store: &Store, + workspace: &str, provider_type: &str, ) -> Result { - let Some(profile) = get_provider_type_profile(store, provider_type).await? else { + let Some(profile) = get_provider_type_profile(store, workspace, provider_type).await? else { return Ok(false); }; Ok(profile.allows_empty_provider_credentials()) } -async fn merged_provider_profiles(store: &Store) -> Result, Status> { +async fn merged_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { let mut profiles = default_profiles().to_vec(); profiles.extend( - custom_provider_profiles(store) + custom_provider_profiles(store, workspace) .await? .into_iter() .filter_map(|stored| { @@ -1566,9 +1704,12 @@ async fn merged_provider_profiles(store: &Store) -> Result Result, Status> { +async fn custom_provider_profiles( + store: &Store, + workspace: &str, +) -> Result, Status> { let profiles: Vec = store - .list_messages(10_000, 0) + .list_messages(workspace, 10_000, 0) .await .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; Ok(profiles) @@ -1625,6 +1766,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1643,7 +1785,7 @@ async fn profile_conflict_diagnostics( continue; } if store - .get_message_by_name::(&id) + .get_message_by_name::(workspace, &id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .is_some() @@ -1662,6 +1804,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1693,7 +1836,7 @@ async fn profile_update_target_diagnostics( return Ok(diagnostics); } if store - .get_message_by_name::(target_id) + .get_message_by_name::(workspace, target_id) .await .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? .is_none() @@ -1725,6 +1868,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, + workspace: &str, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1740,29 +1884,56 @@ async fn profile_attached_sandbox_diagnostics( return Ok(Vec::new()); } - let sandboxes = scan_sandboxes(store, |sandbox| { - sandbox - .spec - .as_ref() - .is_some_and(|spec| !spec.providers.is_empty()) - .then_some(sandbox) - }) - .await?; + let is_platform_scope = workspace.is_empty(); + + let sandboxes = if is_platform_scope { + scan_sandboxes_all(store, |sandbox| { + sandbox + .spec + .as_ref() + .is_some_and(|spec| !spec.providers.is_empty()) + .then_some(sandbox) + }) + .await? + } else { + scan_sandboxes(store, workspace, |sandbox| { + sandbox + .spec + .as_ref() + .is_some_and(|spec| !spec.providers.is_empty()) + .then_some(sandbox) + }) + .await? + }; let mut diagnostics = Vec::new(); for sandbox in sandboxes { let sandbox_name = sandbox.object_name().to_string(); + let sandbox_workspace = sandbox.object_workspace().to_string(); let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); let mut bindings = Vec::new(); let mut imported_profiles_used = Vec::<(String, String)>::new(); for provider_name in &spec.providers { + let provider_ws = if is_platform_scope { + &sandbox_workspace + } else { + workspace + }; let Some(provider) = store - .get_message_by_name::(provider_name) + .get_message_by_name::(provider_ws, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { continue; }; + let scope_mismatch = (is_platform_scope && !provider.profile_workspace.is_empty()) + || (!is_platform_scope && provider.profile_workspace.is_empty()); + if scope_mismatch { + bindings.extend( + dynamic_token_grant_bindings_for_provider(store, &provider).await?, + ); + continue; + } let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); if let Some((source, profile)) = candidate_profiles.get(profile_id) { @@ -1775,7 +1946,9 @@ async fn profile_attached_sandbox_diagnostics( imported_profiles_used.push(used); } } else { - bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + bindings.extend( + dynamic_token_grant_bindings_for_provider(store, &provider).await?, + ); } } @@ -1801,7 +1974,10 @@ async fn profile_attached_sandbox_diagnostics( Ok(diagnostics) } -fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { +fn stored_provider_profile_for_workspace( + profile: ProviderProfile, + workspace: &str, +) -> StoredProviderProfile { use crate::persistence::current_time_ms; let now_ms = current_time_ms(); let profile = profile_storage_payload(profile); @@ -1812,11 +1988,17 @@ fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), profile: Some(profile), } } +#[cfg(test)] +fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + stored_provider_profile_for_workspace(profile, "default") +} + fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { profile.resource_version = 0; profile @@ -1853,29 +2035,56 @@ fn has_errors(diagnostics: &[ProfileValidationDiagnostic]) -> bool { .any(|diagnostic| diagnostic.severity == "error") } -async fn sandboxes_using_profile(store: &Store, profile_id: &str) -> Result, Status> { - // Collect all sandboxes that reference at least one provider — pagination - // is handled by `scan_sandboxes`; the async provider lookup happens below. - let candidates = scan_sandboxes(store, |sandbox| { - let has_providers = sandbox - .spec - .as_ref() - .is_some_and(|s| !s.providers.is_empty()); - has_providers.then_some(sandbox) - }) - .await?; +async fn sandboxes_using_profile( + store: &Store, + workspace: &str, + profile_id: &str, +) -> Result, Status> { + let is_platform_scope = workspace.is_empty(); + + let candidates = if is_platform_scope { + scan_sandboxes_all(store, |sandbox| { + let has_providers = sandbox + .spec + .as_ref() + .is_some_and(|s| !s.providers.is_empty()); + has_providers.then_some(sandbox) + }) + .await? + } else { + scan_sandboxes(store, workspace, |sandbox| { + let has_providers = sandbox + .spec + .as_ref() + .is_some_and(|s| !s.providers.is_empty()); + has_providers.then_some(sandbox) + }) + .await? + }; let mut blocking = Vec::new(); for sandbox in candidates { + let sandbox_workspace = sandbox.object_workspace().to_string(); let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); for provider_name in &spec.providers { + let provider_ws = if is_platform_scope { + &sandbox_workspace + } else { + workspace + }; let Some(provider) = store - .get_message_by_name::(provider_name) + .get_message_by_name::(provider_ws, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? else { continue; }; + if is_platform_scope && !provider.profile_workspace.is_empty() { + continue; + } + if !is_platform_scope && provider.profile_workspace.is_empty() { + continue; + } if normalize_profile_id(&provider.r#type).as_deref() == Some(profile_id) { blocking.push(sandbox.object_name().to_string()); break; @@ -1892,6 +2101,8 @@ pub(super) async fn handle_update_provider( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -1904,7 +2115,7 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); - let result = update_provider_record(state.store.as_ref(), provider).await; + let result = update_provider_record(state.store.as_ref(), &workspace, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1932,12 +2143,14 @@ pub(super) async fn handle_get_provider_refresh_status( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider.trim().is_empty() { return Err(Status::invalid_argument("provider is required")); } let provider = state .store - .get_message_by_name::(&request.provider) + .get_message_by_name::(&workspace, &request.provider) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; @@ -1951,6 +2164,7 @@ pub(super) async fn handle_get_provider_refresh_status( } else { crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), request.credential_key.trim(), ) @@ -1972,6 +2186,8 @@ pub(super) async fn handle_configure_provider_refresh( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2054,18 +2270,20 @@ pub(super) async fn handle_configure_provider_refresh( let provider = state .store - .get_message_by_name::(provider_name) + .get_message_by_name::(&workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; validate_provider_credential_key_available_for_attached_sandboxes( state.store.as_ref(), + &workspace, &provider, credential_key, ) .await?; let refresh_defaults = - provider_refresh_defaults(state.store.as_ref(), &provider, credential_key).await?; + provider_refresh_defaults(state.store.as_ref(), &provider, credential_key) + .await?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults @@ -2108,6 +2326,7 @@ pub(super) async fn handle_configure_provider_refresh( } let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) @@ -2120,6 +2339,7 @@ pub(super) async fn handle_configure_provider_refresh( }); let mut state_record = crate::provider_refresh::new_refresh_state( &provider, + &workspace, credential_key, crate::provider_refresh::NewRefreshStateConfig { strategy, @@ -2146,6 +2366,7 @@ pub(super) async fn handle_configure_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2154,8 +2375,9 @@ pub(super) async fn handle_configure_provider_refresh( credential_key.to_string(), expires_at_ms, )]), + profile_workspace: String::new(), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record(state.store.as_ref(), &workspace, updated).await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2170,6 +2392,8 @@ pub(super) async fn handle_rotate_provider_credential( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2180,6 +2404,7 @@ pub(super) async fn handle_rotate_provider_credential( } let refresh_state = crate::provider_refresh::refresh_provider_credential( state.store.as_ref(), + &workspace, provider_name, credential_key, ) @@ -2197,6 +2422,8 @@ pub(super) async fn handle_delete_provider_refresh( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); if provider_name.is_empty() { @@ -2207,18 +2434,20 @@ pub(super) async fn handle_delete_provider_refresh( } let provider = state .store - .get_message_by_name::(provider_name) + .get_message_by_name::(&workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) .await?; let deleted_refresh_state = crate::provider_refresh::delete_refresh_state( state.store.as_ref(), + &workspace, provider.object_id(), credential_key, ) @@ -2241,6 +2470,7 @@ pub(super) async fn handle_delete_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2249,8 +2479,9 @@ pub(super) async fn handle_delete_provider_refresh( credential_key.to_string(), 0, )]), + profile_workspace: String::new(), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record(state.store.as_ref(), &workspace, updated).await?; } Ok(Response::new(DeleteProviderRefreshResponse { @@ -2262,9 +2493,12 @@ pub(super) async fn handle_delete_provider( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - let provider_profile = provider_profile_for_name(state.store.as_ref(), &name).await; - let result = delete_provider_record(state.store.as_ref(), &name).await; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let name = req.name; + let provider_profile = provider_profile_for_name(state.store.as_ref(), &workspace, &name).await; + let result = delete_provider_record(state.store.as_ref(), &workspace, &name).await; match result { Ok(deleted) => { let outcome = TelemetryOutcome::from_success(deleted); @@ -2303,9 +2537,13 @@ fn emit_provider_profile_lifecycle( openshell_core::telemetry::emit_provider_lifecycle(operation, outcome, provider_profile); } -async fn provider_profile_for_name(store: &Store, name: &str) -> Option { +async fn provider_profile_for_name( + store: &Store, + workspace: &str, + name: &str, +) -> Option { store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .ok() .flatten() @@ -2340,13 +2578,13 @@ mod tests { use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ - DeleteProviderProfileRequest, GetProviderProfileRequest, ImportProviderProfilesRequest, - L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, NetworkBinary, - NetworkEndpoint, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, - ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, - ProviderProfileImportItem, Sandbox, SandboxSpec, StoredProviderProfile, - UpdateProviderProfilesRequest, + CreateWorkspaceRequest, DeleteProviderProfileRequest, GetProviderProfileRequest, + ImportProviderProfilesRequest, L7Allow, L7Rule, LintProviderProfilesRequest, + ListProviderProfilesRequest, NetworkBinary, NetworkEndpoint, ProviderCredentialRefresh, + ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, ProviderProfileImportItem, Sandbox, SandboxSpec, + StoredProviderProfile, UpdateProviderProfilesRequest, }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; @@ -2506,6 +2744,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -2519,6 +2758,7 @@ mod tests { ) -> Provider { create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2526,11 +2766,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -2548,6 +2790,7 @@ mod tests { let err = validate_provider_environment_keys_unique( store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -2576,6 +2819,7 @@ mod tests { validate_provider_environment_keys_unique( store, + "default", &["provider-default".to_string(), "provider-admin".to_string()], ) .await @@ -2591,6 +2835,7 @@ mod tests { create_empty_token_grant_provider(store, "provider-existing", "grant-existing").await; create_provider_record( store, + "default", provider_with_values("provider-candidate", "grant-new"), ) .await @@ -2603,6 +2848,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2632,6 +2878,7 @@ mod tests { profile: Some(profile), source: "grant-new.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2659,6 +2906,7 @@ mod tests { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -2691,7 +2939,7 @@ mod tests { state.store.put_message(&stored).await.unwrap(); let before: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2713,6 +2961,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2727,7 +2976,7 @@ mod tests { ); let after: StoredProviderProfile = state .store - .get_message_by_name("custom-api") + .get_message_by_name("default", "custom-api") .await .unwrap() .unwrap(); @@ -2757,6 +3006,7 @@ mod tests { }), expected_resource_version: 0, id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2778,6 +3028,7 @@ mod tests { }), expected_resource_version: 0, id: "missing-custom".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2809,6 +3060,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2831,6 +3083,7 @@ mod tests { }), expected_resource_version: 0, id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2858,7 +3111,7 @@ mod tests { .unwrap(); let profile_a_version = state .store - .get_message_by_name::("profile-a") + .get_message_by_name::("default", "profile-a") .await .unwrap() .unwrap() @@ -2879,6 +3132,7 @@ mod tests { }), expected_resource_version: 0, id: "profile-a".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2894,7 +3148,7 @@ mod tests { })); let stored_b: StoredProviderProfile = state .store - .get_message_by_name("profile-b") + .get_message_by_name("default", "profile-b") .await .unwrap() .unwrap(); @@ -2918,6 +3172,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2933,7 +3188,7 @@ mod tests { let mut profile = custom_profile("grant-updated"); profile.resource_version = store - .get_message_by_name::("grant-updated") + .get_message_by_name::("default", "grant-updated") .await .unwrap() .unwrap() @@ -2958,6 +3213,7 @@ mod tests { }), expected_resource_version: 0, id: "grant-updated".to_string(), + workspace: "default".to_string(), }), ) .await @@ -2980,6 +3236,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: [ @@ -2995,6 +3252,7 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -3069,6 +3327,7 @@ mod tests { profile: Some(profile), source: format!("{id}.yaml"), }], + workspace: "default".to_string(), }), ) .await @@ -3128,6 +3387,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3174,6 +3434,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3191,6 +3452,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "generic".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3208,6 +3470,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3222,6 +3485,7 @@ mod tests { Request::new(ListProviderProfilesRequest { limit: 100, offset: 0, + workspace: "default".to_string(), }), ) .await @@ -3238,6 +3502,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3258,6 +3523,7 @@ mod tests { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3285,6 +3551,7 @@ mod tests { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3298,6 +3565,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-llm".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3328,6 +3596,7 @@ mod tests { source: "case.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3355,6 +3624,7 @@ mod tests { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3364,6 +3634,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3377,6 +3648,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: " Alex-API ".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3405,6 +3677,7 @@ mod tests { source: "bulk-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3422,7 +3695,10 @@ mod tests { for id in ["bulk-one", "bulk-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { id: id.to_string() }), + Request::new(GetProviderProfileRequest { + id: id.to_string(), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); @@ -3470,6 +3746,7 @@ mod tests { }), source: "advanced-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3482,6 +3759,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "advanced-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3525,6 +3803,7 @@ mod tests { source: "lint-two.yaml".to_string(), }, ], + workspace: "default".to_string(), }), ) .await @@ -3541,7 +3820,10 @@ mod tests { for id in ["lint-one", "lint-two"] { let missing = handle_get_provider_profile( &state, - Request::new(GetProviderProfileRequest { id: id.to_string() }), + Request::new(GetProviderProfileRequest { + id: id.to_string(), + workspace: "default".to_string(), + }), ) .await .unwrap_err(); @@ -3549,6 +3831,77 @@ mod tests { } } + #[tokio::test] + async fn lint_provider_profiles_checks_conflicts_in_request_workspace() { + let state = test_server_state().await; + + handle_import_provider_profiles( + &state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("scoped-lint")), + source: "scoped-lint.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let conflict = handle_lint_provider_profiles( + &state, + Request::new(LintProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("scoped-lint")), + source: "scoped-lint.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!( + !conflict.valid, + "lint should detect conflict with existing profile in the same workspace" + ); + assert!(conflict.diagnostics.iter().any(|d| { + d.profile_id == "scoped-lint" && d.message.contains("already exists") + })); + + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "alpha".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let no_conflict = handle_lint_provider_profiles( + &state, + Request::new(LintProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("scoped-lint")), + source: "scoped-lint.yaml".to_string(), + }], + workspace: "alpha".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!( + no_conflict + .diagnostics + .iter() + .all(|d| d.profile_id != "scoped-lint" + || !d.message.contains("already exists")), + "lint against different workspace should not see profile from default" + ); + } + #[tokio::test] async fn delete_provider_profile_rejects_builtin_and_in_use_custom_profiles() { let state = test_server_state().await; @@ -3559,6 +3912,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -3568,6 +3922,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "github".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3576,6 +3931,7 @@ mod tests { create_provider_record( state.store.as_ref(), + "default", provider_with_values("custom-provider", "custom-api"), ) .await @@ -3589,6 +3945,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3603,6 +3960,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3617,6 +3975,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3624,6 +3983,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3633,6 +3993,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -3652,6 +4013,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -3666,6 +4028,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3676,7 +4039,7 @@ mod tests { let provider = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3692,6 +4055,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3704,6 +4068,7 @@ mod tests { Request::new(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3713,7 +4078,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3729,6 +4094,7 @@ mod tests { let state = test_server_state().await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3736,6 +4102,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -3745,6 +4112,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -3768,6 +4136,7 @@ mod tests { ]), secret_material_keys: vec!["private_key".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -3792,6 +4161,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3799,6 +4169,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3808,6 +4179,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -3827,6 +4199,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(refresh_expires_at_ms), + workspace: "default".to_string(), }), ) .await @@ -3835,6 +4208,7 @@ mod tests { let manual_expires_at_ms = refresh_expires_at_ms + 60_000; update_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3842,6 +4216,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), @@ -3850,6 +4225,7 @@ mod tests { "MS_GRAPH_ACCESS_TOKEN".to_string(), manual_expires_at_ms, )]), + profile_workspace: "default".to_string(), }, ) .await @@ -3860,6 +4236,7 @@ mod tests { Request::new(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace: "default".to_string(), }), ) .await @@ -3869,7 +4246,7 @@ mod tests { let provider_after_delete = state .store - .get_message_by_name::("msgraph") + .get_message_by_name::("default", "msgraph") .await .unwrap() .expect("provider"); @@ -3887,6 +4264,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3894,6 +4272,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3903,12 +4282,14 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3916,12 +4297,14 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -3935,6 +4318,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -3958,6 +4342,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -3979,6 +4364,7 @@ mod tests { for name in ["first-graph", "second-graph"] { create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -3986,11 +4372,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4005,6 +4393,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4028,6 +4417,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4046,6 +4436,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4064,6 +4455,7 @@ mod tests { import_test_graph_refresh_profile(&state).await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4071,6 +4463,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4080,6 +4473,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4102,6 +4496,7 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4118,6 +4513,7 @@ mod tests { material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4131,6 +4527,7 @@ mod tests { let state = test_server_state().await; create_provider_record( state.store.as_ref(), + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4138,6 +4535,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4147,6 +4545,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4165,6 +4564,7 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: None, + workspace: "default".to_string(), }), ) .await @@ -4193,6 +4593,7 @@ mod tests { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await @@ -4202,6 +4603,7 @@ mod tests { &state, Request::new(DeleteProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4213,6 +4615,7 @@ mod tests { &state, Request::new(GetProviderProfileRequest { id: "custom-api".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4236,6 +4639,7 @@ mod tests { &task_state, Request::new(DeleteProviderProfileRequest { id: "guarded-delete".to_string(), + workspace: "default".to_string(), }), ) .await @@ -4262,7 +4666,7 @@ mod tests { let store = test_store().await; let created = provider_with_values("gitlab-local", "gitlab"); - let persisted = create_provider_record(&store, created.clone()) + let persisted = create_provider_record(&store, "default", created.clone()) .await .unwrap(); assert_eq!(persisted.object_name(), "gitlab-local"); @@ -4270,18 +4674,25 @@ mod tests { assert!(!persisted.object_id().is_empty()); let provider_id = persisted.object_id().to_string(); - let duplicate_err = create_provider_record(&store, created).await.unwrap_err(); + let duplicate_err = create_provider_record(&store, "default", created) + .await + .unwrap_err(); assert_eq!(duplicate_err.code(), Code::AlreadyExists); - let loaded = get_provider_record(&store, "gitlab-local").await.unwrap(); + let loaded = get_provider_record(&store, "default", "gitlab-local") + .await + .unwrap(); assert_eq!(loaded.object_id(), provider_id); - let listed = list_provider_records(&store, 100, 0).await.unwrap(); + let listed = list_provider_records(&store, "default", 100, 0) + .await + .unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].object_name(), "gitlab-local"); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4289,6 +4700,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4299,6 +4711,7 @@ mod tests { config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string())) .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4315,7 +4728,7 @@ mod tests { Some(&"REDACTED".to_string()), ); let stored: Provider = store - .get_message_by_name("gitlab-local") + .get_message_by_name("default", "gitlab-local") .await .unwrap() .unwrap(); @@ -4333,17 +4746,17 @@ mod tests { ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); - let deleted = delete_provider_record(&store, "gitlab-local") + let deleted = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(deleted); - let deleted_again = delete_provider_record(&store, "gitlab-local") + let deleted_again = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(!deleted_again); - let missing = get_provider_record(&store, "gitlab-local") + let missing = get_provider_record(&store, "default", "gitlab-local") .await .unwrap_err(); assert_eq!(missing.code(), Code::NotFound); @@ -4355,6 +4768,7 @@ mod tests { let provider = create_provider_record( &store, + "default", Provider { credential_expires_at_ms: HashMap::from([("API_TOKEN".to_string(), 123_456)]), ..provider_with_values("gitlab-local", "gitlab") @@ -4364,6 +4778,7 @@ mod tests { .unwrap(); let refresh_state = crate::provider_refresh::new_refresh_state( &provider, + "default", "API_TOKEN", crate::provider_refresh::NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::External, @@ -4384,7 +4799,7 @@ mod tests { .await .unwrap(); - let deleted = delete_provider_record(&store, "gitlab-local") + let deleted = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap(); assert!(deleted); @@ -4400,9 +4815,13 @@ mod tests { async fn delete_provider_rejects_attached_provider() { let store = test_store().await; - create_provider_record(&store, provider_with_values("gitlab-local", "gitlab")) - .await - .unwrap(); + create_provider_record( + &store, + "default", + provider_with_values("gitlab-local", "gitlab"), + ) + .await + .unwrap(); store .put_message(&Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -4411,6 +4830,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4421,7 +4841,7 @@ mod tests { .await .unwrap(); - let err = delete_provider_record(&store, "gitlab-local") + let err = delete_provider_record(&store, "default", "gitlab-local") .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -4438,7 +4858,9 @@ mod tests { // Create provider and verify resource_version: 1 in response let created = provider_with_values("test-provider", "openai"); - let persisted = create_provider_record(&store, created).await.unwrap(); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); assert_eq!( persisted.metadata.as_ref().unwrap().resource_version, 1, @@ -4448,6 +4870,7 @@ mod tests { // Update provider and verify resource_version: 2 in response let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4455,6 +4878,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4464,6 +4888,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4477,6 +4902,7 @@ mod tests { // Update again and verify resource_version: 3 let updated_again = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4484,6 +4910,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4493,6 +4920,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4511,6 +4939,7 @@ mod tests { let create_missing_type = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4518,11 +4947,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4531,6 +4962,7 @@ mod tests { let create_missing_credentials = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4538,11 +4970,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4599,12 +5033,14 @@ mod tests { }), source: "delegated-refresh-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let delegated_refresh_bootstrap_provider = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4612,11 +5048,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4635,12 +5073,14 @@ mod tests { profile: Some(mixed_required_profile), source: "mixed-required-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let mixed_required_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4648,11 +5088,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4671,12 +5113,14 @@ mod tests { profile: Some(optional_static_profile), source: "optional-static-api.yaml".to_string(), }], + workspace: "default".to_string(), }), ) .await .unwrap(); let optional_static_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4684,11 +5128,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4697,6 +5143,7 @@ mod tests { let vertex_empty = create_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4704,25 +5151,30 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); assert!(vertex_empty.credentials.is_empty()); - let get_err = get_provider_record(store, "").await.unwrap_err(); + let get_err = get_provider_record(store, "default", "").await.unwrap_err(); assert_eq!(get_err.code(), Code::InvalidArgument); - let delete_err = delete_provider_record(store, "").await.unwrap_err(); + let delete_err = delete_provider_record(store, "default", "") + .await + .unwrap_err(); assert_eq!(delete_err.code(), Code::InvalidArgument); let update_missing_err = update_provider_record( store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4730,11 +5182,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4747,10 +5201,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("noop-test", "nvidia"); - let persisted = create_provider_record(&store, created).await.unwrap(); + let persisted = create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4758,11 +5215,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4782,7 +5241,7 @@ mod tests { ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); let stored: Provider = store - .get_message_by_name("noop-test") + .get_message_by_name("default", "noop-test") .await .unwrap() .unwrap(); @@ -4794,10 +5253,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("delete-key-test", "openai"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4805,11 +5267,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), config: std::iter::once(("region".to_string(), String::new())).collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4828,7 +5292,7 @@ mod tests { ); assert!(!updated.config.contains_key("region")); let stored: Provider = store - .get_message_by_name("delete-key-test") + .get_message_by_name("default", "delete-key-test") .await .unwrap() .unwrap(); @@ -4845,10 +5309,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("type-preserve-test", "anthropic"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4856,11 +5323,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4874,10 +5343,13 @@ mod tests { let store = test_store().await; let created = provider_with_values("type-change-test", "nvidia"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4885,11 +5357,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4904,11 +5378,14 @@ mod tests { let store = test_store().await; let created = provider_with_values("validate-merge-test", "gitlab"); - create_provider_record(&store, created).await.unwrap(); + create_provider_record(&store, "default", created) + .await + .unwrap(); let oversized_key = "K".repeat(MAX_MAP_KEY_LEN + 1); let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4916,11 +5393,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4944,16 +5423,19 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; store.put_message(&legacy).await.unwrap(); let updated = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -4961,12 +5443,14 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -4978,7 +5462,9 @@ mod tests { #[tokio::test] async fn resolve_provider_env_empty_list_returns_empty() { let store = test_store().await; - let result = resolve_provider_environment(&store, &[]).await.unwrap(); + let result = resolve_provider_environment(&store, "default", &[]) + .await + .unwrap(); assert!(result.is_empty()); } @@ -4992,6 +5478,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: [ @@ -5006,10 +5493,13 @@ mod tests { )) .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; - create_provider_record(&store, provider).await.unwrap(); + create_provider_record(&store, "default", provider) + .await + .unwrap(); - let result = resolve_provider_environment(&store, &["claude-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["claude-local".to_string()]) .await .unwrap(); assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); @@ -5022,14 +5512,16 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", provider_with_values("static-provider", "unprofiled-static-api"), ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["static-provider".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["static-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("API_TOKEN"), Some(&"token-123".to_string())); assert!(result.dynamic_credentials.is_empty()); @@ -5046,6 +5538,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "test".to_string(), credentials: [ @@ -5061,12 +5554,16 @@ mod tests { ] .into_iter() .collect(), + profile_workspace: "default".to_string(), }; - create_provider_record(&store, provider).await.unwrap(); - - let result = resolve_provider_environment(&store, &["expiring-provider".to_string()]) + create_provider_record(&store, "default", provider) .await .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["expiring-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("FRESH_TOKEN"), Some(&"fresh".to_string())); assert!(!result.contains_key("STALE_TOKEN")); assert_eq!( @@ -5078,7 +5575,7 @@ mod tests { #[tokio::test] async fn resolve_provider_env_unknown_name_returns_error() { let store = test_store().await; - let err = resolve_provider_environment(&store, &["nonexistent".to_string()]) + let err = resolve_provider_environment(&store, "default", &["nonexistent".to_string()]) .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -5095,6 +5592,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "test".to_string(), credentials: [ @@ -5106,12 +5604,16 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; - create_provider_record(&store, provider).await.unwrap(); - - let result = resolve_provider_environment(&store, &["test-provider".to_string()]) + create_provider_record(&store, "default", provider) .await .unwrap(); + + let result = + resolve_provider_environment(&store, "default", &["test-provider".to_string()]) + .await + .unwrap(); assert_eq!(result.get("VALID_KEY"), Some(&"value".to_string())); assert!(!result.contains_key("nested.api_key")); assert!(!result.contains_key("bad-key")); @@ -5122,6 +5624,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5129,6 +5632,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5138,12 +5642,14 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5151,12 +5657,14 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5164,6 +5672,7 @@ mod tests { let result = resolve_provider_environment( &store, + "default", &["claude-local".to_string(), "gitlab-local".to_string()], ) .await @@ -5177,6 +5686,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5184,18 +5694,21 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5203,6 +5716,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5212,6 +5726,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5219,6 +5734,7 @@ mod tests { let err = resolve_provider_environment( &store, + "default", &["provider-a".to_string(), "provider-b".to_string()], ) .await @@ -5234,6 +5750,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5241,6 +5758,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5258,12 +5776,13 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["vertex-local".to_string()]) .await .unwrap(); @@ -5308,6 +5827,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5315,6 +5835,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5331,14 +5852,16 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-bootstrap".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-bootstrap".to_string()]) + .await + .unwrap(); assert!(!result.contains_key("GOOGLE_SERVICE_ACCOUNT_KEY")); assert_eq!( @@ -5352,6 +5875,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5359,6 +5883,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5368,14 +5893,16 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-no-config".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-no-config".to_string()]) + .await + .unwrap(); // Static flags still present. assert!(!result.contains_key("CLAUDE_CODE_USE_VERTEX")); @@ -5400,6 +5927,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5407,6 +5935,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5426,14 +5955,16 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-collision".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, "default", &["vertex-collision".to_string()]) + .await + .unwrap(); // Credential value wins over the injected static value. assert_eq!( @@ -5447,6 +5978,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5454,18 +5986,20 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); - let result = resolve_provider_environment(&store, &["openai-local".to_string()]) + let result = resolve_provider_environment(&store, "default", &["openai-local".to_string()]) .await .unwrap(); @@ -5485,6 +6019,7 @@ mod tests { let store = test_store().await; create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5492,6 +6027,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -5501,12 +6037,14 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await .unwrap(); create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5514,6 +6052,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -5523,6 +6062,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5534,6 +6074,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -5545,6 +6086,7 @@ mod tests { let err = update_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5552,6 +6094,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), credentials: std::iter::once(( @@ -5561,6 +6104,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5579,6 +6123,7 @@ mod tests { create_provider_record( &store, + "default", Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -5586,6 +6131,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5595,6 +6141,7 @@ mod tests { .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }, ) .await @@ -5607,6 +6154,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -5623,7 +6171,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, "default", &spec.providers) .await .unwrap(); @@ -5643,6 +6191,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec::default()), status: None, @@ -5656,7 +6205,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, "default", &spec.providers) .await .unwrap(); @@ -5678,7 +6227,7 @@ mod tests { // Create a valid provider let provider = provider_with_values("test-validate-provider", "test-type"); - let created = create_provider_record(&store, provider.clone()) + let created = create_provider_record(&store, "default", provider.clone()) .await .unwrap(); @@ -5690,11 +6239,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; // Attempt to update with an oversized credential key (exceeds MAX_MAP_KEY_LEN) @@ -5703,7 +6254,7 @@ mod tests { "oversized-key-value".to_string(), ); - let result = update_provider_record(&store, update_req).await; + let result = update_provider_record(&store, "default", update_req).await; // Update should fail with InvalidArgument due to oversized key assert!(result.is_err(), "update with invalid data should fail"); @@ -5721,7 +6272,7 @@ mod tests { // Verify database still contains the ORIGINAL valid provider (not the invalid one) let stored = store - .get_message_by_name::("test-validate-provider") + .get_message_by_name::("default", "test-validate-provider") .await .unwrap() .expect("provider should still exist"); @@ -5753,11 +6304,17 @@ mod tests { // Spawn two concurrent creation attempts for the same provider let store1 = store.clone(); let provider1 = provider.clone(); - let handle1 = tokio::spawn(async move { create_provider_record(&store1, provider1).await }); + let handle1 = + tokio::spawn( + async move { create_provider_record(&store1, "default", provider1).await }, + ); let store2 = store.clone(); let provider2 = provider.clone(); - let handle2 = tokio::spawn(async move { create_provider_record(&store2, provider2).await }); + let handle2 = + tokio::spawn( + async move { create_provider_record(&store2, "default", provider2).await }, + ); // Wait for both to complete let result1 = handle1.await.unwrap(); @@ -5789,7 +6346,7 @@ mod tests { .find_map(Result::ok) .expect("should have one successful creation"); let retrieved = store - .get_message_by_name::("test-concurrent-provider") + .get_message_by_name::("default", "test-concurrent-provider") .await .unwrap(); assert!( @@ -5816,6 +6373,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -5824,7 +6382,7 @@ mod tests { // Fetch the provider to get its current resource_version let current = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5843,6 +6401,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -5884,6 +6443,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -5892,7 +6452,7 @@ mod tests { // Fetch the current state let current = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5911,6 +6471,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -5927,7 +6488,7 @@ mod tests { // Verify the provider was not modified let unchanged = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5951,6 +6512,7 @@ mod tests { &state, Request::new(CreateProviderRequest { provider: Some(provider.clone()), + workspace: "default".to_string(), }), ) .await @@ -5959,7 +6521,7 @@ mod tests { // All three clients fetch the provider and see the same version let initial = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -5981,6 +6543,7 @@ mod tests { Request::new(UpdateProviderRequest { provider: Some(updated), credential_expires_at_ms: HashMap::new(), + workspace: "default".to_string(), }), ) .await @@ -6013,7 +6576,7 @@ mod tests { // Final provider should have exactly 1 new credential key and resource_version = initial_version + 1 let final_provider = state .store - .get_message_by_name::("test-provider") + .get_message_by_name::("default", "test-provider") .await .unwrap() .unwrap(); @@ -6037,11 +6600,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), config, credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -6139,11 +6704,13 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "github".to_string(), credentials: HashMap::new(), config: HashMap::from([("project_id".to_string(), "should-be-ignored".to_string())]), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), }; let mut env = HashMap::new(); openshell_providers::ProviderRegistry::new().inject_env(&provider, &mut env); @@ -6152,4 +6719,533 @@ mod tests { "non-GCP provider should not inject any env vars" ); } + + #[tokio::test] + async fn provider_crud_is_workspace_isolated() { + use openshell_core::proto::{ + CreateProviderRequest, CreateWorkspaceRequest, DeleteProviderRequest, + GetProviderRequest, ListProvidersRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Create same-named provider in each workspace via handlers. + let make_provider = || Provider { + metadata: None, + r#type: "custom".to_string(), + credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + }; + + let created_default = handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "shared-name".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + let default_id = created_default + .provider + .as_ref() + .unwrap() + .object_id() + .to_string(); + + let created_beta = handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "shared-name".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + let beta_id = created_beta + .provider + .as_ref() + .unwrap() + .object_id() + .to_string(); + + assert_ne!(default_id, beta_id); + + // Get in each workspace returns the correct provider. + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), default_id); + + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), beta_id); + + // List is workspace-scoped. + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 1); + assert_eq!(listed.providers[0].object_id(), default_id); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 1); + assert_eq!(listed.providers[0].object_id(), beta_id); + + // Delete in "default" does not affect "beta". + let deleted = handle_delete_provider( + &state, + Request::new(DeleteProviderRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(deleted.deleted); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.providers.is_empty()); + + let got = handle_get_provider( + &state, + Request::new(GetProviderRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.provider.as_ref().unwrap().object_id(), beta_id); + + // all_workspaces returns providers from all workspaces. + // Re-create the "default" provider. + handle_create_provider( + &state, + Request::new(CreateProviderRequest { + provider: Some({ + let mut p = make_provider(); + p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-d".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }); + p + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let listed = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.providers.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_providers( + &state, + Request::new(ListProvidersRequest { + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[tokio::test] + async fn create_provider_rejects_cross_workspace_profile_workspace() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "cross-ws".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "other-workspace".to_string(), + }; + let err = create_provider_record(&store, "default", provider) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("profile_workspace")); + } + + #[tokio::test] + async fn create_provider_accepts_global_profile_workspace() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "global-profile".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + }; + let created = create_provider_record(&store, "default", provider) + .await + .unwrap(); + assert!(created.profile_workspace.is_empty()); + } + + #[tokio::test] + async fn create_provider_accepts_same_workspace_profile_workspace() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "same-ws-profile".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }; + let created = create_provider_record(&store, "default", provider) + .await + .unwrap(); + assert_eq!(created.profile_workspace, "default"); + } + + #[tokio::test] + async fn update_provider_rejects_profile_workspace_change() { + let store = test_store().await; + let provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "immutable-pw".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "claude".to_string(), + credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }; + create_provider_record(&store, "default", provider) + .await + .unwrap(); + + let update = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "immutable-pw".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "other".to_string(), + }; + let err = update_provider_record(&store, "default", update) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("profile_workspace")); + } + + #[tokio::test] + async fn provider_with_global_profile_resolves_platform_scoped_profile() { + use crate::persistence::{ObjectName, WriteCondition}; + use prost::Message; + + let store = test_store().await; + + let stored = stored_provider_profile_for_workspace(custom_profile("global-custom"), ""); + store + .put_if( + StoredProviderProfile::object_type(), + stored.object_id(), + stored.object_name(), + "", + &stored.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .unwrap(); + + let profile = get_provider_type_profile(&store, "", "global-custom") + .await + .unwrap(); + assert!(profile.is_some(), "global profile should be found at workspace ''"); + + let miss = get_provider_type_profile(&store, "default", "global-custom") + .await + .unwrap(); + assert!(miss.is_none(), "global profile should NOT be found at workspace 'default'"); + } + + #[tokio::test] + async fn provider_with_workspace_profile_resolves_workspace_scoped_profile() { + let state = test_server_state().await; + + handle_import_provider_profiles( + &state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("ws-custom")), + source: "ws-custom.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + create_provider_record( + state.store.as_ref(), + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "uses-ws".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + workspace: "default".to_string(), + }), + r#type: "ws-custom".to_string(), + credentials: HashMap::from([("TOKEN".to_string(), "val".to_string())]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap(); + + let profile = get_provider_type_profile(state.store.as_ref(), "default", "ws-custom") + .await + .unwrap(); + assert!(profile.is_some()); + } + + #[tokio::test] + async fn import_provider_profile_platform_and_workspace_scopes_are_isolated() { + let state = test_server_state().await; + + let import = |id: &str, workspace: &str| { + let state = state.clone(); + let id = id.to_string(); + let workspace = workspace.to_string(); + async move { + handle_import_provider_profiles( + &state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile(&id)), + source: format!("{id}.yaml"), + }], + workspace, + }), + ) + .await + .unwrap() + .into_inner() + } + }; + + let resp = import("e2e-platform", "").await; + assert!(resp.imported); + + let resp = import("e2e-workspace", "default").await; + assert!(resp.imported); + + let list = |workspace: &str| { + let state = state.clone(); + let workspace = workspace.to_string(); + async move { + handle_list_provider_profiles( + &state, + Request::new(ListProviderProfilesRequest { + limit: 200, + offset: 0, + workspace, + }), + ) + .await + .unwrap() + .into_inner() + } + }; + + let platform_profiles = list("").await; + assert!( + platform_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-platform"), + "platform-scoped profile should appear in platform list" + ); + assert!( + !platform_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-workspace"), + "workspace-scoped profile should NOT appear in platform list" + ); + + let workspace_profiles = list("default").await; + assert!( + workspace_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-workspace"), + "workspace-scoped profile should appear in workspace list" + ); + assert!( + !workspace_profiles + .profiles + .iter() + .any(|p| p.id == "e2e-platform"), + "platform-scoped profile should NOT appear in workspace list" + ); + + let delete = |id: &str, workspace: &str| { + let state = state.clone(); + let id = id.to_string(); + let workspace = workspace.to_string(); + async move { + handle_delete_provider_profile( + &state, + Request::new(DeleteProviderProfileRequest { id, workspace }), + ) + .await + .unwrap() + .into_inner() + } + }; + + assert!(delete("e2e-platform", "").await.deleted); + assert!(delete("e2e-workspace", "default").await.deleted); + } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 04d5a4ed52..224da4cc63 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -27,7 +27,7 @@ use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, SandboxTemplateSource, TelemetryComputeDriver, TelemetryOutcome, }; -use openshell_core::{ObjectId, ObjectName}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; use std::net::IpAddr; use std::pin::Pin; @@ -134,6 +134,9 @@ async fn handle_create_sandbox_inner( crate::grpc::validation::validate_label_value(value)?; } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { @@ -144,12 +147,13 @@ async fn handle_create_sandbox_inner( for name in &spec.providers { state .store - .get_message_by_name::(name) + .get_message_by_name::(&workspace, name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; } - validate_provider_environment_keys_unique(state.store.as_ref(), &spec.providers).await?; + validate_provider_environment_keys_unique(state.store.as_ref(), &workspace, &spec.providers) + .await?; // Ensure the template always carries the resolved image. let mut spec = spec; @@ -182,6 +186,7 @@ async fn handle_create_sandbox_inner( created_at_ms: now_ms, labels: request.labels.clone(), resource_version: 0, + workspace, }), spec: Some(spec), status: None, @@ -236,14 +241,16 @@ pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; - if name.is_empty() { + let req = request.into_inner(); + if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let sandbox = state .store - .get_message_by_name::(&name) + .get_message_by_name::(&workspace, &req.name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; @@ -258,21 +265,56 @@ pub(super) async fn handle_list_sandboxes( request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.all_workspaces && !request.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let sandboxes: Vec = if request.label_selector.is_empty() { - state - .store - .list_messages(limit, request.offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + let sandboxes: Vec = if request.all_workspaces { + if request.label_selector.is_empty() { + state + .store + .list_all_messages(limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_all_messages_with_selector( + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + } } else { - crate::grpc::validation::validate_label_selector(&request.label_selector)?; - state - .store - .list_messages_with_selector(&request.label_selector, limit, request.offset) - .await - .map_err(|e| Status::internal(format!("list sandboxes with selector failed: {e}")))? + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; + if request.label_selector.is_empty() { + state + .store + .list_messages(&workspace, limit, request.offset) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? + } else { + crate::grpc::validation::validate_label_selector(&request.label_selector)?; + state + .store + .list_messages_with_selector( + &workspace, + &request.label_selector, + limit, + request.offset, + ) + .await + .map_err(|e| { + Status::internal(format!("list sandboxes with selector failed: {e}")) + })? + } }; Ok(Response::new(ListSandboxesResponse { sandboxes })) @@ -282,8 +324,11 @@ pub(super) async fn handle_list_sandbox_providers( state: &Arc, request: Request, ) -> Result, Status> { - let sandbox = sandbox_by_name(state, &request.into_inner().sandbox_name).await?; - let providers = providers_for_sandbox(state, &sandbox).await?; + let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + let sandbox = sandbox_by_name(state, &workspace, &req.sandbox_name).await?; + let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; Ok(Response::new(ListSandboxProvidersResponse { providers })) } @@ -292,6 +337,8 @@ pub(super) async fn handle_attach_sandbox_provider( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -306,7 +353,7 @@ pub(super) async fn handle_attach_sandbox_provider( ))); } - get_provider_record(state.store.as_ref(), &request.provider_name) + get_provider_record(state.store.as_ref(), &workspace, &request.provider_name) .await .map_err(|err| { if err.code() == tonic::Code::NotFound { @@ -320,7 +367,7 @@ pub(super) async fn handle_attach_sandbox_provider( })?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; let sandbox_id = sandbox .metadata .as_ref() @@ -356,8 +403,12 @@ pub(super) async fn handle_attach_sandbox_provider( candidate_spec.providers.push(request.provider_name.clone()); } validate_sandbox_spec(&request.sandbox_name, &candidate_spec)?; - validate_provider_environment_keys_unique(state.store.as_ref(), &candidate_spec.providers) - .await?; + validate_provider_environment_keys_unique( + state.store.as_ref(), + &workspace, + &candidate_spec.providers, + ) + .await?; let provider_name = request.provider_name.clone(); let attached = Arc::new(AtomicBool::new(false)); @@ -406,6 +457,8 @@ pub(super) async fn handle_detach_sandbox_provider( request: Request, ) -> Result, Status> { let request = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &request.workspace).await?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -420,7 +473,7 @@ pub(super) async fn handle_detach_sandbox_provider( } let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &request.sandbox_name).await?; + let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; let sandbox_id = sandbox .metadata .as_ref() @@ -497,19 +550,22 @@ async fn handle_delete_sandbox_inner( state: &Arc, request: Request, ) -> Result, Status> { - let name = request.into_inner().name; + let req = request.into_inner(); + let name = req.name; if name.is_empty() { return Err(Status::invalid_argument("name is required")); } + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; let sandbox_id = state .store - .get_message_by_name::(&name) + .get_message_by_name::(&workspace, &name) .await .ok() .flatten() .map(|sandbox| sandbox.object_id().to_string()); - let deleted = state.compute.delete_sandbox(&name).await?; + let deleted = state.compute.delete_sandbox(&workspace, &name).await?; if deleted && let Some(sandbox_id) = sandbox_id { state.telemetry.end_sandbox_session(&sandbox_id); } @@ -517,14 +573,18 @@ async fn handle_delete_sandbox_inner( Ok(Response::new(DeleteSandboxResponse { deleted })) } -async fn sandbox_by_name(state: &Arc, name: &str) -> Result { +async fn sandbox_by_name( + state: &Arc, + workspace: &str, + name: &str, +) -> Result { if name.is_empty() { return Err(Status::invalid_argument("sandbox_name is required")); } state .store - .get_message_by_name::(name) + .get_message_by_name::(workspace, name) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found")) @@ -533,6 +593,7 @@ async fn sandbox_by_name(state: &Arc, name: &str) -> Result, sandbox: &Sandbox, + workspace: &str, ) -> Result, Status> { let provider_names = sandbox .spec @@ -542,7 +603,7 @@ async fn providers_for_sandbox( let mut providers = Vec::with_capacity(provider_names.len()); for name in provider_names { - let provider = get_provider_record(state.store.as_ref(), name) + let provider = get_provider_record(state.store.as_ref(), workspace, name) .await .map_err(|err| { if err.code() == tonic::Code::NotFound { @@ -1361,6 +1422,7 @@ pub(super) async fn handle_create_ssh_session( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: sandbox.object_workspace().to_string(), }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), @@ -1378,6 +1440,7 @@ pub(super) async fn handle_create_ssh_session( SshSession::object_type(), &token, session.object_name(), + session.object_workspace(), &session.encode_to_vec(), None, WriteCondition::MustCreate, @@ -1436,6 +1499,7 @@ pub(super) async fn handle_revoke_ssh_session( SshSession::object_type(), session.object_id(), session.object_name(), + session.object_workspace(), &session.encode_to_vec(), None, WriteCondition::MatchResourceVersion(resource_version), @@ -2253,12 +2317,14 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) .collect(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } @@ -2270,6 +2336,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(openshell_core::proto::SandboxSpec { log_level: "debug".to_string(), @@ -2304,6 +2371,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2313,7 +2381,7 @@ mod tests { assert!(response.attached); let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -2347,6 +2415,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2356,7 +2425,7 @@ mod tests { assert!(!response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2388,6 +2457,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2397,7 +2467,7 @@ mod tests { assert!(response.detached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2412,6 +2482,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2438,6 +2509,7 @@ mod tests { &state, Request::new(ListSandboxProvidersRequest { sandbox_name: "work".to_string(), + workspace: String::new(), }), ) .await @@ -2467,6 +2539,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2640,6 +2713,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2672,6 +2746,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2703,6 +2778,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + workspace: String::new(), }), ) .await @@ -2752,6 +2828,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2798,6 +2875,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2807,7 +2885,7 @@ mod tests { assert!(response.attached); let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2852,6 +2930,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2863,7 +2942,7 @@ mod tests { // Verify sandbox was not modified let providers = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -2898,6 +2977,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -2924,6 +3004,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, + workspace: String::new(), }), ) .await @@ -3075,7 +3156,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3088,6 +3169,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3099,7 +3181,7 @@ mod tests { // Verify the resource_version incremented let updated_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3126,7 +3208,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3139,6 +3221,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3156,7 +3239,7 @@ mod tests { // Verify the sandbox was not modified let unchanged_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3188,7 +3271,7 @@ mod tests { // Fetch the sandbox to get its current resource_version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3201,6 +3284,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, + workspace: String::new(), }), ) .await @@ -3212,7 +3296,7 @@ mod tests { // Verify the resource_version incremented let updated_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3239,7 +3323,7 @@ mod tests { // Get current version let sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3252,6 +3336,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, + workspace: String::new(), }), ) .await @@ -3269,7 +3354,7 @@ mod tests { // Verify the sandbox was not modified let unchanged_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3312,7 +3397,7 @@ mod tests { // All three clients fetch the sandbox and see version 1 let initial_version = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap() @@ -3332,6 +3417,7 @@ mod tests { sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, + workspace: String::new(), }), ) .await @@ -3368,7 +3454,7 @@ mod tests { // Final sandbox should have exactly 1 provider and resource_version = initial_version + 1 let final_sandbox = state .store - .get_message_by_name::("work") + .get_message_by_name::("default", "work") .await .unwrap() .unwrap(); @@ -3378,4 +3464,214 @@ mod tests { initial_version + 1 ); } + + #[tokio::test] + async fn sandbox_crud_is_workspace_isolated() { + use crate::persistence::ObjectType; + use openshell_core::proto::{ + CreateWorkspaceRequest, GetSandboxRequest, ListSandboxesRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Seed a sandbox named "shared-name" in each workspace. + let mut sbx_default = test_sandbox("shared-name", Vec::new()); + sbx_default.metadata.as_mut().unwrap().id = "sbx-default-id".to_string(); + sbx_default.metadata.as_mut().unwrap().workspace = "default".to_string(); + state.store.put_message(&sbx_default).await.unwrap(); + + let mut sbx_beta = test_sandbox("shared-name", Vec::new()); + sbx_beta.metadata.as_mut().unwrap().id = "sbx-beta-id".to_string(); + sbx_beta.metadata.as_mut().unwrap().workspace = "beta".to_string(); + state.store.put_message(&sbx_beta).await.unwrap(); + + // Get in "default" returns the default sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-default-id"); + + // Get in "beta" returns the beta sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-beta-id"); + + // List in "default" returns 1 sandbox. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 1); + assert_eq!(listed.sandboxes[0].object_id(), "sbx-default-id",); + + // List in "beta" returns 1 sandbox. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 1); + assert_eq!(listed.sandboxes[0].object_id(), "sbx-beta-id"); + + // Delete in "default" (via store) does not affect "beta". + state + .store + .delete_by_name(Sandbox::object_type(), "default", "shared-name") + .await + .unwrap(); + + // "default" now has 0 sandboxes. + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.sandboxes.is_empty()); + + // "beta" still has its sandbox. + let got = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "shared-name".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.sandbox.as_ref().unwrap().object_id(), "sbx-beta-id"); + + // all_workspaces returns sandboxes from all workspaces. + // Re-create the "default" sandbox so both workspaces have one. + state + .store + .put( + Sandbox::object_type(), + "sbx-default-2", + "sandbox-d", + "default", + &Sandbox::default().encode_to_vec(), + None, + ) + .await + .unwrap(); + let listed = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.sandboxes.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_sandboxes( + &state, + Request::new(ListSandboxesRequest { + limit: 100, + offset: 0, + label_selector: String::new(), + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + + #[tokio::test] + async fn revoke_ssh_session_preserves_workspace() { + let state = test_server_state().await; + state + .store + .put_message(&test_sandbox("ws-test", Vec::new())) + .await + .unwrap(); + + let response = handle_create_ssh_session( + &state, + Request::new(CreateSshSessionRequest { + sandbox_id: "sandbox-ws-test".to_string(), + }), + ) + .await + .unwrap(); + let token = response.into_inner().token; + + handle_revoke_ssh_session( + &state, + Request::new(RevokeSshSessionRequest { + token: token.clone(), + }), + ) + .await + .unwrap(); + + let session: SshSession = state + .store + .get_message::(&token) + .await + .unwrap() + .expect("session should still exist after revocation"); + assert!(session.revoked); + assert_eq!(session.object_workspace(), "default"); + } } diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 246d639bef..8b9fbe77a9 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -4,12 +4,12 @@ use std::collections::HashMap; use std::sync::Arc; -use openshell_core::ObjectId; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest, ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse, }; +use openshell_core::{ObjectId, ObjectWorkspace}; use prost::Message as _; use tonic::{Request, Response, Status}; use uuid::Uuid; @@ -18,14 +18,16 @@ use crate::ServerState; use crate::persistence::{ObjectType, WriteCondition}; use crate::service_routing; -const MAX_SERVICE_NAME_LEN: usize = 28; -const MAX_SANDBOX_NAME_LEN: usize = 28; +const MAX_SERVICE_NAME_LEN: usize = super::MAX_ROUTABLE_NAME_LEN; +const MAX_SANDBOX_NAME_LEN: usize = super::MAX_ROUTABLE_NAME_LEN; pub(super) async fn handle_expose_service( state: &Arc, request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { @@ -34,7 +36,7 @@ pub(super) async fn handle_expose_service( let sandbox = state .store - .get_message_by_name::(&req.sandbox) + .get_message_by_name::(&workspace, &req.sandbox) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -45,7 +47,7 @@ pub(super) async fn handle_expose_service( // Fetch existing endpoint to determine create vs. update path let existing = state .store - .get_message_by_name::(&key) + .get_message_by_name::(&workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}")))?; @@ -87,6 +89,7 @@ pub(super) async fn handle_expose_service( created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, + workspace: workspace.clone(), }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -102,6 +105,7 @@ pub(super) async fn handle_expose_service( ServiceEndpoint::object_type(), &id, &key, + &workspace, &endpoint.encode_to_vec(), Some(&labels_json), condition, @@ -114,7 +118,7 @@ pub(super) async fn handle_expose_service( meta.resource_version = result.resource_version; } - let url = service_routing::endpoint_url(&state.config, &req.sandbox, &req.service) + let url = service_routing::endpoint_url(&state.config, &workspace, &req.sandbox, &req.service) .unwrap_or_default(); service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); @@ -129,10 +133,12 @@ pub(super) async fn handle_get_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &req.sandbox, &req.service) + let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service) .await? .ok_or_else(|| Status::not_found("service endpoint not found"))?; @@ -144,18 +150,42 @@ pub(super) async fn handle_list_services( request: Request, ) -> Result, Status> { let req = request.into_inner(); + if req.all_workspaces && !req.workspace.is_empty() { + return Err(Status::invalid_argument( + "all_workspaces and workspace are mutually exclusive", + )); + } if !req.sandbox.is_empty() { validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; } let limit = super::clamp_limit(req.limit, 100, super::MAX_PAGE_SIZE); - let endpoints: Vec = if req.sandbox.is_empty() { - state.store.list_messages(limit, req.offset).await + let endpoints: Vec = if req.all_workspaces { + if !req.sandbox.is_empty() { + return Err(Status::invalid_argument( + "sandbox filter is not supported with all_workspaces", + )); + } + state.store.list_all_messages(limit, req.offset).await } else { - state - .store - .list_messages_with_selector(&format!("sandbox={}", req.sandbox), limit, req.offset) - .await + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; + if req.sandbox.is_empty() { + state + .store + .list_messages(&workspace, limit, req.offset) + .await + } else { + state + .store + .list_messages_with_selector( + &workspace, + &format!("sandbox={}", req.sandbox), + limit, + req.offset, + ) + .await + } } .map_err(|e| Status::internal(format!("list endpoints failed: {e}")))?; @@ -172,10 +202,12 @@ pub(super) async fn handle_delete_service( request: Request, ) -> Result, Status> { let req = request.into_inner(); + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace).await?; validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &req.sandbox, &req.service).await?; + let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service).await?; let Some(endpoint) = endpoint else { return Ok(Response::new(DeleteServiceResponse { deleted: false })); }; @@ -183,7 +215,7 @@ pub(super) async fn handle_delete_service( let key = service_routing::endpoint_key(&req.sandbox, &req.service); let deleted = state .store - .delete_by_name(ServiceEndpoint::object_type(), &key) + .delete_by_name(ServiceEndpoint::object_type(), &workspace, &key) .await .map_err(|e| Status::internal(format!("delete endpoint failed: {e}")))?; @@ -196,13 +228,14 @@ pub(super) async fn handle_delete_service( async fn get_service_endpoint( state: &Arc, + workspace: &str, sandbox: &str, service: &str, ) -> Result, Status> { let key = service_routing::endpoint_key(sandbox, service); state .store - .get_message_by_name::(&key) + .get_message_by_name::(workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}"))) } @@ -211,8 +244,10 @@ fn service_endpoint_response( state: &Arc, endpoint: ServiceEndpoint, ) -> ServiceEndpointResponse { + let workspace = endpoint.object_workspace(); let url = service_routing::endpoint_url( &state.config, + workspace, &endpoint.sandbox_name, &endpoint.service_name, ) @@ -286,6 +321,7 @@ mod tests { created_at_ms: 1_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() @@ -331,6 +367,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -344,6 +381,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -360,6 +399,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -372,6 +412,7 @@ mod tests { Request::new(DeleteServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -384,6 +425,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -396,6 +438,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -419,6 +463,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -433,6 +478,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -457,6 +503,8 @@ mod tests { sandbox: "my-sandbox".to_string(), limit: 0, offset: 0, + workspace: "default".to_string(), + all_workspaces: false, }), ) .await @@ -478,6 +526,7 @@ mod tests { service: "web".to_string(), target_port: 7070, domain: true, + workspace: "default".to_string(), }), ) .await @@ -493,6 +542,7 @@ mod tests { service: "web".to_string(), target_port: 8080, domain: true, + workspace: "default".to_string(), }), ) .await @@ -507,6 +557,7 @@ mod tests { service: "web".to_string(), target_port: 9090, domain: true, + workspace: "default".to_string(), }), ) .await @@ -529,6 +580,7 @@ mod tests { Request::new(GetServiceRequest { sandbox: "my-sandbox".to_string(), service: "web".to_string(), + workspace: "default".to_string(), }), ) .await @@ -541,4 +593,223 @@ mod tests { ); assert_ne!(port, 7070, "port should not be the original value"); } + + #[tokio::test] + async fn service_crud_is_workspace_isolated() { + use openshell_core::proto::{ + CreateWorkspaceRequest, DeleteServiceRequest, GetServiceRequest, ListServicesRequest, + }; + + let state = test_server_state().await; + + // Create a second workspace "beta". + crate::grpc::workspace::handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "beta".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + // Seed a sandbox named "my-sandbox" in each workspace. + seed_sandbox(&state, "my-sandbox").await; + + let mut sbx_beta = Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox-my-sandbox-beta".to_string(), + name: "my-sandbox".to_string(), + created_at_ms: 1_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + }), + spec: Some(openshell_core::proto::SandboxSpec::default()), + ..Default::default() + }; + sbx_beta.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sbx_beta).await.unwrap(); + + // Expose same service name on the same sandbox name in each workspace. + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + target_port: 8080, + domain: true, + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + target_port: 9090, + domain: true, + workspace: "beta".to_string(), + }), + ) + .await + .unwrap(); + + // Get in "default" returns port 8080. + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 8080); + + // Get in "beta" returns port 9090. + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 9090); + + // List in each workspace returns 1 service. + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 1); + assert_eq!( + listed.services[0].endpoint.as_ref().unwrap().target_port, + 8080 + ); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "beta".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 1); + assert_eq!( + listed.services[0].endpoint.as_ref().unwrap().target_port, + 9090 + ); + + // Delete in "default" does not affect "beta". + let deleted = handle_delete_service( + &state, + Request::new(DeleteServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(deleted.deleted); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: "my-sandbox".to_string(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: false, + }), + ) + .await + .unwrap() + .into_inner(); + assert!(listed.services.is_empty()); + + let got = handle_get_service( + &state, + Request::new(GetServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "web".to_string(), + workspace: "beta".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(got.endpoint.as_ref().unwrap().target_port, 9090); + + // all_workspaces returns services from all workspaces. + // Re-create the "default" service. + handle_expose_service( + &state, + Request::new(ExposeServiceRequest { + sandbox: "my-sandbox".to_string(), + service: "api".to_string(), + target_port: 3000, + domain: true, + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let listed = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: String::new(), + limit: 100, + offset: 0, + workspace: String::new(), + all_workspaces: true, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(listed.services.len(), 2); + + // all_workspaces with non-empty workspace is rejected. + let err = handle_list_services( + &state, + Request::new(ListServicesRequest { + sandbox: String::new(), + limit: 100, + offset: 0, + workspace: "default".to_string(), + all_workspaces: true, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 0b3548b062..57465b5c84 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -17,8 +17,8 @@ use tonic::Status; use super::{ MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_NAME_LEN, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, MAX_PROVIDER_CREDENTIALS_ENTRIES, - MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, - MAX_TEMPLATE_STRUCT_SIZE, + MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, MAX_TEMPLATE_MAP_ENTRIES, + MAX_TEMPLATE_STRING_LEN, MAX_TEMPLATE_STRUCT_SIZE, }; // --------------------------------------------------------------------------- @@ -83,6 +83,46 @@ pub(super) fn reject_control_chars(value: &str, field_name: &str) -> Result<(), Ok(()) } +// --------------------------------------------------------------------------- +// DNS-1123 label validation +// --------------------------------------------------------------------------- + +/// Validate that a string conforms to DNS-1123 label rules: lowercase +/// alphanumeric and hyphens, no leading/trailing hyphens, no consecutive +/// hyphens, max 63 characters. `field` is used in error messages. +/// +/// Empty names are allowed (the caller decides whether empty is valid). +pub(super) fn validate_dns1123_label(name: &str, field: &str) -> Result<(), Status> { + if name.is_empty() { + return Ok(()); + } + if name.len() > MAX_NAME_LEN { + return Err(Status::invalid_argument(format!( + "{field} exceeds maximum length ({} > {MAX_NAME_LEN})", + name.len() + ))); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(Status::invalid_argument(format!( + "{field} must contain only lowercase alphanumeric characters or hyphens", + ))); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Status::invalid_argument(format!( + "{field} must not start or end with a hyphen", + ))); + } + if name.contains("--") { + return Err(Status::invalid_argument(format!( + "{field} must not contain consecutive hyphens", + ))); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Sandbox spec validation // --------------------------------------------------------------------------- @@ -95,12 +135,13 @@ pub(super) fn validate_sandbox_spec( spec: &openshell_core::proto::SandboxSpec, ) -> Result<(), Status> { // --- request.name --- - if name.len() > MAX_NAME_LEN { + if !name.is_empty() && name.len() > MAX_ROUTABLE_NAME_LEN { return Err(Status::invalid_argument(format!( - "name exceeds maximum length ({} > {MAX_NAME_LEN})", + "name exceeds maximum length ({} > {MAX_ROUTABLE_NAME_LEN})", name.len() ))); } + validate_dns1123_label(name, "name")?; // --- spec.providers --- if spec.providers.len() > MAX_PROVIDERS { @@ -818,18 +859,41 @@ mod tests { #[test] fn validate_sandbox_spec_accepts_at_limit_name() { - let name = "a".repeat(MAX_NAME_LEN); + let name = "a".repeat(MAX_ROUTABLE_NAME_LEN); assert!(validate_sandbox_spec(&name, &default_spec()).is_ok()); } #[test] fn validate_sandbox_spec_rejects_over_limit_name() { - let name = "a".repeat(MAX_NAME_LEN + 1); + let name = "a".repeat(MAX_ROUTABLE_NAME_LEN + 1); let err = validate_sandbox_spec(&name, &default_spec()).unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("name")); } + #[test] + fn validate_sandbox_spec_rejects_uppercase_name() { + let err = validate_sandbox_spec("MySandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_rejects_leading_hyphen_name() { + let err = validate_sandbox_spec("-sandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_rejects_consecutive_hyphens_name() { + let err = validate_sandbox_spec("my--sandbox", &default_spec()).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_sandbox_spec_accepts_single_hyphens_name() { + assert!(validate_sandbox_spec("my-sandbox", &default_spec()).is_ok()); + } + #[test] fn validate_sandbox_spec_accepts_at_limit_providers() { let spec = SandboxSpec { @@ -1139,11 +1203,13 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: String::new(), }), r#type: provider_type.to_string(), credentials, config, credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs new file mode 100644 index 0000000000..99826aacaa --- /dev/null +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -0,0 +1,807 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Workspace lifecycle handlers. + +#![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> + +use std::sync::Arc; + +use openshell_core::ObjectName; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::{ + AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateWorkspaceRequest, + CreateWorkspaceResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, GetWorkspaceRequest, + GetWorkspaceResponse, InferenceRoute, ListWorkspaceMembersRequest, + ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, Provider, + RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, Sandbox, ServiceEndpoint, + SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, Workspace, + WorkspaceMember, WorkspaceRole, +}; +use prost::Message; +use tonic::{Request, Response, Status}; + +use crate::ServerState; +use crate::persistence::{ + DRAFT_CHUNK_OBJECT_TYPE, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, current_time_ms, +}; + +use super::{MAX_PAGE_SIZE, clamp_limit}; + +pub const WORKSPACE_OBJECT_TYPE: &str = "workspace"; +pub const DEFAULT_WORKSPACE_NAME: &str = "default"; +const MAX_WORKSPACE_MEMBERS: u32 = 1000; + +impl ObjectType for Workspace { + fn object_type() -> &'static str { + WORKSPACE_OBJECT_TYPE + } +} + +impl ObjectType for WorkspaceMember { + fn object_type() -> &'static str { + "workspace_member" + } +} + +fn validate_workspace_name(name: &str) -> Result<(), Status> { + if name.is_empty() { + return Err(Status::invalid_argument("workspace name is required")); + } + if name.len() > super::MAX_ROUTABLE_NAME_LEN { + return Err(Status::invalid_argument(format!( + "workspace name exceeds maximum length ({} > {})", + name.len(), + super::MAX_ROUTABLE_NAME_LEN, + ))); + } + super::validation::validate_dns1123_label(name, "workspace name") +} + +/// Resolve a workspace for provider profile operations. +/// +/// Provider profiles support a platform scope where `""` is a distinct, +/// meaningful value (not an alias for `"default"`). This function preserves +/// `""` as-is for platform-scoped operations. Non-empty workspace values are +/// validated for existence via [`resolve_workspace`]. +pub async fn resolve_profile_workspace( + store: &crate::persistence::Store, + workspace: &str, +) -> Result { + if workspace.is_empty() { + return Ok(String::new()); + } + resolve_workspace(store, workspace).await +} + +/// Resolve and validate a workspace name from a request field. +/// +/// Empty strings are normalized to `"default"`. The workspace must exist in the +/// store; returns `NOT_FOUND` if it doesn't. +/// +/// TODO(phase2): this only validates existence. Workspace membership enforcement +/// (checking the caller is a member of the resolved workspace) is deferred to +/// Phase 2. +pub async fn resolve_workspace( + store: &crate::persistence::Store, + workspace: &str, +) -> Result { + let name = if workspace.is_empty() { + DEFAULT_WORKSPACE_NAME.to_string() + } else { + workspace.to_string() + }; + + let exists: Option = store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("workspace lookup failed: {e}")))?; + + if exists.is_none() { + return Err(Status::not_found(format!("workspace '{name}' not found"))); + } + + Ok(name) +} + +pub(super) async fn handle_create_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + validate_workspace_name(&req.name)?; + + let now_ms = current_time_ms(); + let workspace_id = uuid::Uuid::new_v4().to_string(); + + let workspace = Workspace { + metadata: Some(ObjectMeta { + id: workspace_id.clone(), + name: req.name, + created_at_ms: now_ms, + labels: req.labels, + resource_version: 0, + workspace: String::new(), + }), + }; + + super::validation::validate_object_metadata(workspace.metadata.as_ref(), "workspace")?; + + let result = state + .store + .put_if( + Workspace::object_type(), + &workspace_id, + workspace.object_name(), + "", + &workspace.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists("workspace already exists") + } else { + Status::internal(format!("persist workspace failed: {e}")) + } + })?; + + let mut workspace = workspace; + if let Some(metadata) = workspace.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + + Ok(Response::new(CreateWorkspaceResponse { + workspace: Some(workspace), + })) +} + +pub(super) async fn handle_get_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let name = request.into_inner().name; + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + + let workspace: Workspace = state + .store + .get_message_by_name("", &name) + .await + .map_err(|e| Status::internal(format!("fetch workspace failed: {e}")))? + .ok_or_else(|| Status::not_found("workspace not found"))?; + + Ok(Response::new(GetWorkspaceResponse { + workspace: Some(workspace), + })) +} + +pub(super) async fn handle_list_workspaces( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); + + let workspaces: Vec = if req.label_selector.is_empty() { + state + .store + .list_messages("", limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? + } else { + state + .store + .list_messages_with_selector("", &req.label_selector, limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspaces failed: {e}")))? + }; + + Ok(Response::new(ListWorkspacesResponse { workspaces })) +} + +pub(super) async fn handle_delete_workspace( + state: &Arc, + request: Request, +) -> Result, Status> { + let name = request.into_inner().name; + if name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + if name == DEFAULT_WORKSPACE_NAME { + return Err(Status::failed_precondition( + "the default workspace cannot be deleted", + )); + } + + // TOCTOU: the blocker scan and the subsequent delete are not transactional, + // so a resource could be created between the check and the delete. The + // persistence layer does not currently offer a transaction primitive that + // spans both reads and deletes. Acceptable for now — workspace deletion is + // an admin-only, low-frequency operation. + let mut blocking = Vec::new(); + // Policy revisions and draft chunks are normally deleted when their parent + // sandbox is removed, and refresh state is transient; these checks are a + // safety net against regressions. + for (object_type, label) in [ + (Sandbox::object_type(), "sandbox"), + (Provider::object_type(), "provider"), + (StoredProviderProfile::object_type(), "provider profile"), + (ServiceEndpoint::object_type(), "service"), + (InferenceRoute::object_type(), "inference route"), + (SshSession::object_type(), "ssh session"), + (super::policy::SANDBOX_SETTINGS_OBJECT_TYPE, "sandbox settings"), + (POLICY_OBJECT_TYPE, "sandbox policy"), + (DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunk"), + (StoredProviderCredentialRefreshState::object_type(), "credential refresh state"), + ] { + let records = state + .store + .list(object_type, &name, 1, 0) + .await + .map_err(|e| Status::internal(format!("resource check failed: {e}")))?; + if !records.is_empty() { + blocking.push(label); + } + } + if !blocking.is_empty() { + return Err(Status::failed_precondition(format!( + "workspace '{}' still contains resources: {}", + name, + blocking.join(", ") + ))); + } + + // Clean up membership records before deleting the workspace itself. + state + .store + .delete_all_in_workspace(WorkspaceMember::object_type(), &name) + .await + .map_err(|e| Status::internal(format!("delete workspace members failed: {e}")))?; + + let deleted = state + .store + .delete_by_name(Workspace::object_type(), "", &name) + .await + .map_err(|e| Status::internal(format!("delete workspace failed: {e}")))?; + + Ok(Response::new(DeleteWorkspaceResponse { deleted })) +} + +pub(super) async fn handle_add_workspace_member( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + if req.principal_subject.is_empty() { + return Err(Status::invalid_argument("principal_subject is required")); + } + + let role = WorkspaceRole::try_from(req.role).unwrap_or(WorkspaceRole::Unspecified); + if role == WorkspaceRole::Unspecified { + return Err(Status::invalid_argument( + "role must be USER or ADMIN, not UNSPECIFIED", + )); + } + + let count = state + .store + .count_in_workspace(WorkspaceMember::object_type(), &workspace) + .await + .map_err(|e| Status::internal(format!("count workspace members failed: {e}")))?; + if count >= u64::from(MAX_WORKSPACE_MEMBERS) { + return Err(Status::resource_exhausted(format!( + "workspace has reached the maximum of {MAX_WORKSPACE_MEMBERS} members" + ))); + } + + let member_id = uuid::Uuid::new_v4().to_string(); + let now_ms = current_time_ms(); + + let member = WorkspaceMember { + metadata: Some(ObjectMeta { + id: member_id.clone(), + name: req.principal_subject.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: workspace.clone(), + }), + principal_subject: req.principal_subject, + role: req.role, + }; + + let result = state + .store + .put_if( + WorkspaceMember::object_type(), + &member_id, + member.object_name(), + &workspace, + &member.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists("member already exists in this workspace") + } else { + Status::internal(format!("persist workspace member failed: {e}")) + } + })?; + + let mut member = member; + if let Some(metadata) = member.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + + Ok(Response::new(AddWorkspaceMemberResponse { + member: Some(member), + })) +} + +pub(super) async fn handle_remove_workspace_member( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + if req.principal_subject.is_empty() { + return Err(Status::invalid_argument("principal_subject is required")); + } + + let removed = state + .store + .delete_by_name( + WorkspaceMember::object_type(), + &workspace, + &req.principal_subject, + ) + .await + .map_err(|e| Status::internal(format!("remove workspace member failed: {e}")))?; + + Ok(Response::new(RemoveWorkspaceMemberResponse { removed })) +} + +pub(super) async fn handle_list_workspace_members( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + + let workspace = resolve_workspace(&state.store, &req.workspace).await?; + + let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE); + + let members: Vec = state + .store + .list_messages(&workspace, limit, req.offset) + .await + .map_err(|e| Status::internal(format!("list workspace members failed: {e}")))?; + + Ok(Response::new(ListWorkspaceMembersResponse { members })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + use openshell_core::proto::datamodel::v1::ObjectMeta; + use tonic::{Code, Request}; + + use crate::grpc::test_support::test_server_state; + + #[tokio::test] + async fn delete_workspace_blocked_by_resources() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "ephemeral".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let sbx = Sandbox { + metadata: Some(ObjectMeta { + id: "sbx-eph-1".to_string(), + name: "blocker".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "ephemeral".to_string(), + }), + ..Default::default() + }; + state.store.put_message(&sbx).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "ephemeral".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("sandbox"), + "error should name the blocking resource type: {}", + err.message() + ); + + state + .store + .delete_by_name(Sandbox::object_type(), "ephemeral", "blocker") + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "ephemeral".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn delete_workspace_blocked_by_ssh_session() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "sessioned".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let session = SshSession { + metadata: Some(ObjectMeta { + id: "ssh-1".to_string(), + name: "session-ssh-1".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "sessioned".to_string(), + }), + sandbox_id: "sbx-1".to_string(), + token: "ssh-1".to_string(), + revoked: false, + expires_at_ms: 0, + }; + state.store.put_message(&session).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "sessioned".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("ssh session"), + "error should name ssh session as blocker: {}", + err.message() + ); + } + + #[tokio::test] + async fn delete_workspace_blocked_by_provider_profiles() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "profiles-ws".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + let profile = StoredProviderProfile { + metadata: Some(ObjectMeta { + id: "prof-1".to_string(), + name: "my-profile".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + workspace: "profiles-ws".to_string(), + }), + ..Default::default() + }; + state.store.put_message(&profile).await.unwrap(); + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "profiles-ws".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message().contains("provider profile"), + "error should name provider profile as blocking: {}", + err.message() + ); + + state + .store + .delete_by_name( + StoredProviderProfile::object_type(), + "profiles-ws", + "my-profile", + ) + .await + .unwrap(); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "profiles-ws".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + } + + #[tokio::test] + async fn delete_default_workspace_rejected() { + let state = test_server_state().await; + + let err = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "default".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + } + + #[tokio::test] + async fn add_and_list_workspace_members() { + let state = test_server_state().await; + + let resp = handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "alice@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap() + .into_inner(); + + let member = resp.member.unwrap(); + assert_eq!(member.principal_subject, "alice@example.com"); + assert_eq!(member.role, i32::from(WorkspaceRole::Admin)); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "bob@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "default".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(list.members.len(), 2); + } + + #[tokio::test] + async fn remove_workspace_member() { + let state = test_server_state().await; + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "charlie@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let resp = handle_remove_workspace_member( + &state, + Request::new(RemoveWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "charlie@example.com".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.removed); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "default".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + assert!(list.members.is_empty()); + } + + #[tokio::test] + async fn add_duplicate_member_rejected() { + let state = test_server_state().await; + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "dave@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let err = handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "default".to_string(), + principal_subject: "dave@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::AlreadyExists); + } + + #[tokio::test] + async fn delete_workspace_cleans_up_members() { + let state = test_server_state().await; + + handle_create_workspace( + &state, + Request::new(CreateWorkspaceRequest { + name: "cleanup-test".to_string(), + labels: HashMap::new(), + }), + ) + .await + .unwrap(); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "cleanup-test".to_string(), + principal_subject: "alice@example.com".to_string(), + role: WorkspaceRole::Admin.into(), + }), + ) + .await + .unwrap(); + + handle_add_workspace_member( + &state, + Request::new(AddWorkspaceMemberRequest { + workspace: "cleanup-test".to_string(), + principal_subject: "bob@example.com".to_string(), + role: WorkspaceRole::User.into(), + }), + ) + .await + .unwrap(); + + let list = handle_list_workspace_members( + &state, + Request::new(ListWorkspaceMembersRequest { + workspace: "cleanup-test".to_string(), + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(list.members.len(), 2); + + let resp = handle_delete_workspace( + &state, + Request::new(DeleteWorkspaceRequest { + name: "cleanup-test".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(resp.deleted); + + // Membership records should have been cleaned up. + let remaining: Vec = state + .store + .list_messages("cleanup-test", 100, 0) + .await + .unwrap(); + assert!( + remaining.is_empty(), + "expected 0 orphaned members, found {}", + remaining.len() + ); + } + + #[test] + fn validate_workspace_name_accepts_single_hyphens() { + validate_workspace_name("my-workspace").unwrap(); + } + + #[test] + fn validate_workspace_name_rejects_uppercase() { + let err = validate_workspace_name("MyWorkspace").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_workspace_name_rejects_leading_hyphen() { + let err = validate_workspace_name("-workspace").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn validate_workspace_name_rejects_consecutive_hyphens() { + let err = validate_workspace_name("team--ml").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } +} diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 43416c35d1..0bf1c0a891 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -3,16 +3,16 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> -use openshell_core::ObjectId; use openshell_core::inference::{ VERTEX_AI_PROJECT_ID_KEY, VERTEX_AI_PUBLISHER_KEY, VERTEX_AI_REGION_KEY, }; use openshell_core::proto::{ - ClusterInferenceConfig, GetClusterInferenceRequest, GetClusterInferenceResponse, - GetInferenceBundleRequest, GetInferenceBundleResponse, InferenceRoute, Provider, ResolvedRoute, - SetClusterInferenceRequest, SetClusterInferenceResponse, ValidatedEndpoint, + GetInferenceBundleRequest, GetInferenceBundleResponse, GetInferenceRouteRequest, + GetInferenceRouteResponse, InferenceRoute, InferenceRouteConfig, Provider, ResolvedRoute, + Sandbox, SetInferenceRouteRequest, SetInferenceRouteResponse, ValidatedEndpoint, inference_server::Inference, }; +use openshell_core::{ObjectId, ObjectWorkspace}; use openshell_providers::normalize_provider_type; use openshell_router::config::ResolvedRoute as RouterResolvedRoute; use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; @@ -68,26 +68,38 @@ impl Inference for InferenceService { &self, request: Request, ) -> Result, Status> { - authorize_inference_bundle( + let sandbox_id = authorize_inference_bundle( request .extensions() .get::(), )?; - resolve_inference_bundle(self.state.store.as_ref()) + let sandbox: Sandbox = self + .state + .store + .get_message::(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found(format!("sandbox '{sandbox_id}' not found")))?; + let workspace = sandbox.object_workspace(); + resolve_inference_bundle(self.state.store.as_ref(), workspace) .await .map(Response::new) } #[rpc_auth(auth = "bearer", scope = "inference:write", role = "admin")] - async fn set_cluster_inference( + async fn set_inference_route( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await?; let route_name = effective_route_name(&req.route_name)?; let verify = !req.no_verify; - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( self.state.store.as_ref(), + &workspace, route_name, &req.provider_name, &req.model_id, @@ -102,7 +114,7 @@ impl Inference for InferenceService { .as_ref() .ok_or_else(|| Status::internal("managed route missing config"))?; - Ok(Response::new(SetClusterInferenceResponse { + Ok(Response::new(SetInferenceRouteResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), version: route.route.version, @@ -110,25 +122,29 @@ impl Inference for InferenceService { validation_performed: !route.validation.is_empty(), validated_endpoints: route.validation, timeout_secs: config.timeout_secs, + workspace, })) } #[rpc_auth(auth = "bearer", scope = "inference:read", role = "user")] - async fn get_cluster_inference( + async fn get_inference_route( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); + let workspace = + crate::grpc::workspace::resolve_workspace(self.state.store.as_ref(), &req.workspace) + .await?; let route_name = effective_route_name(&req.route_name)?; let route = self .state .store - .get_message_by_name::(route_name) + .get_message_by_name::(&workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))? .ok_or_else(|| { Status::not_found(format!( - "inference route '{route_name}' is not configured; run 'openshell inference set --provider --model '" + "inference route '{route_name}' is not configured in workspace '{workspace}'; run 'openshell inference set --provider --model '" )) })?; @@ -143,18 +159,20 @@ impl Inference for InferenceService { )); } - Ok(Response::new(GetClusterInferenceResponse { + Ok(Response::new(GetInferenceRouteResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), version: route.version, route_name: route_name.to_string(), timeout_secs: config.timeout_secs, + workspace, })) } } -async fn upsert_cluster_inference_route( +async fn upsert_inference_route( store: &Store, + workspace: &str, route_name: &str, provider_name: &str, model_id: &str, @@ -169,11 +187,13 @@ async fn upsert_cluster_inference_route( } let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| { - Status::failed_precondition(format!("provider '{provider_name}' not found")) + Status::failed_precondition(format!( + "provider '{provider_name}' not found in workspace '{workspace}'" + )) })?; let resolved = resolve_provider_route(&provider, model_id)?; @@ -183,18 +203,16 @@ async fn upsert_cluster_inference_route( Vec::new() }; - let config = build_cluster_inference_config(&provider, model_id, timeout_secs); + let config = build_inference_route_config(&provider, model_id, timeout_secs); - // Fetch existing route to determine create vs. update path let existing = store - .get_message_by_name::(route_name) + .get_message_by_name::(workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?; let now_ms = current_time_ms(); let (id, metadata, new_version, condition) = if let Some(existing) = existing { - // Update path: preserve metadata, increment version, use CAS let resource_version = existing.metadata.as_ref().map_or(0, |m| m.resource_version); ( existing.object_id().to_string(), @@ -203,7 +221,6 @@ async fn upsert_cluster_inference_route( WriteCondition::MatchResourceVersion(resource_version), ) } else { - // Create path: new metadata, version 1, use MustCreate let new_id = uuid::Uuid::new_v4().to_string(); let new_metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: new_id.clone(), @@ -211,6 +228,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -221,15 +239,14 @@ async fn upsert_cluster_inference_route( version: new_version, }; - // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) crate::grpc::validate_object_metadata(route.metadata.as_ref(), "inference_route")?; - // Single-attempt CAS write: fails with ABORTED on concurrent modification store .put_if( InferenceRoute::object_type(), &id, route_name, + workspace, &route.encode_to_vec(), None, condition, @@ -240,12 +257,12 @@ async fn upsert_cluster_inference_route( Ok(UpsertedInferenceRoute { route, validation }) } -fn build_cluster_inference_config( +fn build_inference_route_config( provider: &Provider, model_id: &str, timeout_secs: u64, -) -> ClusterInferenceConfig { - ClusterInferenceConfig { +) -> InferenceRouteConfig { + InferenceRouteConfig { provider_name: provider.object_name().to_string(), model_id: model_id.to_string(), timeout_secs, @@ -875,9 +892,9 @@ fn find_provider_config_value(provider: &Provider, preferred_keys: &[&str]) -> O fn authorize_inference_bundle( principal: Option<&crate::auth::principal::Principal>, -) -> Result<(), Status> { +) -> Result { match principal { - Some(crate::auth::principal::Principal::Sandbox(_)) => Ok(()), + Some(crate::auth::principal::Principal::Sandbox(s)) => Ok(s.sandbox_id.clone()), Some(crate::auth::principal::Principal::User(_)) => Err(Status::permission_denied( "GetInferenceBundle requires a sandbox principal", )), @@ -887,13 +904,16 @@ fn authorize_inference_bundle( } } -/// Resolve the inference bundle (all managed routes + revision hash). -async fn resolve_inference_bundle(store: &Store) -> Result { +/// Resolve the inference bundle for a workspace (all managed routes + revision hash). +async fn resolve_inference_bundle( + store: &Store, + workspace: &str, +) -> Result { let mut routes = Vec::new(); - if let Some(r) = resolve_route_by_name(store, CLUSTER_INFERENCE_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name(store, workspace, CLUSTER_INFERENCE_ROUTE_NAME).await? { routes.push(r); } - if let Some(r) = resolve_route_by_name(store, SANDBOX_SYSTEM_ROUTE_NAME).await? { + if let Some(r) = resolve_route_by_name(store, workspace, SANDBOX_SYSTEM_ROUTE_NAME).await? { routes.push(r); } @@ -932,10 +952,11 @@ async fn resolve_inference_bundle(store: &Store) -> Result Result, Status> { let route = store - .get_message_by_name::(route_name) + .get_message_by_name::(workspace, route_name) .await .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?; @@ -960,12 +981,12 @@ async fn resolve_route_by_name( } let provider = store - .get_message_by_name::(&config.provider_name) + .get_message_by_name::(workspace, &config.provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| { Status::failed_precondition(format!( - "configured provider '{}' was not found", + "configured provider '{}' was not found in workspace '{workspace}'", config.provider_name )) })?; @@ -1030,8 +1051,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: provider_name.to_string(), model_id: model_id.to_string(), timeout_secs: 0, @@ -1048,11 +1070,13 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), } } @@ -1093,8 +1117,9 @@ mod tests { .await .expect("provider should persist"); - let first = upsert_cluster_inference_route( + let first = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -1105,8 +1130,9 @@ mod tests { .expect("first set should succeed"); assert_eq!(first.route.object_name(), CLUSTER_INFERENCE_ROUTE_NAME); - let second = upsert_cluster_inference_route( + let second = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -1145,6 +1171,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1161,14 +1188,16 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .await .expect("provider should persist"); - let upserted = upsert_cluster_inference_route( + let upserted = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-bridge", "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -1187,7 +1216,7 @@ mod tests { // auth (empty api_key + provider_type = "aws-bedrock"). Note // the api_key is empty even though the provider has a // credential — auth: None skips api-key lookup entirely. - let managed = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let managed = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1221,6 +1250,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1231,14 +1261,16 @@ mod tests { // Intentionally no BEDROCK_BASE_URL. config: std::collections::HashMap::new(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .await .expect("provider should persist"); - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-misconfigured", "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -1269,6 +1301,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1278,6 +1311,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1296,8 +1330,9 @@ mod tests { "tab\there", "newline\nhere", ] { - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "bedrock-bridge", unsafe_model, @@ -1323,7 +1358,7 @@ mod tests { async fn resolve_managed_route_returns_none_when_missing() { let store = test_store().await; - let route = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let route = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("resolution should not fail"); assert!(route.is_none()); @@ -1342,7 +1377,7 @@ mod tests { let route = make_route(CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "mock/model-a"); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1380,7 +1415,7 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1422,7 +1457,7 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1447,7 +1482,7 @@ mod tests { async fn bundle_without_cluster_route_returns_empty_routes() { let store = test_store().await; - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); assert!(resp.routes.is_empty()); @@ -1470,10 +1505,10 @@ mod tests { ); store.put_message(&route).await.expect("persist route"); - let resp1 = resolve_inference_bundle(&store) + let resp1 = resolve_inference_bundle(&store, "default") .await .expect("first resolve"); - let resp2 = resolve_inference_bundle(&store) + let resp2 = resolve_inference_bundle(&store, "default") .await .expect("second resolve"); @@ -1494,6 +1529,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1504,6 +1540,7 @@ mod tests { )) .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) @@ -1517,8 +1554,9 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), - config: Some(ClusterInferenceConfig { + config: Some(InferenceRouteConfig { provider_name: "openai-dev".to_string(), model_id: "test/model".to_string(), timeout_secs: 0, @@ -1530,7 +1568,7 @@ mod tests { .await .expect("route should persist"); - let managed = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let managed = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1566,7 +1604,7 @@ mod tests { .await .expect("route should persist"); - let first = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let first = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1579,13 +1617,14 @@ mod tests { .collect(), config: provider.config.clone(), credential_expires_at_ms: provider.credential_expires_at_ms.clone(), + profile_workspace: provider.profile_workspace.clone(), }; store .put_message(&rotated_provider) .await .expect("provider rotation should persist"); - let second = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let second = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("route should resolve") .expect("managed route should exist"); @@ -1599,8 +1638,9 @@ mod tests { let provider = make_provider("anthropic-dev", "anthropic", "ANTHROPIC_API_KEY", "sk-ant"); store.put_message(&provider).await.expect("persist"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", SANDBOX_SYSTEM_ROUTE_NAME, "anthropic-dev", "claude-sonnet-4-20250514", @@ -1617,7 +1657,7 @@ mod tests { } #[tokio::test] - async fn upsert_cluster_inference_route_vertex_ai_anthropic_sets_model_in_path() { + async fn upsert_inference_route_vertex_ai_anthropic_sets_model_in_path() { let store = test_store().await; // Build a Vertex AI provider with the required config and a minted access token. @@ -1628,6 +1668,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1645,14 +1686,16 @@ mod tests { .into_iter() .collect(), credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), }; store .put_message(&provider) .await .expect("persist provider"); - let result = upsert_cluster_inference_route( + let result = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "vertex-test", "claude-3-5-sonnet@20241022", @@ -1669,7 +1712,7 @@ mod tests { assert_eq!(config.model_id, "claude-3-5-sonnet@20241022"); // Resolve the persisted route and assert Vertex AI Anthropic path contract - let resolved = resolve_route_by_name(&store, CLUSTER_INFERENCE_ROUTE_NAME) + let resolved = resolve_route_by_name(&store, "default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("resolve should not fail") .expect("route should exist after upsert"); @@ -1723,7 +1766,7 @@ mod tests { .await .expect("persist system route"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1743,7 +1786,7 @@ mod tests { let system_route = make_route(SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini"); store.put_message(&system_route).await.expect("persist"); - let resp = resolve_inference_bundle(&store) + let resp = resolve_inference_bundle(&store, "default") .await .expect("bundle should resolve"); @@ -1759,8 +1802,9 @@ mod tests { let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); store.put_message(&provider).await.expect("persist"); - upsert_cluster_inference_route( + upsert_inference_route( &store, + "default", SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1771,7 +1815,7 @@ mod tests { .expect("upsert should succeed"); let route = store - .get_message_by_name::(SANDBOX_SYSTEM_ROUTE_NAME) + .get_message_by_name::("default", SANDBOX_SYSTEM_ROUTE_NAME) .await .expect("fetch should succeed") .expect("route should exist"); @@ -1816,8 +1860,9 @@ mod tests { .await .expect("persist provider"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1856,8 +1901,9 @@ mod tests { .await .expect("persist provider"); - let err = upsert_cluster_inference_route( + let err = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1876,7 +1922,7 @@ mod tests { assert!(err.message().contains("--no-verify")); let persisted = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch route") .is_none(); @@ -1899,8 +1945,9 @@ mod tests { .await .expect("persist provider"); - let route = upsert_cluster_inference_route( + let route = upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o-mini", @@ -1965,6 +2012,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, + workspace: "default".to_string(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1974,6 +2022,7 @@ mod tests { .collect(), config, credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), } } @@ -2869,8 +2918,9 @@ mod tests { // Spawn two concurrent upsert calls for the same route (create path) let store1 = store.clone(); let handle1 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store1, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -2882,8 +2932,9 @@ mod tests { let store2 = store.clone(); let handle2 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store2, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -2940,7 +2991,7 @@ mod tests { // Only one route should exist. let route = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch") .expect("route should exist"); @@ -2956,8 +3007,9 @@ mod tests { store.put_message(&provider).await.expect("persist"); // Create initial route - upsert_cluster_inference_route( + upsert_inference_route( &store, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-3.5", @@ -2970,8 +3022,9 @@ mod tests { // Spawn two concurrent updates let store1 = store.clone(); let handle1 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store1, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", @@ -2983,8 +3036,9 @@ mod tests { let store2 = store.clone(); let handle2 = tokio::spawn(async move { - upsert_cluster_inference_route( + upsert_inference_route( &store2, + "default", CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", @@ -3007,7 +3061,7 @@ mod tests { // The route should have one of the new model values and version 2 let route = store - .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .get_message_by_name::("default", CLUSTER_INFERENCE_ROUTE_NAME) .await .expect("fetch") .expect("route should exist"); @@ -3027,4 +3081,132 @@ mod tests { route.version ); } + + // ------------------------------------------------------------------------- + // Workspace isolation tests + // ------------------------------------------------------------------------- + + #[tokio::test] + async fn inference_bundle_resolves_workspace_scoped_route() { + let store = test_store().await; + + let alpha_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-alpha".to_string(), + name: "openai-alpha".to_string(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: "alpha".to_string(), + }), + r#type: "openai".to_string(), + credentials: std::iter::once(( + "OPENAI_API_KEY".to_string(), + "sk-alpha-key".to_string(), + )) + .collect(), + config: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), + }; + store + .put_message(&alpha_provider) + .await + .expect("persist alpha provider"); + + let beta_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-beta".to_string(), + name: "anthropic-beta".to_string(), + created_at_ms: 1_000_000, + labels: std::collections::HashMap::new(), + resource_version: 0, + workspace: "beta".to_string(), + }), + r#type: "anthropic".to_string(), + credentials: std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "sk-beta-key".to_string(), + )) + .collect(), + config: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + profile_workspace: String::new(), + }; + store + .put_message(&beta_provider) + .await + .expect("persist beta provider"); + + upsert_inference_route( + &store, + "alpha", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-alpha", + "gpt-4", + 0, + false, + ) + .await + .expect("set alpha route"); + + upsert_inference_route( + &store, + "beta", + CLUSTER_INFERENCE_ROUTE_NAME, + "anthropic-beta", + "claude-sonnet-4-20250514", + 0, + false, + ) + .await + .expect("set beta route"); + + let alpha_bundle = resolve_inference_bundle(&store, "alpha") + .await + .expect("alpha bundle should resolve"); + assert_eq!(alpha_bundle.routes.len(), 1); + assert_eq!(alpha_bundle.routes[0].api_key, "sk-alpha-key"); + assert_eq!(alpha_bundle.routes[0].model_id, "gpt-4"); + assert_eq!(alpha_bundle.routes[0].provider_type, "openai"); + + let beta_bundle = resolve_inference_bundle(&store, "beta") + .await + .expect("beta bundle should resolve"); + assert_eq!(beta_bundle.routes.len(), 1); + assert_eq!(beta_bundle.routes[0].api_key, "sk-beta-key"); + assert_eq!(beta_bundle.routes[0].model_id, "claude-sonnet-4-20250514"); + assert_eq!(beta_bundle.routes[0].provider_type, "anthropic"); + } + + #[tokio::test] + async fn inference_bundle_empty_for_workspace_without_route() { + let store = test_store().await; + + let provider = make_provider("openai-dev", "openai", "OPENAI_API_KEY", "sk-test"); + store + .put_message(&provider) + .await + .expect("persist provider"); + + upsert_inference_route( + &store, + "default", + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4", + 0, + false, + ) + .await + .expect("set default route"); + + let other_bundle = resolve_inference_bundle(&store, "other-workspace") + .await + .expect("bundle should resolve"); + assert!( + other_bundle.routes.is_empty(), + "workspace with no route should get empty bundle, not inherit from another workspace" + ); + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6462ccbbf3..86abac897d 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -369,6 +369,8 @@ pub(crate) async fn run_server( // shutdown so the running compute state matches the persisted store. // Runs before watchers spawn so the watch loop sees the post-resume // snapshot on its first poll. + ensure_default_workspace(&store).await?; + if let Err(err) = state.compute.resume_persisted_sandboxes().await { warn!(error = %err, "Failed to resume persisted sandboxes during startup"); } @@ -895,6 +897,50 @@ fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { } } +pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { + use grpc::workspace::{DEFAULT_WORKSPACE_NAME, WORKSPACE_OBJECT_TYPE}; + use openshell_core::proto::Workspace; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use prost::Message; + + let id = uuid::Uuid::new_v4().to_string(); + let workspace = Workspace { + metadata: Some(ObjectMeta { + id: id.clone(), + name: DEFAULT_WORKSPACE_NAME.to_string(), + created_at_ms: persistence::current_time_ms(), + labels: HashMap::new(), + resource_version: 0, + workspace: String::new(), + }), + }; + + match store + .put_if( + WORKSPACE_OBJECT_TYPE, + &id, + DEFAULT_WORKSPACE_NAME, + "", + &workspace.encode_to_vec(), + None, + persistence::WriteCondition::MustCreate, + ) + .await + { + Ok(_) => { + info!("Created default workspace"); + Ok(()) + } + Err(persistence::PersistenceError::UniqueViolation { .. }) => { + debug!("Default workspace already exists"); + Ok(()) + } + Err(e) => Err(Error::config(format!( + "failed to ensure default workspace: {e}" + ))), + } +} + #[cfg(test)] mod tests { use super::{ @@ -1029,7 +1075,7 @@ mod tests { fn service_request(addr: SocketAddr, extra_headers: &[(&str, &str)]) -> String { let mut request = format!( - "GET / HTTP/1.1\r\nHost: my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", + "GET / HTTP/1.1\r\nHost: default--my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", addr.port() ); for (name, value) in extra_headers { @@ -1160,7 +1206,7 @@ mod tests { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("127.0.0.1:0", true).await; let origin = format!( - "http://my-sandbox--web.dev.openshell.localhost:{}", + "http://default--my-sandbox--web.dev.openshell.localhost:{}", addr.port() ); let response = send_plain_http( diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 9e70c64721..01c0b6e7a7 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -1678,8 +1678,8 @@ mod tests { "/openshell.v1.OpenShell/DeleteSandbox", "/openshell.v1.OpenShell/CreateProvider", "/openshell.v1.OpenShell/ApproveDraftChunk", - "/openshell.inference.v1.Inference/GetClusterInference", - "/openshell.inference.v1.Inference/SetClusterInference", + "/openshell.inference.v1.Inference/GetInferenceRoute", + "/openshell.inference.v1.Inference/SetInferenceRoute", ] { let mock = Arc::new(MockAuthenticator::returning(Ok(Some(sandbox_principal())))); let chain = AuthenticatorChain::new(vec![mock]); diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 8b6172c1c6..84fb4d237a 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -81,6 +81,7 @@ pub struct ObjectRecord { pub object_type: String, pub id: String, pub name: String, + pub workspace: String, pub payload: Vec, pub created_at_ms: i64, pub updated_at_ms: i64, @@ -125,7 +126,7 @@ pub trait ObjectType { // Import object metadata accessor traits from openshell-core // (implementations for all proto types are in openshell-core::metadata) pub use openshell_core::{ - GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion, + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, }; /// Generate a random 6-character lowercase alphabetic name. @@ -139,6 +140,11 @@ pub fn generate_name() -> String { /// Decode a single [`ObjectRecord`] into a protobuf message, hydrating /// `resource_version` from the authoritative DB row. /// +/// Only `resource_version` is hydrated here; `workspace` is NOT backfilled from +/// the DB column because the workspace field is authoritative in the protobuf +/// payload at creation time. This is a breaking upgrade — pre-workspace records +/// will carry an empty workspace until they are re-created. +/// /// Extracted to avoid repeating the identical decode-and-hydrate block across /// `get_message`, `get_message_by_name`, `list_messages`, and /// `list_messages_with_selector`. @@ -221,6 +227,7 @@ impl Store { /// * `object_type` - Type discriminator for the object /// * `id` - Stable object identifier /// * `name` - Human-readable object name + /// * `workspace` - Workspace scope for multi-tenant isolation /// * `payload` - Serialized object data /// * `labels` - Optional JSON-serialized labels /// * `condition` - Write precondition (`MustCreate`, `MatchResourceVersion`, or `Unconditional`) @@ -229,16 +236,18 @@ impl Store { /// * `Ok(WriteResult)` - Write succeeded with new `resource_version` and timestamps /// * `Err(Conflict)` - Resource version mismatch (for `MatchResourceVersion`) /// * `Err(UniqueViolation)` - Object already exists (for `MustCreate`) or name conflict + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, ) -> PersistenceResult { - store_dispatch!(self.put_if(object_type, id, name, payload, labels, condition)) + store_dispatch!(self.put_if(object_type, id, name, workspace, payload, labels, condition)) } /// Delete an object by id with compare-and-swap support. @@ -262,16 +271,18 @@ impl Store { } /// Insert or update a generic named object with an application-owned scope. + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { - store_dispatch!(self.put_scoped(object_type, id, name, scope, payload, labels)) + store_dispatch!(self.put_scoped(object_type, id, name, workspace, scope, payload, labels)) } /// Fetch an object by id. @@ -283,13 +294,14 @@ impl Store { store_dispatch!(self.get(object_type, id)) } - /// Fetch an object by name within an object type. + /// Fetch an object by name within an object type and workspace. pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { - store_dispatch!(self.get_by_name(object_type, name)) + store_dispatch!(self.get_by_name(object_type, workspace, name)) } /// Delete an object by id. @@ -297,22 +309,68 @@ impl Store { store_dispatch!(self.delete(object_type, id)) } - /// Delete an object by name within an object type. - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { - store_dispatch!(self.delete_by_name(object_type, name)) + /// Count objects of a given type within a workspace. + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + store_dispatch!(self.count_in_workspace(object_type, workspace)) + } + + /// Delete all objects of a given type within a workspace. + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_all_in_workspace(object_type, workspace)) + } + + /// Delete all objects of a given type with a matching scope. + pub async fn delete_by_scope( + &self, + object_type: &str, + scope: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_by_scope(object_type, scope)) + } + + /// Delete an object by name within an object type and workspace. + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + store_dispatch!(self.delete_by_name(object_type, workspace, name)) } - /// List objects by type. + /// List objects by type and workspace. pub async fn list( &self, object_type: &str, + workspace: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - store_dispatch!(self.list(object_type, limit, offset)) + store_dispatch!(self.list(object_type, workspace, limit, offset)) + } + + /// List objects by type across all workspaces. + pub async fn list_by_type( + &self, + object_type: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + store_dispatch!(self.list_by_type(object_type, limit, offset)) } /// List objects by type and application-owned scope. + /// + /// Workspace filtering is intentionally omitted: scope values are sandbox + /// UUIDs which are globally unique. Revisit if non-UUID scopes are introduced. pub async fn list_by_scope( &self, object_type: &str, @@ -323,16 +381,39 @@ impl Store { store_dispatch!(self.list_by_scope(object_type, scope, limit, offset)) } - /// List objects by type with label selector filtering. + /// List objects by type and workspace with label selector filtering. /// Label selector format: "key1=value1,key2=value2" (comma-separated equality matches). pub async fn list_with_selector( + &self, + object_type: &str, + workspace: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + store_dispatch!(self.list_with_selector( + object_type, + workspace, + label_selector, + limit, + offset + )) + } + + /// List objects by type across all workspaces with label selector filtering. + pub async fn list_all_with_selector( &self, object_type: &str, label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - store_dispatch!(self.list_with_selector(object_type, label_selector, limit, offset)) + store_dispatch!(self.list_all_with_selector( + object_type, + label_selector, + limit, + offset + )) } // ----------------------------------------------------------------------- @@ -341,12 +422,18 @@ impl Store { /// Insert or update a protobuf message under an application-owned scope. pub async fn put_scoped_message< - T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels, + T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels + ObjectWorkspace, >( &self, message: &T, scope: &str, ) -> PersistenceResult<()> { + if T::requires_workspace() && message.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -360,6 +447,7 @@ impl Store { T::object_type(), message.object_id(), message.object_name(), + message.object_workspace(), scope, &message.encode_to_vec(), labels_json.as_deref(), @@ -378,25 +466,59 @@ impl Store { .transpose() } - /// Fetch and decode a protobuf message by name. + /// Fetch and decode a protobuf message by workspace and name. pub async fn get_message_by_name( &self, + workspace: &str, name: &str, ) -> PersistenceResult> { - self.get_by_name(T::object_type(), name) + self.get_by_name(T::object_type(), workspace, name) .await? .map(decode_record) .transpose() } - /// List and decode protobuf messages, hydrating `resource_version` from - /// the authoritative DB row (mirrors `get_message`). + /// List and decode protobuf messages by workspace, hydrating + /// `resource_version` from the authoritative DB row (mirrors `get_message`). pub async fn list_messages( &self, + workspace: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + self.list(T::object_type(), workspace, limit, offset) + .await? + .into_iter() + .map(decode_record) + .collect() + } + + /// List and decode protobuf messages across all workspaces, hydrating + /// `resource_version` from the authoritative DB row. + pub async fn list_all_messages( + &self, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + self.list_by_type(T::object_type(), limit, offset) + .await? + .into_iter() + .map(decode_record) + .collect() + } + + /// List and decode protobuf messages across all workspaces with label + /// selector filtering, hydrating `resource_version` from the authoritative + /// DB row. + pub async fn list_all_messages_with_selector< + T: Message + Default + ObjectType + SetResourceVersion, + >( + &self, + label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - self.list(T::object_type(), limit, offset) + self.list_all_with_selector(T::object_type(), label_selector, limit, offset) .await? .into_iter() .map(decode_record) @@ -409,11 +531,12 @@ impl Store { T: Message + Default + ObjectType + SetResourceVersion, >( &self, + workspace: &str, label_selector: &str, limit: u32, offset: u32, ) -> PersistenceResult> { - self.list_with_selector(T::object_type(), label_selector, limit, offset) + self.list_with_selector(T::object_type(), workspace, label_selector, limit, offset) .await? .into_iter() .map(decode_record) @@ -450,6 +573,7 @@ impl Store { + ObjectId + ObjectName + ObjectLabels + + ObjectWorkspace + SetResourceVersion + GetResourceVersion + Clone, @@ -491,12 +615,20 @@ impl Store { })?) }; + if T::requires_workspace() && updated.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } + // Single-attempt CAS write - fails with Conflict on version mismatch let result = self .put_if( T::object_type(), updated.object_id(), updated.object_name(), + updated.object_workspace(), &updated.encode_to_vec(), labels_json.as_deref(), WriteCondition::MatchResourceVersion(cas_version), @@ -531,7 +663,7 @@ fn infer_sqlite_unique_constraint(message: &str) -> Option { Some("objects_version_uq".to_string()) } else if message.contains("objects.object_type, objects.scope, objects.dedup_key") { Some("objects_dedup_uq".to_string()) - } else if message.contains("objects.object_type, objects.name") { + } else if message.contains("objects.object_type, objects.workspace, objects.name") { Some("objects_name_uq".to_string()) } else if message.contains("objects.id") { Some("objects_pkey".to_string()) @@ -596,16 +728,25 @@ impl Store { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { - store_dispatch!(self.put(object_type, id, name, payload, labels)) + store_dispatch!(self.put(object_type, id, name, workspace, payload, labels)) } - pub async fn put_message( + pub async fn put_message< + T: Message + ObjectType + ObjectId + ObjectName + ObjectLabels + ObjectWorkspace, + >( &self, message: &T, ) -> PersistenceResult<()> { + if T::requires_workspace() && message.object_workspace().is_empty() { + return Err(PersistenceError::Encode(format!( + "{} requires a non-empty workspace", + T::object_type(), + ))); + } let labels_map = message.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None @@ -618,6 +759,7 @@ impl Store { T::object_type(), message.object_id(), message.object_name(), + message.object_workspace(), &message.encode_to_vec(), labels_json.as_deref(), ) diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 529bc38bee..ddb4c526b8 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -59,6 +59,7 @@ impl PostgresStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -70,9 +71,9 @@ impl PostgresStore { sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb)) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb)) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, labels = EXCLUDED.labels @@ -81,6 +82,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -90,11 +92,13 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, @@ -110,14 +114,15 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET // Insert only - fail if object exists let row = sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb), 1) +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) RETURNING resource_version, created_at_ms, updated_at_ms ", ) .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -177,9 +182,9 @@ RETURNING resource_version, created_at_ms, updated_at_ms // Unconditional upsert by name let row = sqlx::query( r" -INSERT INTO objects (object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $5, COALESCE($6, '{}'::jsonb), 1) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, labels = EXCLUDED.labels, @@ -190,6 +195,7 @@ RETURNING resource_version, created_at_ms, updated_at_ms .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels_jsonb) @@ -241,11 +247,13 @@ WHERE object_type = $1 AND id = $2 AND resource_version = $3 } } + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, @@ -258,9 +266,9 @@ WHERE object_type = $1 AND id = $2 AND resource_version = $3 sqlx::query( r" -INSERT INTO objects (object_type, id, name, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) -VALUES ($1, $2, $3, $4, $5, $6, $6, COALESCE($7, '{}'::jsonb), 1) -ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET +INSERT INTO objects (object_type, id, name, workspace, scope, payload, created_at_ms, updated_at_ms, labels, resource_version) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, COALESCE($8, '{}'::jsonb), 1) +ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET scope = EXCLUDED.scope, payload = EXCLUDED.payload, updated_at_ms = EXCLUDED.updated_at_ms, @@ -271,6 +279,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(scope) .bind(payload) .bind(now_ms) @@ -288,7 +297,7 @@ ON CONFLICT (object_type, name) WHERE name IS NOT NULL DO UPDATE SET ) -> PersistenceResult> { let row = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND id = $2 ", @@ -305,16 +314,18 @@ WHERE object_type = $1 AND id = $2 pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { let row = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects -WHERE object_type = $1 AND name = $2 +WHERE object_type = $1 AND workspace = $2 AND name = $3 ", ) .bind(object_type) + .bind(workspace) .bind(name) .fetch_optional(&self.pool) .await @@ -333,17 +344,100 @@ WHERE object_type = $1 AND name = $2 Ok(result.rows_affected() > 0) } - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { - let result = sqlx::query("DELETE FROM objects WHERE object_type = $1 AND name = $2") - .bind(object_type) - .bind(name) - .execute(&self.pool) - .await - .map_err(|e| map_db_error(&e))?; + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(u64::try_from(row.0).unwrap_or(0)) + } + + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND workspace = $2", + ) + .bind(object_type) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + + pub async fn delete_by_scope( + &self, + object_type: &str, + scope: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND scope = $2", + ) + .bind(object_type) + .bind(scope) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + let result = sqlx::query( + "DELETE FROM objects WHERE object_type = $1 AND workspace = $2 AND name = $3", + ) + .bind(object_type) + .bind(workspace) + .bind(name) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; Ok(result.rows_affected() > 0) } pub async fn list( + &self, + object_type: &str, + workspace: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + let rows = sqlx::query( + r" +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version +FROM objects +WHERE object_type = $1 AND workspace = $2 +ORDER BY created_at_ms ASC, name ASC +LIMIT $3 OFFSET $4 +", + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_by_type( &self, object_type: &str, limit: u32, @@ -351,7 +445,7 @@ WHERE object_type = $1 AND name = $2 ) -> PersistenceResult> { let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 ORDER BY created_at_ms ASC, name ASC @@ -377,7 +471,7 @@ LIMIT $2 OFFSET $3 ) -> PersistenceResult> { let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels FROM objects WHERE object_type = $1 AND scope = $2 ORDER BY created_at_ms ASC, name ASC @@ -396,6 +490,41 @@ LIMIT $3 OFFSET $4 } pub async fn list_with_selector( + &self, + object_type: &str, + workspace: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + use super::parse_label_selector; + + let required_labels = parse_label_selector(label_selector)?; + let labels_jsonb = serde_json::to_value(&required_labels) + .map_err(|e| PersistenceError::Encode(format!("failed to serialize labels: {e}")))?; + + let rows = sqlx::query( + r" +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version +FROM objects +WHERE object_type = $1 AND workspace = $2 AND labels @> $3 +ORDER BY created_at_ms ASC, name ASC +LIMIT $4 OFFSET $5 +", + ) + .bind(object_type) + .bind(workspace) + .bind(&labels_jsonb) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_all_with_selector( &self, object_type: &str, label_selector: &str, @@ -410,7 +539,7 @@ LIMIT $3 OFFSET $4 let rows = sqlx::query( r" -SELECT object_type, id, name, payload, created_at_ms, updated_at_ms, labels, resource_version +SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND labels @> $2 ORDER BY created_at_ms ASC, name ASC @@ -432,6 +561,7 @@ LIMIT $3 OFFSET $4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -453,9 +583,9 @@ LIMIT $3 OFFSET $4 sqlx::query( r" INSERT INTO objects ( - object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms + object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms, workspace ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $7) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8) ", ) .bind(POLICY_OBJECT_TYPE) @@ -465,6 +595,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(workspace) .execute(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -630,6 +761,7 @@ WHERE object_type = $1 &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { let payload = draft_chunk_payload_from_record(chunk)?; // RETURNING id gives the row's effective id whether INSERT inserted @@ -638,9 +770,9 @@ WHERE object_type = $1 let row = sqlx::query( r" INSERT INTO objects ( - object_type, id, scope, status, dedup_key, hit_count, payload, created_at_ms, updated_at_ms + object_type, id, scope, status, dedup_key, hit_count, payload, created_at_ms, updated_at_ms, workspace ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (object_type, scope, dedup_key) WHERE dedup_key IS NOT NULL DO UPDATE SET hit_count = objects.hit_count + EXCLUDED.hit_count, updated_at_ms = EXCLUDED.updated_at_ms @@ -656,6 +788,7 @@ RETURNING id .bind(payload) .bind(chunk.first_seen_ms) .bind(chunk.last_seen_ms) + .bind(workspace) .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -847,6 +980,7 @@ fn row_to_object_record(row: sqlx::postgres::PgRow) -> ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), name: row.get("name"), + workspace: row.try_get("workspace").unwrap_or_default(), payload: row.get("payload"), created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 1958b32323..c485f31439 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -85,6 +85,7 @@ impl SqliteStore { object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, ) -> PersistenceResult<()> { @@ -92,9 +93,9 @@ impl SqliteStore { sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels" @@ -103,6 +104,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -112,11 +114,13 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET Ok(()) } + #[allow(clippy::too_many_arguments)] pub async fn put_if( &self, object_type: &str, id: &str, name: &str, + workspace: &str, payload: &[u8], labels: Option<&str>, condition: WriteCondition, @@ -128,13 +132,14 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET // Insert only - fail if object exists sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, 1) +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) "#, ) .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -195,9 +200,9 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 // Unconditional upsert by name sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, 1) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels", @@ -207,6 +212,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) @@ -215,9 +221,12 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .map_err(|e| map_db_error(&e))?; // Fetch the result to get the resource_version - let record = self.get_by_name(object_type, name).await?.ok_or_else(|| { - PersistenceError::Database("object disappeared after upsert".to_string()) - })?; + let record = self + .get_by_name(object_type, workspace, name) + .await? + .ok_or_else(|| { + PersistenceError::Database("object disappeared after upsert".to_string()) + })?; Ok(WriteResult { resource_version: record.resource_version, @@ -261,11 +270,13 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 } } + #[allow(clippy::too_many_arguments)] pub async fn put_scoped( &self, object_type: &str, id: &str, name: &str, + workspace: &str, scope: &str, payload: &[u8], labels: Option<&str>, @@ -274,9 +285,9 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 sqlx::query( r#" -INSERT INTO "objects" ("object_type", "id", "name", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) -ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET +INSERT INTO "objects" ("object_type", "id", "name", "workspace", "scope", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 1) +ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPDATE SET "scope" = excluded."scope", "payload" = excluded."payload", "updated_at_ms" = excluded."updated_at_ms", @@ -287,6 +298,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET .bind(object_type) .bind(id) .bind(name) + .bind(workspace) .bind(scope) .bind(payload) .bind(now_ms) @@ -304,7 +316,7 @@ ON CONFLICT ("object_type", "name") WHERE "name" IS NOT NULL DO UPDATE SET ) -> PersistenceResult> { let row = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" WHERE "object_type" = ?1 AND "id" = ?2 "#, @@ -321,16 +333,18 @@ WHERE "object_type" = ?1 AND "id" = ?2 pub async fn get_by_name( &self, object_type: &str, + workspace: &str, name: &str, ) -> PersistenceResult> { let row = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 "#, ) .bind(object_type) + .bind(workspace) .bind(name) .fetch_optional(&self.pool) .await @@ -354,14 +368,77 @@ WHERE "object_type" = ?1 AND "id" = ?2 Ok(result.rows_affected() > 0) } - pub async fn delete_by_name(&self, object_type: &str, name: &str) -> PersistenceResult { + pub async fn count_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let row: (i64,) = sqlx::query_as( + r#" +SELECT COUNT(*) FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .fetch_one(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(u64::try_from(row.0).unwrap_or(0)) + } + + pub async fn delete_all_in_workspace( + &self, + object_type: &str, + workspace: &str, + ) -> PersistenceResult { + let result = sqlx::query( + r#" +DELETE FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +"#, + ) + .bind(object_type) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + + pub async fn delete_by_scope( + &self, + object_type: &str, + scope: &str, + ) -> PersistenceResult { let result = sqlx::query( r#" DELETE FROM "objects" -WHERE "object_type" = ?1 AND "name" = ?2 +WHERE "object_type" = ?1 AND "scope" = ?2 "#, ) .bind(object_type) + .bind(scope) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(result.rows_affected()) + } + + pub async fn delete_by_name( + &self, + object_type: &str, + workspace: &str, + name: &str, + ) -> PersistenceResult { + let result = sqlx::query( + r#" +DELETE FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 AND "name" = ?3 +"#, + ) + .bind(object_type) + .bind(workspace) .bind(name) .execute(&self.pool) .await @@ -370,6 +447,33 @@ WHERE "object_type" = ?1 AND "name" = ?2 } pub async fn list( + &self, + object_type: &str, + workspace: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + let rows = sqlx::query( + r#" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +FROM "objects" +WHERE "object_type" = ?1 AND "workspace" = ?2 +ORDER BY "created_at_ms" ASC, "name" ASC +LIMIT ?3 OFFSET ?4 +"#, + ) + .bind(object_type) + .bind(workspace) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + + Ok(rows.into_iter().map(row_to_object_record).collect()) + } + + pub async fn list_by_type( &self, object_type: &str, limit: u32, @@ -377,7 +481,7 @@ WHERE "object_type" = ?1 AND "name" = ?2 ) -> PersistenceResult> { let rows = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version" FROM "objects" WHERE "object_type" = ?1 ORDER BY "created_at_ms" ASC, "name" ASC @@ -403,7 +507,7 @@ LIMIT ?2 OFFSET ?3 ) -> PersistenceResult> { let rows = sqlx::query( r#" -SELECT "object_type", "id", "name", "payload", "created_at_ms", "updated_at_ms", "labels" +SELECT "object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels" FROM "objects" WHERE "object_type" = ?1 AND "scope" = ?2 ORDER BY "created_at_ms" ASC, "name" ASC @@ -421,6 +525,37 @@ LIMIT ?3 OFFSET ?4 Ok(rows.into_iter().map(row_to_object_record).collect()) } pub async fn list_with_selector( + &self, + object_type: &str, + workspace: &str, + label_selector: &str, + limit: u32, + offset: u32, + ) -> PersistenceResult> { + use super::parse_label_selector; + + let required_labels = parse_label_selector(label_selector)?; + let all_records = self.list(object_type, workspace, u32::MAX, 0).await?; + + let filtered: Vec = all_records + .into_iter() + .filter(|record| { + let labels_json = record.labels.as_deref().unwrap_or("{}"); + let labels: std::collections::HashMap = + serde_json::from_str(labels_json).unwrap_or_default(); + + required_labels + .iter() + .all(|(key, value)| labels.get(key).is_some_and(|v| v == value)) + }) + .skip(offset as usize) + .take(limit as usize) + .collect(); + + Ok(filtered) + } + + pub async fn list_all_with_selector( &self, object_type: &str, label_selector: &str, @@ -430,7 +565,7 @@ LIMIT ?3 OFFSET ?4 use super::parse_label_selector; let required_labels = parse_label_selector(label_selector)?; - let all_records = self.list(object_type, u32::MAX, 0).await?; + let all_records = self.list_by_type(object_type, u32::MAX, 0).await?; let filtered: Vec = all_records .into_iter() @@ -453,6 +588,7 @@ LIMIT ?3 OFFSET ?4 &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -474,9 +610,9 @@ LIMIT ?3 OFFSET ?4 sqlx::query( r#" INSERT INTO "objects" ( - "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms" + "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms", "workspace" ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8) "#, ) .bind(POLICY_OBJECT_TYPE) @@ -486,6 +622,7 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) .bind("pending") .bind(wrapped_payload) .bind(now_ms) + .bind(workspace) .execute(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -651,6 +788,7 @@ WHERE "object_type" = ?1 &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { let payload = draft_chunk_payload_from_record(chunk)?; // RETURNING "id" gives us the row's effective id regardless of @@ -660,9 +798,9 @@ WHERE "object_type" = ?1 let row = sqlx::query( r#" INSERT INTO "objects" ( - "object_type", "id", "scope", "status", "dedup_key", "hit_count", "payload", "created_at_ms", "updated_at_ms" + "object_type", "id", "scope", "status", "dedup_key", "hit_count", "payload", "created_at_ms", "updated_at_ms", "workspace" ) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT ("object_type", "scope", "dedup_key") WHERE "dedup_key" IS NOT NULL DO UPDATE SET "hit_count" = "objects"."hit_count" + excluded."hit_count", "updated_at_ms" = excluded."updated_at_ms" @@ -678,6 +816,7 @@ RETURNING "id" .bind(payload) .bind(chunk.first_seen_ms) .bind(chunk.last_seen_ms) + .bind(workspace) .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; @@ -900,6 +1039,7 @@ fn row_to_object_record(row: sqlx::sqlite::SqliteRow) -> ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), name: row.get("name"), + workspace: row.try_get("workspace").unwrap_or_default(), payload: row.get("payload"), created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index d092b68de0..de8e138d98 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -11,7 +11,7 @@ async fn sqlite_put_get_round_trip() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); @@ -26,7 +26,7 @@ async fn sqlite_put_get_round_trip() { async fn sqlite_connect_runs_embedded_migrations() { let store = test_store().await; - let records = store.list("sandbox", 10, 0).await.unwrap(); + let records = store.list("sandbox", "default", 10, 0).await.unwrap(); assert!(records.is_empty()); } @@ -212,14 +212,14 @@ async fn sqlite_updates_timestamp() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); let first = store.get("sandbox", "abc").await.unwrap().unwrap(); store - .put("sandbox", "abc", "my-sandbox", b"payload2", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload2", None) .await .unwrap(); @@ -237,12 +237,12 @@ async fn sqlite_list_paging() { let name = format!("name-{idx}"); let payload = format!("payload-{idx}"); store - .put("sandbox", &id, &name, payload.as_bytes(), None) + .put("sandbox", &id, &name, "default", payload.as_bytes(), None) .await .unwrap(); } - let records = store.list("sandbox", 2, 1).await.unwrap(); + let records = store.list("sandbox", "default", 2, 1).await.unwrap(); assert_eq!(records.len(), 2); assert_eq!(records[0].name, "name-1"); assert_eq!(records[1].name, "name-2"); @@ -253,7 +253,7 @@ async fn sqlite_delete_behavior() { let store = test_store().await; store - .put("sandbox", "abc", "my-sandbox", b"payload", None) + .put("sandbox", "abc", "my-sandbox", "default", b"payload", None) .await .unwrap(); @@ -292,12 +292,12 @@ async fn sqlite_get_by_name() { let store = test_store().await; store - .put("sandbox", "id-1", "my-sandbox", b"payload", None) + .put("sandbox", "id-1", "my-sandbox", "default", b"payload", None) .await .unwrap(); let record = store - .get_by_name("sandbox", "my-sandbox") + .get_by_name("sandbox", "default", "my-sandbox") .await .unwrap() .unwrap(); @@ -305,7 +305,10 @@ async fn sqlite_get_by_name() { assert_eq!(record.name, "my-sandbox"); assert_eq!(record.payload, b"payload"); - let missing = store.get_by_name("sandbox", "no-such-name").await.unwrap(); + let missing = store + .get_by_name("sandbox", "default", "no-such-name") + .await + .unwrap(); assert!(missing.is_none()); } @@ -322,7 +325,7 @@ async fn sqlite_get_message_by_name() { store.put_message(&object).await.unwrap(); let loaded = store - .get_message_by_name::("my-test") + .get_message_by_name::("", "my-test") .await .unwrap() .unwrap(); @@ -331,7 +334,7 @@ async fn sqlite_get_message_by_name() { assert_eq!(loaded.count, 7); let missing = store - .get_message_by_name::("no-such-name") + .get_message_by_name::("", "no-such-name") .await .unwrap(); assert!(missing.is_none()); @@ -342,14 +345,20 @@ async fn sqlite_delete_by_name() { let store = test_store().await; store - .put("sandbox", "id-1", "my-sandbox", b"payload", None) + .put("sandbox", "id-1", "my-sandbox", "default", b"payload", None) .await .unwrap(); - let deleted = store.delete_by_name("sandbox", "my-sandbox").await.unwrap(); + let deleted = store + .delete_by_name("sandbox", "default", "my-sandbox") + .await + .unwrap(); assert!(deleted); - let deleted_again = store.delete_by_name("sandbox", "my-sandbox").await.unwrap(); + let deleted_again = store + .delete_by_name("sandbox", "default", "my-sandbox") + .await + .unwrap(); assert!(!deleted_again); let gone = store.get("sandbox", "id-1").await.unwrap(); @@ -361,18 +370,32 @@ async fn sqlite_name_unique_per_object_type() { let store = test_store().await; store - .put("sandbox", "id-1", "shared-name", b"payload1", None) + .put( + "sandbox", + "id-1", + "shared-name", + "default", + b"payload1", + None, + ) .await .unwrap(); // Same name, same object_type, different id -> upsert on name. store - .put("sandbox", "id-2", "shared-name", b"payload2", None) + .put( + "sandbox", + "id-2", + "shared-name", + "default", + b"payload2", + None, + ) .await .unwrap(); let record = store - .get_by_name("sandbox", "shared-name") + .get_by_name("sandbox", "default", "shared-name") .await .unwrap() .unwrap(); @@ -381,7 +404,14 @@ async fn sqlite_name_unique_per_object_type() { // Same name, different object_type -> should succeed. store - .put("secret", "id-3", "shared-name", b"payload3", None) + .put( + "secret", + "id-3", + "shared-name", + "default", + b"payload3", + None, + ) .await .unwrap(); } @@ -391,14 +421,14 @@ async fn sqlite_id_globally_unique() { let store = test_store().await; store - .put("sandbox", "same-id", "name-a", b"payload1", None) + .put("sandbox", "same-id", "name-a", "default", b"payload1", None) .await .unwrap(); // Same id, different object_type -> should fail because ids remain global // primary keys even when writes upsert on name. let result = store - .put("secret", "same-id", "name-b", b"payload2", None) + .put("secret", "same-id", "name-b", "default", b"payload2", None) .await; assert!(result.is_err()); @@ -444,6 +474,7 @@ async fn labels_round_trip() { "sandbox", "id-1", "labeled-sandbox", + "default", b"payload", Some(labels), ) @@ -459,11 +490,25 @@ async fn label_selector_single_match() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-1", + "s1", + "default", + b"p1", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); store - .put("sandbox", "id-2", "s2", b"p2", Some(r#"{"env":"dev"}"#)) + .put( + "sandbox", + "id-2", + "s2", + "default", + b"p2", + Some(r#"{"env":"dev"}"#), + ) .await .unwrap(); store @@ -471,6 +516,7 @@ async fn label_selector_single_match() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -478,7 +524,7 @@ async fn label_selector_single_match() { .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod", 10, 0) + .list_with_selector("sandbox", "default", "env=prod", 10, 0) .await .unwrap(); @@ -497,6 +543,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-1", "s1", + "default", b"p1", Some(r#"{"env":"prod","team":"platform"}"#), ) @@ -507,6 +554,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-2", "s2", + "default", b"p2", Some(r#"{"env":"prod","team":"data"}"#), ) @@ -517,6 +565,7 @@ async fn label_selector_multiple_labels() { "sandbox", "id-3", "s3", + "default", b"p3", Some(r#"{"env":"dev","team":"platform"}"#), ) @@ -524,7 +573,7 @@ async fn label_selector_multiple_labels() { .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod,team=platform", 10, 0) + .list_with_selector("sandbox", "default", "env=prod,team=platform", 10, 0) .await .unwrap(); @@ -537,12 +586,19 @@ async fn label_selector_no_match() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-1", + "s1", + "default", + b"p1", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); let results = store - .list_with_selector("sandbox", "env=staging", 10, 0) + .list_with_selector("sandbox", "default", "env=staging", 10, 0) .await .unwrap(); @@ -557,25 +613,32 @@ async fn label_selector_respects_paging() { let id = format!("id-{idx}"); let name = format!("name-{idx}"); store - .put("sandbox", &id, &name, b"payload", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + &id, + &name, + "default", + b"payload", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); } let page1 = store - .list_with_selector("sandbox", "env=prod", 2, 0) + .list_with_selector("sandbox", "default", "env=prod", 2, 0) .await .unwrap(); assert_eq!(page1.len(), 2); let page2 = store - .list_with_selector("sandbox", "env=prod", 2, 2) + .list_with_selector("sandbox", "default", "env=prod", 2, 2) .await .unwrap(); assert_eq!(page2.len(), 2); let page3 = store - .list_with_selector("sandbox", "env=prod", 2, 4) + .list_with_selector("sandbox", "default", "env=prod", 2, 4) .await .unwrap(); assert_eq!(page3.len(), 1); @@ -586,16 +649,23 @@ async fn empty_labels_not_matched_by_selector() { let store = test_store().await; store - .put("sandbox", "id-1", "s1", b"p1", None) + .put("sandbox", "id-1", "s1", "default", b"p1", None) .await .unwrap(); store - .put("sandbox", "id-2", "s2", b"p2", Some(r#"{"env":"prod"}"#)) + .put( + "sandbox", + "id-2", + "s2", + "default", + b"p2", + Some(r#"{"env":"prod"}"#), + ) .await .unwrap(); let results = store - .list_with_selector("sandbox", "env=prod", 10, 0) + .list_with_selector("sandbox", "default", "env=prod", 10, 0) .await .unwrap(); @@ -613,7 +683,7 @@ async fn policy_put_and_get_latest() { let policy_v1 = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_v1, "hash1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_v1, "hash1") .await .unwrap(); @@ -630,7 +700,7 @@ async fn policy_put_and_get_latest() { } .encode_to_vec(); store - .put_policy_revision("p2", "sandbox-1", 2, &policy_v2, "hash2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &policy_v2, "hash2") .await .unwrap(); @@ -650,11 +720,11 @@ async fn policy_get_by_version() { } .encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_v1, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_v1, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &policy_v2, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &policy_v2, "h2") .await .unwrap(); @@ -684,7 +754,7 @@ async fn policy_update_status_and_get_loaded() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); @@ -715,7 +785,7 @@ async fn policy_status_failed_with_error() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); @@ -739,15 +809,15 @@ async fn policy_supersede_older() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &payload, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &payload, "h2") .await .unwrap(); store - .put_policy_revision("p3", "sandbox-1", 3, &payload, "h3") + .put_policy_revision("p3", "sandbox-1", "default", 3, &payload, "h3") .await .unwrap(); @@ -792,15 +862,15 @@ async fn policy_list_ordered_by_version_desc() { let payload = SandboxPolicy::default().encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &payload, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &payload, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-1", 2, &payload, "h2") + .put_policy_revision("p2", "sandbox-1", "default", 2, &payload, "h2") .await .unwrap(); store - .put_policy_revision("p3", "sandbox-1", 3, &payload, "h3") + .put_policy_revision("p3", "sandbox-1", "default", 3, &payload, "h3") .await .unwrap(); @@ -828,11 +898,11 @@ async fn policy_isolation_between_sandboxes() { } .encode_to_vec(); store - .put_policy_revision("p1", "sandbox-1", 1, &policy_s1, "h1") + .put_policy_revision("p1", "sandbox-1", "default", 1, &policy_s1, "h1") .await .unwrap(); store - .put_policy_revision("p2", "sandbox-2", 1, &policy_s2, "h2") + .put_policy_revision("p2", "sandbox-2", "default", 1, &policy_s2, "h2") .await .unwrap(); @@ -930,6 +1000,7 @@ async fn cas_put_if_must_create_succeeds() { "sandbox", "id-1", "new-sandbox", + "default", b"payload", None, WriteCondition::MustCreate, @@ -956,6 +1027,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-1", + "default", b"payload1", None, WriteCondition::MustCreate, @@ -969,6 +1041,7 @@ async fn cas_put_if_must_create_fails_on_duplicate() { "sandbox", "id-1", "sandbox-2", + "default", b"payload2", None, WriteCondition::MustCreate, @@ -993,6 +1066,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1006,6 +1080,7 @@ async fn cas_put_if_match_version_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1032,6 +1107,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1045,6 +1121,7 @@ async fn cas_put_if_match_version_fails_on_mismatch() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(99), @@ -1075,6 +1152,7 @@ async fn cas_delete_if_succeeds_with_correct_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1100,6 +1178,7 @@ async fn cas_delete_if_fails_with_wrong_version() { "sandbox", "id-1", "sandbox-1", + "default", b"payload", None, WriteCondition::MustCreate, @@ -1132,6 +1211,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v1", None, WriteCondition::MustCreate, @@ -1146,6 +1226,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v2", None, WriteCondition::MatchResourceVersion(1), @@ -1160,6 +1241,7 @@ async fn cas_resource_version_increments() { "sandbox", "id-1", "sandbox-1", + "default", b"v3", None, WriteCondition::MatchResourceVersion(2), @@ -1185,6 +1267,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", b"initial", None, WriteCondition::MustCreate, @@ -1202,6 +1285,7 @@ async fn cas_concurrent_updates_one_succeeds() { "sandbox", "id-1", "sandbox-1", + "default", format!("update-{i}").as_bytes(), None, WriteCondition::MatchResourceVersion(1), @@ -1243,6 +1327,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: None, status: None, @@ -1282,6 +1367,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: None, status: None, diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 9a6333543d..183fc913f7 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -13,6 +13,7 @@ pub trait PolicyStoreExt { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -72,6 +73,7 @@ pub trait PolicyStoreExt { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult; async fn get_draft_chunk(&self, id: &str) -> PersistenceResult>; @@ -110,6 +112,7 @@ impl PolicyStoreExt for Store { &self, id: &str, sandbox_id: &str, + workspace: &str, version: i64, payload: &[u8], hash: &str, @@ -117,12 +120,12 @@ impl PolicyStoreExt for Store { match self { Self::Postgres(store) => { store - .put_policy_revision(id, sandbox_id, version, payload, hash) + .put_policy_revision(id, sandbox_id, workspace, version, payload, hash) .await } Self::Sqlite(store) => { store - .put_policy_revision(id, sandbox_id, version, payload, hash) + .put_policy_revision(id, sandbox_id, workspace, version, payload, hash) .await } } @@ -213,10 +216,11 @@ impl PolicyStoreExt for Store { &self, chunk: &DraftChunkRecord, dedup_key: Option<&str>, + workspace: &str, ) -> PersistenceResult { match self { - Self::Postgres(store) => store.put_draft_chunk(chunk, dedup_key).await, - Self::Sqlite(store) => store.put_draft_chunk(chunk, dedup_key).await, + Self::Postgres(store) => store.put_draft_chunk(chunk, dedup_key, workspace).await, + Self::Sqlite(store) => store.put_draft_chunk(chunk, dedup_key, workspace).await, } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c4..f543795db8 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -6,6 +6,7 @@ #![allow(clippy::result_large_err)] use crate::persistence::{ObjectType, Store, current_time_ms}; +use openshell_core::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, StoredProviderCredentialRefreshState, @@ -79,7 +80,7 @@ pub async fn list_all_refresh_states( let mut offset = 0; loop { let records = store - .list( + .list_by_type( StoredProviderCredentialRefreshState::object_type(), REFRESH_WORKER_PAGE_SIZE, offset, @@ -108,24 +109,30 @@ pub async fn list_all_refresh_states( pub async fn get_refresh_state( store: &Store, + workspace: &str, provider_id: &str, credential_key: &str, ) -> Result, Status> { let name = refresh_state_name(provider_id, credential_key); store - .get_message_by_name::(&name) + .get_message_by_name::(workspace, &name) .await .map_err(|e| Status::internal(format!("fetch provider refresh state failed: {e}"))) } pub async fn delete_refresh_state( store: &Store, + workspace: &str, provider_id: &str, credential_key: &str, ) -> Result { let name = refresh_state_name(provider_id, credential_key); store - .delete_by_name(StoredProviderCredentialRefreshState::object_type(), &name) + .delete_by_name( + StoredProviderCredentialRefreshState::object_type(), + workspace, + &name, + ) .await .map_err(|e| Status::internal(format!("delete provider refresh state failed: {e}"))) } @@ -136,10 +143,11 @@ pub async fn delete_refresh_states_for_provider( ) -> Result { let states = list_refresh_states_for_provider(store, provider_id).await?; let mut deleted = 0; - for state in states { + for state in &states { if store .delete_by_name( StoredProviderCredentialRefreshState::object_type(), + state.object_workspace(), state.object_name(), ) .await @@ -181,6 +189,7 @@ pub struct NewRefreshStateConfig { #[allow(clippy::unnecessary_wraps)] pub fn new_refresh_state( provider: &Provider, + workspace: &str, credential_key: &str, config: NewRefreshStateConfig, ) -> Result { @@ -200,6 +209,7 @@ pub fn new_refresh_state( created_at_ms: now_ms, labels: HashMap::new(), resource_version: 0, + workspace: workspace.to_string(), }), provider_id, provider_name, @@ -294,15 +304,17 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) pub async fn refresh_provider_credential( store: &Store, + workspace: &str, provider_name: &str, credential_key: &str, ) -> Result { let provider = store - .get_message_by_name::(provider_name) + .get_message_by_name::(workspace, provider_name) .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - let Some(mut state) = get_refresh_state(store, provider.object_id(), credential_key).await? + let Some(mut state) = + get_refresh_state(store, workspace, provider.object_id(), credential_key).await? else { return Err(Status::not_found("provider refresh state not found")); }; @@ -321,7 +333,7 @@ pub async fn refresh_provider_credential( Ok(minted) => { let now_ms = current_time_ms(); if let Err(err) = - apply_minted_credential(store, &provider, credential_key, &minted).await + apply_minted_credential(store, workspace, &provider, credential_key, &minted).await { state.status = "error".to_string(); state.last_error = err.message().to_string(); @@ -399,6 +411,7 @@ pub async fn refresh_provider_credential( async fn apply_minted_credential( store: &Store, + workspace: &str, provider: &Provider, credential_key: &str, minted: &MintedCredential, @@ -414,8 +427,10 @@ async fn apply_minted_credential( } else { updated.credential_expires_at_ms.remove(credential_key); } - crate::grpc::provider::validate_provider_update_against_attached_sandboxes(store, &updated) - .await?; + crate::grpc::provider::validate_provider_update_against_attached_sandboxes( + store, workspace, &updated, + ) + .await?; store .update_message_cas::(provider.object_id(), 0, |current| { current @@ -752,8 +767,13 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { status = %state.status, "refreshing provider credential" ); - if let Err(err) = - refresh_provider_credential(store, &state.provider_name, &state.credential_key).await + if let Err(err) = refresh_provider_credential( + store, + state.object_workspace(), + &state.provider_name, + &state.credential_key, + ) + .await { warn!( provider = %state.provider_name, @@ -852,6 +872,7 @@ mod tests { let before_refresh_ms = crate::persistence::current_time_ms(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -870,9 +891,10 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = refresh_provider_credential(&store, "my-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = + refresh_provider_credential(&store, "default", "my-graph", "MS_GRAPH_ACCESS_TOKEN") + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); assert!(refreshed.next_refresh_at_ms > 0); @@ -880,7 +902,7 @@ mod tests { assert!(refreshed.last_error.is_empty()); let stored = store - .get_message_by_name::("my-graph") + .get_message_by_name::("default", "my-graph") .await .unwrap() .unwrap(); @@ -924,6 +946,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -935,6 +958,7 @@ mod tests { .unwrap(); let state = new_refresh_state( &provider_b, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, @@ -953,21 +977,30 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let err = refresh_provider_credential(&store, "refreshing-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap_err(); + let err = refresh_provider_credential( + &store, + "default", + "refreshing-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN")); - let stored_state = - get_refresh_state(&store, provider_b.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider_b.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_eq!(stored_state.status, "error"); assert!(stored_state.last_error.contains("MS_GRAPH_ACCESS_TOKEN")); let stored_provider = store - .get_message_by_name::("refreshing-graph") + .get_message_by_name::("default", "refreshing-graph") .await .unwrap() .unwrap(); @@ -1003,6 +1036,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, @@ -1021,15 +1055,19 @@ mod tests { .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - let refreshed = - refresh_provider_credential(&store, "my-delegated-graph", "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap(); + let refreshed = refresh_provider_credential( + &store, + "default", + "my-delegated-graph", + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored_provider = store - .get_message_by_name::("my-delegated-graph") + .get_message_by_name::("default", "my-delegated-graph") .await .unwrap() .unwrap(); @@ -1044,10 +1082,15 @@ mod tests { Some(&refreshed.expires_at_ms) ); - let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_eq!( stored_state.material.get("refresh_token"), Some(&"rotated-refresh-token".to_string()) @@ -1082,6 +1125,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "GOOGLE_DRIVE_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt, @@ -1104,14 +1148,14 @@ mod tests { put_refresh_state(&store, &state).await.unwrap(); let refreshed = - refresh_provider_credential(&store, "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") + refresh_provider_credential(&store, "default", "my-drive", "GOOGLE_DRIVE_ACCESS_TOKEN") .await .unwrap(); assert_eq!(refreshed.status, "refreshed"); assert!(refreshed.expires_at_ms > 0); let stored = store - .get_message_by_name::("my-drive") + .get_message_by_name::("default", "my-drive") .await .unwrap() .unwrap(); @@ -1128,6 +1172,7 @@ mod tests { store.put_message(&provider).await.unwrap(); let state = new_refresh_state( &provider, + "default", "MS_GRAPH_ACCESS_TOKEN", NewRefreshStateConfig { strategy: ProviderCredentialRefreshStrategy::External, @@ -1145,15 +1190,20 @@ mod tests { run_refresh_worker_tick(&store).await.unwrap(); - let stored_state = get_refresh_state(&store, provider.object_id(), "MS_GRAPH_ACCESS_TOKEN") - .await - .unwrap() - .unwrap(); + let stored_state = get_refresh_state( + &store, + "default", + provider.object_id(), + "MS_GRAPH_ACCESS_TOKEN", + ) + .await + .unwrap() + .unwrap(); assert_ne!(stored_state.status, "error"); assert!(stored_state.last_error.is_empty()); let stored_provider = store - .get_message_by_name::("my-external") + .get_message_by_name::("default", "my-external") .await .unwrap() .unwrap(); @@ -1172,11 +1222,13 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), } } diff --git a/crates/openshell-server/src/sandbox_index.rs b/crates/openshell-server/src/sandbox_index.rs index 9ad7c941b5..589f88fd88 100644 --- a/crates/openshell-server/src/sandbox_index.rs +++ b/crates/openshell-server/src/sandbox_index.rs @@ -15,7 +15,7 @@ pub struct SandboxIndex { #[derive(Debug, Default)] struct Inner { - sandbox_name_to_id: HashMap, + sandbox_name_to_id: HashMap<(String, String), String>, agent_pod_to_id: HashMap, } @@ -29,9 +29,10 @@ impl SandboxIndex { let mut inner = self.inner.write().expect("sandbox index lock poisoned"); if let Some(metadata) = &sandbox.metadata { if !metadata.name.is_empty() { - inner - .sandbox_name_to_id - .insert(metadata.name.clone(), metadata.id.clone()); + inner.sandbox_name_to_id.insert( + (metadata.workspace.clone(), metadata.name.clone()), + metadata.id.clone(), + ); } if let Some(status) = sandbox.status.as_ref() @@ -51,9 +52,12 @@ impl SandboxIndex { } #[must_use] - pub fn sandbox_id_for_sandbox_name(&self, name: &str) -> Option { + pub fn sandbox_id_for_sandbox_name(&self, workspace: &str, name: &str) -> Option { let inner = self.inner.read().expect("sandbox index lock poisoned"); - inner.sandbox_name_to_id.get(name).cloned() + inner + .sandbox_name_to_id + .get(&(workspace.to_string(), name.to_string())) + .cloned() } #[must_use] diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 7ebd6dba93..305e2777bc 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -48,10 +48,11 @@ pub fn endpoint_key(sandbox: &str, service: &str) -> String { pub fn endpoint_url( config: &openshell_core::Config, + workspace: &str, sandbox: &str, service: &str, ) -> Option { - let host = endpoint_host(&config.service_routing, sandbox, service)?; + let host = endpoint_host(&config.service_routing, workspace, sandbox, service)?; let scheme = endpoint_scheme(config); let port = config.bind_address.port(); let include_port = !matches!((scheme, port), ("https", 443) | ("http", 80)); @@ -73,34 +74,50 @@ fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { } } -fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { +fn endpoint_host( + config: &ServiceRoutingConfig, + workspace: &str, + sandbox: &str, + service: &str, +) -> Option { let base_domain = config.base_domains.first()?; Some(if service.is_empty() { - format!("{sandbox}.{base_domain}") + format!("{workspace}--{sandbox}.{base_domain}") } else { - format!("{sandbox}--{service}.{base_domain}") + format!("{workspace}--{sandbox}--{service}.{base_domain}") }) } -pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { +// The `--` delimiter is unambiguous because both workspace and sandbox name +// validation reject consecutive hyphens (see `validate_workspace_name` and +// `validate_sandbox_spec`). +pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String, String)> { let host = host.split_once(':').map_or(host, |(name, _)| name); for base_domain in &config.base_domains { let expected_suffix = format!(".{base_domain}"); let Some(encoded) = host.strip_suffix(&expected_suffix) else { continue; }; - let (sandbox, service) = if let Some((sandbox, service)) = encoded.split_once("--") { + let (workspace, rest) = encoded.split_once("--")?; + if workspace.is_empty() || workspace.contains("--") { + return None; + } + let (sandbox, service) = if let Some((sandbox, service)) = rest.split_once("--") { if service.is_empty() || service.contains("--") { return None; } (sandbox, service) } else { - (encoded, "") + (rest, "") }; if sandbox.is_empty() || sandbox.contains("--") { return None; } - return Some((sandbox.to_string(), service.to_string())); + return Some(( + workspace.to_string(), + sandbox.to_string(), + service.to_string(), + )); } None } @@ -116,11 +133,13 @@ pub async fn proxy_sandbox_service_request( let Some(host) = request_host(&req) else { return StatusCode::NOT_FOUND.into_response(); }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + let Some((workspace, sandbox_name, service_name)) = + parse_host(host, &state.config.service_routing) + else { return StatusCode::NOT_FOUND.into_response(); }; - match proxy_to_endpoint(state, req, sandbox_name, service_name).await { + match proxy_to_endpoint(state, req, &workspace, sandbox_name, service_name).await { Ok(response) => response.into_response(), Err(err) => err.into_response(), } @@ -209,10 +228,12 @@ pub fn service_error_response(status: StatusCode, message: &'static str) -> Axum async fn proxy_to_endpoint( state: Arc, mut req: Request, + workspace: &str, sandbox_name: String, service_name: String, ) -> Result, ServiceRouteError> { - let endpoint = match load_endpoint(&state.store, &sandbox_name, &service_name).await { + let endpoint = match load_endpoint(&state.store, workspace, &sandbox_name, &service_name).await + { Ok(endpoint) => endpoint, Err(err) => { emit_service_http_failure(&state, &req, &sandbox_name, &service_name, None, &err); @@ -409,12 +430,13 @@ async fn proxy_to_endpoint( async fn load_endpoint( store: &Store, + workspace: &str, sandbox_name: &str, service_name: &str, ) -> Result { let key = endpoint_key(sandbox_name, service_name); store - .get_message_by_name::(&key) + .get_message_by_name::(workspace, &key) .await .map_err(|err| { warn!(error = %err, endpoint = %key, "sandbox service routing: failed to load service endpoint"); @@ -572,7 +594,9 @@ pub fn emit_cross_origin_service_http_rejection(state: &ServerState, req: &Reque let Some(host) = request_host(req) else { return; }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + let Some((_workspace, sandbox_name, service_name)) = + parse_host(host, &state.config.service_routing) + else { return; }; let err = ServiceRouteError::new( @@ -804,6 +828,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, + workspace: "default".to_string(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), @@ -839,8 +864,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("http://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("http://default--my-sandbox--web.dev.openshell.localhost:8080/") ); } @@ -851,8 +876,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "").as_deref(), - Some("http://my-sandbox.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "").as_deref(), + Some("http://default--my-sandbox.dev.openshell.localhost:8080/") ); } @@ -863,8 +888,8 @@ mod tests { .with_server_sans(["*.dev.openshell.localhost"]); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") ); } @@ -876,24 +901,47 @@ mod tests { .with_loopback_service_http(false); assert_eq!( - endpoint_url(&cfg, "my-sandbox", "web").as_deref(), - Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + + #[test] + fn endpoint_url_includes_workspace_prefix_for_non_default() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_server_sans(["*.dev.openshell.localhost"]); + + assert_eq!( + endpoint_url(&cfg, "staging", "my-sandbox", "web").as_deref(), + Some("http://staging--my-sandbox--web.dev.openshell.localhost:8080/") ); } #[test] fn parses_sandbox_service_host() { assert_eq!( - parse_host("my-sandbox--web.dev.openshell.localhost", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host( + "default--my-sandbox--web.dev.openshell.localhost", + &config() + ), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } #[test] fn parses_sandbox_host_without_service_label() { assert_eq!( - parse_host("my-sandbox.dev.openshell.localhost", &config()), - Some(("my-sandbox".to_string(), String::new())) + parse_host("default--my-sandbox.dev.openshell.localhost", &config()), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + String::new() + )) ); } @@ -908,16 +956,27 @@ mod tests { #[test] fn parses_sandbox_service_host_with_port() { assert_eq!( - parse_host("my-sandbox--web.dev.openshell.localhost:8080", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host( + "default--my-sandbox--web.dev.openshell.localhost:8080", + &config() + ), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } #[test] fn parses_alternate_service_routing_domain() { assert_eq!( - parse_host("my-sandbox--web.svc.gateway.localhost", &config()), - Some(("my-sandbox".to_string(), "web".to_string())) + parse_host("default--my-sandbox--web.svc.gateway.localhost", &config()), + Some(( + "default".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) ); } @@ -929,6 +988,43 @@ mod tests { ); } + #[test] + fn parses_workspace_prefixed_host() { + assert_eq!( + parse_host( + "staging--my-sandbox--web.dev.openshell.localhost", + &config() + ), + Some(( + "staging".to_string(), + "my-sandbox".to_string(), + "web".to_string() + )) + ); + } + + #[test] + fn rejects_consecutive_hyphens_in_workspace() { + assert_eq!( + parse_host( + "team--ml--my-sandbox--web.dev.openshell.localhost", + &config() + ), + None + ); + } + + #[test] + fn rejects_consecutive_hyphens_in_service() { + assert_eq!( + parse_host( + "default--my-sandbox--web--app.dev.openshell.localhost", + &config() + ), + None + ); + } + #[test] fn identifies_sandbox_service_request_from_host_header() { let request = Request::builder() @@ -1100,4 +1196,35 @@ mod tests { assert_eq!(upstream.headers()["sec-websocket-key"], "abc"); assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080"); } + + #[tokio::test] + async fn load_endpoint_uses_workspace_for_lookup() { + let store = crate::persistence::test_store().await; + + let ep = ServiceEndpoint { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "ep-1".to_string(), + name: "my-sandbox--web".to_string(), + created_at_ms: 1_700_000_000_000, + labels: std::collections::HashMap::default(), + resource_version: 0, + workspace: "default".to_string(), + }), + sandbox_id: "sandbox-1".to_string(), + sandbox_name: "my-sandbox".to_string(), + service_name: "web".to_string(), + target_port: 8080, + domain: true, + }; + store.put_message(&ep).await.unwrap(); + + let found = load_endpoint(&store, "default", "my-sandbox", "web").await; + assert!(found.is_ok(), "should find endpoint in correct workspace"); + + let not_found = load_endpoint(&store, "staging", "my-sandbox", "web").await; + assert!( + not_found.is_err(), + "should not find endpoint in wrong workspace" + ); + } } diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 752fee1c08..8c168eccfe 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -37,7 +37,7 @@ async fn reap_expired_sessions(store: &Store) -> Result<(), String> { let now_ms = now_ms(); let records = store - .list(SshSession::object_type(), 1000, 0) + .list_by_type(SshSession::object_type(), 1000, 0) .await .map_err(|e| e.to_string())?; @@ -86,6 +86,7 @@ mod tests { created_at_ms: 1000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 4adf9e8b6f..03fd032d3a 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -841,6 +841,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + workspace: "default".to_string(), }), ..Default::default() } diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 3934c8af42..007917f2c0 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -473,6 +473,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")) + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index bd94d151e5..c55ebdd6c2 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -413,6 +413,51 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn create_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn list_workspaces( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn delete_workspace( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn add_workspace_member( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn remove_workspace_member( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn list_workspace_members( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 3cbc315026..c17e5d37be 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -88,6 +88,7 @@ pub struct PolicyLocalContext { gateway_endpoint: Option, sandbox_name: Option, shorthand_log_dir: PathBuf, + workspace_rx: tokio::sync::watch::Receiver, } impl PolicyLocalContext { @@ -95,12 +96,14 @@ impl PolicyLocalContext { current_policy: Option, gateway_endpoint: Option, sandbox_name: Option, + workspace_rx: tokio::sync::watch::Receiver, ) -> Self { Self::with_log_dir( current_policy, gateway_endpoint, sandbox_name, PathBuf::from(LOG_DIR), + workspace_rx, ) } @@ -109,12 +112,14 @@ impl PolicyLocalContext { gateway_endpoint: Option, sandbox_name: Option, shorthand_log_dir: PathBuf, + workspace_rx: tokio::sync::watch::Receiver, ) -> Self { Self { current_policy: Arc::new(RwLock::new(current_policy)), gateway_endpoint, sandbox_name, shorthand_log_dir, + workspace_rx, } } @@ -490,7 +495,10 @@ async fn submit_proposal(ctx: &PolicyLocalContext, body: &[u8]) -> (u16, serde_j }; let client = match openshell_core::grpc_client::CachedOpenShellClient::connect(endpoint).await { - Ok(client) => client, + Ok(client) => { + client.set_workspace(ctx.workspace_rx.borrow().clone()); + client + } Err(error) => { return ( 502, @@ -917,6 +925,7 @@ async fn open_lookup_session( let client = openshell_core::grpc_client::CachedOpenShellClient::connect(endpoint) .await .map_err(|e| (502, error_payload("gateway_connect_failed", e.to_string())))?; + client.set_workspace(ctx.workspace_rx.borrow().clone()); Ok(LookupSession { client, sandbox_name, @@ -1326,6 +1335,10 @@ struct L7DenyRuleJson { mod tests { use super::*; + fn test_workspace_rx() -> tokio::sync::watch::Receiver { + tokio::sync::watch::channel(String::new()).1 + } + #[test] fn proposal_chunks_from_body_accepts_add_rule_operation() { let body = br#"{ @@ -1467,7 +1480,7 @@ mod tests { "; std::fs::write(&log_path, body).unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf(), test_workspace_rx()); let (status, payload) = recent_denials_response(&ctx, "last=10").await; assert_eq!(status, 200); assert_eq!(payload["log_available"], true); @@ -1498,7 +1511,7 @@ mod tests { ) .unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf(), test_workspace_rx()); let (status, payload) = recent_denials_response(&ctx, "").await; assert_eq!(status, 200); assert_eq!(payload["log_available"], false); @@ -1508,7 +1521,7 @@ mod tests { #[tokio::test] async fn recent_denials_signals_when_log_is_missing() { let dir = tempfile::tempdir().unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf(), test_workspace_rx()); let (status, payload) = recent_denials_response(&ctx, "").await; assert_eq!(status, 200); assert_eq!(payload["log_available"], false); @@ -1571,7 +1584,7 @@ mod tests { ); std::fs::write(&log_path, line).unwrap(); - let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf()); + let ctx = PolicyLocalContext::with_log_dir(None, None, None, dir.path().to_path_buf(), test_workspace_rx()); let (_, payload) = recent_denials_response(&ctx, "last=1").await; let denials = payload["denials"].as_array().unwrap(); assert_eq!(denials.len(), 1); @@ -1633,6 +1646,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); // Even the otherwise-public `current_policy` route returns 404 with @@ -1660,6 +1674,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); let (mut client, mut server) = tokio::io::duplex(4096); @@ -1753,7 +1768,7 @@ mod tests { #[tokio::test] async fn proposal_routes_reject_malformed_paths() { let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, None); + let ctx = PolicyLocalContext::new(None, None, None, test_workspace_rx()); // Empty chunk_id after the prefix is 404, not a wildcard list. let (status, _) = route_request(&ctx, "GET", "/v1/proposals/", &[]).await; @@ -1774,7 +1789,7 @@ mod tests { #[tokio::test] async fn proposal_status_route_returns_503_when_no_gateway() { let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string())); + let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string()), test_workspace_rx()); let (status, body) = route_request(&ctx, "GET", "/v1/proposals/chunk-id", &[]).await; assert_eq!(status, 503); @@ -1784,7 +1799,7 @@ mod tests { #[tokio::test] async fn proposal_wait_route_returns_503_when_no_gateway() { let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string())); + let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string()), test_workspace_rx()); let (status, body) = route_request(&ctx, "GET", "/v1/proposals/chunk-id/wait?timeout=1", &[]).await; @@ -1795,7 +1810,7 @@ mod tests { #[tokio::test] async fn proposal_routes_return_feature_disabled_when_flag_off() { let _guard = ProposalsFlagGuard::set(false).await; - let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string())); + let ctx = PolicyLocalContext::new(None, None, Some("test-sandbox".to_string()), test_workspace_rx()); let (status, body) = route_request(&ctx, "GET", "/v1/proposals/abc", &[]).await; assert_eq!(status, 404); @@ -1871,7 +1886,7 @@ mod tests { // whole-policy diff would never see another change and burn the // full timeout. Rule-coverage must return immediately. let proposed = proposed_curl_rule_for_github(); - let ctx = PolicyLocalContext::new(Some(policy_with_rule(proposed.clone())), None, None); + let ctx = PolicyLocalContext::new(Some(policy_with_rule(proposed.clone())), None, None, test_workspace_rx()); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); let start = tokio::time::Instant::now(); @@ -1897,7 +1912,7 @@ mod tests { version: 1, ..Default::default() }; - let ctx = PolicyLocalContext::new(Some(initial), None, None); + let ctx = PolicyLocalContext::new(Some(initial), None, None, test_workspace_rx()); // Concurrently, an unrelated rule lands. We must not return. let unrelated_load = { @@ -1948,6 +1963,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); let matching_load = { @@ -1985,6 +2001,7 @@ mod tests { }), None, None, + test_workspace_rx(), ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(300); let start = tokio::time::Instant::now(); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 9553e06736..6c359de90c 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -83,9 +83,11 @@ pub async fn run_networking( sandbox_id: Option<&str>, sandbox_name: Option<&str>, openshell_endpoint: Option<&str>, + #[allow(unused_variables)] inference_routes: Option<&str>, denial_tx: Option>, activity_tx: Option, + workspace_rx: tokio::sync::watch::Receiver, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll // loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so @@ -96,6 +98,7 @@ pub async fn run_networking( sandbox_name .map(str::to_string) .or_else(|| sandbox_id.map(str::to_string)), + workspace_rx, )); // Readiness signal for the proxy accept loop: the proxy binds the TCP diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4538b207d6..555704d78f 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -318,7 +318,8 @@ pub struct CreateSandboxForm { /// When the create animation started (for pacman timing). pub anim_start: Option, /// Buffered create result — held until min display time elapses. - pub create_result: Option>, + /// `Ok((name, workspace))` or `Err(message)`. + pub create_result: Option>, } // --------------------------------------------------------------------------- @@ -529,12 +530,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,6 +583,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, + pub sandbox_workspaces: Vec, pub sandbox_policy_versions: Vec, pub sandbox_selected: usize, pub sandbox_count: usize, @@ -852,6 +861,7 @@ impl App { client: OpenShellClient>, gateway_name: String, endpoint: String, + workspace: String, theme: crate::theme::Theme, ) -> Self { Self { @@ -880,11 +890,16 @@ impl App { confirm_setting_delete: None, pending_setting_set: false, pending_setting_delete: false, + current_workspace: workspace, + 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, @@ -903,6 +918,7 @@ impl App { sandbox_images: Vec::new(), sandbox_notes: Vec::new(), sandbox_labels: Vec::new(), + sandbox_workspaces: Vec::new(), sandbox_policy_versions: Vec::new(), sandbox_selected: 0, sandbox_count: 0, @@ -1053,6 +1069,43 @@ 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; + self.current_workspace = "default".to_string(); + } 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; + self.current_workspace = "default".to_string(); + } + } + } + // 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; @@ -1359,6 +1412,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); } @@ -2518,6 +2574,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 @@ -2931,4 +3006,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/event.rs b/crates/openshell-tui/src/event.rs index 4c6eeb8b46..70c354b535 100644 --- a/crates/openshell-tui/src/event.rs +++ b/crates/openshell-tui/src/event.rs @@ -20,8 +20,8 @@ pub enum Event { Resize(u16, u16), /// A batch of log lines from the streaming log task. LogLines(Vec), - /// Result of a create sandbox request: `Ok(name)` or `Err(message)`. - CreateResult(Result), + /// Result of a create sandbox request: `Ok((name, workspace))` or `Err(message)`. + CreateResult(Result<(String, String), String>), /// Result of creating a provider on the gateway: `Ok(name)` or `Err(message)`. ProviderCreateResult(Result), /// Provider detail fetched from gateway. diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d38..6d648147d7 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; @@ -47,6 +47,7 @@ pub async fn run( interceptor: EdgeAuthInterceptor, gateway_name: &str, endpoint: &str, + workspace: &str, theme_mode: ThemeMode, ) -> Result<()> { // Detect theme *before* entering raw/alternate-screen mode. @@ -59,6 +60,7 @@ pub async fn run( client, gateway_name.to_string(), endpoint.to_string(), + workspace.to_string(), detected_theme, ); @@ -159,6 +161,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); @@ -341,7 +348,7 @@ pub async fn run( h.abort(); } match result { - Some(Ok(name)) => { + Some(Ok((name, create_workspace))) => { app.create_form = None; let ports = std::mem::take(&mut app.pending_forward_ports); let command = std::mem::take(&mut app.pending_exec_command); @@ -366,6 +373,7 @@ pub async fn run( &events, &name, &command, + &create_workspace, ) .await; } @@ -623,6 +631,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. @@ -632,6 +641,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 { @@ -728,7 +738,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(); @@ -760,6 +773,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). @@ -845,6 +859,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)) => { @@ -987,11 +1002,13 @@ async fn handle_exec_command( events: &EventHandler, sandbox_name: &str, command: &str, + workspace: &str, ) { // Step 1: Resolve sandbox → SSH session (same as handle_shell_connect). let sandbox_id = { let req = openshell_core::proto::GetSandboxRequest { name: sandbox_name.to_string(), + workspace: workspace.to_string(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { @@ -1328,6 +1345,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(); @@ -1360,6 +1378,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + workspace: workspace.clone(), }; let sandbox_name = @@ -1400,6 +1419,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 @@ -1431,7 +1451,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { } } - let _ = tx.send(Event::CreateResult(Ok(sandbox_name))); + let _ = tx.send(Event::CreateResult(Ok((sandbox_name, workspace)))); }); } @@ -1597,6 +1617,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. @@ -1615,12 +1636,15 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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 { @@ -1658,9 +1682,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 { @@ -1694,6 +1719,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(); @@ -1707,13 +1733,16 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + 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 { @@ -1739,9 +1768,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))); @@ -1778,9 +1808,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(); @@ -1817,12 +1852,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(_)) => { @@ -1858,11 +1895,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), @@ -1904,15 +1943,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), @@ -1942,6 +2007,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 { @@ -1981,6 +2052,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; } @@ -2011,6 +2086,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), @@ -2083,6 +2159,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2119,6 +2196,7 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { global: true, merge_operations: vec![], expected_resource_version: 0, + workspace: String::new(), }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2150,6 +2228,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}; @@ -2189,6 +2268,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { global: false, merge_operations: vec![], expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2216,6 +2296,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; @@ -2229,6 +2310,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { global: false, merge_operations: vec![], expected_resource_version: 0, + workspace, }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2271,6 +2353,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 { @@ -2347,6 +2435,11 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); + app.sandbox_workspaces = sandboxes + .iter() + .map(|s| s.object_workspace().to_string()) + .collect(); + if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { app.sandbox_selected = app.sandbox_count - 1; } @@ -2404,6 +2497,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(), }; match tokio::time::timeout(Duration::from_secs(5), app.client.get_draft_policy(req)).await { @@ -2430,11 +2524,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/create_sandbox.rs b/crates/openshell-tui/src/ui/create_sandbox.rs index 154a94e7e8..5628dffc53 100644 --- a/crates/openshell-tui/src/ui/create_sandbox.rs +++ b/crates/openshell-tui/src/ui/create_sandbox.rs @@ -282,7 +282,7 @@ fn draw_creating(frame: &mut Frame<'_>, app: &App, area: Rect) { // Header — changes once result arrives. let (header, header_style) = match &form.create_result { - Some(Ok(name)) => (format!("Created sandbox: {name}"), t.status_ok), + Some(Ok((name, _workspace))) => (format!("Created sandbox: {name}"), t.status_ok), Some(Err(msg)) => (format!("Failed: {msg}"), t.status_err), None => ("Creating sandbox...".to_string(), t.text), }; 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 c6fec9aeb8..8d1d8b895b 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/proto/compute_driver.proto b/proto/compute_driver.proto index f471f575a0..eac37062a5 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -73,6 +73,9 @@ message DriverSandbox { DriverSandboxSpec spec = 4; // Raw platform-observed status. DriverSandboxStatus status = 5; + // Workspace the sandbox belongs to. Used by drivers to construct + // collision-safe resource names and labels in shared-namespace mode. + string workspace = 6; } // Driver-owned provisioning inputs required to create a sandbox. diff --git a/proto/datamodel.proto b/proto/datamodel.proto index f92d7b7a36..b779e39472 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -26,6 +26,19 @@ message ObjectMeta { // Optimistic concurrency control version. // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; + + // Workspace that owns this resource. Empty is normalized to "default" by the + // gateway. Immutable after creation. + string workspace = 6; +} + +// Workspace resource. A hard isolation boundary for sandboxes, providers, and +// other workspace-scoped resources. +message Workspace { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + // The workspace field in this ObjectMeta is unused (a workspace does not + // belong to another workspace). + ObjectMeta metadata = 1; } // Provider model stored by OpenShell. @@ -41,4 +54,8 @@ message Provider { // Expiration timestamps for credential values, keyed by credential/env var // name. A zero or missing value means the credential does not expire. map credential_expires_at_ms = 5; + // Workspace where this provider's type profile is stored. + // Empty string = platform/global scope. Must be empty or match + // metadata.workspace; cross-workspace references are rejected. + string profile_workspace = 6; } diff --git a/proto/inference.proto b/proto/inference.proto index b0bc581e81..5c8bf44223 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -7,29 +7,30 @@ package openshell.inference.v1; import "datamodel.proto"; -// Inference service provides cluster inference configuration and bundle delivery. +// Inference service provides workspace-scoped inference route configuration and bundle delivery. service Inference { // Return the resolved inference route bundle for sandbox-local execution. rpc GetInferenceBundle(GetInferenceBundleRequest) returns (GetInferenceBundleResponse); - // Set cluster-level inference configuration. + // Set the inference route for a workspace. // - // This controls how requests sent to `inference.local` are routed. - rpc SetClusterInference(SetClusterInferenceRequest) - returns (SetClusterInferenceResponse); + // This controls how requests sent to `inference.local` are routed + // for sandboxes in the specified workspace. + rpc SetInferenceRoute(SetInferenceRouteRequest) + returns (SetInferenceRouteResponse); - // Get cluster-level inference configuration. - rpc GetClusterInference(GetClusterInferenceRequest) - returns (GetClusterInferenceResponse); + // Get the inference route for a workspace. + rpc GetInferenceRoute(GetInferenceRouteRequest) + returns (GetInferenceRouteResponse); } -// Persisted cluster inference configuration. +// Persisted inference route configuration. // // Only `provider_name` and `model_id` are stored; endpoint, protocols, // credentials, and auth style are resolved from the provider at bundle time. -message ClusterInferenceConfig { +message InferenceRouteConfig { // Provider record name backing this route. string provider_name = 1; // Model identifier to force on generation calls. @@ -38,15 +39,15 @@ message ClusterInferenceConfig { uint64 timeout_secs = 3; } -// Storage envelope for the managed cluster inference route. +// Storage envelope for a workspace-scoped inference route. message InferenceRoute { openshell.datamodel.v1.ObjectMeta metadata = 1; - ClusterInferenceConfig config = 2; + InferenceRouteConfig config = 2; // Monotonic version incremented on every update. uint64 version = 3; } -message SetClusterInferenceRequest { +message SetInferenceRouteRequest { // Provider record name to use for credentials + endpoint mapping. string provider_name = 1; // Model identifier to force on generation calls. @@ -60,6 +61,8 @@ message SetClusterInferenceRequest { bool no_verify = 5; // Per-route request timeout in seconds. 0 means use default (60s). uint64 timeout_secs = 6; + // Target workspace. Empty string defaults to "default". + string workspace = 7; } message ValidatedEndpoint { @@ -67,7 +70,7 @@ message ValidatedEndpoint { string protocol = 2; } -message SetClusterInferenceResponse { +message SetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -79,15 +82,19 @@ message SetClusterInferenceResponse { repeated ValidatedEndpoint validated_endpoints = 6; // Per-route request timeout in seconds that was persisted. uint64 timeout_secs = 7; + // Workspace the route was configured in. + string workspace = 8; } -message GetClusterInferenceRequest { +message GetInferenceRouteRequest { // Route name to query. Empty string defaults to "inference.local" (user-facing). // Use "sandbox-system" for the sandbox system-level inference route. string route_name = 1; + // Target workspace. Empty string defaults to "default". + string workspace = 2; } -message GetClusterInferenceResponse { +message GetInferenceRouteResponse { string provider_name = 1; string model_id = 2; uint64 version = 3; @@ -95,6 +102,8 @@ message GetClusterInferenceResponse { string route_name = 4; // Per-route request timeout in seconds. 0 means default (60s). uint64 timeout_secs = 5; + // Workspace the route belongs to. + string workspace = 6; } message GetInferenceBundleRequest {} diff --git a/proto/openshell.proto b/proto/openshell.proto index d2d884f2e8..d38cd659b6 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -244,6 +244,31 @@ service OpenShell { // rewritten. rpc RefreshSandboxToken(RefreshSandboxTokenRequest) returns (RefreshSandboxTokenResponse); + + // --------------------------------------------------------------------------- + // Workspace management RPCs + // --------------------------------------------------------------------------- + + // Create a workspace. + rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse); + + // Fetch a workspace by name. + rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse); + + // List workspaces. + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + + // Delete a workspace by name. + rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse); + + // Add a member to a workspace. + rpc AddWorkspaceMember(AddWorkspaceMemberRequest) returns (AddWorkspaceMemberResponse); + + // Remove a member from a workspace. + rpc RemoveWorkspaceMember(RemoveWorkspaceMemberRequest) returns (RemoveWorkspaceMemberResponse); + + // List members of a workspace. + rpc ListWorkspaceMembers(ListWorkspaceMembersRequest) returns (ListWorkspaceMembersResponse); } // IssueSandboxToken request. Empty body; identity is established by the @@ -447,12 +472,16 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; + // Workspace for the sandbox. Empty defaults to "default". + string workspace = 4; } // Get sandbox request. message GetSandboxRequest { // Sandbox name (canonical lookup key). string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // List sandboxes request. @@ -461,12 +490,18 @@ message ListSandboxesRequest { uint32 offset = 2; // Optional label selector for filtering (format: "key1=value1,key2=value2"). string label_selector = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 5; } // List providers attached to a sandbox request. message ListSandboxProvidersRequest { // Sandbox name (canonical lookup key). string sandbox_name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Attach provider to sandbox request. @@ -480,6 +515,8 @@ message AttachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } // Detach provider from sandbox request. @@ -493,12 +530,16 @@ message DetachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } // Delete sandbox request. message DeleteSandboxRequest { // Sandbox name (canonical lookup key). string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Sandbox response. @@ -585,6 +626,8 @@ message ExposeServiceRequest { uint32 target_port = 3; // Whether to print/use the browser-facing service URL. bool domain = 4; + // Workspace scope. Empty defaults to "default". + string workspace = 5; } // Request to fetch an exposed sandbox service endpoint. @@ -593,6 +636,8 @@ message GetServiceRequest { string sandbox = 1; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Request to list exposed sandbox service endpoints. @@ -603,6 +648,10 @@ message ListServicesRequest { uint32 limit = 2; // Page offset. uint32 offset = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 5; } // Response containing exposed sandbox service endpoints. @@ -616,6 +665,8 @@ message DeleteServiceRequest { string sandbox = 1; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Response for deleting an exposed sandbox service endpoint. @@ -845,17 +896,25 @@ message SandboxStreamWarning { // Create provider request. message CreateProviderRequest { openshell.datamodel.v1.Provider provider = 1; + // Workspace for the provider. Empty defaults to "default". + string workspace = 2; } // Get provider request. message GetProviderRequest { string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // List providers request. message ListProvidersRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; + // List across all workspaces. Mutually exclusive with workspace. + bool all_workspaces = 4; } // Update provider request. @@ -864,11 +923,15 @@ message UpdateProviderRequest { // Optional per-credential expiry timestamps to merge into the provider. // A zero value removes the expiry for that credential. map credential_expires_at_ms = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } // Delete provider request. message DeleteProviderRequest { string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } // Provider response. @@ -885,11 +948,18 @@ message ListProvidersResponse { message ListProviderProfilesRequest { uint32 limit = 1; uint32 offset = 2; + // Workspace scope. When set, returns workspace-scoped + built-in profiles. + // When empty, returns platform-scoped + built-in only. + string workspace = 3; } // Fetch provider type profile request. message GetProviderProfileRequest { string id = 1; + // Workspace scope for two-tier profile resolution. When set, checks + // workspace-scoped profiles first, then platform-scoped, then built-in. + // When empty, checks platform-scoped then built-in only. + string workspace = 2; } // Provider profile payload with optional source metadata for diagnostics. @@ -1032,6 +1102,8 @@ message StoredProviderCredentialRefreshState { message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetProviderRefreshStatusResponse { @@ -1045,6 +1117,8 @@ message ConfigureProviderRefreshRequest { map material = 4; repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; + // Workspace scope. Empty defaults to "default". + string workspace = 7; } message ConfigureProviderRefreshResponse { @@ -1054,6 +1128,8 @@ message ConfigureProviderRefreshResponse { message RotateProviderCredentialRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message RotateProviderCredentialResponse { @@ -1063,6 +1139,8 @@ message RotateProviderCredentialResponse { message DeleteProviderRefreshRequest { string provider = 1; string credential_key = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message DeleteProviderRefreshResponse { @@ -1117,6 +1195,9 @@ message ListProviderProfilesResponse { // Import custom provider profiles request. message ImportProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). + // When empty, profiles are platform-scoped (Platform Admin). + string workspace = 2; } // Import custom provider profiles response. @@ -1136,6 +1217,9 @@ message UpdateProviderProfilesRequest { uint64 expected_resource_version = 2; // Existing custom provider profile ID to update. The payload ID must match. string id = 3; + // Workspace scope. When set, targets workspace-scoped profile. When empty, + // targets platform-scoped profile. + string workspace = 4; } // Update one custom provider profile response. @@ -1148,6 +1232,9 @@ message UpdateProviderProfilesResponse { // Lint provider profiles request. message LintProviderProfilesRequest { repeated ProviderProfileImportItem profiles = 1; + // Workspace scope. Used to check for conflicts against existing profiles + // in the target workspace. + string workspace = 2; } // Lint provider profiles response. @@ -1164,6 +1251,9 @@ message DeleteProviderResponse { // Delete custom provider profile request. message DeleteProviderProfileRequest { string id = 1; + // Workspace scope. When set, targets workspace-scoped profile. When empty, + // targets platform-scoped profile. + string workspace = 2; } // Delete custom provider profile response. @@ -1226,6 +1316,8 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; + // Workspace scope. Empty defaults to "default". Ignored for global-scoped updates. + string workspace = 9; } message PolicyMergeOperation { @@ -1291,6 +1383,8 @@ message GetSandboxPolicyStatusRequest { uint32 version = 2; // Query global policy revisions instead of a sandbox-scoped one. bool global = 3; + // Workspace scope. Empty defaults to "default". Ignored when global is true. + string workspace = 4; } // Get sandbox policy status response. @@ -1309,6 +1403,8 @@ message ListSandboxPoliciesRequest { uint32 offset = 3; // List global policy revisions instead of sandbox-scoped ones. bool global = 4; + // Workspace scope. Empty defaults to "default". Ignored when global is true. + string workspace = 5; } // List sandbox policies response. @@ -1378,6 +1474,8 @@ message GetSandboxLogsRequest { repeated string sources = 4; // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. string min_level = 5; + // Workspace scope. Empty defaults to "default". + string workspace = 6; } // Batch of log lines pushed from sandbox to server. @@ -1677,6 +1775,8 @@ message SubmitPolicyAnalysisRequest { string name = 4; // Anonymous network activity counters. repeated NetworkActivitySummary network_activity_summaries = 5; + // Workspace scope. Empty defaults to "default". + string workspace = 6; } message SubmitPolicyAnalysisResponse { @@ -1698,6 +1798,8 @@ message GetDraftPolicyRequest { string name = 1; // Optional status filter: "pending", "approved", "rejected", or "" for all. string status_filter = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message GetDraftPolicyResponse { @@ -1717,6 +1819,8 @@ message ApproveDraftChunkRequest { string name = 1; // Chunk ID to approve. string chunk_id = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message ApproveDraftChunkResponse { @@ -1734,6 +1838,8 @@ message RejectDraftChunkRequest { string chunk_id = 2; // Optional reason for rejection (fed to LLM context in future analysis). string reason = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } message RejectDraftChunkResponse {} @@ -1744,6 +1850,8 @@ message ApproveAllDraftChunksRequest { string name = 1; // Include chunks with security_notes (default false: skips them). bool include_security_flagged = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message ApproveAllDraftChunksResponse { @@ -1765,6 +1873,8 @@ message EditDraftChunkRequest { string chunk_id = 2; // The modified rule (replaces existing proposed_rule). openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; + // Workspace scope. Empty defaults to "default". + string workspace = 4; } message EditDraftChunkResponse {} @@ -1775,6 +1885,8 @@ message UndoDraftChunkRequest { string name = 1; // Chunk ID to undo. string chunk_id = 2; + // Workspace scope. Empty defaults to "default". + string workspace = 3; } message UndoDraftChunkResponse { @@ -1788,6 +1900,8 @@ message UndoDraftChunkResponse { message ClearDraftChunksRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message ClearDraftChunksResponse { @@ -1799,6 +1913,8 @@ message ClearDraftChunksResponse { message GetDraftHistoryRequest { // Sandbox name. string name = 1; + // Workspace scope. Empty defaults to "default". + string workspace = 2; } message DraftHistoryEntry { @@ -1897,3 +2013,116 @@ message StoredDraftChunk { // Operator-supplied free-form rejection text. See PolicyChunk. string rejection_reason = 19; } + +// --------------------------------------------------------------------------- +// Workspace messages +// --------------------------------------------------------------------------- + +// Create workspace request. +message CreateWorkspaceRequest { + // Workspace name. Must be a valid DNS-1123 label. + string name = 1; + // Optional labels for the workspace (key-value metadata). + map labels = 2; +} + +// Create workspace response. +message CreateWorkspaceResponse { + openshell.datamodel.v1.Workspace workspace = 1; +} + +// Get workspace request. +message GetWorkspaceRequest { + // Workspace name (canonical lookup key). + string name = 1; +} + +// Get workspace response. +message GetWorkspaceResponse { + openshell.datamodel.v1.Workspace workspace = 1; +} + +// List workspaces request. +message ListWorkspacesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + string label_selector = 3; +} + +// List workspaces response. +message ListWorkspacesResponse { + repeated openshell.datamodel.v1.Workspace workspaces = 1; +} + +// Delete workspace request. +message DeleteWorkspaceRequest { + // Workspace name (canonical lookup key). + string name = 1; +} + +// Delete workspace response. +message DeleteWorkspaceResponse { + bool deleted = 1; +} + +// --------------------------------------------------------------------------- +// Workspace membership messages +// --------------------------------------------------------------------------- + +// Workspace-scoped role for members. +enum WorkspaceRole { + WORKSPACE_ROLE_UNSPECIFIED = 0; + WORKSPACE_ROLE_USER = 1; + WORKSPACE_ROLE_ADMIN = 2; +} + +// Workspace membership record. +message WorkspaceMember { + openshell.datamodel.v1.ObjectMeta metadata = 1; + // OIDC subject claim identifying the principal. + string principal_subject = 2; + // Role assigned to the principal within the workspace. + WorkspaceRole role = 3; +} + +// Add workspace member request. +message AddWorkspaceMemberRequest { + // Workspace name. + string workspace = 1; + // OIDC subject claim identifying the principal. + string principal_subject = 2; + // Role to assign. + WorkspaceRole role = 3; +} + +// Add workspace member response. +message AddWorkspaceMemberResponse { + WorkspaceMember member = 1; +} + +// Remove workspace member request. +message RemoveWorkspaceMemberRequest { + // Workspace name. + string workspace = 1; + // OIDC subject claim identifying the principal to remove. + string principal_subject = 2; +} + +// Remove workspace member response. +message RemoveWorkspaceMemberResponse { + bool removed = 1; +} + +// List workspace members request. +message ListWorkspaceMembersRequest { + // Workspace name. + string workspace = 1; + uint32 limit = 2; + uint32 offset = 3; +} + +// List workspace members response. +message ListWorkspaceMembersResponse { + repeated WorkspaceMember members = 1; +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 8a5a593334..048e1c3c37 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -329,4 +329,7 @@ message GetSandboxConfigResponse { // Fingerprint for provider credential inputs attached to this sandbox. // Changes when attached provider names or attached provider records change. uint64 provider_env_revision = 8; + // Workspace the sandbox belongs to. Allows the supervisor to learn its + // workspace context for subsequent workspace-scoped RPCs. + string workspace = 9; } 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..4275af31ff 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,23 @@ 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 + ) + + self._workspace = self._session._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 +831,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 +923,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..79657ac71b --- /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 + `openshell-{sandbox}` to `openshell-{sandbox}.{workspace}` — 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. +