diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 7411629483..e80edefc77 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -504,6 +504,7 @@ struct Cli { } #[derive(Subcommand, Debug)] +#[allow(clippy::large_enum_variant)] enum Commands { // =================================================================== // SANDBOX COMMANDS @@ -1536,6 +1537,10 @@ enum SandboxCommands { #[arg(long, value_hint = ValueHint::FilePath)] policy: Option, + /// Freeze the effective policy after initialization for this sandbox's lifetime. + #[arg(long)] + frozen: bool, + /// Forward a local port to the sandbox before the initial command or shell starts. /// Accepts [`bind_address`:]port (e.g. 8080, 0.0.0.0:8080). Keeps the sandbox alive. #[arg(long, conflicts_with = "no_keep")] @@ -3395,6 +3400,7 @@ async fn run_async() -> Result<()> { driver_config_json, providers, policy, + frozen, forward, expose, expose_authorization_mode, @@ -3467,41 +3473,53 @@ async fn run_async() -> Result<()> { let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name)?; - let exit_code = Box::pin(run::sandbox_create( - endpoint, - &ctx.name, - run::SandboxCreateConfig { - name: name.as_deref(), - template: template.as_deref(), - from: from.as_deref(), - uploads: &upload_specs, - keep, - gpu_requirements, - cpu: cpu.as_deref(), - memory: memory.as_deref(), - driver_config_json: driver_config_json.as_deref(), - editor, - providers: &providers, - policy: policy.as_deref(), - forward, - expose, - expose_authorization_mode: expose_authorization_mode.into(), - command: &command, - tty_override, - auto_providers_override, - labels: labels_map, - annotations: annotations_map, - environment: env_map, - approval_mode: &approval_mode, - output: output.as_str(), - detach, - suppress_credential_warnings: no_credential_warnings, - restart_policy: &restart_policy, - }, - &cli.workspace, - &tls, - )) - .await?; + let config = run::SandboxCreateConfig { + name: name.as_deref(), + template: template.as_deref(), + from: from.as_deref(), + uploads: &upload_specs, + keep, + gpu_requirements, + cpu: cpu.as_deref(), + memory: memory.as_deref(), + driver_config_json: driver_config_json.as_deref(), + editor, + providers: &providers, + policy: policy.as_deref(), + forward, + expose, + expose_authorization_mode: expose_authorization_mode.into(), + command: &command, + tty_override, + auto_providers_override, + labels: labels_map, + annotations: annotations_map, + environment: env_map, + approval_mode: &approval_mode, + output: output.as_str(), + detach, + suppress_credential_warnings: no_credential_warnings, + restart_policy: &restart_policy, + }; + let exit_code = if frozen { + Box::pin(run::sandbox_create_frozen( + endpoint, + &ctx.name, + config, + &cli.workspace, + &tls, + )) + .await? + } else { + Box::pin(run::sandbox_create( + endpoint, + &ctx.name, + config, + &cli.workspace, + &tls, + )) + .await? + }; if exit_code != 0 { std::process::exit(exit_code); } @@ -6324,6 +6342,38 @@ mod tests { } } + #[test] + fn sandbox_create_frozen_supports_inline_and_named_templates() { + for args in [ + vec!["openshell", "sandbox", "create", "--frozen"], + vec![ + "openshell", + "sandbox", + "create", + "--template", + "agent", + "--frozen", + ], + ] { + let cli = Cli::try_parse_from(args).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Create { frozen: true, .. }), + .. + }) + )); + } + let cli = Cli::try_parse_from(["openshell", "sandbox", "create"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Create { frozen: false, .. }), + .. + }) + )); + } + #[test] fn sandbox_create_restart_policy_defaults_to_never() { let cli = Cli::try_parse_from(["openshell", "sandbox", "create"]).unwrap(); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index eb224685ed..8db3c49e81 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -431,6 +431,7 @@ async fn finalize_sandbox_create_session( /// on the function signature, following the `provider_refresh_config(server, input, tls)` /// precedent. This struct captures sandbox-specific options. #[derive(Debug)] +#[allow(clippy::struct_excessive_bools)] // Independent command-line switches. pub struct SandboxCreateConfig<'a> { pub name: Option<&'a str>, pub template: Option<&'a str>, @@ -500,6 +501,44 @@ pub async fn sandbox_create( config: SandboxCreateConfig<'_>, workspace: &str, tls: &TlsOptions, +) -> Result { + Box::pin(sandbox_create_with_mode( + server, + gateway_name, + config, + workspace, + tls, + false, + )) + .await +} + +/// Create a sandbox whose first accepted effective policy is retained for its lifetime. +pub async fn sandbox_create_frozen( + server: &str, + gateway_name: &str, + config: SandboxCreateConfig<'_>, + workspace: &str, + tls: &TlsOptions, +) -> Result { + Box::pin(sandbox_create_with_mode( + server, + gateway_name, + config, + workspace, + tls, + true, + )) + .await +} + +async fn sandbox_create_with_mode( + server: &str, + gateway_name: &str, + config: SandboxCreateConfig<'_>, + workspace: &str, + tls: &TlsOptions, + frozen: bool, ) -> Result { let SandboxCreateConfig { name, @@ -712,7 +751,42 @@ pub async fn sandbox_create( .collect(), }; - let response = match client.create_sandbox(request).await { + let created = if frozen { + let mut frozen_client = + crate::tls::frozen_policies_client(&effective_server, &effective_tls).await?; + frozen_client + .create_sandbox(openshell_core::proto::CreateFrozenSandboxRequest { + workspace_scope: request.workspace_scope, + spec: request.spec, + name: request.name, + labels: request.labels, + annotations: request.annotations, + await_main_process_attachment: request.await_main_process_attachment, + workload_template: request.workload_template, + request_id: request.request_id, + service_exposures: request.service_exposures, + }) + .await + .and_then(|response| { + let response = response.into_inner(); + if !response.frozen_policy.is_some_and(|state| state.requested) { + let name = response.sandbox.as_ref().map_or("unknown", ObjectName::object_name); + let id = response.sandbox.as_ref().map_or("unknown", ObjectId::object_id); + return Err(Status::failed_precondition(format!( + "gateway did not acknowledge frozen creation for sandbox '{name}' (id {id}, workspace {workspace}); inspect it before retrying and remove it with: openshell --workspace {workspace} sandbox delete {name}" + ))); + } + Ok(tonic::Response::new( + openshell_core::proto::SandboxResponse { + sandbox: response.sandbox, + service_urls: response.service_urls, + }, + )) + }) + } else { + client.create_sandbox(request).await + }; + let response = match created { Ok(resp) => resp, Err(status) if status.code() == Code::AlreadyExists => { return Err(miette::miette!( @@ -1124,6 +1198,16 @@ pub async fn sandbox_create( if structured_output { let mut value = sandbox_to_json(&last_sandbox); + if frozen { + let state = frozen_policy_state( + &effective_server, + &sandbox_name, + workspace, + &effective_tls, + ) + .await?; + append_frozen_policy_json(&mut value, &state); + } if let Some(object) = value.as_object_mut() { object.insert("service_urls".to_string(), serde_json::json!(service_urls)); } @@ -1668,7 +1752,9 @@ where return Ok(()); } - let detail_json = sandbox_detail_to_json(&sandbox, &config)?; + let frozen_state = frozen_policy_state(server, name, workspace, tls).await?; + let mut detail_json = sandbox_detail_to_json(&sandbox, &config)?; + append_frozen_policy_json(&mut detail_json, &frozen_state); if crate::output::print_output_single(output, &detail_json, Clone::clone)? { return Ok(()); } @@ -1688,6 +1774,15 @@ where println!(" {} {}", "Id:".dimmed(), id); println!(" {} {}", "Name:".dimmed(), name); println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase())); + if let Some(frozen) = frozen_state.policy.as_ref() { + println!(" {} frozen", "Policy state:".dimmed()); + println!(" {} {}", "Frozen hash:".dimmed(), frozen.policy_hash); + if let Some(time) = &frozen.frozen_time { + println!(" {} {}", "Frozen at:".dimmed(), time); + } + } else if frozen_state.requested { + println!(" {} pending freeze", "Policy state:".dimmed()); + } if let Some(status) = sandbox.status.as_ref() { for condition in &status.conditions { if matches!( @@ -1825,11 +1920,7 @@ where println!( " {} {}", "Policy source:".dimmed(), - if policy_from_global { - "global" - } else { - "sandbox" - } + policy_source_name(config.policy_source) ); let revision = if policy_from_global { if config.global_policy_version > 0 { @@ -3002,6 +3093,13 @@ pub async fn sandbox_list( Ok(()) } +fn policy_source_name(source: i32) -> &'static str { + match PolicySource::try_from(source) { + Ok(PolicySource::Global) => "global", + _ => "sandbox", + } +} + fn configuration_failure_message(sandbox: &Sandbox) -> Option<&str> { sandbox .status @@ -3012,6 +3110,54 @@ fn configuration_failure_message(sandbox: &Sandbox) -> Option<&str> { .filter(|message| !message.is_empty()) } +async fn frozen_policy_state( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result { + let mut client = crate::tls::frozen_policies_client(server, tls).await?; + match client + .get_policy(openshell_core::proto::GetFrozenSandboxPolicyRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + name: name.to_string(), + }) + .await + { + Ok(response) => Ok(response.into_inner()), + Err(status) if status.code() == Code::Unimplemented => { + Ok(openshell_core::proto::FrozenSandboxPolicyState::default()) + } + Err(error) => Err(error).into_diagnostic(), + } +} + +fn append_frozen_policy_json( + value: &mut serde_json::Value, + state: &openshell_core::proto::FrozenSandboxPolicyState, +) { + if !state.requested { + return; + } + let object = value.as_object_mut().expect("sandbox output is an object"); + object.insert("frozen".into(), serde_json::json!(true)); + object.insert( + "frozen_policy".into(), + state + .policy + .as_ref() + .map_or(serde_json::Value::Null, |policy| { + serde_json::json!({ + "version": policy.version, + "policy_hash": policy.policy_hash, + "frozen_time": policy.frozen_time.as_ref().map(ToString::to_string), + "policy_source": policy_source_name(policy.policy_source), + "global_policy_version": policy.global_policy_version, + }) + }), + ); +} + fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { use openshell_core::proto::ConfigurationAdmissionState; @@ -3340,11 +3486,7 @@ fn sandbox_detail_to_json( ); } - let policy_source = if config.policy_source == PolicySource::Global as i32 { - "global" - } else { - "sandbox" - }; + let policy_source = policy_source_name(config.policy_source); obj.insert("policy_source".into(), serde_json::json!(policy_source)); let policy_from_global = config.policy_source == PolicySource::Global as i32; @@ -5408,6 +5550,50 @@ pub async fn sandbox_upload( // Sandbox policy commands // --------------------------------------------------------------------------- +async fn update_global_config_with_frozen_report( + server: &str, + tls: &TlsOptions, + request: UpdateConfigRequest, +) -> Result<(openshell_core::proto::UpdateConfigResponse, Vec)> { + let mut client = crate::tls::frozen_policies_client(server, tls).await?; + let response = client + .update_global_config(openshell_core::proto::UpdateFrozenGlobalConfigRequest { + workspace_scope: request.workspace_scope.clone(), + policy: request.policy.clone(), + setting_key: request.setting_key.clone(), + setting_value: request.setting_value.clone(), + delete_setting: request.delete_setting, + global: request.global, + merge_operations: request.merge_operations.clone(), + expected_resource_version: request.expected_resource_version, + annotations: request.annotations.clone(), + sandbox: request.sandbox.clone(), + request_id: request.request_id.clone(), + }) + .await; + match response { + Ok(response) => { + let response = response.into_inner(); + let update = response + .update + .ok_or_else(|| miette!("gateway returned no global configuration result"))?; + Ok((update, response.frozen_sandboxes)) + } + Err(status) if status.code() == Code::Unimplemented => { + // Ordinary global commands keep working with pre-feature gateways; + // explicit frozen creation has no such fallback. + let update = grpc_client(server, tls) + .await? + .update_config(request) + .await + .into_diagnostic()? + .into_inner(); + Ok((update, Vec::new())) + } + Err(status) => Err(status).into_diagnostic(), + } +} + pub async fn sandbox_policy_set_global( server: &str, policy_path: &str, @@ -5428,16 +5614,16 @@ pub async fn sandbox_policy_set_global( let policy = load_sandbox_policy(Some(policy_path))? .ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?; - let mut client = grpc_client(server, tls).await?; - let response = client - .update_config(UpdateConfigRequest { + let (response, frozen_sandboxes) = update_global_config_with_frozen_report( + server, + tls, + UpdateConfigRequest { policy: Some(policy), global: true, ..Default::default() - }) - .await - .into_diagnostic()? - .into_inner(); + }, + ) + .await?; eprintln!( "{} Global policy configured (hash: {}, settings revision: {})", @@ -5449,6 +5635,12 @@ pub async fn sandbox_policy_set_global( }, response.settings_revision, ); + if !frozen_sandboxes.is_empty() { + eprintln!( + "Frozen sandboxes retain their policy: {}", + frozen_sandboxes.join(", ") + ); + } Ok(()) } @@ -5477,11 +5669,7 @@ pub async fn sandbox_settings_get( return Ok(()); } - let policy_source = if response.policy_source == PolicySource::Global as i32 { - "global" - } else { - "sandbox" - }; + let policy_source = policy_source_name(response.policy_source); println!("Sandbox: {name}"); println!("Config Rev: {}", response.config_revision); @@ -5553,11 +5741,7 @@ fn settings_to_json_sandbox( workspace: &str, response: &GetSandboxConfigResponse, ) -> serde_json::Value { - let policy_source = if response.policy_source == PolicySource::Global as i32 { - "global" - } else { - "sandbox" - }; + let policy_source = policy_source_name(response.policy_source); let mut settings = serde_json::Map::new(); let mut keys: Vec<_> = response.settings.keys().cloned().collect(); @@ -5686,17 +5870,17 @@ pub async fn gateway_setting_delete( ) -> Result<()> { confirm_global_setting_delete(key, yes)?; - let mut client = grpc_client(server, tls).await?; - let response = client - .update_config(UpdateConfigRequest { + let (response, frozen_sandboxes) = update_global_config_with_frozen_report( + server, + tls, + UpdateConfigRequest { setting_key: key.to_string(), delete_setting: true, global: true, ..Default::default() - }) - .await - .into_diagnostic()? - .into_inner(); + }, + ) + .await?; if response.deleted { println!( @@ -5708,6 +5892,12 @@ pub async fn gateway_setting_delete( } else { println!("{} Global setting {} not found", "!".yellow(), key); } + if !frozen_sandboxes.is_empty() { + println!( + "Frozen sandboxes retain their policy: {}", + frozen_sandboxes.join(", ") + ); + } Ok(()) } @@ -8792,6 +8982,40 @@ mod tests { ); } + #[test] + fn sandbox_json_distinguishes_pending_and_frozen_policy() { + let sandbox = Sandbox::default(); + let mut state = openshell_core::proto::FrozenSandboxPolicyState { + requested: true, + policy: None, + }; + let mut pending = super::sandbox_to_json(&sandbox); + super::append_frozen_policy_json(&mut pending, &state); + assert_eq!(pending["frozen"], true); + assert!(pending["frozen_policy"].is_null()); + state.policy = Some(openshell_core::proto::FrozenSandboxPolicy { + version: 3, + policy_hash: "accepted-hash".into(), + frozen_time: openshell_core::time::timestamp_from_millis(600_000).ok(), + policy_source: PolicySource::Global as i32, + global_policy_version: 7, + ..Default::default() + }); + super::append_frozen_policy_json(&mut pending, &state); + assert_eq!(pending["frozen_policy"]["policy_hash"], "accepted-hash"); + assert_eq!(pending["frozen_policy"]["policy_source"], "global"); + assert_eq!( + pending["frozen_policy"]["frozen_time"], + "1970-01-01T00:10:00Z" + ); + let mut live = super::sandbox_to_json(&sandbox); + super::append_frozen_policy_json( + &mut live, + &openshell_core::proto::FrozenSandboxPolicyState::default(), + ); + assert!(live.get("frozen").is_none()); + } + #[test] fn sandbox_json_exposes_repair_diagnostic_and_accepted_generation() { use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission}; diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index fd138aae5b..16a014c44c 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -344,15 +344,8 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { /// `TRACEPARENT`/`TRACESTATE` trace context from the environment on every /// request. With no token and no trace context, the interceptor is a no-op. pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result { - let config = sdk_client_config(server, tls)?; - // Keep the SDK's transport state machine out of every command future. - // Most CLI commands compose this helper into larger async operations. - let client = Box::pin(OpenShellClient::connect(config)) - .await - .into_diagnostic()?; - // tonic allows a single interceptor per channel: compose the SDK's auth - // interceptor (sharing its live bearer slot) with the CLI's passive - // trace-context forwarding. + let client = sdk_client(server, tls).await?; + // Share the SDK's live bearer slot and preserve passive trace forwarding. let interceptor = GatewayInterceptor { auth: client.auth_interceptor(), trace: EnvTraceContextInterceptor::from_env(), @@ -363,6 +356,35 @@ pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result { )) } +pub(crate) async fn frozen_policies_client( + server: &str, + tls: &TlsOptions, +) -> Result< + openshell_sdk::raw::FrozenSandboxPoliciesClient< + tonic::service::interceptor::InterceptedService, + >, +> { + let client = sdk_client(server, tls).await?; + let interceptor = GatewayInterceptor { + auth: client.auth_interceptor(), + trace: EnvTraceContextInterceptor::from_env(), + }; + Ok( + openshell_sdk::raw::FrozenSandboxPoliciesClient::with_interceptor( + client.channel(), + interceptor, + ), + ) +} + +async fn sdk_client(server: &str, tls: &TlsOptions) -> Result { + let config = sdk_client_config(server, tls)?; + // Keep the SDK's transport state machine out of every command future. + Box::pin(OpenShellClient::connect(config)) + .await + .into_diagnostic() +} + #[cfg(test)] mod tests { use super::{GatewayInterceptor, TlsOptions, sdk_client_config}; diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 587910fbaf..8757bcb124 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -1855,6 +1855,50 @@ async fn sandbox_create_tolerates_an_unreachable_profile_catalog() { .await; } +#[tokio::test] +async fn sandbox_create_frozen_rejects_unsupported_gateway_before_creation() { + let server = run_server().await; + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + // This legacy gateway has no frozen-policy service. No ordinary + // create is allowed as a fallback for the explicit frozen request. + let error = run::sandbox_create_frozen( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("legacy-frozen"), + command: &["/opt/agent".into()], + ..test_config() + }, + "default", + &test_tls(&server), + ) + .await + .expect_err("an unsupported freeze must stop the client workflow"); + + let message = error.to_string(); + assert!( + message.contains(tonic::Code::Unimplemented.description()), + "{message}" + ); + assert!(create_requests(&server).await.is_empty()); + assert_eq!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst), + 0 + ); + assert!(deleted_names(&server).await.is_empty()); + assert_eq!(load_last_sandbox("openshell", "default"), None); + }) + .await; +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 64268a7866..efac6d677b 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1260,6 +1260,110 @@ async fn fetch_settings_snapshot_with_client( Ok(settings_poll_result(response.into_inner())) } +/// Runtime configuration paired with its authoritative frozen-policy state. +/// Existing polling results retain their source-compatible shape. +#[derive(Clone, Debug)] +pub struct FrozenSettingsPollResult { + pub snapshot: SettingsPollResult, + pub frozen: bool, + pub frozen_requested: bool, +} + +impl From for FrozenSettingsPollResult { + fn from(snapshot: SettingsPollResult) -> Self { + Self { + snapshot, + frozen: false, + frozen_requested: false, + } + } +} + +impl std::ops::Deref for FrozenSettingsPollResult { + type Target = SettingsPollResult; + + fn deref(&self) -> &Self::Target { + &self.snapshot + } +} + +impl std::ops::DerefMut for FrozenSettingsPollResult { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.snapshot + } +} + +async fn fetch_frozen_snapshot_on_channel( + channel: AuthedChannel, + sandbox: &str, + workspace: Option<&str>, +) -> Result { + use crate::proto::frozen_sandbox_policies_client::FrozenSandboxPoliciesClient; + + let response = FrozenSandboxPoliciesClient::new(channel.clone()) + .get_config(crate::proto::GetFrozenSandboxConfigRequest { + workspace_scope: workspace.map(crate::proto::workspace_selector), + name: sandbox.to_string(), + }) + .await; + match response { + Ok(response) => { + let response = response.into_inner(); + let config = response.config.ok_or_else(|| { + miette::miette!("gateway returned no frozen-policy configuration snapshot") + })?; + if response.frozen && !response.frozen_requested { + return Err(miette::miette!( + "gateway returned inconsistent frozen-policy state" + )); + } + Ok(FrozenSettingsPollResult { + snapshot: settings_poll_result(config), + frozen: response.frozen, + frozen_requested: response.frozen_requested, + }) + } + Err(status) if status.code() == tonic::Code::Unimplemented => { + // Older gateways cannot create frozen sandboxes. Preserve ordinary + // startup against them; errors from a supporting gateway fail closed. + fetch_settings_snapshot_with_client( + &mut OpenShellClient::new(channel), + sandbox, + workspace, + ) + .await + .map(Into::into) + } + Err(status) => Err(grpc_status_error(status)), + } +} + +/// Fetch runtime configuration and freeze state without extending old messages. +pub async fn fetch_frozen_settings_snapshot( + gateway: &GatewayEndpoint, + sandbox: &str, +) -> Result { + fetch_frozen_snapshot_on_channel(connect_channel(gateway).await?, sandbox, None).await +} + +/// Sync an initialization policy and retain the freeze signal on the same channel. +pub async fn sync_policy_and_fetch_frozen_snapshot( + gateway: &GatewayEndpoint, + sandbox: &str, + policy: &ProtoSandboxPolicy, + workspace: &str, +) -> Result { + let channel = connect_channel(gateway).await?; + sync_policy_with_client( + &mut OpenShellClient::new(channel.clone()), + sandbox, + policy, + workspace, + ) + .await?; + fetch_frozen_snapshot_on_channel(channel, sandbox, Some(workspace)).await +} + fn learned_workspace_selector(workspace: Option<&str>) -> Option { workspace.map(crate::proto::workspace_selector) } @@ -2446,3 +2550,205 @@ impl CachedOpenShellClient { Ok(()) } } + +#[cfg(test)] +mod frozen_snapshot_tests { + use super::*; + use crate::proto::frozen_sandbox_policies_server::{ + FrozenSandboxPolicies, FrozenSandboxPoliciesServer, + }; + use crate::proto::{ + CreateFrozenSandboxRequest, FrozenSandboxConfigResponse, FrozenSandboxPolicyState, + FrozenSandboxResponse, GetFrozenSandboxConfigRequest, GetFrozenSandboxPolicyRequest, + UpdateFrozenGlobalConfigRequest, UpdateFrozenGlobalConfigResponse, + }; + use std::convert::Infallible; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::codegen::http; + use tonic::{Request, Response, Status}; + + #[derive(Clone)] + struct FrozenConfigService { + response: std::result::Result, + calls: Arc, + } + + #[tonic::async_trait] + impl FrozenSandboxPolicies for FrozenConfigService { + async fn create_sandbox( + &self, + _: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_policy( + &self, + _: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn update_global_config( + &self, + _: Request, + ) -> std::result::Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn get_config( + &self, + request: Request, + ) -> std::result::Result, Status> { + assert_eq!( + request.metadata().get("authorization").unwrap(), + "Bearer test-token" + ); + assert_eq!(request.get_ref().name, "sandbox"); + self.calls.fetch_add(1, Ordering::SeqCst); + self.response.clone().map(Response::new) + } + } + + // A real tonic unary endpoint for the one legacy method this projection + // uses, without implementing unrelated OpenShell operations in the fixture. + #[derive(Clone)] + struct LegacyConfigService(Arc); + impl tonic::server::NamedService for LegacyConfigService { + const NAME: &'static str = "openshell.v1.OpenShell"; + } + impl tonic::codegen::Service> for LegacyConfigService { + type Response = http::Response; + type Error = Infallible; + type Future = tonic::codegen::BoxFuture; + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, request: http::Request) -> Self::Future { + let calls = self.0.clone(); + Box::pin(async move { + struct GetConfig(Arc); + impl tonic::server::UnaryService for GetConfig { + type Response = crate::proto::GetSandboxConfigResponse; + type Future = + std::future::Ready, Status>>; + fn call(&mut self, request: Request) -> Self::Future { + assert_eq!( + request.metadata().get("authorization").unwrap(), + "Bearer test-token" + ); + assert_eq!(request.get_ref().name, "sandbox"); + self.0.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(Response::new( + crate::proto::GetSandboxConfigResponse { + version: 3, + policy_hash: "legacy".into(), + workspace: "default".into(), + ..Default::default() + }, + ))) + } + } + assert_eq!( + request.uri().path(), + "/openshell.v1.OpenShell/GetSandboxConfig" + ); + Ok(tonic::server::Grpc::new(tonic_prost::ProstCodec::default()) + .unary(GetConfig(calls), request) + .await) + }) + } + } + + async fn gateway( + response: Option>, + ) -> (GatewayEndpoint, Arc, Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let legacy = Arc::new(AtomicUsize::new(0)); + let frozen = Arc::new(AtomicUsize::new(0)); + let router = tonic::transport::Server::builder() + .tcp_nodelay(true) + .add_service(LegacyConfigService(legacy.clone())); + if let Some(response) = response { + tokio::spawn( + router + .add_service(FrozenSandboxPoliciesServer::new(FrozenConfigService { + response, + calls: frozen.clone(), + })) + .serve_with_incoming(TcpListenerStream::new(listener)), + ); + } else { + tokio::spawn(router.serve_with_incoming(TcpListenerStream::new(listener))); + } + ( + GatewayEndpoint::new( + endpoint, + GatewayCredentials::from_gateway_token("test-token").unwrap(), + ), + legacy, + frozen, + ) + } + + #[tokio::test] + async fn frozen_snapshot_preserves_mode_and_supports_legacy_gateways() { + for captured in [false, true] { + let (gateway, legacy, frozen) = gateway(Some(Ok(FrozenSandboxConfigResponse { + config: Some(crate::proto::GetSandboxConfigResponse { + version: 7, + policy_hash: "captured".into(), + workspace: "default".into(), + ..Default::default() + }), + frozen: captured, + frozen_requested: true, + }))) + .await; + let snapshot = fetch_frozen_settings_snapshot(&gateway, "sandbox") + .await + .unwrap(); + assert_eq!(snapshot.version, 7); + assert_eq!(snapshot.policy_hash, "captured"); + assert_eq!(snapshot.frozen, captured); + assert!(snapshot.frozen_requested); + assert_eq!(frozen.load(Ordering::SeqCst), 1); + assert_eq!(legacy.load(Ordering::SeqCst), 0); + } + let (gateway, legacy, frozen) = gateway(None).await; + let snapshot = fetch_frozen_settings_snapshot(&gateway, "sandbox") + .await + .unwrap(); + assert_eq!(snapshot.version, 3); + assert!(!snapshot.frozen); + assert!(!snapshot.frozen_requested); + assert_eq!(legacy.load(Ordering::SeqCst), 1); + assert_eq!(frozen.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn frozen_snapshot_denials_and_inconsistent_state_never_use_legacy_fallback() { + for response in [ + Err(Status::permission_denied("denied")), + Ok(FrozenSandboxConfigResponse { + config: Some(crate::proto::GetSandboxConfigResponse::default()), + frozen: true, + frozen_requested: false, + }), + Ok(FrozenSandboxConfigResponse { + config: None, + frozen: true, + frozen_requested: true, + }), + ] { + let (gateway, legacy, frozen) = gateway(Some(response)).await; + assert!( + fetch_frozen_settings_snapshot(&gateway, "sandbox") + .await + .is_err() + ); + assert_eq!(frozen.load(Ordering::SeqCst), 1); + assert_eq!(legacy.load(Ordering::SeqCst), 0); + } + } +} diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 001b2e9249..d9dd4acf52 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -28,6 +28,25 @@ Code constructing complete raw Rust message literals must add Exhaustive destructuring patterns must include `service_routes` or end with `..`. The curated SDK API is unchanged. +## Frozen sandbox policies + +`create_frozen_sandbox(FrozenSandboxCreateSpec { sandbox, policy })` creates a +sandbox through the optional frozen-policy service. The template equivalent is +`create_frozen_sandbox_from_template(SandboxTemplateCreateSpec)`. Both are also +available on a workspace-scoped client. Existing create specs and sandbox +references retain their ordinary shape. + +Use `get_frozen_policy(name)` to distinguish a requested, pending freeze from +the captured policy, hash and timestamp. Once initialization accepts the first +configuration, subsequent reads return that snapshot. Changing its access +requires recreating the sandbox. `update_global_config(UpdateConfigRequest)` +returns the mutation result and names of sandboxes retaining a frozen policy. + +Older gateways return `Unimplemented` before creation; the SDK never retries +through ordinary creation. `raw_frozen_policies` and +`raw_frozen_policies_fresh` share the client's transport and live bearer slot, +with the same refresh distinction as the main raw client. + ## Auth and refresh The curated surface drives OIDC refresh automatically: proactively before a diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 8241ac49b5..6fc96880e8 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -12,12 +12,13 @@ use crate::auth::{BearerSlot, EdgeAuthInterceptor, bearer_metadata}; use crate::config::{AuthConfig, ClientConfig}; use crate::error::{Result, SdkError}; use crate::pagination::{Page, Pager}; -use crate::raw::{AuthedGrpcClient, AuthedSandboxUpdatesClient}; +use crate::raw::{AuthedFrozenSandboxPoliciesClient, AuthedGrpcClient, AuthedSandboxUpdatesClient}; use crate::refresh::{RefreshedToken, TokenSource}; use crate::routing::OwnerCache; use crate::types::{ - DeleteOptions, DeletionResult, ExecOptions, ExecResult, Health, ListOptions, ResumeOptions, - SandboxPhase, SandboxRef, SandboxSpec, SandboxTemplateCreateSpec, SandboxTemplateListOptions, + DeleteOptions, DeletionResult, ExecOptions, ExecResult, FrozenGlobalConfigUpdate, + FrozenPolicyState, FrozenSandboxCreateSpec, Health, ListOptions, ResumeOptions, SandboxPhase, + SandboxRef, SandboxSpec, SandboxTemplateCreateSpec, SandboxTemplateListOptions, SandboxWorkloadTemplate, WorkspaceRef, }; use crate::{WatchEvent, WatchOptions, transport}; @@ -50,6 +51,94 @@ pub struct OpenShellClient { } impl OpenShellClient { + /// Authenticated optional frozen-policy service; no proactive OIDC refresh. + pub fn raw_frozen_policies(&self) -> AuthedFrozenSandboxPoliciesClient { + proto::frozen_sandbox_policies_client::FrozenSandboxPoliciesClient::with_interceptor( + self.channel.clone(), + self.interceptor.clone(), + ) + } + + /// Optional frozen-policy service after proactive OIDC refresh. + pub async fn raw_frozen_policies_fresh(&self) -> Result { + self.ensure_fresh().await?; + Ok(self.raw_frozen_policies()) + } + + /// Create a frozen sandbox. Older gateways fail before creating a sandbox. + pub async fn create_frozen_sandbox(&self, spec: FrozenSandboxCreateSpec) -> Result { + self.workspace("default").create_frozen_sandbox(spec).await + } + + /// Create a frozen sandbox from a workspace-scoped workload template. + pub async fn create_frozen_sandbox_from_template( + &self, + spec: SandboxTemplateCreateSpec, + ) -> Result { + self.workspace("default") + .create_frozen_sandbox_from_template(spec) + .await + } + + /// Read the requested freeze and accepted policy in the default workspace. + pub async fn get_frozen_policy(&self, name: &str) -> Result { + self.workspace("default").get_frozen_policy(name).await + } + + /// Apply a global configuration update and report frozen-policy exceptions. + pub async fn update_global_config( + &self, + request: proto::UpdateConfigRequest, + ) -> Result { + let request = proto::UpdateFrozenGlobalConfigRequest { + workspace_scope: request.workspace_scope, + sandbox: request.sandbox, + policy: request.policy, + setting_key: request.setting_key, + setting_value: request.setting_value, + delete_setting: request.delete_setting, + global: true, + merge_operations: request.merge_operations, + expected_resource_version: request.expected_resource_version, + annotations: request.annotations, + request_id: request.request_id, + }; + let response = self + .frozen_unary(|mut grpc| { + let request = request.clone(); + async move { grpc.update_global_config(request).await } + }) + .await?; + Ok(FrozenGlobalConfigUpdate { + update: response.update.ok_or_else(|| { + SdkError::invalid_config("global update result missing from gateway response") + })?, + frozen_sandboxes: response.frozen_sandboxes, + }) + } + + async fn frozen_unary(&self, call: F) -> Result + where + F: Fn(AuthedFrozenSandboxPoliciesClient) -> Fut, + Fut: Future, tonic::Status>>, + { + self.ensure_fresh().await?; + match call(self.raw_frozen_policies()).await { + Ok(response) => Ok(response.into_inner()), + Err(status) if status.code() == tonic::Code::Unauthenticated => { + if self.refresh_on_unauthorized().await? { + call(self.raw_frozen_policies()) + .await + .map(tonic::Response::into_inner) + .map_err(map_status) + } else { + Err(map_status(status)) + } + } + Err(status) => Err(map_status(status)), + } + } + /// Open a connection to the gateway described by `config`. /// /// Performs the gRPC channel handshake immediately; subsequent RPCs reuse @@ -1008,6 +1097,71 @@ pub struct WorkspaceScopedClient { } impl WorkspaceScopedClient { + /// Create a frozen sandbox in this workspace using a distinct optional RPC. + pub async fn create_frozen_sandbox(&self, spec: FrozenSandboxCreateSpec) -> Result { + let mut request = create_sandbox_request(spec.sandbox); + request + .spec + .as_mut() + .expect("create request has a spec") + .policy = spec.policy; + self.create_frozen(request).await + } + + /// Create a frozen sandbox from a workload template in this workspace. + pub async fn create_frozen_sandbox_from_template( + &self, + spec: SandboxTemplateCreateSpec, + ) -> Result { + self.create_frozen(create_sandbox_from_template_request(spec)) + .await + } + + async fn create_frozen(&self, request: proto::CreateSandboxRequest) -> Result { + let request = proto::CreateFrozenSandboxRequest { + workspace_scope: Some(proto::workspace_selector(&self.workspace)), + request_id: request.request_id, + spec: request.spec, + name: request.name, + labels: request.labels, + annotations: request.annotations, + await_main_process_attachment: request.await_main_process_attachment, + workload_template: request.workload_template, + service_exposures: request.service_exposures, + }; + let response = self + .client + .frozen_unary(|mut grpc| { + let request = request.clone(); + async move { grpc.create_sandbox(request).await } + }) + .await?; + let mut sandbox = sandbox_from_response(response.sandbox)?; + if !response.frozen_policy.is_some_and(|state| state.requested) { + return Err(SdkError::invalid_config(format!( + "gateway did not acknowledge frozen creation for sandbox '{}' (ID '{}', workspace '{}'); remove the sandbox before retrying", + sandbox.name, sandbox.id, sandbox.workspace, + ))); + } + sandbox.service_urls = response.service_urls; + Ok(sandbox) + } + + /// Read a pending freeze or the immutable accepted snapshot in this workspace. + pub async fn get_frozen_policy(&self, name: &str) -> Result { + let request = proto::GetFrozenSandboxPolicyRequest { + workspace_scope: Some(proto::workspace_selector(&self.workspace)), + name: name.to_string(), + }; + self.client + .frozen_unary(|mut grpc| { + let request = request.clone(); + async move { grpc.get_policy(request).await } + }) + .await + .map(Into::into) + } + /// The workspace this client targets. pub fn workspace_name(&self) -> &str { &self.workspace diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index bede009107..c996db7f7b 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -51,9 +51,10 @@ pub use error::SdkError; pub use pagination::{Page, Pager}; pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource}; pub use types::{ - DeleteOptions, DeletionOutcome, DeletionResult, ExecOptions, ExecResult, Health, ListOptions, - LogLine, PlatformEvent, ResumeOptions, SandboxPhase, SandboxRef, SandboxResources, - SandboxRestartPolicy, SandboxServiceLevel, SandboxSpec, SandboxStartup, + DeleteOptions, DeletionOutcome, DeletionResult, ExecOptions, ExecResult, + FrozenGlobalConfigUpdate, FrozenPolicyState, FrozenSandboxCreateSpec, FrozenSandboxPolicy, + Health, ListOptions, LogLine, PlatformEvent, ResumeOptions, SandboxPhase, SandboxRef, + SandboxResources, SandboxRestartPolicy, SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec, SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceExposure, ServiceStatus, WatchEvent, WatchOptions, diff --git a/crates/openshell-sdk/src/raw.rs b/crates/openshell-sdk/src/raw.rs index 15e26d52cc..735e6be6f9 100644 --- a/crates/openshell-sdk/src/raw.rs +++ b/crates/openshell-sdk/src/raw.rs @@ -19,22 +19,27 @@ //! ``` pub use openshell_core::proto; +pub use openshell_core::proto::frozen_sandbox_policies_client::FrozenSandboxPoliciesClient; pub use openshell_core::proto::open_shell_client::OpenShellClient as GrpcClient; pub use openshell_core::proto::sandbox_updates_client::SandboxUpdatesClient; pub use openshell_core::proto::{ - CreateSandboxRequest, CreateSandboxTemplateRequest, CreateWorkspaceRequest, - DeleteSandboxRequest, DeleteSandboxTemplateRequest, DeleteWorkspaceRequest, ExecSandboxRequest, - GetSandboxProviderStatusRequest, GetSandboxProviderStatusResponse, GetSandboxRequest, - GetSandboxTemplateRequest, GetWorkspaceRequest, HealthRequest, ListProvidersRequest, - ListSandboxTemplatesRequest, ListSandboxesRequest, ListWorkspacesRequest, - ProviderDesiredIdentity, ProviderMutationKind, ProviderMutationReceipt, - ProviderReadinessObservation, ProviderReadinessReason, ProviderReadinessState, - ProviderReadinessStatus, ResumeSandboxRequest, Sandbox, SandboxCommandUpdate, SandboxMapUpdate, - SandboxPhase as ProtoSandboxPhase, SandboxResources, SandboxServiceLevel, - SandboxSpec as ProtoSandboxSpec, SandboxStartup, SandboxTemplate, SandboxTemplateResponse, - SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, - SandboxWorkloadTemplateSpec, ServiceStatus as ProtoServiceStatus, StartSandboxRequest, - StopSandboxRequest, SuspendSandboxRequest, UpdateSandboxRequest, Workspace, + CreateFrozenSandboxRequest, CreateSandboxRequest, CreateSandboxTemplateRequest, + CreateWorkspaceRequest, DeleteSandboxRequest, DeleteSandboxTemplateRequest, + DeleteWorkspaceRequest, ExecSandboxRequest, FrozenSandboxConfigResponse, + FrozenSandboxPolicyState, FrozenSandboxResponse, GetFrozenSandboxConfigRequest, + GetFrozenSandboxPolicyRequest, GetSandboxProviderStatusRequest, + GetSandboxProviderStatusResponse, GetSandboxRequest, GetSandboxTemplateRequest, + GetWorkspaceRequest, HealthRequest, ListProvidersRequest, ListSandboxTemplatesRequest, + ListSandboxesRequest, ListWorkspacesRequest, ProviderDesiredIdentity, ProviderMutationKind, + ProviderMutationReceipt, ProviderReadinessObservation, ProviderReadinessReason, + ProviderReadinessState, ProviderReadinessStatus, ResumeSandboxRequest, Sandbox, + SandboxCommandUpdate, SandboxMapUpdate, SandboxPhase as ProtoSandboxPhase, SandboxResources, + SandboxServiceLevel, SandboxSpec as ProtoSandboxSpec, SandboxStartup, SandboxTemplate, + SandboxTemplateResponse, SandboxWorkloadConfig, SandboxWorkloadTemplate, + SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, + ServiceStatus as ProtoServiceStatus, StartSandboxRequest, StopSandboxRequest, + SuspendSandboxRequest, UpdateFrozenGlobalConfigRequest, UpdateFrozenGlobalConfigResponse, + UpdateSandboxRequest, Workspace, }; /// Type alias for the gRPC client wrapped in the SDK's auth interceptor. @@ -53,3 +58,11 @@ pub type AuthedSandboxUpdatesClient = SandboxUpdatesClient< crate::EdgeAuthInterceptor, >, >; + +/// Frozen-policy service sharing the client's transport and live auth slot. +pub type AuthedFrozenSandboxPoliciesClient = FrozenSandboxPoliciesClient< + tonic::service::interceptor::InterceptedService< + tonic::transport::Channel, + crate::EdgeAuthInterceptor, + >, +>; diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 571ee42b68..a4cbe8b98a 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -301,6 +301,63 @@ pub struct SandboxSpec { pub restart_policy: SandboxRestartPolicy, } +/// Create a sandbox whose first accepted effective policy is retained for its lifetime. +#[derive(Clone, Debug, Default)] +pub struct FrozenSandboxCreateSpec { + pub sandbox: SandboxSpec, + /// Omit to discover the initial policy from the image. + pub policy: Option, +} + +/// Freeze request and accepted snapshot, queried independently of sandbox status. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct FrozenPolicyState { + pub requested: bool, + /// Absent until initialization successfully accepts a configuration. + pub policy: Option, +} + +/// Immutable accepted policy and its provenance. Provider authorization remains raw-only. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct FrozenSandboxPolicy { + pub policy: Option, + pub version: u32, + pub policy_hash: String, + pub frozen_at_ms: Option, + pub policy_source: proto::PolicySource, + pub global_policy_version: u32, +} + +impl From for FrozenPolicyState { + fn from(value: proto::FrozenSandboxPolicyState) -> Self { + Self { + requested: value.requested, + policy: value.policy.map(|policy| FrozenSandboxPolicy { + policy: policy.policy, + version: policy.version, + policy_hash: policy.policy_hash, + frozen_at_ms: policy + .frozen_time + .as_ref() + .and_then(|time| openshell_core::time::timestamp_to_millis(time).ok()), + policy_source: proto::PolicySource::try_from(policy.policy_source) + .unwrap_or_default(), + global_policy_version: policy.global_policy_version, + }), + } + } +} + +/// Global mutation result including sandboxes retaining their frozen policy. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct FrozenGlobalConfigUpdate { + pub update: proto::UpdateConfigResponse, + pub frozen_sandboxes: Vec, +} + /// A loopback HTTP service to expose during sandbox creation. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ServiceExposure { diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 1f3f791a08..bd4b10ac84 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -9,6 +9,9 @@ //! `mtls_integration` and OIDC tests. use openshell_core::proto; +use openshell_core::proto::frozen_sandbox_policies_server::{ + FrozenSandboxPolicies, FrozenSandboxPoliciesServer, +}; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::sandbox_updates_server::{SandboxUpdates, SandboxUpdatesServer}; use openshell_core::replica_routing; @@ -48,6 +51,12 @@ fn selects_all_workspaces(scope: &Option>, + frozen_create_response: Option, + frozen_policy: Option, + last_frozen_get: Mutex>, + last_frozen_global_update: Mutex>, last_get_name: Mutex>, last_get_workspace: Mutex>, last_create: Mutex>, @@ -1195,6 +1204,88 @@ impl SandboxUpdates for TestOpenShell { } } +#[tonic::async_trait] +impl FrozenSandboxPolicies for TestOpenShell { + async fn create_sandbox( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.check_frozen_auth(&request)?; + let request = request.into_inner(); + let response = proto::FrozenSandboxResponse { + sandbox: Some(sandbox_with_phase_ws( + &request.name, + proto::SandboxPhase::Provisioning, + selected_workspace(&request.workspace_scope).unwrap_or("default"), + )), + frozen_policy: self.state.frozen_create_response.clone(), + service_urls: request + .service_exposures + .iter() + .map(|exposure| { + ( + exposure.service.clone(), + format!("https://{}.example.test/", exposure.service), + ) + }) + .collect(), + }; + *self.state.last_frozen_create.lock().await = Some(request); + Ok(Response::new(response)) + } + + async fn get_policy( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.check_frozen_auth(&request)?; + *self.state.last_frozen_get.lock().await = Some(request.into_inner()); + Ok(Response::new(proto::FrozenSandboxPolicyState { + requested: true, + policy: self.state.frozen_policy.clone(), + })) + } + + async fn get_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "runtime config is not used by the curated SDK", + )) + } + + async fn update_global_config( + &self, + request: tonic::Request, + ) -> Result, Status> { + self.check_frozen_auth(&request)?; + *self.state.last_frozen_global_update.lock().await = Some(request.into_inner()); + Ok(Response::new(proto::UpdateFrozenGlobalConfigResponse { + update: Some(proto::UpdateConfigResponse { + version: 7, + ..Default::default() + }), + frozen_sandboxes: vec!["staging/frozen".into()], + })) + } +} + +impl TestOpenShell { + fn check_frozen_auth(&self, request: &tonic::Request) -> Result<(), Status> { + if let Some(expected) = &self.state.require_bearer { + let got = request + .metadata() + .get("authorization") + .and_then(|value| value.to_str().ok()); + if got != Some(expected.as_str()) { + return Err(Status::unauthenticated("invalid or missing bearer token")); + } + } + Ok(()) + } +} + /// Spin up the mock gateway, return its endpoint URL. async fn start_mock(state: Arc) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1204,11 +1295,17 @@ async fn start_mock(state: Arc) -> String { let updates = SandboxUpdatesServer::new(TestOpenShell { state: state.clone(), }); + let frozen = (!state.omit_frozen_service).then(|| { + FrozenSandboxPoliciesServer::new(TestOpenShell { + state: state.clone(), + }) + }); let svc = OpenShellServer::new(TestOpenShell { state }); tokio::spawn(async move { let _ = tonic::transport::Server::builder() .add_service(svc) .add_service(updates) + .add_optional_service(frozen) .serve_with_incoming(stream) .await; }); @@ -2740,3 +2837,199 @@ async fn watch_logs_addresses_the_sandbox_by_canonical_name() { ); } } + +#[tokio::test] +async fn frozen_creation_preserves_request_and_requires_acknowledgement() { + use openshell_sdk::FrozenSandboxCreateSpec; + for acknowledged in [None, Some(false), Some(true)] { + let state = Arc::new(MockState { + frozen_create_response: acknowledged.map(|requested| proto::FrozenSandboxPolicyState { + requested, + ..Default::default() + }), + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + for (workspace, template) in [ + ("default", false), + ("default", true), + ("staging", false), + ("staging", true), + ] { + let policy = proto::SandboxPolicy { + version: 1, + ..Default::default() + }; + let spec = SandboxSpec { + name: Some("frozen".into()), + providers: vec!["github".into()], + service_exposures: vec![ServiceExposure { + service: "web".into(), + target_port: 8080, + ..Default::default() + }], + ..Default::default() + }; + let template_spec = SandboxTemplateCreateSpec { + name: Some("frozen".into()), + template_name: "agent".into(), + policy: Some(policy.clone()), + providers: spec.providers.clone(), + service_exposures: spec.service_exposures.clone(), + ..Default::default() + }; + let frozen_spec = FrozenSandboxCreateSpec { + sandbox: spec, + policy: Some(policy), + }; + let created = match (workspace, template) { + ("default", false) => client.create_frozen_sandbox(frozen_spec).await, + ("default", true) => { + client + .create_frozen_sandbox_from_template(template_spec) + .await + } + (_, false) => { + client + .workspace(workspace) + .create_frozen_sandbox(frozen_spec) + .await + } + (_, true) => { + client + .workspace(workspace) + .create_frozen_sandbox_from_template(template_spec) + .await + } + }; + if acknowledged == Some(true) { + assert_eq!( + created + .expect("acknowledged freeze may be pending") + .service_urls + .get("web") + .map(String::as_str), + Some("https://web.example.test/") + ); + } else { + let error = created.expect_err("missing acknowledgement must fail"); + assert!( + error + .to_string() + .contains("did not acknowledge frozen creation") + ); + assert!(error.to_string().contains("frozen")); + } + let request = state.last_frozen_create.lock().await.clone().unwrap(); + assert_eq!( + selected_workspace(&request.workspace_scope), + Some(workspace) + ); + assert_eq!( + request.workload_template, + if template { "agent" } else { "" } + ); + let spec = request.spec.unwrap(); + assert_eq!(spec.policy.unwrap().version, 1); + assert_eq!(spec.providers, ["github"]); + assert!(state.last_create.lock().await.is_none()); + assert!(state.last_delete_name.lock().await.is_none()); + } + } +} + +#[tokio::test] +async fn frozen_creation_on_older_gateway_never_uses_ordinary_create() { + let state = Arc::new(MockState { + omit_frozen_service: true, + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let error = connect(&endpoint) + .await + .create_frozen_sandbox(openshell_sdk::FrozenSandboxCreateSpec::default()) + .await + .expect_err("old gateway lacks service"); + assert_eq!( + error.grpc_status().map(Status::code), + Some(tonic::Code::Unimplemented) + ); + assert!(state.last_create.lock().await.is_none()); +} + +#[tokio::test] +async fn frozen_policy_reads_and_global_updates_share_authentication() { + let state = Arc::new(MockState { + require_bearer: Some("Bearer test-token".into()), + frozen_policy: Some(proto::FrozenSandboxPolicy { + policy: Some(proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + version: 3, + policy_hash: "accepted-hash".into(), + frozen_time: openshell_core::time::timestamp_from_millis(600_000).ok(), + policy_source: proto::PolicySource::Global as i32, + global_policy_version: 7, + ..Default::default() + }), + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let mut config = ClientConfig::new(endpoint); + config.auth = Some(AuthConfig::oidc("test-token")); + let client = OpenShellClient::connect(config).await.unwrap(); + let captured = client + .workspace("staging") + .get_frozen_policy("frozen") + .await + .unwrap(); + assert!(captured.requested); + let policy = captured.policy.unwrap(); + assert_eq!(policy.policy_hash, "accepted-hash"); + assert_eq!(policy.frozen_at_ms, Some(600_000)); + assert_eq!(policy.policy.unwrap().version, 1); + assert_eq!(policy.policy_source, proto::PolicySource::Global); + assert_eq!( + selected_workspace( + &state + .last_frozen_get + .lock() + .await + .as_ref() + .unwrap() + .workspace_scope + ), + Some("staging") + ); + let result = client + .update_global_config(proto::UpdateConfigRequest { + policy: Some(proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(result.update.version, 7); + assert_eq!(result.frozen_sandboxes, ["staging/frozen"]); + assert!( + state + .last_frozen_global_update + .lock() + .await + .as_ref() + .unwrap() + .global + ); + let pending_state = Arc::new(MockState::default()); + let endpoint = start_mock(pending_state).await; + let pending = connect(&endpoint) + .await + .get_frozen_policy("pending") + .await + .unwrap(); + assert!(pending.requested && pending.policy.is_none()); +} diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 3c52ebb127..88ff35766b 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -18,6 +18,30 @@ Retries reuse that generation and session, including after gateway restart. Credential renewal retains both; credentials for an older launch cannot authorize the new one. +## Frozen sandbox policy + +`FrozenSandboxPolicies.CreateSandbox` chooses frozen mode atomically with +creation, using a gateway-private `StoredFrozenSandboxPolicy` record keyed by +sandbox ID. First unary configuration acceptance captures the policy and +non-secret provider authorization in the same transaction as the sandbox CAS. +Session admission cannot activate a pending freeze. Existing public sandbox +messages and the `PolicySource` enum retain their source shape. + +Configuration, credential and token delivery share the captured policy and +profile catalog. Reads spanning first capture reload that authority before +returning. Credential values remain fresh; changed provider identity, +configuration or refresh authorization withholds credentials. Policy writes +are fenced again inside their storage transaction; sandbox deletion removes +the private record atomically. + +The additive runtime configuration response carries requested and captured +state together. A captured policy cannot gain implicit filesystem paths on +restart. An incompatible runtime rejects launch with recreation guidance; +initial frozen global policies must already contain required proxy paths. +All gateways sharing the store must support freezing before use. Older binary +versions do not enforce the private feature, so mixed-version fleets and +subsequent downgrade are unsupported while frozen sandboxes exist. + ## Shared supervisor pool The `SharedSupervision` the gateway hands a compute driver that asks for it diff --git a/crates/openshell-server/build.rs b/crates/openshell-server/build.rs index 6230f8d259..da5d512db8 100644 --- a/crates/openshell-server/build.rs +++ b/crates/openshell-server/build.rs @@ -31,9 +31,11 @@ fn main() -> Result<(), Box> { let public_proto_dir = manifest_dir.join("../../proto"); let storage_proto = storage_proto_dir.join("storage.proto"); let image_proto = storage_proto_dir.join("sandbox_image.proto"); + let frozen_proto = storage_proto_dir.join("sandbox_frozen_policy.proto"); println!("cargo:rerun-if-changed={}", storage_proto.display()); println!("cargo:rerun-if-changed={}", image_proto.display()); + println!("cargo:rerun-if-changed={}", frozen_proto.display()); for imported_proto in [ "datamodel.proto", "openshell.proto", @@ -73,7 +75,11 @@ fn main() -> Result<(), Box> { "::openshell_core::proto::sandbox::v1", ) .file_descriptor_set_path(&descriptor_path) - .compile_with_config(proto_config, &[storage_proto, image_proto], &include_dirs)?; + .compile_with_config( + proto_config, + &[storage_proto, image_proto, frozen_proto], + &include_dirs, + )?; Ok(()) } diff --git a/crates/openshell-server/proto/sandbox_frozen_policy.proto b/crates/openshell-server/proto/sandbox_frozen_policy.proto new file mode 100644 index 0000000000..bbbe377ead --- /dev/null +++ b/crates/openshell-server/proto/sandbox_frozen_policy.proto @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +syntax = "proto3"; + +// Gateway-private freeze state. Existing public sandbox rows and frozen +// openshell.storage.v1 declarations remain unchanged. +package openshell.storage.frozen.v1; +import "openshell.proto"; + +// Existence chooses frozen mode at creation; an absent policy is pending. +// The snapshot is captured once in the same transaction as first acceptance. +message StoredFrozenSandboxPolicy { + string sandbox_id = 1; + openshell.v1.FrozenSandboxPolicy policy = 2; +} diff --git a/crates/openshell-server/src/auth/descriptor_authz.rs b/crates/openshell-server/src/auth/descriptor_authz.rs index 13e669a27b..abd5fce976 100644 --- a/crates/openshell-server/src/auth/descriptor_authz.rs +++ b/crates/openshell-server/src/auth/descriptor_authz.rs @@ -298,6 +298,49 @@ mod tests { ); } + #[test] + fn frozen_policy_methods_keep_distinct_user_and_sandbox_access() { + let table = + DescriptorAuthTable::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + for (method, mode, scope, workspace_role, global_role) in [ + ( + "CreateSandbox", + AuthMode::Bearer, + "sandbox:write", + Some("user"), + None, + ), + ( + "GetPolicy", + AuthMode::Bearer, + "sandbox:read", + Some("user"), + None, + ), + ( + "GetConfig", + AuthMode::Dual, + "config:read", + Some("user"), + None, + ), + ( + "UpdateGlobalConfig", + AuthMode::Bearer, + "config:write", + None, + Some("platform_admin"), + ), + ] { + let path = format!("/openshell.v1.FrozenSandboxPolicies/{method}"); + let entry = table.entries.get(&path).unwrap(); + assert_eq!(entry.auth_mode, mode, "{path}"); + assert_eq!(entry.scope.as_deref(), Some(scope), "{path}"); + assert_eq!(entry.workspace_role.as_deref(), workspace_role, "{path}"); + assert_eq!(entry.global_role.as_deref(), global_role, "{path}"); + } + } + #[test] fn no_duplicate_paths() { let paths: Vec<&str> = all_paths().collect(); diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index a6768040e6..9e5cd76268 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -116,6 +116,7 @@ use provisioning_operation::ProvisioningOperationError; /// creation without expanding the public compute-driver protobuf contract. #[derive(Clone, Default)] pub struct SandboxCreateRuntimeInputs { + pub freeze_policy: bool, pub effective_policy: Option, pub launch_authentication: Option>, } @@ -126,6 +127,7 @@ impl SandboxCreateRuntimeInputs { Self { effective_policy: Some(effective_policy), launch_authentication: None, + freeze_policy: false, } } } @@ -1698,31 +1700,34 @@ impl ComputeRuntime { .map_err(|e| Status::internal(format!("failed to serialize labels: {e}")))?, ) }; - let result = self - .store - .put_if( - Sandbox::object_type(), - &sandbox_id, - sandbox.object_name(), - sandbox.object_workspace(), - &sandbox.encode_to_vec(), - labels_json.as_deref(), - WriteCondition::MustCreate, - ) - .await - .map_err(|e| { - if matches!( - e, - crate::persistence::PersistenceError::UniqueViolation { .. } - ) { - Status::already_exists(format!( - "sandbox '{}' already exists", - sandbox.object_name() - )) - } else { - Status::internal(format!("persist sandbox failed: {e}")) - } - })?; + let result = if runtime_inputs.freeze_policy { + self.store.create_frozen_sandbox(&sandbox).await + } else { + self.store + .put_if( + Sandbox::object_type(), + &sandbox_id, + sandbox.object_name(), + sandbox.object_workspace(), + &sandbox.encode_to_vec(), + labels_json.as_deref(), + WriteCondition::MustCreate, + ) + .await + } + .map_err(|e| { + if matches!( + e, + crate::persistence::PersistenceError::UniqueViolation { .. } + ) { + Status::already_exists(format!( + "sandbox '{}' already exists", + sandbox.object_name() + )) + } else { + Status::internal(format!("persist sandbox failed: {e}")) + } + })?; if let Some(metadata) = sandbox.metadata.as_mut() { metadata.resource_version = result.resource_version; } @@ -5905,6 +5910,21 @@ impl ComputeRuntime { terminal_delivery_finalized, } => (false, None, None, terminal_delivery_finalized, false), }; + // Frozen mode is creation-only and capture is monotone. A streamed + // acceptance may confirm a captured policy, but first acceptance must + // use the unary path that atomically captures its effective inputs. + if admission.is_some_and(|admission| { + admission.state + == i32::from(openshell_core::proto::ConfigurationAdmissionState::Accepted) + }) && self + .store + .get_sandbox_frozen_policy(sandbox_id) + .await + .map_err(|error| error.to_string())? + .is_some_and(|state| state.policy.is_none()) + { + return Err("frozen sandbox must first accept configuration through ReportSandboxConfiguration before session acceptance".into()); + } for attempt in 1..=SUPERVISOR_SESSION_CAS_RETRY_LIMIT { let Some(current) = existing else { return Ok(()); diff --git a/crates/openshell-server/src/grpc/frozen_policy.rs b/crates/openshell-server/src/grpc/frozen_policy.rs new file mode 100644 index 0000000000..8bd1bdd448 --- /dev/null +++ b/crates/openshell-server/src/grpc/frozen_policy.rs @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Additive creation and inspection APIs for gateway-owned policy freezing. + +use super::{OpenShellService, extract_principal, mutation_replay, policy, sandbox}; +use crate::ServerState; +use crate::auth::workspace_authz::{MinWorkspaceRole, selected_workspace_name}; +use openshell_core::ObjectId; +use openshell_core::proto::{ + CreateFrozenSandboxRequest, CreateSandboxRequest, FrozenSandboxConfigResponse, + FrozenSandboxPolicyState, FrozenSandboxResponse, GetFrozenSandboxConfigRequest, + GetFrozenSandboxPolicyRequest, GetSandboxConfigRequest, UpdateConfigRequest, + UpdateFrozenGlobalConfigRequest, UpdateFrozenGlobalConfigResponse, +}; +use std::sync::Arc; +use tonic::{Request, Response, Status}; + +#[derive(Clone)] +pub(super) struct FrozenSandboxes(pub Vec); + +pub(super) async fn state_for( + store: &crate::persistence::Store, + sandbox_id: &str, +) -> Result { + let state = store + .get_sandbox_frozen_policy(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch frozen policy failed: {error}")))?; + Ok(FrozenSandboxPolicyState { + requested: state.is_some(), + policy: state.and_then(|state| state.policy), + }) +} + +#[tonic::async_trait] +impl openshell_core::proto::frozen_sandbox_policies_server::FrozenSandboxPolicies + for OpenShellService +{ + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + mutation_replay::run(&self.state, request).await + } + + async fn get_policy( + &self, + request: Request, + ) -> Result, Status> { + let principal = extract_principal(&request)?; + let request = request.into_inner(); + let sandbox = sandbox::resolve_and_authorize_sandbox_name( + &self.state, + &principal, + &request.name, + selected_workspace_name(request.workspace_scope.as_ref())?, + MinWorkspaceRole::User, + ) + .await?; + Ok(Response::new( + state_for(self.state.store.as_ref(), sandbox.object_id()).await?, + )) + } + + async fn get_config( + &self, + request: Request, + ) -> Result, Status> { + let (response, frozen, frozen_requested) = policy::handle_get_sandbox_config_with_frozen( + &self.state, + request.map(|request| GetSandboxConfigRequest { + name: request.name, + workspace_scope: request.workspace_scope, + }), + ) + .await?; + Ok(response.map(|config| FrozenSandboxConfigResponse { + config: Some(config), + frozen, + frozen_requested, + })) + } + + async fn update_global_config( + &self, + request: Request, + ) -> Result, Status> { + mutation_replay::run(&self.state, request).await + } +} + +pub(super) async fn handle_create( + state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.map(|request| CreateSandboxRequest { + workspace_scope: request.workspace_scope, + spec: request.spec, + name: request.name, + labels: request.labels, + annotations: request.annotations, + await_main_process_attachment: request.await_main_process_attachment, + workload_template: request.workload_template, + request_id: request.request_id, + service_exposures: request.service_exposures, + }); + let response = sandbox::handle_create_sandbox_with_mode(state, request, true).await?; + let sandbox = response + .get_ref() + .sandbox + .as_ref() + .ok_or_else(|| Status::internal("created sandbox is missing"))?; + let frozen_policy = state_for(state.store.as_ref(), sandbox.object_id()).await?; + Ok(response.map(|response| FrozenSandboxResponse { + sandbox: response.sandbox, + frozen_policy: Some(frozen_policy), + service_urls: response.service_urls, + })) +} + +pub(super) fn validate_global_request( + request: &UpdateFrozenGlobalConfigRequest, +) -> Result<(), Status> { + if !request.global { + return Err(Status::invalid_argument("global must be true")); + } + if request.workspace_scope.is_some() + || !request.sandbox.is_empty() + || !request.merge_operations.is_empty() + || !request.annotations.is_empty() + || request.expected_resource_version != 0 + { + return Err(Status::invalid_argument( + "sandbox, workspace_scope, merge_operations, annotations, and expected_resource_version are unsupported for global configuration", + )); + } + Ok(()) +} + +pub(super) async fn handle_update_global( + state: &Arc, + request: Request, +) -> Result, Status> { + validate_global_request(request.get_ref())?; + let response = policy::handle_update_config( + state, + request.map(|request| UpdateConfigRequest { + workspace_scope: request.workspace_scope, + policy: request.policy, + setting_key: request.setting_key, + setting_value: request.setting_value, + delete_setting: request.delete_setting, + global: request.global, + merge_operations: request.merge_operations, + expected_resource_version: request.expected_resource_version, + annotations: request.annotations, + sandbox: request.sandbox, + request_id: request.request_id, + }), + ) + .await?; + let names = response + .extensions() + .get::() + .map_or_else(Vec::new, |names| names.0.clone()); + Ok(response.map(|update| UpdateFrozenGlobalConfigResponse { + update: Some(update), + frozen_sandboxes: names, + })) +} diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 69208348de..eb1169d205 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -4,6 +4,7 @@ //! gRPC service implementation. mod auth_rpc; +mod frozen_policy; pub mod mutation_replay; pub mod policy; pub mod provider; diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs index a88c6abb9d..a6efe3be3c 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs @@ -197,6 +197,13 @@ pub(in crate::grpc) enum Outcome { skipped: u32, cleared: u32, }, + FrozenGlobalConfig { + version: u32, + policy_hash: String, + settings_revision: u64, + deleted: bool, + frozen_sandboxes: Vec, + }, Refresh(Refresh), } @@ -474,6 +481,95 @@ scoped_mutation!( }) } ); +mutation!( + openshell_core::proto::CreateFrozenSandboxRequest, + openshell_core::proto::FrozenSandboxResponse, + "CreateFrozenSandbox", + crate::grpc::frozen_policy::handle_create, + async |req: &openshell_core::proto::CreateFrozenSandboxRequest, + state: &ServerState, + principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await + }, + |response: &Response| sandbox_receipt( + response.get_ref().sandbox.as_ref(), + false, + response.get_ref().service_urls.clone(), + ), + async |store: &Store, outcome: Outcome| { + let Outcome::Sandbox { + id, service_urls, .. + } = outcome + else { + return Err(replay_unavailable()); + }; + let sandbox: Sandbox = live(store, &id).await?; + let frozen_policy = crate::grpc::frozen_policy::state_for(store, &id).await?; + if !frozen_policy.requested { + return Err(replay_unavailable()); + } + Ok(openshell_core::proto::FrozenSandboxResponse { + sandbox: Some(sandbox), + frozen_policy: Some(frozen_policy), + service_urls, + }) + }, + "/openshell.v1.FrozenSandboxPolicies/CreateSandbox" +); +mutation!( + openshell_core::proto::UpdateFrozenGlobalConfigRequest, + openshell_core::proto::UpdateFrozenGlobalConfigResponse, + "UpdateFrozenGlobalConfig", + crate::grpc::frozen_policy::handle_update_global, + async |req: &openshell_core::proto::UpdateFrozenGlobalConfigRequest, + state: &ServerState, + principal: &Principal| { + crate::grpc::frozen_policy::validate_global_request(req)?; + global_scope(state, principal) + }, + |response: &Response| { + if !facts(response)?.global { + return Err(uncertain()); + } + let update = response.get_ref().update.as_ref().ok_or_else(uncertain)?; + Ok(Outcome::FrozenGlobalConfig { + version: update.version, + policy_hash: update.policy_hash.clone(), + settings_revision: update.settings_revision, + deleted: update.deleted, + frozen_sandboxes: response.get_ref().frozen_sandboxes.clone(), + }) + }, + async |_store: &Store, outcome: Outcome| { + let Outcome::FrozenGlobalConfig { + version, + policy_hash, + settings_revision, + deleted, + frozen_sandboxes, + } = outcome + else { + return Err(replay_unavailable()); + }; + Ok(openshell_core::proto::UpdateFrozenGlobalConfigResponse { + update: Some(UpdateConfigResponse { + version, + policy_hash, + settings_revision, + deleted, + annotations: HashMap::new(), + }), + frozen_sandboxes, + }) + }, + "/openshell.v1.FrozenSandboxPolicies/UpdateGlobalConfig" +); sandbox_mutation!( openshell_core::proto::UpdateSandboxRequest, "UpdateSandbox", diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs index f0867a97e0..9665c68afd 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -24,6 +24,83 @@ async fn protected_state() -> (tempfile::TempDir, Arc) { (directory, state) } +#[tokio::test] +async fn global_policy_replay_preserves_frozen_sandbox_report() { + let (_directory, state) = protected_state().await; + let policy = openshell_policy::restrictive_default_policy(); + let sandbox = Sandbox { + metadata: Some(meta("frozen-report")), + ..Default::default() + }; + state.store.create_frozen_sandbox(&sandbox).await.unwrap(); + let stored = state + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + state + .store + .accept_frozen_sandbox_configuration( + stored, + openshell_core::proto::FrozenSandboxPolicy { + policy: Some(policy.clone()), + policy_hash: openshell_core::policy_identity::deterministic_policy_hash(&policy), + policy_source: openshell_core::proto::PolicySource::Sandbox.into(), + ..Default::default() + }, + ) + .await + .unwrap(); + let request = openshell_core::proto::UpdateFrozenGlobalConfigRequest { + request_id: id(), + global: true, + policy: Some(policy), + ..Default::default() + }; + let service = crate::grpc::OpenShellService::new(Arc::clone(&state)); + for field in [ + "workspace_scope", + "sandbox", + "merge_operations", + "annotations", + "expected_resource_version", + ] { + let mut invalid = request.clone(); + invalid.request_id = id(); + match field { + "workspace_scope" => { + invalid.workspace_scope = + Some(openshell_core::proto::workspace_selector("default")); + } + "sandbox" => invalid.sandbox = "frozen-report".into(), + "merge_operations" => invalid + .merge_operations + .push(openshell_core::proto::PolicyMergeOperation::default()), + "annotations" => { + invalid.annotations.insert("example".into(), "value".into()); + } + "expected_resource_version" => invalid.expected_resource_version = 1, + _ => unreachable!(), + } + let error = openshell_core::proto::frozen_sandbox_policies_server::FrozenSandboxPolicies::update_global_config( + &service, authed_request(invalid)).await.unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument, "{field}"); + assert!( + error + .message() + .contains("unsupported for global configuration"), + "{field}: {error}" + ); + } + let original = run(&state, authed_request(request.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(original.frozen_sandboxes, ["default/frozen-report"]); + assert_eq!(replay(&state, request).await, original); +} + fn configure_key(state: &mut Arc, directory: &tempfile::TempDir) { let key = directory.path().join("private-key"); std::fs::write(&key, b"test-only stable private fingerprint material").unwrap(); @@ -265,6 +342,49 @@ async fn replay(state: &Arc, req: M) -> M::Out response.into_inner() } +#[tokio::test] +async fn frozen_policy_create_replays_mode_without_colliding_with_ordinary_create() { + let (_directory, state) = protected_state().await; + let ordinary = create_sandbox("frozen-replay"); + let request = openshell_core::proto::CreateFrozenSandboxRequest { + workspace_scope: ordinary.workspace_scope.clone(), + spec: ordinary.spec.clone(), + name: ordinary.name.clone(), + request_id: ordinary.request_id.clone(), + ..Default::default() + }; + let created = run(&state, authed_request(request.clone())) + .await + .unwrap() + .into_inner(); + assert!(created.frozen_policy.as_ref().unwrap().requested); + assert!(created.frozen_policy.as_ref().unwrap().policy.is_none()); + let returned = replay(&state, request.clone()).await; + assert_eq!( + returned.sandbox.as_ref().unwrap().object_id(), + created.sandbox.as_ref().unwrap().object_id() + ); + assert!(returned.frozen_policy.unwrap().requested); + // Identical caller request ids are independent across the two RPC services. + assert_eq!( + run(&state, authed_request(ordinary)) + .await + .unwrap_err() + .code(), + Code::AlreadyExists + ); + let sandbox = created.sandbox.unwrap(); + state + .store + .delete_sandbox_with_image_state_if(sandbox.object_id(), sandbox.get_resource_version()) + .await + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(request)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + #[tokio::test] async fn sandbox_replay_survives_status_churn_but_never_selects_replacement() { let (_directory, state) = protected_state().await; diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc83789417..b83dafb122 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,6 +12,7 @@ mod config_inputs; mod endpoint_status; +pub(super) mod frozen; mod provisioning_clock; #[cfg(test)] mod token_grant_owners_tests; @@ -1808,6 +1809,7 @@ async fn auto_approve_chunk( ) -> Result<(), Status> { let sandbox_id = context.sandbox.object_id(); let sandbox_name = context.sandbox.object_name(); + frozen::ensure_mutable_by_id(state.store.as_ref(), sandbox_id).await?; // Same gate the human-driven approve paths apply: if a global policy is // active, sandbox-scoped chunk approvals are meaningless because @@ -1989,6 +1991,9 @@ async fn current_effective_policy_from_records( sandbox_id: &str, records: &[super::provider::ProviderEnvironmentRecord], ) -> Result<(ProtoSandboxPolicy, PolicySource), Status> { + if let Some(snapshot) = frozen::snapshot(state.store.as_ref(), sandbox).await? { + return Ok((frozen::policy(&snapshot)?, frozen::source(&snapshot)?)); + } let global_settings = load_global_settings(state.store.as_ref()).await?; let global_policy = decode_policy_from_global_settings(&global_settings)?; let latest_policy = if global_policy.is_some() { @@ -2524,6 +2529,12 @@ async fn validate_provider_composition_for_existing_sandboxes( .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; for sandbox in page.messages { + if frozen::snapshot(state.store.as_ref(), &sandbox) + .await? + .is_some() + { + continue; + } let provider_names = sandbox .spec .as_ref() @@ -2811,6 +2822,16 @@ pub(super) async fn handle_get_sandbox_config( state: &Arc, request: Request, ) -> Result, Status> { + handle_get_sandbox_config_with_frozen(state, request) + .await + .map(|(response, _, _)| response) +} + +/// Return the mode from the same captured inputs as the configuration. +pub(super) async fn handle_get_sandbox_config_with_frozen( + state: &Arc, + request: Request, +) -> Result<(Response, bool, bool), Status> { let principal = super::extract_principal(&request)?; let sandbox_name = request.get_ref().name.clone(); let workspace = match (&principal, request.get_ref().workspace_scope.as_ref()) { @@ -2830,9 +2851,24 @@ pub(super) async fn handle_get_sandbox_config( MinWorkspaceRole::User, ) .await?; - Ok(Response::new(sandbox_config_response( - unadmitted_sandbox_config(state, &sandbox, &error), - ))) + let frozen_state = state + .store + .get_sandbox_frozen_policy(sandbox.object_id()) + .await + .map_err(|error| { + Status::internal(format!("fetch frozen policy failed: {error}")) + })?; + let frozen = frozen_state + .as_ref() + .is_some_and(|state| state.policy.is_some()); + let requested = frozen_state.is_some(); + Ok(( + Response::new(sandbox_config_response(unadmitted_sandbox_config( + state, &sandbox, &error, + ))), + frozen, + requested, + )) } result => result, } @@ -2878,7 +2914,7 @@ pub fn unadmitted_sandbox_config( async fn handle_get_sandbox_config_inner( state: &Arc, request: Request, -) -> Result, Status> { +) -> Result<(Response, bool, bool), Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); let workspace = match &principal { @@ -2893,7 +2929,17 @@ async fn handle_get_sandbox_config_inner( MinWorkspaceRole::User, ) .await?; - Ok(Response::new(load_sandbox_config(state, &sandbox).await?)) + let inputs = load_sandbox_config_inputs(state, sandbox).await?; + let frozen = inputs + .frozen + .as_ref() + .is_some_and(|state| state.policy.is_some()); + let config = build_sandbox_config_snapshot_from_inputs(state, &inputs).await?; + Ok(( + Response::new(sandbox_config_response(config)), + frozen, + inputs.frozen.is_some(), + )) } /// Resolve the same effective configuration for authenticated RPCs and trusted @@ -2918,8 +2964,7 @@ pub async fn build_sandbox_config_snapshot( build_sandbox_config_snapshot_from_inputs(state, &inputs).await } -/// Build the effective configuration from captured inputs. Sandbox settings -/// and global policy version metadata are read here. +/// Build the effective configuration from one captured set of inputs. pub async fn build_sandbox_config_snapshot_from_inputs( state: &ServerState, inputs: &SandboxConfigInputs, @@ -2930,78 +2975,86 @@ pub async fn build_sandbox_config_snapshot_from_inputs( let provider_profile_catalog = &inputs.catalog; let provider_records = &inputs.provider_records; let global_settings = &inputs.global_settings; + let frozen_policy = inputs + .frozen + .as_ref() + .and_then(|state| state.policy.as_ref()); - let global_policy = decode_policy_from_global_settings(global_settings)?; - let mut global_policy_version: u32 = 0; + let global_policy = if frozen_policy.is_some() { + None + } else { + decode_policy_from_global_settings(global_settings)? + }; + let global_policy_version = inputs.global_policy_version; // Under a global override, only the sandbox version metadata is observed; // the dormant payload is neither decoded nor validated. let latest = inputs.latest_policy.as_ref(); - let (mut policy, version, mut policy_hash, policy_source) = - if let Some(global_policy) = global_policy { - let version = latest - .map(|record| u32::try_from(record.version).unwrap_or(0)) - .filter(|version| *version > 0) - .unwrap_or(1); - let hash = deterministic_policy_hash(&global_policy); - (Some(global_policy), version, hash, PolicySource::Global) - } else if let Some(record) = latest { - let (policy, hash) = canonical_policy_record_identity(record)?; - debug!( - sandbox_id = %sandbox_id, - version = record.version, - "GetSandboxConfig served from policy history" - ); - ( - Some(policy), - u32::try_from(record.version).unwrap_or(0), - hash, - PolicySource::Sandbox, - ) - } else { - // Older sandboxes may have policy only in the sandbox spec. Reading a - // snapshot must not create policy history, so project that baseline as - // version 1 until the startup repair persists it. - let spec = sandbox - .spec - .as_ref() - .ok_or_else(|| Status::internal("sandbox has no spec"))?; - - match spec.policy.clone() { - None => (None, 0, String::new(), PolicySource::Sandbox), - Some(spec_policy) => { - let spec_policy = validate_and_canonicalize_stored_policy( - spec_policy, - STORED_POLICY_SOURCE_SPEC, - )?; - let hash = deterministic_policy_hash(&spec_policy); - (Some(spec_policy), 1, hash, PolicySource::Sandbox) - } + let (mut policy, version, mut policy_hash, policy_source) = if let Some(frozen) = frozen_policy + { + ( + Some(frozen::policy(frozen)?), + frozen.version, + frozen.policy_hash.clone(), + frozen::source(frozen)?, + ) + } else if let Some(global_policy) = global_policy { + let version = latest + .map(|record| u32::try_from(record.version).unwrap_or(0)) + .filter(|version| *version > 0) + .unwrap_or(1); + let hash = deterministic_policy_hash(&global_policy); + (Some(global_policy), version, hash, PolicySource::Global) + } else if let Some(record) = latest { + let (policy, hash) = canonical_policy_record_identity(record)?; + debug!( + sandbox_id = %sandbox_id, + version = record.version, + "GetSandboxConfig served from policy history" + ); + ( + Some(policy), + u32::try_from(record.version).unwrap_or(0), + hash, + PolicySource::Sandbox, + ) + } else { + // Older sandboxes may have policy only in the sandbox spec. Reading a + // snapshot must not create policy history, so project that baseline as + // version 1 until the startup repair persists it. + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| Status::internal("sandbox has no spec"))?; + + match spec.policy.clone() { + None => (None, 0, String::new(), PolicySource::Sandbox), + Some(spec_policy) => { + let spec_policy = validate_and_canonicalize_stored_policy( + spec_policy, + STORED_POLICY_SOURCE_SPEC, + )?; + let hash = deterministic_policy_hash(&spec_policy); + (Some(spec_policy), 1, hash, PolicySource::Sandbox) } - }; + } + }; - let sandbox_settings = - load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; + let sandbox_settings = inputs.sandbox_settings.as_ref().map_err(Clone::clone)?; let mut provider_policy_context = provider_policy_context_from_records(provider_profile_catalog, provider_records); - if matches!(policy_source, PolicySource::Global) - && let Ok(Some(global_rev)) = state - .store - .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) - .await + if frozen_policy.is_none() + && let Some(source_policy) = policy.as_mut() { - global_policy_version = u32::try_from(global_rev.version).unwrap_or(0); - } - - if let Some(source_policy) = policy.as_mut() { // Never trust provenance supplied by a persisted/user-authored policy. // The gateway derives it from the attached provider catalog below. clear_provider_credentialed_markers(source_policy); } - if !matches!(policy_source, PolicySource::Global) + if frozen_policy.is_none() + && !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() && !provider_policy_context.layers.is_empty() { @@ -3031,7 +3084,9 @@ pub async fn build_sandbox_config_snapshot_from_inputs( &provider_policy_context.endpointless_provider_names, ); let mut configuration_error = String::new(); - if let Some(effective_policy) = policy.as_mut() { + if frozen_policy.is_none() + && let Some(effective_policy) = policy.as_mut() + { stamp_provider_credentialed_endpoints( effective_policy, &provider_policy_context.credentialed_scopes, @@ -3056,7 +3111,8 @@ pub async fn build_sandbox_config_snapshot_from_inputs( })?; } - let settings = merge_effective_settings(global_settings, &sandbox_settings)?; + let mut settings = merge_effective_settings(global_settings, sandbox_settings)?; + frozen::disable_proposals(inputs.frozen.is_some(), &mut settings); let supervisor_middleware_services = state.middleware_registry.required_services(policy.as_ref()); let mut config_revision = compute_config_revision_with_validation_mode( @@ -3075,13 +3131,14 @@ pub async fn build_sandbox_config_snapshot_from_inputs( &policy_credential_bindings, )?; } - let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + let provider_env_revision = frozen::provider_env_revision( provider_profile_catalog, provider_records, + &inputs.withheld_providers, &policy_credential_bindings, policy .as_ref() - .filter(|_| matches!(policy_source, PolicySource::Global)), + .filter(|_| frozen::uses_global_policy(frozen_policy, policy_source)), )?; let service_routes = super::service::configured_app_routes(state, sandbox).await?; @@ -3914,13 +3971,21 @@ fn provider_environment_target( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let provider_profile_catalog = &inputs.catalog; let provider_records = &inputs.provider_records; - let (effective_policy, policy_source) = effective_policy_from_captured_inputs( - provider_profile_catalog, - provider_records, - decode_policy_from_global_settings(&inputs.global_settings)?, - inputs.latest_policy.as_ref(), - sandbox, - )?; + let (effective_policy, policy_source) = if let Some(policy) = inputs + .frozen + .as_ref() + .and_then(|state| state.policy.as_ref()) + { + (frozen::policy(policy)?, frozen::source(policy)?) + } else { + effective_policy_from_captured_inputs( + provider_profile_catalog, + provider_records, + decode_policy_from_global_settings(&inputs.global_settings)?, + inputs.latest_policy.as_ref(), + sandbox, + )? + }; let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?; validate_policy_credential_binding_context( @@ -3929,11 +3994,12 @@ fn provider_environment_target( &effective_policy, &policy_credential_bindings, )?; - let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + let provider_env_revision = frozen::provider_env_revision( provider_profile_catalog, provider_records, + &inputs.withheld_providers, &policy_credential_bindings, - matches!(policy_source, PolicySource::Global).then_some(&effective_policy), + (policy_source == PolicySource::Global).then_some(&effective_policy), )?; Ok(ProviderEnvironmentTarget { spec, @@ -3964,17 +4030,31 @@ pub async fn build_provider_environment_snapshot_from_inputs( let provider_names = &spec.providers; let provider_profile_catalog = &inputs.catalog; let provider_records = &inputs.provider_records; + // A dynamic grant can use the sandbox's identity without a provider secret. + // Withhold the whole provider's delivery, including those grants, when its + // captured authorization no longer matches. Retain unrelated providers. + let authorized_records = if inputs.withheld_providers.is_empty() { + std::borrow::Cow::Borrowed(provider_records.as_slice()) + } else { + std::borrow::Cow::Owned( + provider_records + .iter() + .filter(|record| !inputs.withheld_providers.contains(&record.name)) + .cloned() + .collect::>(), + ) + }; let mut provider_environment = super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( provider_profile_catalog, - provider_records, + &authorized_records, &policy_credential_bindings, &state.credentials, Some(&sandbox_id), ) .await?; - if matches!(policy_source, PolicySource::Global) { + if policy_source == PolicySource::Global { // A global policy replaces provider ACLs. Grants retain their profile // destination selectors, but only the selected global endpoint may // authorize their use. Keeping every global owner avoids reimplementing @@ -3993,7 +4073,11 @@ pub async fn build_provider_environment_snapshot_from_inputs( } } - let mut readiness_reason = provider_environment.readiness_reason; + let mut readiness_reason = if inputs.withheld_providers.is_empty() { + provider_environment.readiness_reason + } else { + ProviderReadinessReason::CredentialsWithheld + }; if supports_static_credential_bindings { let unbound_static_keys = provider_environment @@ -4299,6 +4383,12 @@ async fn handle_update_config_inner( super::persistence_error_to_status(error, "acquire policy mutation lock") })?; + let frozen_sandboxes = if has_policy || (req.delete_setting && key == POLICY_SETTING_KEY) { + frozen::names(state).await? + } else { + Vec::new() + }; + if has_merge_ops { return Err(Status::invalid_argument( "merge_operations are not supported for global policy updates", @@ -4355,13 +4445,17 @@ async fn handle_update_config_inner( save_global_settings(state.store.as_ref(), &global_settings).await?; crate::config_delivery::publish_all_connected(state); } - return Ok(update_config_response( + let mut response = update_config_response( u32::try_from(current.version).unwrap_or(0), hash, global_settings.revision, false, HashMap::new(), - )); + ); + response + .extensions_mut() + .insert(super::frozen_policy::FrozenSandboxes(frozen_sandboxes)); + return Ok(response); } let next_version = latest.map_or(1, |r| r.version + 1); @@ -4413,13 +4507,17 @@ async fn handle_update_config_inner( crate::config_delivery::publish_all_connected(state); } - return Ok(update_config_response( + let mut response = update_config_response( u32::try_from(next_version).unwrap_or(0), hash, global_settings.revision, false, HashMap::new(), - )); + ); + response + .extensions_mut() + .insert(super::frozen_policy::FrozenSandboxes(frozen_sandboxes)); + return Ok(response); } // Global setting mutation. @@ -4474,13 +4572,17 @@ async fn handle_update_config_inner( } } - return Ok(update_config_response( + let mut response = update_config_response( 0, String::new(), global_settings.revision, req.delete_setting && changed, HashMap::new(), - )); + ); + response + .extensions_mut() + .insert(super::frozen_policy::FrozenSandboxes(frozen_sandboxes)); + return Ok(response); } let sandbox = sandbox.expect("non-global config update resolves a sandbox"); @@ -4601,6 +4703,7 @@ async fn handle_update_config_inner( .map_err(|error| { super::persistence_error_to_status(error, "acquire policy mutation lock") })?; + frozen::ensure_mutable_by_id(state.store.as_ref(), &sandbox_id).await?; if has_merge_ops { let global_settings = load_global_settings(state.store.as_ref()).await?; if global_settings.settings.contains_key(POLICY_SETTING_KEY) { @@ -5143,21 +5246,25 @@ pub(super) async fn handle_report_sandbox_configuration( "supervisor configuration instance has changed", )); } + let mut frozen_policy = None; if reported == ConfigurationAdmissionState::Accepted { - let mut config_request = Request::new(GetSandboxConfigRequest { - name: sandbox_name.clone(), - workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())), - }); - *config_request.extensions_mut() = request.extensions().clone(); - let config = handle_get_sandbox_config(state, config_request) - .await? - .into_inner(); + let inputs = load_sandbox_config_inputs(state, sandbox.clone()).await?; + let config = sandbox_config_response( + build_sandbox_config_snapshot_from_inputs(state, &inputs).await?, + ); if !config.configuration_admitted || !configuration_generation_matches(&admission, &config) { return Err(Status::aborted( "configuration changed or is not admitted; fetch and validate again", )); } + if inputs + .frozen + .as_ref() + .is_some_and(|state| state.policy.is_none()) + { + frozen_policy = Some(frozen::capture(&config, &inputs, current_time_ms())?); + } admission.error.clear(); } else if reported == ConfigurationAdmissionState::Rejected { // Runtime error strings may contain parser payloads. Only gateway-authored @@ -5215,31 +5322,38 @@ pub(super) async fn handle_report_sandbox_configuration( .map_err(Status::internal)?; } } - let updated = state - .store - .update_message_cas::(&sandbox_id, expected_version, |sandbox| { - sandbox - .status - .get_or_insert_with(Default::default) - .provisioning - .clone_from(&provisioning); + let update = |sandbox: &mut Sandbox| { + sandbox + .status + .get_or_insert_with(Default::default) + .provisioning + .clone_from(&provisioning); + sandbox + .status + .get_or_insert_with(Default::default) + .configuration_admission = Some(admission.clone()); + if reported == ConfigurationAdmissionState::Accepted { sandbox .status - .get_or_insert_with(Default::default) - .configuration_admission = Some(admission.clone()); - if reported == ConfigurationAdmissionState::Accepted { - sandbox - .status - .as_mut() - .expect("status initialized") - .configuration_activated = Some(true); - } - crate::compute::apply_configuration_readiness(sandbox); - }) - .await - .map_err(|error| { - super::persistence_error_to_status(error, "report configuration admission") - })?; + .as_mut() + .expect("status initialized") + .configuration_activated = Some(true); + } + crate::compute::apply_configuration_readiness(sandbox); + }; + let updated = if let Some(policy) = frozen_policy { + update(&mut sandbox); + state + .store + .accept_frozen_sandbox_configuration(sandbox, policy) + .await + } else { + state + .store + .update_message_cas::(&sandbox_id, expected_version, update) + .await + } + .map_err(|error| super::persistence_error_to_status(error, "report configuration admission"))?; state.sandbox_index.update_from_sandbox(&updated); state.sandbox_watch_bus.notify(&sandbox_id); Ok(Response::new( @@ -5599,6 +5713,19 @@ pub(super) async fn handle_submit_policy_analysis( })); } + // Network activity remains observable, but a frozen sandbox does not + // generate proposals or enter an automatic approval path. + if frozen::requested(state.store.as_ref(), &sandbox).await? { + return Ok(Response::new(SubmitPolicyAnalysisResponse { + accepted_chunks: 0, + rejected_chunks: u32::try_from(req.proposed_chunks.len()).unwrap_or(u32::MAX), + rejection_reasons: vec![ + "sandbox policy is frozen; recreate the sandbox to change access".to_string(), + ], + accepted_chunk_ids: Vec::new(), + })); + } + // `current_policy` is captured ONCE at the top of the batch and frozen // for every chunk's delta computation, even if an earlier chunk in the // batch auto-approves and merges. This is intentional v1 behavior: @@ -6096,6 +6223,7 @@ async fn handle_approve_draft_chunk_inner( return Err(Status::invalid_argument("chunk_id is required")); } + frozen::ensure_mutable(state.store.as_ref(), &sandbox).await?; require_no_global_policy(state).await?; let sandbox_id = sandbox.object_id().to_string(); @@ -6240,6 +6368,7 @@ async fn handle_reject_draft_chunk_inner( ) .await?; let workspace = sandbox.object_workspace().to_string(); + frozen::ensure_mutable(state.store.as_ref(), &sandbox).await?; if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } @@ -6347,6 +6476,7 @@ async fn handle_approve_all_draft_chunks_inner( .await?; let workspace = sandbox.object_workspace().to_string(); + frozen::ensure_mutable(state.store.as_ref(), &sandbox).await?; require_no_global_policy(state).await?; let sandbox_id = sandbox.object_id().to_string(); @@ -6650,6 +6780,7 @@ pub(super) async fn handle_edit_draft_chunk( ) .await?; let workspace = sandbox.object_workspace().to_string(); + frozen::ensure_mutable(state.store.as_ref(), &sandbox).await?; if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } @@ -6726,6 +6857,7 @@ async fn handle_undo_draft_chunk_inner( ) .await?; let workspace = sandbox.object_workspace().to_string(); + frozen::ensure_mutable(state.store.as_ref(), &sandbox).await?; if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } @@ -7687,6 +7819,7 @@ async fn apply_merge_operations_with_retry( ) -> Result<(i64, String, Option), Status> { let provider_layers = validation_context.provider_layers; for attempt in 1..=MERGE_RETRY_LIMIT { + frozen::ensure_mutable_by_id(store, sandbox_id).await?; let latest = store .get_latest_policy(sandbox_id) .await @@ -8281,6 +8414,7 @@ fn materialize_global_settings( #[cfg(test)] mod tests { + include!("policy/frozen_tests.rs"); use super::*; use crate::auth::audit::test_support::assert_boundary_finding; use crate::auth::identity::{Identity, IdentityProvider}; @@ -10123,6 +10257,15 @@ mod tests { for (case, legacy_policy) in defaulted_mcp_policy_cases() { let sandbox_id = format!("identity-merge-default-{case}"); + store + .put_message(&test_sandbox( + &sandbox_id, + &sandbox_id, + legacy_policy.clone(), + vec![], + )) + .await + .unwrap(); store .put_policy_revision( &format!("policy-identity-merge-default-{case}"), @@ -12049,7 +12192,7 @@ mod tests { id: "sb-no-policy".to_string(), name: "no-policy-sandbox".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -13727,6 +13870,16 @@ mod tests { .map(|(rule_name, rule)| PolicyMergeOp::AddRule { rule_name, rule }) .collect::>(); + state + .store + .put_message(&test_sandbox( + "sb-ambiguous-merge", + "ambiguous-merge", + openshell_policy::restrictive_default_policy(), + vec![], + )) + .await + .unwrap(); let error = apply_merge_operations_with_retry( state.store.as_ref(), "sb-ambiguous-merge", @@ -16199,7 +16352,7 @@ mod tests { id: "sb-backfill".to_string(), name: "backfill-sandbox".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -17186,7 +17339,7 @@ mod tests { id: "sb-draft-flow".to_string(), name: "draft-flow".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -17445,7 +17598,7 @@ mod tests { id: "sb-feedback".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -17551,7 +17704,7 @@ mod tests { id: "sb-agent-l7-verdict".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -17672,7 +17825,7 @@ mod tests { id: "sb-supersede-flow".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -17885,7 +18038,7 @@ mod tests { id: "sb-mechanistic-clean".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19140,7 +19293,7 @@ mod tests { id: "sb-l7-full-with-cred".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19251,7 +19404,7 @@ mod tests { id: "sb-default-manual-mode".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19350,7 +19503,7 @@ mod tests { id: "sb-unknown-mode".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19441,7 +19594,7 @@ mod tests { id: "sb-explicit-manual-mode".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19534,7 +19687,7 @@ mod tests { id: "sb-gateway-auto-mode".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19627,7 +19780,7 @@ mod tests { id: "sb-gateway-pinned-manual".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19725,7 +19878,7 @@ mod tests { id: "sb-reject-provider-prefix".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -19995,7 +20148,7 @@ mod tests { id: "sb-agent-l4-with-cred".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -20098,7 +20251,7 @@ mod tests { id: "sb-agent-l4-no-cred".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -20189,7 +20342,7 @@ mod tests { id: "sb-agent-link-local".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -20576,7 +20729,7 @@ mod tests { id: "sb-full-loop-v2".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -20768,7 +20921,7 @@ mod tests { id: "sb-redraft".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -20892,7 +21045,7 @@ mod tests { id: "sb-mech-dedup".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -21002,9 +21155,9 @@ mod tests { id: sandbox_id.to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), workspace: "default".to_string(), deletion_time: None, }), @@ -21280,7 +21433,7 @@ mod tests { id: "sb-undo-clears".to_string(), name: sandbox_name.clone(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -21423,7 +21576,7 @@ mod tests { id: "sb-draft-owner".to_string(), name: "draft-owner".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -21442,7 +21595,7 @@ mod tests { id: "sb-draft-other".to_string(), name: "draft-other".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_000_001).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -21730,7 +21883,7 @@ mod tests { id: format!("sb-{name}"), name: name.to_string(), created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), - labels: std::collections::HashMap::new(), + labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), @@ -22041,6 +22194,16 @@ mod tests { ..Default::default() }; + store + .put_message(&test_sandbox( + &chunk.sandbox_id, + &chunk.sandbox_id, + openshell_policy::restrictive_default_policy(), + vec![], + )) + .await + .unwrap(); + let (version, _) = merge_chunk_into_policy(&store, &chunk.sandbox_id, "default", &chunk, &[]) .await @@ -22091,6 +22254,16 @@ mod tests { .collect(), ..Default::default() }; + store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + openshell_policy::restrictive_default_policy(), + vec![], + )) + .await + .unwrap(); + store .put_policy_revision( "p-seed", @@ -22192,6 +22365,16 @@ mod tests { .collect(), ..Default::default() }; + store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + openshell_policy::restrictive_default_policy(), + vec![], + )) + .await + .unwrap(); + store .put_policy_revision( "p-seed", @@ -22280,6 +22463,16 @@ mod tests { .collect(), ..Default::default() }; + store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + openshell_policy::restrictive_default_policy(), + vec![], + )) + .await + .unwrap(); + store .put_policy_revision( "p-seed", diff --git a/crates/openshell-server/src/grpc/policy/config_inputs.rs b/crates/openshell-server/src/grpc/policy/config_inputs.rs index f8905bc992..abf4693b1d 100644 --- a/crates/openshell-server/src/grpc/policy/config_inputs.rs +++ b/crates/openshell-server/src/grpc/policy/config_inputs.rs @@ -10,14 +10,19 @@ //! independent, not one database transaction, and credential values are //! resolved later by the provider builder alone. +use std::collections::BTreeSet; + use openshell_core::proto::Sandbox; use tonic::Status; use super::super::StoredSettings; use super::super::provider::{ProviderEnvironmentRecord, load_provider_environment_records}; -use super::load_global_settings; +use super::{ + GLOBAL_POLICY_SANDBOX_ID, decode_policy_from_global_settings, frozen, load_global_settings, + load_sandbox_settings, +}; use crate::ServerState; -use crate::persistence::{ObjectId, ObjectWorkspace, PolicyRecord}; +use crate::persistence::{ObjectId, ObjectName, ObjectWorkspace, PolicyRecord}; use crate::policy_store::PolicyStoreExt as _; use crate::provider_profile_sources::EffectiveProviderProfileCatalog; @@ -31,9 +36,15 @@ pub struct SandboxConfigInputs { /// Records in `spec.providers` order, which the provider revision hashes. pub(super) provider_records: Vec, pub(super) global_settings: StoredSettings, + pub(super) sandbox_settings: Result, + pub(super) global_policy_version: u32, /// Observed for its version metadata even under a global policy, whose /// dormant sandbox payload is never decoded. pub(super) latest_policy: Option, + /// Frozen providers whose current authorization no longer matches the + /// captured one. Always empty for live sandboxes. + pub(super) withheld_providers: BTreeSet, + pub(super) frozen: Option, } impl SandboxConfigInputs { @@ -47,15 +58,72 @@ impl SandboxConfigInputs { pub async fn load_sandbox_config_inputs( state: &ServerState, sandbox: Sandbox, +) -> Result { + // Keep the captured-input resolver's state machine off every caller's + // stack; configuration, readiness and credential RPCs share this boundary. + Box::pin(async move { + let store = state.store.as_ref(); + let frozen = store + .get_sandbox_frozen_policy(sandbox.object_id()) + .await + .map_err(|error| Status::internal(format!("fetch frozen policy failed: {error}")))?; + let pending = frozen.as_ref().is_some_and(|state| state.policy.is_none()); + let inputs = load_inputs(state, sandbox.clone(), frozen).await; + // Capture is the only allowed mode transition. Recheck after every live + // authorization/composition read, including errors from now-obsolete inputs. + if pending + && let Some(captured) = store + .get_sandbox_frozen_policy(sandbox.object_id()) + .await + .map_err(|error| Status::internal(format!("fetch frozen policy failed: {error}")))? + && captured.policy.is_some() + { + return load_inputs(state, sandbox, Some(captured)).await; + } + inputs + }) + .await +} + +async fn load_inputs( + state: &ServerState, + sandbox: Sandbox, + frozen: Option, ) -> Result { let store = state.store.as_ref(); + if let Some(captured) = frozen.as_ref().and_then(|state| state.policy.as_ref()) { + // A frozen sandbox reads its captured catalog and provider identities, + // and never the live policy history. + let (context, global_settings, sandbox_settings) = tokio::try_join!( + frozen::provider_context_from_snapshot(state, &sandbox, captured), + load_global_settings(store), + async { + Ok::<_, Status>( + load_sandbox_settings(store, sandbox.object_workspace(), sandbox.object_name()) + .await, + ) + }, + )?; + let global_policy_version = captured.global_policy_version; + return Ok(SandboxConfigInputs { + sandbox, + catalog: context.catalog, + provider_records: context.records, + global_settings, + sandbox_settings, + global_policy_version, + latest_policy: None, + withheld_providers: context.withheld_providers, + frozen, + }); + } let workspace = sandbox.object_workspace(); let provider_names = sandbox .spec .as_ref() .map(|spec| spec.providers.as_slice()) .unwrap_or_default(); - let (catalog, provider_records, global_settings, latest_policy) = tokio::try_join!( + let (catalog, provider_records, global_settings, latest_policy, sandbox_settings) = tokio::try_join!( state .provider_profile_sources .snapshot_catalog(store, workspace), @@ -67,12 +135,29 @@ pub async fn load_sandbox_config_inputs( .await .map_err(|e| Status::internal(format!("fetch policy history failed: {e}"))) }, + async { + Ok::<_, Status>(load_sandbox_settings(store, workspace, sandbox.object_name()).await) + }, )?; + let global_policy_version = if decode_policy_from_global_settings(&global_settings)?.is_some() { + store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .ok() + .flatten() + .map_or(0, |record| u32::try_from(record.version).unwrap_or(0)) + } else { + 0 + }; Ok(SandboxConfigInputs { sandbox, catalog, provider_records, global_settings, + sandbox_settings, + global_policy_version, latest_policy, + withheld_providers: BTreeSet::new(), + frozen, }) } diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs index 6d35131cc5..f2c84d1a2c 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -258,20 +258,30 @@ async fn current_endpoint_context( state: &ServerState, sandbox: &Sandbox, ) -> Result { - let workspace = sandbox.object_workspace(); - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), workspace) + if let Some(snapshot) = super::frozen::snapshot(state.store.as_ref(), sandbox).await? { + return frozen_endpoint_context(state, sandbox, &snapshot).await; + } + let live = async { + let workspace = sandbox.object_workspace(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let policy = current_effective_policy_for_sandbox( + state, + &catalog, + workspace, + sandbox, + sandbox.object_id(), + ) .await?; - let policy = current_effective_policy_for_sandbox( - state, - &catalog, - workspace, - sandbox, - sandbox.object_id(), - ) - .await?; - derive_endpoint_context(state, sandbox, &catalog, policy).await + derive_endpoint_context(state, sandbox, &catalog, policy).await + } + .await; + if let Some(snapshot) = super::frozen::snapshot(state.store.as_ref(), sandbox).await? { + return frozen_endpoint_context(state, sandbox, &snapshot).await; + } + live } async fn active_endpoint_context( @@ -298,21 +308,51 @@ async fn endpoint_context_for_loaded_policy( sandbox: &Sandbox, version: i64, ) -> Result { - let workspace = sandbox.object_workspace(); - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), workspace) - .await?; - let global_settings = load_global_settings(state.store.as_ref()).await?; - let global_policy = decode_policy_from_global_settings(&global_settings)?; - endpoint_context_for_loaded_policy_with_inputs( - state, - sandbox, - version, - &catalog, - global_policy.as_ref(), - ) - .await + if let Some(snapshot) = super::frozen::snapshot(state.store.as_ref(), sandbox).await? { + return frozen_endpoint_context(state, sandbox, &snapshot).await; + } + let live = async { + let workspace = sandbox.object_workspace(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let global_settings = load_global_settings(state.store.as_ref()).await?; + let global_policy = decode_policy_from_global_settings(&global_settings)?; + endpoint_context_for_loaded_policy_with_inputs( + state, + sandbox, + version, + &catalog, + global_policy.as_ref(), + ) + .await + } + .await; + if let Some(snapshot) = super::frozen::snapshot(state.store.as_ref(), sandbox).await? { + return frozen_endpoint_context(state, sandbox, &snapshot).await; + } + live +} + +async fn frozen_endpoint_context( + state: &ServerState, + sandbox: &Sandbox, + snapshot: &openshell_core::proto::FrozenSandboxPolicy, +) -> Result { + let policy = super::frozen::policy(snapshot)?; + let providers = super::frozen::provider_context_from_snapshot(state, sandbox, snapshot).await?; + let bindings = policy_static_credential_endpoint_bindings(Some(&policy))?; + let provider_env_revision = providers.revision( + &bindings, + super::frozen::uses_global_policy(Some(snapshot), super::frozen::source(snapshot)?) + .then_some(&policy), + )?; + Ok(EndpointContext { + policy_hash: snapshot.policy_hash.clone(), + provider_env_revision, + endpoints: expected_endpoint_statuses(&policy), + }) } async fn endpoint_context_for_loaded_policy_with_inputs( @@ -996,6 +1036,16 @@ pub(super) async fn endpoint_status_reset_for_loaded_policy( sandbox: &Sandbox, version: i64, ) -> Result>, Status> { + let current_version = i64::from(sandbox.current_policy_version()); + if let Some(snapshot) = super::frozen::snapshot(state.store.as_ref(), sandbox).await? { + // Every acknowledged revision of an initialized frozen sandbox resolves + // to the captured authorization. Never consult today's profile source + // or global override when replacing its supervisor. + let context = frozen_endpoint_context(state, sandbox, &snapshot).await?; + let same_configuration = + current_version != 0 || endpoint_cursor_matches_context(state, sandbox, &context); + return Ok((!same_configuration).then(|| unknown_endpoint_reports(&context.endpoints))); + } // Both policy versions must use one provider/profile and global-policy view; // an external catalog can change between independent snapshot requests. let catalog = state @@ -1012,7 +1062,6 @@ pub(super) async fn endpoint_status_reset_for_loaded_policy( global_policy.as_ref(), ) .await?; - let current_version = i64::from(sandbox.current_policy_version()); let same_configuration = if current_version == version { // An acknowledgement can be retried after endpoint reports have already // committed. Repeating activation must not erase their accepted evidence. @@ -1034,22 +1083,30 @@ pub(super) async fn endpoint_status_reset_for_loaded_policy( // The first endpoint report and load acknowledgement travel independently. // Only the current session's accepted report proves which configuration // produced evidence before any loaded policy version has been recorded. - state - .supervisor_sessions - .current_session_id(sandbox.object_id()) - .and_then(|session_id| { - state - .supervisor_sessions - .endpoint_report_cursor(sandbox.object_id(), &session_id) - }) - .is_some_and(|cursor| { - cursor.policy_hash == context.policy_hash - && cursor.provider_env_revision == context.provider_env_revision - }) + endpoint_cursor_matches_context(state, sandbox, &context) }; Ok((!same_configuration).then(|| unknown_endpoint_reports(&context.endpoints))) } +fn endpoint_cursor_matches_context( + state: &ServerState, + sandbox: &Sandbox, + context: &EndpointContext, +) -> bool { + state + .supervisor_sessions + .current_session_id(sandbox.object_id()) + .and_then(|session_id| { + state + .supervisor_sessions + .endpoint_report_cursor(sandbox.object_id(), &session_id) + }) + .is_some_and(|cursor| { + cursor.policy_hash == context.policy_hash + && cursor.provider_env_revision == context.provider_env_revision + }) +} + /// Clear all supervised network evidence without changing lifecycle state. pub(super) fn reset_endpoint_statuses( sandbox: &mut Sandbox, diff --git a/crates/openshell-server/src/grpc/policy/frozen.rs b/crates/openshell-server/src/grpc/policy/frozen.rs new file mode 100644 index 0000000000..841ce12e47 --- /dev/null +++ b/crates/openshell-server/src/grpc/policy/frozen.rs @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Durable, non-secret authorization captured with first configuration acceptance. + +use super::{ + Digest, EffectiveProviderProfileCatalog, EffectiveSetting, GetSandboxConfigResponse, HashMap, + ObjectId, ObjectListQuery, ObjectName, ObjectWorkspace, PolicySource, ProtoSandboxPolicy, + Provider, Sandbox, SandboxConfigInputs, ServerState, SettingScope, SettingValue, Sha256, + StaticCredentialEndpointBinding, Status, Store, StoredProviderCredentialRefreshState, + compute_provider_env_revision_from_records_and_policy_bindings, deterministic_policy_hash, + setting_value, settings, validate_and_canonicalize_policy, validate_policy_safety, +}; +use openshell_core::GetResourceVersion; +use openshell_core::proto::{FrozenSandboxPolicy, FrozenSandboxProvider, ProviderProfile}; +use openshell_credential_issuer::refresh::CredentialRefresh; +use std::collections::BTreeSet; + +pub(in crate::grpc) struct ProviderContext { + pub catalog: EffectiveProviderProfileCatalog, + pub records: Vec, + pub withheld_providers: BTreeSet, +} + +impl ProviderContext { + pub(super) fn revision( + &self, + bindings: &HashMap>, + global_policy: Option<&ProtoSandboxPolicy>, + ) -> Result { + provider_env_revision( + &self.catalog, + &self.records, + &self.withheld_providers, + bindings, + global_policy, + ) + } +} + +pub(super) fn provider_env_revision( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::super::provider::ProviderEnvironmentRecord], + withheld_providers: &BTreeSet, + bindings: &HashMap>, + global_policy: Option<&ProtoSandboxPolicy>, +) -> Result { + let revision = compute_provider_env_revision_from_records_and_policy_bindings( + catalog, + records, + bindings, + global_policy, + )?; + if withheld_providers.is_empty() { + return Ok(revision); + } + // A replacement can have the same resource version and no static keys. + // Polling must still revoke its previously installed dynamic grants. + let mut hasher = Sha256::new(); + hasher.update(b"openshell-frozen-withheld-providers-v1"); + hasher.update(revision.to_le_bytes()); + for name in withheld_providers { + hasher.update(name.len().to_le_bytes()); + hasher.update(name.as_bytes()); + } + let digest = hasher.finalize(); + Ok(u64::from_le_bytes(digest[..8].try_into().map_err( + |_| Status::internal("provider env revision digest too short"), + )?)) +} + +pub(in crate::grpc) async fn snapshot( + store: &Store, + sandbox: &Sandbox, +) -> Result, Status> { + Ok(store + .get_sandbox_frozen_policy(sandbox.object_id()) + .await + .map_err(|error| Status::internal(format!("fetch frozen policy failed: {error}")))? + .and_then(|state| state.policy)) +} + +pub(super) async fn requested(store: &Store, sandbox: &Sandbox) -> Result { + Ok(store + .get_sandbox_frozen_policy(sandbox.object_id()) + .await + .map_err(|error| Status::internal(format!("fetch frozen policy failed: {error}")))? + .is_some()) +} + +pub(in crate::grpc) async fn ensure_mutable( + store: &Store, + sandbox: &Sandbox, +) -> Result<(), Status> { + if snapshot(store, sandbox).await?.is_some() { + return Err(Status::failed_precondition(format!( + "sandbox '{}' is frozen; delete and recreate it to change its policy or providers", + sandbox.object_name() + ))); + } + Ok(()) +} + +pub(in crate::grpc) async fn ensure_mutable_by_id(store: &Store, id: &str) -> Result<(), Status> { + let sandbox = store + .get_message::(id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + ensure_mutable(store, &sandbox).await +} + +pub(super) async fn names(state: &ServerState) -> Result, Status> { + let sandboxes = state + .store + .collect_messages::(ObjectListQuery::AllWorkspaces) + .await + .map_err(|error| Status::internal(format!("list frozen sandboxes failed: {error}")))?; + let mut names = Vec::new(); + for sandbox in sandboxes { + if snapshot(state.store.as_ref(), &sandbox).await?.is_some() { + names.push(format!( + "{}/{}", + sandbox.object_workspace(), + sandbox.object_name() + )); + } + } + names.sort(); + Ok(names) +} + +pub(super) fn source(snapshot: &FrozenSandboxPolicy) -> Result { + match PolicySource::try_from(snapshot.policy_source) { + Ok(source @ (PolicySource::Sandbox | PolicySource::Global)) => Ok(source), + _ => Err(Status::failed_precondition( + "frozen policy has an invalid original source", + )), + } +} + +pub(super) fn policy(snapshot: &FrozenSandboxPolicy) -> Result { + let policy = snapshot + .policy + .as_ref() + .ok_or_else(|| Status::failed_precondition("frozen policy snapshot is missing"))?; + let canonical = validate_and_canonicalize_policy(policy.clone())?; + if deterministic_policy_hash(&canonical) != snapshot.policy_hash { + return Err(Status::failed_precondition( + "frozen policy is incompatible or its identity changed; recreate the sandbox", + )); + } + validate_policy_safety(&canonical)?; + Ok(canonical) +} + +pub(in crate::grpc) async fn provider_context_from_snapshot( + state: &ServerState, + sandbox: &Sandbox, + frozen: &FrozenSandboxPolicy, +) -> Result { + let workspace = sandbox.object_workspace(); + // The captured catalog, rather than today's live source, defines how both + // fresh credential values and the frozen endpoint authority are interpreted. + let profiles: Vec = frozen + .providers + .iter() + .filter_map(|provider| provider.profile.clone()) + .collect(); + let catalog = EffectiveProviderProfileCatalog::from_frozen_profiles(&profiles)?; + let mut records = Vec::with_capacity(frozen.providers.len()); + let mut withheld_providers = BTreeSet::new(); + for captured in &frozen.providers { + let current = state + .store + .get_message_by_name::(workspace, &captured.provider) + .await + .map_err(|error| Status::internal(format!("fetch frozen provider failed: {error}")))?; + let mut provider = current.unwrap_or_default(); + let same_identity = provider.object_id() == captured.provider_id; + let resource_version = provider.get_resource_version(); + let mut refresh_states = if same_identity { + state + .refresh + .list(Some(provider.object_id())) + .await? + .iter() + .map(Into::into) + .collect::>() + } else { + Vec::new() + }; + let epochs = authorization_epochs(&refresh_states)?; + if !same_identity + || provider.r#type != captured.r#type + || provider.profile_workspace != captured.profile_workspace + || provider.config != captured.config + || epochs != captured.refresh_authorization_epochs + { + // Keep the captured interpretation, but deliver no credential under + // a replaced provider or a different authorization. Clearing values + // also revokes already-installed credentials on the next snapshot. + provider.credentials.clear(); + provider.credential_handles.clear(); + provider.credential_expiration_times.clear(); + refresh_states.clear(); + withheld_providers.insert(captured.provider.clone()); + provider.r#type.clone_from(&captured.r#type); + provider + .profile_workspace + .clone_from(&captured.profile_workspace); + provider.config.clone_from(&captured.config); + } + let metadata = provider.metadata.get_or_insert_with(Default::default); + metadata.name.clone_from(&captured.provider); + metadata.id.clone_from(&captured.provider_id); + metadata.workspace = workspace.to_string(); + records.push(super::super::provider::ProviderEnvironmentRecord { + name: captured.provider.clone(), + object_id: captured.provider_id.clone(), + resource_version, + provider, + refresh_states, + }); + } + Ok(ProviderContext { + catalog, + records, + withheld_providers, + }) +} + +fn authorization_epochs( + states: &[StoredProviderCredentialRefreshState], +) -> Result, Status> { + let mut epochs = HashMap::new(); + for state in states { + if state + .metadata + .as_ref() + .is_some_and(|meta| meta.deletion_time.is_some()) + { + continue; + } + epochs.insert( + state.credential_key.clone(), + crate::provider_refresh::effective_authorization_epoch(state)?.to_string(), + ); + } + Ok(epochs) +} + +pub(super) fn capture( + config: &GetSandboxConfigResponse, + inputs: &SandboxConfigInputs, + now_ms: i64, +) -> Result { + let mut providers = Vec::with_capacity(inputs.provider_records.len()); + for record in &inputs.provider_records { + let provider = &record.provider; + providers.push(FrozenSandboxProvider { + provider: record.name.clone(), + provider_id: record.object_id.clone(), + r#type: provider.r#type.clone(), + profile_workspace: provider.profile_workspace.clone(), + config: provider.config.clone(), + profile: inputs + .catalog + .get_profile_for_scope(&provider.r#type, &provider.profile_workspace), + refresh_authorization_epochs: authorization_epochs(&record.refresh_states)?, + }); + } + Ok(FrozenSandboxPolicy { + policy: config.policy.clone(), + version: config.version, + policy_hash: config.policy_hash.clone(), + frozen_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), + policy_source: config.policy_source, + global_policy_version: config.global_policy_version, + providers, + }) +} + +pub(super) fn uses_global_policy( + snapshot: Option<&FrozenSandboxPolicy>, + source: PolicySource, +) -> bool { + snapshot.map_or(source == PolicySource::Global, |snapshot| { + snapshot.policy_source == i32::from(PolicySource::Global) + }) +} + +pub(super) fn disable_proposals(requested: bool, settings: &mut HashMap) { + if requested { + settings.insert( + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + EffectiveSetting { + value: Some(SettingValue { + value: Some(setting_value::Value::BoolValue(false)), + }), + scope: i32::from(SettingScope::Sandbox), + }, + ); + } +} diff --git a/crates/openshell-server/src/grpc/policy/frozen_tests.rs b/crates/openshell-server/src/grpc/policy/frozen_tests.rs new file mode 100644 index 0000000000..3ac59b1c5c --- /dev/null +++ b/crates/openshell-server/src/grpc/policy/frozen_tests.rs @@ -0,0 +1,1095 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod frozen_policy_tests { + use super::*; + use crate::persistence::PersistenceError; + use openshell_core::proto::{ + AttachSandboxProviderRequest, ConfigurationAdmissionState, DetachSandboxProviderRequest, + ProviderProfile, ProviderProfileCredential, ReportSandboxConfigurationRequest, + SandboxConfigurationAdmission, UpdateProviderRequest, + }; + + const ID: &str = "sb-frozen-policy"; + const NAME: &str = "frozen-policy"; + const PROVIDER: &str = "frozen-provider"; + const PROFILE: &str = "frozen-profile"; + const TOKEN: &str = "FROZEN_TOKEN"; + + fn profile(host: &str) -> ProviderProfile { + ProviderProfile { + id: PROFILE.to_string(), + display_name: "Frozen policy test".to_string(), + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: vec![TOKEN.to_string()], + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + protocol: "rest".to_string(), + access: openshell_core::proto::NetworkAccessPreset::Full.into(), + ..Default::default() + }], + ..Default::default() + } + } + + async fn seed(state: &Arc, frozen: bool) -> Provider { + state + .store + .put_message(&crate::provider_profile_sources::stored_provider_profile( + profile("api.original.example"), + )) + .await + .unwrap(); + let mut provider = test_provider(PROVIDER, PROFILE); + provider.profile_workspace.clear(); + provider.credentials = HashMap::from([(TOKEN.to_string(), "original-token".to_string())]); + state.store.put_message(&provider).await.unwrap(); + let sandbox = test_sandbox( + ID, + NAME, + openshell_policy::restrictive_default_policy(), + vec![PROVIDER.to_string()], + ); + if frozen { + state.store.create_frozen_sandbox(&sandbox).await.unwrap(); + } else { + state.store.put_message(&sandbox).await.unwrap(); + } + // Creation persists the baseline revision that supervisors acknowledge. + let policy = + validate_and_canonicalize_policy(openshell_policy::restrictive_default_policy()) + .unwrap(); + state + .store + .put_policy_revision( + "frozen-policy-1", + ID, + sandbox.object_workspace(), + 1, + &policy.encode_to_vec(), + &deterministic_policy_hash(&policy), + ) + .await + .unwrap(); + provider + } + + async fn config(state: &Arc) -> GetSandboxConfigResponse { + handle_get_sandbox_config( + state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: NAME.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + ID, + ), + ) + .await + .unwrap() + .into_inner() + } + + async fn environment(state: &Arc) -> GetSandboxProviderEnvironmentResponse { + handle_get_sandbox_provider_environment( + state, + with_sandbox( + Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: ID.to_string(), + supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, + }), + ID, + ), + ) + .await + .unwrap() + .into_inner() + } + + async fn register(state: &Arc, instance: &str, previous: &str) { + handle_report_sandbox_configuration( + state, + with_sandbox( + Request::new(ReportSandboxConfigurationRequest { + sandbox_id: ID.to_string(), + admission: Some(SandboxConfigurationAdmission { + instance_id: instance.to_string(), + state: ConfigurationAdmissionState::Pending.into(), + ..Default::default() + }), + expected_instance_id: previous.to_string(), + }), + ID, + ), + ) + .await + .unwrap(); + } + + async fn accept( + state: &Arc, + instance: &str, + config: &GetSandboxConfigResponse, + ) -> Result, Status> { + handle_report_sandbox_configuration( + state, + with_sandbox( + Request::new(ReportSandboxConfigurationRequest { + sandbox_id: ID.to_string(), + admission: Some(SandboxConfigurationAdmission { + instance_id: instance.to_string(), + state: ConfigurationAdmissionState::Accepted.into(), + policy_version: config.version, + policy_hash: config.policy_hash.clone(), + config_revision: config.config_revision, + provider_env_revision: config.provider_env_revision, + ..Default::default() + }), + ..Default::default() + }), + ID, + ), + ) + .await + } + + async fn initialize(state: &Arc) -> (GetSandboxConfigResponse, String) { + let instance = uuid::Uuid::new_v4().to_string(); + register(state, &instance, "").await; + let config = config(state).await; + assert!( + config.configuration_admitted, + "{}", + config.configuration_error + ); + assert!( + frozen::snapshot(state.store.as_ref(), &stored_sandbox(state).await) + .await + .unwrap() + .is_none() + ); + accept(state, &instance, &config).await.unwrap(); + (config, instance) + } + + async fn stored_sandbox(state: &Arc) -> Sandbox { + state + .store + .get_message::(ID) + .await + .unwrap() + .unwrap() + } + + async fn update_global(state: &Arc, host: &str) { + let mut policy = openshell_policy::restrictive_default_policy(); + let mut endpoint = profile(host).endpoints.remove(0); + endpoint.provider_credentialed = false; + policy.network_policies.insert( + "global_access".to_string(), + NetworkPolicyRule { + name: "global_access".to_string(), + endpoints: vec![endpoint], + ..Default::default() + }, + ); + handle_update_config( + state, + with_user(Request::new(UpdateConfigRequest { + policy: Some(policy), + global: true, + ..Default::default() + })), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn frozen_policy_service_delivers_mode_with_matching_config_and_workspace_authz() { + use openshell_core::proto::frozen_sandbox_policies_server::FrozenSandboxPolicies; + use openshell_core::proto::{GetFrozenSandboxConfigRequest, GetFrozenSandboxPolicyRequest}; + let state = test_server_state().await; + seed(&state, true).await; + let service = crate::grpc::OpenShellService::new(Arc::clone(&state)); + let pending = FrozenSandboxPolicies::get_config( + &service, + with_sandbox( + Request::new(GetFrozenSandboxConfigRequest { + name: NAME.into(), + workspace_scope: None, + }), + ID, + ), + ) + .await + .unwrap() + .into_inner(); + assert!(!pending.frozen); + assert!(pending.frozen_requested); + let (accepted, _) = initialize(&state).await; + let captured = FrozenSandboxPolicies::get_config( + &service, + with_sandbox( + Request::new(GetFrozenSandboxConfigRequest { + name: NAME.into(), + workspace_scope: None, + }), + ID, + ), + ) + .await + .unwrap() + .into_inner(); + assert!(captured.frozen); + assert!(captured.frozen_requested); + assert_eq!(captured.config.unwrap().policy_hash, accepted.policy_hash); + let policy = FrozenSandboxPolicies::get_policy( + &service, + with_user(Request::new(GetFrozenSandboxPolicyRequest { + name: NAME.into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .unwrap() + .into_inner(); + assert!(policy.requested); + assert_eq!(policy.policy.unwrap().policy_hash, accepted.policy_hash); + drop(service); + let mut restricted = Arc::try_unwrap(state).ok().unwrap(); + restricted.admin_role = "required-platform-admin".into(); + let state = Arc::new(restricted); + let mut foreign = stored_sandbox(&state).await; + let metadata = foreign.metadata.as_mut().unwrap(); + metadata.id = "foreign-frozen-id".into(); + metadata.workspace = "foreign".into(); + state.store.create_frozen_sandbox(&foreign).await.unwrap(); + let service = crate::grpc::OpenShellService::new(Arc::clone(&state)); + let error = FrozenSandboxPolicies::get_policy( + &service, + with_user(Request::new(GetFrozenSandboxPolicyRequest { + name: NAME.into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("foreign")), + })), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::NotFound); + let error = FrozenSandboxPolicies::get_config( + &service, + with_sandbox( + Request::new(GetFrozenSandboxConfigRequest { + name: "other-sandbox".into(), + workspace_scope: None, + }), + ID, + ), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::PermissionDenied); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn frozen_policy_capture_during_live_reads_rebuilds_from_captured_authority() { + use openshell_core::proto::GetFrozenSandboxConfigRequest; + use openshell_core::proto::frozen_sandbox_policies_server::FrozenSandboxPolicies; + for entry in ["unary", "combined"] { + let state = test_server_state().await; + seed(&state, true).await; + update_global(&state, "api.original.example").await; + let original = config(&state).await; + let sandbox = stored_sandbox(&state).await; + let inputs = load_sandbox_config_inputs(&state, sandbox.clone()) + .await + .unwrap(); + let captured = frozen::capture(&original, &inputs, current_time_ms()).unwrap(); + let (sources, gate) = + ProviderProfileSources::from_test_profiles_blocked(vec![profile( + "api.changed.example", + )]); + let mut server = Arc::try_unwrap(state).ok().unwrap(); + server.provider_profile_sources = sources; + let state = Arc::new(server); + let reader = Arc::clone(&state); + let pending = sandbox.clone(); + let read = tokio::spawn(async move { + match entry { + "unary" => { + let service = crate::grpc::OpenShellService::new(Arc::clone(&reader)); + let response = FrozenSandboxPolicies::get_config( + &service, + with_sandbox( + Request::new(GetFrozenSandboxConfigRequest { + name: NAME.into(), + workspace_scope: None, + }), + ID, + ), + ) + .await + .unwrap() + .into_inner(); + assert!(response.frozen && response.frozen_requested); + let config = response.config.unwrap(); + (config.policy_hash, config.global_policy_version) + } + "combined" => { + // These same shared builders feed pushed configuration + // and session bootstrap from one input selection. + let inputs = load_sandbox_config_inputs(&reader, pending).await.unwrap(); + let (config, environment) = tokio::try_join!( + build_sandbox_config_snapshot_from_inputs(&reader, &inputs), + build_provider_environment_snapshot_from_inputs( + &reader, &inputs, true, true + ), + ) + .unwrap(); + assert!(inputs.frozen.unwrap().policy.is_some()); + assert_eq!(config.policy_hash, environment.policy_hash); + (config.policy_hash, config.global_policy_version) + } + _ => unreachable!(), + } + }); + // The blocked source pauses the first poll before later SQL reads + // in try_join, making capture-before-new-global-read deterministic. + gate.entered().await; + state + .store + .accept_frozen_sandbox_configuration(sandbox, captured.clone()) + .await + .unwrap(); + update_global(&state, "api.changed.example").await; + gate.release(); + let (hash, global_version) = read.await.unwrap(); + assert_eq!(hash, original.policy_hash, "{entry}"); + assert_eq!(global_version, captured.global_policy_version, "{entry}"); + } + } + + #[tokio::test] + async fn frozen_policy_session_acceptance_requires_first_unary_capture() { + let state = test_server_state().await; + seed(&state, true).await; + let instance = uuid::Uuid::new_v4().to_string(); + register(&state, &instance, "").await; + let config = config(&state).await; + let admission = SandboxConfigurationAdmission { + instance_id: instance.clone(), + state: ConfigurationAdmissionState::Accepted.into(), + policy_version: config.version, + policy_hash: config.policy_hash.clone(), + config_revision: config.config_revision, + provider_env_revision: config.provider_env_revision, + ..Default::default() + }; + let error = state + .compute + .supervisor_session_admission(ID, "session-first", "supervisor-first", &admission) + .await + .unwrap_err(); + assert!(error.contains("ReportSandboxConfiguration")); + let pending = stored_sandbox(&state).await; + assert_eq!( + pending + .status + .unwrap() + .configuration_admission + .unwrap() + .state, + i32::from(ConfigurationAdmissionState::Pending) + ); + assert!( + state + .store + .get_sandbox_frozen_policy(ID) + .await + .unwrap() + .unwrap() + .policy + .is_none() + ); + accept(&state, &instance, &config).await.unwrap(); + let captured = state + .store + .get_sandbox_frozen_policy(ID) + .await + .unwrap() + .unwrap(); + state + .compute + .supervisor_session_admission(ID, "session-first", "supervisor-first", &admission) + .await + .unwrap(); + assert_eq!( + state + .store + .get_sandbox_frozen_policy(ID) + .await + .unwrap() + .unwrap(), + captured + ); + } + + #[tokio::test] + async fn frozen_policy_admission_captures_once_and_survives_restart() { + for (frozen_requested, global) in [(false, false), (true, false), (true, true)] { + let state = test_server_state().await; + let mut provider = seed(&state, frozen_requested).await; + let mut state = Arc::try_unwrap(state).ok().unwrap(); + state.provider_profile_sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![( + "original".to_string(), + vec![profile("api.original.example")], + )], + Arc::new(AtomicUsize::new(0)), + ); + let state = Arc::new(state); + config(&state).await; + if global { + update_global(&state, "api.original.example").await; + } + let instance = uuid::Uuid::new_v4().to_string(); + register(&state, &instance, "").await; + let outdated = config(&state).await; + assert!( + outdated.configuration_admitted, + "{}", + outdated.configuration_error + ); + provider + .credentials + .insert(TOKEN.to_string(), "rotated-token".to_string()); + crate::grpc::provider::handle_update_provider( + &state, + with_user(Request::new(UpdateProviderRequest { + provider: Some(provider), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + })), + ) + .await + .unwrap(); + assert_eq!( + accept(&state, &instance, &outdated) + .await + .unwrap_err() + .code(), + Code::Aborted + ); + assert!( + frozen::snapshot(state.store.as_ref(), &stored_sandbox(&state).await) + .await + .unwrap() + .is_none() + ); + let accepted = config(&state).await; + accept(&state, &instance, &accepted).await.unwrap(); + let sandbox = stored_sandbox(&state).await; + assert_eq!( + frozen::snapshot(state.store.as_ref(), &sandbox) + .await + .unwrap() + .is_some(), + frozen_requested + ); + if !frozen_requested { + update_global(&state, "api.changed.example").await; + let changed = config(&state).await; + assert_ne!(changed.policy_hash, accepted.policy_hash); + assert_eq!(changed.policy_source, i32::from(PolicySource::Global)); + continue; + } + let captured = frozen::snapshot(state.store.as_ref(), &sandbox) + .await + .unwrap() + .unwrap() + .clone(); + assert_eq!(captured.policy, accepted.policy); + assert_eq!(captured.policy_hash, accepted.policy_hash); + assert!(captured.frozen_time.is_some()); + assert_eq!( + captured.policy_source, + i32::from(if global { + PolicySource::Global + } else { + PolicySource::Sandbox + }) + ); + assert_eq!(captured.global_policy_version, u32::from(global)); + assert_eq!( + captured.providers[0].profile.as_ref().unwrap().endpoints[0].host, + "api.original.example" + ); + + // Any live source read would now supply different authorization. + let fetches = Arc::new(AtomicUsize::new(0)); + let mut state = Arc::try_unwrap(state).ok().unwrap(); + state.provider_profile_sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![("changed".to_string(), vec![profile("api.changed.example")])], + Arc::clone(&fetches), + ); + let state = Arc::new(state); + update_global(&state, "api.changed.example").await; + let delivered = config(&state).await; + let credentials = environment(&state).await; + assert_eq!(delivered.policy_source, captured.policy_source); + assert_eq!(delivered.policy, accepted.policy); + assert_eq!(delivered.policy_hash, accepted.policy_hash); + assert_eq!(delivered.version, accepted.version); + assert_eq!(credentials.policy_hash, accepted.policy_hash); + assert_eq!(credentials.environment[TOKEN], "rotated-token"); + + let replacement = uuid::Uuid::new_v4().to_string(); + register(&state, &replacement, &instance).await; + let replacement_config = config(&state).await; + assert_eq!(replacement_config.policy, accepted.policy); + assert_eq!(replacement_config.policy_hash, accepted.policy_hash); + assert_eq!( + replacement_config.global_policy_version, + captured.global_policy_version + ); + accept(&state, &replacement, &replacement_config) + .await + .unwrap(); + assert_eq!( + frozen::snapshot(state.store.as_ref(), &stored_sandbox(&state).await) + .await + .unwrap(), + Some(captured.clone()) + ); + assert_eq!(fetches.load(Ordering::SeqCst), 0); + + let directory = tempfile::tempdir().unwrap(); + let database_url = format!("sqlite://{}", directory.path().join("frozen.db").display()); + let store = Store::connect(&database_url).await.unwrap(); + let sandbox = stored_sandbox(&state).await; + let bytes = sandbox.encode_to_vec(); + for secret in ["original-token", "rotated-token"] { + assert!(!String::from_utf8_lossy(&bytes).contains(secret)); + } + store.create_frozen_sandbox(&sandbox).await.unwrap(); + let persisted = store.get_message::(ID).await.unwrap().unwrap(); + store + .accept_frozen_sandbox_configuration(persisted, captured.clone()) + .await + .unwrap(); + let private = store + .get_sandbox_frozen_policy(ID) + .await + .unwrap() + .unwrap() + .encode_to_vec(); + for secret in ["original-token", "rotated-token"] { + assert!(!String::from_utf8_lossy(&private).contains(secret)); + } + drop(store); + let reopened = Store::connect(&database_url).await.unwrap(); + let restored = reopened.get_message::(ID).await.unwrap().unwrap(); + assert_eq!( + frozen::snapshot(&reopened, &restored).await.unwrap(), + Some(captured) + ); + } + } + + #[tokio::test] + async fn frozen_policy_rejects_mutation_handlers_for_user_and_sandbox() { + let state = test_server_state().await; + seed(&state, true).await; + let (accepted, _) = initialize(&state).await; + for sandbox_caller in [false, true] { + for merge in [false, true] { + let request = Request::new(UpdateConfigRequest { + sandbox: NAME.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: (!merge).then(openshell_policy::restrictive_default_policy), + merge_operations: if merge { + vec![PolicyMergeOperation { + operation: Some(policy_merge_operation::Operation::RemoveRule( + openshell_core::proto::RemoveNetworkRule { + rule_name: "unused".to_string(), + }, + )), + }] + } else { + Vec::new() + }, + ..Default::default() + }); + let request = if sandbox_caller { + with_sandbox(request, ID) + } else { + with_user(request) + }; + let error = handle_update_config(&state, request).await.unwrap_err(); + if sandbox_caller && merge { + assert_eq!(error.code(), Code::PermissionDenied); + } else { + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("frozen")); + } + } + } + for sandbox_caller in [false, true] { + let attach = Request::new(AttachSandboxProviderRequest { + sandbox: NAME.to_string(), + provider: PROVIDER.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }); + let detach = Request::new(DetachSandboxProviderRequest { + sandbox: NAME.to_string(), + provider: PROVIDER.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }); + let attach = if sandbox_caller { + with_sandbox(attach, ID) + } else { + with_user(attach) + }; + let detach = if sandbox_caller { + with_sandbox(detach, ID) + } else { + with_user(detach) + }; + let attach_error = crate::grpc::sandbox::handle_attach_sandbox_provider(&state, attach) + .await + .unwrap_err(); + let detach_error = crate::grpc::sandbox::handle_detach_sandbox_provider(&state, detach) + .await + .unwrap_err(); + for error in [attach_error, detach_error] { + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("frozen")); + } + } + + let rule = test_policy_with_rule("new_access", "proposal.example") + .network_policies + .remove("new_access") + .unwrap(); + let mut chunk = pending_draft_chunk("before-freeze", ID); + chunk.rule_name.clone_from(&rule.name); + chunk.proposed_rule = rule.encode_to_vec(); + chunk.host = "proposal.example".to_string(); + chunk.port = 443; + state + .store + .put_draft_chunk(&chunk, None, "default") + .await + .unwrap(); + let error = handle_approve_draft_chunk( + &state, + with_user(Request::new(ApproveDraftChunkRequest { + sandbox: NAME.to_string(), + chunk_id: chunk.id.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + })), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("frozen")); + let error = handle_approve_all_draft_chunks( + &state, + with_user(Request::new(ApproveAllDraftChunksRequest { + sandbox: NAME.to_string(), + include_security_flagged: true, + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + })), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("frozen")); + let proposed = handle_submit_policy_analysis( + &state, + with_sandbox( + Request::new(SubmitPolicyAnalysisRequest { + name: NAME.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + proposed_chunks: vec![PolicyChunk { + rule_name: rule.name.clone(), + proposed_rule: Some(rule), + ..Default::default() + }], + ..Default::default() + }), + ID, + ), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(proposed.accepted_chunks, 0); + assert_eq!(proposed.rejected_chunks, 1); + assert_eq!( + state + .store + .get_draft_chunk(&chunk.id) + .await + .unwrap() + .unwrap() + .status, + "pending" + ); + assert_eq!(config(&state).await.policy_hash, accepted.policy_hash); + + // A writer that read before first acceptance cannot bypass the RPC + // guard by reaching the atomic persistence boundary afterward. + let changed = test_policy_with_rule("raced", "raced.example"); + let error = state + .store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: "raced-policy".to_string(), + sandbox_id: ID.to_string(), + workspace: "default".to_string(), + version: i64::from(accepted.version) + 1, + policy_payload: changed.encode_to_vec(), + policy_hash: deterministic_policy_hash(&changed), + provenance: HashMap::new(), + expected_resource_version: 0, + annotations: HashMap::new(), + backfill_policy: None, + }) + .await + .unwrap_err(); + assert!(matches!(error, PersistenceError::Conflict { .. })); + assert_eq!(config(&state).await.policy_hash, accepted.policy_hash); + } + + #[tokio::test] + async fn frozen_policy_rotates_credentials_and_withholds_incompatible_providers() { + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantType, + }; + + for dynamic in [false, true] { + let state = test_server_state().await; + let mut provider = seed(&state, true).await; + if dynamic { + // No static keys: a same-RV replacement must still revoke the grant. + provider.credentials.clear(); + state.store.put_message(&provider).await.unwrap(); + let mut dynamic_profile = profile("api.original.example"); + dynamic_profile.credentials[0].env_vars.clear(); + dynamic_profile.credentials[0].token_grant = Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::ClientCredentials.into(), + token_endpoint: "https://issuer.example/token".to_string(), + jwt_svid_audience: "https://issuer.example".to_string(), + audience: "api://original".to_string(), + ..Default::default() + }); + state + .store + .put_message(&crate::provider_profile_sources::stored_provider_profile( + dynamic_profile, + )) + .await + .unwrap(); + } + let mut other_profile = profile("api.other.example"); + other_profile.id = "other-profile".to_string(); + other_profile.credentials[0].env_vars = vec!["OTHER_TOKEN".to_string()]; + state + .store + .put_message(&crate::provider_profile_sources::stored_provider_profile( + other_profile, + )) + .await + .unwrap(); + let mut other = test_provider("other-provider", "other-profile"); + other.profile_workspace.clear(); + other.credentials = + HashMap::from([("OTHER_TOKEN".to_string(), "other-token".to_string())]); + state.store.put_message(&other).await.unwrap(); + let mut sandbox = stored_sandbox(&state).await; + sandbox + .spec + .as_mut() + .unwrap() + .providers + .push("other-provider".to_string()); + state.store.put_message(&sandbox).await.unwrap(); + let (accepted, _) = initialize(&state).await; + let before = environment(&state).await; + assert_eq!( + before + .dynamic_credentials + .values() + .filter(|credential| credential.token_grant.is_some()) + .count(), + usize::from(dynamic) + ); + if dynamic { + let mut rotated = provider.clone(); + rotated + .credentials + .insert(TOKEN.to_string(), "rotated-token".to_string()); + state.store.put_message(&rotated).await.unwrap(); + assert_eq!( + environment(&state) + .await + .dynamic_credentials + .values() + .filter(|credential| credential.token_grant.is_some()) + .count(), + 1 + ); + state.store.put_message(&provider).await.unwrap(); + } else { + provider + .credentials + .insert(TOKEN.to_string(), "rotated-token".to_string()); + crate::grpc::provider::handle_update_provider( + &state, + with_user(Request::new(UpdateProviderRequest { + provider: Some(provider), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + })), + ) + .await + .unwrap(); + provider = state + .store + .get_message_by_name::("default", PROVIDER) + .await + .unwrap() + .unwrap(); + let rotated = environment(&state).await; + assert_eq!(rotated.environment[TOKEN], "rotated-token"); + assert_ne!(rotated.provider_env_revision, before.provider_env_revision); + assert_eq!( + rotated.static_credential_bindings, + before.static_credential_bindings + ); + assert_eq!(rotated.policy_hash, accepted.policy_hash); + } + for change in ["deleted", "identity", "config", "refresh authorization"] { + state + .store + .delete(Provider::object_type(), provider.object_id()) + .await + .unwrap(); + let mut changed = provider.clone(); + match change { + "identity" => { + changed.metadata.as_mut().unwrap().id = "replacement-provider".to_string(); + } + "config" => { + changed + .config + .insert("region".to_string(), "changed".to_string()); + } + "refresh authorization" => { + crate::provider_refresh::put_refresh_state( + state.store.as_ref(), + &StoredProviderCredentialRefreshState { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "new-refresh-authorization".to_string(), + name: crate::provider_refresh::refresh_state_name( + provider.object_id(), + TOKEN, + ), + workspace: "default".to_string(), + ..Default::default() + }), + provider_id: provider.object_id().to_string(), + provider_name: PROVIDER.to_string(), + credential_key: TOKEN.to_string(), + authorization_epoch: "new-authorization".to_string(), + ..Default::default() + }, + ) + .await + .unwrap(); + } + _ => {} + } + if change != "deleted" { + state.store.put_message(&changed).await.unwrap(); + } + let withheld = environment(&state).await; + assert!( + !withheld.environment.contains_key(TOKEN), + "{dynamic}/{change}" + ); + assert!( + withheld + .dynamic_credentials + .values() + .all(|credential| credential.token_grant.is_none()), + "{dynamic}/{change}" + ); + assert_ne!( + withheld.provider_env_revision, before.provider_env_revision, + "{dynamic}/{change}" + ); + let delivered = config(&state).await; + assert_eq!( + withheld.provider_env_revision, + delivered.provider_env_revision + ); + assert_eq!(delivered.policy_hash, accepted.policy_hash); + assert_eq!(withheld.environment["OTHER_TOKEN"], "other-token"); + assert_eq!( + withheld.readiness_reason, + i32::from(ProviderReadinessReason::CredentialsWithheld) + ); + if change == "identity" { + state + .store + .delete(Provider::object_type(), changed.object_id()) + .await + .unwrap(); + } + state.store.put_message(&provider).await.unwrap(); + } + } + } + + #[tokio::test] + async fn frozen_policy_endpoint_reports_survive_source_removal_and_reconnect() { + use openshell_core::proto::{ + EndpointObservation, EndpointResult, ReportEndpointStatusRequest, + ReportPolicyStatusRequest, + }; + + for global in [false, true] { + let state = test_server_state().await; + seed(&state, true).await; + config(&state).await; // Persist the sandbox baseline before a global override. + let mut policy = mcp_policy_with_versions(&["2025-11-25"]); + policy.network_policies.get_mut("mcp").unwrap().endpoints[0].host = + "tools.original.example".to_string(); + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + sandbox: if global { + String::new() + } else { + NAME.to_string() + }, + workspace_scope: (!global) + .then(|| openshell_core::proto::workspace_selector("default")), + policy: Some(policy), + global, + ..Default::default() + })), + ) + .await + .unwrap(); + let (accepted, _) = initialize(&state).await; + let stored_profile = state + .store + .get_message_by_name::("", PROFILE) + .await + .unwrap() + .unwrap(); + state + .store + .delete( + StoredProviderProfile::object_type(), + stored_profile.object_id(), + ) + .await + .unwrap(); + // A newly configured source supplies no captured profile. Every + // endpoint path must bypass it, including before the first load ack. + let fetches = Arc::new(AtomicUsize::new(0)); + let mut state = Arc::try_unwrap(state).ok().unwrap(); + state.provider_profile_sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![("unavailable".to_string(), Vec::new())], + Arc::clone(&fetches), + ); + let state = Arc::new(state); + update_global(&state, "global.changed.example").await; + let delivered = config(&state).await; + for (index, session) in ["session-one", "session-two"].into_iter().enumerate() { + let (sender, _receiver) = tokio::sync::mpsc::channel(1); + let (shutdown, _shutdown_receiver) = tokio::sync::oneshot::channel(); + state.supervisor_sessions.register( + ID.to_string(), + session.to_string(), + sender, + shutdown, + ); + reset_endpoint_status_for_supervisor_session(&state, ID, session) + .await + .unwrap(); + assert!( + state + .supervisor_sessions + .initialize_endpoint_status_authority(ID, session) + ); + let sandbox = stored_sandbox(&state).await; + let endpoints = &sandbox.status.as_ref().unwrap().endpoint_statuses; + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].host, "tools.original.example"); + let endpoint_id = endpoints[0].endpoint_id.clone(); + handle_report_endpoint_status( + &state, + with_sandbox( + Request::new(ReportEndpointStatusRequest { + sandbox_id: ID.to_string(), + policy_hash: accepted.policy_hash.clone(), + provider_env_revision: delivered.provider_env_revision, + supervisor_session_id: session.to_string(), + report_sequence: 1, + observations: vec![EndpointObservation { + endpoint_id: endpoint_id.clone(), + result: EndpointResult::HttpResponseReceived.into(), + ..Default::default() + }], + observed_endpoint_ids: vec![endpoint_id], + ..Default::default() + }), + ID, + ), + ) + .await + .unwrap(); + handle_report_policy_status( + &state, + with_sandbox( + Request::new(ReportPolicyStatusRequest { + sandbox_id: ID.to_string(), + version: accepted.version, + status: PolicyStatus::Loaded.into(), + ..Default::default() + }), + ID, + ), + ) + .await + .unwrap(); + assert_eq!( + stored_sandbox(&state) + .await + .status + .unwrap() + .endpoint_statuses[0] + .last_result, + i32::from(EndpointResult::HttpResponseReceived), + "session {index}" + ); + } + assert_eq!(fetches.load(Ordering::SeqCst), 0); + } + } +} diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 6ef3c3b15d..fd8ffbe326 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2171,6 +2171,14 @@ pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); for sandbox in sandboxes_using_provider_records(store, workspace, &provider_name).await? { + if super::policy::frozen::snapshot(store, &sandbox) + .await? + .is_some() + { + // Frozen readers use their captured catalog and withhold credentials + // when this provider no longer matches their authorization. + continue; + } let sandbox_name = sandbox.object_name().to_string(); let Some(spec) = sandbox.spec.as_ref() else { continue; @@ -3962,6 +3970,29 @@ async fn profile_attached_sandbox_diagnostics( let validate_policy_composition = super::policy::provider_policy_composition_enabled(store).await?; for sandbox in sandboxes { + if let Some(frozen) = super::policy::frozen::snapshot(store, &sandbox).await? { + for provider in &frozen.providers { + let same_scope = if is_platform_scope { + provider.profile_workspace.is_empty() + } else { + provider.profile_workspace == workspace + }; + if same_scope && let Some((source, _)) = candidate_profiles.get(&provider.r#type) { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: provider.r#type.clone(), + field: "sandbox".to_string(), + message: format!( + "sandbox '{}/{}' is frozen and retains its initialized provider policy", + sandbox.object_workspace(), + sandbox.object_name() + ), + severity: "warning".to_string(), + }); + } + } + continue; + } 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"); @@ -4394,6 +4425,65 @@ fn token_grant_dependency_status(status: Status) -> Status { TokenGrantFailure::new(kind, TokenGrantStage::GatewayRpc).to_status_with_code(status.code()) } +async fn load_provider_subject_token_context( + state: &ServerState, + sandbox: &Sandbox, + provider_name: &str, +) -> Result<(Provider, ProviderProfile), Status> { + let mut captured = super::policy::frozen::snapshot(state.store.as_ref(), sandbox).await?; + for _ in 0..2 { + let (provider, catalog) = if let Some(snapshot) = captured.as_ref() { + let context = + super::policy::frozen::provider_context_from_snapshot(state, sandbox, snapshot) + .await + .map_err(token_grant_dependency_status)?; + if context.withheld_providers.contains(provider_name) { + return Err(token_grant_authorization_required_status()); + } + let provider = context + .records + .into_iter() + .find(|record| record.name == provider_name) + .ok_or_else(|| token_grant_configuration_status(tonic::Code::PermissionDenied))? + .provider; + (provider, context.catalog) + } else { + let live = async { + let workspace = sandbox.object_workspace(); + let provider = state + .store + .get_message_by_name::(workspace, provider_name) + .await + .map_err(|_| token_grant_store_status())? + .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await + .map_err(token_grant_dependency_status)?; + Ok::<_, Status>((provider, catalog)) + } + .await; + if let Some(snapshot) = + super::policy::frozen::snapshot(state.store.as_ref(), sandbox).await? + { + captured = Some(snapshot); + continue; + } + live? + }; + let profile = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))? + .to_proto(); + return Ok((provider, profile)); + } + unreachable!("a captured frozen policy cannot transition again") +} + async fn exchange_provider_subject_token_inner( state: &Arc, request: Request, @@ -4437,7 +4527,6 @@ async fn exchange_provider_subject_token_inner( .await .map_err(|_| token_grant_store_status())? .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; - let workspace = sandbox.object_workspace().to_string(); let spec = sandbox .spec .as_ref() @@ -4452,24 +4541,8 @@ async fn exchange_provider_subject_token_inner( )); } - let provider = state - .store - .get_message_by_name::(&workspace, &req.provider) - .await - .map_err(|_| token_grant_store_status())? - .ok_or_else(|| token_grant_configuration_status(tonic::Code::NotFound))?; - let catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), &workspace) - .await - .map_err(token_grant_dependency_status)?; - let profile = get_provider_type_profile_for_scope( - &catalog, - &provider.r#type, - &provider.profile_workspace, - ) - .ok_or_else(|| token_grant_configuration_status(tonic::Code::FailedPrecondition))?; - let profile_proto = profile.to_proto(); + let (provider, profile_proto) = + load_provider_subject_token_context(state, &sandbox, &req.provider).await?; let credential = profile_proto .credentials .iter() @@ -5867,6 +5940,357 @@ mod tests { ); } + fn frozen_exchange_profile(endpoint: &str, subject: &str) -> ProviderProfile { + let mut profile = custom_profile("frozen-exchange"); + profile.credentials = vec![ + ProviderProfileCredential { + name: subject.to_string(), + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange.into(), + token_endpoint: endpoint.to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: subject.to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ]; + profile + } + + async fn frozen_exchange_state( + next_profiles: Vec, + ) -> ( + Arc, + Sandbox, + Provider, + Arc, + ) { + use openshell_core::proto::{FrozenSandboxPolicy, FrozenSandboxProvider}; + let fetches = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut state = test_server_state_without_provider_profiles().await; + Arc::get_mut(&mut state).unwrap().provider_profile_sources = + ProviderProfileSources::from_test_snapshot_sequence( + vec![ + ( + "original".to_string(), + vec![frozen_exchange_profile( + "https://original.example/token", + "subject_token", + )], + ), + ("replacement".to_string(), next_profiles), + ], + Arc::clone(&fetches), + ); + let mut provider = provider_with_values("frozen-exchange-provider", "frozen-exchange"); + provider.metadata.as_mut().unwrap().id = "frozen-exchange-provider-id".to_string(); + provider.credentials = HashMap::from([ + ("subject_token".to_string(), "original-subject".to_string()), + ("live_subject".to_string(), "live-subject".to_string()), + ]); + state.store.put_message(&provider).await.unwrap(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let captured_profile = catalog + .get_profile_for_scope(&provider.r#type, &provider.profile_workspace) + .unwrap(); + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "frozen-exchange-sandbox".to_string(), + name: "frozen-exchange-sandbox".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + spec: Some(SandboxSpec { + providers: vec![provider.object_name().to_string()], + ..Default::default() + }), + ..Default::default() + }; + state.store.create_frozen_sandbox(&sandbox).await.unwrap(); + let stored = state + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + state + .store + .accept_frozen_sandbox_configuration( + stored, + FrozenSandboxPolicy { + providers: vec![FrozenSandboxProvider { + provider: provider.object_name().to_string(), + provider_id: provider.object_id().to_string(), + r#type: provider.r#type.clone(), + config: provider.config.clone(), + profile_workspace: provider.profile_workspace.clone(), + profile: Some(captured_profile), + ..Default::default() + }], + ..Default::default() + }, + ) + .await + .unwrap(); + (state, sandbox, provider, fetches) + } + + fn frozen_exchange_request( + sandbox: &Sandbox, + provider: &Provider, + ) -> Request { + let mut request = Request::new(ExchangeProviderSubjectTokenRequest { + sandbox_id: sandbox.object_id().to_string(), + provider: provider.object_name().to_string(), + credential_key: "access_token".to_string(), + supervisor_jwt_svid: "unused-before-subject-authorization".to_string(), + }); + request + .extensions_mut() + .insert(Principal::Sandbox(SandboxPrincipal { + sandbox_id: sandbox.object_id().to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "test".to_string(), + auth_epoch: None, + }, + trust_domain: None, + })); + request + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn frozen_exchange_capture_during_live_lookup_uses_captured_profile() { + use crate::persistence::frozen_policy::{FROZEN_POLICY_OBJECT_TYPE, frozen_policy_id}; + let (state, sandbox, provider, _) = frozen_exchange_state(Vec::new()).await; + let snapshot = state + .store + .get_sandbox_frozen_policy(sandbox.object_id()) + .await + .unwrap() + .unwrap() + .policy + .unwrap(); + // Build the pending fixture from the same captured inputs; the + // production operation below performs the only transition under test. + let pending = crate::storage_proto::StoredFrozenSandboxPolicy { + sandbox_id: sandbox.object_id().into(), + policy: None, + }; + let private_id = frozen_policy_id(sandbox.object_id()); + let private = state + .store + .get(FROZEN_POLICY_OBJECT_TYPE, &private_id) + .await + .unwrap() + .unwrap(); + state + .store + .write_atomically(&[crate::persistence::AtomicWrite::Update { + object_type: FROZEN_POLICY_OBJECT_TYPE.into(), + id: private_id, + payload: pending.encode_to_vec(), + expected_version: private.resource_version, + }]) + .await + .unwrap(); + let (sources, gate) = + ProviderProfileSources::from_test_profiles_blocked(vec![frozen_exchange_profile( + "https://changed.example/token", + "live_subject", + )]); + let mut server = Arc::try_unwrap(state).ok().unwrap(); + server.provider_profile_sources = sources; + let state = Arc::new(server); + let reader = Arc::clone(&state); + let requested_sandbox = sandbox.clone(); + let name = provider.object_name().to_string(); + let read = tokio::spawn(async move { + load_provider_subject_token_context(&reader, &requested_sandbox, &name) + .await + .unwrap() + }); + gate.entered().await; + let current = state + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + state + .store + .accept_frozen_sandbox_configuration(current, snapshot) + .await + .unwrap(); + gate.release(); + let (_, profile) = read.await.unwrap(); + let grant = profile + .credentials + .iter() + .find(|credential| credential.name == "access_token") + .unwrap() + .token_grant + .as_ref() + .unwrap(); + assert_eq!(grant.token_endpoint, "https://original.example/token"); + assert_eq!( + grant.subject_token.as_ref().unwrap().credential, + "subject_token" + ); + } + + #[tokio::test] + async fn frozen_exchange_uses_captured_profile_and_rotated_subject_without_live_sources() { + use std::sync::atomic::Ordering; + for next_profiles in [ + vec![frozen_exchange_profile( + "https://changed.example/token", + "live_subject", + )], + Vec::new(), + ] { + let (state, sandbox, mut provider, fetches) = + frozen_exchange_state(next_profiles).await; + provider + .credentials + .insert("subject_token".to_string(), "rotated-subject".to_string()); + state.store.put_message(&provider).await.unwrap(); + let (selected_provider, selected_profile) = + load_provider_subject_token_context(&state, &sandbox, provider.object_name()) + .await + .unwrap(); + let grant = selected_profile + .credentials + .iter() + .find(|credential| credential.name == "access_token") + .unwrap() + .token_grant + .as_ref() + .unwrap(); + assert_eq!(grant.token_endpoint, "https://original.example/token"); + assert_eq!( + grant.subject_token.as_ref().unwrap().credential, + "subject_token" + ); + assert_eq!( + resolve_subject_token_credential( + &state.credentials, + &selected_provider, + "subject_token" + ) + .await + .unwrap(), + "rotated-subject" + ); + assert_eq!(selected_provider.object_id(), provider.object_id()); + assert_eq!(selected_provider.config, provider.config); + + // The actual RPC selects the captured, now-expired subject; + // it must stop before SPIFFE acquisition or an HTTP exchange. + provider + .credential_expiration_times + .insert("subject_token".to_string(), ts(0)); + state.store.put_message(&provider).await.unwrap(); + let error = handle_exchange_provider_subject_token( + &state, + frozen_exchange_request(&sandbox, &provider), + ) + .await + .unwrap_err(); + assert_eq!( + assert_generic(&error, Code::FailedPrecondition).kind(), + TokenGrantFailureKind::ProviderAuthorizationRequired + ); + assert_eq!( + fetches.load(Ordering::SeqCst), + 1, + "frozen exchange must never fetch the live source" + ); + } + } + + #[tokio::test] + async fn frozen_exchange_rejects_changed_authorization_before_subject_lookup() { + use std::sync::atomic::Ordering; + for change in ["configuration", "identity", "refresh authorization"] { + let (state, sandbox, mut provider, fetches) = + frozen_exchange_state(Vec::new()).await; + provider.credentials.clear(); + provider.credential_handles.insert( + "subject_token".to_string(), + CredentialHandle { + driver: "unconfigured-driver-must-not-be-called".to_string(), + handle: "unresolved-subject".to_string(), + ..Default::default() + }, + ); + match change { + "configuration" => { + provider + .config + .insert("region".to_string(), "changed".to_string()); + } + "identity" => { + state + .store + .delete(Provider::object_type(), provider.object_id()) + .await + .unwrap(); + provider.metadata.as_mut().unwrap().id = "replacement-provider".to_string(); + } + _ => { + crate::provider_refresh::put_refresh_state( + state.store.as_ref(), + &StoredProviderCredentialRefreshState { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "new-refresh-authorization".to_string(), + name: crate::provider_refresh::refresh_state_name( + provider.object_id(), + "subject_token", + ), + workspace: "default".to_string(), + ..Default::default() + }), + provider_id: provider.object_id().to_string(), + provider_name: provider.object_name().to_string(), + credential_key: "subject_token".to_string(), + authorization_epoch: "new-authorization".to_string(), + ..Default::default() + }, + ) + .await + .unwrap(); + } + } + state.store.put_message(&provider).await.unwrap(); + let error = handle_exchange_provider_subject_token( + &state, + frozen_exchange_request(&sandbox, &provider), + ) + .await + .unwrap_err(); + assert_eq!( + assert_generic(&error, Code::FailedPrecondition).kind(), + TokenGrantFailureKind::ProviderAuthorizationRequired, + "{change}" + ); + assert_eq!(fetches.load(Ordering::SeqCst), 1, "{change}"); + } + } + #[test] fn invalid_grant_configuration_fails_before_acquisition() { let valid = token_grant_credential("access_token").token_grant.unwrap(); diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 86ff12b170..fa23470e10 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -297,11 +297,19 @@ fn generate_routable_name() -> String { pub(super) async fn handle_create_sandbox( state: &Arc, request: Request, +) -> Result, Status> { + handle_create_sandbox_with_mode(state, request, false).await +} + +pub(super) async fn handle_create_sandbox_with_mode( + state: &Arc, + request: Request, + frozen: bool, ) -> Result, Status> { let create_request = request.get_ref().clone(); // Sandbox creation retains large configuration values across awaits. // Box the inner future to keep this wrapper small for every caller. - let result = Box::pin(handle_create_sandbox_inner(state, request)).await; + let result = Box::pin(handle_create_sandbox_inner(state, request, frozen)).await; let created_sandbox = result .as_ref() .ok() @@ -556,6 +564,7 @@ fn sandbox_create_telemetry_attrs( async fn handle_create_sandbox_inner( state: &Arc, request: Request, + frozen: bool, ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); @@ -770,6 +779,7 @@ async fn handle_create_sandbox_inner( // their revisions cannot change before the sandbox's launch is committed. let mut runtime_inputs = super::policy::resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox).await?; + runtime_inputs.freeze_policy = frozen; state .compute @@ -1507,6 +1517,7 @@ pub(super) async fn handle_attach_sandbox_provider( err } })?; + super::policy::frozen::ensure_mutable_by_id(state.store.as_ref(), sandbox.object_id()).await?; let sandbox_name = sandbox.object_name().to_string(); let sandbox_id = sandbox .metadata @@ -1682,6 +1693,7 @@ pub(super) async fn handle_detach_sandbox_provider( .await .map_err(|err| super::persistence_error_to_status(err, "acquire sandbox mutation lock"))?; let sandbox_name = sandbox.object_name().to_string(); + super::policy::frozen::ensure_mutable_by_id(state.store.as_ref(), sandbox.object_id()).await?; let sandbox_id = sandbox .metadata .as_ref() diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 140f372347..8d9edcc16c 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -3173,7 +3173,11 @@ mod tests { .iter() .map(|service| service.name.as_str()) .collect::>(), - ["openshell.v1.OpenShell", "openshell.v1.SandboxUpdates"] + [ + "openshell.v1.OpenShell", + "openshell.v1.SandboxUpdates", + "openshell.v1.FrozenSandboxPolicies" + ] ); stop_listener(shutdown, handle).await; diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 438b7d4bc6..fe70c64c83 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -17,6 +17,7 @@ use hyper_util::{ service::TowerToHyperService, }; use metrics::{counter, histogram}; +use openshell_core::proto::frozen_sandbox_policies_server::FrozenSandboxPoliciesServer; use openshell_core::proto::open_shell_server::OpenShellServer; use openshell_core::proto::sandbox_updates_server::SandboxUpdatesServer; use openshell_core::proto::supervisor_host::v1::supervisor_host_server::SupervisorHostServer; @@ -313,6 +314,14 @@ impl MultiplexService { self.state.gateway_interceptors.clone(), Some(self.state.clone()), ); + let frozen_policies = + FrozenSandboxPoliciesServer::new(OpenShellService::new(self.state.clone())) + .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); + let frozen_policies = GatewayInterceptorGrpcService::new( + frozen_policies, + self.state.gateway_interceptors.clone(), + Some(self.state.clone()), + ); let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), @@ -320,7 +329,9 @@ impl MultiplexService { }); let authenticator_chain = build_authenticator_chain(&self.state); let grpc_service = AuthGrpcRouter::with_peer_identity( - GrpcRouter::new(openshell, reflection).with_sandbox_updates(sandbox_updates), + GrpcRouter::new(openshell, reflection) + .with_sandbox_updates(sandbox_updates) + .with_frozen_policies(frozen_policies), authenticator_chain, authz_policy, self.state @@ -997,10 +1008,11 @@ where /// Combined gRPC service for public gateway services and reflection. #[derive(Clone)] -pub struct GrpcRouter { +pub struct GrpcRouter { openshell: N, reflection: R, sandbox_updates: Option, + frozen_policies: Option, } impl GrpcRouter { @@ -1009,6 +1021,7 @@ impl GrpcRouter { openshell, reflection, sandbox_updates: None, + frozen_policies: None, } } @@ -1017,13 +1030,25 @@ impl GrpcRouter { openshell: self.openshell, reflection: self.reflection, sandbox_updates: Some(sandbox_updates), + frozen_policies: None, + } + } +} + +impl GrpcRouter { + fn with_frozen_policies(self, frozen_policies: F) -> GrpcRouter { + GrpcRouter { + openshell: self.openshell, + reflection: self.reflection, + sandbox_updates: self.sandbox_updates, + frozen_policies: Some(frozen_policies), } } } pub const REFLECTION_PATH_PREFIX: &str = "/grpc.reflection.v1."; -impl tower::Service> for GrpcRouter +impl tower::Service> for GrpcRouter where N: tower::Service> + Clone + Send + 'static, N::Response: Send, @@ -1039,6 +1064,11 @@ where + Send + 'static, U::Future: Send, + F: tower::Service, Response = N::Response, Error = N::Error> + + Clone + + Send + + 'static, + F::Future: Send, B: Send + 'static, { type Response = N::Response; @@ -1060,6 +1090,13 @@ where && let Some(mut svc) = self.sandbox_updates.clone() { Box::pin(async move { svc.ready().await?.call(req).await }) + } else if req + .uri() + .path() + .starts_with("/openshell.v1.FrozenSandboxPolicies/") + && let Some(mut svc) = self.frozen_policies.clone() + { + Box::pin(async move { svc.ready().await?.call(req).await }) } else { let mut svc = self.openshell.clone(); Box::pin(async move { svc.ready().await?.call(req).await }) @@ -2943,11 +2980,13 @@ mod tests { calls: calls.clone(), }; let mut router = GrpcRouter::new(service("openshell"), service("reflection")) - .with_sandbox_updates(service("sandbox_updates")); + .with_sandbox_updates(service("sandbox_updates")) + .with_frozen_policies(service("frozen_policies")); for path in [ "/openshell.v1.OpenShell/Health", "/openshell.v1.SandboxUpdates/UpdateSandbox", + "/openshell.v1.FrozenSandboxPolicies/GetConfig", "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", ] { router @@ -2963,7 +3002,12 @@ mod tests { assert_eq!( *calls.lock().unwrap(), - vec!["openshell", "sandbox_updates", "reflection"] + vec![ + "openshell", + "sandbox_updates", + "frozen_policies", + "reflection" + ] ); } @@ -3062,7 +3106,11 @@ mod tests { assert_eq!( names, - vec!["openshell.v1.OpenShell", "openshell.v1.SandboxUpdates"] + vec![ + "openshell.v1.FrozenSandboxPolicies", + "openshell.v1.OpenShell", + "openshell.v1.SandboxUpdates" + ] ); let descriptor_response = responses.message().await.unwrap().unwrap(); diff --git a/crates/openshell-server/src/persistence/frozen_policy.rs b/crates/openshell-server/src/persistence/frozen_policy.rs new file mode 100644 index 0000000000..c9a54109ba --- /dev/null +++ b/crates/openshell-server/src/persistence/frozen_policy.rs @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Creation-only mode and first-accepted policy, separate from public sandbox rows. + +use super::{ + AtomicWrite, PersistenceError, PersistenceResult, Store, WriteResult, current_time_ms, + map_db_error, +}; +use crate::storage_proto::StoredFrozenSandboxPolicy; +use openshell_core::proto::{FrozenSandboxPolicy, Sandbox}; +use openshell_core::{ + GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion, +}; +use prost::Message; + +pub const FROZEN_POLICY_OBJECT_TYPE: &str = "sandbox_frozen_policy"; + +pub fn frozen_policy_id(sandbox_id: &str) -> String { + format!("sandbox-frozen-policy:{sandbox_id}") +} + +pub fn decode_frozen_policy( + sandbox_id: &str, + payload: &[u8], +) -> PersistenceResult { + let state = StoredFrozenSandboxPolicy::decode(payload) + .map_err(|error| PersistenceError::Decode(error.to_string()))?; + if state.sandbox_id != sandbox_id { + return Err(PersistenceError::Decode( + "invalid frozen sandbox policy identity".into(), + )); + } + Ok(state) +} + +impl Store { + pub(crate) async fn get_sandbox_frozen_policy( + &self, + sandbox_id: &str, + ) -> PersistenceResult> { + self.get(FROZEN_POLICY_OBJECT_TYPE, &frozen_policy_id(sandbox_id)) + .await? + .map(|record| decode_frozen_policy(sandbox_id, &record.payload)) + .transpose() + } + + /// Commit the immutable mode with the public identity before any driver dispatch. + pub(crate) async fn create_frozen_sandbox( + &self, + sandbox: &Sandbox, + ) -> PersistenceResult { + let sandbox_id = sandbox.object_id(); + if sandbox_id.is_empty() || sandbox.object_workspace().is_empty() { + return Err(PersistenceError::Encode( + "frozen sandbox requires an immutable id and workspace".into(), + )); + } + let private_id = frozen_policy_id(sandbox_id); + let payload = sandbox.encode_to_vec(); + let private_payload = StoredFrozenSandboxPolicy { + sandbox_id: sandbox_id.into(), + policy: None, + } + .encode_to_vec(); + let labels = serde_json::to_value(sandbox.object_labels().unwrap_or_default()) + .map_err(|error| PersistenceError::Encode(error.to_string()))?; + let now_ms = current_time_ms(); + match self { + Self::Sqlite(store) => { + let mut tx = store + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| map_db_error(&error))?; + sqlx::query("INSERT INTO objects (object_type, id, name, workspace, payload, labels, created_at_ms, updated_at_ms, resource_version) VALUES ('sandbox', ?1, ?2, ?3, ?4, ?5, ?6, ?6, 1)") + .bind(sandbox_id).bind(sandbox.object_name()).bind(sandbox.object_workspace()) + .bind(&payload).bind(labels.to_string()).bind(now_ms) + .execute(&mut *tx).await.map_err(|error| map_db_error(&error))?; + sqlx::query("INSERT INTO objects (object_type, id, name, workspace, scope, payload, created_at_ms, updated_at_ms, resource_version) VALUES (?1, ?2, NULL, '', ?3, ?4, ?5, ?5, 1)") + .bind(FROZEN_POLICY_OBJECT_TYPE).bind(&private_id).bind(sandbox_id).bind(&private_payload).bind(now_ms) + .execute(&mut *tx).await.map_err(|error| map_db_error(&error))?; + tx.commit().await.map_err(|error| map_db_error(&error))?; + } + Self::Postgres(store) => { + let mut tx = store + .pool + .begin() + .await + .map_err(|error| map_db_error(&error))?; + sqlx::query("INSERT INTO objects (object_type, id, name, workspace, payload, labels, created_at_ms, updated_at_ms, resource_version) VALUES ('sandbox', $1, $2, $3, $4, $5, $6, $6, 1)") + .bind(sandbox_id).bind(sandbox.object_name()).bind(sandbox.object_workspace()) + .bind(&payload).bind(&labels).bind(now_ms) + .execute(&mut *tx).await.map_err(|error| map_db_error(&error))?; + sqlx::query("INSERT INTO objects (object_type, id, name, workspace, scope, payload, created_at_ms, updated_at_ms, resource_version) VALUES ($1, $2, NULL, '', $3, $4, $5, $5, 1)") + .bind(FROZEN_POLICY_OBJECT_TYPE).bind(&private_id).bind(sandbox_id).bind(&private_payload).bind(now_ms) + .execute(&mut *tx).await.map_err(|error| map_db_error(&error))?; + tx.commit().await.map_err(|error| map_db_error(&error))?; + } + } + Ok(WriteResult { + resource_version: 1, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }) + } + + /// The sandbox CAS fences every writer that read before first acceptance. + pub(crate) async fn accept_frozen_sandbox_configuration( + &self, + mut sandbox: Sandbox, + policy: FrozenSandboxPolicy, + ) -> PersistenceResult { + let sandbox_id = sandbox.object_id().to_string(); + let private_id = frozen_policy_id(&sandbox_id); + let record = self + .get(FROZEN_POLICY_OBJECT_TYPE, &private_id) + .await? + .ok_or_else(|| PersistenceError::Decode("frozen sandbox mode is missing".into()))?; + let mut state = decode_frozen_policy(&sandbox_id, &record.payload)?; + if state.policy.is_some() { + return Err(PersistenceError::Conflict { + current_resource_version: Some(sandbox.get_resource_version()), + }); + } + state.policy = Some(policy); + let results = self + .write_atomically(&[ + AtomicWrite::Update { + object_type: "sandbox".into(), + id: sandbox_id, + payload: sandbox.encode_to_vec(), + expected_version: sandbox.get_resource_version(), + }, + AtomicWrite::Update { + object_type: FROZEN_POLICY_OBJECT_TYPE.into(), + id: private_id, + payload: state.encode_to_vec(), + expected_version: record.resource_version, + }, + ]) + .await?; + sandbox.set_resource_version( + results[0] + .as_ref() + .expect("sandbox update result") + .resource_version, + ); + Ok(sandbox) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::proto::{PolicySource, SandboxPhase, SandboxSpec, SandboxStatus}; + use std::collections::HashMap; + + fn sandbox(id: &str, name: &str) -> Sandbox { + Sandbox { + metadata: Some(ObjectMeta { + id: id.into(), + name: name.into(), + workspace: "default".into(), + labels: HashMap::from([("team".into(), "frozen-test".into())]), + ..Default::default() + }), + spec: Some(SandboxSpec::default()), + status: Some(SandboxStatus::default()), + ..Default::default() + } + } + + async fn exercise_frozen_policy_persistence(store: &Store) { + Box::pin(async move { + let original = sandbox("frozen-id", "frozen-name"); + store.create_frozen_sandbox(&original).await.unwrap(); + let stale = store + .get_message::("frozen-id") + .await + .unwrap() + .unwrap(); + assert_eq!( + store + .get_sandbox_frozen_policy("frozen-id") + .await + .unwrap() + .unwrap() + .policy, + None + ); + let record = store.get("sandbox", "frozen-id").await.unwrap().unwrap(); + assert!(record.labels.unwrap().contains("frozen-test")); + + // Name contention cannot leave an orphan mode for an uncreated identity. + assert!( + store + .create_frozen_sandbox(&sandbox("other-id", "frozen-name")) + .await + .is_err() + ); + assert!( + store + .get_message::("other-id") + .await + .unwrap() + .is_none() + ); + assert!( + store + .get_sandbox_frozen_policy("other-id") + .await + .unwrap() + .is_none() + ); + + let current = store + .update_message_cas::( + "frozen-id", + stale.get_resource_version(), + |sandbox| { + sandbox.status.as_mut().unwrap().phase = SandboxPhase::Ready.into(); + }, + ) + .await + .unwrap(); + let policy = openshell_policy::restrictive_default_policy(); + let snapshot = FrozenSandboxPolicy { + policy: Some(policy.clone()), + policy_hash: openshell_core::policy_identity::deterministic_policy_hash(&policy), + policy_source: PolicySource::Sandbox.into(), + version: 1, + ..Default::default() + }; + assert!(matches!( + store + .accept_frozen_sandbox_configuration(stale, snapshot.clone()) + .await, + Err(PersistenceError::Conflict { .. }) + )); + assert!( + store + .get_sandbox_frozen_policy("frozen-id") + .await + .unwrap() + .unwrap() + .policy + .is_none() + ); + let accepted = store + .accept_frozen_sandbox_configuration(current, snapshot.clone()) + .await + .unwrap(); + // Even a policy writer without an expected sandbox version is fenced in + // the same transaction that would insert its revision. + let write = AtomicPolicyRevisionWrite { + id: "forbidden-revision".into(), + sandbox_id: "frozen-id".into(), + workspace: "default".into(), + version: 2, + policy_payload: policy.encode_to_vec(), + policy_hash: snapshot.policy_hash.clone(), + provenance: HashMap::new(), + expected_resource_version: 0, + annotations: HashMap::new(), + backfill_policy: None, + }; + assert!(matches!( + store.put_policy_revision_atomic(&write).await, + Err(PersistenceError::Conflict { .. }) + )); + assert!( + store + .get_latest_policy("frozen-id") + .await + .unwrap() + .is_none() + ); + assert!(matches!( + store + .accept_frozen_sandbox_configuration(accepted.clone(), snapshot.clone()) + .await, + Err(PersistenceError::Conflict { .. }) + )); + + // A public status rewrite cannot clear the gateway-private snapshot. + let updated = store + .update_message_cas::( + "frozen-id", + accepted.get_resource_version(), + |sandbox| { + sandbox.status = Some(SandboxStatus::default()); + }, + ) + .await + .unwrap(); + assert_eq!( + store + .get_sandbox_frozen_policy("frozen-id") + .await + .unwrap() + .unwrap() + .policy, + Some(snapshot) + ); + assert!(matches!( + store + .delete_sandbox_with_image_state_if( + "frozen-id", + accepted.get_resource_version() + ) + .await, + Err(PersistenceError::Conflict { .. }) + )); + assert!( + store + .get_sandbox_frozen_policy("frozen-id") + .await + .unwrap() + .is_some() + ); + assert!( + store + .delete_sandbox_with_image_state_if("frozen-id", updated.get_resource_version()) + .await + .unwrap() + ); + assert!( + store + .get_sandbox_frozen_policy("frozen-id") + .await + .unwrap() + .is_none() + ); + let replacement = sandbox("replacement-id", "frozen-name"); + store.put_message(&replacement).await.unwrap(); + assert!( + store + .get_sandbox_frozen_policy("replacement-id") + .await + .unwrap() + .is_none() + ); + }) + .await; + } + + #[tokio::test] + async fn frozen_policy_persistence_is_atomic_and_identity_scoped() { + exercise_frozen_policy_persistence(&super::super::test_store().await).await; + } + + #[tokio::test] + #[ignore = "requires a disposable PostgreSQL database"] + async fn frozen_policy_persistence_postgres() { + let schema = super::super::test_postgres::TestSchema::create("frozen_policy").await; + exercise_frozen_policy_persistence(&schema.connect_store().await).await; + schema.drop_schema().await; + } + + #[test] + fn frozen_policy_rejects_mismatched_private_identity() { + let payload = StoredFrozenSandboxPolicy { + sandbox_id: "other".into(), + policy: None, + } + .encode_to_vec(); + assert!(decode_frozen_policy("expected", &payload).is_err()); + } +} diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index d61451604d..71640ae081 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -3,6 +3,7 @@ //! Persistence layer for `OpenShell` Server. +pub mod frozen_policy; mod image_activation; mod legacy_time_wire; pub mod lock_order; diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 45511726fd..2b9dca362a 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -1678,6 +1678,23 @@ FOR UPDATE let sandbox_payload: Vec = row.get("payload"); let current_version: i64 = row.try_get("resource_version").unwrap_or(1); let current_version = current_version.max(1).cast_unsigned(); + // Locking the sandbox first serializes this check with first acceptance. + let frozen_payload: Option> = + sqlx::query_scalar("SELECT payload FROM objects WHERE object_type = $1 AND id = $2") + .bind(super::frozen_policy::FROZEN_POLICY_OBJECT_TYPE) + .bind(super::frozen_policy::frozen_policy_id(&write.sandbox_id)) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_db_error(&error))?; + if let Some(payload) = frozen_payload + && super::frozen_policy::decode_frozen_policy(&write.sandbox_id, &payload)? + .policy + .is_some() + { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_version), + }); + } let (mut sandbox, sandbox_changed) = project_policy_revision_onto_sandbox(write, &sandbox_payload, current_version)?; diff --git a/crates/openshell-server/src/persistence/sandbox_image.rs b/crates/openshell-server/src/persistence/sandbox_image.rs index 386fe78136..27d9f4d5a2 100644 --- a/crates/openshell-server/src/persistence/sandbox_image.rs +++ b/crates/openshell-server/src/persistence/sandbox_image.rs @@ -371,9 +371,14 @@ ORDER BY id LIMIT $2", return Err(conflict(Some(resource_version(&sandbox)))); } let image_state_id = state_id(sandbox_id); + let frozen_policy_id = super::frozen_policy::frozen_policy_id(sandbox_id); for (object_type, id) in [ ("sandbox", sandbox_id), (IMAGE_STATE_OBJECT_TYPE, image_state_id.as_str()), + ( + super::frozen_policy::FROZEN_POLICY_OBJECT_TYPE, + frozen_policy_id.as_str(), + ), ] { match &mut tx { ImageTransaction::Sqlite(tx) => { diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 8c434dcaa4..bd091b00df 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -1454,6 +1454,23 @@ WHERE "object_type" = 'sandbox' AND "id" = ?1 let sandbox_payload: Vec = row.get("payload"); let current_version: i64 = row.try_get("resource_version").unwrap_or(1); let current_version = current_version.max(1).cast_unsigned(); + // Locking the sandbox first serializes this check with first acceptance. + let frozen_payload: Option> = + sqlx::query_scalar("SELECT payload FROM objects WHERE object_type = ?1 AND id = ?2") + .bind(super::frozen_policy::FROZEN_POLICY_OBJECT_TYPE) + .bind(super::frozen_policy::frozen_policy_id(&write.sandbox_id)) + .fetch_optional(&mut *tx) + .await + .map_err(|error| map_db_error(&error))?; + if let Some(payload) = frozen_payload + && super::frozen_policy::decode_frozen_policy(&write.sandbox_id, &payload)? + .policy + .is_some() + { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_version), + }); + } let (mut sandbox, sandbox_changed) = project_policy_revision_onto_sandbox(write, &sandbox_payload, current_version)?; diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 77230e5cce..494dd6e753 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -450,6 +450,30 @@ impl ProviderProfileSources { })]) } + #[cfg(test)] + pub(crate) fn from_test_profiles_blocked( + profiles: Vec, + ) -> (Self, Arc) { + let gate = Arc::new(TestSnapshotGate::default()); + let source = BlockingProviderProfileSource { + inner: StaticProviderProfileSource { + snapshot: ProviderProfileSnapshot { + revision: profile_snapshot_revision(&profiles), + profiles: profiles + .into_iter() + .map(|profile| ScopedSnapshotProfile { + scope: ProfileScope::Static, + profile, + }) + .collect(), + }, + }, + first: std::sync::atomic::AtomicBool::new(true), + gate: Arc::clone(&gate), + }; + (Self::uncached(vec![Arc::new(source)]), gate) + } + /// A source-managed catalog composed with the user-managed source. /// /// Models the shape an operator gets from an interceptor-vended catalog: @@ -561,6 +585,82 @@ pub fn spawn_refresh(state: Arc) { } impl EffectiveProviderProfileCatalog { + /// Rebuild the profiles captured for a frozen sandbox without consulting + /// the live profile sources or store. + pub(crate) fn from_frozen_profiles(profiles: &[ProviderProfile]) -> Result { + let mut captured: BTreeMap<(String, String, String), ScopedSnapshotProfile> = + BTreeMap::new(); + for profile in profiles { + let scope = match profile.scope.as_str() { + "" => ProfileScope::Static, + "platform" => ProfileScope::Platform, + "workspace" => ProfileScope::Workspace, + _ => { + return Err(Status::failed_precondition(format!( + "frozen provider profile '{}' has invalid scope '{}'", + profile.id, profile.scope + ))); + } + }; + if profile.source.trim().is_empty() + || profile.source.trim() != profile.source + || (profile.source == USER_SOURCE_ID) == (scope == ProfileScope::Static) + { + return Err(Status::failed_precondition(format!( + "frozen provider profile '{}' has inconsistent source and scope", + profile.id + ))); + } + let id = normalize_profile_id(&profile.id).ok_or_else(|| { + Status::failed_precondition(format!( + "frozen provider profile '{}' has invalid id", + profile.id + )) + })?; + let key = (profile.source.clone(), profile.scope.clone(), id); + if let Some(existing) = captured.get(&key) { + if existing.profile != *profile { + return Err(Status::failed_precondition(format!( + "conflicting frozen provider profile '{}' in scope '{}'", + profile.id, profile.scope + ))); + } + continue; + } + captured.insert( + key, + ScopedSnapshotProfile { + scope, + profile: profile.clone(), + }, + ); + } + + let mut snapshots: BTreeMap = BTreeMap::new(); + for ((source_id, _, _), profile) in captured { + snapshots + .entry(source_id.clone()) + .or_insert_with(|| CollectedProviderProfileSnapshot { + user_managed: source_id == USER_SOURCE_ID, + source_id, + revision: String::new(), + profiles: Vec::new(), + allow_empty: true, + }) + .profiles + .push(profile); + } + for snapshot in snapshots.values_mut() { + let mut hasher = Sha256::new(); + hasher.update(b"openshell-frozen-provider-profile-source-v1"); + for profile in &snapshot.profiles { + hasher.update(canonical_provider_profile_bytes(&profile.profile)); + } + snapshot.revision = format!("sha256:{:x}", hasher.finalize()); + } + build_effective_profiles(snapshots.into_values().collect()) + } + pub(crate) fn revision(&self) -> &str { &self.revision } @@ -613,6 +713,15 @@ impl EffectiveProviderProfileCatalog { .map(|entry| entry.profile.clone()) } + pub(crate) fn get_profile_for_scope( + &self, + id: &str, + profile_workspace: &str, + ) -> Option { + self.scoped_type_profile_for_scope(id, profile_workspace) + .map(|entry| entry.response.clone()) + } + /// Resolve a profile by its exact normalized ID. /// /// Profiles are import-only, so a provider type names a profile the @@ -1006,6 +1115,75 @@ impl ProviderProfileSource for StaticProviderProfileSource { } } +/// Pause the source's first poll before `try_join` polls later database reads. +#[cfg(test)] +#[derive(Debug, Default)] +pub struct TestSnapshotGate { + entered: tokio::sync::Notify, + released: std::sync::Mutex, + wake: std::sync::Condvar, +} + +#[cfg(test)] +impl TestSnapshotGate { + pub(crate) async fn entered(&self) { + self.entered.notified().await; + } + pub(crate) fn release(&self) { + *self.released.lock().unwrap() = true; + self.wake.notify_all(); + } +} + +#[cfg(test)] +#[derive(Debug)] +struct BlockingProviderProfileSource { + inner: StaticProviderProfileSource, + first: std::sync::atomic::AtomicBool, + gate: Arc, +} + +#[cfg(test)] +#[async_trait] +impl ProviderProfileSource for BlockingProviderProfileSource { + fn source_id(&self) -> &'static str { + "blocking-test" + } + fn user_managed(&self) -> bool { + false + } + fn allow_empty(&self) -> bool { + false + } + async fn snapshot( + &self, + store: &Store, + workspace: &str, + ) -> Result { + if self.first.swap(false, std::sync::atomic::Ordering::SeqCst) { + let released = tokio::task::block_in_place(|| { + self.gate.entered.notify_one(); + let (released, _) = self + .gate + .wake + .wait_timeout_while( + self.gate.released.lock().unwrap(), + Duration::from_secs(20), + |released| !*released, + ) + .unwrap(); + *released + }); + if !released { + return Err(Status::deadline_exceeded( + "test profile source was not released", + )); + } + } + self.inner.snapshot(store, workspace).await + } +} + #[cfg(test)] #[derive(Debug)] struct SequencedProviderProfileSource { @@ -1320,6 +1498,92 @@ mod tests { ); } + #[test] + fn frozen_catalog_preserves_scoped_resolution_and_deduplicates_captures() { + let mut workspace_profile = profile("shared"); + workspace_profile.display_name = "Workspace override".to_string(); + let live = build_effective_profiles(vec![ + CollectedProviderProfileSnapshot { + source_id: USER_SOURCE_ID.to_string(), + revision: "user-revision".to_string(), + profiles: vec![ + scoped(ProfileScope::Platform, "shared"), + ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: workspace_profile, + }, + scoped(ProfileScope::Workspace, "workspace-only"), + ], + user_managed: true, + allow_empty: true, + }, + CollectedProviderProfileSnapshot { + source_id: "interceptor/governance".to_string(), + revision: "external-revision".to_string(), + profiles: vec![scoped(ProfileScope::Static, "managed")], + user_managed: false, + allow_empty: false, + }, + ]) + .unwrap(); + let mut captured = vec![ + live.get_profile_for_scope("shared", "").unwrap(), + live.get_profile_for_scope("shared", "default").unwrap(), + live.get_profile_for_scope("workspace-only", "default") + .unwrap(), + live.get_profile_for_scope("managed", "default").unwrap(), + ]; + captured.push(captured[0].clone()); + let frozen = EffectiveProviderProfileCatalog::from_frozen_profiles(&captured).unwrap(); + for (id, workspace) in [ + ("shared", ""), + ("shared", "default"), + ("workspace-only", ""), + ("workspace-only", "default"), + ("managed", ""), + ("managed", "default"), + ] { + assert_eq!( + frozen.get_profile_for_scope(id, workspace), + live.get_profile_for_scope(id, workspace), + "profile {id} in workspace '{workspace}'" + ); + } + captured.reverse(); + let reordered = EffectiveProviderProfileCatalog::from_frozen_profiles(&captured).unwrap(); + assert_eq!(frozen.revision(), reordered.revision()); + } + + #[test] + fn frozen_catalog_rejects_conflicting_or_invalid_captures() { + let mut first = profile("shared"); + first.source = USER_SOURCE_ID.to_string(); + first.scope = "platform".to_string(); + let mut conflicting = first.clone(); + conflicting.endpoints[0].host = "changed.example.com".to_string(); + let error = + EffectiveProviderProfileCatalog::from_frozen_profiles(&[first.clone(), conflicting]) + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!( + error + .message() + .contains("conflicting frozen provider profile") + ); + + for (source, scope) in [ + ("", "platform"), + (USER_SOURCE_ID, ""), + ("interceptor/governance", "workspace"), + (USER_SOURCE_ID, "unknown"), + ] { + let mut invalid = first.clone(); + invalid.source = source.to_string(); + invalid.scope = scope.to_string(); + assert!(EffectiveProviderProfileCatalog::from_frozen_profiles(&[invalid]).is_err()); + } + } + #[tokio::test] async fn captured_catalog_is_immutable_and_each_source_is_fetched_once() { let mut revision_a = profile("moving-profile"); diff --git a/crates/openshell-server/src/reflection.rs b/crates/openshell-server/src/reflection.rs index d8eb12482e..3feba36ca2 100644 --- a/crates/openshell-server/src/reflection.rs +++ b/crates/openshell-server/src/reflection.rs @@ -28,7 +28,11 @@ use openshell_core::Config; use crate::multiplex::GrpcRateLimiter; const REFLECTED_PROTO_ROOTS: &[&str] = &["openshell.proto"]; -const ADVERTISED_SERVICES: &[&str] = &["openshell.v1.OpenShell", "openshell.v1.SandboxUpdates"]; +const ADVERTISED_SERVICES: &[&str] = &[ + "openshell.v1.OpenShell", + "openshell.v1.SandboxUpdates", + "openshell.v1.FrozenSandboxPolicies", +]; pub type GatewayReflectionServer = ServerReflectionServer; diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index d8da420871..5aa3052943 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -20,6 +20,11 @@ mod sandbox_image { } pub(crate) use sandbox_image::{StoredSandboxImageState, StoredSandboxImageUpdate}; +mod frozen_policy { + include!(concat!(env!("OUT_DIR"), "/openshell.storage.frozen.v1.rs")); +} +pub(crate) use frozen_policy::StoredFrozenSandboxPolicy; + pub(crate) const STORAGE_FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/storage_descriptor.bin")); @@ -161,6 +166,9 @@ mod tests { // Sandbox-wide grant reporting adds TokenGrantFailureObservation to both // closures and TokenGrantStatus to public reports. Older rows have no grant // record; absent report extensions retain compatibility without a rewrite. + // Frozen mode and first-accepted policy live in a separate private record; + // existing public SandboxSpec/Status and storage.v1 retain their source shape. + // The private snapshot holds no credential values or handles. // Supervisor middleware binding policy and selectors are public-only. An // absent policy preserves dynamic discovery; older supervisors ignore both fields. // Suspend/resume adds two public request messages without durable request @@ -178,11 +186,11 @@ mod tests { // image wire identity, now with explicit presence. Compute configuration // updates use a separate capability/service and add one classified RPC. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "3b4c2af6cf22e7ed1d84279ff865acb0db7199717bee9bc041caef791334104d"; + "abb7449b7d1f455caec60ece526ee031d2febeae24d472fe577cc7fc04b20390"; const DURABLE_SCHEMA_SHA256: &str = - "cb15f8f79a855064a43fe8c6cd908bf07eca6604e2bf8ab7d2de0ef33e97da4e"; + "c7a86a700ab2beb5479b769fbe3b154f5dbbcc7bf6a7ca22845d4730bd18147c"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "c0c3ea17f105aaa6d843c466fd4240451c2105dce7675384935d4aaecdac8abf"; + "53f3864940fe0e829a1ae129fb35e379d5ab7010b738169f617a990c678bca51"; // Encoded with the schema that omitted stable_placeholder. Keep these // bytes fixed so the current encoder cannot hide a compatibility change. const LEGACY_STATIC_PROFILE: &str = "0a240a116c65676163792d70726f66696c652d6964120d6c65676163792d737461746963280712510a0d6c65676163792d73746174696312184c6567616379207374617469632063726564656e7469616c2a260a076170695f6b65791a1153594e5448455449435f4150495f4b455920012a06626561726572"; @@ -230,6 +238,12 @@ mod tests { const PEER_CONFIG_RPC_SIGNATURES: [&str; 1] = [ "openshell.v1.OpenShell/PeerNotifyConfigUpdate|.openshell.v1.PeerNotifyConfigUpdateRequest|.openshell.v1.PeerNotifyConfigUpdateResponse|false|false", ]; + const FROZEN_POLICY_RPC_SIGNATURES: &[&str] = &[ + "openshell.v1.FrozenSandboxPolicies/CreateSandbox|.openshell.v1.CreateFrozenSandboxRequest|.openshell.v1.FrozenSandboxResponse|false|false", + "openshell.v1.FrozenSandboxPolicies/GetPolicy|.openshell.v1.GetFrozenSandboxPolicyRequest|.openshell.v1.FrozenSandboxPolicyState|false|false", + "openshell.v1.FrozenSandboxPolicies/GetConfig|.openshell.v1.GetFrozenSandboxConfigRequest|.openshell.v1.FrozenSandboxConfigResponse|false|false", + "openshell.v1.FrozenSandboxPolicies/UpdateGlobalConfig|.openshell.v1.UpdateFrozenGlobalConfigRequest|.openshell.v1.UpdateFrozenGlobalConfigResponse|false|false", + ]; // Synthetic SandboxSpec bytes with log level, provider, and command fields, // emitted before the gateway-owned attachment epoch field was introduced. const PRE_READINESS_SANDBOX_SPEC: &str = @@ -244,6 +258,7 @@ mod tests { ".openshell.storage.v1.StoredProviderCredentialRefreshStateV2", ".openshell.storage.v1.StoredProviderProfile", ".openshell.storage.image.v1.StoredSandboxImageState", + ".openshell.storage.frozen.v1.StoredFrozenSandboxPolicy", ".openshell.v1.Sandbox", ".openshell.v1.SandboxWorkloadTemplate", ".openshell.v1.ServiceEndpoint", @@ -596,8 +611,10 @@ mod tests { } if !matches!( (package, service_name), - ("openshell.v1", "OpenShell" | "SandboxUpdates") - | ("openshell.inference.v1", "Inference") + ( + "openshell.v1", + "OpenShell" | "SandboxUpdates" | "FrozenSandboxPolicies" + ) | ("openshell.inference.v1", "Inference") ) { continue; } @@ -650,6 +667,12 @@ mod tests { "peer config RPC is missing or changed: {signature}" ); } + for signature in FROZEN_POLICY_RPC_SIGNATURES { + assert!( + methods.iter().any(|method| method == signature), + "frozen policy RPC is missing or changed: {signature}" + ); + } assert_eq!( compiled_method_count, 109 + PROVIDER_READINESS_RPC_SIGNATURES.len() @@ -657,14 +680,16 @@ mod tests { + PEER_CONFIG_RPC_SIGNATURES.len() + isolation_methods.len() + sandbox_protocol_methods.len() - + supervisor_host_methods.len(), + + supervisor_host_methods.len() + + FROZEN_POLICY_RPC_SIGNATURES.len(), "classify every compiled RPC" ); assert_eq!( methods.len(), 81 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() - + PEER_CONFIG_RPC_SIGNATURES.len(), + + PEER_CONFIG_RPC_SIGNATURES.len() + + FROZEN_POLICY_RPC_SIGNATURES.len(), "inventory every public gateway RPC" ); assert_eq!( @@ -723,9 +748,9 @@ mod tests { overlap_hash.as_str(), ), ( - (337, 31), - (100, 22), - (86, 22), + (353, 31), + (105, 22), + (90, 22), PUBLIC_RPC_SCHEMA_SHA256, DURABLE_SCHEMA_SHA256, PUBLIC_DURABLE_OVERLAP_SHA256 diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index b9a5546bf2..282fb393f5 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -2260,6 +2260,49 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy) modified } +fn prepare_startup_baseline_paths( + policy: &mut openshell_core::proto::SandboxPolicy, + snapshot: &openshell_core::grpc_client::FrozenSettingsPollResult, +) -> Result { + let immutable = snapshot.frozen + || (snapshot.frozen_requested + && snapshot.policy_source == openshell_core::proto::PolicySource::Global); + if !immutable { + return Ok(enrich_proto_baseline_paths(policy)); + } + if policy.network_policies.is_empty() { + return Ok(false); + } + let (read_only, read_write) = proxy_baseline_paths(); + let mut candidate = policy.clone(); + if !enrich_proto_baseline_paths_with(&mut candidate, &read_only, &read_write, |path| { + std::path::Path::new(path).exists() + }) { + return Ok(false); + } + let present = policy.filesystem.as_ref(); + let mut missing = candidate + .filesystem + .iter() + .flat_map(|fs| fs.read_only.iter().chain(&fs.read_write)) + .filter(|path| { + !present.is_some_and(|fs| fs.read_only.contains(path) || fs.read_write.contains(path)) + }) + .cloned() + .collect::>(); + missing.sort(); + missing.dedup(); + let recovery = if snapshot.frozen { + "the captured policy is incompatible with this runtime; delete and recreate the sandbox with an approved policy containing these paths" + } else { + "the global policy must include these paths before frozen initialization can be accepted; repair the global policy and retry" + }; + Err(miette::miette!( + "frozen policy is missing required proxy filesystem paths: {}; {recovery}", + missing.join(", ") + )) +} + fn strip_proto_provider_policy_entries(proto: &mut openshell_core::proto::SandboxPolicy) -> bool { openshell_policy::strip_provider_rule_names(proto) } @@ -2623,7 +2666,7 @@ trait StartupGateway: Send + Sync { fn snapshot( &self, sandbox: &str, - ) -> impl Future> + Send; + ) -> impl Future> + Send; fn provider( &self, id: &str, @@ -2633,7 +2676,7 @@ trait StartupGateway: Send + Sync { sandbox: &str, policy: &openshell_core::proto::SandboxPolicy, workspace: &str, - ) -> impl Future> + Send; + ) -> impl Future> + Send; fn report( &self, id: &str, @@ -2660,8 +2703,8 @@ impl StartupGateway for RemoteStartupGateway { async fn snapshot( &self, sandbox: &str, - ) -> Result { - openshell_core::grpc_client::fetch_settings_snapshot(self.gateway()?, sandbox).await + ) -> Result { + openshell_core::grpc_client::fetch_frozen_settings_snapshot(self.gateway()?, sandbox).await } async fn provider( &self, @@ -2674,8 +2717,8 @@ impl StartupGateway for RemoteStartupGateway { sandbox: &str, policy: &openshell_core::proto::SandboxPolicy, workspace: &str, - ) -> Result { - openshell_core::grpc_client::sync_policy_and_fetch_snapshot( + ) -> Result { + openshell_core::grpc_client::sync_policy_and_fetch_frozen_snapshot( self.gateway()?, sandbox, policy, @@ -3053,9 +3096,31 @@ async fn load_policy_with_gateway( // a sandbox-sourced policy is written back. The gateway refuses // every sandbox policy write while a global policy is active, so a // global policy keeps the added paths in this process only. - let enriched = enrich_proto_baseline_paths(&mut proto_policy); + let enriched = match prepare_startup_baseline_paths(&mut proto_policy, &snapshot) { + Ok(enriched) => enriched, + Err(error) => { + reject_startup_configuration( + gateway, + &mut rejection_log, + id, + &instance_id, + &snapshot, + &error.to_string(), + None, + ) + .await?; + if snapshot.frozen { + return Err(error); + } + reconciliation_attempts = 0; + continue; + } + }; let sync_policy = proto_sync_payload_for_enriched_policy(&proto_policy, enriched) - .filter(|_| snapshot.policy_source == openshell_core::proto::PolicySource::Sandbox); + .filter(|_| { + !snapshot.frozen + && snapshot.policy_source == openshell_core::proto::PolicySource::Sandbox + }); if let Some(sync_policy) = sync_policy { let synced = grpc_retry("Enriched policy synchronization", || { gateway.sync(sandbox, &sync_policy, &snapshot.workspace) @@ -5196,7 +5261,7 @@ network_policies: async fn snapshot( &self, _id: &str, - ) -> Result { + ) -> Result { if self.pending_snapshot { return std::future::pending().await; } @@ -5205,7 +5270,7 @@ network_policies: tonic::Status::new(code, "snapshot unavailable"), )); } - Ok(self.desired.lock().unwrap().clone()) + Ok(self.desired.lock().unwrap().clone().into()) } async fn provider( &self, @@ -5220,7 +5285,7 @@ network_policies: _sandbox: &str, _policy: &openshell_core::proto::SandboxPolicy, _workspace: &str, - ) -> Result { + ) -> Result { self.snapshot("").await } async fn report( @@ -5728,6 +5793,8 @@ network_policies: /// script rejection-report errors to exercise retries and fatal failures. #[derive(Clone)] struct WriteRefusingStartupGateway { + frozen: bool, + frozen_requested: bool, desired: Arc>, refusals: Arc>>, repair_after_refusal: Option, @@ -5739,9 +5806,13 @@ network_policies: async fn snapshot( &self, _sandbox: &str, - ) -> Result { + ) -> Result { self.calls.send(StartupCall::Snapshot).unwrap(); - Ok(self.desired.lock().unwrap().clone()) + Ok(openshell_core::grpc_client::FrozenSettingsPollResult { + snapshot: self.desired.lock().unwrap().clone(), + frozen: self.frozen, + frozen_requested: self.frozen_requested || self.frozen, + }) } async fn provider( &self, @@ -5756,7 +5827,7 @@ network_policies: _sandbox: &str, policy: &openshell_core::proto::SandboxPolicy, _workspace: &str, - ) -> Result { + ) -> Result { self.calls.send(StartupCall::Sync).unwrap(); let mut desired = self.desired.lock().unwrap(); // The gateway checks for a global policy before it validates the @@ -5787,7 +5858,11 @@ network_policies: version, openshell_core::proto::PolicySource::Sandbox, ); - Ok(desired.clone()) + Ok(openshell_core::grpc_client::FrozenSettingsPollResult { + snapshot: desired.clone(), + frozen: self.frozen, + frozen_requested: self.frozen_requested || self.frozen, + }) } async fn report( &self, @@ -5891,6 +5966,8 @@ network_policies: .collect(); let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); let gateway = WriteRefusingStartupGateway { + frozen: false, + frozen_requested: false, desired: Arc::new(std::sync::Mutex::new(initial)), refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from( refusals, @@ -6041,6 +6118,8 @@ network_policies: let diagnostic = format!("Gateway rejected the {write}: {}", refusal.message); let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); let gateway = WriteRefusingStartupGateway { + frozen: false, + frozen_requested: false, desired: Arc::new(std::sync::Mutex::new(initial)), refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from([ refusal, @@ -6153,6 +6232,8 @@ network_policies: let snapshot = unset_policy_snapshot(); let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); let gateway = WriteRefusingStartupGateway { + frozen: false, + frozen_requested: false, desired: Arc::new(std::sync::Mutex::new(snapshot.clone())), refusals: Arc::default(), repair_after_refusal: None, @@ -6363,19 +6444,36 @@ network_policies: #[cfg(unix)] #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_global_policy_skips_baseline_write_back() { + for (source, frozen) in [ + (openshell_core::proto::PolicySource::Global, false), + (openshell_core::proto::PolicySource::Sandbox, true), + ] { + assert_fixed_policy_skips_baseline_write_back(source, frozen).await; + } + } + + async fn assert_fixed_policy_skips_baseline_write_back( + source: openshell_core::proto::PolicySource, + frozen: bool, + ) { use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; // The gateway serves a global policy that has network rules but lacks // the proxy baseline paths. It refuses every sandbox policy write while // that policy is active, so startup must add the paths locally and // install the policy without writing it back. - let mut global = - settings_poll_result(Some(proto_tcp_policy_fixture()), 1, PolicySource::Global); - global.global_policy_version = 1; + let mut initial = proto_tcp_policy_fixture(); + if frozen { + enrich_proto_baseline_paths(&mut initial); + } + let mut global = settings_poll_result(Some(initial), 1, source); + global.global_policy_version = u32::from(source == PolicySource::Global); let global_generation = report_generation(&global); let mut enriched = proto_tcp_policy_fixture(); assert!(enrich_proto_baseline_paths(&mut enriched)); let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); let gateway = WriteRefusingStartupGateway { + frozen, + frozen_requested: frozen, desired: Arc::new(std::sync::Mutex::new(global)), refusals: Arc::default(), repair_after_refusal: None, @@ -6398,10 +6496,10 @@ network_policies: ), ) .await - .expect("startup must not wait for repair under a global policy") + .expect("startup must not wait for repair under a fixed policy") .unwrap_or_else(|error| { panic!( - "startup exited under a global policy: {}", + "startup exited under a fixed policy: {}", startup_error_chain(&error) ) }); @@ -6424,21 +6522,101 @@ network_policies: StartupCall::Snapshot, report(ConfigurationAdmissionState::Accepted), ], - "startup must not write a global policy back as a sandbox policy" + "startup must not write a fixed policy back as a sandbox policy" ); assert_eq!( bundle.2, Some(enriched), - "startup installs the global policy with the baseline paths added" + "startup installs the fixed policy with the baseline paths added" ); let LoadedPolicyOrigin::Gateway { revision: Some(revision), .. } = bundle.4 else { - panic!("startup must bind the global policy to its gateway revision"); + panic!("startup must bind the fixed policy to its gateway revision"); }; - assert_eq!(revision.policy_source, PolicySource::Global); + assert_eq!(revision.policy_source, source); + } + + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn frozen_startup_rejects_implicit_baseline_expansion() { + use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + for captured in [false, true] { + let source = if captured { + PolicySource::Sandbox + } else { + PolicySource::Global + }; + let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = WriteRefusingStartupGateway { + frozen: captured, + frozen_requested: true, + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + Some(proto_tcp_policy_fixture()), + 1, + source, + ))), + refusals: Arc::default(), + repair_after_refusal: None, + rejection_report_errors: Arc::default(), + calls, + }; + let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); + let mut startup = Box::pin(load_policy_with_gateway( + Some("sandbox-id".into()), + Some("sandbox".into()), + Some(test_gateway()), + None, + None, + &extension_credentials, + LocalPolicyIdentity::Required, + None, + Some(ImagePolicyDiscovery::Missing), + &gateway, + )); + let mut trace = Vec::new(); + loop { + tokio::select! { + biased; + call = observed.recv() => { + let call = call.expect("startup records its rejection"); + let rejected = matches!(&call, StartupCall::Report(report) if report.state == ConfigurationAdmissionState::Rejected); + trace.push(call); + if rejected { break; } + } + result = &mut startup => panic!( + "startup completed before rejecting a frozen expansion: {}", + result.err().map_or_else(|| "accepted".to_string(), |error| error.to_string()) + ), + } + } + assert!(!trace.iter().any(|call| matches!( + call, + StartupCall::Sync + | StartupCall::Report(StartupReport { + state: ConfigurationAdmissionState::Accepted, + .. + }) + ))); + if captured { + let error = startup + .await + .err() + .expect("an incompatible captured policy cannot be repaired in place"); + assert!(error.to_string().contains("delete and recreate")); + } else { + let mut repaired = proto_tcp_policy_fixture(); + enrich_proto_baseline_paths(&mut repaired); + *gateway.desired.lock().unwrap() = + settings_poll_result(Some(repaired.clone()), 2, source); + let result = startup + .await + .expect("a baseline-complete global repair is accepted"); + assert_eq!(result.2, Some(repaired)); + } + } } #[tokio::test(start_paused = true)] @@ -6469,6 +6647,8 @@ network_policies: ] { let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); let gateway = WriteRefusingStartupGateway { + frozen: false, + frozen_requested: false, desired: Arc::new(std::sync::Mutex::new(initial.clone())), refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from([ SyncRefusal::new(code, "sandbox caller cannot write this policy"), diff --git a/docs/how-it-works/policies/manage-policies.mdx b/docs/how-it-works/policies/manage-policies.mdx index c20af7b67b..461b17e750 100644 --- a/docs/how-it-works/policies/manage-policies.mdx +++ b/docs/how-it-works/policies/manage-policies.mdx @@ -37,6 +37,43 @@ openshell sandbox create --name my-sandbox Without either, the sandbox uses a policy from its image or the restrictive [default policy](/how-it-works/policies/default-policy). +## Freeze a Sandbox Policy + +Use `--frozen` to keep a sandbox's first accepted effective policy for its lifetime. +All gateways sharing its store must support frozen policies before you create +a frozen sandbox. Do not downgrade those gateways while frozen sandboxes exist. + +```shell +openshell sandbox create --name review-bot \ + --policy ./approved-policy.yaml --provider github --frozen --detach +openshell sandbox get review-bot +openshell policy get review-bot --full +``` + +The snapshot includes any global override and attached provider profiles and +authorization. Later global-policy or profile changes leave it unchanged, even +across restarts. Sandbox and image policies receive required proxy filesystem +paths before the snapshot is captured. A global policy must already include +those paths; otherwise initialization is rejected until you repair it. +An upgraded runtime that requires additional paths refuses to start a captured +policy. Review the required paths and recreate the sandbox instead of extending +its existing access. + +After the configuration is accepted, policy edits, proposal approvals, and +provider attach or detach fail. Recreate the sandbox to change its policy or +providers. You cannot enable or disable freezing on an existing sandbox. + +Credentials and tokens still rotate. Changing a provider's identity, +configuration, or refresh authorization withholds that provider's credentials +without changing the frozen policy. + +`sandbox get` shows a pending freeze before initialization and the frozen hash +and timestamp afterward. `policy get --full` returns the saved effective policy. + +Verify approved policy and profile artifacts in a trusted deployment step. +Freezing preserves the accepted policy; it does not verify approval or +provide a pre-launch approval gate. + ## Ship a Policy in an Image To include a policy in a sandbox image, add it at `/etc/openshell/policy.yaml`: @@ -55,9 +92,9 @@ does not fall back to the default policy. A sandbox's policy has two views. The base policy is the policy you set for the sandbox. The effective policy is the base policy plus any rules that attached -providers add, and the sandbox enforces it once the latest revision loads. While -a global policy is active, both views show the global policy. `--base` shows the -base policy, and `--full` shows the effective policy: +providers add, and the sandbox enforces it once the latest revision loads. +While a global policy is active, non-frozen sandboxes show it in both views. +`--base` shows the base policy, and `--full` shows the effective policy: ```shell openshell policy get my-sandbox --base @@ -297,10 +334,10 @@ filesystem and process changes still apply. ## Apply a Global Policy -A gateway administrator can apply one policy to every sandbox on the gateway. -The global policy replaces each sandbox's policy and blocks sandbox policy -changes until you delete it. These commands require the platform administrator -role: +A gateway administrator can apply one policy across the gateway. Sandboxes with +a frozen policy keep their accepted snapshot. For other sandboxes, the global +policy replaces the sandbox policy and blocks sandbox policy changes until you +delete it. These commands require the platform administrator role: | Task | Command | |---|---| @@ -309,9 +346,8 @@ role: | View global policy history. | `openshell policy list --global` | | Remove the global policy and restore normal policy selection. | `openshell policy delete --global` | -The set and delete commands ask for confirmation, because each one changes the -network access of every sandbox on the gateway. Running sandboxes load a new -global policy at their next configuration check, within about 10 seconds. +The set and delete commands ask for confirmation. Running sandboxes without a +frozen policy load a new global policy within about 10 seconds. `--wait` is not available for global policies, so test traffic in a sandbox to confirm a change. After you delete the global policy, `policy get --global` still shows the last one, with the status `Superseded`. Deleting the global diff --git a/docs/how-it-works/policies/overview.mdx b/docs/how-it-works/policies/overview.mdx index d5800bf0ac..8a3601e13e 100644 --- a/docs/how-it-works/policies/overview.mdx +++ b/docs/how-it-works/policies/overview.mdx @@ -33,10 +33,16 @@ specific time: | `network_middlewares` | Additional inspection, transformation, or blocking of traffic that network rules allow. | Sandbox network proxy. | While the sandbox runs. | Filesystem, Landlock, and process settings are fixed once the sandbox starts. -Network rules and middleware can change while it runs, as described in [How +Network rules and middleware can change while it runs, unless the sandbox was +created with `--frozen`, as described in [How Changes Take Effect](/how-it-works/policies/manage-policies#how-changes-take-effect). +A [frozen sandbox](/how-it-works/policies/manage-policies#freeze-a-sandbox-policy) +keeps its first accepted effective policy and provider authorization until it +is deleted. Later global-policy and provider-profile changes apply to live +sandboxes; frozen sandboxes retain their captured policy. + Network rules make up most of a typical policy. OpenShell denies every outbound connection from a sandbox unless a rule in `network_policies` allows it. Each rule lists the destinations it allows and the binaries that can reach them, and @@ -81,8 +87,9 @@ both views. ### Global Policy -A gateway administrator can apply one policy to every sandbox on the gateway. -The global policy replaces each sandbox's own policy rather than limiting it. +A gateway administrator can apply one policy across the gateway. The global +policy replaces a sandbox's own policy rather than limiting it. Frozen +sandboxes keep their accepted snapshot. While it is active, OpenShell blocks sandbox policy changes and proposal approvals, and suppresses provider-contributed network rules. Deleting the global policy restores normal policy selection and provider rules. Refer to diff --git a/proto/openshell.proto b/proto/openshell.proto index b40ec68263..3a59a84737 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -874,6 +874,135 @@ service SandboxUpdates { } } +// Opt-in frozen policy operations. Existing sandbox messages and the OpenShell +// service retain their source shape on the 0.1.x release train. +service FrozenSandboxPolicies { + // Atomically choose frozen mode when creating a sandbox, including from a template. + rpc CreateSandbox(CreateFrozenSandboxRequest) returns (FrozenSandboxResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + workspace_role: "user" + }; + } + // Read the creation mode and first-accepted snapshot. An absent policy is pending. + rpc GetPolicy(GetFrozenSandboxPolicyRequest) returns (FrozenSandboxPolicyState) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + workspace_role: "user" + }; + } + // Runtime delivery includes an explicit captured-policy signal without extending + // the stable PolicySource enum. Uncaptured policies retain normal startup behavior. + rpc GetConfig(GetFrozenSandboxConfigRequest) returns (FrozenSandboxConfigResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "dual" + scope: "config:read" + workspace_role: "user" + }; + } + // Update gateway-global policy and report sandboxes retaining captured policy. + // global must be true; the ordinary UpdateConfig response remains unchanged. + rpc UpdateGlobalConfig(UpdateFrozenGlobalConfigRequest) + returns (UpdateFrozenGlobalConfigResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "config:write" + global_role: "platform_admin" + }; + } +} + +// Frozen mode is chosen at creation; it cannot be enabled or reset later. +message FrozenSandboxPolicyState { + bool requested = 1; + // Absent until the first successfully accepted configuration is captured. + FrozenSandboxPolicy policy = 2; +} + +message FrozenSandboxResponse { + Sandbox sandbox = 1; + FrozenSandboxPolicyState frozen_policy = 2; + map service_urls = 3; +} + +message FrozenSandboxConfigResponse { + openshell.sandbox.v1.GetSandboxConfigResponse config = 1; + // True only after the authoritative first-accepted snapshot was captured. + bool frozen = 2; + // Chosen at creation, including before the first configuration is accepted. + bool frozen_requested = 3; +} + +message UpdateFrozenGlobalConfigResponse { + UpdateConfigResponse update = 1; + // Workspace-qualified names that retain a captured policy. + repeated string frozen_sandboxes = 2; +} + +message CreateFrozenSandboxRequest { + // Workspace scope. Only a named workspace selection is accepted. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 7; + SandboxSpec spec = 1; + // Optional user-supplied sandbox name. When empty the server generates one. + string name = 2; + // Optional labels for the sandbox (key-value metadata). + map labels = 3; + // Optional annotations for the sandbox (non-selector metadata). Keys under + // the `internal.openshell.ai/` prefix are reserved for the gateway. + map annotations = 4; + // One-shot launch hint indicating that the creating client will attach to + // the canonical main process. The supervisor keeps the terminal transport + // alive until that attachment connects and closes naturally. + bool await_main_process_attachment = 5; + // Workspace-scoped SandboxWorkloadTemplate name to resolve at creation time. + string workload_template = 6; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 8; + // HTTP services to expose when the sandbox is created. Endpoints are + // registered after the sandbox has been persisted and route only while the + // sandbox is ready. + repeated SandboxServiceExposure service_exposures = 9; +} + +message GetFrozenSandboxPolicyRequest { + // Workspace scope. Only a named workspace selection is accepted. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 2; + string name = 1; +} + +message GetFrozenSandboxConfigRequest { + // Workspace scope. Select one named workspace; authenticated sandbox callers may omit it during bootstrap. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + string name = 1; +} + +// Global-only counterpart of UpdateConfig. The mirrored sandbox-only fields +// retain familiar request mapping but must remain absent/empty; the gateway +// rejects them rather than silently ignoring a requested operation. +message UpdateFrozenGlobalConfigRequest { + // Must be omitted for this global operation. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 10; + openshell.sandbox.v1.SandboxPolicy policy = 2; + string setting_key = 3; + openshell.sandbox.v1.SettingValue setting_value = 4; + bool delete_setting = 5; + // Required to be true. + bool global = 6; + // Unsupported at global scope; must be empty. + repeated PolicyMergeOperation merge_operations = 7; + // Unsupported at global scope; must be zero. + uint64 expected_resource_version = 8; + // Unsupported at global scope; must be empty. + map annotations = 9; + // Unsupported at global scope; must be empty. + string sandbox = 1; + // Optional nonzero UUID for durable at-most-once admission. + string request_id = 11; +} + // IssueSandboxToken request. Empty body; identity is established by the // authentication credentials carried in the request headers (a projected // Kubernetes ServiceAccount JWT in the K8s driver path). @@ -1260,6 +1389,31 @@ message SandboxStatus { TokenGrantFailureObservation last_token_grant_failure = 17; } +// Non-secret policy and provider authorization captured by frozen creation. +message FrozenSandboxPolicy { + openshell.sandbox.v1.SandboxPolicy policy = 1; + uint32 version = 2; + string policy_hash = 3; + google.protobuf.Timestamp frozen_time = 4; + // Original selection before the policy became frozen. + openshell.sandbox.v1.PolicySource policy_source = 5; + uint32 global_policy_version = 6; + repeated FrozenSandboxProvider providers = 7; +} + +// Provider authorization only. Credential values and handles are never copied. +message FrozenSandboxProvider { + string provider = 1; + // Internal immutable identity prevents same-name provider replacement. + string provider_id = 2; + string type = 3; + string profile_workspace = 4; + map config = 5; + ProviderProfile profile = 6; + // Automatic token rotation preserves these epochs; reauthorization does not. + map refresh_authorization_epochs = 7; +} + // User-facing sandbox condition derived from platform or gateway observations. message SandboxCondition { reserved 5; diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index c99c3b48e0..eea4e50fcf 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -12,6 +12,9 @@ ClientCredentialsAuth, ExecChunk, ExecResult, + FrozenGlobalConfigUpdate, + FrozenPolicyState, + FrozenSandboxPolicyRef, Page, Pager, Sandbox, @@ -45,6 +48,9 @@ "ExecChunk", "ExecResult", "FieldViolation", + "FrozenGlobalConfigUpdate", + "FrozenPolicyState", + "FrozenSandboxPolicyRef", "GatewayError", "Page", "Pager", diff --git a/python/openshell/frozen_policy_test.py b/python/openshell/frozen_policy_test.py new file mode 100644 index 0000000000..51e5731502 --- /dev/null +++ b/python/openshell/frozen_policy_test.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Optional frozen-policy operations over the existing authenticated channel.""" + +from concurrent import futures +from contextlib import contextmanager + +import grpc +import pytest + +from openshell import GatewayError, SandboxClient, SandboxError +from openshell._proto import ( + datamodel_pb2, + openshell_pb2, + openshell_pb2_grpc, + sandbox_pb2, +) + + +class LegacyService(openshell_pb2_grpc.OpenShellServicer): + def __init__(self): + self.create_calls = 0 + + def CreateSandbox(self, request, context): + del request + self.create_calls += 1 + context.abort(grpc.StatusCode.INTERNAL, "ordinary creation must not be used") + + +class FrozenService(openshell_pb2_grpc.FrozenSandboxPoliciesServicer): + def __init__(self, acknowledged=True): + self.acknowledged = acknowledged + self.create_request: openshell_pb2.CreateFrozenSandboxRequest | None = None + self.get_request: openshell_pb2.GetFrozenSandboxPolicyRequest | None = None + self.update_request: openshell_pb2.UpdateFrozenGlobalConfigRequest | None = None + self.state = openshell_pb2.FrozenSandboxPolicyState(requested=True) + + def CreateSandbox(self, request, context): + del context + self.create_request = request + response = openshell_pb2.FrozenSandboxResponse( + sandbox=openshell_pb2.Sandbox( + metadata=datamodel_pb2.ObjectMeta( + id="created-id", name=request.name, workspace="staging" + ) + ), + service_urls={"web": "https://web.example"}, + ) + if self.acknowledged is not None: + response.frozen_policy.requested = self.acknowledged + return response + + def GetPolicy(self, request, context): + del context + self.get_request = request + return self.state + + def UpdateGlobalConfig(self, request, context): + del context + self.update_request = request + return openshell_pb2.UpdateFrozenGlobalConfigResponse( + update=openshell_pb2.UpdateConfigResponse(version=7), + frozen_sandboxes=["staging/frozen"], + ) + + +@contextmanager +def client_for(service): + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + legacy = LegacyService() + openshell_pb2_grpc.add_OpenShellServicer_to_server(legacy, server) + if service is not None: + openshell_pb2_grpc.add_FrozenSandboxPoliciesServicer_to_server(service, server) + port = server.add_insecure_port("127.0.0.1:0") + server.start() + try: + with SandboxClient(f"127.0.0.1:{port}") as client: + yield client, legacy + finally: + server.stop(0).wait() + + +@pytest.mark.parametrize("acknowledged", [None, False, True]) +@pytest.mark.parametrize("template", [False, True]) +def test_frozen_create_uses_optional_service_and_requires_acknowledgement( + acknowledged, template +): + service = FrozenService(acknowledged) + with client_for(service) as (client, legacy): + kwargs = { + "workspace": "staging", + "name": "frozen", + "spec": openshell_pb2.SandboxSpec( + providers=["github"], policy=sandbox_pb2.SandboxPolicy(version=1) + ), + } + if template: + kwargs["workload_template"] = "agent" + create = ( + client.create_frozen_from_template if template else client.create_frozen + ) + if acknowledged: + sandbox = create(**kwargs) + assert sandbox.service_urls == {"web": "https://web.example"} + else: + with pytest.raises( + SandboxError, match="did not acknowledge frozen creation" + ) as error: + create(**kwargs) + assert "created-id" in str(error.value) + assert legacy.create_calls == 0 + request = service.create_request + assert request is not None + assert request.workspace_scope.workspace == "staging" + assert list(request.spec.providers) == ["github"] + assert request.spec.policy.version == 1 + assert request.workload_template == ("agent" if template else "") + + +def test_older_gateway_returns_unimplemented_without_creating(): + with client_for(None) as (client, legacy): + with pytest.raises(GatewayError) as error: + client.create_frozen(workspace="staging", name="frozen") + assert error.value.code() == grpc.StatusCode.UNIMPLEMENTED + assert legacy.create_calls == 0 + + +def test_frozen_policy_pending_capture_and_global_exceptions(): + service = FrozenService() + with client_for(service) as (client, _legacy): + pending = client.get_frozen_policy("frozen", workspace="staging") + assert pending.requested and pending.policy is None + service.state.policy.CopyFrom( + openshell_pb2.FrozenSandboxPolicy( + policy=sandbox_pb2.SandboxPolicy(version=1), + version=3, + policy_hash="accepted-hash", + policy_source=sandbox_pb2.POLICY_SOURCE_GLOBAL, + global_policy_version=7, + ) + ) + service.state.policy.frozen_time.FromMilliseconds(600_000) + state = client.get_frozen_policy("frozen", workspace="staging") + assert state.policy is not None + assert state.policy.policy.version == 1 + assert state.policy.policy_hash == "accepted-hash" + assert state.policy.frozen_at_ms == 600_000 + assert state.policy.policy_source == sandbox_pb2.POLICY_SOURCE_GLOBAL + assert service.get_request is not None + assert service.get_request.workspace_scope.workspace == "staging" + request = openshell_pb2.UpdateFrozenGlobalConfigRequest( + policy=sandbox_pb2.SandboxPolicy(version=1) + ) + result = client.update_global_config(request) + assert result.update.version == 7 + assert result.frozen_sandboxes == ("staging/frozen",) + assert getattr(service.update_request, "global") is True + assert getattr(request, "global") is False diff --git a/python/openshell/providers_test.py b/python/openshell/providers_test.py index b8a9b20152..e0439f9c1f 100644 --- a/python/openshell/providers_test.py +++ b/python/openshell/providers_test.py @@ -212,6 +212,7 @@ def stub(c): return SimpleNamespace() monkeypatch.setattr(openshell_pb2_grpc, "OpenShellStub", stub) + monkeypatch.setattr(openshell_pb2_grpc, "FrozenSandboxPoliciesStub", stub) def reject_bearer(*_args): pytest.fail("operator connection must not attach a bearer interceptor") @@ -222,7 +223,7 @@ def reject_bearer(*_args): tls=TlsConfig(ca_path=ca, cert_path=cert, key_path=key), ) as connection: assert isinstance(connection.providers(), ProviderClient) - assert stub_channels == [channel, channel] + assert stub_channels == [channel, channel, channel] assert seen == { "root_certificates": b"ca", "private_key": b"operator-key", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index c02a33cdbf..c47167f85b 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -28,6 +28,7 @@ datamodel_pb2, openshell_pb2, openshell_pb2_grpc, + sandbox_pb2, ) from .errors import _error_mapping_channel, from_grpc_error from .mutations import DeletionOutcome, DeletionResult @@ -515,6 +516,34 @@ class SandboxConditionRef: transition_time_ms: int | None = None +@dataclass(frozen=True) +class FrozenSandboxPolicyRef: + """Immutable snapshot identity and a copied effective policy.""" + + policy: sandbox_pb2.SandboxPolicy | None + version: int + policy_hash: str + frozen_at_ms: int | None + policy_source: int + global_policy_version: int + + +@dataclass(frozen=True) +class FrozenPolicyState: + """A requested freeze is pending until its policy is present.""" + + requested: bool + policy: FrozenSandboxPolicyRef | None = None + + +@dataclass(frozen=True) +class FrozenGlobalConfigUpdate: + """Global mutation result with names retaining a captured policy.""" + + update: openshell_pb2.UpdateConfigResponse + frozen_sandboxes: tuple[str, ...] = () + + @dataclass(frozen=True) class SandboxStatusRef: phase: int @@ -785,6 +814,9 @@ def __init__( _error_mapping_channel(self._channel) ) self._owners = _OwnerCache() + self._frozen_stub = openshell_pb2_grpc.FrozenSandboxPoliciesStub( + _error_mapping_channel(self._channel) + ) @classmethod def from_active_cluster( @@ -989,6 +1021,124 @@ def create_session( ), ) + def create_frozen( + self, + *, + workspace: str, + spec: openshell_pb2.SandboxSpec | None = None, + name: str | None = None, + labels: Mapping[str, str] | None = None, + service_exposures: Sequence[ServiceExposure] | None = None, + ) -> SandboxRef: + """Create through the optional frozen-policy service without a legacy fallback.""" + return self._create_frozen( + workspace=workspace, + spec=spec if spec is not None else _default_spec(), + name=name, + labels=labels, + service_exposures=service_exposures, + ) + + def create_frozen_from_template( + self, + *, + workspace: str, + workload_template: str, + spec: openshell_pb2.SandboxSpec | None = None, + name: str | None = None, + labels: Mapping[str, str] | None = None, + service_exposures: Sequence[ServiceExposure] | None = None, + ) -> SandboxRef: + """Create a frozen sandbox using a workspace-scoped workload template.""" + if not workload_template.strip(): + raise SandboxError("workload_template is required") + return self._create_frozen( + workspace=workspace, + workload_template=workload_template, + spec=spec if spec is not None else openshell_pb2.SandboxSpec(), + name=name, + labels=labels, + service_exposures=service_exposures, + ) + + def _create_frozen( + self, + *, + workspace: str, + spec: openshell_pb2.SandboxSpec, + name: str | None, + labels: Mapping[str, str] | None, + service_exposures: Sequence[ServiceExposure] | None, + workload_template: str = "", + ) -> SandboxRef: + response = self._frozen_stub.CreateSandbox( + openshell_pb2.CreateFrozenSandboxRequest( + workspace_scope=_workspace_scope(workspace), + spec=spec, + name=name or "", + labels=dict(labels) if labels else {}, + workload_template=workload_template, + service_exposures=_service_exposure_messages(service_exposures), + ), + timeout=self._timeout, + ) + sandbox = _sandbox_ref(response.sandbox, response.service_urls) + if not sandbox.id: + raise SandboxError("CreateSandbox returned empty sandbox id") + if ( + not response.HasField("frozen_policy") + or not response.frozen_policy.requested + ): + raise SandboxError( + "gateway did not acknowledge frozen creation for sandbox " + f"'{sandbox.name}' (ID '{sandbox.id}', workspace '{workspace}'); " + "remove the sandbox before retrying" + ) + return sandbox + + def get_frozen_policy(self, name: str, *, workspace: str) -> FrozenPolicyState: + """Read a pending freeze or the first accepted policy snapshot.""" + response = self._frozen_stub.GetPolicy( + openshell_pb2.GetFrozenSandboxPolicyRequest( + workspace_scope=_workspace_scope(workspace), name=name + ), + timeout=self._timeout, + ) + policy = None + if response.HasField("policy"): + snapshot = response.policy + body = None + if snapshot.HasField("policy"): + body = sandbox_pb2.SandboxPolicy() + body.CopyFrom(snapshot.policy) + policy = FrozenSandboxPolicyRef( + policy=body, + version=snapshot.version, + policy_hash=snapshot.policy_hash, + frozen_at_ms=snapshot.frozen_time.ToMilliseconds() + if snapshot.HasField("frozen_time") + else None, + policy_source=snapshot.policy_source, + global_policy_version=snapshot.global_policy_version, + ) + return FrozenPolicyState(requested=response.requested, policy=policy) + + def update_global_config( + self, request: openshell_pb2.UpdateFrozenGlobalConfigRequest + ) -> FrozenGlobalConfigUpdate: + """Update global configuration and report frozen-policy exceptions.""" + global_request = openshell_pb2.UpdateFrozenGlobalConfigRequest() + global_request.CopyFrom(request) + setattr(global_request, "global", True) + response = self._frozen_stub.UpdateGlobalConfig( + global_request, timeout=self._timeout + ) + if not response.HasField("update"): + raise SandboxError("global update result missing from gateway response") + update = openshell_pb2.UpdateConfigResponse() + update.CopyFrom(response.update) + return FrozenGlobalConfigUpdate(update, tuple(response.frozen_sandboxes)) + def create_session_from_template( self, *, diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 8b64c9768d..7a5125661b 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -25,6 +25,19 @@ npm install @nvidia/openshell-sdk ## Usage +Frozen sandbox creation uses `client.sandbox.createFrozen(spec)` or +`createFrozenFromTemplate(spec)`, with the existing create spec shape. These +methods use a distinct optional service and fail on older gateways before +creating a sandbox. They return the ordinary `SandboxRef`; use +`getFrozenPolicy(name)` to read the requested mode and optional accepted policy, +hash and timestamp. An absent policy means the requested freeze is pending. +After capture, access changes require sandbox recreation. + +`client.sandbox.updateGlobalConfig({ policy })` returns the global mutation in +`update` and workspace-qualified names in `frozenSandboxes` that retain their +captured policies. Existing configuration result and policy-source types keep +their original shapes. + ```ts import { OpenShellClient } from '@nvidia/openshell-sdk' diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 205796f1bd..f0c31d98dd 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -19,6 +19,8 @@ import { type CallOptions, type Client, Code, createClient, type Transport } fro import { errorCode, fromConnect, SdkError } from './errors.js'; import type { Provider, WorkspaceSelectorSchema } from './gen/datamodel_pb.js'; import type { + FrozenSandboxPolicyState, + FrozenSandboxResponse, Sandbox, SandboxLogLine, SandboxStreamEvent, @@ -28,6 +30,7 @@ import type { } from './gen/openshell_pb.js'; import { type ExecSandboxInputSchema, + FrozenSandboxPolicies, OpenShell, ServiceAuthorizationMode as ProtoServiceAuthorizationMode, SandboxPhase, @@ -565,6 +568,36 @@ export interface UpdateConfigResult { deleted: boolean; } +/** Immutable accepted policy, exposed without changing ordinary sandbox references. */ +export interface FrozenSandboxPolicy { + policy?: SandboxPolicy; + version: number; + policyHash: string; + frozenAtMs?: number; + policySource: PolicySourceName; + globalPolicyVersion: number; +} + +/** A requested freeze is pending until policy is present. */ +export interface FrozenPolicyState { + requested: boolean; + policy?: FrozenSandboxPolicy; +} + +export interface FrozenGlobalConfigUpdateSpec { + policy?: MessageInitShape; + settingKey?: string; + settingValue?: MessageInitShape; + deleteSetting?: boolean; + requestId?: string; +} + +/** Result of the optional global update operation, including frozen exceptions. */ +export interface FrozenGlobalConfigUpdate { + update: UpdateConfigResult; + frozenSandboxes: string[]; +} + // ---- enum → lowercase string ----------------------------------------------- // Exported for the enum-name drift test only; not re-exported from index.ts, so @@ -703,6 +736,86 @@ function updateConfigResult(resp: UpdateConfigResponse): UpdateConfigResult { }; } +function frozenPolicyState(state: FrozenSandboxPolicyState): FrozenPolicyState { + const policy = state.policy; + return { + requested: state.requested, + policy: policy + ? { + policy: policy.policy, + version: policy.version, + policyHash: policy.policyHash, + frozenAtMs: timestampEpochMs(policy.frozenTime), + policySource: POLICY_SOURCE_NAMES[policy.policySource] ?? 'unspecified', + globalPolicyVersion: policy.globalPolicyVersion, + } + : undefined, + }; +} + +function frozenSandboxFromResponse(response: FrozenSandboxResponse): SandboxRef { + const sandbox = sandboxRef(response.sandbox, response.serviceUrls); + if (!response.frozenPolicy?.requested) { + throw new SdkError( + 'invalid_config', + `gateway did not acknowledge frozen creation for sandbox '${sandbox.name}' (ID '${sandbox.id}', workspace '${sandbox.workspace}'); remove the sandbox before retrying`, + ); + } + return sandbox; +} + +function createSandboxRequest( + spec: SandboxSpec, +): Omit, '$typeName'> { + const specInit: MessageInitShape = { + environment: spec.environment ?? {}, + providers: spec.providers ?? [], + template: spec.image ? { image: spec.image } : undefined, + resourceRequirements: spec.gpu ? { gpu: {} } : undefined, + policy: spec.policy, + command: spec.command ?? [], + tty: spec.tty ?? false, + restartPolicy: restartPolicyValue(spec.restartPolicy), + }; + if (spec.rawSpec) Object.assign(specInit, spec.rawSpec); + return { + workspaceScope: workspaceScope(spec), + name: spec.name ?? '', + labels: spec.labels ?? {}, + spec: specInit, + serviceExposures: + spec.serviceExposures?.map((exposure) => ({ + service: exposure.service ?? '', + targetPort: exposure.targetPort, + authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode), + })) ?? [], + }; +} + +function createSandboxTemplateRequest( + spec: SandboxFromTemplateSpec, +): Omit, '$typeName'> { + if (spec.workloadTemplate.trim() === '') throw new SdkError('invalid_config', 'workloadTemplate is required'); + return { + workspaceScope: workspaceScope(spec), + name: spec.name ?? '', + labels: spec.labels ?? {}, + spec: { + providers: spec.providers ?? [], + command: spec.command ?? [], + tty: spec.tty ?? false, + policy: spec.policy, + }, + workloadTemplate: spec.workloadTemplate, + serviceExposures: + spec.serviceExposures?.map((exposure) => ({ + service: exposure.service ?? '', + targetPort: exposure.targetPort, + authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode), + })) ?? [], + }; +} + // Optimistic-concurrency version pin: absent/empty means 0n (server uses the // current version, backward-compatible). A mismatch surfaces as Aborted → // SdkError code 'aborted'. @@ -1184,6 +1297,7 @@ export class SandboxTemplateClient { // transport (one connection) across all of its scoped clients. export class SandboxClient { private readonly grpc: Client; + private readonly frozenGrpc: Client; /** * Advanced escape hatch: a generated client for every gateway RPC, including @@ -1201,6 +1315,44 @@ export class SandboxClient { this.transport = transport; this.grpc = grpc; this.raw = this.grpc; + this.frozenGrpc = createClient(FrozenSandboxPolicies, transport); + } + + /** Create through the optional frozen-policy service; never fall back to ordinary creation. */ + async createFrozen(spec: SandboxSpec): Promise { + try { + return frozenSandboxFromResponse(await this.frozenGrpc.createSandbox(createSandboxRequest(spec))); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async createFrozenFromTemplate(spec: SandboxFromTemplateSpec): Promise { + try { + return frozenSandboxFromResponse(await this.frozenGrpc.createSandbox(createSandboxTemplateRequest(spec))); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async getFrozenPolicy(name: string, options?: SandboxCallOptions | null): Promise { + try { + return frozenPolicyState( + await this.frozenGrpc.getPolicy(namedTarget(name, options), requestCallOptions(options)), + ); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async updateGlobalConfig(update: FrozenGlobalConfigUpdateSpec): Promise { + try { + const response = await this.frozenGrpc.updateGlobalConfig({ ...update, global: true }); + if (!response.update) throw new SdkError('invalid_config', 'global update result missing from gateway response'); + return { update: updateConfigResult(response.update), frozenSandboxes: [...response.frozenSandboxes] }; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } } /** @@ -1213,62 +1365,18 @@ export class SandboxClient { async create(spec: SandboxSpec): Promise { try { - // Curated fields build the base spec; rawSpec then shallow-overrides at - // the top spec level (Object.assign, so any field it sets wins). The - // runtime assign avoids the generated $typeName upgrading the literal and - // rejecting the curated `template: { image }` init shorthand. - const specInit: MessageInitShape = { - environment: spec.environment ?? {}, - providers: spec.providers ?? [], - template: spec.image ? { image: spec.image } : undefined, - resourceRequirements: spec.gpu ? { gpu: {} } : undefined, - policy: spec.policy, - command: spec.command ?? [], - tty: spec.tty ?? false, - restartPolicy: restartPolicyValue(spec.restartPolicy), - }; - if (spec.rawSpec) Object.assign(specInit, spec.rawSpec); - - const resp = await this.grpc.createSandbox({ - workspaceScope: workspaceScope(spec), - name: spec.name ?? '', - labels: spec.labels ?? {}, - spec: specInit, - serviceExposures: - spec.serviceExposures?.map((exposure) => ({ - service: exposure.service ?? '', - targetPort: exposure.targetPort, - authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode), - })) ?? [], - }); - return sandboxRef(resp.sandbox, resp.serviceUrls); + const response = await this.grpc.createSandbox(createSandboxRequest(spec)); + return sandboxRef(response.sandbox, response.serviceUrls); } catch (e) { throw fromConnect(e); } } async createFromTemplate(spec: SandboxFromTemplateSpec): Promise { - if (spec.workloadTemplate.trim() === '') throw new SdkError('invalid_config', 'workloadTemplate is required'); + const request = createSandboxTemplateRequest(spec); try { - const resp = await this.grpc.createSandbox({ - workspaceScope: workspaceScope(spec), - name: spec.name ?? '', - labels: spec.labels ?? {}, - spec: { - providers: spec.providers ?? [], - command: spec.command ?? [], - tty: spec.tty ?? false, - policy: spec.policy, - }, - workloadTemplate: spec.workloadTemplate, - serviceExposures: - spec.serviceExposures?.map((exposure) => ({ - service: exposure.service ?? '', - targetPort: exposure.targetPort, - authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode), - })) ?? [], - }); - return sandboxRef(resp.sandbox, resp.serviceUrls); + const response = await this.grpc.createSandbox(request); + return sandboxRef(response.sandbox, response.serviceUrls); } catch (e) { throw fromConnect(e); } diff --git a/sdk/typescript/src/frozen-policy.test.ts b/sdk/typescript/src/frozen-policy.test.ts new file mode 100644 index 0000000000..4840c150f3 --- /dev/null +++ b/sdk/typescript/src/frozen-policy.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Code, createRouterTransport } from '@connectrpc/connect'; +import { describe, expect, it } from 'vitest'; +import { SandboxClient } from './client.js'; +import { type CreateFrozenSandboxRequest, FrozenSandboxPolicies, OpenShell } from './gen/openshell_pb.js'; +import { PolicySource } from './gen/sandbox_pb.js'; + +describe('optional frozen policy service', () => { + it.each( + [undefined, false, true].flatMap((acknowledged) => [false, true].map((template) => ({ acknowledged, template }))), + )( + 'uses a distinct creation RPC and requires acknowledgement ($template, $acknowledged)', + async ({ acknowledged, template }) => { + let ordinaryCreates = 0; + let requested: CreateFrozenSandboxRequest | undefined; + const transport = createRouterTransport((router) => { + router.service(OpenShell, { + createSandbox: () => { + ordinaryCreates++; + return {}; + }, + }); + router.service(FrozenSandboxPolicies, { + createSandbox: (request) => { + requested = request; + return { + sandbox: { metadata: { id: 'created-id', name: request.name, workspace: 'staging' } }, + frozenPolicy: acknowledged === undefined ? undefined : { requested: acknowledged }, + serviceUrls: { web: 'https://web.example' }, + }; + }, + }); + }); + const client = new SandboxClient(transport); + const spec = { workspace: 'staging', name: 'frozen', policy: { version: 1 }, providers: ['github'] }; + const created = template + ? client.createFrozenFromTemplate({ ...spec, workloadTemplate: 'agent' }) + : client.createFrozen(spec); + if (acknowledged) { + expect((await created).serviceUrls).toEqual({ web: 'https://web.example' }); + } else { + await expect(created).rejects.toMatchObject({ + message: expect.stringContaining('did not acknowledge frozen creation'), + }); + } + expect(ordinaryCreates).toBe(0); + expect(requested?.workspaceScope?.selection).toEqual({ case: 'workspace', value: 'staging' }); + expect(requested?.spec?.policy?.version).toBe(1); + expect(requested?.spec?.providers).toEqual(['github']); + expect(requested?.workloadTemplate).toBe(template ? 'agent' : ''); + }, + ); + + it('does not fall back to ordinary creation on an older gateway', async () => { + let ordinaryCreates = 0; + const transport = createRouterTransport((router) => { + router.service(OpenShell, { + createSandbox: () => { + ordinaryCreates++; + return {}; + }, + }); + }); + await expect(new SandboxClient(transport).createFrozen({})).rejects.toMatchObject({ + connectCode: Code.Unimplemented, + }); + expect(ordinaryCreates).toBe(0); + }); + + it('reads pending and captured snapshots and reports global exceptions', async () => { + let captured = false; + let observedGlobal = false; + const transport = createRouterTransport((router) => { + router.service(FrozenSandboxPolicies, { + getPolicy: () => ({ + requested: true, + policy: captured + ? { + policy: { version: 1 }, + version: 3, + policyHash: 'accepted-hash', + frozenTime: { seconds: 600n, nanos: 0 }, + policySource: PolicySource.GLOBAL, + globalPolicyVersion: 7, + } + : undefined, + }), + updateGlobalConfig: (request) => { + observedGlobal = request.global; + return { update: { version: 7 }, frozenSandboxes: ['staging/frozen'] }; + }, + }); + }); + const client = new SandboxClient(transport); + expect(await client.getFrozenPolicy('frozen', { workspace: 'staging' })).toEqual({ + requested: true, + policy: undefined, + }); + captured = true; + const policy = (await client.getFrozenPolicy('frozen', { workspace: 'staging' })).policy; + expect(policy).toMatchObject({ + version: 3, + policyHash: 'accepted-hash', + frozenAtMs: 600_000, + policySource: 'global', + globalPolicyVersion: 7, + }); + expect(policy?.policy?.version).toBe(1); + const update = await client.updateGlobalConfig({ policy: { version: 1 } }); + expect(update.update.version).toBe(7); + expect(update.frozenSandboxes).toEqual(['staging/frozen']); + expect(observedGlobal).toBe(true); + }); +}); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 4948c13623..253e894e4f 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -19,6 +19,10 @@ export type { ExecStreamEvent, ForwardHandle, ForwardOptions, + FrozenGlobalConfigUpdate, + FrozenGlobalConfigUpdateSpec, + FrozenPolicyState, + FrozenSandboxPolicy, Health, HealthStatus, ListOptions, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 864e9d61f3..b19a8e8c1b 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -13,6 +13,11 @@ This skill translates a user's plain-language policy intent into a valid sandbox The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema. +When the sandbox was created with `--frozen`, inspect its initialized state +before proposing a policy update. A frozen sandbox requires deletion and +recreation with the newly reviewed policy. Freeze is a creation option, not a +YAML policy field; credential values may rotate without changing authorization. + ## Step 1: Gather Inputs ### Determine the Detail Tier diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index b757172b7a..26cd80f22d 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -104,6 +104,13 @@ openshell sandbox create --from registry.example.com/your-org/claude-agent:lates openshell sandbox create --from registry.example.com/your-org/codex-agent:latest --provider codex -- codex ``` +For fixed access, add `--frozen` at creation. The first accepted effective policy +and provider authorization remain fixed until deletion; new policy requires +recreation. Check `sandbox get` for pending or initialized freeze state and +`policy get --full` for the captured policy. Credential/token rotation +continues within that authorization. Do not try to repair an initialized frozen +sandbox with policy updates, proposal approvals or provider attach/detach. + The agent will be prompted interactively if credentials are missing. ### Step 4: Exit and clean up