diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 64268a7866..e1c571319b 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1287,8 +1287,8 @@ async fn sync_policy_with_client( sandbox: &str, policy: &ProtoSandboxPolicy, workspace: &str, -) -> Result<()> { - client +) -> Result { + let response = client .update_config(UpdateConfigRequest { sandbox: sandbox.to_string(), workspace_scope: Some(crate::proto::workspace_selector(workspace)), @@ -1299,7 +1299,7 @@ async fn sync_policy_with_client( .map_err(grpc_status_error) .wrap_err("failed to sync policy to server")?; - Ok(()) + Ok(response.into_inner()) } /// Discover and sync policy using a single gRPC connection. @@ -1344,7 +1344,17 @@ pub async fn sync_policy( ) -> Result<()> { debug!(endpoint = %gateway.url(), sandbox = %sandbox, "Syncing enriched policy to gateway"); let mut client = connect(gateway).await?; - sync_policy_with_client(&mut client, sandbox, policy, workspace).await + sync_policy_with_client(&mut client, sandbox, policy, workspace) + .await + .map(|_| ()) +} + +/// The authoritative snapshot and the acknowledged revision this write created. +/// A deduplicated or unknown write outcome never establishes startup ownership. +#[derive(Debug)] +pub struct PolicySyncResult { + pub snapshot: SettingsPollResult, + pub created_policy_version: Option, } /// Sync an enriched policy and return the authoritative revision snapshot. @@ -1358,20 +1368,23 @@ pub async fn sync_policy_and_fetch_snapshot( sandbox: &str, policy: &ProtoSandboxPolicy, workspace: &str, -) -> Result { +) -> Result { let status = ClientSpanStatus::current(); let mut client = connect(gateway).await?; - sync_policy_with_client(&mut client, sandbox, policy, workspace).await?; - status.finish(fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await) + let write = sync_policy_with_client(&mut client, sandbox, policy, workspace).await?; + let snapshot = status + .finish(fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await)?; + Ok(PolicySyncResult { + snapshot, + created_policy_version: write + .created_policy_revision + .then_some(write.version) + .filter(|version| *version > 0), + }) } /// Report an exact runtime configuration generation. Pending registration uses /// the snapshot's instance fence; retain that snapshot across registration retries. -#[tracing::instrument( - name = "supervisor.gateway.report_sandbox_configuration", - skip_all, - fields(otel.kind = "client", otel.status_code = tracing::field::Empty) -)] pub async fn report_sandbox_configuration( gateway: &GatewayEndpoint, sandbox_id: &str, @@ -1380,10 +1393,9 @@ pub async fn report_sandbox_configuration( state: crate::proto::ConfigurationAdmissionState, error: &str, ) -> Result<()> { - let status = ClientSpanStatus::current(); - let mut client = connect(gateway).await?; - client - .report_sandbox_configuration(crate::proto::ReportSandboxConfigurationRequest { + report_sandbox_configuration_request( + gateway, + crate::proto::ReportSandboxConfigurationRequest { sandbox_id: sandbox_id.to_string(), expected_instance_id: snapshot.map_or_else(String::new, |snapshot| { snapshot.configuration_instance_id.clone() @@ -1399,7 +1411,27 @@ pub async fn report_sandbox_configuration( .map_or(0, |snapshot| snapshot.provider_env_revision), error: error.to_string(), }), - }) + refused_before_start: false, + }, + ) + .await +} + +/// Send a configuration report the caller built itself, such as a refusal of +/// the generation this instance already accepted. +#[tracing::instrument( + name = "supervisor.gateway.report_sandbox_configuration", + skip_all, + fields(otel.kind = "client", otel.status_code = tracing::field::Empty) +)] +pub async fn report_sandbox_configuration_request( + gateway: &GatewayEndpoint, + request: crate::proto::ReportSandboxConfigurationRequest, +) -> Result<()> { + let status = ClientSpanStatus::current(); + let mut client = connect(gateway).await?; + client + .report_sandbox_configuration(request) .await .map_err(grpc_status_error)?; status.finish(Ok(())) diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 001b2e9249..ac878f6383 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -212,3 +212,11 @@ These helpers use the raw client's authentication slot. They do not perform OIDC - Async-only. Tonic is async-native; callers needing a blocking call can wrap with their own runtime. - The curated surface will grow as more RPCs graduate from `raw`. + +## Next minor API migration + +This source-breaking API change targets the next minor release and must not be backported into the `0.1.x` patch series. + +Raw `ReportSandboxConfigurationRequest` adds `refused_before_start`, and `UpdateConfigResponse` adds `created_policy_revision`. Complete generated Rust literals must initialize both fields to `false` or use `..Default::default()`. Complete raw TypeScript messages need the corresponding boolean defaults; generated message builders supply them. + +Legacy wire payloads decode both flags as false. A false or unacknowledged creation receipt does not establish startup ownership. Upgrade gateway replicas and supervisors together before relying on this metadata; older writers can discard it. diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs index a88c6abb9d..6f069cfa7c 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs @@ -940,6 +940,8 @@ mutation!( let _: Sandbox = live(store, &id).await?; } Ok(UpdateConfigResponse { + // Legacy durable replay outcomes do not attest which write created a policy. + created_policy_revision: false, version, policy_hash, settings_revision, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc83789417..785844e742 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2676,6 +2676,7 @@ fn update_config_response( settings_revision, deleted, annotations, + created_policy_revision: false, }) } @@ -4884,13 +4885,15 @@ async fn handle_update_config_inner( spawn_pending_chunk_refresh(state, &workspace, &sandbox); emit_full_policy_update_success(sandbox_caller, next_version); - Ok(update_config_response( + let mut response = update_config_response( u32::try_from(next_version).unwrap_or(0), hash, 0, false, response_annotations, - )) + ); + response.get_mut().created_policy_revision = true; + Ok(response) } // --------------------------------------------------------------------------- @@ -5056,6 +5059,26 @@ fn configuration_failure_diagnostic(error: &Status) -> &'static str { } } +/// Both admissions name the same instance and configuration generation. +fn same_generation( + current: &openshell_core::proto::SandboxConfigurationAdmission, + reported: &openshell_core::proto::SandboxConfigurationAdmission, +) -> bool { + ( + ¤t.instance_id, + current.policy_version, + ¤t.policy_hash, + current.config_revision, + current.provider_env_revision, + ) == ( + &reported.instance_id, + reported.policy_version, + &reported.policy_hash, + reported.config_revision, + reported.provider_env_revision, + ) +} + fn configuration_generation_matches( admission: &openshell_core::proto::SandboxConfigurationAdmission, config: &GetSandboxConfigResponse, @@ -5093,6 +5116,12 @@ pub(super) async fn handle_report_sandbox_configuration( if reported == ConfigurationAdmissionState::Unspecified { return Err(Status::invalid_argument("admission state is required")); } + let refused_before_start = request.get_ref().refused_before_start; + if refused_before_start && reported != ConfigurationAdmissionState::Rejected { + return Err(Status::invalid_argument( + "refused_before_start requires a rejected admission", + )); + } let Some(_mutation_guard) = state .compute .sandbox_mutation_guard_by_id(&sandbox_id) @@ -5177,10 +5206,16 @@ pub(super) async fn handle_report_sandbox_configuration( if let Some(current) = current && current.state == i32::from(ConfigurationAdmissionState::Accepted) { - // A rejected desired update does not invalidate an accepted runtime. - let error = admission.error; - admission = current.clone(); - admission.error = error; + if refused_before_start && same_generation(current, &admission) { + // The runtime refused what it accepted, so the workload never + // started on it. Runtime text stays out of public status. + admission.error = "The isolation backend refused this configuration before the workload started; create the sandbox again with a policy the backend accepts".to_string(); + } else { + // A rejected desired update does not invalidate an accepted runtime. + let error = admission.error; + admission = current.clone(); + admission.error = error; + } } } else { admission.error.clear(); @@ -8868,6 +8903,7 @@ mod tests { sandbox_id: sandbox_id.to_string(), admission: Some(admission), expected_instance_id: String::new(), + refused_before_start: false, }), sandbox_id, ) @@ -9005,6 +9041,134 @@ mod tests { } } + #[tokio::test] + async fn configuration_admission_records_refusal_of_accepted_generation() { + use openshell_core::proto::{ + ConfigurationAdmissionState as Admission, ReportSandboxConfigurationRequest, + SandboxConfigurationAdmission, + }; + let state = test_server_state().await; + let sandbox_id = "sb-refused"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + "refused", + openshell_policy::restrictive_default_policy(), + Vec::new(), + )) + .await + .unwrap(); + let instance_id = uuid::Uuid::new_v4().to_string(); + let report = |admission, refused_before_start| { + with_sandbox( + Request::new(ReportSandboxConfigurationRequest { + sandbox_id: sandbox_id.to_string(), + admission: Some(admission), + expected_instance_id: String::new(), + refused_before_start, + }), + sandbox_id, + ) + }; + handle_report_sandbox_configuration( + &state, + report( + SandboxConfigurationAdmission { + instance_id: instance_id.clone(), + state: Admission::Pending.into(), + ..Default::default() + }, + false, + ), + ) + .await + .unwrap(); + let config = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: "refused".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), + }), + sandbox_id, + ), + ) + .await + .unwrap() + .into_inner(); + let accepted = SandboxConfigurationAdmission { + instance_id, + state: Admission::Accepted.into(), + policy_version: config.version, + policy_hash: config.policy_hash, + config_revision: config.config_revision, + provider_env_revision: config.provider_env_revision, + error: String::new(), + }; + handle_report_sandbox_configuration(&state, report(accepted.clone(), false)) + .await + .unwrap(); + let readiness = || async { + let persisted = state + .store + .get_message::(sandbox_id) + .await + .unwrap() + .unwrap(); + let status = persisted.status.unwrap(); + let ready = status + .conditions + .into_iter() + .find(|condition| condition.r#type == "ConfigurationReady") + .unwrap(); + (status.configuration_admission.unwrap().state, ready) + }; + + // A runtime that fails to apply the generation it runs, such as a + // reconcile of unreachable middleware, keeps the accepted state. + let mut refused = accepted; + refused.state = Admission::Rejected.into(); + refused.error = "backend text is not shown".to_string(); + handle_report_sandbox_configuration(&state, report(refused.clone(), false)) + .await + .unwrap(); + let (admission_state, ready) = readiness().await; + assert_eq!(admission_state, i32::from(Admission::Accepted)); + assert_eq!(ready.status, "True"); + + let mut flagged_acceptance = refused.clone(); + flagged_acceptance.state = Admission::Accepted.into(); + assert_eq!( + handle_report_sandbox_configuration(&state, report(flagged_acceptance, true)) + .await + .unwrap_err() + .code(), + Code::InvalidArgument + ); + let mut other_generation = refused.clone(); + other_generation.policy_hash.push('x'); + assert!(same_generation(&refused, &refused)); + assert!(!same_generation(&refused, &other_generation)); + + handle_report_sandbox_configuration(&state, report(refused, true)) + .await + .unwrap(); + let (admission_state, ready) = readiness().await; + assert_eq!(admission_state, i32::from(Admission::Rejected)); + assert_eq!( + (ready.status.as_str(), ready.reason.as_str()), + ("False", "ConfigurationInvalid") + ); + assert!( + ready.message.contains("isolation backend refused"), + "{ready:?}" + ); + assert!(!ready.message.contains("backend text"), "{ready:?}"); + } + #[test] fn configuration_diagnostic_is_bounded_and_removes_control_characters() { let diagnostic = bounded_configuration_diagnostic(&format!("rule\n{}", "é".repeat(1000))); @@ -9046,6 +9210,7 @@ mod tests { state: Admission::Pending.into(), ..Default::default() }), + refused_before_start: false, }), sandbox_id, ), @@ -10054,6 +10219,45 @@ mod tests { } } + #[tokio::test] + async fn policy_write_receipt_distinguishes_created_and_deduplicated_revisions() { + let state = test_server_state().await; + let policy = mcp_policy_with_versions(&["2025-11-25"]); + state + .store + .put_message(&test_sandbox( + "write-receipt", + "write-receipt", + policy.clone(), + vec![], + )) + .await + .unwrap(); + let mut narrowed = policy.clone(); + narrowed.network_policies.clear(); + for (candidate, expected_version, created) in [ + (policy.clone(), 1, true), + (policy, 1, false), + (narrowed.clone(), 2, true), + (narrowed, 2, false), + ] { + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + sandbox: "write-receipt".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(candidate), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(response.version, expected_version); + assert_eq!(response.created_policy_revision, created); + } + } + #[tokio::test] async fn policy_record_identity_sandbox_deduplicates_defaulted_mcp_history() { let state = test_server_state().await; @@ -10104,6 +10308,7 @@ mod tests { .into_inner(); assert_eq!(response.version, 1, "{case}"); + assert!(!response.created_policy_revision, "{case}"); assert_eq!(response.policy_hash, canonical_hash, "{case}"); let revisions = state .store diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index d8da420871..af4730ad49 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -177,8 +177,10 @@ mod tests { // selections absent; storage.v1 remains frozen. Public field 3 retains its // image wire identity, now with explicit presence. Compute configuration // updates use a separate capability/service and add one classified RPC. + // ReportSandboxConfigurationRequest field 4 adds a public-only refusal + // flag. Older supervisors omit it; durable schemas remain unchanged. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "3b4c2af6cf22e7ed1d84279ff865acb0db7199717bee9bc041caef791334104d"; + "94e2cdf1066c0af624af56ec3a7364499382931992d39bd34e0d5e019dff4dd4"; const DURABLE_SCHEMA_SHA256: &str = "cb15f8f79a855064a43fe8c6cd908bf07eca6604e2bf8ab7d2de0ef33e97da4e"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = diff --git a/crates/openshell-supervisor/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index a0cd52ea87..ca8be97e38 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -85,6 +85,10 @@ pub trait PreparedBackend: Send + Sync { ) -> std::result::Result; } +/// Diagnostic code of an Attach error that is a backend refusal, as opposed to +/// a transient failure the next start can retry. +pub(crate) const ATTACH_REFUSED: &str = "openshell::backend::attach_refused"; + /// Created only after name and launch identity checks. Consuming attachment /// prevents reusing one prepared startup to build or attach a second client. pub struct SelectedBackend { @@ -200,7 +204,12 @@ impl SelectedBackend { }, ) .await - .map_err(|error| miette::miette!(error.to_string()))?; + .map_err(|error| match error { + BackendError::Denied(_) => { + miette::miette!(code = ATTACH_REFUSED, "{error}") + } + error => miette::miette!("{error}"), + })?; Ok((bound, built.proxy_listener)) } } diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 42626fbe7c..a2684358e6 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -117,6 +117,7 @@ struct Observed { deny_confirmation: AtomicBool, require_networking: AtomicBool, active: AtomicBool, + attach_error: Mutex>, starts: AtomicUsize, releases: AtomicUsize, suspendable: AtomicBool, @@ -308,6 +309,10 @@ impl PreparedBackend for TestLaunch { Ok(match request { Request::Attach { .. } => { observed.record("attach"); + let attach_error = observed.attach_error.lock().unwrap().take(); + if let Some(error) = attach_error { + return Err(error); + } if observed.active.swap(true, Ordering::SeqCst) { return Err(BackendError::Denied("already attached".into())); } @@ -583,6 +588,46 @@ async fn attachment_receives_live_supervisor_services() { assert!(setup.observed.services.lock().unwrap().upgrade().is_none()); } +#[tokio::test] +async fn attach_refusal_is_marked_as_a_refusal() { + let setup = TestSetup::new(); + setup.observed.active.store(true, Ordering::SeqCst); + let error = setup + .select() + .attach(setup.services(), policy(), agent()) + .await + .err() + .expect("an already attached boundary refuses attachment"); + assert_eq!( + error.code().map(|code| code.to_string()).as_deref(), + Some(ATTACH_REFUSED) + ); + assert!(error.to_string().contains("already attached")); +} + +#[tokio::test] +async fn attach_transport_failures_are_not_policy_refusals() { + for failure in [ + BackendError::Attach("mediation transport failed".into()), + BackendError::Unavailable("backend unavailable".into()), + ] { + let setup = TestSetup::new(); + *setup.observed.attach_error.lock().unwrap() = Some(failure); + let error = setup + .select() + .attach(setup.services(), policy(), agent()) + .await + .err() + .expect("backend transport failure must fail startup"); + assert_ne!( + error.code().map(|code| code.to_string()).as_deref(), + Some(ATTACH_REFUSED), + "infrastructure failures must not be reported as policy denials" + ); + assert_eq!(setup.observed.starts.load(Ordering::SeqCst), 0); + } +} + #[tokio::test] async fn restored_boundary_attaches_with_retained_processes_and_starts_agent() { let setup = TestSetup::new(); diff --git a/crates/openshell-supervisor/src/config_runtime.rs b/crates/openshell-supervisor/src/config_runtime.rs index 02736ad184..addf4819a0 100644 --- a/crates/openshell-supervisor/src/config_runtime.rs +++ b/crates/openshell-supervisor/src/config_runtime.rs @@ -2202,6 +2202,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), has_last_valid_policy: true, + written_at_startup: false, }, connector, ); @@ -2323,6 +2324,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2370,6 +2372,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2470,6 +2473,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2528,6 +2532,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2590,6 +2595,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2694,6 +2700,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2870,6 +2877,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_revision), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -2941,6 +2949,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_revision), has_last_valid_policy: true, + written_at_startup: false, }, middleware_connector, ); @@ -3018,6 +3027,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_revision), has_last_valid_policy: true, + written_at_startup: false, }, connector, ); @@ -3085,6 +3095,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_revision), has_last_valid_policy: true, + written_at_startup: false, }, connector, ); @@ -3162,6 +3173,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_v1.clone()), has_last_valid_policy: true, + written_at_startup: false, }, Some(1), ) @@ -3178,6 +3190,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(empty_loaded), has_last_valid_policy: true, + written_at_startup: false, }, Some(1), ) @@ -3189,6 +3202,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_v1.clone()), has_last_valid_policy: true, + written_at_startup: false, }, Some(1), ) @@ -3201,6 +3215,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_v2), has_last_valid_policy: true, + written_at_startup: false, }, Some(2), ) @@ -3227,6 +3242,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(loaded_revision), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3348,6 +3364,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3431,6 +3448,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3505,6 +3523,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3634,6 +3653,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3738,6 +3758,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3777,6 +3798,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3832,6 +3854,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -3884,6 +3907,7 @@ mod tests { revision: has_last_valid .then(|| LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: has_last_valid, + written_at_startup: false, }, default_middleware_connector(), ); @@ -4008,6 +4032,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: None, has_last_valid_policy: false, + written_at_startup: false, }, default_middleware_connector(), ); @@ -4053,6 +4078,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -4092,6 +4118,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); @@ -4185,6 +4212,7 @@ mod tests { LoadedPolicyOrigin::Gateway { revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), has_last_valid_policy: true, + written_at_startup: false, }, default_middleware_connector(), ); diff --git a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs index a9dd93b5c2..dbaea94ed9 100644 --- a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs +++ b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs @@ -15,11 +15,18 @@ use std::sync::atomic::Ordering; use tonic::codegen::tokio_stream::{StreamExt as _, wrappers::TcpListenerStream}; use tonic::codegen::{Body, BoxFuture, BoxStream, Bytes, Service, StdError, http}; +#[derive(Clone)] +struct PolicySyncCase { + write: UpdateConfigResponse, + snapshot: GetSandboxConfigResponse, +} + #[derive(Clone, Default)] struct LegacyReadyProbe { ready_event: Arc, premature_ready: Arc, discovered: Arc, + policy_sync: Arc>>, } #[derive(Clone)] @@ -132,10 +139,15 @@ where .await } "/openshell.v1.OpenShell/GetSandboxConfig" => { - let mut policy = openshell_policy::restrictive_default_policy(); - enrich_proto_baseline_paths(&mut policy); - legacy_unary::( - request, + let scripted = probe + .policy_sync + .lock() + .unwrap() + .as_ref() + .map(|case| case.snapshot.clone()); + let snapshot = scripted.unwrap_or_else(|| { + let mut policy = openshell_policy::restrictive_default_policy(); + enrich_proto_baseline_paths(&mut policy); GetSandboxConfigResponse { policy: Some(policy), version: 1, @@ -147,9 +159,9 @@ where policy_source: openshell_core::proto::PolicySource::Sandbox.into(), policy_validation_failure_mode: "fail_closed".into(), ..Default::default() - }, - ) - .await + } + }); + legacy_unary::(request, snapshot).await } "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" => { legacy_unary::( @@ -173,15 +185,15 @@ where .await } "/openshell.v1.OpenShell/UpdateConfig" => { - legacy_unary::( - request, - UpdateConfigResponse { + let response = probe.policy_sync.lock().unwrap().as_ref().map_or_else( + || UpdateConfigResponse { version: 1, policy_hash: "legacy-policy".into(), ..Default::default() }, - ) - .await + |case| case.write.clone(), + ); + legacy_unary::(request, response).await } _ => http::Response::builder() .status(200) @@ -346,3 +358,48 @@ async fn config_push_regression_legacy_gateway_never_observes_ready_before_workl .unwrap_err(); assert!(error.to_string().contains("test boundary remains held")); } + +#[tokio::test] +async fn policy_sync_preserves_write_receipt_separately_from_refetched_policy() { + for (created, snapshot_version) in [(true, 2), (true, 3), (false, 2)] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let probe = LegacyReadyProbe::default(); + *probe.policy_sync.lock().unwrap() = Some(PolicySyncCase { + write: UpdateConfigResponse { + version: 2, + created_policy_revision: created, + ..Default::default() + }, + snapshot: GetSandboxConfigResponse { + version: snapshot_version, + policy_source: openshell_core::proto::PolicySource::Sandbox.into(), + policy_validation_failure_mode: "fail_closed".into(), + workspace: "default".into(), + ..Default::default() + }, + }); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(LegacyGateway { probe }) + .serve_with_incoming(TcpListenerStream::new(listener)), + ); + let gateway = openshell_core::grpc_client::GatewayEndpoint::new( + endpoint, + openshell_core::grpc_client::GatewayCredentials::from_gateway_token("test-token") + .unwrap(), + ); + let synced = openshell_core::grpc_client::sync_policy_and_fetch_snapshot( + &gateway, + "sandbox", + &openshell_policy::restrictive_default_policy(), + "default", + ) + .await + .expect("sync the policy through the production RPC helper"); + assert_eq!(synced.snapshot.version, snapshot_version); + assert_eq!(synced.created_policy_version, created.then_some(2)); + server.abort(); + let _ = server.await; + } +} diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index b9a5546bf2..c12928bdb5 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -1331,7 +1331,7 @@ pub async fn run_sandbox_instance( // supervisor-owned handles shared with the backend. let admitted_backend_name = selected_backend.backend_name().to_string(); let ca_file_paths = Arc::new(std::sync::Mutex::new(None)); - let (bound, proxy_listener) = selected_backend + let attached = selected_backend .attach( backend_setup::BackendServices { ca_file_paths: ca_file_paths.clone(), @@ -1351,7 +1351,22 @@ pub async fn run_sandbox_instance( }, ) .instrument(tracing::info_span!(parent: &startup, "supervisor.boundary.attach")) - .await?; + .await; + let (bound, proxy_listener) = match attached { + Ok(attached) => attached, + Err(error) => { + if let (Some(sandbox_id), Some(gateway)) = (sandbox_id.as_deref(), gateway.as_ref()) { + Box::pin(report_attach_refusal( + gateway, + sandbox_id, + &loaded_policy_origin, + &error, + )) + .await; + } + return Err(error); + } + }; info!(backend = %admitted_backend_name, "Isolation boundary attached"); let remote_boundary = (bound, admitted_backend_name, ca_file_paths); @@ -2633,7 +2648,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, @@ -2674,7 +2689,7 @@ impl StartupGateway for RemoteStartupGateway { sandbox: &str, policy: &openshell_core::proto::SandboxPolicy, workspace: &str, - ) -> Result { + ) -> Result { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( self.gateway()?, sandbox, @@ -2941,6 +2956,7 @@ async fn load_policy_with_gateway( .await?; let discovery = image_discovery.unwrap_or_else(discover_image_policy); let mut reconciliation_attempts = 0u32; + let mut startup_created_policy_version = None; let mut rejection_log = StartupRejectionLog::default(); loop { if reconciliation_attempts == 5 { @@ -3002,7 +3018,12 @@ async fn load_policy_with_gateway( }) .await; snapshot = match synced { - Ok(synced) => synced, + Ok(synced) => { + if let Some(version) = synced.created_policy_version { + startup_created_policy_version = Some(version); + } + synced.snapshot + } Err(error) => { // The gateway stored nothing, so report the rejection // against the snapshot this upload was built from and @@ -3062,7 +3083,12 @@ async fn load_policy_with_gateway( }) .await; let canonical = match synced { - Ok(canonical) => canonical, + Ok(canonical) => { + if let Some(version) = canonical.created_policy_version { + startup_created_policy_version = Some(version); + } + canonical.snapshot + } Err(error) => { // The stored policy is unchanged, so report the // rejection against the snapshot it was read from and @@ -3280,6 +3306,9 @@ async fn load_policy_with_gateway( LoadedPolicyOrigin::Gateway { revision: loaded_policy_revision, has_last_valid_policy, + written_at_startup: snapshot.policy_source + == openshell_core::proto::PolicySource::Sandbox + && startup_created_policy_version == Some(snapshot.version), }, agent_proposals_enabled_from_settings(&snapshot.settings), snapshot.extension_authentication_enabled, @@ -3704,6 +3733,9 @@ enum LoadedPolicyOrigin { Gateway { revision: Option, has_last_valid_policy: bool, + /// This start wrote `revision` to the gateway, so it is the + /// supervisor's own revision to fail if the backend refuses it. + written_at_startup: bool, }, } @@ -4198,6 +4230,88 @@ fn enqueue_policy_status(sender: &UnboundedSender, update: P } } +/// The report that the backend refused, at Attach, the configuration this start +/// accepted. Only a refusal qualifies: `Unavailable` can succeed on the next start. +fn attach_refusal_report( + sandbox_id: &str, + origin: &LoadedPolicyOrigin, + error: &miette::Report, +) -> Option { + let LoadedPolicyOrigin::Gateway { + revision: Some(revision), + .. + } = origin + else { + return None; + }; + if error + .code() + .is_none_or(|code| code.to_string() != backend_setup::ATTACH_REFUSED) + { + return None; + } + Some(openshell_core::proto::ReportSandboxConfigurationRequest { + sandbox_id: sandbox_id.to_string(), + admission: Some(openshell_core::proto::SandboxConfigurationAdmission { + instance_id: revision.admission_instance_id.clone()?, + state: openshell_core::proto::ConfigurationAdmissionState::Rejected.into(), + policy_version: revision.version, + policy_hash: revision.policy_hash.clone(), + config_revision: revision.config_revision, + provider_env_revision: revision.provider_env_revision, + error: error.to_string(), + }), + expected_instance_id: String::new(), + refused_before_start: true, + }) +} + +/// Report an Attach refusal before the supervisor exits. The gateway records +/// the accepted configuration as refused, and a revision this start wrote ends +/// Failed with the backend's message. Both reports are best effort, and the +/// refusal report gets a single bounded attempt so exit is not held up. +async fn report_attach_refusal( + gateway: &openshell_core::grpc_client::GatewayEndpoint, + sandbox_id: &str, + origin: &LoadedPolicyOrigin, + error: &miette::Report, +) { + if let Some(request) = attach_refusal_report(sandbox_id, origin, error) + && let Err(report_error) = grpc_attempt( + "Attach refusal report", + openshell_core::grpc_client::report_sandbox_configuration_request(gateway, request), + ) + .await + { + warn!(error = %report_error, "Failed to report the isolation backend refusal"); + } + if let Some(revision) = refused_startup_revision(origin, error) { + report_initial_policy_failure(gateway, sandbox_id, Some(revision), error).await; + } +} + +/// The revision this start wrote that the backend refused at Attach, if any. +/// +/// Only a refusal marks it: `Unavailable` can succeed on the next start, and a +/// revision the user wrote stays as it was. +fn refused_startup_revision<'a>( + origin: &'a LoadedPolicyOrigin, + error: &miette::Report, +) -> Option<&'a LoadedPolicyRevision> { + let LoadedPolicyOrigin::Gateway { + revision: Some(revision), + written_at_startup: true, + .. + } = origin + else { + return None; + }; + error + .code() + .is_some_and(|code| code.to_string() == backend_setup::ATTACH_REFUSED) + .then_some(revision) +} + /// Best-effort `FAILED` acknowledgement when initial policy construction or /// conversion fails. /// @@ -5220,8 +5334,13 @@ network_policies: _sandbox: &str, _policy: &openshell_core::proto::SandboxPolicy, _workspace: &str, - ) -> Result { - self.snapshot("").await + ) -> Result { + self.snapshot("") + .await + .map(|snapshot| openshell_core::grpc_client::PolicySyncResult { + snapshot, + created_policy_version: None, + }) } async fn report( &self, @@ -5756,7 +5875,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 +5906,10 @@ network_policies: version, openshell_core::proto::PolicySource::Sandbox, ); - Ok(desired.clone()) + Ok(openshell_core::grpc_client::PolicySyncResult { + snapshot: desired.clone(), + created_policy_version: Some(version), + }) } async fn report( &self, @@ -6441,6 +6563,244 @@ network_policies: assert_eq!(revision.policy_source, PolicySource::Global); } + struct ConcurrentSyncStartupGateway { + inner: WriteRefusingStartupGateway, + after_sync: openshell_core::grpc_client::SettingsPollResult, + created_version: Option, + reject_first_acceptance: AtomicBool, + } + + impl StartupGateway for ConcurrentSyncStartupGateway { + async fn snapshot( + &self, + sandbox: &str, + ) -> Result { + self.inner.snapshot(sandbox).await + } + + async fn provider( + &self, + id: &str, + ) -> Result { + self.inner.provider(id).await + } + + async fn sync( + &self, + _sandbox: &str, + _policy: &openshell_core::proto::SandboxPolicy, + _workspace: &str, + ) -> Result { + self.inner.calls.send(StartupCall::Sync).unwrap(); + *self.inner.desired.lock().unwrap() = self.after_sync.clone(); + Ok(openshell_core::grpc_client::PolicySyncResult { + snapshot: self.after_sync.clone(), + created_policy_version: self.created_version, + }) + } + + async fn report( + &self, + id: &str, + instance_id: &str, + snapshot: Option<&openshell_core::grpc_client::SettingsPollResult>, + state: openshell_core::proto::ConfigurationAdmissionState, + error: &str, + ) -> Result<()> { + if state == openshell_core::proto::ConfigurationAdmissionState::Accepted + && self.reject_first_acceptance.swap(false, Ordering::SeqCst) + { + return Err(openshell_core::grpc_client::grpc_status_error( + tonic::Status::aborted("retry the same accepted configuration"), + )); + } + self.inner + .report(id, instance_id, snapshot, state, error) + .await + } + } + + #[tokio::test(start_paused = true)] + async fn startup_write_ownership_requires_a_matching_created_sandbox_revision() { + use openshell_core::proto::PolicySource; + + for (created_version, accepted_version, source, owned) in [ + (Some(2), 2, PolicySource::Sandbox, true), + (Some(2), 3, PolicySource::Sandbox, false), + (None, 2, PolicySource::Sandbox, false), + (Some(2), 2, PolicySource::Global, false), + ] { + let initial = proto_tcp_policy_fixture(); + let mut enriched = initial.clone(); + assert!(enrich_proto_baseline_paths(&mut enriched)); + let mut after_sync = settings_poll_result(Some(enriched), accepted_version, source); + if source == PolicySource::Global { + after_sync.global_policy_version = 7; + } + let (calls, _observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = ConcurrentSyncStartupGateway { + inner: WriteRefusingStartupGateway { + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + Some(initial), + 1, + PolicySource::Sandbox, + ))), + refusals: Arc::default(), + repair_after_refusal: None, + rejection_report_errors: Arc::default(), + calls, + }, + after_sync, + created_version, + reject_first_acceptance: AtomicBool::new(true), + }; + let bundle = load_policy_with_gateway( + Some("sandbox-id".into()), + Some("sandbox".into()), + Some(test_gateway()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(ImagePolicyDiscovery::Missing), + &gateway, + ) + .await + .expect("startup must retry and accept the canonical snapshot"); + let denial = miette::miette!(code = backend_setup::ATTACH_REFUSED, "policy denied"); + assert_eq!( + refused_startup_revision(&bundle.4, &denial).is_some(), + owned, + "ownership must survive a same-version retry but never pass to a later or deduplicated user revision" + ); + } + } + + #[tokio::test(start_paused = true)] + async fn startup_marks_only_the_revision_it_wrote() { + use openshell_core::proto::PolicySource; + + for (needs_baseline_paths, written) in [(true, true), (false, false)] { + let mut policy = proto_tcp_policy_fixture(); + if !needs_baseline_paths { + assert!(enrich_proto_baseline_paths(&mut policy)); + } + let (calls, _observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = WriteRefusingStartupGateway { + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + Some(policy), + 1, + PolicySource::Sandbox, + ))), + refusals: Arc::default(), + repair_after_refusal: None, + rejection_report_errors: Arc::default(), + calls, + }; + let bundle = load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some(test_gateway()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(ImagePolicyDiscovery::Missing), + &gateway, + ) + .await + .expect("startup must accept the policy"); + let LoadedPolicyOrigin::Gateway { + revision: Some(revision), + written_at_startup, + .. + } = bundle.4 + else { + panic!("startup must bind the policy to its gateway revision"); + }; + assert_eq!(written_at_startup, written); + assert_eq!(revision.version, if written { 2 } else { 1 }); + } + } + + #[test] + fn attach_refusal_reports_the_accepted_generation_as_refused() { + use openshell_core::proto::ConfigurationAdmissionState; + + let mut revision = LoadedPolicyRevision::from_snapshot(&settings_poll_result( + Some(proto_policy_fixture()), + 3, + openshell_core::proto::PolicySource::Sandbox, + )); + revision.admission_instance_id = Some("instance-1".to_string()); + let origin = |revision: &LoadedPolicyRevision| LoadedPolicyOrigin::Gateway { + revision: Some(revision.clone()), + has_last_valid_policy: true, + written_at_startup: false, + }; + let refusal = miette::miette!(code = backend_setup::ATTACH_REFUSED, "refused"); + + let request = attach_refusal_report("sandbox-id", &origin(&revision), &refusal) + .expect("a refusal of the accepted generation is reported"); + assert!(request.refused_before_start); + let admission = request.admission.unwrap(); + assert_eq!( + admission.state, + i32::from(ConfigurationAdmissionState::Rejected) + ); + assert_eq!( + ( + admission.instance_id.as_str(), + admission.policy_version, + admission.policy_hash.as_str(), + admission.config_revision, + admission.provider_env_revision, + ), + ( + "instance-1", + revision.version, + revision.policy_hash.as_str(), + revision.config_revision, + revision.provider_env_revision, + ) + ); + + let transient = miette::miette!("unavailable"); + assert!(attach_refusal_report("sandbox-id", &origin(&revision), &transient).is_none()); + assert!( + attach_refusal_report("sandbox-id", &LoadedPolicyOrigin::LocalOverride, &refusal) + .is_none() + ); + revision.admission_instance_id = None; + assert!(attach_refusal_report("sandbox-id", &origin(&revision), &refusal).is_none()); + } + + #[test] + fn attach_refusal_fails_only_the_revision_this_start_wrote() { + let revision = LoadedPolicyRevision::from_snapshot(&settings_poll_result( + Some(proto_policy_fixture()), + 3, + openshell_core::proto::PolicySource::Sandbox, + )); + let origin = |written_at_startup| LoadedPolicyOrigin::Gateway { + revision: Some(revision.clone()), + has_last_valid_policy: true, + written_at_startup, + }; + let refusal = miette::miette!(code = backend_setup::ATTACH_REFUSED, "refused"); + let transient = miette::miette!("unavailable"); + + assert_eq!( + refused_startup_revision(&origin(true), &refusal).map(|revision| revision.version), + Some(3) + ); + assert!(refused_startup_revision(&origin(true), &transient).is_none()); + assert!(refused_startup_revision(&origin(false), &refusal).is_none()); + assert!(refused_startup_revision(&LoadedPolicyOrigin::LocalOverride, &refusal).is_none()); + } + #[tokio::test(start_paused = true)] async fn startup_policy_write_refusal_permanent_codes_still_exit() { use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; @@ -7250,6 +7610,7 @@ network_policies: &LoadedPolicyOrigin::Gateway { revision: Some(loaded), has_last_valid_policy: true, + written_at_startup: false, }, &canonical, ), @@ -7282,6 +7643,7 @@ network_policies: let origin = LoadedPolicyOrigin::Gateway { revision: None, has_last_valid_policy: true, + written_at_startup: false, }; assert_eq!( @@ -7389,6 +7751,7 @@ network_policies: &LoadedPolicyOrigin::Gateway { revision: None, has_last_valid_policy: true, + written_at_startup: false, }, true, true diff --git a/docs/extensibility/isolation-backends.mdx b/docs/extensibility/isolation-backends.mdx index 8dd5903176..a35e545561 100644 --- a/docs/extensibility/isolation-backends.mdx +++ b/docs/extensibility/isolation-backends.mdx @@ -34,6 +34,12 @@ The supervisor's concrete RPC client, `OpenShellRuntimeBackend`, lives in The admission sequence is Attach, Confirm, then StartAgent. No workload code runs before StartAgent. +A backend refuses a policy it cannot enforce by returning `Denied` from Attach. +The sandbox then reports `ConfigurationReady: False (ConfigurationInvalid)`. +Transport and backend availability failures remain infrastructure failures. The +backend's reason is in the supervisor log. Delete the sandbox and create it +again with a policy the backend accepts. + External compute drivers advertise `openshell.compute.isolation-backend` to accept an operator-selected `IsolationBackendRegistration`, including its Unix, TCP, or vsock endpoint. The driver provisions the resource and delivers the @@ -50,3 +56,9 @@ backend's trusted launch metadata to the supervisor. For how `openshell-sandbox` enforces the boundary, refer to [Inside the Sandbox Boundary](/about/architecture#inside-the-sandbox-boundary). For driver-specific workload behavior, refer to [Runtimes](/how-it-works/sandboxes/runtimes). + +## Release compatibility + +These API changes target the next minor release and must not be backported into the `0.1.x` patch series. Upgrade gateway replicas and supervisors together before relying on startup refusal reporting. + +For raw Rust and TypeScript message changes, see the [SDK migration notes](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sdk/README.md#next-minor-api-migration). diff --git a/proto/openshell.proto b/proto/openshell.proto index b40ec68263..0c814d9821 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -3018,6 +3018,11 @@ message UpdateConfigResponse { bool deleted = 4; // Sandbox metadata annotations after the update. Empty for global updates. map annotations = 5; + // True only when this request created a new sandbox policy revision. + // False for deduplicated policies, settings/global updates and older gateways. + // A supervisor may claim startup ownership only when this receipt's version + // matches the accepted sandbox-sourced configuration. + bool created_policy_revision = 6; } // Get sandbox policy status request. @@ -3100,6 +3105,12 @@ message ReportSandboxConfigurationRequest { SandboxConfigurationAdmission admission = 2; // Pending registration replaces only this previously observed instance. string expected_instance_id = 3; + // With a Rejected admission for the generation this instance already + // reported Accepted: the runtime refused it before the workload started, for + // example an isolation backend refusing Attach. The gateway then records the + // configuration as rejected instead of keeping the accepted state. Omitted or + // false keeps the accepted state; set with any other state is INVALID_ARGUMENT. + bool refused_before_start = 4; } message ReportSandboxConfigurationResponse {} diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 8b64c9768d..75d4338010 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -348,3 +348,7 @@ mise run e2e:sdk:ts:exec # public interactive helper against an isolated Dock ``` Formatting and linting are handled by [Biome](https://biomejs.dev) (`biome.json`): 2-space indent, single quotes, semicolons, 120-column width. Generated `src/gen/` is excluded. `sdk:ts:lint` runs in CI as part of `sdk:ts:ci`. + +## Next minor raw message migration + +Raw `ReportSandboxConfigurationRequest` messages add `refusedBeforeStart`, and `UpdateConfigResponse` adds `createdPolicyRevision`. Complete object literals need boolean defaults; use generated message builders when creating partial messages. This change belongs to the next minor release; it is not a patch-source-compatibility claim. Upgrade gateway writers together so older protobuf writers cannot discard the new metadata.