diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml index c7f21684a1..96353688b8 100644 --- a/.github/workflows/e2e-docker-test.yml +++ b/.github/workflows/e2e-docker-test.yml @@ -29,7 +29,7 @@ on: required: false type: string default: >- - [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] + [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker-push","cmd":"mise run --no-deps --skip-deps e2e:rust:push","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] permissions: actions: read diff --git a/Cargo.lock b/Cargo.lock index f9c022162b..e152fdc96d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5033,6 +5033,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.26.2", "tonic", + "tonic-prost", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/TESTING.md b/TESTING.md index ebfedc71cb..440cb1adc4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -246,6 +246,7 @@ Suites: the selected deployment. - Docker suite (`--features e2e-docker`) - includes Docker-only coverage such as Dockerfile image builds, Docker preflight checks, and managed Docker gateway start. - Docker GPU suite (`--features e2e-docker-gpu`) - Docker suite plus GPU sandbox smoke coverage. +- Configuration push suite (`--features e2e-config-push`) - CLI conformance and the policy tests against a Docker gateway with `config_delivery_mode = "push"`. Run it with `mise run e2e:rust:push`. - VM suite (`--features e2e-vm`) - runs e2e tests on a VM. - Kubernetes credential-driver suite (`--features e2e-kubernetes-credential-drivers`) - targeted Kubernetes Secrets and Vault provider credential storage coverage. diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 897f4268ad..4092137f8e 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -129,6 +129,10 @@ pub async fn sandbox_provider_list( Ok(()) } +/// Attempts for an attach or detach whose resource version went stale between +/// the command's read and its write. +const PROVIDER_ATTACHMENT_CAS_ATTEMPTS: u32 = 5; + /// Save a provider attachment and optionally wait for its installed authority. pub async fn sandbox_provider_attach( server: &str, @@ -141,45 +145,53 @@ pub async fn sandbox_provider_attach( readiness.validate()?; let mut client = grpc_client(server, tls).await?; - // Fetch current sandbox to get resource_version for CAS - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: (name).to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - (workspace).to_string(), - )), - }) - .await - .map_err(|status| miette!("provider attachment lookup failed ({})", status.code()))? - .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found"))?; + // The resource version only guards this command's own read, so a + // conflict from a concurrent status write is retried with a fresh read. + let mut attempt = 1; + let (sandbox, response) = loop { + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + (workspace).to_string(), + )), + }) + .await + .map_err(|status| miette!("provider attachment lookup failed ({})", status.code()))? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; - let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); - let response = match client - .attach_sandbox_provider(AttachSandboxProviderRequest { - request_id: String::new(), - sandbox: (name).to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - (workspace).to_string(), - )), - provider: provider.to_string(), - expected_resource_version: resource_version, - }) - .await - { - Ok(response) => response.into_inner(), - // Explicit post-save uncertainty takes precedence over a generic retry hint. - Err(status) - if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) => + match client + .attach_sandbox_provider(AttachSandboxProviderRequest { + request_id: String::new(), + sandbox: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + (workspace).to_string(), + )), + provider: provider.to_string(), + expected_resource_version: resource_version, + }) + .await { - return Err(miette::miette!( - "Failed to attach provider: sandbox was modified by another operation.\n\ - Please retry the command." - )); + Ok(response) => break (sandbox, response.into_inner()), + // Explicit post-save uncertainty takes precedence over a generic retry hint. + Err(status) + if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) => + { + if attempt < PROVIDER_ATTACHMENT_CAS_ATTEMPTS { + attempt += 1; + continue; + } + return Err(miette::miette!( + "Failed to attach provider: sandbox was modified by another operation.\n\ + Please retry the command." + )); + } + Err(error) => return Err(provider_mutation_error(&error, "attachment")), } - Err(error) => return Err(provider_mutation_error(&error, "attachment")), }; let receipt = response.receipt.ok_or_else(|| { @@ -216,45 +228,53 @@ pub async fn sandbox_provider_detach( readiness.validate()?; let mut client = grpc_client(server, tls).await?; - // Fetch current sandbox to get resource_version for CAS - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: (name).to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - (workspace).to_string(), - )), - }) - .await - .map_err(|status| miette!("provider detachment lookup failed ({})", status.code()))? - .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found"))?; + // The resource version only guards this command's own read, so a + // conflict from a concurrent status write is retried with a fresh read. + let mut attempt = 1; + let (sandbox, response) = loop { + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + (workspace).to_string(), + )), + }) + .await + .map_err(|status| miette!("provider detachment lookup failed ({})", status.code()))? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; - let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); - let response = match client - .detach_sandbox_provider(DetachSandboxProviderRequest { - request_id: String::new(), - sandbox: (name).to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - (workspace).to_string(), - )), - provider: provider.to_string(), - expected_resource_version: resource_version, - }) - .await - { - Ok(response) => response.into_inner(), - // Explicit post-save uncertainty takes precedence over a generic retry hint. - Err(status) - if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) => + match client + .detach_sandbox_provider(DetachSandboxProviderRequest { + request_id: String::new(), + sandbox: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + (workspace).to_string(), + )), + provider: provider.to_string(), + expected_resource_version: resource_version, + }) + .await { - return Err(miette::miette!( - "Failed to detach provider: sandbox was modified by another operation.\n\ - Please retry the command." - )); + Ok(response) => break (sandbox, response.into_inner()), + // Explicit post-save uncertainty takes precedence over a generic retry hint. + Err(status) + if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) => + { + if attempt < PROVIDER_ATTACHMENT_CAS_ATTEMPTS { + attempt += 1; + continue; + } + return Err(miette::miette!( + "Failed to detach provider: sandbox was modified by another operation.\n\ + Please retry the command." + )); + } + Err(error) => return Err(provider_mutation_error(&error, "detachment")), } - Err(error) => return Err(provider_mutation_error(&error, "detachment")), }; let receipt = response.receipt.ok_or_else(|| miette!("gateway did not return a provider receipt; saved detachment cannot establish revocation"))?; diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index ba5f60c6cc..53ccb70309 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -80,6 +80,13 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 4428666184..2fd266941a 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -32,6 +32,13 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 538eebf060..8812daeefe 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -111,6 +111,8 @@ struct ProviderState { readiness_sequence: Arc, corrupt_mutation_receipt: Arc>>, fail_mutation_after_save: Arc>>, + /// Plain resource-version conflicts to return before an attachment saves. + sandbox_provider_conflicts: Arc, global_settings: Arc>>, } @@ -233,6 +235,13 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, @@ -422,6 +431,18 @@ impl OpenShell for TestOpenShell { sandbox_name: sandbox_name.clone(), provider: request.provider.clone(), }); + if self + .state + .sandbox_provider_conflicts + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(1) + }) + .is_ok() + { + return Err(Status::aborted( + "sandbox was modified concurrently (current resource_version: 2)", + )); + } if !self .state .providers @@ -484,6 +505,18 @@ impl OpenShell for TestOpenShell { sandbox_name: sandbox_name.clone(), provider: request.provider.clone(), }); + if self + .state + .sandbox_provider_conflicts + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(1) + }) + .is_ok() + { + return Err(Status::aborted( + "sandbox was modified concurrently (current resource_version: 2)", + )); + } let mut sandbox_providers = self.state.sandbox_providers.lock().await; let providers = sandbox_providers.entry(sandbox_name.clone()).or_default(); let before_len = providers.len(); @@ -6175,3 +6208,95 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() { }) .await; } + +#[tokio::test] +async fn provider_attachment_retries_resource_version_conflicts() { + let server = run_server().await; + seed_readiness_provider(&server).await; + for action in ["attach", "detach"] { + let sandbox_name = "conflicted"; + server.state.sandbox_providers.lock().await.insert( + sandbox_name.to_string(), + if action == "attach" { + Vec::new() + } else { + vec![READINESS_PROVIDER.to_string()] + }, + ); + server.state.sandbox_provider_requests.lock().await.clear(); + server + .state + .sandbox_provider_conflicts + .store(2, Ordering::SeqCst); + let output = run_readiness_cli( + &server, + &[ + "sandbox", + "provider", + action, + sandbox_name, + READINESS_PROVIDER, + ], + ) + .await; + assert!( + output.status.success(), + "{action}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + server.state.sandbox_provider_requests.lock().await.len(), + 3, + "{action} retried each conflict once" + ); + assert_eq!( + server + .state + .sandbox_providers + .lock() + .await + .get(sandbox_name) + .unwrap() + .contains(&READINESS_PROVIDER.to_string()), + action == "attach" + ); + } +} + +#[tokio::test] +async fn provider_attachment_reports_persistent_conflicts() { + let server = run_server().await; + seed_readiness_provider(&server).await; + server + .state + .sandbox_providers + .lock() + .await + .insert("contended".to_string(), Vec::new()); + server + .state + .sandbox_provider_conflicts + .store(u64::MAX, Ordering::SeqCst); + let output = run_readiness_cli( + &server, + &[ + "sandbox", + "provider", + "attach", + "contended", + READINESS_PROVIDER, + ], + ) + .await; + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("Please retry the command"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(server.state.sandbox_provider_requests.lock().await.len(), 5); + server + .state + .sandbox_provider_conflicts + .store(0, Ordering::SeqCst); +} diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 31345d6e2e..726b110687 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -101,6 +101,13 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs index c57e2d4ea5..48b84a3373 100644 --- a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs +++ b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs @@ -275,6 +275,7 @@ mock_gateway! { peer_report_provider_readiness(proto::ReportProviderReadinessRequest) -> proto::ReportProviderReadinessResponse; peer_report_endpoint_status(proto::ReportEndpointStatusRequest) -> proto::ReportEndpointStatusResponse; peer_get_sandbox_provider_status(proto::GetSandboxProviderStatusRequest) -> proto::GetSandboxProviderStatusResponse; + peer_notify_config_update(proto::PeerNotifyConfigUpdateRequest) -> proto::PeerNotifyConfigUpdateResponse; submit_policy_analysis(proto::SubmitPolicyAnalysisRequest) -> proto::SubmitPolicyAnalysisResponse; get_draft_policy(proto::GetDraftPolicyRequest) -> proto::GetDraftPolicyResponse; approve_draft_chunk(proto::ApproveDraftChunkRequest) -> proto::ApproveDraftChunkResponse; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 94a185567d..46f7c31306 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -47,6 +47,13 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index d68562398c..7f5d4d1ec0 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -46,6 +46,33 @@ pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; /// Default domain used for browser-facing sandbox service URLs. pub const DEFAULT_SERVICE_ROUTING_DOMAIN: &str = "openshell.localhost"; +/// Gateway delivery path for supervisor configuration. +/// +/// `Push` sends configuration over the supervisor session to supervisors that +/// apply streamed configuration, which then stop polling. Other supervisors +/// keep polling. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfigDeliveryMode { + #[default] + Poll, + Push, +} + +impl FromStr for ConfigDeliveryMode { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "poll" => Ok(Self::Poll), + "push" => Ok(Self::Push), + _ => Err(format!( + "invalid config delivery mode '{value}'; expected poll or push" + )), + } + } +} + /// Gateway posture when a sandbox rejects a candidate policy generation. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -209,6 +236,9 @@ pub struct Config { /// Security posture for rejected sandbox policy generations. pub policy_validation_failure_mode: PolicyValidationFailureMode, + /// Whether supervisors poll for configuration or receive it over their session. + pub config_delivery_mode: ConfigDeliveryMode, + /// TLS configuration. When `None`, the server listens on plaintext HTTP. pub tls: Option, @@ -901,6 +931,7 @@ impl Config { metrics_bind_address: None, log_level: default_log_level(), policy_validation_failure_mode: PolicyValidationFailureMode::default(), + config_delivery_mode: ConfigDeliveryMode::default(), tls, oidc: None, auth: GatewayAuthConfig::default(), @@ -1190,10 +1221,11 @@ const fn default_ssh_session_ttl_secs() -> u64 { #[cfg(test)] mod tests { use super::{ - AppArmorProfile, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, - GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, - GatewayProviderProfileSourceConfig, ImagePullPolicy, PolicyValidationFailureMode, - UpstreamProxyConfig, default_sandbox_pids_limit, normalize_compute_driver_name, + AppArmorProfile, Config, ConfigDeliveryMode, DEFAULT_SERVICE_ROUTING_DOMAIN, + GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, ImagePullPolicy, + PolicyValidationFailureMode, UpstreamProxyConfig, default_sandbox_pids_limit, + normalize_compute_driver_name, }; use std::net::SocketAddr; use std::time::Duration; @@ -1213,6 +1245,19 @@ mod tests { assert!("keep_old".parse::().is_err()); } + #[test] + fn config_delivery_defaults_to_poll_and_rejects_unknown_modes() { + assert_eq!( + Config::new(None).config_delivery_mode, + ConfigDeliveryMode::Poll + ); + assert_eq!( + "push".parse::().unwrap(), + ConfigDeliveryMode::Push + ); + assert!("enabled".parse::().is_err()); + } + #[test] fn compute_driver_name_normalization_accepts_builtin_and_custom_names() { assert_eq!(normalize_compute_driver_name(" VM ").unwrap(), "vm"); diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index ce76bc3d7b..a97deab7d6 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -57,6 +57,10 @@ pub fn grpc_status_error(status: Status) -> miette::Report { /// generated client type signatures stay readable. pub type AuthedChannel = InterceptedService; +/// Largest gateway message a supervisor accepts on its `ConnectSupervisor` +/// stream. The gateway keeps configuration updates below it. +pub const MAX_SUPERVISOR_STREAM_MESSAGE_BYTES: usize = 9 * 1024 * 1024; + /// Shared, refreshable Bearer header. All [`AuthInterceptor`] clones read /// the same slot, so the renewal task can replace the token in place without /// rebuilding the channel. @@ -1572,6 +1576,7 @@ pub struct SettingsPollResult { pub version: u32, pub policy_hash: String, pub config_revision: u64, + pub settings_revision: u64, pub policy_source: PolicySource, /// Effective settings keyed by name. pub settings: HashMap, @@ -1598,6 +1603,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin version: inner.version, policy_hash: inner.policy_hash, config_revision: inner.config_revision, + settings_revision: inner.settings_revision, policy_source: PolicySource::try_from(inner.policy_source) .unwrap_or(PolicySource::Unspecified), settings: inner.settings, @@ -1614,6 +1620,32 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin } } +/// Delivered snapshots go through the polling projection, so both paths +/// apply the same defaults and validation. +impl From for SettingsPollResult { + fn from(snapshot: crate::proto::SandboxConfigSnapshot) -> Self { + settings_poll_result(crate::proto::GetSandboxConfigResponse { + policy: snapshot.policy, + version: snapshot.version, + policy_hash: snapshot.policy_hash, + settings: snapshot.settings, + config_revision: snapshot.config_revision, + policy_source: snapshot.policy_source, + global_policy_version: snapshot.global_policy_version, + provider_env_revision: snapshot.provider_env_revision, + supervisor_middleware_services: snapshot.supervisor_middleware_services, + workspace: snapshot.workspace, + policy_validation_failure_mode: snapshot.policy_validation_failure_mode, + extension_authentication_enabled: snapshot.extension_authentication_enabled, + configuration_admitted: snapshot.configuration_admitted, + provider_attachment_epoch: snapshot.provider_attachment_epoch, + configuration_instance_id: snapshot.configuration_instance_id, + configuration_error: snapshot.configuration_error, + settings_revision: snapshot.settings_revision, + }) + } +} + #[cfg(test)] mod settings_poll_tests { use super::{learned_workspace_selector, settings_poll_result}; @@ -1656,6 +1688,25 @@ mod settings_poll_tests { assert!(!legacy.extension_authentication_enabled); } + #[test] + fn delivered_sandbox_snapshot_uses_the_polling_projection() { + let result = super::SettingsPollResult::from(crate::proto::SandboxConfigSnapshot { + version: 7, + policy_hash: "hash-7".to_string(), + config_revision: 42, + provider_env_revision: 9, + workspace: "workspace-a".to_string(), + extension_authentication_enabled: true, + ..Default::default() + }); + assert_eq!(result.version, 7); + assert_eq!(result.policy_hash, "hash-7"); + assert_eq!(result.config_revision, 42); + assert_eq!(result.provider_env_revision, 9); + assert_eq!(result.workspace, "workspace-a"); + assert!(result.extension_authentication_enabled); + } + #[test] fn workspace_selector_is_omitted_until_bootstrap_learns_the_workspace() { assert!(learned_workspace_selector(None).is_none()); @@ -1690,6 +1741,140 @@ pub struct ProviderEnvironmentResult { pub stable_placeholder_environment_keys: Vec, } +/// Delivered snapshots go through the polling projection, so both paths +/// reject invalid expiration times and withhold on unknown readiness reasons. +impl TryFrom for ProviderEnvironmentResult { + type Error = miette::Report; + + fn try_from(snapshot: crate::proto::ProviderEnvironmentSnapshot) -> Result { + use crate::proto::ProviderEnvironmentValueClassification; + + let mut response = GetSandboxProviderEnvironmentResponse { + provider_env_revision: snapshot.provider_env_revision, + dynamic_credentials: snapshot.dynamic_credentials, + provider_attachment_epoch: snapshot.provider_attachment_epoch, + policy_hash: snapshot.policy_hash, + readiness_reason: snapshot.readiness_reason, + files: snapshot.files, + // Supervisor session payloads are version-locked with the gateway, + // so every delivered snapshot carries stable-placeholder metadata. + supports_stable_placeholder_environment_keys: true, + ..Default::default() + }; + for value in snapshot.values { + if let Some(expiration_time) = value.expiration_time { + response + .credential_expiration_times + .insert(value.name.clone(), expiration_time); + } + match ProviderEnvironmentValueClassification::try_from(value.classification) + .unwrap_or_default() + { + ProviderEnvironmentValueClassification::NonSecret => { + response + .non_secret_environment_keys + .push(value.name.clone()); + } + ProviderEnvironmentValueClassification::StaticCredential => { + if value.stable_placeholder { + response + .stable_placeholder_environment_keys + .push(value.name.clone()); + } + if let Some(binding) = value.static_credential_binding { + response + .static_credential_bindings + .insert(value.name.clone(), binding); + } + } + ProviderEnvironmentValueClassification::Unspecified => {} + } + response.environment.insert(value.name, value.value); + } + provider_environment_result(response, false) + } +} + +#[cfg(test)] +mod provider_snapshot_tests { + use super::ProviderEnvironmentResult; + use crate::proto::{ + ProviderEnvironmentSnapshot, ProviderEnvironmentValue, + ProviderEnvironmentValueClassification, ProviderReadinessReason, StaticCredentialBinding, + }; + + #[test] + fn delivered_provider_snapshot_preserves_secret_classification_metadata() { + let result = ProviderEnvironmentResult::try_from(ProviderEnvironmentSnapshot { + provider_env_revision: 11, + values: vec![ + ProviderEnvironmentValue { + name: "REGION".to_string(), + value: "west".to_string(), + classification: ProviderEnvironmentValueClassification::NonSecret.into(), + ..Default::default() + }, + ProviderEnvironmentValue { + name: "TOKEN".to_string(), + value: "redacted".to_string(), + classification: ProviderEnvironmentValueClassification::StaticCredential.into(), + static_credential_binding: Some(StaticCredentialBinding::default()), + expiration_time: Some(prost_types::Timestamp { + seconds: 1_900_000_000, + nanos: 123_000_000, + }), + stable_placeholder: true, + }, + ], + ..Default::default() + }) + .expect("valid provider snapshot"); + assert_eq!(result.provider_env_revision, 11); + assert_eq!(result.environment.len(), 2); + assert_eq!(result.non_secret_environment_keys, ["REGION"]); + assert!(result.static_credential_bindings.contains_key("TOKEN")); + assert_eq!(result.stable_placeholder_environment_keys, ["TOKEN"]); + assert_eq!( + result.credential_expires_at_ms.get("TOKEN"), + Some(&1_900_000_000_123) + ); + assert_eq!( + result.readiness_reason, + ProviderReadinessReason::Unspecified + ); + } + + #[test] + fn delivered_provider_snapshot_withholds_unknown_readiness_reason() { + let result = ProviderEnvironmentResult::try_from(ProviderEnvironmentSnapshot { + readiness_reason: i32::MAX, + ..Default::default() + }) + .expect("valid provider snapshot"); + assert_eq!( + result.readiness_reason, + ProviderReadinessReason::CredentialsWithheld + ); + } + + #[test] + fn delivered_provider_snapshot_rejects_invalid_credential_expiration() { + let result = ProviderEnvironmentResult::try_from(ProviderEnvironmentSnapshot { + values: vec![ProviderEnvironmentValue { + name: "TOKEN".to_string(), + value: "redacted".to_string(), + expiration_time: Some(prost_types::Timestamp { + seconds: 1_900_000_000, + nanos: -1, + }), + ..Default::default() + }], + ..Default::default() + }); + assert!(result.is_err()); + } +} + /// Intermediate provider credential returned to the trusted supervisor. /// /// Tokens deliberately have no Debug representation. diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 9784bea474..56ea582109 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -234,6 +234,13 @@ fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloa #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-server/src/auth/method_authz.rs b/crates/openshell-server/src/auth/method_authz.rs index fb9f3ae461..1efefb3564 100644 --- a/crates/openshell-server/src/auth/method_authz.rs +++ b/crates/openshell-server/src/auth/method_authz.rs @@ -168,6 +168,7 @@ mod tests { "/openshell.v1.OpenShell/PeerReportProviderReadiness", "/openshell.v1.OpenShell/PeerReportEndpointStatus", "/openshell.v1.OpenShell/PeerGetSandboxProviderStatus", + "/openshell.v1.OpenShell/PeerNotifyConfigUpdate", ] { assert!(!is_user_callable(path)); assert!(is_peer_callable(path)); diff --git a/crates/openshell-server/src/auth/peer.rs b/crates/openshell-server/src/auth/peer.rs index 66e4c0a133..9026e49147 100644 --- a/crates/openshell-server/src/auth/peer.rs +++ b/crates/openshell-server/src/auth/peer.rs @@ -711,6 +711,7 @@ mod tests { "/openshell.v1.OpenShell/PeerReportProviderReadiness", "/openshell.v1.OpenShell/PeerReportEndpointStatus", "/openshell.v1.OpenShell/PeerGetSandboxProviderStatus", + "/openshell.v1.OpenShell/PeerNotifyConfigUpdate", ] { assert!(matches!( auth.authenticate(&headers, path).await.unwrap(), @@ -723,7 +724,7 @@ mod tests { .unwrap() .is_none() ); - assert_eq!(resolver.seen_tokens.lock().unwrap().len(), 4); + assert_eq!(resolver.seen_tokens.lock().unwrap().len(), 5); } fn identity() -> ResolvedGatewayPeerIdentity { diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index f7e0b31efb..5b2ce01c13 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -6,7 +6,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; -use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; +use openshell_core::config::{ConfigDeliveryMode, DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::collections::BTreeMap; use std::ffi::OsString; use std::net::{IpAddr, SocketAddr}; @@ -273,6 +273,10 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS")] grpc_rate_limit_window_seconds: Option, + /// Supervisor configuration delivery: poll (default) or push over the supervisor session. + #[arg(long, env = "OPENSHELL_CONFIG_DELIVERY_MODE")] + config_delivery_mode: Option, + /// Subject Alternative Names configured on the gateway server certificate. /// Wildcard DNS SANs also enable sandbox service URLs under that domain. #[arg( @@ -606,6 +610,13 @@ fn prepare_server_config_with_drivers( { config.policy_validation_failure_mode = mode; } + config.config_delivery_mode = args + .config_delivery_mode + .or_else(|| { + file.as_ref() + .and_then(|f| f.openshell.gateway.config_delivery_mode) + }) + .unwrap_or_default(); if let Some(seconds) = file .as_ref() diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 3b9e541c65..09b5fc3b82 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -771,6 +771,20 @@ impl fmt::Debug for ComputeRuntime { } } +#[derive(Clone, Copy)] +enum SupervisorSessionStateUpdate<'a> { + Connected { + instance_id: &'a str, + admission: Option<&'a openshell_core::proto::SandboxConfigurationAdmission>, + /// The session's workload runs. Without this, only the admission is + /// recorded. + runtime_ready: bool, + }, + Disconnected { + terminal_delivery_finalized: bool, + }, +} + impl ComputeRuntime { #[allow(clippy::too_many_arguments)] #[tracing::instrument( @@ -1470,6 +1484,18 @@ impl ComputeRuntime { } }; + if let Err(status) = Box::pin(crate::grpc::policy::initialize_policy_history( + self.store.as_ref(), + &sandbox, + crate::grpc::policy::InitialPolicyHistoryStatus::Pending, + )) + .await + { + return Err(self + .compensate_failed_create(&sandbox, lifecycle_guard, None, status) + .await); + } + if let Some(token) = sandbox_token && let Some(spec) = driver_sandbox.spec.as_mut() { @@ -5065,8 +5091,47 @@ impl ComputeRuntime { sandbox_id: &str, instance_id: &str, ) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, true, Some(instance_id), false) + self.set_supervisor_session_state( + sandbox_id, + SupervisorSessionStateUpdate::Connected { + instance_id, + admission: None, + runtime_ready: true, + }, + ) + .await + } + + pub async fn supervisor_session_admission( + &self, + sandbox_id: &str, + session_id: &str, + instance_id: &str, + admission: &openshell_core::proto::SandboxConfigurationAdmission, + ) -> Result<(), String> { + let _guard = self.lock_sandbox_local(sandbox_id).await; + // A session ending while this waited for the lock has already removed + // itself, so its disconnect, queued behind this guard, keeps the + // sandbox out of Ready. Checking before the lock could restore Ready + // after that disconnect. + let runtime_ready = self + .supervisor_sessions + .is_current_session(sandbox_id, session_id); + let existing = self + .store + .get_message::(sandbox_id) .await + .map_err(|err| err.to_string())?; + self.set_supervisor_session_state_from_snapshot( + sandbox_id, + SupervisorSessionStateUpdate::Connected { + instance_id, + admission: Some(admission), + runtime_ready, + }, + existing, + ) + .await } pub async fn supervisor_session_disconnected( @@ -5089,9 +5154,9 @@ impl ComputeRuntime { .map_err(|error| error.to_string())?; self.set_supervisor_session_state_from_snapshot( sandbox_id, - false, - None, - terminal_delivery_finalized, + SupervisorSessionStateUpdate::Disconnected { + terminal_delivery_finalized, + }, existing, ) .await?; @@ -5110,9 +5175,11 @@ impl ComputeRuntime { .map_err(|error| error.to_string())?; self.set_supervisor_session_state_from_snapshot( sandbox_id, - true, - Some(&instance_id), - false, + SupervisorSessionStateUpdate::Connected { + instance_id: &instance_id, + admission: None, + runtime_ready: true, + }, existing, ) .await?; @@ -5150,9 +5217,7 @@ impl ComputeRuntime { async fn set_supervisor_session_state( &self, sandbox_id: &str, - connected: bool, - instance_id: Option<&str>, - terminal_delivery_finalized: bool, + update: SupervisorSessionStateUpdate<'_>, ) -> Result<(), String> { let _guard = self.lock_sandbox_local(sandbox_id).await; let existing = self @@ -5160,24 +5225,27 @@ impl ComputeRuntime { .get_message::(sandbox_id) .await .map_err(|err| err.to_string())?; - self.set_supervisor_session_state_from_snapshot( - sandbox_id, - connected, - instance_id, - terminal_delivery_finalized, - existing, - ) - .await + self.set_supervisor_session_state_from_snapshot(sandbox_id, update, existing) + .await } async fn set_supervisor_session_state_from_snapshot( &self, sandbox_id: &str, - connected: bool, - instance_id: Option<&str>, - terminal_delivery_finalized: bool, + update: SupervisorSessionStateUpdate<'_>, mut existing: Option, ) -> Result<(), String> { + let (connected, instance_id, admission, terminal_delivery_finalized, runtime_ready) = + match update { + SupervisorSessionStateUpdate::Connected { + instance_id, + admission, + runtime_ready, + } => (true, Some(instance_id), admission, false, runtime_ready), + SupervisorSessionStateUpdate::Disconnected { + terminal_delivery_finalized, + } => (false, None, None, terminal_delivery_finalized, false), + }; for attempt in 1..=SUPERVISOR_SESSION_CAS_RETRY_LIMIT { let Some(current) = existing else { return Ok(()); @@ -5224,6 +5292,10 @@ impl ComputeRuntime { { return Err("restarting sandbox requires a fresh supervisor instance".into()); } + let provisioning = admission + .map(|admission| admitted_provisioning(¤t, admission)) + .transpose()? + .flatten(); let expected_resource_version = sandbox_resource_version(¤t); let result = self .store @@ -5232,21 +5304,31 @@ impl ComputeRuntime { expected_resource_version, |sandbox| { if connected { - ensure_supervisor_ready_status(&mut sandbox.status); let status = sandbox.status.get_or_insert_with(Default::default); - let same_instance = - Some(status.main_process_instance_id.as_str()) == instance_id; - status.main_process_instance_id = - instance_id.unwrap_or_default().to_string(); - status.exit_code = None; - set_next_restart_at_ms(status, 0); - if !same_instance || main_process_started_at_ms(status) == 0 { - set_main_process_started_at_ms( - status, - openshell_core::time::now_ms(), - ); + if let Some(admission) = admission { + record_session_admission(status, admission); + } + if provisioning.is_some() { + status.provisioning.clone_from(&provisioning); + } + // An admission-only update leaves restart state to + // the instance that last connected. + if runtime_ready { + let same_instance = + Some(status.main_process_instance_id.as_str()) == instance_id; + status.main_process_instance_id = + instance_id.unwrap_or_default().to_string(); + status.exit_code = None; + set_next_restart_at_ms(status, 0); + if !same_instance || main_process_started_at_ms(status) == 0 { + set_main_process_started_at_ms( + status, + openshell_core::time::now_ms(), + ); + } + ensure_supervisor_ready_status(&mut sandbox.status); + sandbox.set_phase(SandboxPhase::Ready as i32); } - sandbox.set_phase(SandboxPhase::Ready as i32); } else { ensure_supervisor_not_ready_status(&mut sandbox.status); sandbox.set_phase(SandboxPhase::Provisioning as i32); @@ -6983,6 +7065,60 @@ fn apply_driver_snapshot( apply_configuration_readiness(sandbox); } +/// Record a supervisor session's admission as a polled configuration report +/// does. A rejected update leaves an accepted runtime accepted and records only +/// its error, and activation is recorded but never revoked. +fn record_session_admission( + status: &mut SandboxStatus, + admission: &openshell_core::proto::SandboxConfigurationAdmission, +) { + use openshell_core::proto::ConfigurationAdmissionState; + + let accepted = admission.state == i32::from(ConfigurationAdmissionState::Accepted); + let admission = match status.configuration_admission.as_ref() { + Some(current) + if !accepted + && current.state == i32::from(ConfigurationAdmissionState::Accepted) + && current.instance_id == admission.instance_id => + { + openshell_core::proto::SandboxConfigurationAdmission { + error: admission.error.clone(), + ..current.clone() + } + } + _ => admission.clone(), + }; + status.configuration_admission = Some(admission); + if accepted { + status.configuration_activated = Some(true); + } +} + +/// Admission evidence from a supervisor session ends image preparation, as a +/// polled configuration report does. Returns the updated provisioning record, +/// or `None` when the sandbox has none. +fn admitted_provisioning( + sandbox: &Sandbox, + admission: &openshell_core::proto::SandboxConfigurationAdmission, +) -> Result, String> { + let Some(mut record) = sandbox + .status + .as_ref() + .and_then(|status| status.provisioning.clone()) + else { + return Ok(None); + }; + let now_ms = openshell_core::time::now_ms(); + if !provisioning_deadline::allows_admission(&record, now_ms) { + return Err("provisioning deadline expired".to_string()); + } + provisioning_deadline::record_admission_start(&mut record, now_ms)?; + if admission.state == i32::from(openshell_core::proto::ConfigurationAdmissionState::Rejected) { + provisioning_deadline::record_rejection(&mut record, now_ms)?; + } + Ok(Some(record)) +} + /// Configuration readiness is independent of compute/container readiness. pub fn apply_configuration_readiness(sandbox: &mut Sandbox) { use openshell_core::proto::ConfigurationAdmissionState; @@ -7722,6 +7858,7 @@ mod tests { mod error_recovery; use super::*; + use crate::policy_store::PolicyStoreExt; use futures::stream; use openshell_core::proto::SandboxPolicy as PublicSandboxPolicy; use openshell_core::proto::compute::v1::{ @@ -15107,6 +15244,119 @@ mod tests { assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); } + #[tokio::test] + async fn rejected_session_admission_keeps_an_accepted_runtime_ready() { + use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission}; + + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + runtime + .supervisor_session_connected("sb-1", "supervisor-1") + .await + .unwrap(); + let accepted = SandboxConfigurationAdmission { + instance_id: "configuration-1".into(), + state: ConfigurationAdmissionState::Accepted.into(), + policy_version: 1, + config_revision: 7, + ..Default::default() + }; + runtime + .supervisor_session_admission("sb-1", "session-1", "supervisor-1", &accepted) + .await + .unwrap(); + + // A later update fails to apply on the running supervisor. + let rejected = SandboxConfigurationAdmission { + state: ConfigurationAdmissionState::Rejected.into(), + policy_version: 2, + config_revision: 8, + error: "effective configuration could not be activated".into(), + ..accepted.clone() + }; + runtime + .supervisor_session_admission("sb-1", "session-1", "supervisor-1", &rejected) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Ready as i32); + let status = stored.status.as_ref().unwrap(); + assert_eq!(status.configuration_activated, Some(true)); + assert_eq!( + status.configuration_admission, + Some(SandboxConfigurationAdmission { + error: rejected.error.clone(), + ..accepted + }) + ); + assert!(status.conditions.iter().any(|condition| { + condition.r#type == "DesiredConfigurationReady" && condition.status == "False" + })); + } + + #[tokio::test] + async fn stale_session_admission_does_not_restore_ready() { + use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission}; + + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Provisioning); + runtime.store.put_message(&sandbox).await.unwrap(); + register_test_supervisor_session(&runtime, "sb-1"); + runtime + .supervisor_session_connected("sb-1", "supervisor-1") + .await + .unwrap(); + + // session-1 ends and a replacement session is registered before its + // admission write takes the sandbox lock. + runtime + .supervisor_sessions + .remove_if_current("sb-1", "session-1"); + runtime + .supervisor_session_disconnected("sb-1", false) + .await + .unwrap(); + let (tx, _rx) = mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + runtime.supervisor_sessions.register( + "sb-1".to_string(), + "session-2".to_string(), + tx, + shutdown_tx, + ); + + let admission = SandboxConfigurationAdmission { + instance_id: "configuration-1".into(), + state: ConfigurationAdmissionState::Accepted.into(), + policy_version: 1, + config_revision: 7, + ..Default::default() + }; + runtime + .supervisor_session_admission("sb-1", "session-1", "supervisor-1", &admission) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Provisioning as i32); + assert_eq!( + stored.status.as_ref().unwrap().configuration_admission, + Some(admission) + ); + } + #[tokio::test] async fn supervisor_session_connected_retries_a_stale_store_snapshot() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; @@ -15125,9 +15375,11 @@ mod tests { runtime .set_supervisor_session_state_from_snapshot( "sb-1", - true, - Some("test-generation"), - false, + SupervisorSessionStateUpdate::Connected { + instance_id: "test-generation", + admission: None, + runtime_ready: true, + }, stale, ) .await @@ -17395,6 +17647,36 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" ); } + #[tokio::test] + async fn create_sandbox_persists_initial_policy_revision() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record( + "sb-initial-policy", + "initial-policy", + SandboxPhase::Provisioning, + ); + let policy = openshell_core::proto::SandboxPolicy::default(); + sandbox.spec = Some(SandboxSpec { + policy: Some(policy.clone()), + ..Default::default() + }); + + runtime.create_sandbox(sandbox, None, false).await.unwrap(); + + let revision = runtime + .store + .get_latest_policy("sb-initial-policy") + .await + .unwrap() + .expect("initial policy revision"); + assert_eq!(revision.version, 1); + assert_eq!( + revision.policy_hash, + crate::grpc::policy::deterministic_policy_hash(&policy) + ); + assert_eq!(revision.status, "pending"); + } + #[tokio::test] async fn created_sandbox_is_immediately_visible_to_label_selectors() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; diff --git a/crates/openshell-server/src/config_delivery/mod.rs b/crates/openshell-server/src/config_delivery/mod.rs new file mode 100644 index 0000000000..dbf5fe86d5 --- /dev/null +++ b/crates/openshell-server/src/config_delivery/mod.rs @@ -0,0 +1,2189 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Build and deliver complete supervisor configuration. + +pub mod session; +mod task; + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use futures::StreamExt; +use metrics::{counter, histogram}; +use openshell_core::config::ConfigDeliveryMode; +use openshell_core::proto::{ + PeerConfigProviderTarget, PeerConfigSandboxTarget, PeerNotifyConfigUpdateRequest, + PeerNotifyConfigUpdateResponse, ProviderEnvironmentSnapshot, ProviderReadinessReason, Sandbox, + SandboxConfigSnapshot, peer_notify_config_update_request, +}; +use tokio::sync::Semaphore; +use tonic::{Code, Request, Response, Status}; +use tracing::warn; + +use crate::ServerState; +use crate::auth::principal::Principal; +use crate::gateway_members::{GatewayMember, GatewayMemberIndex, MEMBER_TTL}; +use crate::grpc::policy::{ + SandboxConfigInputs, build_provider_environment_snapshot_from_inputs, + build_sandbox_config_snapshot_from_inputs, is_configuration_rejection, + load_sandbox_config_inputs, unadmitted_sandbox_config, +}; +use crate::supervisor_owner::{OWNER_TTL, SupervisorOwnerIndex, dialable_peer_endpoint}; + +pub use task::Registration; +use task::{Deliver, FanoutScope, SessionRecord}; + +/// Leaves 1 MiB below the supervisor's receive limit for framing and future +/// envelope fields. +pub const MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES: usize = + openshell_core::grpc_client::MAX_SUPERVISOR_STREAM_MESSAGE_BYTES - 1024 * 1024; +/// Loading inputs and building the sandbox configuration only read the store, +/// which answers in milliseconds when healthy; anything slower means the +/// store is in trouble. +const SANDBOX_CONFIG_BUILD_TIMEOUT: Duration = Duration::from_secs(5); +/// Building the provider environment also resolves credentials. Covers a cold +/// Vault Kubernetes-auth login plus a read at the default 10-second request +/// timeout. +const PROVIDER_ENVIRONMENT_BUILD_TIMEOUT: Duration = Duration::from_secs(20); +/// Concurrent snapshot builds allowed per pooled database connection. Builds +/// are short bursts of small queries, so a little oversubscription keeps the +/// pool busy without stacking every waiter on the acquire timeout. +const SNAPSHOT_BUILDS_PER_DB_CONNECTION: usize = 2; +const MIN_CONCURRENT_SNAPSHOT_BUILDS: usize = 4; +const MAX_CONCURRENT_PEER_NOTIFIES: usize = 8; +const PEER_NOTIFY_TIMEOUT: Duration = Duration::from_secs(5); +const PEER_FANOUT_CONCURRENCY: usize = 8; + +/// Complete configuration awaiting delivery to a supervisor: the sandbox +/// configuration and the provider environment it names. +#[derive(Clone)] +pub struct SupervisorConfig { + pub sandbox_config: SandboxConfigSnapshot, + pub provider_environment: ProviderEnvironmentSnapshot, +} + +impl fmt::Debug for SupervisorConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SupervisorConfig()") + } +} + +/// Whether `provider` is the environment `sandbox` names. Without a gateway +/// policy the sandbox configuration carries no policy hash and is not +/// admitted, so its supervisor installs no provider environment. +fn names_provider_environment( + sandbox: &SandboxConfigSnapshot, + provider: &ProviderEnvironmentSnapshot, +) -> bool { + sandbox.provider_env_revision == provider.provider_env_revision + && sandbox.provider_attachment_epoch == provider.provider_attachment_epoch + && (sandbox.policy.is_none() || sandbox.policy_hash == provider.policy_hash) +} + +/// The provider environment to deliver when the one `sandbox` names cannot +/// be built: no values, marked as failed. The supervisor revokes its static +/// credentials and rejects the update, as it does when a polling fetch +/// fails, and the session retries the build. +fn unresolved_provider_environment(sandbox: &SandboxConfigSnapshot) -> ProviderEnvironmentSnapshot { + ProviderEnvironmentSnapshot { + provider_env_revision: sandbox.provider_env_revision, + provider_attachment_epoch: sandbox.provider_attachment_epoch.clone(), + policy_hash: sandbox.policy_hash.clone(), + readiness_reason: ProviderReadinessReason::CredentialInstallFailed.into(), + ..Default::default() + } +} + +/// Result of handing one configuration to a supervisor session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryDisposition { + Queued, + /// Held until the supervisor answers the previous update. + Coalesced, + /// Matches the update the supervisor last acknowledged. + SuppressedUnchanged, + NoActiveSession, + UnsupportedSession, + PayloadTooLarge, + /// The session's outbound stream was full or closed, so the session was + /// closed for its supervisor to reconnect. + StreamClosed, +} + +impl DeliveryDisposition { + fn metric_label(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Coalesced => "coalesced", + Self::SuppressedUnchanged => "unchanged", + Self::NoActiveSession => "no_active_session", + Self::UnsupportedSession => "unsupported_session", + Self::PayloadTooLarge => "payload_too_large", + Self::StreamClosed => "stream_closed", + } + } +} + +/// Per-session delivery tasks for this replica's push sessions, plus +/// coalesced notifications to peer replicas. +#[derive(Debug)] +pub struct ConfigDelivery { + sessions: Mutex, + /// Snapshot builds in progress, bounded by the database pool they read. + builds: Arc, + /// Targets with a notification task running, and whether another + /// notification arrived since that task last sent one. + peer_notifies: Mutex>, + peer_notify_permits: Arc, +} + +/// This replica's push sessions and the publications that looked for them. +#[derive(Debug, Default)] +struct Sessions { + records: HashMap>, + /// Counts publications, so a registering session can tell whether one + /// raced its bootstrap. It shares the lock with `records`, so a + /// publication either finds a registered session or is counted before + /// that session compares the count. + publications: u64, +} + +/// Where a peer notification goes. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum PeerNotifyTarget { + /// Sandbox without a local push session; its owner may be a peer. + Sandbox(String), + /// Scope notification for every peer gateway. + Peers(FanoutScope), +} + +impl ConfigDelivery { + /// Size the build bound from the persistence pool that every build reads. + #[must_use] + pub fn for_db_connections(max_connections: u32) -> Self { + let max_connections = usize::try_from(max_connections).unwrap_or(usize::MAX); + let builds = max_connections + .saturating_mul(SNAPSHOT_BUILDS_PER_DB_CONNECTION) + .max(MIN_CONCURRENT_SNAPSHOT_BUILDS); + Self { + sessions: Mutex::new(Sessions::default()), + builds: Arc::new(Semaphore::new(builds)), + peer_notifies: Mutex::new(HashMap::new()), + peer_notify_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_PEER_NOTIFIES)), + } + } + + pub(crate) fn publications(&self) -> u64 { + self.sessions().publications + } + + #[cfg(test)] + fn has_session(&self, sandbox_id: &str) -> bool { + self.sessions().records.contains_key(sandbox_id) + } + + /// Delivery is best effort beside the session lifecycle, so a poisoned + /// lock must not turn session teardown into a panic. + fn sessions(&self) -> MutexGuard<'_, Sessions> { + self.sessions.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Returns false when this gateway has no push session for the sandbox. + fn publish_sandbox(&self, sandbox_id: &str) -> bool { + let record = { + let mut sessions = self.sessions(); + sessions.publications += 1; + sessions.records.get(sandbox_id).cloned() + }; + let Some(record) = record else { + return false; + }; + record.mark(); + true + } + + fn publish_fanout(&self, scope: &FanoutScope) { + let mut sessions = self.sessions(); + sessions.publications += 1; + for record in sessions.records.values() { + if record.in_scope(scope) { + record.mark(); + } + } + } +} + +pub fn push_enabled(state: &ServerState) -> bool { + state.config.config_delivery_mode == ConfigDeliveryMode::Push +} + +/// Push a committed change to one sandbox's supervisor. +pub fn publish_sandbox(state: &Arc, sandbox_id: &str) { + if !push_enabled(state) { + return; + } + // A single-replica gateway owns every session, and registration covers a + // session that is still connecting. + if !state.config_delivery.publish_sandbox(sandbox_id) && !state.store.is_single_replica() { + notify_peer(state, PeerNotifyTarget::Sandbox(sandbox_id.to_string())); + } +} + +/// Push a committed change to every sandbox in a workspace. +pub fn publish_workspace(state: &Arc, workspace: &str) { + publish_fanout(state, &FanoutScope::Workspace(workspace.to_string())); +} + +/// Push a provider change to the sandboxes that attach that provider. +pub fn publish_provider(state: &Arc, workspace: &str, provider_name: &str) { + publish_fanout( + state, + &FanoutScope::Provider { + workspace: workspace.to_string(), + name: provider_name.to_string(), + }, + ); +} + +/// Push a committed change to every connected sandbox. +pub fn publish_all_connected(state: &Arc) { + publish_fanout(state, &FanoutScope::AllConnected); +} + +/// Push a change that every replica observes on its own, such as a refreshed +/// provider profile source, to the sessions this replica holds. Each replica +/// publishes its own change, so notifying peers would repeat it. +pub fn publish_all_local(state: &Arc) { + if push_enabled(state) { + state + .config_delivery + .publish_fanout(&FanoutScope::AllConnected); + } +} + +fn publish_fanout(state: &Arc, scope: &FanoutScope) { + if !push_enabled(state) { + return; + } + state.config_delivery.publish_fanout(scope); + if !state.store.is_single_replica() { + notify_peer(state, PeerNotifyTarget::Peers(scope.clone())); + } +} + +/// Make a streamed-apply session visible to publications and start its +/// delivery task. +pub fn register_session(state: &Arc, registration: Registration) { + let captured_seq = registration.captured_seq; + let rebuild = registration.rebuild; + let record = Arc::new(SessionRecord::new(registration)); + let raced = { + let mut sessions = state.config_delivery.sessions(); + // A concurrent reconnect may already own the registry entry. Checking + // under the sessions lock keeps the newest session registered. + if !state + .supervisor_sessions + .is_current_session(&record.sandbox_id, &record.session_id) + { + return; + } + if let Some(previous) = sessions + .records + .insert(record.sandbox_id.clone(), Arc::clone(&record)) + { + previous.close(); + } + sessions.publications > captured_seq + }; + // A publication between the bootstrap read and registration may have + // missed this session. + if raced || rebuild { + record.mark(); + } + let state = Arc::clone(state); + tokio::spawn(async move { + task::run( + record, + &state.config_delivery.builds, + &Builder(&state), + task::RECONCILE_INTERVAL, + ) + .await; + }); +} + +pub fn unregister_session(state: &ServerState, sandbox_id: &str, session_id: &str) { + let mut sessions = state.config_delivery.sessions(); + if sessions + .records + .get(sandbox_id) + .is_none_or(|record| record.session_id != session_id) + { + return; + } + if let Some(record) = sessions.records.remove(sandbox_id) { + record.close(); + } +} + +/// Builds and delivers a session's configuration. +struct Builder<'a>(&'a Arc); + +impl Deliver for Builder<'_> { + async fn deliver(&self, record: &SessionRecord) -> (bool, Option>) { + let state = self.0; + let sandbox_id = record.sandbox_id.as_str(); + let started = Instant::now(); + let built = tokio::time::timeout( + SANDBOX_CONFIG_BUILD_TIMEOUT, + build_sandbox_config(state, sandbox_id), + ) + .await; + let (inputs, sandbox_config) = match built { + Ok(Ok(Some(BuiltSandboxConfig::Built(inputs, sandbox_config)))) => { + (*inputs, sandbox_config) + } + Ok(Ok(Some(BuiltSandboxConfig::Rejected { + sandbox_config, + providers, + error_code, + }))) => { + record_build_failure(sandbox_id, "rejected", error_code, started.elapsed()); + let provider_environment = unresolved_provider_environment(&sandbox_config); + let config = SupervisorConfig { + sandbox_config, + provider_environment, + }; + let owner = deliver_if_owner(state, sandbox_id, &record.session_id, config).await; + if matches!(owner, OwnerCheck::Remote) { + notify_peer(state, PeerNotifyTarget::Sandbox(record.sandbox_id.clone())); + } + return (matches!(owner, OwnerCheck::Unknown), Some(providers)); + } + Ok(Ok(None)) => { + record_build("ok", started.elapsed()); + return (false, None); + } + Ok(Err(error)) => { + record_build_failure(sandbox_id, "failed", error.code(), started.elapsed()); + return (true, None); + } + Err(_) => { + record_build_failure( + sandbox_id, + "timeout", + Code::DeadlineExceeded, + started.elapsed(), + ); + return (true, None); + } + }; + let providers = inputs + .sandbox() + .spec + .as_ref() + .map(|spec| spec.providers.iter().cloned().collect()) + .unwrap_or_default(); + let (provider_environment, failed) = + provider_environment(state, record, &inputs, &sandbox_config).await; + if failed.is_none() { + record_build("ok", started.elapsed()); + } + let config = SupervisorConfig { + sandbox_config, + provider_environment, + }; + let owner = deliver_if_owner(state, sandbox_id, &record.session_id, config).await; + if matches!(owner, OwnerCheck::Remote) { + notify_peer(state, PeerNotifyTarget::Sandbox(record.sandbox_id.clone())); + } + if let Some((outcome, error_code)) = failed { + record_build_failure(sandbox_id, outcome, error_code, started.elapsed()); + } + ( + failed.is_some() || matches!(owner, OwnerCheck::Unknown), + Some(providers), + ) + } +} + +enum BuiltSandboxConfig { + Built(Box, SandboxConfigSnapshot), + /// The stored configuration is invalid, so the supervisor receives the + /// not-admitted configuration polling serves and fails closed. + Rejected { + sandbox_config: SandboxConfigSnapshot, + providers: HashSet, + error_code: Code, + }, +} + +/// Read the sandbox and the inputs its configuration shares, and build its +/// sandbox configuration, or `None` once the sandbox is gone. +async fn build_sandbox_config( + state: &Arc, + sandbox_id: &str, +) -> Result, Status> { + let Some(sandbox) = state + .store + .get_message::(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + else { + return Ok(None); + }; + let built = async { + let inputs = load_sandbox_config_inputs(state, sandbox.clone()).await?; + let snapshot = build_sandbox_config_snapshot_from_inputs(state, &inputs).await?; + Ok::<_, Status>((inputs, snapshot)) + } + .await; + match built { + Ok((inputs, snapshot)) => Ok(Some(BuiltSandboxConfig::Built(Box::new(inputs), snapshot))), + Err(error) if is_configuration_rejection(&error) => { + Ok(Some(BuiltSandboxConfig::Rejected { + sandbox_config: unadmitted_sandbox_config(state, &sandbox, &error), + providers: sandbox + .spec + .as_ref() + .map(|spec| spec.providers.iter().cloned().collect()) + .unwrap_or_default(), + error_code: error.code(), + })) + } + Err(error) => Err(error), + } +} + +/// The provider environment `sandbox` names. The session's last resolved +/// environment is reused while the name is unchanged, so only a change +/// resolves credentials. A failed resolution returns the unresolved +/// environment and the failure to record. +async fn provider_environment( + state: &Arc, + record: &SessionRecord, + inputs: &SandboxConfigInputs, + sandbox: &SandboxConfigSnapshot, +) -> (ProviderEnvironmentSnapshot, Option<(&'static str, Code)>) { + if let Some(cached) = record + .provider_environment() + .filter(|cached| names_provider_environment(sandbox, cached)) + { + return (cached, None); + } + let built = tokio::time::timeout( + PROVIDER_ENVIRONMENT_BUILD_TIMEOUT, + build_provider_environment_snapshot_from_inputs(state, inputs, true, true), + ) + .await; + match built { + Ok(Ok(snapshot)) => { + record.cache_provider_environment(snapshot.clone()); + (snapshot, None) + } + Ok(Err(error)) => ( + unresolved_provider_environment(sandbox), + Some(("failed", error.code())), + ), + Err(_) => ( + unresolved_provider_environment(sandbox), + Some(("timeout", Code::DeadlineExceeded)), + ), + } +} + +/// Deliver `config` if this gateway still owns the session it was built for. +async fn deliver_if_owner( + state: &Arc, + sandbox_id: &str, + session_id: &str, + config: SupervisorConfig, +) -> OwnerCheck { + let owner = owner_check(state, sandbox_id, session_id).await; + let outcome = match owner { + OwnerCheck::Current => state + .supervisor_sessions + .deliver_config(sandbox_id, session_id, config) + .metric_label(), + OwnerCheck::Remote => "owner_moved", + OwnerCheck::Gone => "no_active_session", + OwnerCheck::Unknown => "owner_lookup_failed", + }; + record_delivery(outcome); + owner +} + +enum OwnerCheck { + /// This gateway owns the session the build started for. + Current, + /// The session moved to another gateway after the build started. + Remote, + Gone, + Unknown, +} + +async fn owner_check(state: &Arc, sandbox_id: &str, session_id: &str) -> OwnerCheck { + // A single-replica gateway owns every session, and `deliver_config` + // already refuses a replaced session. + if state.store.is_single_replica() { + return OwnerCheck::Current; + } + let owners = SupervisorOwnerIndex::new(Arc::clone(&state.store), OWNER_TTL); + match owners.read(sandbox_id).await { + Ok(Some(owner)) if owner.is_fresh(OWNER_TTL) => { + if owner.owner_replica_id != state.replica_id { + OwnerCheck::Remote + } else if owner.session_id == session_id { + OwnerCheck::Current + } else { + OwnerCheck::Gone + } + } + Ok(_) => OwnerCheck::Gone, + Err(error) => { + warn!(sandbox_id, error = %error, "configuration owner lookup failed"); + OwnerCheck::Unknown + } + } +} + +/// Queue a notification for `target`. Notifications for the same target +/// coalesce while one is pending or running. +fn notify_peer(state: &Arc, target: PeerNotifyTarget) { + let start = { + let mut pending = state + .config_delivery + .peer_notifies + .lock() + .unwrap_or_else(PoisonError::into_inner); + let mut start = false; + pending + .entry(target.clone()) + .and_modify(|queued| *queued = true) + .or_insert_with(|| { + start = true; + true + }); + start + }; + if start { + tokio::spawn(run_peer_notifies(Arc::clone(state), target)); + } +} + +async fn run_peer_notifies(state: Arc, target: PeerNotifyTarget) { + let _permit = Arc::clone(&state.config_delivery.peer_notify_permits) + .acquire_owned() + .await + .expect("peer notification semaphore is never closed"); + loop { + { + let mut pending = state + .config_delivery + .peer_notifies + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(queued) = pending.get_mut(&target) else { + return; + }; + if !std::mem::take(queued) { + pending.remove(&target); + return; + } + } + match &target { + PeerNotifyTarget::Sandbox(sandbox_id) => { + if notify_sandbox_owner(&state, sandbox_id).await == PeerNotifyResult::Local { + state.config_delivery.publish_sandbox(sandbox_id); + } + } + PeerNotifyTarget::Peers(scope) => notify_peers(&state, scope).await, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum PeerNotifyResult { + Done, + /// This gateway owns the current session for the notified sandbox. + Local, +} + +/// Send a secret-free notification to the gateway that owns the sandbox session. The +/// owner builds its own configuration from shared state. +async fn notify_sandbox_owner(state: &Arc, sandbox_id: &str) -> PeerNotifyResult { + let owners = SupervisorOwnerIndex::new(Arc::clone(&state.store), OWNER_TTL); + let mut final_outcome = "stale_owner"; + for attempt in 0..2 { + let owner = match owners.read(sandbox_id).await { + Ok(Some(owner)) if owner.is_fresh(OWNER_TTL) => owner, + Ok(_) => return PeerNotifyResult::Done, + Err(error) => { + warn!(sandbox_id, error = %error, "configuration owner lookup failed"); + return PeerNotifyResult::Done; + } + }; + if owner.owner_replica_id == state.replica_id { + return if state + .supervisor_sessions + .is_current_session(sandbox_id, &owner.session_id) + { + PeerNotifyResult::Local + } else { + PeerNotifyResult::Done + }; + } + let Some(peer_endpoint) = owner.peer_endpoint().map(ToString::to_string) else { + warn!(sandbox_id, owner = %owner.owner_replica_id, "configuration owner has no reachable peer endpoint"); + return PeerNotifyResult::Done; + }; + let request = PeerNotifyConfigUpdateRequest { + scope: Some(peer_notify_config_update_request::Scope::Sandbox( + PeerConfigSandboxTarget { + sandbox_id: sandbox_id.to_string(), + session_id: owner.session_id, + }, + )), + }; + match send_peer_notify(state, &peer_endpoint, request).await { + PeerNotifyOutcome::StaleOwner => final_outcome = "stale_owner", + PeerNotifyOutcome::Failed(outcome) => { + final_outcome = outcome; + warn!(sandbox_id, owner = %owner.owner_replica_id, attempt, outcome, "configuration peer notification failed"); + } + outcome => { + record_peer_notify(outcome.label()); + return PeerNotifyResult::Done; + } + } + } + record_peer_notify(final_outcome); + PeerNotifyResult::Done +} + +/// The endpoints of the other live replicas, the only ones that can own a +/// session this replica does not. A replica without a peer endpoint does not +/// join the membership. +fn peer_endpoints(members: Vec, local_replica_id: &str) -> Vec { + members + .into_iter() + .filter(|member| member.replica_id != local_replica_id) + .filter_map(|member| dialable_peer_endpoint(&member.peer_endpoint).map(str::to_string)) + .collect() +} + +async fn notify_peers(state: &Arc, scope: &FanoutScope) { + let members = GatewayMemberIndex::new(Arc::clone(&state.store), MEMBER_TTL); + let endpoints = match members.live_members().await { + Ok(members) => peer_endpoints(members, &state.replica_id), + Err(error) => { + warn!(error = %error, "configuration peer fanout membership listing failed"); + return; + } + }; + let scope = peer_scope(scope); + futures::stream::iter(endpoints) + .for_each_concurrent(PEER_FANOUT_CONCURRENCY, |endpoint| { + let request = PeerNotifyConfigUpdateRequest { + scope: Some(scope.clone()), + }; + async move { + let outcome = send_peer_notify(state, &endpoint, request).await; + if let PeerNotifyOutcome::Failed(outcome) = outcome { + warn!(endpoint = %endpoint, outcome, "configuration peer fanout notification failed"); + } + record_peer_notify(outcome.label()); + } + }) + .await; +} + +fn peer_scope(scope: &FanoutScope) -> peer_notify_config_update_request::Scope { + match scope { + FanoutScope::AllConnected => peer_notify_config_update_request::Scope::AllConnected(true), + FanoutScope::Workspace(workspace) => { + peer_notify_config_update_request::Scope::Workspace(workspace.clone()) + } + FanoutScope::Provider { workspace, name } => { + peer_notify_config_update_request::Scope::Provider(PeerConfigProviderTarget { + workspace: workspace.clone(), + name: name.clone(), + }) + } + } +} + +enum PeerNotifyOutcome { + Accepted, + StaleOwner, + UnsupportedPeer, + Failed(&'static str), +} + +impl PeerNotifyOutcome { + fn label(&self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::StaleOwner => "stale_owner", + Self::UnsupportedPeer => "unsupported_peer", + Self::Failed(outcome) => outcome, + } + } +} + +async fn send_peer_notify( + state: &Arc, + endpoint: &str, + request: PeerNotifyConfigUpdateRequest, +) -> PeerNotifyOutcome { + match tokio::time::timeout( + PEER_NOTIFY_TIMEOUT, + crate::supervisor_session::forward_config_notify_to_peer(state, endpoint, request), + ) + .await + { + Ok(Ok(response)) if response.stale_owner => PeerNotifyOutcome::StaleOwner, + Ok(Ok(_)) => PeerNotifyOutcome::Accepted, + Ok(Err(error)) if error.code() == Code::Unimplemented => PeerNotifyOutcome::UnsupportedPeer, + Ok(Err(_)) => PeerNotifyOutcome::Failed("peer_error"), + Err(_) => PeerNotifyOutcome::Failed("timeout"), + } +} + +pub fn handle_peer_notify_config_update( + state: &Arc, + request: Request, +) -> Result, Status> { + if !matches!( + request.extensions().get::(), + Some(Principal::Peer(_)) + ) { + return Err(Status::permission_denied("gateway peer principal required")); + } + if !push_enabled(state) { + return Err(Status::failed_precondition( + "configuration push is disabled", + )); + } + let notification = request.into_inner(); + let mut response = PeerNotifyConfigUpdateResponse::default(); + // Notifications from peers never fan out to peers again. + let scope = match notification.scope { + Some(peer_notify_config_update_request::Scope::Sandbox(target)) => { + if target.sandbox_id.is_empty() || target.session_id.is_empty() { + return Err(Status::invalid_argument( + "sandbox and session IDs are required", + )); + } + // The sender resolved this session from the owner index. The + // owner check after the build guards against a later move. + if state + .supervisor_sessions + .is_current_session(&target.sandbox_id, &target.session_id) + { + state.config_delivery.publish_sandbox(&target.sandbox_id); + } else { + response.stale_owner = true; + } + return Ok(Response::new(response)); + } + Some(peer_notify_config_update_request::Scope::Workspace(workspace)) => { + if workspace.is_empty() { + return Err(Status::invalid_argument("workspace is required")); + } + FanoutScope::Workspace(workspace) + } + Some(peer_notify_config_update_request::Scope::Provider(target)) => { + if target.workspace.is_empty() || target.name.is_empty() { + return Err(Status::invalid_argument( + "provider workspace and name are required", + )); + } + FanoutScope::Provider { + workspace: target.workspace, + name: target.name, + } + } + Some(peer_notify_config_update_request::Scope::AllConnected(true)) => { + FanoutScope::AllConnected + } + _ => { + return Err(Status::invalid_argument( + "configuration notification scope is required", + )); + } + }; + state.config_delivery.publish_fanout(&scope); + Ok(Response::new(response)) +} + +/// Streamed-apply supervisor session registered without a gRPC stream. It +/// holds each update until the previous one is acknowledged. The registry +/// keeps its outbound channel open while the session is registered. +#[cfg(test)] +pub struct TestApplySession { + pub outbound: tokio::sync::mpsc::Receiver, +} + +#[cfg(test)] +pub fn register_test_apply_session( + state: &Arc, + sandbox: &Sandbox, + session_id: &str, +) -> TestApplySession { + let captured_seq = state.config_delivery.publications(); + register_test_apply_session_after(state, sandbox, session_id, captured_seq) +} + +/// Register a test session whose bootstrap was read when the publication +/// count was `captured_seq`. +#[cfg(test)] +fn register_test_apply_session_after( + state: &Arc, + sandbox: &Sandbox, + session_id: &str, + captured_seq: u64, +) -> TestApplySession { + use crate::persistence::{ObjectId, ObjectWorkspace}; + + let (tx, outbound) = tokio::sync::mpsc::channel(4); + let sandbox_id = sandbox.object_id().to_string(); + state.supervisor_sessions.register_with_mode( + sandbox_id.clone(), + session_id.to_string(), + tx, + tokio::sync::oneshot::channel().0, + crate::supervisor_session::SessionMode::Push, + ); + register_session( + state, + Registration { + sandbox_id, + session_id: session_id.to_string(), + workspace: sandbox.object_workspace().to_string(), + providers: sandbox + .spec + .as_ref() + .map(|spec| spec.providers.iter().cloned().collect()) + .unwrap_or_default(), + provider_environment: None, + captured_seq, + rebuild: false, + }, + ); + TestApplySession { outbound } +} + +fn record_build(outcome: &'static str, elapsed: Duration) { + counter!( + "openshell_supervisor_config_builds_total", + "outcome" => outcome, + ) + .increment(1); + histogram!( + crate::gateway_metrics::SUPERVISOR_CONFIG_BUILD_DURATION_SECONDS, + "outcome" => outcome, + ) + .record(elapsed.as_secs_f64()); +} + +fn record_build_failure( + sandbox_id: &str, + outcome: &'static str, + error_code: Code, + elapsed: Duration, +) { + record_build(outcome, elapsed); + warn!( + sandbox_id, + ?error_code, + "failed to build supervisor configuration" + ); +} + +fn record_delivery(outcome: &'static str) { + counter!( + "openshell_supervisor_config_deliveries_total", + "outcome" => outcome, + ) + .increment(1); +} + +fn record_peer_notify(outcome: &'static str) { + counter!("openshell_supervisor_config_peer_notifications_total", "outcome" => outcome) + .increment(1); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::grpc::test_support::{ + StreamFeatures, SupervisorStreamHarness, connect_supervisor_stream, + reconnect_supervisor_stream, test_server_state, + }; + use openshell_core::proto::{ + GatewayMessage, ObjectMeta, Provider, SandboxSpec, gateway_message, + }; + + fn peer_request( + scope: peer_notify_config_update_request::Scope, + ) -> Request { + let mut request = Request::new(PeerNotifyConfigUpdateRequest { scope: Some(scope) }); + request + .extensions_mut() + .insert(Principal::Peer(crate::auth::principal::PeerPrincipal { + replica_id: "other-replica".into(), + pod_uid: "peer-pod".into(), + })); + request + } + + fn peer_notify(sandbox_id: &str, session_id: &str) -> Request { + peer_request(peer_notify_config_update_request::Scope::Sandbox( + PeerConfigSandboxTarget { + sandbox_id: sandbox_id.into(), + session_id: session_id.into(), + }, + )) + } + + async fn push_state() -> Arc { + let mut state = test_server_state().await; + Arc::get_mut(&mut state) + .unwrap() + .config + .config_delivery_mode = ConfigDeliveryMode::Push; + state + } + + async fn put_sandbox(state: &ServerState, sandbox_id: &str, providers: &[&str]) { + state + .store + .put_message(&Sandbox { + metadata: Some(ObjectMeta { + id: sandbox_id.into(), + name: sandbox_id.into(), + workspace: "default".into(), + ..Default::default() + }), + spec: Some(SandboxSpec { + providers: providers.iter().map(ToString::to_string).collect(), + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + } + + async fn put_provider(state: &ServerState, name: &str) { + put_provider_with_token(state, name, None).await; + } + + async fn put_provider_with_token(state: &ServerState, name: &str, token: Option<&str>) { + state + .store + .put_message(&Provider { + metadata: Some(ObjectMeta { + id: format!("{name}-id"), + name: name.into(), + workspace: "default".into(), + ..Default::default() + }), + r#type: "github".into(), + credentials: token + .map(|token| HashMap::from([("GITHUB_TOKEN".to_string(), token.to_string())])) + .unwrap_or_default(), + ..Default::default() + }) + .await + .unwrap(); + } + + /// Commit a new policy revision, so the next sandbox configuration build + /// differs from the bootstrap the session acknowledged. + async fn change_policy(state: &ServerState, sandbox_id: &str, version: i64) { + use crate::policy_store::PolicyStoreExt as _; + use prost::Message as _; + + let policy = openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }; + state + .store + .put_policy_revision( + &uuid::Uuid::new_v4().to_string(), + sandbox_id, + "default", + version, + &policy.encode_to_vec(), + &crate::grpc::policy::deterministic_policy_hash(&policy), + ) + .await + .unwrap(); + } + + /// Connect a streamed-apply supervisor and answer the bootstrap it is + /// accepted with. + async fn accepted_session( + state: &Arc, + sandbox_id: &str, + ) -> (SupervisorStreamHarness, String) { + let (harness, session_id, _) = accepted_session_with_bootstrap(state, sandbox_id).await; + (harness, session_id) + } + + async fn accepted_session_with_bootstrap( + state: &Arc, + sandbox_id: &str, + ) -> ( + SupervisorStreamHarness, + String, + Option, + ) { + let mut harness = connect_supervisor_stream(state, sandbox_id, StreamFeatures::Apply) + .await + .unwrap(); + let first = tokio::time::timeout(Duration::from_secs(5), harness.inbound.message()) + .await + .unwrap() + .unwrap() + .unwrap(); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = first.payload else { + panic!("expected SessionAccepted"); + }; + // Live updates wait for the bootstrap result, as from a real + // supervisor. + if let Some(bootstrap) = accepted.bootstrap.as_ref() { + harness + .outbound + .send(openshell_core::proto::SupervisorMessage { + payload: Some( + openshell_core::proto::supervisor_message::Payload::ConfigUpdateResult( + session::applied_result(bootstrap), + ), + ), + }) + .await + .unwrap(); + } + (harness, accepted.session_id, accepted.bootstrap) + } + + async fn next_update( + harness: &mut SupervisorStreamHarness, + wait: Duration, + ) -> Option { + tokio::time::timeout(wait, harness.inbound.message()) + .await + .ok() + .map(|message| message.unwrap().unwrap()) + } + + fn is_config_update(message: &GatewayMessage) -> bool { + matches!( + message.payload, + Some(gateway_message::Payload::ConfigUpdate(_)) + ) + } + + #[test] + fn peer_fanout_dials_other_live_replicas() { + let member = |replica_id: &str, peer_endpoint: &str| GatewayMember { + replica_id: replica_id.into(), + peer_endpoint: peer_endpoint.into(), + }; + let members = vec![ + member("gw-1", "http://gw-1"), + member("gw-2", "http://gw-2"), + member("gw-3", "local://unreachable"), + member("gw-4", ""), + ]; + assert_eq!(peer_endpoints(members, "gw-1"), vec!["http://gw-2"]); + } + + #[tokio::test] + async fn peer_notify_requires_push_and_current_session() { + let state = test_server_state().await; + let error = handle_peer_notify_config_update(&state, peer_notify("sandbox", "old-session")) + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + + let state = push_state().await; + let response = + handle_peer_notify_config_update(&state, peer_notify("sandbox", "old-session")) + .unwrap() + .into_inner(); + assert!(response.stale_owner); + + let error = handle_peer_notify_config_update( + &state, + Request::new(PeerNotifyConfigUpdateRequest::default()), + ) + .unwrap_err(); + assert_eq!(error.code(), Code::PermissionDenied); + + let error = handle_peer_notify_config_update( + &state, + peer_request(peer_notify_config_update_request::Scope::Provider( + PeerConfigProviderTarget { + workspace: "default".into(), + name: String::new(), + }, + )), + ) + .unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + } + + #[tokio::test] + async fn peer_notify_rebuilds_snapshot_on_the_session_owner() { + let state = push_state().await; + put_sandbox(&state, "owned-sandbox", &[]).await; + let (mut harness, session_id) = accepted_session(&state, "owned-sandbox").await; + change_policy(&state, "owned-sandbox", 1).await; + + let response = + handle_peer_notify_config_update(&state, peer_notify("owned-sandbox", &session_id)) + .unwrap() + .into_inner(); + assert!(!response.stale_owner); + let update = next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("configuration update"); + assert!(is_config_update(&update)); + } + + /// Store a policy revision that decodes but fails stored-policy + /// validation: MCP options on a non-MCP endpoint. + async fn store_invalid_policy(state: &ServerState, sandbox_id: &str, version: i64) { + use crate::policy_store::PolicyStoreExt as _; + use openshell_core::proto::{McpOptions, NetworkEndpoint, NetworkPolicyRule}; + use prost::Message as _; + + let mut policy = openshell_policy::restrictive_default_policy(); + policy.network_policies.insert( + "mcp".to_string(), + NetworkPolicyRule { + name: "mcp".to_string(), + endpoints: vec![NetworkEndpoint { + host: "mcp.example.com".to_string(), + port: 443, + protocol: "rest".to_string(), + mcp: Some(McpOptions { + strict_tool_names: Some(true), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }, + ); + state + .store + .put_policy_revision( + &uuid::Uuid::new_v4().to_string(), + sandbox_id, + "default", + version, + &policy.encode_to_vec(), + "uncanonicalized-hash", + ) + .await + .unwrap(); + } + + fn delivered_sandbox_config(message: GatewayMessage) -> SandboxConfigSnapshot { + let Some(gateway_message::Payload::ConfigUpdate(update)) = message.payload else { + panic!("expected a configuration update"); + }; + update.sandbox_config.expect("sandbox configuration") + } + + #[tokio::test] + async fn invalid_configuration_bootstrap_is_delivered_and_repaired_on_the_same_stream() { + use openshell_core::proto::{ConfigApplyOutcome, SupervisorMessage, supervisor_message}; + + for mode in [ + openshell_core::PolicyValidationFailureMode::FailClosed, + openshell_core::PolicyValidationFailureMode::RetainLastValid, + ] { + for reconnect in [false, true] { + let mut state = push_state().await; + Arc::get_mut(&mut state) + .unwrap() + .config + .policy_validation_failure_mode = mode; + put_sandbox(&state, "sandbox", &[]).await; + if reconnect { + change_policy(&state, "sandbox", 1).await; + let (_old, _) = accepted_session(&state, "sandbox").await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let sandbox = state + .store + .get_message::("sandbox") + .await + .unwrap() + .unwrap(); + if sandbox.current_policy_version() == 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("initial policy acknowledgement is persisted"); + state.supervisor_sessions.disconnect("sandbox"); + } + let invalid_version = if reconnect { 2 } else { 1 }; + store_invalid_policy(&state, "sandbox", invalid_version).await; + let connected = if reconnect { + reconnect_supervisor_stream(&state, "sandbox", StreamFeatures::Apply).await + } else { + connect_supervisor_stream(&state, "sandbox", StreamFeatures::Apply).await + }; + let mut harness = connected + .expect("invalid configuration must not prevent explicit bootstrap rejection"); + let message = next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("accepted session"); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = message.payload + else { + panic!("expected SessionAccepted"); + }; + let bootstrap = accepted.bootstrap.expect("rejected bootstrap"); + let snapshot = bootstrap.sandbox_config.as_ref().unwrap(); + assert!(!snapshot.configuration_admitted); + assert_eq!(snapshot.policy_validation_failure_mode, mode.as_str()); + assert!(snapshot.policy.is_none()); + assert!(!snapshot.configuration_error.is_empty()); + assert_eq!( + bootstrap.provider_environment.as_ref().unwrap(), + &unresolved_provider_environment(snapshot) + ); + let record = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let record = state + .config_delivery + .sessions() + .records + .get("sandbox") + .cloned(); + if let Some(record) = + record.filter(|record| record.session_id == accepted.session_id) + { + break record; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("accepted session is registered for configuration delivery"); + assert!(record.provider_environment().is_none()); + let mut rejected = session::applied_result(&bootstrap); + rejected.outcome = ConfigApplyOutcome::FailedClosed.into(); + rejected.policy_load = None; + harness + .outbound + .send(SupervisorMessage { + payload: Some(supervisor_message::Payload::ConfigUpdateResult(rejected)), + }) + .await + .unwrap(); + + let repaired_version = invalid_version + 1; + change_policy(&state, "sandbox", repaired_version).await; + publish_sandbox(&state, "sandbox"); + let repaired = sent_config( + &next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("repaired live update"), + ); + let snapshot = repaired.sandbox_config.as_ref().unwrap(); + assert!(snapshot.configuration_admitted); + assert_eq!(i64::from(snapshot.version), repaired_version); + } + } + } + + /// The delivery record registered for `session_id`, once registration + /// completes. + async fn registered_record( + state: &ServerState, + sandbox_id: &str, + session_id: &str, + ) -> Arc { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let record = state + .config_delivery + .sessions() + .records + .get(sandbox_id) + .cloned(); + if let Some(record) = record.filter(|record| record.session_id == session_id) { + return record; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("accepted session is registered for configuration delivery") + } + + #[tokio::test] + async fn bootstrap_without_a_policy_seeds_the_provider_environment() { + let state = push_state().await; + put_sandbox(&state, "sandbox", &[]).await; + let (_harness, session_id, bootstrap) = + accepted_session_with_bootstrap(&state, "sandbox").await; + let bootstrap = bootstrap.expect("push bootstrap"); + // A sandbox without a policy is not admitted, but nothing was + // rejected and its provider environment resolved. + assert!(!bootstrap.sandbox_config.unwrap().configuration_admitted); + let record = registered_record(&state, "sandbox", &session_id).await; + assert_eq!( + record.provider_environment(), + bootstrap.provider_environment + ); + } + + #[tokio::test] + async fn bootstrap_delivers_an_unresolved_provider_environment_and_rebuilds_it() { + let state = push_state().await; + put_driver_provider( + &state, + HashMap::from([( + "GITHUB_TOKEN".to_string(), + openshell_core::proto::CredentialHandle { + driver: "test-static".into(), + handle: "missing".into(), + metadata: HashMap::new(), + }, + )]), + ) + .await; + put_sandbox(&state, "sandbox", &["provider"]).await; + change_policy(&state, "sandbox", 1).await; + + // The supervisor revokes static credentials and rejects an update + // whose provider environment is unresolved. + let reject = |update: &openshell_core::proto::ConfigUpdate| { + use openshell_core::proto::{ + ConfigApplyOutcome, ConfigurationAdmissionState, SupervisorMessage, + supervisor_message, + }; + let mut result = session::applied_result(update); + result.outcome = ConfigApplyOutcome::FailedRetainedLastKnownGood.into(); + result.policy_load = None; + if let Some(admission) = result.admission.as_mut() { + admission.state = ConfigurationAdmissionState::Rejected.into(); + } + SupervisorMessage { + payload: Some(supervisor_message::Payload::ConfigUpdateResult(result)), + } + }; + + // The credential does not resolve, but the session is still accepted + // with the admitted policy and an unresolved environment. + let mut harness = connect_supervisor_stream(&state, "sandbox", StreamFeatures::Apply) + .await + .expect("an unresolved provider environment does not refuse the session"); + let first = next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("accepted session"); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = first.payload else { + panic!("expected SessionAccepted"); + }; + let bootstrap = accepted.bootstrap.expect("push bootstrap"); + let sandbox_config = bootstrap + .sandbox_config + .clone() + .expect("sandbox configuration"); + assert!(sandbox_config.configuration_admitted); + assert_eq!(sandbox_config.version, 1); + assert_eq!( + bootstrap + .provider_environment + .clone() + .expect("provider environment"), + unresolved_provider_environment(&sandbox_config) + ); + let record = registered_record(&state, "sandbox", &accepted.session_id).await; + assert!(record.provider_environment().is_none()); + harness.outbound.send(reject(&bootstrap)).await.unwrap(); + + // Once the credential resolves, the session delivers it without a + // publication or waiting for its reconciliation interval. + let credential_handles = state + .credentials + .store_provider_credentials( + "provider", + "default", + "provider", + &HashMap::from([("GITHUB_TOKEN".to_string(), "token".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + put_driver_provider(&state, credential_handles).await; + let resolved = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let message = next_update(&mut harness, Duration::from_secs(10)) + .await + .expect("rebuilt configuration"); + let update = sent_config(&message); + if !update + .provider_environment + .as_ref() + .unwrap() + .values + .is_empty() + { + break update; + } + // A rebuild that still could not resolve it is rejected too. + harness.outbound.send(reject(&update)).await.unwrap(); + } + }) + .await + .expect("the session rebuilds the unresolved provider environment"); + assert_eq!(resolved.sandbox_config.unwrap().version, 1); + } + + #[tokio::test] + async fn invalid_stored_policy_is_delivered_not_admitted() { + let state = push_state().await; + put_sandbox(&state, "sandbox", &[]).await; + let (mut harness, session_id) = accepted_session(&state, "sandbox").await; + + store_invalid_policy(&state, "sandbox", 1).await; + handle_peer_notify_config_update(&state, peer_notify("sandbox", &session_id)).unwrap(); + let rejected = delivered_sandbox_config( + next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("configuration update"), + ); + assert!(!rejected.configuration_admitted); + assert!(rejected.policy.is_none()); + assert!(!rejected.configuration_error.is_empty()); + } + + #[tokio::test] + async fn provider_change_reaches_only_attached_sessions() { + let state = push_state().await; + put_provider(&state, "github").await; + put_sandbox(&state, "attached", &["github"]).await; + put_sandbox(&state, "unattached", &[]).await; + let (mut attached, _) = accepted_session(&state, "attached").await; + let (mut unattached, _) = accepted_session(&state, "unattached").await; + put_provider_with_token(&state, "github", Some("rotated")).await; + + publish_provider(&state, "default", "github"); + + let update = next_update(&mut attached, Duration::from_secs(5)) + .await + .expect("attached sandbox receives its configuration"); + assert!(is_config_update(&update)); + assert!( + next_update(&mut unattached, Duration::from_millis(200)) + .await + .is_none() + ); + + change_policy(&state, "unattached", 1).await; + publish_workspace(&state, "default"); + assert!( + next_update(&mut unattached, Duration::from_secs(5)) + .await + .is_some_and(|update| is_config_update(&update)) + ); + } + + fn has_recipient(state: &ServerState, sandbox_id: &str) -> bool { + state.config_delivery.has_session(sandbox_id) + } + + #[tokio::test] + async fn poll_mode_leaves_delivery_dormant() { + let state = test_server_state().await; + put_provider(&state, "github").await; + put_sandbox(&state, "sandbox", &["github"]).await; + let (mut harness, _) = accepted_session(&state, "sandbox").await; + assert!(!has_recipient(&state, "sandbox")); + + publish_sandbox(&state, "sandbox"); + publish_workspace(&state, "default"); + publish_provider(&state, "default", "github"); + publish_all_connected(&state); + publish_all_local(&state); + + assert_eq!(state.config_delivery.publications(), 0); + assert!( + next_update(&mut harness, Duration::from_millis(100)) + .await + .is_none() + ); + } + + #[tokio::test] + async fn disconnected_sessions_leave_the_queue() { + let state = push_state().await; + put_sandbox(&state, "sandbox", &[]).await; + let (_harness, _) = accepted_session(&state, "sandbox").await; + assert!(has_recipient(&state, "sandbox")); + + assert!(state.supervisor_sessions.disconnect("sandbox")); + tokio::time::timeout(Duration::from_secs(5), async { + while has_recipient(&state, "sandbox") { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("session cleanup unregisters the recipient"); + } + + #[tokio::test] + async fn peer_notifications_for_one_target_coalesce() { + let state = push_state().await; + let target = PeerNotifyTarget::Sandbox("remote".into()); + notify_peer(&state, target.clone()); + notify_peer(&state, target.clone()); + { + let pending = state.config_delivery.peer_notifies.lock().unwrap(); + assert_eq!(pending.len(), 1); + assert!(pending[&target]); + } + tokio::time::timeout(Duration::from_secs(5), async { + while !state + .config_delivery + .peer_notifies + .lock() + .unwrap() + .is_empty() + { + tokio::task::yield_now().await; + } + }) + .await + .expect("the notification task drains its target"); + } + + #[tokio::test] + async fn sandbox_configuration_names_its_built_and_unresolved_provider_environment() { + let state = push_state().await; + put_provider_with_token(&state, "provider", Some("token")).await; + put_sandbox(&state, "sandbox", &["provider"]).await; + let sandbox = state + .store + .get_message::("sandbox") + .await + .unwrap() + .unwrap(); + let inputs = load_sandbox_config_inputs(&state, sandbox).await.unwrap(); + let sandbox_config = build_sandbox_config_snapshot_from_inputs(&state, &inputs) + .await + .unwrap(); + let built = build_provider_environment_snapshot_from_inputs(&state, &inputs, true, true) + .await + .unwrap(); + assert!(!built.values.is_empty()); + // The supervisor matches both to the sandbox configuration that names + // them, and to the one it holds. + assert!(names_provider_environment(&sandbox_config, &built)); + + let unresolved = unresolved_provider_environment(&sandbox_config); + assert!(names_provider_environment(&sandbox_config, &unresolved)); + assert!(unresolved.values.is_empty()); + assert!(unresolved.dynamic_credentials.is_empty()); + assert!(unresolved.files.is_empty()); + assert_eq!( + unresolved.readiness_reason, + i32::from(ProviderReadinessReason::CredentialInstallFailed) + ); + } + + #[tokio::test] + async fn registration_delivers_a_publication_that_found_no_session() { + let state = push_state().await; + put_sandbox(&state, "sandbox", &[]).await; + let sandbox = state + .store + .get_message::("sandbox") + .await + .unwrap() + .unwrap(); + let captured_seq = state.config_delivery.publications(); + // The change commits after the session read its bootstrap, and its + // publication runs before the session registers. + change_policy(&state, "sandbox", 1).await; + assert!(!state.config_delivery.publish_sandbox("sandbox")); + + let mut session = + register_test_apply_session_after(&state, &sandbox, "session-1", captured_seq); + // Reconciliation is at least 30 seconds away, so only registration + // can have caught the publication. + let update = tokio::time::timeout(Duration::from_secs(5), session.outbound.recv()) + .await + .expect("registration delivers the missed publication") + .expect("outbound message"); + assert!(is_config_update(&update)); + } + + #[test] + fn fanout_marks_ten_thousand_sessions_once_each() { + let delivery = ConfigDelivery::for_db_connections(5); + let records: Vec<_> = (0..10_000) + .map(|index| { + Arc::new(SessionRecord::new(Registration { + sandbox_id: format!("sb-{index:05}"), + session_id: "session".into(), + workspace: "ws".into(), + providers: HashSet::new(), + provider_environment: None, + captured_seq: 0, + rebuild: false, + })) + }) + .collect(); + delivery.sessions().records.extend( + records + .iter() + .map(|record| (record.sandbox_id.clone(), Arc::clone(record))), + ); + for _ in 0..3 { + delivery.publish_fanout(&FanoutScope::AllConnected); + } + assert_eq!(delivery.publications(), 3); + assert!( + records + .iter() + .all(|record| record.in_scope(&FanoutScope::AllConnected)) + ); + } + + #[test] + fn build_slots_are_sized_from_the_database_pool() { + let builds = |connections| { + ConfigDelivery::for_db_connections(connections) + .builds + .available_permits() + }; + assert_eq!(builds(10), 20); + assert_eq!(builds(1), MIN_CONCURRENT_SNAPSHOT_BUILDS); + } + + #[test] + fn configuration_message_debug_output_redacts_payloads() { + let message = SupervisorConfig { + sandbox_config: SandboxConfigSnapshot::default(), + provider_environment: ProviderEnvironmentSnapshot { + values: vec![openshell_core::proto::ProviderEnvironmentValue { + name: "TOKEN".into(), + value: "secret-marker".into(), + ..Default::default() + }], + ..Default::default() + }, + }; + assert!(!format!("{message:?}").contains("secret-marker")); + } + + #[tokio::test] + async fn session_acceptance_precedes_live_configuration_updates() { + let state = push_state().await; + put_sandbox(&state, "sandbox", &[]).await; + let (mut harness, _) = accepted_session(&state, "sandbox").await; + assert!(has_recipient(&state, "sandbox")); + change_policy(&state, "sandbox", 1).await; + + publish_sandbox(&state, "sandbox"); + let update = next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("configuration update"); + assert!(is_config_update(&update)); + } + + #[tokio::test] + async fn local_publication_reaches_this_replicas_sessions_without_peers() { + let state = push_state().await; + put_sandbox(&state, "sandbox", &[]).await; + let (mut harness, _) = accepted_session(&state, "sandbox").await; + change_policy(&state, "sandbox", 1).await; + + publish_all_local(&state); + + let update = next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("configuration update"); + assert!(is_config_update(&update)); + assert!( + state + .config_delivery + .peer_notifies + .lock() + .unwrap() + .is_empty(), + "a local publication notifies no peers" + ); + } + + #[tokio::test] + async fn legacy_supervisor_keeps_polling_when_gateway_push_is_enabled() { + let state = push_state().await; + put_sandbox(&state, "legacy-sandbox", &[]).await; + let mut harness = + connect_supervisor_stream(&state, "legacy-sandbox", StreamFeatures::Legacy) + .await + .unwrap(); + let first = harness.inbound.message().await.unwrap().unwrap(); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = first.payload else { + panic!("expected SessionAccepted"); + }; + assert!(accepted.bootstrap.is_none()); + publish_sandbox(&state, "legacy-sandbox"); + publish_all_connected(&state); + assert!( + next_update(&mut harness, Duration::from_millis(100)) + .await + .is_none() + ); + } + + #[tokio::test] + async fn stalled_credentials_keep_the_live_session_serving_relays() { + let state = push_state().await; + let credential_handles = state + .credentials + .store_provider_credentials( + "provider", + "default", + "provider", + &HashMap::from([("GITHUB_TOKEN".to_string(), "token".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + state + .store + .put_message(&Provider { + metadata: Some(ObjectMeta { + id: "provider".into(), + name: "provider".into(), + workspace: "default".into(), + ..Default::default() + }), + r#type: "github".into(), + credential_handles, + ..Default::default() + }) + .await + .unwrap(); + state + .store + .put_message(&Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox".into(), + name: "sandbox".into(), + workspace: "default".into(), + ..Default::default() + }), + spec: Some(SandboxSpec { + providers: vec!["provider".into()], + policy: Some(openshell_policy::restrictive_default_policy()), + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + let mut live = connect_supervisor_stream(&state, "sandbox", StreamFeatures::Apply) + .await + .unwrap(); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = + next_update(&mut live, Duration::from_secs(5)) + .await + .expect("session acceptance") + .payload + else { + panic!("expected SessionAccepted"); + }; + let live_session = accepted.session_id; + + let (resolve_hit, release_resolve) = state.credentials.gate_next_resolve(); + let reconnect = tokio::spawn({ + let state = Arc::clone(&state); + async move { reconnect_supervisor_stream(&state, "sandbox", StreamFeatures::Apply).await } + }); + tokio::time::timeout(Duration::from_secs(5), resolve_hit) + .await + .expect("reconnect bootstrap must reach the stalled credential driver") + .unwrap(); + + // The bootstrap is built before the reconnect is registered, so the + // live session keeps serving relays while credentials are stalled. + assert!( + state + .supervisor_sessions + .is_current_session("sandbox", &live_session) + ); + let (_, relay) = state + .supervisor_sessions + .open_relay("sandbox", Duration::from_secs(1)) + .await + .unwrap(); + assert!(matches!( + next_update(&mut live, Duration::from_secs(5)) + .await + .expect("relay open") + .payload, + Some(gateway_message::Payload::RelayOpen(_)) + )); + drop(relay); + + release_resolve.send(()).unwrap(); + let mut replacement = tokio::time::timeout(Duration::from_secs(5), reconnect) + .await + .expect("released bootstrap completes the reconnect") + .unwrap() + .unwrap(); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = + next_update(&mut replacement, Duration::from_secs(5)) + .await + .expect("replacement acceptance") + .payload + else { + panic!("expected SessionAccepted"); + }; + assert!(accepted.bootstrap.is_some()); + assert!( + state + .supervisor_sessions + .is_current_session("sandbox", &accepted.session_id) + ); + } + + async fn put_driver_provider( + state: &ServerState, + credential_handles: HashMap, + ) { + state + .store + .put_message(&Provider { + metadata: Some(ObjectMeta { + id: "provider".into(), + name: "provider".into(), + workspace: "default".into(), + ..Default::default() + }), + r#type: "github".into(), + credential_handles, + ..Default::default() + }) + .await + .unwrap(); + } + + /// Attach a provider whose credential resolves through the credential + /// driver, and accept a session for its sandbox. The session's record + /// holds the provider environment its bootstrap delivered. + async fn driver_backed_session( + state: &Arc, + ) -> (SupervisorStreamHarness, SessionRecord) { + let credential_handles = state + .credentials + .store_provider_credentials( + "provider", + "default", + "provider", + &HashMap::from([("GITHUB_TOKEN".to_string(), "token".to_string())]), + &HashMap::new(), + ) + .await + .unwrap(); + put_driver_provider(state, credential_handles).await; + put_sandbox(state, "sandbox", &["provider"]).await; + let (harness, session_id, bootstrap) = + accepted_session_with_bootstrap(state, "sandbox").await; + let record = SessionRecord::new(Registration { + sandbox_id: "sandbox".into(), + session_id, + workspace: "default".into(), + providers: HashSet::from(["provider".to_string()]), + provider_environment: bootstrap.and_then(|bootstrap| bootstrap.provider_environment), + captured_seq: 0, + rebuild: false, + }); + (harness, record) + } + + fn sent_config(message: &GatewayMessage) -> openshell_core::proto::ConfigUpdate { + let Some(gateway_message::Payload::ConfigUpdate(update)) = &message.payload else { + panic!("expected a configuration update"); + }; + update.clone() + } + + #[tokio::test] + async fn provider_failure_delivers_the_configuration_with_an_unresolved_environment() { + let state = push_state().await; + let (mut harness, record) = driver_backed_session(&state).await; + // The provider's credential no longer resolves, and a new policy + // revision changes the provider environment the configuration names. + put_driver_provider( + &state, + HashMap::from([( + "GITHUB_TOKEN".to_string(), + openshell_core::proto::CredentialHandle { + driver: "test-static".into(), + handle: "missing".into(), + metadata: HashMap::new(), + }, + )]), + ) + .await; + change_policy(&state, "sandbox", 1).await; + + let (failed, providers) = Builder(&state).deliver(&record).await; + assert!(failed, "the session retries the build"); + assert_eq!(providers, Some(HashSet::from(["provider".to_string()]))); + let update = sent_config( + &next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("configuration update"), + ); + let sandbox_config = update.sandbox_config.expect("sandbox configuration"); + let provider = update.provider_environment.expect("provider environment"); + assert!(names_provider_environment(&sandbox_config, &provider)); + assert!(provider.values.is_empty()); + assert_eq!( + provider.readiness_reason, + i32::from(ProviderReadinessReason::CredentialInstallFailed) + ); + } + + #[tokio::test] + async fn unchanged_provider_environment_resolves_no_credentials() { + let state = push_state().await; + let (mut harness, record) = driver_backed_session(&state).await; + let (mut resolve_hit, _release_resolve) = state.credentials.gate_next_resolve(); + + let (failed, _) = + tokio::time::timeout(Duration::from_secs(5), Builder(&state).deliver(&record)) + .await + .expect("an unchanged provider environment must not wait on credentials"); + assert!(!failed); + assert_eq!( + resolve_hit.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + assert!( + next_update(&mut harness, Duration::from_millis(200)) + .await + .is_none(), + "the supervisor already has this configuration" + ); + } + + #[tokio::test] + async fn changed_provider_environment_is_resolved_and_cached() { + let state = push_state().await; + let (mut harness, record) = driver_backed_session(&state).await; + change_policy(&state, "sandbox", 1).await; + + let (failed, _) = Builder(&state).deliver(&record).await; + assert!(!failed); + let update = sent_config( + &next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("configuration update"), + ); + let provider = update.provider_environment.expect("provider environment"); + assert!(!provider.values.is_empty()); + assert_eq!(record.provider_environment(), Some(provider)); + } + + async fn state_with_large_provider_environment(provider_count: usize) -> Arc { + use crate::grpc::test_support::authed_request; + use openshell_core::proto::CreateProviderRequest; + use openshell_core::proto::open_shell_server::OpenShell as _; + + let mut state = push_state().await; + let profiles = (0..provider_count) + .map(|index| { + let mut profile = openshell_providers::example_profiles::load("github").to_proto(); + profile.id = format!("large-provider-{index}"); + profile.credentials[0].env_vars = (0..32) + .map(|key| format!("PROVIDER_{index}_TOKEN_{key}")) + .collect(); + profile + }) + .collect(); + Arc::get_mut(&mut state).unwrap().provider_profile_sources = + crate::provider_profile_sources::ProviderProfileSources::from_test_profiles(profiles); + let service = crate::grpc::OpenShellService::new(Arc::clone(&state)); + let mut providers = Vec::new(); + for index in 0..provider_count { + let name = format!("large-provider-{index}"); + service + .create_provider(authed_request(CreateProviderRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + provider: Some(Provider { + metadata: Some(ObjectMeta { + name: name.clone(), + ..Default::default() + }), + r#type: name.clone(), + credentials: (0..32) + .map(|key| (format!("PROVIDER_{index}_TOKEN_{key}"), "x".repeat(8192))) + .collect(), + ..Default::default() + }), + ..Default::default() + })) + .await + .expect("provider credentials within public limits are accepted"); + providers.push(name); + } + let provider_names = providers.iter().map(String::as_str).collect::>(); + put_sandbox(&state, "large-config", &provider_names).await; + change_policy(&state, "large-config", 1).await; + + state + } + + #[tokio::test] + async fn oversized_configuration_is_rejected_over_grpc_and_smaller_configuration_recovers() { + use openshell_core::proto::{ + ConfigApplyOutcome, ConfigUpdate, ConfigUpdateResult, ConfigurationAdmissionState, + SandboxConfigurationAdmission, SupervisorMessage, supervisor_message, + }; + use prost::Message as _; + + let state = state_with_large_provider_environment(32).await; + let mut harness = connect_supervisor_stream(&state, "large-config", StreamFeatures::Apply) + .await + .unwrap(); + let first = harness.inbound.message().await.unwrap().unwrap(); + assert!(first.encoded_len() < 1024); + let Some(gateway_message::Payload::SessionAccepted(accepted)) = first.payload else { + panic!("expected accepted session with explicit rejection"); + }; + let bootstrap = accepted.bootstrap.unwrap(); + let report_rejection = |update: &ConfigUpdate| { + let snapshot = update.sandbox_config.as_ref().unwrap(); + assert!(!snapshot.configuration_admitted); + assert!( + snapshot + .configuration_error + .starts_with("configuration_too_large:") + ); + assert!(snapshot.policy.is_none()); + let provider = update.provider_environment.as_ref().unwrap(); + assert!(provider.values.is_empty()); + assert!(provider.dynamic_credentials.is_empty()); + assert!(provider.files.is_empty()); + SupervisorMessage { + payload: Some(supervisor_message::Payload::ConfigUpdateResult( + ConfigUpdateResult { + update_id: update.update_id.clone(), + outcome: ConfigApplyOutcome::FailedClosed.into(), + admission: Some(SandboxConfigurationAdmission { + instance_id: snapshot.configuration_instance_id.clone(), + state: ConfigurationAdmissionState::Rejected.into(), + policy_version: snapshot.version, + policy_hash: snapshot.policy_hash.clone(), + config_revision: snapshot.config_revision, + provider_env_revision: snapshot.provider_env_revision, + error: snapshot.configuration_error.clone(), + }), + ..Default::default() + }, + )), + } + }; + harness + .outbound + .send(report_rejection(&bootstrap)) + .await + .unwrap(); + let expected = bootstrap.sandbox_config.as_ref().unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let stored = state + .store + .get_message::("large-config") + .await + .unwrap() + .unwrap(); + if let Some(admission) = stored + .status + .and_then(|status| status.configuration_admission) + && admission.state == i32::from(ConfigurationAdmissionState::Rejected) + { + assert_eq!(admission.config_revision, expected.config_revision); + assert_eq!( + admission.provider_env_revision, + expected.provider_env_revision + ); + assert_eq!(admission.error, expected.configuration_error); + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("gateway persists explicit bootstrap rejection"); + + change_policy(&state, "large-config", 2).await; + publish_sandbox(&state, "large-config"); + let live = sent_config( + &next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("oversized live update produces explicit rejection"), + ); + assert_eq!(live.sandbox_config.as_ref().unwrap().version, 2); + harness + .outbound + .send(report_rejection(&live)) + .await + .unwrap(); + + let mut sandbox = state + .store + .get_message::("large-config") + .await + .unwrap() + .unwrap(); + sandbox.spec.as_mut().unwrap().providers.truncate(16); + state.store.put_message(&sandbox).await.unwrap(); + change_policy(&state, "large-config", 3).await; + publish_sandbox(&state, "large-config"); + let recovery = sent_config( + &next_update(&mut harness, Duration::from_secs(5)) + .await + .expect("smaller update recovers on the same stream"), + ); + let snapshot = recovery.sandbox_config.as_ref().unwrap(); + assert!(snapshot.configuration_admitted); + assert_eq!(snapshot.version, 3); + assert_eq!( + recovery.provider_environment.as_ref().unwrap().values.len(), + 16 * 32 + ); + harness + .outbound + .send(SupervisorMessage { + payload: Some(supervisor_message::Payload::ConfigUpdateResult( + session::applied_result(&recovery), + )), + }) + .await + .unwrap(); + } +} diff --git a/crates/openshell-server/src/config_delivery/session.rs b/crates/openshell-server/src/config_delivery/session.rs new file mode 100644 index 0000000000..2942d932b0 --- /dev/null +++ b/crates/openshell-server/src/config_delivery/session.rs @@ -0,0 +1,2703 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Configuration protocol of one streamed-apply supervisor session. +//! +//! Every session starts from a bootstrap update, and afterwards holds at most +//! one unanswered update. Updates share the session's ordered outbound channel +//! with its other messages. Session lifecycle and relays stay in +//! `supervisor_session`. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use metrics::{counter, histogram}; +use openshell_core::proto::{ + ConfigApplyOutcome, ConfigUpdate, ConfigUpdateResult, GatewayMessage, PolicyLoadResult, + PolicySource, ProviderEnvironmentSnapshot, Sandbox, SandboxConfigRevision, + SandboxConfigSnapshot, SandboxConfigurationAdmission, SessionAccepted, gateway_message, +}; +use prost::Message; +use tokio::sync::mpsc; +use tonic::Status; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::{DeliveryDisposition, MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES, SupervisorConfig}; +use crate::ServerState; +use crate::grpc::policy::{ + build_provider_environment_snapshot_from_inputs, build_sandbox_config_snapshot_from_inputs, + is_configuration_rejection, load_sandbox_config_inputs, unadmitted_sandbox_config, +}; +use crate::persistence::ObjectId; +use crate::supervisor_session::{AcceptedSession, SupervisorSessionRegistry}; + +/// Bounds the whole bootstrap build. The provider environment has its own +/// shorter timeout and is delivered unresolved when it expires, so only a +/// store that cannot answer refuses the session. +const CONFIG_BOOTSTRAP_BUILD_TIMEOUT: Duration = Duration::from_secs(45); + +// --------------------------------------------------------------------------- +// Configuration identity +// --------------------------------------------------------------------------- + +/// Identifies a configuration the supervisor already has. The sandbox +/// configuration names its provider environment by revision, attachment +/// epoch, and policy hash, so it identifies the whole update. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ConfigFingerprint { + revision: SandboxConfigRevision, + provider_env_revision: u64, + provider_attachment_epoch: String, + policy_hash: String, +} + +impl ConfigFingerprint { + fn of(snapshot: &SandboxConfigSnapshot) -> Self { + Self { + revision: SandboxConfigRevision { + config_revision: snapshot.config_revision, + policy_version: snapshot.version, + policy_source: snapshot.policy_source, + global_policy_version: snapshot.global_policy_version, + settings_revision: snapshot.settings_revision, + }, + provider_env_revision: snapshot.provider_env_revision, + provider_attachment_epoch: snapshot.provider_attachment_epoch.clone(), + policy_hash: snapshot.policy_hash.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// Per-session delivery state +// --------------------------------------------------------------------------- + +/// Delivery state of a session that applies and acknowledges streamed +/// configuration. One update is in flight at a time, starting with the +/// bootstrap, until the supervisor answers it or the session closes. +#[derive(Debug)] +pub struct ConfigSessionState { + /// The session's ordered outbound channel. + tx: mpsc::Sender, + in_flight: Option, + /// Newest configuration built while an update was in flight. + pending: Option, + /// The configuration the supervisor last applied. + acknowledged: Option, + /// The admission this session last persisted. The admission is written + /// again only when it changes, so a redelivered configuration writes + /// nothing. + persisted_admission: Option, + /// Preserve the last successful load across later rejected updates until + /// it is recorded or a newer successful load supersedes it. + unrecorded_policy_loads: UnrecordedPolicyLoads, +} + +#[derive(Debug, Default)] +struct UnrecordedPolicyLoads { + loaded: Option, + failed: Option, +} + +impl UnrecordedPolicyLoads { + fn keep(&mut self, mut result: PolicyLoadResult) { + let slot = if result.loaded { + result.error.clear(); + if self + .failed + .as_ref() + .is_some_and(|failed| failed.version == result.version) + { + self.failed = None; + } + &mut self.loaded + } else { + result.error = result.error.chars().take(1024).collect(); + &mut self.failed + }; + if slot + .as_ref() + .is_none_or(|kept| kept.version <= result.version) + { + *slot = Some(result); + } + } + + fn take(&mut self) -> Vec { + let mut results = [self.loaded.take(), self.failed.take()] + .into_iter() + .flatten() + .collect::>(); + results.sort_by_key(|result| result.version); + results + } +} + +#[derive(Debug)] +struct InFlightConfigUpdate { + update_id: String, + fingerprint: ConfigFingerprint, + admission: SandboxConfigurationAdmission, + /// The session's bootstrap, whose result completes session setup. + bootstrap: bool, + sent_at: Instant, +} + +/// The bootstrap a session is accepted with, in flight from the start. +#[derive(Debug)] +pub struct BootstrapInFlight(InFlightConfigUpdate); + +struct CompletedConfigUpdate { + update_id: String, + fingerprint: ConfigFingerprint, + outcome: ConfigApplyOutcome, + admission: SandboxConfigurationAdmission, + bootstrap: bool, + sent_at: Instant, +} + +/// An update to queue on the session stream once the registry lock is +/// released. +struct Outgoing { + tx: mpsc::Sender, + message: GatewayMessage, +} + +impl ConfigSessionState { + pub fn new(tx: mpsc::Sender) -> Self { + Self { + tx, + in_flight: None, + pending: None, + acknowledged: None, + persisted_admission: None, + unrecorded_policy_loads: UnrecordedPolicyLoads::default(), + } + } + + fn deliver(&mut self, config: SupervisorConfig) -> Result { + if self.in_flight.is_none() + && self.acknowledged.as_ref() == Some(&ConfigFingerprint::of(&config.sandbox_config)) + { + return Err(DeliveryDisposition::SuppressedUnchanged); + } + // Hold the configuration until the supervisor answers the update it + // already has. The supervisor answers every update in order, so an + // answer that never comes means the session is gone. + if self.in_flight.is_some() { + self.pending = Some(config); + return Err(DeliveryDisposition::Coalesced); + } + self.send(config) + } + + fn send(&mut self, config: SupervisorConfig) -> Result { + let (update, in_flight) = build_config_update(config, false, live_message_len); + let message = GatewayMessage { + payload: Some(gateway_message::Payload::ConfigUpdate(update)), + }; + if message.encoded_len() > MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES { + return Err(DeliveryDisposition::PayloadTooLarge); + } + self.in_flight = Some(in_flight); + Ok(Outgoing { + tx: self.tx.clone(), + message, + }) + } + + fn complete(&self, result: &ConfigUpdateResult) -> Result { + let in_flight = self + .in_flight + .as_ref() + .ok_or_else(|| Status::failed_precondition("no update is in flight"))?; + if in_flight.update_id != result.update_id { + return Err(Status::invalid_argument( + "configuration result does not match the in-flight update", + )); + } + // An outcome this gateway does not know did not take effect here. + let outcome = ConfigApplyOutcome::try_from(result.outcome) + .ok() + .filter(|outcome| *outcome != ConfigApplyOutcome::Unspecified) + .unwrap_or(ConfigApplyOutcome::Unsupported); + Ok(CompletedConfigUpdate { + update_id: in_flight.update_id.clone(), + fingerprint: in_flight.fingerprint.clone(), + outcome, + admission: in_flight.admission.clone(), + bootstrap: in_flight.bootstrap, + sent_at: in_flight.sent_at, + }) + } + + /// Finish the update in flight and release configuration held behind + /// it, unless the supervisor already has that configuration. + fn finalize( + &mut self, + completed: &CompletedConfigUpdate, + acknowledge: bool, + ) -> Option { + if self + .in_flight + .as_ref() + .is_none_or(|update| update.update_id != completed.update_id) + { + return None; + } + self.in_flight = None; + if acknowledge && outcome_acknowledges_revision(completed.outcome) { + // A degraded supervisor enforces this configuration but failed to + // reload part of its runtime, so reconciliation delivers it again + // to retry the reload, as the next poll would. + self.acknowledged = (completed.outcome != ConfigApplyOutcome::Degraded) + .then(|| completed.fingerprint.clone()); + } + let pending = self.pending.take()?; + if self.acknowledged.as_ref() == Some(&ConfigFingerprint::of(&pending.sandbox_config)) { + return None; + } + self.send(pending).ok() + } + + fn admission_changed(&self, admission: &SandboxConfigurationAdmission) -> bool { + self.persisted_admission.as_ref() != Some(admission) + } +} + +impl SupervisorSessionRegistry { + /// Hand a configuration to the session it was built for. A replacement + /// session received its own bootstrap and must not receive an older + /// build. + pub(crate) fn deliver_config( + &self, + sandbox_id: &str, + session_id: &str, + config: SupervisorConfig, + ) -> DeliveryDisposition { + let delivery = self.with_config_session(sandbox_id, session_id, |session| { + session.map_or(Err(DeliveryDisposition::UnsupportedSession), |session| { + session.deliver(config) + }) + }); + match delivery { + None => DeliveryDisposition::NoActiveSession, + Some(Err(disposition)) => disposition, + Some(Ok(outgoing)) => { + if self.send_or_close(sandbox_id, session_id, outgoing) { + DeliveryDisposition::Queued + } else { + DeliveryDisposition::StreamClosed + } + } + } + } + + /// Queue `outgoing` on the session's stream. A stream too full to take + /// it has stopped draining, so the session closes and its supervisor + /// reconnects with a fresh bootstrap. A closed stream is already ending. + fn send_or_close(&self, sandbox_id: &str, session_id: &str, outgoing: Outgoing) -> bool { + match outgoing.tx.try_send(outgoing.message) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + sandbox_id, + session_id, "supervisor session stopped draining configuration; closing it" + ); + self.close_if_current(sandbox_id, session_id); + false + } + Err(mpsc::error::TrySendError::Closed(_)) => false, + } + } + + /// Hold configuration updates for a session until the bootstrap it was + /// accepted with is answered. + /// Start the session with its bootstrap in flight. `persisted_admission` + /// is the admission the sandbox record already holds, which a result + /// reporting the same admission does not write again. + pub(crate) fn begin_config_bootstrap( + &self, + sandbox_id: &str, + session_id: &str, + bootstrap: BootstrapInFlight, + persisted_admission: Option, + ) { + self.with_config_session(sandbox_id, session_id, |session| { + if let Some(session) = session { + session.in_flight = Some(bootstrap.0); + session.persisted_admission = persisted_admission; + } + }); + } + + fn complete_config_update( + &self, + sandbox_id: &str, + session_id: &str, + result: &ConfigUpdateResult, + ) -> Result { + self.with_config_session(sandbox_id, session_id, |session| { + session.map(|session| session.complete(result)) + }) + .flatten() + .unwrap_or_else(|| { + Err(Status::failed_precondition( + "obsolete supervisor session result", + )) + }) + } + + /// Finish a validated update after all durable side effects have either + /// succeeded or failed. Keeping the update in flight until this point + /// prevents reconciliation from treating a locally reported result as an + /// acknowledgement before its admission is durable. + fn finalize_config_update( + &self, + sandbox_id: &str, + session_id: &str, + completed: &CompletedConfigUpdate, + acknowledge: bool, + ) { + if let Some(outgoing) = self + .with_config_session(sandbox_id, session_id, |session| { + session.and_then(|session| session.finalize(completed, acknowledge)) + }) + .flatten() + { + self.send_or_close(sandbox_id, session_id, outgoing); + } + } + + /// Whether persisting `admission` would change what this session last + /// persisted, or `None` once the session was replaced. + fn admission_changed( + &self, + sandbox_id: &str, + session_id: &str, + admission: &SandboxConfigurationAdmission, + ) -> Option { + self.with_config_session(sandbox_id, session_id, |session| { + session.is_none_or(|session| session.admission_changed(admission)) + }) + } + + fn record_persisted_admission( + &self, + sandbox_id: &str, + session_id: &str, + admission: &SandboxConfigurationAdmission, + ) { + self.with_config_session(sandbox_id, session_id, |session| { + if let Some(session) = session { + session.persisted_admission = Some(admission.clone()); + } + }); + } + + fn take_unrecorded_policy_loads( + &self, + sandbox_id: &str, + session_id: &str, + ) -> Vec { + self.with_config_session(sandbox_id, session_id, |session| { + session.map_or_else(Vec::new, |session| session.unrecorded_policy_loads.take()) + }) + .unwrap_or_default() + } + + fn keep_unrecorded_policy_load( + &self, + sandbox_id: &str, + session_id: &str, + policy_load: PolicyLoadResult, + ) { + self.with_config_session(sandbox_id, session_id, |session| { + if let Some(session) = session { + session.unrecorded_policy_loads.keep(policy_load); + } + }); + } +} + +/// The encoded length of a message whose one empty message field is replaced +/// by a message of `field_len` bytes. An empty message field encodes as its +/// key and a one-byte zero length. Measuring this way avoids copying a large +/// configuration into its envelope. +fn with_message_field(len_with_empty_field: usize, field_len: usize) -> usize { + len_with_empty_field - 1 + prost::encoding::encoded_len_varint(field_len as u64) + field_len +} + +/// The encoded length of the message that carries a live update of +/// `update_len` bytes. +fn live_message_len(update_len: usize) -> usize { + let envelope = GatewayMessage { + payload: Some(gateway_message::Payload::ConfigUpdate( + ConfigUpdate::default(), + )), + }; + with_message_field(envelope.encoded_len(), update_len) +} + +/// The encoded length of the `accepted` message once it carries a bootstrap +/// of `update_len` bytes. +fn bootstrap_message_len(accepted: &SessionAccepted, update_len: usize) -> usize { + let accepted = SessionAccepted { + bootstrap: Some(ConfigUpdate::default()), + ..accepted.clone() + }; + let envelope = GatewayMessage { + payload: Some(gateway_message::Payload::SessionAccepted( + SessionAccepted::default(), + )), + }; + with_message_field( + envelope.encoded_len(), + with_message_field(accepted.encoded_len(), update_len), + ) +} + +/// `message_size` maps the encoded length of the update to the length of +/// the message that carries it. +fn build_config_update( + config: SupervisorConfig, + bootstrap: bool, + message_size: impl Fn(usize) -> usize, +) -> (ConfigUpdate, InFlightConfigUpdate) { + let update_id = Uuid::new_v4().to_string(); + let mut update = ConfigUpdate { + update_id: update_id.clone(), + sandbox_config: Some(config.sandbox_config), + provider_environment: Some(config.provider_environment), + }; + let size = message_size(update.encoded_len()); + if size > MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES { + let snapshot = update + .sandbox_config + .as_mut() + .expect("sandbox configuration"); + snapshot.policy = None; + snapshot.settings.clear(); + snapshot.supervisor_middleware_services.clear(); + snapshot.configuration_admitted = false; + snapshot.configuration_error = format!( + "configuration_too_large: encoded configuration is {size} bytes; maximum is {MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES} bytes" + ); + update.provider_environment = Some(super::unresolved_provider_environment(snapshot)); + } + let snapshot = update + .sandbox_config + .as_ref() + .expect("sandbox configuration"); + let in_flight = InFlightConfigUpdate { + update_id, + fingerprint: ConfigFingerprint::of(snapshot), + admission: expected_configuration_admission(snapshot), + bootstrap, + sent_at: Instant::now(), + }; + (update, in_flight) +} + +/// The update a session is accepted with, and its in-flight state for +/// [`SupervisorSessionRegistry::begin_config_bootstrap`]. +pub fn bootstrap_update( + config: SupervisorConfig, + accepted: &SessionAccepted, +) -> (ConfigUpdate, BootstrapInFlight) { + let (update, in_flight) = build_config_update(config, true, |update_len| { + bootstrap_message_len(accepted, update_len) + }); + (update, BootstrapInFlight(in_flight)) +} + +// --------------------------------------------------------------------------- +// Results and admission +// --------------------------------------------------------------------------- + +fn outcome_acknowledges_revision(outcome: ConfigApplyOutcome) -> bool { + matches!( + outcome, + ConfigApplyOutcome::Applied + | ConfigApplyOutcome::IgnoredDuplicate + | ConfigApplyOutcome::RetainedLocalOverride + | ConfigApplyOutcome::Degraded + ) +} + +fn expected_configuration_admission( + snapshot: &SandboxConfigSnapshot, +) -> SandboxConfigurationAdmission { + use openshell_core::proto::ConfigurationAdmissionState; + + SandboxConfigurationAdmission { + instance_id: snapshot.configuration_instance_id.clone(), + state: if snapshot.configuration_admitted { + ConfigurationAdmissionState::Accepted.into() + } else { + ConfigurationAdmissionState::Rejected.into() + }, + policy_version: snapshot.version, + policy_hash: snapshot.policy_hash.clone(), + config_revision: snapshot.config_revision, + provider_env_revision: snapshot.provider_env_revision, + error: snapshot.configuration_error.clone(), + } +} + +fn validate_configuration_admission( + reported: Option<&SandboxConfigurationAdmission>, + expected: &SandboxConfigurationAdmission, + outcome: ConfigApplyOutcome, +) -> Result { + use openshell_core::proto::ConfigurationAdmissionState; + + let reported = reported.ok_or_else(|| { + Status::invalid_argument("sandbox configuration admission result is required") + })?; + if reported.instance_id != expected.instance_id + || reported.policy_version != expected.policy_version + || reported.policy_hash != expected.policy_hash + || reported.config_revision != expected.config_revision + || reported.provider_env_revision != expected.provider_env_revision + { + return Err(Status::invalid_argument( + "configuration admission does not match the delivered generation", + )); + } + let expected_state = ConfigurationAdmissionState::try_from(expected.state).unwrap_or_default(); + let reported_state = ConfigurationAdmissionState::try_from(reported.state).unwrap_or_default(); + if expected_state == ConfigurationAdmissionState::Rejected { + if reported_state != ConfigurationAdmissionState::Rejected { + return Err(Status::invalid_argument( + "rejected gateway configuration was reported as accepted", + )); + } + return Ok(expected.clone()); + } + if outcome_acknowledges_revision(outcome) + && reported_state == ConfigurationAdmissionState::Accepted + { + let mut admission = expected.clone(); + admission.error.clear(); + return Ok(admission); + } + if reported_state == ConfigurationAdmissionState::Rejected { + let mut admission = expected.clone(); + admission.state = ConfigurationAdmissionState::Rejected.into(); + admission.error = "effective configuration could not be activated".to_string(); + return Ok(admission); + } + Err(Status::invalid_argument( + "configuration admission is inconsistent with the apply result", + )) +} + +/// Record the result of the update in flight, including the bootstrap. +/// Nothing is persisted for an invalid result. Returns false when the +/// session must close: a result that answers no update in flight, or a +/// bootstrap whose result is invalid or whose admission could not be +/// persisted. +pub async fn handle_config_update_result( + session: &AcceptedSession, + result: &ConfigUpdateResult, +) -> bool { + let AcceptedSession { + state, + sandbox_id, + session_id, + .. + } = session; + let registry = &state.supervisor_sessions; + let completed = match registry.complete_config_update(sandbox_id, session_id, result) { + Ok(completed) => completed, + Err(error) => { + // The supervisor answers each update once, in order, so this + // session and its supervisor disagree; a reconnect resets both. + warn!( + sandbox_id, + session_id, + error = %error, + "supervisor configuration result answers no update in flight; closing the session" + ); + return false; + } + }; + histogram!( + crate::gateway_metrics::SUPERVISOR_CONFIG_APPLY_DURATION_SECONDS, + "outcome" => completed.outcome.as_str_name(), + ) + .record(completed.sent_at.elapsed().as_secs_f64()); + record_apply_result(sandbox_id, completed.outcome, result); + let admission = match validate_configuration_admission( + result.admission.as_ref(), + &completed.admission, + completed.outcome, + ) { + Ok(admission) => admission, + Err(error) => { + warn!( + sandbox_id, + session_id, + error = %error, + "supervisor configuration result did not match the delivered update" + ); + registry.finalize_config_update(sandbox_id, session_id, &completed, false); + return !completed.bootstrap; + } + }; + let bootstrap_load = if completed.bootstrap && result.policy_load.is_none() { + bootstrap_policy_load(state, sandbox_id, &completed, &admission).await + } else { + None + }; + // Retry the successful load before a later rejection so the policy still + // enforced cannot lose its load acknowledgement. + let mut recorded = true; + let policy_loads = registry + .take_unrecorded_policy_loads(sandbox_id, session_id) + .into_iter() + .chain(result.policy_load.clone()) + .chain(bootstrap_load); + for policy_load in policy_loads { + match record_policy_load(state, sandbox_id, &policy_load).await { + Ok(()) => {} + // The revision is gone, so there is nothing left to record. + Err(error) if error.code() == tonic::Code::NotFound => { + debug!( + sandbox_id, + session_id, + version = policy_load.version, + "dropped policy result for a missing revision" + ); + } + Err(error) => { + warn!( + sandbox_id, + session_id, + error = %error, + "failed to persist supervisor policy result" + ); + registry.keep_unrecorded_policy_load(sandbox_id, session_id, policy_load); + recorded = false; + } + } + } + let admitted = persist_admission(session, &admission).await; + // An unpersisted result leaves the configuration unacknowledged, so + // reconciliation delivers it again and the supervisor reports it again. + registry.finalize_config_update(sandbox_id, session_id, &completed, recorded && admitted); + admitted || !completed.bootstrap +} + +/// Count and log an update result. +fn record_apply_result(sandbox_id: &str, outcome: ConfigApplyOutcome, result: &ConfigUpdateResult) { + counter!( + "openshell_supervisor_config_apply_results_total", + "outcome" => outcome.as_str_name(), + ) + .increment(1); + if !outcome_acknowledges_revision(outcome) { + let failure = result.failure.as_ref(); + warn!( + sandbox_id, + outcome = outcome.as_str_name(), + failure_code = failure.map_or("", |failure| failure.code.as_str()), + failure = failure + .map(|failure| failure.message.chars().take(1024).collect::()) + .unwrap_or_default(), + "supervisor failed to apply pushed configuration" + ); + } +} + +/// Record a sandbox policy load result. The supervisor reports one only +/// when an update changed the sandbox policy, as polling does. +async fn record_policy_load( + state: &Arc, + sandbox_id: &str, + policy_load: &PolicyLoadResult, +) -> Result<(), Status> { + crate::grpc::policy::record_policy_apply_result( + state, + sandbox_id, + policy_load.version, + policy_load.loaded, + (!policy_load.loaded).then_some(policy_load.error.as_str()), + "stream", + ) + .await +} + +/// The load that a bootstrap result implies for the sandbox policy revision +/// the supervisor enforces, unless that revision is already settled. +/// +/// The supervisor reports a load only when an update changes its policy, so +/// the bootstrap of a reconnect reports none. A load result lost with an +/// earlier session, because its write failed or the session ended before the +/// result arrived, would otherwise leave the revision pending. The bootstrap +/// settles it, as a polling supervisor's startup acknowledgement does. +async fn bootstrap_policy_load( + state: &Arc, + sandbox_id: &str, + completed: &CompletedConfigUpdate, + admission: &SandboxConfigurationAdmission, +) -> Option { + use crate::policy_store::PolicyStoreExt as _; + use openshell_core::proto::ConfigurationAdmissionState; + + let revision = &completed.fingerprint.revision; + // Degraded enforces the policy with only built-in middleware. A local + // override enforces no gateway policy. + let enforced = matches!( + completed.outcome, + ConfigApplyOutcome::Applied + | ConfigApplyOutcome::IgnoredDuplicate + | ConfigApplyOutcome::Degraded + ) && admission.state == i32::from(ConfigurationAdmissionState::Accepted); + if !enforced + || revision.policy_source != i32::from(PolicySource::Sandbox) + || revision.policy_version == 0 + { + return None; + } + let version = revision.policy_version; + match state + .store + .get_policy_by_version(sandbox_id, i64::from(version)) + .await + { + Ok(Some(policy)) if matches!(policy.status.as_str(), "loaded" | "superseded") => { + return None; + } + Ok(None) => return None, + Ok(Some(_)) => {} + // Record it anyway. A failed write is kept and retried with the + // session's next result. + Err(error) => { + debug!(sandbox_id, version, error = %error, "failed to read the bootstrap policy revision"); + } + } + Some(PolicyLoadResult { + version, + loaded: true, + error: String::new(), + }) +} + +/// Persist the session's admission when it differs from the one this session +/// last persisted, so a redelivered configuration writes nothing. +async fn persist_admission( + session: &AcceptedSession, + admission: &SandboxConfigurationAdmission, +) -> bool { + let AcceptedSession { + state, + sandbox_id, + session_id, + instance_id, + .. + } = session; + let registry = &state.supervisor_sessions; + match registry.admission_changed(sandbox_id, session_id, admission) { + None => return false, + Some(false) => return true, + Some(true) => {} + } + if let Err(error) = state + .compute + .supervisor_session_admission(sandbox_id, session_id, instance_id, admission) + .await + { + warn!(sandbox_id, session_id, error = %error, "failed to persist supervisor configuration admission"); + return false; + } + registry.record_persisted_admission(sandbox_id, session_id, admission); + true +} + +// --------------------------------------------------------------------------- +// Bootstrap +// --------------------------------------------------------------------------- + +/// How a session's bootstrap was built. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootstrapOutcome { + Built, + /// The stored configuration is invalid, so the bootstrap carries the + /// not-admitted configuration and the supervisor applies its failure + /// mode. + Rejected, + /// The provider environment could not be built, so the bootstrap carries + /// it unresolved, as live delivery does. The supervisor revokes static + /// credentials and rejects the update until a rebuild resolves it. + ProviderEnvironmentFailed, +} + +impl BootstrapOutcome { + fn label(self) -> &'static str { + match self { + Self::Built => "built", + Self::Rejected => "rejected", + Self::ProviderEnvironmentFailed => "provider_environment_failed", + } + } +} + +/// The configuration a streamed-apply session is accepted with. +#[derive(Debug, Clone)] +pub struct SessionBootstrap { + pub config: SupervisorConfig, + pub outcome: BootstrapOutcome, +} + +impl SessionBootstrap { + /// The resolved provider environment the session can reuse, if any. + pub fn resolved_provider_environment(&self) -> Option { + (self.outcome == BootstrapOutcome::Built).then(|| self.config.provider_environment.clone()) + } + + /// Whether the session must rebuild its configuration at once, because + /// the bootstrap could not resolve part of it. + pub fn needs_rebuild(&self) -> bool { + self.outcome == BootstrapOutcome::ProviderEnvironmentFailed + } +} + +/// Build the bootstrap a streamed-apply session is accepted with. +pub async fn build_session_bootstrap( + state: &Arc, + sandbox: &Sandbox, +) -> Result { + // Bootstraps share the build permits with session reconciliation, so a + // burst of reconnects, such as a replica's sessions redirected at + // shutdown, cannot exhaust the database pool user requests need. + let _permit = state + .config_delivery + .builds + .acquire() + .await + .expect("the build semaphore is never closed"); + let bootstrap = build_config_bootstrap(state, sandbox).await; + let outcome = bootstrap + .as_ref() + .map_or("build_failed", |bootstrap| bootstrap.outcome.label()); + counter!("openshell_supervisor_config_bootstrap_total", "outcome" => outcome).increment(1); + if let Err(error) = &bootstrap { + warn!( + sandbox_id = %sandbox.object_id(), + error_code = ?error.code(), + "failed to build supervisor configuration bootstrap" + ); + } + bootstrap +} + +/// Build both parts of a session's configuration from one load of their +/// inputs. Only a failure to read the stored configuration refuses the +/// session; anything the supervisor can act on is delivered. +async fn build_config_bootstrap( + state: &Arc, + sandbox: &Sandbox, +) -> Result { + tokio::time::timeout(CONFIG_BOOTSTRAP_BUILD_TIMEOUT, async { + let built = async { + let inputs = load_sandbox_config_inputs(state, sandbox.clone()).await?; + let sandbox_config = build_sandbox_config_snapshot_from_inputs(state, &inputs).await?; + Ok::<_, Status>((inputs, sandbox_config)) + } + .await; + let (inputs, sandbox_config) = match built { + Ok(built) => built, + Err(error) if is_configuration_rejection(&error) => { + let sandbox_config = unadmitted_sandbox_config(state, sandbox, &error); + return Ok(SessionBootstrap { + config: SupervisorConfig { + provider_environment: super::unresolved_provider_environment( + &sandbox_config, + ), + sandbox_config, + }, + outcome: BootstrapOutcome::Rejected, + }); + } + Err(error) => return Err(error), + }; + let provider_environment = tokio::time::timeout( + super::PROVIDER_ENVIRONMENT_BUILD_TIMEOUT, + build_provider_environment_snapshot_from_inputs(state, &inputs, true, true), + ) + .await; + let (provider_environment, outcome) = match provider_environment { + Ok(Ok(provider_environment)) => (provider_environment, BootstrapOutcome::Built), + failed => { + let error_code = match &failed { + Ok(Err(error)) => error.code(), + _ => tonic::Code::DeadlineExceeded, + }; + warn!( + sandbox_id = %sandbox.object_id(), + ?error_code, + "failed to build the bootstrap provider environment; delivering it unresolved" + ); + ( + super::unresolved_provider_environment(&sandbox_config), + BootstrapOutcome::ProviderEnvironmentFailed, + ) + } + }; + Ok(SessionBootstrap { + config: SupervisorConfig { + sandbox_config, + provider_environment, + }, + outcome, + }) + }) + .await + .map_err(|_| Status::deadline_exceeded("supervisor configuration bootstrap timed out"))? +} + +/// The result a supervisor reports after applying `update` in full. +#[cfg(test)] +pub fn applied_result(update: &ConfigUpdate) -> ConfigUpdateResult { + let sandbox = update + .sandbox_config + .as_ref() + .expect("sandbox configuration"); + let policy_load = (sandbox.policy_source == i32::from(PolicySource::Sandbox) + && sandbox.version > 0) + .then(|| PolicyLoadResult { + version: sandbox.version, + loaded: true, + error: String::new(), + }); + ConfigUpdateResult { + update_id: update.update_id.clone(), + outcome: ConfigApplyOutcome::Applied.into(), + failure: None, + admission: Some(expected_configuration_admission(sandbox)), + policy_load, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::grpc::test_support::StreamFeatures; + use crate::supervisor_session::SessionMode; + use openshell_core::proto::{ + ProviderEnvironmentSnapshot, ProviderEnvironmentValue, SandboxPolicy, SandboxSpec, + SessionAccepted, SupervisorMessage, supervisor_message, + }; + use tokio::sync::oneshot; + + fn make_shutdown() -> oneshot::Sender<()> { + oneshot::channel::<()>().0 + } + + fn sandbox_record(id: &str, name: &str) -> Sandbox { + Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: id.to_string(), + name: name.to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + ..Default::default() + } + } + + async fn state_with_sandbox(sandbox_id: &str) -> Arc { + let state = crate::grpc::test_support::test_server_state().await; + state + .store + .put_message(&sandbox_record(sandbox_id, sandbox_id)) + .await + .unwrap(); + state + } + + async fn first_gateway_message( + harness: &mut crate::grpc::test_support::SupervisorStreamHarness, + ) -> GatewayMessage { + tokio::time::timeout(Duration::from_secs(5), harness.inbound.message()) + .await + .expect("gateway response before timeout") + .expect("stream open") + .expect("gateway message") + } + + fn accepted_session( + state: &Arc, + sandbox_id: &str, + tx: mpsc::Sender, + ) -> AcceptedSession { + AcceptedSession { + state: Arc::clone(state), + sandbox_id: sandbox_id.into(), + session_id: "session-1".into(), + instance_id: "instance-1".into(), + tx, + supports_session_redirect: false, + pushes_config: true, + } + } + + #[test] + fn configuration_stream_messages_round_trip() { + let update = || ConfigUpdate { + update_id: "update-1".into(), + sandbox_config: Some(SandboxConfigSnapshot::default()), + provider_environment: Some(ProviderEnvironmentSnapshot::default()), + }; + let messages = [ + GatewayMessage { + payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { + session_id: "session-1".into(), + heartbeat_interval: openshell_core::time::duration_from_std( + Duration::from_secs(15), + ) + .ok(), + bootstrap: Some(update()), + config_push_enabled: true, + })), + }, + GatewayMessage { + payload: Some(gateway_message::Payload::ConfigUpdate(update())), + }, + ]; + for original in messages { + let decoded = GatewayMessage::decode(original.encode_to_vec().as_slice()).unwrap(); + assert_eq!(decoded, original); + } + + let result = SupervisorMessage { + payload: Some(supervisor_message::Payload::ConfigUpdateResult( + ConfigUpdateResult { + update_id: "update-1".into(), + outcome: ConfigApplyOutcome::Applied.into(), + failure: None, + admission: Some(SandboxConfigurationAdmission::default()), + policy_load: Some(PolicyLoadResult { + version: 3, + loaded: true, + error: String::new(), + }), + }, + )), + }; + let decoded = SupervisorMessage::decode(result.encode_to_vec().as_slice()).unwrap(); + assert_eq!(decoded, result); + } + + /// Register a streamed-apply session and return its outbound channel. + fn register_apply_session_with_outbound( + registry: &SupervisorSessionRegistry, + sandbox_id: &str, + session_id: &str, + ) -> (mpsc::Sender, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(16); + registry.register_with_mode( + sandbox_id.into(), + session_id.into(), + tx.clone(), + make_shutdown(), + SessionMode::Push, + ); + (tx, rx) + } + + /// The next configuration update queued on a session's channel, skipping + /// any admission sent ahead of it. + fn take_update(outbound: &mut mpsc::Receiver) -> Option { + while let Ok(message) = outbound.try_recv() { + if let Some(gateway_message::Payload::ConfigUpdate(update)) = message.payload { + return Some(update); + } + } + None + } + + fn sent_update(message: GatewayMessage) -> ConfigUpdate { + match message.payload { + Some(gateway_message::Payload::ConfigUpdate(update)) => update, + other => panic!("expected config update, got {other:?}"), + } + } + + fn config(config_revision: u64) -> SupervisorConfig { + SupervisorConfig { + sandbox_config: SandboxConfigSnapshot { + config_revision, + ..Default::default() + }, + provider_environment: ProviderEnvironmentSnapshot::default(), + } + } + + fn config_revision(update: &ConfigUpdate) -> u64 { + update.sandbox_config.as_ref().unwrap().config_revision + } + + /// A supervisor result for `update` with `outcome`. + fn update_result(update: &ConfigUpdate, outcome: ConfigApplyOutcome) -> ConfigUpdateResult { + ConfigUpdateResult { + update_id: update.update_id.clone(), + outcome: outcome.into(), + ..Default::default() + } + } + + #[test] + fn config_delivery_reports_missing_session() { + assert_eq!( + SupervisorSessionRegistry::new().deliver_config("missing", "session", config(1)), + DeliveryDisposition::NoActiveSession + ); + } + + #[tokio::test] + async fn config_delivery_holds_newer_configuration_until_acknowledged() { + let registry = SupervisorSessionRegistry::new(); + let (_tx, mut outbound) = + register_apply_session_with_outbound(®istry, "sb-1", "session-1"); + + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(1)), + DeliveryDisposition::Queued + ); + for config_revision in [2, 3] { + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(config_revision)), + DeliveryDisposition::Coalesced + ); + } + let first = sent_update(outbound.recv().await.unwrap()); + assert_eq!(config_revision(&first), 1); + assert!(first.provider_environment.is_some()); + + let completed = registry + .complete_config_update( + "sb-1", + "session-1", + &update_result(&first, ConfigApplyOutcome::Applied), + ) + .unwrap(); + registry.finalize_config_update("sb-1", "session-1", &completed, true); + + // The acknowledgement releases only the newest held configuration. + let next = sent_update(outbound.recv().await.unwrap()); + assert_eq!(config_revision(&next), 3); + assert_ne!(next.update_id, first.update_id); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(3)), + DeliveryDisposition::Coalesced, + "the release is in flight" + ); + } + + #[tokio::test] + async fn config_delivery_never_crosses_into_a_replacement_session() { + let registry = SupervisorSessionRegistry::new(); + let (_old_tx, _old_outbound) = + register_apply_session_with_outbound(®istry, "sb-1", "old-session"); + let (_new_tx, mut new_outbound) = + register_apply_session_with_outbound(®istry, "sb-1", "new-session"); + + assert_eq!( + registry.deliver_config("sb-1", "old-session", config(1)), + DeliveryDisposition::NoActiveSession + ); + assert_eq!( + registry.deliver_config("sb-1", "new-session", config(2)), + DeliveryDisposition::Queued + ); + let update = sent_update(new_outbound.recv().await.unwrap()); + assert_eq!(config_revision(&update), 2); + } + + #[test] + fn config_delivery_skips_polling_sessions() { + let registry = SupervisorSessionRegistry::new(); + let (tx, _rx) = mpsc::channel(1); + registry.register("sb-1".into(), "session-1".into(), tx, make_shutdown()); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(1)), + DeliveryDisposition::UnsupportedSession + ); + } + + fn oversized_config() -> SupervisorConfig { + let mut config = admitted_config(); + config.sandbox_config.policy = Some(SandboxPolicy::default()); + config.sandbox_config.version = 3; + config.sandbox_config.policy_hash = "policy-hash".into(); + config.sandbox_config.provider_attachment_epoch = "attachment-1".into(); + config.sandbox_config.policy_validation_failure_mode = "retain_last_valid".into(); + config.provider_environment.values = vec![ProviderEnvironmentValue { + name: "TOKEN".into(), + value: "secret".repeat(MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES / 6 + 1), + ..Default::default() + }]; + config + } + + fn assert_oversized_rejection(update: &ConfigUpdate, original: &SandboxConfigSnapshot) { + let rejected = update.sandbox_config.as_ref().unwrap(); + assert_eq!( + ConfigFingerprint::of(rejected), + ConfigFingerprint::of(original) + ); + assert_eq!( + rejected.configuration_instance_id, + original.configuration_instance_id + ); + assert_eq!( + rejected.policy_validation_failure_mode, + original.policy_validation_failure_mode + ); + assert!(!rejected.configuration_admitted); + assert!( + rejected + .configuration_error + .starts_with("configuration_too_large:") + ); + assert!(rejected.configuration_error.len() < 200); + assert!(rejected.policy.is_none()); + assert!(rejected.settings.is_empty()); + assert!(rejected.supervisor_middleware_services.is_empty()); + assert_eq!( + update.provider_environment.as_ref().unwrap(), + &super::super::unresolved_provider_environment(rejected) + ); + assert!(update.encoded_len() < 1024); + assert!(!String::from_utf8_lossy(&update.encode_to_vec()).contains("secret")); + } + + #[tokio::test] + async fn oversized_live_and_pending_updates_deliver_rejections_and_recover() { + for pending in [false, true] { + let registry = SupervisorSessionRegistry::new(); + let (_tx, mut outbound) = + register_apply_session_with_outbound(®istry, "sb-1", "session-1"); + let first = pending.then(|| { + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(1)), + DeliveryDisposition::Queued + ); + sent_update(outbound.try_recv().unwrap()) + }); + let oversized = oversized_config(); + let original = oversized.sandbox_config.clone(); + assert_eq!( + registry.deliver_config("sb-1", "session-1", oversized), + if pending { + DeliveryDisposition::Coalesced + } else { + DeliveryDisposition::Queued + } + ); + if let Some(first) = first { + answer_bootstrap( + ®istry, + "sb-1", + "session-1", + &first, + ConfigApplyOutcome::Applied, + ); + } + let rejection = sent_update(outbound.recv().await.unwrap()); + assert_oversized_rejection(&rejection, &original); + let completed = registry + .complete_config_update( + "sb-1", + "session-1", + &update_result(&rejection, ConfigApplyOutcome::FailedRetainedLastKnownGood), + ) + .unwrap(); + assert_eq!( + completed.admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Rejected) + ); + assert!( + validate_configuration_admission( + Some(&completed.admission), + &completed.admission, + completed.outcome + ) + .is_ok() + ); + registry.finalize_config_update("sb-1", "session-1", &completed, true); + let recovery = SupervisorConfig { + sandbox_config: SandboxConfigSnapshot { + configuration_admitted: true, + ..original + }, + provider_environment: ProviderEnvironmentSnapshot::default(), + }; + assert_eq!( + registry.deliver_config("sb-1", "session-1", recovery), + DeliveryDisposition::Queued + ); + let recovered = sent_update(outbound.recv().await.unwrap()); + assert!(recovered.sandbox_config.unwrap().configuration_admitted); + } + } + + #[test] + fn oversized_bootstrap_delivers_rejection_with_original_identity() { + let config = oversized_config(); + let original = config.sandbox_config.clone(); + let acceptance = SessionAccepted { + session_id: "session-1".into(), + config_push_enabled: true, + ..Default::default() + }; + let (update, in_flight) = bootstrap_update(config, &acceptance); + assert_oversized_rejection(&update, &original); + assert_eq!( + in_flight.0.admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Rejected) + ); + let message = GatewayMessage { + payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { + bootstrap: Some(update), + ..acceptance + })), + }; + assert!(message.encoded_len() <= MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES); + } + + #[test] + fn bootstrap_size_limit_includes_the_session_envelope() { + let mut config = admitted_config(); + config.provider_environment.values = vec![ProviderEnvironmentValue { + name: "TOKEN".into(), + value: "x".repeat(MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES), + ..Default::default() + }]; + let raw = ConfigUpdate { + update_id: Uuid::new_v4().to_string(), + sandbox_config: Some(config.sandbox_config.clone()), + provider_environment: Some(config.provider_environment.clone()), + }; + let overhead = GatewayMessage { + payload: Some(gateway_message::Payload::ConfigUpdate(raw)), + } + .encoded_len() + - MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES; + config.provider_environment.values[0] + .value + .truncate(MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES - overhead); + let (live, _) = build_config_update(config.clone(), false, live_message_len); + assert!(live.sandbox_config.unwrap().configuration_admitted); + let acceptance = SessionAccepted { + session_id: Uuid::new_v4().to_string(), + config_push_enabled: true, + ..Default::default() + }; + let (bootstrap, _) = bootstrap_update(config, &acceptance); + assert!(!bootstrap.sandbox_config.unwrap().configuration_admitted); + } + + #[test] + fn message_lengths_match_their_encodings() { + let accepted = SessionAccepted { + session_id: Uuid::new_v4().to_string(), + config_push_enabled: true, + heartbeat_interval: Some(prost_types::Duration { + seconds: 15, + nanos: 0, + }), + ..Default::default() + }; + // Cover the lengths where a length prefix gains a byte. + for value_len in [0, 100, 127, 128, 16_383, 16_384, 2_097_151, 2_097_152] { + let mut config = admitted_config(); + config.provider_environment.values = vec![ProviderEnvironmentValue { + name: "TOKEN".into(), + value: "x".repeat(value_len), + ..Default::default() + }]; + let update = ConfigUpdate { + update_id: Uuid::new_v4().to_string(), + sandbox_config: Some(config.sandbox_config), + provider_environment: Some(config.provider_environment), + }; + let live = GatewayMessage { + payload: Some(gateway_message::Payload::ConfigUpdate(update.clone())), + }; + assert_eq!(live_message_len(update.encoded_len()), live.encoded_len()); + let bootstrap = GatewayMessage { + payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { + bootstrap: Some(update.clone()), + ..accepted.clone() + })), + }; + assert_eq!( + bootstrap_message_len(&accepted, update.encoded_len()), + bootstrap.encoded_len() + ); + } + } + + /// Register a streamed-apply session and return its outbound channel. + fn register_apply_session( + registry: &SupervisorSessionRegistry, + sandbox_id: &str, + session_id: &str, + ) -> mpsc::Receiver { + register_apply_session_with_outbound(registry, sandbox_id, session_id).1 + } + + async fn push_state() -> Arc { + let mut state = crate::grpc::test_support::test_server_state().await; + Arc::get_mut(&mut state) + .unwrap() + .config + .config_delivery_mode = openshell_core::config::ConfigDeliveryMode::Push; + state + } + + async fn push_state_with_sandbox(sandbox_id: &str) -> Arc { + let state = push_state().await; + state + .store + .put_message(&sandbox_record(sandbox_id, sandbox_id)) + .await + .unwrap(); + state + } + + /// Answer the bootstrap in flight on `session_id` with `outcome`. + fn answer_bootstrap( + registry: &SupervisorSessionRegistry, + sandbox_id: &str, + session_id: &str, + bootstrap: &ConfigUpdate, + outcome: ConfigApplyOutcome, + ) { + let completed = registry + .complete_config_update(sandbox_id, session_id, &update_result(bootstrap, outcome)) + .unwrap(); + registry.finalize_config_update(sandbox_id, session_id, &completed, true); + } + + /// Register a push session accepted with `bootstrap` in flight. + fn register_bootstrapped_session( + registry: &SupervisorSessionRegistry, + sandbox_id: &str, + session_id: &str, + bootstrap: SupervisorConfig, + ) -> (mpsc::Receiver, ConfigUpdate) { + let slots = register_apply_session(registry, sandbox_id, session_id); + let (update, in_flight) = bootstrap_update(bootstrap, &SessionAccepted::default()); + registry.begin_config_bootstrap(sandbox_id, session_id, in_flight, None); + (slots, update) + } + + #[test] + fn rebuild_racing_the_bootstrap_waits_and_is_dropped_when_unchanged() { + let registry = SupervisorSessionRegistry::new(); + let (mut slots, bootstrap) = + register_bootstrapped_session(®istry, "sb-1", "session-1", config(7)); + + // The configuration the bootstrap carried is held, then dropped. + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::Coalesced + ); + answer_bootstrap( + ®istry, + "sb-1", + "session-1", + &bootstrap, + ConfigApplyOutcome::Applied, + ); + assert!(take_update(&mut slots).is_none()); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::SuppressedUnchanged + ); + + // Newer configuration held behind the bootstrap is sent once it lands. + let (mut slots, bootstrap) = + register_bootstrapped_session(®istry, "sb-1", "session-2", config(7)); + assert_eq!( + registry.deliver_config("sb-1", "session-2", config(8)), + DeliveryDisposition::Coalesced + ); + answer_bootstrap( + ®istry, + "sb-1", + "session-2", + &bootstrap, + ConfigApplyOutcome::Applied, + ); + let released = take_update(&mut slots).expect("newer configuration is sent"); + assert_eq!(config_revision(&released), 8); + } + + #[test] + fn session_that_stops_draining_its_stream_is_closed() { + let registry = SupervisorSessionRegistry::new(); + let (tx, _rx) = mpsc::channel(1); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + registry.register_with_mode( + "sb-1".into(), + "session-1".into(), + tx.clone(), + shutdown_tx, + SessionMode::Push, + ); + tx.try_send(GatewayMessage::default()).unwrap(); + + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::StreamClosed + ); + assert!( + shutdown_rx.try_recv().is_ok(), + "the session's task is told to end" + ); + assert!( + registry.is_current_session("sb-1", "session-1"), + "the task's cleanup removes the session and records the disconnect" + ); + } + + #[test] + fn bootstrap_result_acknowledges_only_an_applied_configuration() { + let registry = SupervisorSessionRegistry::new(); + let (mut slots, bootstrap) = + register_bootstrapped_session(®istry, "sb-1", "session-1", config(7)); + answer_bootstrap( + ®istry, + "sb-1", + "session-1", + &bootstrap, + ConfigApplyOutcome::FailedClosed, + ); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::Queued, + "a failed bootstrap leaves its configuration eligible for repair" + ); + assert!(take_update(&mut slots).is_some()); + + let (mut slots, bootstrap) = + register_bootstrapped_session(®istry, "sb-1", "session-2", config(7)); + answer_bootstrap( + ®istry, + "sb-1", + "session-2", + &bootstrap, + ConfigApplyOutcome::Applied, + ); + assert_eq!( + registry.deliver_config("sb-1", "session-2", config(7)), + DeliveryDisposition::SuppressedUnchanged + ); + assert!(take_update(&mut slots).is_none()); + } + + #[test] + fn rejected_configuration_stays_eligible_for_reconciliation_until_applied() { + let registry = SupervisorSessionRegistry::new(); + let mut slots = register_apply_session(®istry, "sb-1", "session-1"); + let mut report = |outcome| { + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::Queued + ); + let update = take_update(&mut slots).expect("update"); + let completed = registry + .complete_config_update("sb-1", "session-1", &update_result(&update, outcome)) + .unwrap(); + registry.finalize_config_update("sb-1", "session-1", &completed, true); + }; + + report(ConfigApplyOutcome::FailedRetainedLastKnownGood); + report(ConfigApplyOutcome::Applied); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::SuppressedUnchanged + ); + } + + #[test] + fn degraded_configuration_is_delivered_again_until_applied() { + let registry = SupervisorSessionRegistry::new(); + let (mut slots, bootstrap) = + register_bootstrapped_session(®istry, "sb-1", "session-1", config(7)); + answer_bootstrap( + ®istry, + "sb-1", + "session-1", + &bootstrap, + ConfigApplyOutcome::Degraded, + ); + let mut report = |outcome| { + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::Queued, + "a degraded runtime reload stays eligible for reconciliation" + ); + let update = take_update(&mut slots).expect("update"); + let completed = registry + .complete_config_update("sb-1", "session-1", &update_result(&update, outcome)) + .unwrap(); + registry.finalize_config_update("sb-1", "session-1", &completed, true); + }; + + report(ConfigApplyOutcome::Degraded); + report(ConfigApplyOutcome::IgnoredDuplicate); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::SuppressedUnchanged + ); + } + + #[test] + fn unmatched_result_leaves_the_update_in_flight() { + let registry = SupervisorSessionRegistry::new(); + let mut slots = register_apply_session(®istry, "sb-1", "session-1"); + registry.deliver_config("sb-1", "session-1", config(7)); + let update = take_update(&mut slots).expect("update"); + let mut stale = update_result(&update, ConfigApplyOutcome::Applied); + stale.update_id = "other".into(); + assert!( + registry + .complete_config_update("sb-1", "session-1", &stale) + .is_err() + ); + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(8)), + DeliveryDisposition::Coalesced + ); + } + + #[test] + fn unknown_outcome_does_not_acknowledge_the_update() { + let registry = SupervisorSessionRegistry::new(); + let mut slots = register_apply_session(®istry, "sb-1", "session-1"); + for outcome in [ConfigApplyOutcome::Unspecified.into(), 1_000] { + registry.deliver_config("sb-1", "session-1", config(7)); + let update = take_update(&mut slots).expect("update"); + let mut result = update_result(&update, ConfigApplyOutcome::Applied); + result.outcome = outcome; + let completed = registry + .complete_config_update("sb-1", "session-1", &result) + .unwrap(); + assert_eq!(completed.outcome, ConfigApplyOutcome::Unsupported); + registry.finalize_config_update("sb-1", "session-1", &completed, true); + } + assert_eq!( + registry.deliver_config("sb-1", "session-1", config(7)), + DeliveryDisposition::Queued, + "the configuration stays eligible for repair" + ); + } + + /// A sandbox configuration that settles sandbox policy version 3. + fn sandbox_policy_snapshot() -> SandboxConfigSnapshot { + SandboxConfigSnapshot { + configuration_instance_id: "configuration-1".into(), + configuration_admitted: true, + config_revision: 7, + version: 3, + policy_hash: "policy-hash".into(), + provider_env_revision: 5, + policy_source: PolicySource::Sandbox.into(), + ..Default::default() + } + } + + async fn put_sandbox_policy_revision(state: &Arc, sandbox_id: &str) { + use crate::policy_store::PolicyStoreExt as _; + + state + .store + .put_policy_revision( + "policy-3", + sandbox_id, + "default", + 3, + &SandboxPolicy::default().encode_to_vec(), + "policy-hash", + ) + .await + .unwrap(); + } + + /// Register `session-1`, streaming to `tx`, as the push session of a + /// running workload. + fn register_running_push_session( + state: &Arc, + sandbox_id: &str, + tx: &mpsc::Sender, + ) { + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + state.supervisor_sessions.register_with_mode( + sandbox_id.into(), + "session-1".into(), + tx.clone(), + shutdown_tx, + SessionMode::Push, + ); + } + + /// What a supervisor reports for `update`: the admission its outcome + /// implies, and a policy load result for a sandbox policy revision. + fn reported_result(update: &ConfigUpdate, outcome: ConfigApplyOutcome) -> ConfigUpdateResult { + use openshell_core::proto::{ConfigApplyFailure, ConfigurationAdmissionState}; + + let snapshot = update.sandbox_config.as_ref().unwrap(); + let applied = outcome_acknowledges_revision(outcome); + let mut admission = expected_configuration_admission(snapshot); + if !applied { + admission.state = ConfigurationAdmissionState::Rejected.into(); + } + ConfigUpdateResult { + update_id: update.update_id.clone(), + outcome: outcome.into(), + failure: (!applied).then(|| ConfigApplyFailure { + code: "policy_rejected".into(), + message: "policy rejected".into(), + retryable: true, + }), + admission: Some(admission), + // A duplicate changes no policy, so it reports no load. + policy_load: (snapshot.policy_source == i32::from(PolicySource::Sandbox) + && snapshot.version > 0 + && outcome != ConfigApplyOutcome::IgnoredDuplicate) + .then(|| PolicyLoadResult { + version: snapshot.version, + loaded: applied, + error: if applied { + String::new() + } else { + "policy rejected".into() + }, + }), + } + } + + /// Deliver `snapshot` to `session-1` and return the supervisor's result + /// for the update it receives. + fn deliver_and_report( + state: &Arc, + sandbox_id: &str, + outbound: &mut mpsc::Receiver, + snapshot: &SandboxConfigSnapshot, + outcome: ConfigApplyOutcome, + ) -> ConfigUpdateResult { + let config = SupervisorConfig { + sandbox_config: snapshot.clone(), + provider_environment: ProviderEnvironmentSnapshot::default(), + }; + assert_eq!( + state + .supervisor_sessions + .deliver_config(sandbox_id, "session-1", config), + DeliveryDisposition::Queued + ); + reported_result(&take_update(outbound).expect("update"), outcome) + } + + /// Push a sandbox policy update to a running workload and return the + /// supervisor's result for it. + async fn running_push_session_result( + state: &Arc, + sandbox_id: &str, + tx: &mpsc::Sender, + outbound: &mut mpsc::Receiver, + ) -> (ConfigUpdateResult, SandboxConfigurationAdmission) { + put_sandbox_policy_revision(state, sandbox_id).await; + register_running_push_session(state, sandbox_id, tx); + let snapshot = sandbox_policy_snapshot(); + let result = deliver_and_report( + state, + sandbox_id, + outbound, + &snapshot, + ConfigApplyOutcome::Applied, + ); + (result, expected_configuration_admission(&snapshot)) + } + + fn sandbox_admission(sandbox: &Sandbox) -> Option<&SandboxConfigurationAdmission> { + sandbox + .status + .as_ref() + .and_then(|status| status.configuration_admission.as_ref()) + } + + async fn stored_sandbox(state: &Arc, sandbox_id: &str) -> Sandbox { + state + .store + .get_message::(sandbox_id) + .await + .unwrap() + .expect("sandbox") + } + + #[tokio::test] + async fn running_workload_results_are_written_inline_without_an_acknowledgement() { + use crate::policy_store::PolicyStoreExt as _; + + let sandbox_id = "sb-running-result"; + let state = push_state_with_sandbox(sandbox_id).await; + let (tx, mut rx) = mpsc::channel(4); + let (result, admission) = + running_push_session_result(&state, sandbox_id, &tx, &mut rx).await; + + assert!( + handle_config_update_result(&accepted_session(&state, sandbox_id, tx.clone()), &result) + .await + ); + + assert!( + rx.try_recv().is_err(), + "a running workload's supervisor needs no admission" + ); + assert_eq!( + sandbox_admission(&stored_sandbox(&state, sandbox_id).await), + Some(&admission) + ); + let policy = state + .store + .get_policy_by_version(sandbox_id, 3) + .await + .unwrap() + .expect("policy revision"); + assert_eq!(policy.status, "loaded"); + } + + #[tokio::test] + async fn running_workload_admission_is_written_when_it_changes() { + let sandbox_id = "sb-running-admission"; + let state = push_state_with_sandbox(sandbox_id).await; + let (tx, mut rx) = mpsc::channel(4); + register_running_push_session(&state, sandbox_id, &tx); + let session = accepted_session(&state, sandbox_id, tx); + // No sandbox policy, so the admission is the only result to persist. + let snapshot = |config_revision| SandboxConfigSnapshot { + config_revision, + policy_source: PolicySource::Unspecified.into(), + ..sandbox_policy_snapshot() + }; + let mut report = |config_revision, outcome| { + deliver_and_report( + &state, + sandbox_id, + &mut rx, + &snapshot(config_revision), + outcome, + ) + }; + let resource_version = |sandbox: &Sandbox| { + sandbox + .metadata + .as_ref() + .map(|metadata| metadata.resource_version) + }; + + let rejected = report(7, ConfigApplyOutcome::FailedRetainedLastKnownGood); + assert!(handle_config_update_result(&session, &rejected).await); + let stored = stored_sandbox(&state, sandbox_id).await; + assert_eq!( + sandbox_admission(&stored).map(|admission| admission.state), + Some(i32::from( + openshell_core::proto::ConfigurationAdmissionState::Rejected + )) + ); + + // The unacknowledged configuration is delivered again and rejected + // again, which leaves the stored admission alone. + let redelivered = report(7, ConfigApplyOutcome::FailedRetainedLastKnownGood); + assert!(handle_config_update_result(&session, &redelivered).await); + assert_eq!( + resource_version(&stored_sandbox(&state, sandbox_id).await), + resource_version(&stored) + ); + + let accepted = report(8, ConfigApplyOutcome::Applied); + assert!(handle_config_update_result(&session, &accepted).await); + let stored = stored_sandbox(&state, sandbox_id).await; + let admission = sandbox_admission(&stored).expect("admission"); + assert_eq!( + admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Accepted) + ); + assert_eq!(admission.config_revision, 8); + } + + #[tokio::test] + async fn replaced_session_result_persists_nothing() { + let sandbox_id = "sb-replaced-result"; + let state = push_state_with_sandbox(sandbox_id).await; + let (tx, mut rx) = mpsc::channel(4); + let (result, _) = running_push_session_result(&state, sandbox_id, &tx, &mut rx).await; + let (replacement_tx, _replacement_rx) = mpsc::channel(4); + state.supervisor_sessions.register_with_mode( + sandbox_id.into(), + "session-2".into(), + replacement_tx, + make_shutdown(), + SessionMode::Push, + ); + + assert!( + !handle_config_update_result(&accepted_session(&state, sandbox_id, tx), &result).await + ); + assert!( + sandbox_admission(&stored_sandbox(&state, sandbox_id).await).is_none(), + "an admission from a replaced session must not be persisted" + ); + } + + /// Fail every update of the named record type, as a database outage + /// would. + const FAIL_POLICY_RECORD_WRITES: &str = r#" +CREATE TRIGGER fail_result_writes BEFORE UPDATE ON "objects" +WHEN OLD."object_type" = 'sandbox_policy' +BEGIN SELECT RAISE(ABORT, 'database unavailable'); END; +"#; + const FAIL_SANDBOX_RECORD_WRITES: &str = r#" +CREATE TRIGGER fail_result_writes BEFORE UPDATE ON "objects" +WHEN OLD."object_type" = 'sandbox' +BEGIN SELECT RAISE(ABORT, 'database unavailable'); END; +"#; + const RESTORE_RESULT_WRITES: &str = "DROP TRIGGER fail_result_writes;"; + + /// A running workload applies `snapshot` while `failing_writes` makes + /// every attempt to persist its result fail. Once writes recover, + /// reconciliation must redeliver the unchanged configuration so the + /// supervisor reports the result again. + async fn reconcile_after_result_writes_fail( + state: &Arc, + sandbox_id: &str, + snapshot: &SandboxConfigSnapshot, + failing_writes: &'static str, + ) { + let (tx, mut rx) = mpsc::channel(4); + register_running_push_session(state, sandbox_id, &tx); + let session = accepted_session(state, sandbox_id, tx); + + state.store.execute_for_test(failing_writes).await; + let applied = deliver_and_report( + state, + sandbox_id, + &mut rx, + snapshot, + ConfigApplyOutcome::Applied, + ); + handle_config_update_result(&session, &applied).await; + state.store.execute_for_test(RESTORE_RESULT_WRITES).await; + + // The supervisor already applied the configuration and reports a + // duplicate. + let duplicate = deliver_and_report( + state, + sandbox_id, + &mut rx, + snapshot, + ConfigApplyOutcome::IgnoredDuplicate, + ); + handle_config_update_result(&session, &duplicate).await; + } + + #[tokio::test] + async fn reconciliation_recovers_a_dropped_policy_result() { + use crate::policy_store::PolicyStoreExt as _; + + let sandbox_id = "sb-dropped-policy-result"; + let state = push_state_with_sandbox(sandbox_id).await; + put_sandbox_policy_revision(&state, sandbox_id).await; + + reconcile_after_result_writes_fail( + &state, + sandbox_id, + &sandbox_policy_snapshot(), + FAIL_POLICY_RECORD_WRITES, + ) + .await; + + let policy = state + .store + .get_policy_by_version(sandbox_id, 3) + .await + .unwrap() + .expect("policy revision"); + assert_eq!(policy.status, "loaded"); + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .unwrap() + .expect("sandbox"); + assert_eq!(sandbox.current_policy_version(), 3); + } + + fn sandbox_policy_config() -> SupervisorConfig { + SupervisorConfig { + sandbox_config: sandbox_policy_snapshot(), + provider_environment: ProviderEnvironmentSnapshot::default(), + } + } + + #[tokio::test] + async fn reconnect_bootstrap_settles_a_policy_load_lost_with_the_previous_session() { + use crate::policy_store::PolicyStoreExt as _; + + let sandbox_id = "sb-lost-policy-load"; + let state = push_state_with_sandbox(sandbox_id).await; + put_sandbox_policy_revision(&state, sandbox_id).await; + + // The supervisor loads policy version 3, but writing its load result + // fails, and the session ends before reconciliation delivers the + // configuration again. + let (tx, mut rx) = mpsc::channel(4); + register_running_push_session(&state, sandbox_id, &tx); + state + .store + .execute_for_test(FAIL_POLICY_RECORD_WRITES) + .await; + let applied = deliver_and_report( + &state, + sandbox_id, + &mut rx, + &sandbox_policy_snapshot(), + ConfigApplyOutcome::Applied, + ); + handle_config_update_result(&accepted_session(&state, sandbox_id, tx), &applied).await; + state.store.execute_for_test(RESTORE_RESULT_WRITES).await; + state + .supervisor_sessions + .remove_if_current(sandbox_id, "session-1"); + let lost = state + .store + .get_policy_by_version(sandbox_id, 3) + .await + .unwrap() + .expect("policy revision"); + assert_eq!(lost.status, "pending"); + + // The reconnect's bootstrap carries the same configuration, which + // the supervisor already enforces, so it reports no load. + let (tx, _rx) = mpsc::channel(4); + register_running_push_session(&state, sandbox_id, &tx); + let bootstrap = begin_bootstrap(&state, sandbox_id, sandbox_policy_config()); + let duplicate = reported_result(&bootstrap, ConfigApplyOutcome::IgnoredDuplicate); + assert_eq!(duplicate.policy_load, None); + assert!( + handle_config_update_result(&accepted_session(&state, sandbox_id, tx), &duplicate) + .await + ); + + let policy = state + .store + .get_policy_by_version(sandbox_id, 3) + .await + .unwrap() + .expect("policy revision"); + assert_eq!(policy.status, "loaded"); + assert_eq!( + stored_sandbox(&state, sandbox_id) + .await + .current_policy_version(), + 3 + ); + } + + #[tokio::test] + async fn reconnect_bootstrap_leaves_a_settled_policy_load_alone() { + let sandbox_id = "sb-settled-policy-load"; + let state = push_state_with_sandbox(sandbox_id).await; + put_sandbox_policy_revision(&state, sandbox_id).await; + record_policy_load( + &state, + sandbox_id, + &PolicyLoadResult { + version: 3, + loaded: true, + error: String::new(), + }, + ) + .await + .unwrap(); + + // The reconnect starts from the stored admission, so only a policy + // write could change the sandbox record. + let (tx, _rx) = mpsc::channel(4); + register_running_push_session(&state, sandbox_id, &tx); + let (bootstrap, in_flight) = + bootstrap_update(sandbox_policy_config(), &SessionAccepted::default()); + state.supervisor_sessions.begin_config_bootstrap( + sandbox_id, + "session-1", + in_flight, + Some(expected_configuration_admission(&sandbox_policy_snapshot())), + ); + let before = stored_sandbox(&state, sandbox_id).await; + assert!( + handle_config_update_result( + &accepted_session(&state, sandbox_id, tx), + &reported_result(&bootstrap, ConfigApplyOutcome::IgnoredDuplicate), + ) + .await + ); + + let after = stored_sandbox(&state, sandbox_id).await; + assert_eq!( + after.metadata.as_ref().unwrap().resource_version, + before.metadata.as_ref().unwrap().resource_version, + "a settled policy revision takes no sandbox write" + ); + } + + #[tokio::test] + async fn reconciliation_recovers_a_dropped_admission() { + let sandbox_id = "sb-dropped-admission"; + let state = push_state_with_sandbox(sandbox_id).await; + // No sandbox policy, so the admission is the only result to persist. + let snapshot = SandboxConfigSnapshot { + policy_source: PolicySource::Unspecified.into(), + ..sandbox_policy_snapshot() + }; + + reconcile_after_result_writes_fail( + &state, + sandbox_id, + &snapshot, + FAIL_SANDBOX_RECORD_WRITES, + ) + .await; + + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .unwrap() + .expect("sandbox"); + assert_eq!( + sandbox + .status + .as_ref() + .and_then(|status| status.configuration_admission.as_ref()), + Some(&expected_configuration_admission(&snapshot)) + ); + } + + #[tokio::test] + async fn reported_policy_load_failure_is_recorded() { + use crate::policy_store::PolicyStoreExt as _; + + let state = push_state_with_sandbox("sb-policy-result").await; + state + .store + .put_policy_revision( + "policy-3", + "sb-policy-result", + "default", + 3, + &SandboxPolicy::default().encode_to_vec(), + "hash-3", + ) + .await + .unwrap(); + record_policy_load( + &state, + "sb-policy-result", + &PolicyLoadResult { + version: 3, + loaded: false, + error: "x".repeat(2_000), + }, + ) + .await + .unwrap(); + + let policy = state + .store + .get_policy_by_version("sb-policy-result", 3) + .await + .unwrap() + .expect("policy revision"); + assert_eq!(policy.status, "failed"); + assert_eq!(policy.load_error.map(|error| error.len()), Some(1_024)); + } + + #[test] + fn configuration_admission_preserves_gateway_rejection_and_accepts_repair() { + use openshell_core::proto::ConfigurationAdmissionState; + + let mut expected = SandboxConfigurationAdmission { + instance_id: "configuration-1".into(), + state: ConfigurationAdmissionState::Rejected.into(), + policy_version: 3, + policy_hash: "policy-hash".into(), + config_revision: 7, + provider_env_revision: 5, + error: "invalid image policy".into(), + }; + let rejected = validate_configuration_admission( + Some(&expected), + &expected, + ConfigApplyOutcome::Applied, + ) + .unwrap(); + assert_eq!(rejected, expected); + + expected.state = ConfigurationAdmissionState::Accepted.into(); + expected.error.clear(); + let accepted = validate_configuration_admission( + Some(&expected), + &expected, + ConfigApplyOutcome::Applied, + ) + .unwrap(); + assert_eq!(accepted, expected); + } + + /// Register a streamed-apply session whose outbound channel stays open + /// for as long as the returned receiver lives. + fn register_apply_session_in_state( + state: &ServerState, + sandbox_id: &str, + ) -> (mpsc::Sender, mpsc::Receiver) { + register_apply_session_with_outbound(&state.supervisor_sessions, sandbox_id, "session-1") + } + + fn admitted_config() -> SupervisorConfig { + SupervisorConfig { + sandbox_config: SandboxConfigSnapshot { + configuration_instance_id: "configuration-1".into(), + configuration_admitted: true, + config_revision: 7, + provider_env_revision: 11, + ..Default::default() + }, + provider_environment: ProviderEnvironmentSnapshot { + provider_env_revision: 11, + ..Default::default() + }, + } + } + + async fn current_config(state: &Arc, sandbox_id: &str) -> SupervisorConfig { + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .unwrap() + .expect("sandbox exists"); + let bootstrap = build_config_bootstrap(state, &sandbox).await.unwrap(); + assert_eq!(bootstrap.outcome, BootstrapOutcome::Built); + bootstrap.config + } + + /// Put `config` in flight on `session-1` as its bootstrap. + fn begin_bootstrap( + state: &ServerState, + sandbox_id: &str, + config: SupervisorConfig, + ) -> ConfigUpdate { + let (update, in_flight) = bootstrap_update(config, &SessionAccepted::default()); + state + .supervisor_sessions + .begin_config_bootstrap(sandbox_id, "session-1", in_flight, None); + update + } + + async fn push_state_with_sandbox_spec(sandbox_id: &str) -> Arc { + let state = push_state().await; + let mut sandbox = sandbox_record(sandbox_id, sandbox_id); + sandbox.spec = Some(SandboxSpec::default()); + state.store.put_message(&sandbox).await.unwrap(); + state + } + + #[tokio::test] + async fn persisted_bootstrap_result_suppresses_unchanged_reconciliation() { + let state = push_state_with_sandbox_spec("sb-bootstrap-ack").await; + let (tx, mut rx) = register_apply_session_in_state(&state, "sb-bootstrap-ack"); + let config = current_config(&state, "sb-bootstrap-ack").await; + let bootstrap = begin_bootstrap(&state, "sb-bootstrap-ack", config.clone()); + + assert!( + handle_config_update_result( + &accepted_session(&state, "sb-bootstrap-ack", tx), + &applied_result(&bootstrap), + ) + .await + ); + assert_eq!( + state + .supervisor_sessions + .deliver_config("sb-bootstrap-ack", "session-1", config), + DeliveryDisposition::SuppressedUnchanged + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn reconnect_reporting_the_stored_admission_writes_nothing() { + let sandbox_id = "sb-reconnect"; + let state = push_state_with_sandbox_spec(sandbox_id).await; + let config = current_config(&state, sandbox_id).await; + + // The first session persists its admission. + let (tx, _rx) = register_apply_session_in_state(&state, sandbox_id); + let bootstrap = begin_bootstrap(&state, sandbox_id, config.clone()); + assert!( + handle_config_update_result( + &accepted_session(&state, sandbox_id, tx), + &applied_result(&bootstrap), + ) + .await + ); + let stored = stored_sandbox(&state, sandbox_id).await; + let admission = sandbox_admission(&stored).cloned().expect("admission"); + + // The reconnect starts from the stored admission and reports it again. + let (tx, _rx) = register_apply_session_in_state(&state, sandbox_id); + let (update, in_flight) = bootstrap_update(config, &SessionAccepted::default()); + state.supervisor_sessions.begin_config_bootstrap( + sandbox_id, + "session-1", + in_flight, + Some(admission), + ); + assert!( + handle_config_update_result( + &accepted_session(&state, sandbox_id, tx), + &applied_result(&update), + ) + .await + ); + let unchanged = stored_sandbox(&state, sandbox_id).await; + assert_eq!( + unchanged.metadata.as_ref().unwrap().resource_version, + stored.metadata.as_ref().unwrap().resource_version, + "an unchanged admission takes no sandbox write" + ); + } + + #[tokio::test] + async fn result_answering_no_update_in_flight_closes_the_session() { + let state = push_state_with_sandbox("sb-unmatched").await; + let (tx, _rx) = register_apply_session_in_state(&state, "sb-unmatched"); + let result = ConfigUpdateResult { + update_id: "unknown".into(), + outcome: ConfigApplyOutcome::Applied.into(), + ..Default::default() + }; + + assert!( + !handle_config_update_result(&accepted_session(&state, "sb-unmatched", tx), &result) + .await + ); + } + + #[tokio::test] + async fn invalid_bootstrap_admission_closes_the_session_without_acknowledging() { + let state = push_state_with_sandbox("sb-bootstrap-invalid").await; + let (tx, mut rx) = register_apply_session_in_state(&state, "sb-bootstrap-invalid"); + let config = admitted_config(); + let bootstrap = begin_bootstrap(&state, "sb-bootstrap-invalid", config.clone()); + let mut result = applied_result(&bootstrap); + result.admission.as_mut().unwrap().config_revision = 8; + + assert!( + !handle_config_update_result( + &accepted_session(&state, "sb-bootstrap-invalid", tx), + &result, + ) + .await, + "a mismatched admission closes the session" + ); + // The bootstrap was not acknowledged, so it remains eligible. + assert_eq!( + state + .supervisor_sessions + .deliver_config("sb-bootstrap-invalid", "session-1", config), + DeliveryDisposition::Queued + ); + assert!(take_update(&mut rx).is_some()); + } + + #[test] + fn fingerprint_covers_the_provider_environment_the_configuration_names() { + let registry = SupervisorSessionRegistry::new(); + let snapshot = SandboxConfigSnapshot { + configuration_admitted: true, + config_revision: 7, + provider_env_revision: 11, + provider_attachment_epoch: "epoch-1".into(), + policy_hash: "policy-1".into(), + ..Default::default() + }; + let with = |snapshot: SandboxConfigSnapshot| SupervisorConfig { + sandbox_config: snapshot, + provider_environment: ProviderEnvironmentSnapshot::default(), + }; + let changes = [ + SandboxConfigSnapshot { + provider_env_revision: 12, + ..snapshot.clone() + }, + SandboxConfigSnapshot { + provider_attachment_epoch: "epoch-2".into(), + ..snapshot.clone() + }, + SandboxConfigSnapshot { + policy_hash: "policy-2".into(), + ..snapshot.clone() + }, + ]; + for (index, changed) in changes.into_iter().enumerate() { + let session_id = format!("session-{index}"); + let (mut slots, bootstrap) = register_bootstrapped_session( + ®istry, + "sb-1", + &session_id, + with(snapshot.clone()), + ); + answer_bootstrap( + ®istry, + "sb-1", + &session_id, + &bootstrap, + ConfigApplyOutcome::Applied, + ); + assert_eq!( + registry.deliver_config("sb-1", &session_id, with(snapshot.clone())), + DeliveryDisposition::SuppressedUnchanged + ); + assert_eq!( + registry.deliver_config("sb-1", &session_id, with(changed)), + DeliveryDisposition::Queued + ); + assert!(take_update(&mut slots).is_some()); + } + } + + #[tokio::test] + async fn poll_mode_gateway_does_not_enable_apply() { + let state = state_with_sandbox("sb-poll-apply").await; + let mut harness = crate::grpc::test_support::connect_supervisor_stream( + &state, + "sb-poll-apply", + StreamFeatures::Apply, + ) + .await + .expect("apply-capable supervisor connects in poll mode"); + + let Some(gateway_message::Payload::SessionAccepted(accepted)) = + first_gateway_message(&mut harness).await.payload + else { + panic!("expected SessionAccepted"); + }; + assert!(!accepted.config_push_enabled); + assert!(accepted.bootstrap.is_none()); + } + + #[tokio::test] + async fn push_session_is_ready_on_accept() { + use openshell_core::proto::SandboxPhase; + + let sandbox_id = "sb-push-ready"; + let state = push_state_with_sandbox_spec(sandbox_id).await; + let mut harness = crate::grpc::test_support::connect_supervisor_stream( + &state, + sandbox_id, + StreamFeatures::Apply, + ) + .await + .expect("push supervisor connects"); + + let Some(gateway_message::Payload::SessionAccepted(accepted)) = + first_gateway_message(&mut harness).await.payload + else { + panic!("expected SessionAccepted"); + }; + assert!(accepted.config_push_enabled); + assert!(accepted.bootstrap.is_some()); + // The supervisor connects once its workload runs, so the sandbox is + // ready before the bootstrap is answered. + assert_eq!( + stored_sandbox(&state, sandbox_id).await.phase(), + SandboxPhase::Ready as i32 + ); + } + + #[test] + fn unrecorded_policy_loads_keep_success_and_coalesce_newer_results() { + let mut pending = UnrecordedPolicyLoads::default(); + for version in 1..=100 { + pending.keep(PolicyLoadResult { + version, + loaded: true, + error: String::new(), + }); + } + for version in 101..=200 { + pending.keep(PolicyLoadResult { + version, + loaded: false, + error: "rejected".repeat(1024), + }); + } + let results = pending.take(); + assert_eq!(results.len(), 2); + assert_eq!((results[0].version, results[0].loaded), (100, true)); + assert_eq!((results[1].version, results[1].loaded), (200, false)); + assert_eq!(results[1].error.len(), 1024); + assert!(pending.take().is_empty()); + + for (version, loaded) in [(201, true), (202, false), (203, true)] { + pending.keep(PolicyLoadResult { + version, + loaded, + error: String::new(), + }); + } + assert_eq!( + pending.take(), + vec![ + PolicyLoadResult { + version: 202, + loaded: false, + error: String::new() + }, + PolicyLoadResult { + version: 203, + loaded: true, + error: String::new() + } + ] + ); + } + + async fn assert_policy_load_recovery( + results: &[(u32, bool)], + expected_statuses: &[(u32, &str)], + active_version: u32, + ) { + use crate::policy_store::PolicyStoreExt as _; + + let sandbox_id = "sb-unrecorded-success"; + let state = push_state_with_sandbox_spec(sandbox_id).await; + put_sandbox_policy_revision(&state, sandbox_id).await; + let latest = results.iter().map(|(version, _)| *version).max().unwrap(); + for version in 4..=latest { + state + .store + .put_policy_revision( + &format!("policy-{version}"), + sandbox_id, + "default", + i64::from(version), + &SandboxPolicy::default().encode_to_vec(), + &format!("policy-{version}-hash"), + ) + .await + .unwrap(); + } + let snapshot = |version| SandboxConfigSnapshot { + version, + config_revision: 7 + u64::from(version - 3), + policy_hash: format!("policy-{version}-hash"), + ..sandbox_policy_snapshot() + }; + let outcome = |loaded| { + if loaded { + ConfigApplyOutcome::Applied + } else { + ConfigApplyOutcome::FailedRetainedLastKnownGood + } + }; + let (tx, mut rx) = register_apply_session_in_state(&state, sandbox_id); + let session = accepted_session(&state, sandbox_id, tx); + state + .store + .execute_for_test(FAIL_POLICY_RECORD_WRITES) + .await; + for &(version, loaded) in results { + let result = deliver_and_report( + &state, + sandbox_id, + &mut rx, + &snapshot(version), + outcome(loaded), + ); + assert!(handle_config_update_result(&session, &result).await); + } + state.store.execute_for_test(RESTORE_RESULT_WRITES).await; + let &(version, loaded) = results.last().unwrap(); + let mut repeated = deliver_and_report( + &state, + sandbox_id, + &mut rx, + &snapshot(version), + outcome(loaded), + ); + repeated.policy_load = None; + assert!(handle_config_update_result(&session, &repeated).await); + for &(version, expected) in expected_statuses { + let record = state + .store + .get_policy_by_version(sandbox_id, i64::from(version)) + .await + .unwrap() + .unwrap(); + assert_eq!(record.status, expected, "policy {version}"); + } + assert_eq!( + stored_sandbox(&state, sandbox_id) + .await + .current_policy_version(), + active_version + ); + } + + #[tokio::test] + async fn newer_rejection_preserves_unrecorded_success() { + assert_policy_load_recovery(&[(3, true), (4, false)], &[(3, "loaded"), (4, "failed")], 3) + .await; + } + + #[tokio::test] + async fn newer_success_preserves_unrecorded_rejection() { + assert_policy_load_recovery( + &[(3, true), (4, false), (5, true)], + &[(3, "superseded"), (4, "failed"), (5, "loaded")], + 5, + ) + .await; + } + + #[tokio::test] + async fn successful_retry_replaces_unrecorded_rejection_of_same_revision() { + assert_policy_load_recovery( + &[(3, true), (4, false), (4, true)], + &[(3, "superseded"), (4, "loaded")], + 4, + ) + .await; + } + + #[tokio::test] + async fn rejection_of_loaded_revision_preserves_its_unrecorded_success() { + assert_policy_load_recovery( + &[(3, true), (4, true), (4, false)], + &[(3, "superseded"), (4, "failed")], + 4, + ) + .await; + } +} diff --git a/crates/openshell-server/src/config_delivery/task.rs b/crates/openshell-server/src/config_delivery/task.rs new file mode 100644 index 0000000000..0d77b95097 --- /dev/null +++ b/crates/openshell-server/src/config_delivery/task.rs @@ -0,0 +1,525 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! One delivery task per push session. +//! +//! A publication only marks a session for a rebuild, so repeated +//! publications coalesce into one build. The session's task takes the mark +//! after it holds a build permit, so every build reads state at least as new +//! as every publication it covers. +//! +//! Build permits come from one FIFO semaphore sized from the database pool. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use metrics::{Gauge, gauge, histogram}; +use openshell_core::proto::ProviderEnvironmentSnapshot; +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore}; +use tokio::time::Instant; + +/// How often a session rebuilds its configuration, to repair a missed +/// publication or retry an update its supervisor failed to apply. Unchanged +/// configuration the supervisor already acknowledged is not sent again. A +/// session first reconciles one to two intervals after it registers, at a +/// random offset, so reconciliation is a steady trickle rather than a burst +/// and never repeats the bootstrap it just applied. +pub(super) const RECONCILE_INTERVAL: Duration = Duration::from_secs(30); +const MIN_FAILURE_BACKOFF: Duration = Duration::from_secs(1); +const MAX_FAILURE_BACKOFF: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FanoutScope { + AllConnected, + Workspace(String), + /// Sandboxes in `workspace` that attach the named provider. + Provider { + workspace: String, + name: String, + }, +} + +/// A push session as the delivery task sees it. +#[derive(Debug, Clone)] +pub struct Registration { + pub sandbox_id: String, + pub session_id: String, + pub workspace: String, + pub providers: HashSet, + /// The provider environment the bootstrap delivered. + pub provider_environment: Option, + /// [`super::ConfigDelivery::publications`] observed before the bootstrap + /// inputs were read. + pub captured_seq: u64, + /// The bootstrap could not resolve part of the configuration, so the + /// session rebuilds it at once rather than at its first reconciliation. + pub rebuild: bool, +} + +#[derive(Debug)] +pub(super) struct SessionRecord { + pub(super) sandbox_id: String, + pub(super) session_id: String, + workspace: String, + marks: Mutex, + wake: Notify, + /// The last provider environment this session resolved, reused while the + /// sandbox configuration names it. + provider_environment: Mutex>, +} + +#[derive(Debug, Default)] +struct Marks { + dirty: bool, + dirty_since: Option, + dirty_gauge: Option, + /// Attachments read by the last build, for provider-scoped fanouts. + providers: HashSet, + /// A running build may install a new attachment, so provider fanouts + /// include the session until it finishes. + building: bool, + closed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Status { + Closed, + Clean, + Dirty, +} + +impl SessionRecord { + pub(super) fn new(registration: Registration) -> Self { + Self { + sandbox_id: registration.sandbox_id, + session_id: registration.session_id, + workspace: registration.workspace, + marks: Mutex::new(Marks { + providers: registration.providers, + ..Marks::default() + }), + wake: Notify::new(), + provider_environment: Mutex::new(registration.provider_environment), + } + } + + pub(super) fn provider_environment(&self) -> Option { + self.provider_environment + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + pub(super) fn cache_provider_environment(&self, snapshot: ProviderEnvironmentSnapshot) { + *self + .provider_environment + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(snapshot); + } + + /// Delivery is best effort beside the session lifecycle, so a poisoned + /// lock must not turn session teardown into a panic. + fn marks(&self) -> MutexGuard<'_, Marks> { + self.marks.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Wakes the task only when the session becomes dirty, so a waiter keeps + /// its place in the permit queue. + pub(super) fn mark(&self) { + if self.add_mark() { + self.wake.notify_one(); + } + } + + /// Returns whether the session became dirty. + fn add_mark(&self) -> bool { + let mut marks = self.marks(); + if marks.closed || marks.dirty { + return false; + } + marks.dirty = true; + marks.dirty_since = Some(Instant::now()); + marks.dirty_gauge = Some(GaugeGuard::acquire(gauge!(DIRTY_SESSIONS))); + true + } + + pub(super) fn close(&self) { + let mut marks = self.marks(); + marks.closed = true; + marks.dirty = false; + marks.dirty_gauge = None; + drop(marks); + self.wake.notify_one(); + } + + pub(super) fn in_scope(&self, scope: &FanoutScope) -> bool { + match scope { + FanoutScope::AllConnected => true, + FanoutScope::Workspace(workspace) => self.workspace == *workspace, + FanoutScope::Provider { workspace, name } => { + self.workspace == *workspace && { + let marks = self.marks(); + marks.building || marks.providers.contains(name) + } + } + } + } + + fn status(&self) -> Status { + let marks = self.marks(); + if marks.closed { + Status::Closed + } else if marks.dirty { + Status::Dirty + } else { + Status::Clean + } + } + + /// Take the mark once a build permit is held. Returns false when there + /// was none. + fn take(&self) -> bool { + let mut marks = self.marks(); + if !std::mem::take(&mut marks.dirty) { + return false; + } + marks.dirty_gauge = None; + marks.building = true; + if let Some(since) = marks.dirty_since.take() { + histogram!("openshell_supervisor_config_queue_wait_seconds") + .record(since.elapsed().as_secs_f64()); + } + true + } + + fn finish(&self, providers: Option>) { + let mut marks = self.marks(); + marks.building = false; + if let Some(providers) = providers { + marks.providers = providers; + } + } +} + +/// Wait for a build permit while the session is dirty, or return `None` once +/// the session closed. +pub(super) async fn acquire_permit( + builds: &Arc, + record: &SessionRecord, +) -> Option { + loop { + match record.status() { + Status::Closed => return None, + Status::Dirty => tokio::select! { + permit = Arc::clone(builds).acquire_owned() => { + return Some(permit.expect("the build semaphore is never closed")); + } + () = record.wake.notified() => {} + }, + Status::Clean => record.wake.notified().await, + } + } +} + +/// Build and deliver a session's configuration. Returns whether the build +/// failed and the attachments it read. +pub(super) trait Deliver: Sync { + fn deliver( + &self, + record: &SessionRecord, + ) -> impl Future>)> + Send; +} + +/// Run a session's deliveries until it closes, rebuilding its configuration +/// every `reconcile_interval`. +pub(super) async fn run( + record: Arc, + builds: &Arc, + deliver: &impl Deliver, + reconcile_interval: Duration, +) { + let mut failures = 0u32; + let mut next_reconcile = Instant::now() + reconcile_interval + jitter(reconcile_interval); + loop { + loop { + match record.status() { + Status::Closed => return, + Status::Dirty => break, + Status::Clean => {} + } + tokio::select! { + () = record.wake.notified() => {} + () = tokio::time::sleep_until(next_reconcile) => { + next_reconcile = Instant::now() + reconcile_interval; + record.mark(); + } + } + } + let Some(permit) = acquire_permit(builds, &record).await else { + return; + }; + if !record.take() { + continue; + } + let (failed, providers) = deliver.deliver(&record).await; + drop(permit); + record.finish(providers); + if !failed { + failures = 0; + continue; + } + // A new publication or session close ends the backoff early. + let retry_at = Instant::now() + failure_backoff(failures); + loop { + tokio::select! { + () = tokio::time::sleep_until(retry_at) => break, + () = record.wake.notified() => { + if record.status() != Status::Clean { + break; + } + } + } + } + failures = failures.saturating_add(1); + // The task is awake, so the retry needs no wakeup. + record.add_mark(); + } +} + +fn failure_backoff(failures: u32) -> Duration { + MIN_FAILURE_BACKOFF + .saturating_mul(1 << failures.min(5)) + .min(MAX_FAILURE_BACKOFF) +} + +fn jitter(interval: Duration) -> Duration { + let millis = u64::try_from(interval.as_millis()) + .unwrap_or(u64::MAX) + .max(1); + Duration::from_millis(rand::random_range(0..millis)) +} + +const DIRTY_SESSIONS: &str = "openshell_supervisor_config_dirty_sessions"; + +/// One unit of a gauge, released through the same handle when dropped. +#[derive(Debug)] +struct GaugeGuard(Gauge); + +impl GaugeGuard { + fn acquire(gauge: Gauge) -> Self { + gauge.increment(1.0); + Self(gauge) + } +} + +impl Drop for GaugeGuard { + fn drop(&mut self) { + self.0.decrement(1.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + /// Long enough that the reconcile timer never fires in a test that does + /// not ask for it. + const NO_RECONCILE: Duration = Duration::from_hours(24); + + fn record(sandbox_id: &str, workspace: &str, providers: &[&str]) -> Arc { + Arc::new(SessionRecord::new(Registration { + sandbox_id: sandbox_id.to_string(), + session_id: format!("{sandbox_id}-session"), + workspace: workspace.to_string(), + providers: providers.iter().map(ToString::to_string).collect(), + provider_environment: None, + captured_seq: 0, + rebuild: false, + })) + } + + fn builds(permits: usize) -> Arc { + Arc::new(Semaphore::new(permits)) + } + + /// Counts deliveries and fails them while `fail` is set. + #[derive(Default)] + struct Recorder { + fail: AtomicBool, + gate: Option>, + calls: AtomicUsize, + delivered: AtomicUsize, + } + + impl Deliver for Recorder { + async fn deliver(&self, _record: &SessionRecord) -> (bool, Option>) { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(gate) = &self.gate { + gate.acquire().await.unwrap().forget(); + } + self.delivered.fetch_add(1, Ordering::SeqCst); + (self.fail.load(Ordering::SeqCst), None) + } + } + + impl Recorder { + fn delivered(&self) -> usize { + self.delivered.load(Ordering::SeqCst) + } + } + + async fn settle() { + for _ in 0..10 { + tokio::task::yield_now().await; + } + } + + #[test] + fn marks_coalesce_until_taken() { + let record = record("sb", "ws", &[]); + assert!(record.add_mark()); + assert!(!record.add_mark()); + assert!(record.take()); + assert!(!record.take()); + } + + #[test] + fn provider_scope_includes_attached_or_building_sessions() { + let attached = record("a", "ws", &["github"]); + let other = record("b", "ws", &[]); + let scope = FanoutScope::Provider { + workspace: "ws".into(), + name: "github".into(), + }; + assert!(attached.in_scope(&scope)); + assert!(!other.in_scope(&scope)); + assert!(!record("c", "other", &["github"]).in_scope(&scope)); + + other.mark(); + assert!(other.take()); + assert!(other.in_scope(&scope), "a running build may attach it"); + other.finish(Some(HashSet::from(["github".to_string()]))); + assert!(other.in_scope(&scope)); + attached.finish(Some(HashSet::new())); + assert!(!attached.in_scope(&scope)); + } + + #[tokio::test(start_paused = true)] + async fn publications_during_a_build_coalesce_into_one_rebuild() { + let builds = builds(4); + let gate = Arc::new(Semaphore::new(0)); + let recorder = Arc::new(Recorder { + gate: Some(Arc::clone(&gate)), + ..Recorder::default() + }); + let session = record("sb", "ws", &[]); + session.mark(); + let task = tokio::spawn({ + let session = Arc::clone(&session); + let recorder = Arc::clone(&recorder); + async move { run(session, &builds, recorder.as_ref(), NO_RECONCILE).await } + }); + settle().await; + assert_eq!(recorder.calls.load(Ordering::SeqCst), 1); + + for _ in 0..5 { + session.mark(); + } + gate.add_permits(3); + settle().await; + assert_eq!(recorder.delivered(), 2); + + session.close(); + task.await.unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn failed_builds_retry_with_backoff_until_they_succeed() { + let builds = builds(4); + let recorder = Arc::new(Recorder::default()); + recorder.fail.store(true, Ordering::SeqCst); + let session = record("sb", "ws", &[]); + session.mark(); + let task = tokio::spawn({ + let session = Arc::clone(&session); + let recorder = Arc::clone(&recorder); + async move { run(session, &builds, recorder.as_ref(), NO_RECONCILE).await } + }); + settle().await; + assert_eq!(recorder.calls.load(Ordering::SeqCst), 1); + + tokio::time::advance(Duration::from_millis(999)).await; + settle().await; + assert_eq!(recorder.calls.load(Ordering::SeqCst), 1, "backs off 1 s"); + tokio::time::advance(Duration::from_millis(1)).await; + settle().await; + assert_eq!(recorder.calls.load(Ordering::SeqCst), 2); + + recorder.fail.store(false, Ordering::SeqCst); + tokio::time::advance(Duration::from_secs(2)).await; + settle().await; + assert_eq!(recorder.calls.load(Ordering::SeqCst), 3, "then 2 s"); + tokio::time::advance(Duration::from_mins(1)).await; + settle().await; + assert_eq!(recorder.calls.load(Ordering::SeqCst), 3, "then stops"); + + session.close(); + task.await.unwrap(); + } + + #[test] + fn failure_backoff_doubles_up_to_thirty_seconds() { + let backoff: Vec<_> = (0..7).map(|n| failure_backoff(n).as_secs()).collect(); + assert_eq!(backoff, [1, 2, 4, 8, 16, 30, 30]); + } + + #[tokio::test(start_paused = true)] + async fn idle_sessions_reconcile_their_configuration() { + let builds = builds(4); + let recorder = Arc::new(Recorder::default()); + let session = record("sb", "ws", &[]); + let task = tokio::spawn({ + let session = Arc::clone(&session); + let recorder = Arc::clone(&recorder); + async move { run(session, &builds, recorder.as_ref(), RECONCILE_INTERVAL).await } + }); + settle().await; + + tokio::time::advance(RECONCILE_INTERVAL).await; + settle().await; + assert_eq!(recorder.delivered(), 0, "not right after bootstrap"); + tokio::time::advance(RECONCILE_INTERVAL).await; + settle().await; + assert_eq!(recorder.delivered(), 1); + tokio::time::advance(RECONCILE_INTERVAL).await; + settle().await; + assert_eq!(recorder.delivered(), 2); + + session.close(); + task.await.unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn closing_a_session_ends_its_task_and_drops_its_marks() { + let builds = builds(1); + let _held = Arc::clone(&builds).acquire_owned().await.unwrap(); + let recorder = Arc::new(Recorder::default()); + let session = record("sb", "ws", &[]); + session.mark(); + let task = tokio::spawn({ + let session = Arc::clone(&session); + let builds = Arc::clone(&builds); + let recorder = Arc::clone(&recorder); + async move { run(session, &builds, recorder.as_ref(), NO_RECONCILE).await } + }); + settle().await; + + session.close(); + task.await.unwrap(); + assert_eq!(recorder.delivered(), 0); + session.mark(); + assert!(!session.take()); + } +} diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index bce0500a67..cba890bff2 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -147,6 +147,9 @@ pub struct GatewayFileSection { /// Security posture when a sandbox rejects a candidate policy generation. #[serde(default)] pub policy_validation_failure_mode: Option, + /// Supervisor configuration delivery; omitted uses the release default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_delivery_mode: Option, // ── Service routing ────────────────────────────────────────────────── /// Subject Alternative Names configured on the gateway server certificate. @@ -1398,6 +1401,29 @@ policy_validation_failure_mode = "keep_old" assert!(error.to_string().contains("policy_validation_failure_mode")); } + #[test] + fn parses_delivery_mode_and_rejects_unknown_value() { + let tmp = write_tmp( + r#" +[openshell.gateway] +config_delivery_mode = "push" +"#, + ); + let file = load(tmp.path()).unwrap(); + assert_eq!( + file.openshell.gateway.config_delivery_mode, + Some(openshell_core::config::ConfigDeliveryMode::Push) + ); + + let tmp = write_tmp( + r#" +[openshell.gateway] +config_delivery_mode = "enabled" +"#, + ); + assert!(load(tmp.path()).is_err()); + } + #[test] fn parses_gateway_auth_config() { let toml = r" diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index 148acaa176..17cfe96c28 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -95,6 +95,17 @@ pub trait CredentialDriver: std::fmt::Debug + Send + Sync { #[cfg(test)] fn fail_next_delete(&self) {} + #[cfg(test)] + #[allow(dead_code)] + fn gate_next_resolve( + &self, + ) -> Option<( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + )> { + None + } + #[cfg(test)] fn gate_next_store( &self, @@ -295,6 +306,20 @@ impl CredentialRuntime { .expect("test credential driver supports store gating") } + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn gate_next_resolve( + &self, + ) -> ( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + ) { + self.drivers + .get(&self.registry.storage_owner_name()) + .and_then(|driver| driver.gate_next_resolve()) + .expect("test credential driver supports resolve gating") + } + pub async fn store_provider_credentials( &self, provider_name: &str, @@ -625,6 +650,12 @@ impl CredentialRuntime { .await } + #[tracing::instrument( + name = "credentials", + level = "debug", + skip_all, + fields(otel.name = "credentials.resolve_provider_handles") + )] pub async fn resolve_provider_handles( &self, provider: &Provider, @@ -1853,6 +1884,13 @@ struct TestStaticCredentialDriver { fail_next_store: std::sync::atomic::AtomicBool, fail_next_delete: std::sync::atomic::AtomicBool, #[cfg(test)] + resolve_gate: std::sync::Mutex< + Option<( + tokio::sync::oneshot::Sender<()>, + tokio::sync::oneshot::Receiver<()>, + )>, + >, + #[cfg(test)] store_gate: std::sync::Mutex< Option<( tokio::sync::oneshot::Sender<()>, @@ -1871,6 +1909,8 @@ impl TestStaticCredentialDriver { fail_next_store: std::sync::atomic::AtomicBool::new(false), fail_next_delete: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] + resolve_gate: std::sync::Mutex::new(None), + #[cfg(test)] store_gate: std::sync::Mutex::new(None), } } @@ -1948,6 +1988,17 @@ impl CredentialDriver for TestStaticCredentialDriver { requests: Vec, ) -> Result, Status> { let mut responses = Vec::with_capacity(requests.len()); + #[cfg(test)] + let gate = self + .resolve_gate + .lock() + .ok() + .and_then(|mut gate| gate.take()); + #[cfg(test)] + if let Some((hit, release)) = gate { + let _ = hit.send(()); + let _ = release.await; + } for request in requests { let handle = Self::handle_from_request(&request.request_id, request.handle)?; let value = self @@ -1978,6 +2029,19 @@ impl CredentialDriver for TestStaticCredentialDriver { .store(true, std::sync::atomic::Ordering::SeqCst); } + #[cfg(test)] + fn gate_next_resolve( + &self, + ) -> Option<( + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender<()>, + )> { + let (hit_tx, hit_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *self.resolve_gate.lock().ok()? = Some((hit_tx, release_rx)); + Some((hit_rx, release_tx)) + } + #[cfg(test)] fn fail_next_delete(&self) { self.fail_next_delete diff --git a/crates/openshell-server/src/gateway_metrics.rs b/crates/openshell-server/src/gateway_metrics.rs index 16734df8b8..7e11a70472 100644 --- a/crates/openshell-server/src/gateway_metrics.rs +++ b/crates/openshell-server/src/gateway_metrics.rs @@ -38,6 +38,10 @@ pub const MUTATION_LOCK_ERRORS_TOTAL: &str = "openshell_server_mutation_lock_err // Histograms (explicit buckets, see BUCKETED_HISTOGRAMS) pub const RELAY_CLAIM_DURATION_SECONDS: &str = "openshell_server_relay_claim_duration_seconds"; pub const PEER_REQUEST_DURATION_SECONDS: &str = "openshell_server_peer_request_duration_seconds"; +pub const SUPERVISOR_CONFIG_BUILD_DURATION_SECONDS: &str = + "openshell_supervisor_config_build_duration_seconds"; +pub const SUPERVISOR_CONFIG_APPLY_DURATION_SECONDS: &str = + "openshell_supervisor_config_apply_duration_seconds"; pub const MUTATION_LOCK_WAIT_SECONDS: &str = "openshell_server_mutation_lock_wait_seconds"; pub const MUTATION_LOCK_HOLD_SECONDS: &str = "openshell_server_mutation_lock_hold_seconds"; @@ -57,9 +61,11 @@ const LATENCY_BUCKETS_SECONDS: [f64; 14] = [ /// Only these names render as Prometheus histograms. Every existing `*_duration_seconds` metric /// keeps its summary format, so current dashboards are unaffected. -const BUCKETED_HISTOGRAMS: [&str; 4] = [ +const BUCKETED_HISTOGRAMS: [&str; 6] = [ RELAY_CLAIM_DURATION_SECONDS, PEER_REQUEST_DURATION_SECONDS, + SUPERVISOR_CONFIG_BUILD_DURATION_SECONDS, + SUPERVISOR_CONFIG_APPLY_DURATION_SECONDS, MUTATION_LOCK_WAIT_SECONDS, MUTATION_LOCK_HOLD_SECONDS, ]; @@ -298,6 +304,16 @@ pub fn describe_and_initialize(relay: RelayCapacity, mutation_lock_connections: Unit::Seconds, "Latency of outbound requests to the owning replica. For relays, until the owner's supervisor claimed the relay." ); + describe_histogram!( + SUPERVISOR_CONFIG_BUILD_DURATION_SECONDS, + Unit::Seconds, + "Time to build one pushed supervisor configuration, including store reads and credential resolution." + ); + describe_histogram!( + SUPERVISOR_CONFIG_APPLY_DURATION_SECONDS, + Unit::Seconds, + "Time from queuing a pushed configuration update on the supervisor session to receiving its apply result." + ); describe_histogram!( MUTATION_LOCK_WAIT_SECONDS, Unit::Seconds, @@ -733,6 +749,16 @@ mod tests { .record(sample); histogram!(MUTATION_LOCK_WAIT_SECONDS, LABEL_SCOPE => "sandbox").record(sample); histogram!(MUTATION_LOCK_HOLD_SECONDS, LABEL_SCOPE => "sandbox").record(sample); + histogram!( + SUPERVISOR_CONFIG_BUILD_DURATION_SECONDS, + "outcome" => "ok" + ) + .record(sample); + histogram!( + SUPERVISOR_CONFIG_APPLY_DURATION_SECONDS, + "outcome" => "CONFIG_APPLY_OUTCOME_APPLIED" + ) + .record(sample); histogram!( "openshell_server_grpc_request_duration_seconds", "method" => "ListSandboxes", diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index eec85c3c5f..35f16c0441 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -51,7 +51,8 @@ use openshell_core::proto::{ ListSandboxProvidersResponse, ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, - ListWorkspacesResponse, MemoryResourceCapabilities, NegotiatedExtensionInfo, PeerRelayFrame, + ListWorkspacesResponse, MemoryResourceCapabilities, NegotiatedExtensionInfo, + PeerNotifyConfigUpdateRequest, PeerNotifyConfigUpdateResponse, PeerRelayFrame, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, @@ -800,7 +801,11 @@ impl OpenShell for OpenShellService { &self, request: Request>, ) -> Result, Status> { - crate::supervisor_session::handle_connect_supervisor(&self.state, request).await + Box::pin(crate::supervisor_session::handle_connect_supervisor( + &self.state, + request, + )) + .await } async fn report_main_process_exit( @@ -908,6 +913,13 @@ impl OpenShell for OpenShellService { ) -> Result, Status> { provider_readiness::handle_peer_get_sandbox_provider_status(&self.state, request).await } + + async fn peer_notify_config_update( + &self, + request: Request, + ) -> Result, Status> { + crate::config_delivery::handle_peer_notify_config_update(&self.state, request) + } } fn public_extension_info(extension: &NegotiatedExtension) -> NegotiatedExtensionInfo { @@ -960,7 +972,9 @@ pub mod test_support { use crate::ServerState; use crate::auth::identity::{Identity, IdentityProvider}; - use crate::auth::principal::{Principal, UserPrincipal}; + use crate::auth::principal::{ + Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, + }; use crate::compute::{ NoopTestDriver, new_test_runtime, new_test_runtime_for_driver, new_test_runtime_with_driver, }; @@ -970,8 +984,128 @@ pub mod test_support { use crate::supervisor_session::SupervisorSessionRegistry; use crate::tracing_bus::TracingLogBus; use openshell_core::Config; + use openshell_core::proto::open_shell_client::OpenShellClient; + use openshell_core::proto::open_shell_server::OpenShellServer; + use openshell_core::proto::{ + GatewayMessage, SupervisorHello, SupervisorMessage, supervisor_message, + }; + use tokio::sync::mpsc; + use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream}; use tonic::Request; + /// A live `ConnectSupervisor` stream against an in-process gateway. + pub struct SupervisorStreamHarness { + server: tokio::task::JoinHandle>, + /// Held so the supervisor side of the stream stays open. + pub outbound: mpsc::Sender, + pub inbound: tonic::Streaming, + } + + impl Drop for SupervisorStreamHarness { + fn drop(&mut self) { + self.server.abort(); + } + } + + /// Switch a test state to push configuration delivery. + pub fn enable_push_delivery(state: &mut Arc) { + Arc::get_mut(state) + .expect("test state is not shared yet") + .config + .config_delivery_mode = openshell_core::config::ConfigDeliveryMode::Push; + } + + /// Configuration stream features advertised by a test supervisor hello. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum StreamFeatures { + /// Predates streamed configuration and keeps polling. + Legacy, + /// Applies and acknowledges streamed configuration. + Apply, + } + + /// Serve `state` on loopback and open a supervisor stream whose hello + /// advertises the given configuration stream features. Returns the gRPC + /// status when the gateway rejects the stream before accepting it. + pub async fn connect_supervisor_stream( + state: &Arc, + sandbox_id: &str, + features: StreamFeatures, + ) -> Result { + connect_supervisor_stream_with_epoch(state, sandbox_id, features, 0).await + } + + /// Model a stock supervisor reconnect, which advances its connection + /// epoch. + pub async fn reconnect_supervisor_stream( + state: &Arc, + sandbox_id: &str, + features: StreamFeatures, + ) -> Result { + connect_supervisor_stream_with_epoch(state, sandbox_id, features, 1).await + } + + async fn connect_supervisor_stream_with_epoch( + state: &Arc, + sandbox_id: &str, + features: StreamFeatures, + connection_epoch: u64, + ) -> Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let principal = Principal::Sandbox(SandboxPrincipal { + sandbox_id: sandbox_id.to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:test".to_string(), + }, + trust_domain: Some("openshell".to_string()), + }); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(OpenShellServer::with_interceptor( + super::OpenShellService::new(Arc::clone(state)), + move |mut request: Request<()>| { + request.extensions_mut().insert(principal.clone()); + Ok(request) + }, + )) + .serve_with_incoming(TcpListenerStream::new(listener)), + ); + let mut client = OpenShellClient::connect(format!("http://{address}")) + .await + .unwrap() + .max_decoding_message_size( + openshell_core::grpc_client::MAX_SUPERVISOR_STREAM_MESSAGE_BYTES, + ); + let (outbound, rx) = mpsc::channel(4); + outbound + .send(SupervisorMessage { + payload: Some(supervisor_message::Payload::Hello(SupervisorHello { + sandbox_id: sandbox_id.into(), + instance_id: "instance".into(), + supports_config_push: features == StreamFeatures::Apply, + connection_epoch, + supports_provider_readiness: false, + redirected: false, + supports_session_redirect: false, + })), + }) + .await + .unwrap(); + let inbound = match client.connect_supervisor(ReceiverStream::new(rx)).await { + Ok(response) => response.into_inner(), + Err(status) => { + server.abort(); + return Err(status); + } + }; + Ok(SupervisorStreamHarness { + server, + outbound, + inbound, + }) + } + /// Wrap a proto message in a `Request` with a dev principal injected. /// /// The dev principal matches the unauthenticated dev user: subject @@ -1016,6 +1150,7 @@ pub mod test_support { crate::provider_profile_sources::ProviderProfileSources::from_config( &[openshell_core::GatewayProviderProfileSourceConfig::User], None, + None, ) .expect("user-only provider profile source configuration should be valid") } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index e9c22bef6f..4a6f32f1aa 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -10,10 +10,12 @@ #![allow(clippy::cast_precision_loss)] // f64->f32 for confidence scores #![allow(clippy::items_after_statements)] // DB_PORTS const inside function +mod config_inputs; mod endpoint_status; mod provisioning_clock; #[cfg(test)] mod token_grant_owners_tests; +pub use config_inputs::{SandboxConfigInputs, load_sandbox_config_inputs}; pub use provisioning_clock::configuration_change; pub(super) use endpoint_status::{ @@ -27,12 +29,14 @@ pub use endpoint_status::{ use crate::ServerState; use crate::auth::principal::Principal; -use crate::auth::workspace_authz::{MinWorkspaceRole, require_platform_admin}; +use crate::auth::workspace_authz::{ + MinWorkspaceRole, require_platform_admin, selected_workspace_name, +}; use crate::compute::MutationScope; use crate::pagination::Pagination; +use crate::persistence::ObjectType; use crate::persistence::{ - DraftChunkRecord, ObjectId, ObjectListQuery, ObjectName, ObjectType, ObjectWorkspace, - PolicyRecord, Store, + DraftChunkRecord, ObjectId, ObjectListQuery, ObjectName, ObjectWorkspace, PolicyRecord, Store, }; use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; use crate::provider_profile_sources::EffectiveProviderProfileCatalog; @@ -41,8 +45,12 @@ use crate::provider_profile_sources::ProviderProfileSources; use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState; #[cfg(test)] use crate::storage_proto::StoredProviderProfile; +use metrics::counter; use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; -use openshell_core::policy_identity::{canonical_rule_bytes, deterministic_policy_hash}; +use openshell_core::policy_identity::canonical_rule_bytes; +pub use openshell_core::policy_identity::deterministic_policy_hash; +#[cfg(test)] +use openshell_core::proto::StaticCredentialBinding; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; use openshell_core::proto::{ @@ -54,14 +62,17 @@ use openshell_core::proto::{ GetGatewayConfigRequest, GetGatewayConfigResponse, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, - GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, L7RuleTarget as ProtoL7RuleTarget, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, - PolicyChunk, PolicyMergeOperation, PolicySource, PolicyStatus, ProviderReadinessReason, + PolicyChunk, PolicyMergeOperation, PolicySource, PolicyStatus, ProviderEnvironmentSnapshot, + ProviderEnvironmentValue, ProviderEnvironmentValueClassification, ProviderReadinessReason, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, - SandboxLogLine, SandboxPolicyRevision, SettingScope, SettingValue, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse, - UpdateConfigRequest, UpdateConfigResponse, + SandboxConfigSnapshot, SandboxLogLine, SandboxPolicyRevision, SettingScope, SettingValue, + SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, +}; +use openshell_core::proto::{ + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, }; use openshell_core::proto::{ L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, @@ -71,7 +82,6 @@ use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome, }; use openshell_core::{ - GetResourceVersion, endpoint_path::EndpointPathPattern, host_pattern::{host_matches, host_patterns_overlap}, settings::{self, SettingValueKind}, @@ -98,7 +108,7 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::IpAddr; use std::sync::Arc; -use tonic::{Request, Response, Status}; +use tonic::{Code, Request, Response, Status}; use tracing::{debug, info, warn}; use super::validation::{ @@ -130,6 +140,11 @@ const STORED_POLICY_SOURCE_GLOBAL: &str = "global policy setting"; /// Maximum number of optimistic retry attempts for policy version conflicts. const MERGE_RETRY_LIMIT: usize = 5; +/// Push a committed sandbox-scoped change to the sandbox's supervisor. +fn publish_sandbox_config_update(state: &Arc, sandbox_id: &str) { + crate::config_delivery::publish_sandbox(state, sandbox_id); +} + // Private wire-only compatibility types for policy history written before // 0.1.0. Public generated bindings intentionally reserve NetworkBinary tag 2, // but stored policies still need its former advisor-provenance value migrated @@ -1883,6 +1898,7 @@ async fn auto_approve_chunk( return Err(status); } }; + crate::config_delivery::publish_sandbox(state, sandbox_id); let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -1972,7 +1988,35 @@ async fn current_effective_policy_from_records( records: &[super::provider::ProviderEnvironmentRecord], ) -> Result<(ProtoSandboxPolicy, PolicySource), Status> { let global_settings = load_global_settings(state.store.as_ref()).await?; - if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { + let global_policy = decode_policy_from_global_settings(&global_settings)?; + let latest_policy = if global_policy.is_some() { + None + } else { + state + .store + .get_latest_policy(sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))? + }; + effective_policy_from_captured_inputs( + catalog, + records, + global_policy, + latest_policy.as_ref(), + sandbox, + ) +} + +/// Derive the effective policy from already loaded sources without further +/// store reads. A missing policy composes provider layers onto an empty one. +fn effective_policy_from_captured_inputs( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], + global_policy: Option, + latest_policy: Option<&PolicyRecord>, + sandbox: &Sandbox, +) -> Result<(ProtoSandboxPolicy, PolicySource), Status> { + if let Some(global_policy) = global_policy { // A global policy is the complete effective policy. Dormant sandbox // history and specs may predate the current schema, but they must not // prevent the valid global policy from being served. @@ -1984,13 +2028,8 @@ async fn current_effective_policy_from_records( .map(|policy| (policy, PolicySource::Global)); } - let policy = if let Some(record) = state - .store - .get_latest_policy(sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))? - { - canonical_policy_record_identity(&record)?.0 + let policy = if let Some(record) = latest_policy { + canonical_policy_record_identity(record)?.0 } else { match sandbox.spec.as_ref().and_then(|spec| spec.policy.clone()) { Some(policy) => { @@ -2714,7 +2753,7 @@ async fn resolve_sandbox_by_name_for_principal( }; crate::auth::guard::ensure_sandbox_scope(principal, sandbox.object_id()).map_err( |status| { - if status.code() == tonic::Code::PermissionDenied { + if status.code() == Code::PermissionDenied { Status::permission_denied("sandbox not found or not owned by caller") } else { status @@ -2747,22 +2786,13 @@ pub(super) async fn handle_get_sandbox_config( let sandbox_name = request.get_ref().name.clone(); let workspace = match (&principal, request.get_ref().workspace_scope.as_ref()) { (Principal::Sandbox(_), None) => String::new(), - (_, selector) => { - crate::auth::workspace_authz::selected_workspace_name(selector)?.to_string() - } + (_, selector) => selected_workspace_name(selector)?.to_string(), }; let result = handle_get_sandbox_config_inner(state, request).await; match result { Err(error) - if matches!(principal, Principal::Sandbox(_)) - && matches!( - error.code(), - tonic::Code::FailedPrecondition | tonic::Code::InvalidArgument - ) => + if matches!(principal, Principal::Sandbox(_)) && is_configuration_rejection(&error) => { - // A malformed stored candidate must not prevent a supervisor - // from registering its startup fence and waiting for repair. - // Do not expose parser payloads or copy malformed policy history. let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( state, &principal, @@ -2771,22 +2801,51 @@ pub(super) async fn handle_get_sandbox_config( MinWorkspaceRole::User, ) .await?; - Ok(Response::new(GetSandboxConfigResponse { - configuration_admitted: false, - configuration_error: configuration_failure_diagnostic(&error).to_string(), - configuration_instance_id: sandbox - .status - .as_ref() - .and_then(|status| status.configuration_admission.as_ref()) - .map_or_else(String::new, |admission| admission.instance_id.clone()), - workspace: sandbox.object_workspace().to_string(), - ..Default::default() - })) + Ok(Response::new(sandbox_config_response( + unadmitted_sandbox_config(state, &sandbox, &error), + ))) } result => result, } } +/// Whether a configuration build failed on the stored configuration itself, +/// rather than on a transient store or credential error. +pub fn is_configuration_rejection(error: &Status) -> bool { + matches!( + error.code(), + Code::FailedPrecondition | Code::InvalidArgument + ) +} + +/// The configuration a supervisor receives while its stored configuration is +/// rejected: not admitted, so the supervisor applies its configured failure mode. +/// A malformed stored candidate must not prevent a supervisor from waiting +/// for that repair. Do not expose parser payloads or copy malformed policy +/// history. +pub fn unadmitted_sandbox_config( + state: &ServerState, + sandbox: &Sandbox, + error: &Status, +) -> SandboxConfigSnapshot { + SandboxConfigSnapshot { + configuration_admitted: false, + configuration_error: configuration_failure_diagnostic(error).to_string(), + configuration_instance_id: sandbox + .status + .as_ref() + .and_then(|status| status.configuration_admission.as_ref()) + .map_or_else(String::new, |admission| admission.instance_id.clone()), + workspace: sandbox.object_workspace().to_string(), + policy_validation_failure_mode: state + .config + .policy_validation_failure_mode + .as_str() + .to_string(), + ..Default::default() + } +} + async fn handle_get_sandbox_config_inner( state: &Arc, request: Request, @@ -2795,7 +2854,7 @@ async fn handle_get_sandbox_config_inner( let req = request.into_inner(); let workspace = match &principal { Principal::Sandbox(_) if req.workspace_scope.is_none() => "", - _ => crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + _ => selected_workspace_name(req.workspace_scope.as_ref())?, }; let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( state, @@ -2814,124 +2873,89 @@ pub(super) async fn load_sandbox_config( state: &Arc, sandbox: &Sandbox, ) -> Result { + Ok(sandbox_config_response( + build_sandbox_config_snapshot(state, sandbox).await?, + )) +} + +/// Build the complete effective configuration for one persisted sandbox. +/// +/// This is a read-only projection shared by polling and stream delivery. +pub async fn build_sandbox_config_snapshot( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + let inputs = load_sandbox_config_inputs(state, sandbox.clone()).await?; + 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. +pub async fn build_sandbox_config_snapshot_from_inputs( + state: &ServerState, + inputs: &SandboxConfigInputs, +) -> Result { + let sandbox = &inputs.sandbox; let sandbox_id = sandbox.object_id().to_string(); let workspace = sandbox.object_workspace().to_string(); - let sandbox_provider_names = sandbox - .spec - .as_ref() - .map(|spec| spec.providers.clone()) - .unwrap_or_default(); - let provider_profile_catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), &workspace) - .await?; + let provider_profile_catalog = &inputs.catalog; + let provider_records = &inputs.provider_records; + let global_settings = &inputs.global_settings; - let global_settings = load_global_settings(state.store.as_ref()).await?; - let global_policy = decode_policy_from_global_settings(&global_settings)?; + let global_policy = decode_policy_from_global_settings(global_settings)?; let mut global_policy_version: u32 = 0; - // Try to get the latest policy from the policy history table. Under a - // global override, only the sandbox version metadata is observed; the - // dormant payload is neither decoded nor validated. - let latest = state - .store - .get_latest_policy(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?; - - let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) = - global_policy - { - let version = latest - .as_ref() - .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 { - // Lazy backfill: no policy history exists yet. - let spec = sandbox - .spec - .as_ref() - .ok_or_else(|| Status::internal("sandbox has no spec"))?; - - match spec.policy.clone() { - None => { - debug!( - sandbox_id = %sandbox_id, - "GetSandboxConfig: no policy configured, returning empty response" - ); - (None, 0, String::new(), PolicySource::Sandbox) - } - Some(spec_policy) => { - // Stored specs may predate the current schema. Validate before - // creating policy history so malformed state is never copied or - // marked loaded, and hash the canonical representation. - let spec_policy = validate_and_canonicalize_stored_policy( - spec_policy, - STORED_POLICY_SOURCE_SPEC, - )?; - let hash = deterministic_policy_hash(&spec_policy); - let payload = spec_policy.encode_to_vec(); - let policy_id = uuid::Uuid::new_v4().to_string(); - - if let Err(e) = state - .store - .put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash) - .await - { - warn!( - sandbox_id = %sandbox_id, - error = %e, - "Failed to backfill policy version 1" - ); - } else if let Err(e) = state - .store - .update_policy_status(&sandbox_id, 1, "loaded", None, None) - .await - { - warn!( - sandbox_id = %sandbox_id, - error = %e, - "Failed to mark backfilled policy as loaded" - ); + // 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) } - - info!( - sandbox_id = %sandbox_id, - "GetSandboxConfig served from spec (backfilled version 1)" - ); - - (Some(spec_policy), 1, hash, PolicySource::Sandbox) } - } - }; + }; - let global_settings = load_global_settings(state.store.as_ref()).await?; let sandbox_settings = load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; - let provider_records = super::provider::load_provider_environment_records( - state.store.as_ref(), - &workspace, - &sandbox_provider_names, - ) - .await?; let mut provider_policy_context = - provider_policy_context_from_records(&provider_profile_catalog, &provider_records); + provider_policy_context_from_records(provider_profile_catalog, provider_records); if matches!(policy_source, PolicySource::Global) && let Ok(Some(global_rev)) = state @@ -3003,7 +3027,7 @@ pub(super) async fn load_sandbox_config( })?; } - let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; + let settings = merge_effective_settings(global_settings, &sandbox_settings)?; let supervisor_middleware_services = state.middleware_registry.required_services(policy.as_ref()); let config_revision = compute_config_revision_with_validation_mode( @@ -3016,22 +3040,22 @@ pub(super) async fn load_sandbox_config( ); if let Some(policy) = policy.as_ref() { validate_policy_credential_binding_context( - &provider_profile_catalog, - &provider_records, + provider_profile_catalog, + provider_records, policy, &policy_credential_bindings, )?; } let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( - &provider_profile_catalog, - &provider_records, + provider_profile_catalog, + provider_records, &policy_credential_bindings, policy .as_ref() .filter(|_| matches!(policy_source, PolicySource::Global)), )?; - Ok(GetSandboxConfigResponse { + Ok(SandboxConfigSnapshot { configuration_instance_id: sandbox .status .as_ref() @@ -3044,6 +3068,7 @@ pub(super) async fn load_sandbox_config( policy_hash, settings, config_revision, + settings_revision: sandbox_settings.revision, policy_source: policy_source.into(), global_policy_version, provider_env_revision, @@ -3063,6 +3088,144 @@ pub(super) async fn load_sandbox_config( }) } +fn sandbox_config_response(snapshot: SandboxConfigSnapshot) -> GetSandboxConfigResponse { + GetSandboxConfigResponse { + policy: snapshot.policy, + version: snapshot.version, + policy_hash: snapshot.policy_hash, + settings: snapshot.settings, + config_revision: snapshot.config_revision, + settings_revision: snapshot.settings_revision, + policy_source: snapshot.policy_source, + global_policy_version: snapshot.global_policy_version, + provider_env_revision: snapshot.provider_env_revision, + supervisor_middleware_services: snapshot.supervisor_middleware_services, + workspace: snapshot.workspace, + policy_validation_failure_mode: snapshot.policy_validation_failure_mode, + extension_authentication_enabled: snapshot.extension_authentication_enabled, + provider_attachment_epoch: snapshot.provider_attachment_epoch, + configuration_admitted: snapshot.configuration_admitted, + configuration_error: snapshot.configuration_error, + configuration_instance_id: snapshot.configuration_instance_id, + } +} + +#[derive(Clone, Copy)] +pub enum InitialPolicyHistoryStatus { + Pending, + Loaded, +} + +impl InitialPolicyHistoryStatus { + fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Loaded => "loaded", + } + } +} + +/// Insert the version-one policy baseline if this sandbox still has no policy +/// history. This never modifies an existing revision or apply result. +/// +/// Returns `true` when a baseline was written. Stored policies that fail the +/// current validation rules are rejected with `FailedPrecondition`. +pub async fn initialize_policy_history( + store: &Store, + sandbox: &Sandbox, + status: InitialPolicyHistoryStatus, +) -> Result { + let Some(policy) = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()) else { + return Ok(false); + }; + if store + .get_latest_policy(sandbox.object_id()) + .await + .map_err(|error| Status::internal(format!("read policy history failed: {error}")))? + .is_some() + { + return Ok(false); + } + let policy = + validate_and_canonicalize_stored_policy(policy.clone(), STORED_POLICY_SOURCE_SPEC)?; + store + .put_initial_policy_revision( + &PolicyRecord { + id: uuid::Uuid::new_v4().to_string(), + sandbox_id: sandbox.object_id().to_string(), + version: 1, + policy_payload: policy.encode_to_vec(), + policy_hash: deterministic_policy_hash(&policy), + status: status.as_str().to_string(), + load_error: None, + created_at_ms: current_time_ms(), + loaded_at_ms: None, + provenance: HashMap::new(), + }, + sandbox.object_workspace(), + ) + .await + .map_err(|error| Status::internal(format!("initialize policy history failed: {error}")))?; + Ok(true) +} + +/// Create policy-history baselines for sandboxes written by older gateways. +/// +/// Snapshot reads stay pure once this startup repair has completed. A stored +/// policy that no longer passes validation is skipped rather than blocking +/// gateway startup; that sandbox keeps the pre-repair behavior where its own +/// configuration reads report the validation failure. Store errors remain +/// fatal. +pub async fn backfill_legacy_policy_history(state: &Arc) -> Result<(), Status> { + const PAGE_SIZE: u32 = 1000; + let mut cursor: Option = None; + let mut repaired = 0_usize; + let mut skipped = 0_usize; + loop { + let page = state + .store + .list_message_page::( + ObjectListQuery::AllWorkspaces, + cursor.as_ref(), + PAGE_SIZE, + ) + .await + .map_err(|error| { + Status::internal(format!("list sandboxes for policy repair failed: {error}")) + })?; + for sandbox in page.messages { + match initialize_policy_history( + state.store.as_ref(), + &sandbox, + InitialPolicyHistoryStatus::Loaded, + ) + .await + { + Ok(true) => repaired += 1, + Ok(false) => {} + Err(status) if status.code() == Code::FailedPrecondition => { + skipped += 1; + warn!( + sandbox_id = %sandbox.object_id(), + workspace = %sandbox.object_workspace(), + error = %status.message(), + "skipping policy history repair for invalid stored policy" + ); + } + Err(status) => return Err(status), + } + } + let Some(next_cursor) = page.next_cursor else { + break; + }; + cursor = Some(next_cursor); + } + if repaired > 0 || skipped > 0 { + info!(repaired, skipped, "legacy policy history repair complete"); + } + Ok(()) +} + #[cfg(test)] async fn compute_provider_env_revision( store: &Store, @@ -3370,7 +3533,7 @@ fn provider_policy_context_from_records( continue; } - let rule_name = openshell_policy::provider_rule_name(provider.object_name()); + let rule_name = openshell_policy::provider_rule_name(name); let mut rule = profile.network_policy_rule(&rule_name); let owners = openshell_core::policy_identity::provider_token_grant_owners(&record.object_id, &rule); @@ -3645,52 +3808,108 @@ pub(super) async fn load_sandbox_provider_environment( supports_static_credential_bindings: bool, supports_stable_placeholder_environment_keys: bool, ) -> Result { - let sandbox_id = sandbox.object_id().to_string(); - let workspace = sandbox.object_workspace().to_string(); + Ok(provider_environment_response( + build_provider_environment_snapshot( + state, + sandbox, + supports_static_credential_bindings, + supports_stable_placeholder_environment_keys, + ) + .await?, + )) +} +/// Build the complete provider environment for one persisted sandbox. +pub async fn build_provider_environment_snapshot( + state: &ServerState, + sandbox: &Sandbox, + supports_static_credential_bindings: bool, + supports_stable_placeholder_environment_keys: bool, +) -> Result { + let inputs = load_sandbox_config_inputs(state, sandbox.clone()).await?; + build_provider_environment_snapshot_from_inputs( + state, + &inputs, + supports_static_credential_bindings, + supports_stable_placeholder_environment_keys, + ) + .await +} + +/// What a provider environment is built for, settled before any credential +/// is resolved. +struct ProviderEnvironmentTarget<'a> { + spec: &'a openshell_core::proto::SandboxSpec, + effective_policy: ProtoSandboxPolicy, + policy_source: PolicySource, + policy_credential_bindings: HashMap>, + provider_env_revision: u64, +} + +fn provider_environment_target( + inputs: &SandboxConfigInputs, +) -> Result, Status> { + let sandbox = &inputs.sandbox; let spec = sandbox .spec .as_ref() .ok_or_else(|| Status::internal("sandbox has no spec"))?; - - let provider_names = spec.providers.clone(); - let provider_profile_catalog = state - .provider_profile_sources - .snapshot_catalog(state.store.as_ref(), &workspace) - .await?; - let provider_records = super::provider::load_provider_environment_records( - state.store.as_ref(), - &workspace, - &provider_names, - ) - .await?; - let (effective_policy, policy_source) = current_effective_policy_from_records( - state.as_ref(), - &provider_profile_catalog, + 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, - &sandbox_id, - &provider_records, - ) - .await?; + )?; let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?; validate_policy_credential_binding_context( - &provider_profile_catalog, - &provider_records, + provider_profile_catalog, + provider_records, &effective_policy, &policy_credential_bindings, )?; let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( - &provider_profile_catalog, - &provider_records, + provider_profile_catalog, + provider_records, &policy_credential_bindings, matches!(policy_source, PolicySource::Global).then_some(&effective_policy), )?; + Ok(ProviderEnvironmentTarget { + spec, + effective_policy, + policy_source, + policy_credential_bindings, + provider_env_revision, + }) +} + +/// Build the provider environment from captured inputs. This is the only +/// configuration build step that resolves credential values. +pub async fn build_provider_environment_snapshot_from_inputs( + state: &ServerState, + inputs: &SandboxConfigInputs, + supports_static_credential_bindings: bool, + supports_stable_placeholder_environment_keys: bool, +) -> Result { + let sandbox_id = inputs.sandbox.object_id().to_string(); + let ProviderEnvironmentTarget { + spec, + effective_policy, + policy_source, + policy_credential_bindings, + provider_env_revision, + } = provider_environment_target(inputs)?; + + let provider_names = &spec.providers; + let provider_profile_catalog = &inputs.catalog; + let provider_records = &inputs.provider_records; let mut provider_environment = super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - state.store.as_ref(), - &provider_profile_catalog, - &provider_records, + provider_profile_catalog, + provider_records, &policy_credential_bindings, &state.credentials, Some(&sandbox_id), @@ -3792,12 +4011,11 @@ pub(super) async fn load_sandbox_provider_environment( "GetSandboxProviderEnvironment request completed successfully" ); - let non_secret_environment_keys = provider_environment + let mut keys = provider_environment .environment .keys() - .filter(|key| !provider_environment.static_credential_keys.contains(*key)) .cloned() - .collect(); + .collect::>(); if provider_environment .stable_placeholder_environment_keys .iter() @@ -3810,38 +4028,102 @@ pub(super) async fn load_sandbox_provider_environment( "provider environment contains an unbound stable placeholder key", )); } - let mut stable_placeholder_environment_keys = provider_environment - .stable_placeholder_environment_keys - .into_iter() - .collect::>(); - stable_placeholder_environment_keys.sort(); + keys.sort(); + let mut values = Vec::with_capacity(keys.len()); + for name in keys { + let value = provider_environment + .environment + .remove(&name) + .expect("provider environment key came from the same map"); + let is_static_credential = provider_environment.static_credential_keys.contains(&name); + let static_credential_binding = provider_environment + .static_credential_bindings + .remove(&name); + if is_static_credential && static_credential_binding.is_none() { + return Err(Status::failed_precondition(format!( + "static provider credential '{name}' has no endpoint binding" + ))); + } + values.push(ProviderEnvironmentValue { + name: name.clone(), + value, + expiration_time: provider_environment + .credential_expiration_times + .remove(&name) + .and_then(|expires_at_ms| { + openshell_core::time::optional_timestamp_from_legacy_millis(expires_at_ms) + .ok() + .flatten() + }), + classification: if is_static_credential { + ProviderEnvironmentValueClassification::StaticCredential.into() + } else { + ProviderEnvironmentValueClassification::NonSecret.into() + }, + static_credential_binding, + stable_placeholder: provider_environment + .stable_placeholder_environment_keys + .contains(&name), + }); + } - let credential_expiration_times = provider_environment - .credential_expiration_times - .into_iter() - .filter_map(|(key, value)| { - openshell_core::time::optional_timestamp_from_legacy_millis(value) - .ok() - .flatten() - .map(|timestamp| (key, timestamp)) - }) - .collect(); - Ok(GetSandboxProviderEnvironmentResponse { - environment: provider_environment.environment, - files: provider_environment.files, + Ok(ProviderEnvironmentSnapshot { provider_env_revision, - credential_expiration_times, + values, + files: provider_environment.files, dynamic_credentials: provider_environment.dynamic_credentials, - static_credential_bindings: provider_environment.static_credential_bindings, - non_secret_environment_keys, - stable_placeholder_environment_keys, - supports_stable_placeholder_environment_keys: true, provider_attachment_epoch: spec.provider_attachment_epoch.clone(), policy_hash: deterministic_policy_hash(&effective_policy), readiness_reason: readiness_reason.into(), }) } +fn provider_environment_response( + snapshot: ProviderEnvironmentSnapshot, +) -> GetSandboxProviderEnvironmentResponse { + let mut environment = HashMap::with_capacity(snapshot.values.len()); + let mut credential_expiration_times = HashMap::new(); + let mut static_credential_bindings = HashMap::new(); + let mut non_secret_environment_keys = Vec::new(); + let mut stable_placeholder_environment_keys = Vec::new(); + for value in snapshot.values { + environment.insert(value.name.clone(), value.value); + if value.stable_placeholder { + stable_placeholder_environment_keys.push(value.name.clone()); + } + if let Some(expiration_time) = value.expiration_time { + credential_expiration_times.insert(value.name.clone(), expiration_time); + } + match ProviderEnvironmentValueClassification::try_from(value.classification) + .unwrap_or_default() + { + ProviderEnvironmentValueClassification::NonSecret => { + non_secret_environment_keys.push(value.name); + } + ProviderEnvironmentValueClassification::StaticCredential => { + if let Some(binding) = value.static_credential_binding { + static_credential_bindings.insert(value.name, binding); + } + } + ProviderEnvironmentValueClassification::Unspecified => {} + } + } + GetSandboxProviderEnvironmentResponse { + environment, + files: snapshot.files, + provider_env_revision: snapshot.provider_env_revision, + credential_expiration_times, + dynamic_credentials: snapshot.dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + stable_placeholder_environment_keys, + supports_stable_placeholder_environment_keys: true, + provider_attachment_epoch: snapshot.provider_attachment_epoch, + policy_hash: snapshot.policy_hash, + readiness_reason: snapshot.readiness_reason, + } +} + // --------------------------------------------------------------------------- // Update config handler (policy + settings mutations) // --------------------------------------------------------------------------- @@ -3897,9 +4179,7 @@ async fn handle_update_config_inner( state, principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name( - req.workspace_scope.as_ref(), - )?, + selected_workspace_name(req.workspace_scope.as_ref())?, min_role, ) .await?, @@ -3998,6 +4278,7 @@ async fn handle_update_config_inner( if changed { global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(state.store.as_ref(), &global_settings).await?; + crate::config_delivery::publish_all_connected(state); } return Ok(update_config_response( u32::try_from(current.version).unwrap_or(0), @@ -4054,6 +4335,7 @@ async fn handle_update_config_inner( if changed { global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(state.store.as_ref(), &global_settings).await?; + crate::config_delivery::publish_all_connected(state); } return Ok(update_config_response( @@ -4101,6 +4383,7 @@ async fn handle_update_config_inner( global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(state.store.as_ref(), &global_settings).await?; + crate::config_delivery::publish_all_connected(state); if req.delete_setting && key == POLICY_SETTING_KEY @@ -4169,6 +4452,7 @@ async fn handle_update_config_inner( &sandbox_settings, ) .await?; + publish_sandbox_config_update(state, &sandbox_id); } response_annotations = persist_update_config_annotations( @@ -4214,6 +4498,7 @@ async fn handle_update_config_inner( &sandbox_settings, ) .await?; + publish_sandbox_config_update(state, &sandbox_id); } response_annotations = persist_update_config_annotations( @@ -4328,6 +4613,8 @@ async fn handle_update_config_inner( spawn_pending_chunk_refresh(state, &workspace, &sandbox); emit_config_update_policy_success(sandbox_caller); + publish_sandbox_config_update(state, &sandbox_id); + return Ok(update_config_response( u32::try_from(version).unwrap_or(0), hash, @@ -4417,7 +4704,7 @@ async fn handle_update_config_inner( let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); - let (_next_version, committed_annotations) = { + let (next_version, committed_annotations) = { let mut committed = None; for attempt in 1..=MERGE_RETRY_LIMIT { let latest = state @@ -4444,6 +4731,7 @@ async fn handle_update_config_inner( sandbox_id = %sandbox_id, "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" ); + publish_sandbox_config_update(state, &sandbox_id); } return Ok(update_config_response( u32::try_from(current.version).unwrap_or(0), @@ -4499,6 +4787,7 @@ async fn handle_update_config_inner( }; response_annotations = committed_annotations; state.sandbox_watch_bus.notify(&sandbox_id); + publish_sandbox_config_update(state, &sandbox_id); // The committed revision changed what pending proposals were evaluated // against. Schedule the refresh here: the matching-revision check below // returns before the end of this handler for every committed write. @@ -4511,50 +4800,6 @@ async fn handle_update_config_inner( ); } - let latest = state - .store - .get_latest_policy(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; - - let payload = new_policy.encode_to_vec(); - let hash = deterministic_policy_hash(&new_policy); - - if let Some(ref current) = latest - && canonical_policy_record_matches_for_deduplication(current, &hash) - { - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(current.version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - annotations: response_annotations, - })); - } - - let next_version = latest.map_or(1, |r| r.version + 1); - let policy_id = uuid::Uuid::new_v4().to_string(); - - state - .store - .put_policy_revision( - &policy_id, - &sandbox_id, - &workspace, - next_version, - &payload, - &hash, - ) - .await - .map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?; - - let _ = state - .store - .supersede_older_policies(&sandbox_id, next_version) - .await; - - state.sandbox_watch_bus.notify(&sandbox_id); - info!( sandbox_id = %sandbox_id, version = next_version, @@ -4597,9 +4842,7 @@ pub(super) async fn handle_get_sandbox_policy_status( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name( - req.workspace_scope.as_ref(), - )?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::User, ) .await?, @@ -4663,9 +4906,7 @@ pub(super) async fn handle_list_sandbox_policies( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name( - req.workspace_scope.as_ref(), - )?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::User, ) .await?, @@ -4945,102 +5186,137 @@ pub(super) async fn handle_report_policy_status( return Err(Status::invalid_argument("version is required")); } - let version = i64::from(req.version); let status_str = match PolicyStatus::try_from(req.status) { Ok(PolicyStatus::Loaded) => "loaded", Ok(PolicyStatus::Failed) => "failed", _ => return Err(Status::invalid_argument("status must be LOADED or FAILED")), }; - let loaded_at_ms = if status_str == "loaded" { - Some(current_time_ms()) - } else { - None - }; + record_policy_apply_result( + state, + &req.sandbox_id, + req.version, + status_str == "loaded", + (!req.load_error.is_empty()).then_some(req.load_error.as_str()), + "polling", + ) + .await?; - let load_error = if status_str == "failed" && !req.load_error.is_empty() { - Some(req.load_error.as_str()) - } else { - None - }; + Ok(Response::new(ReportPolicyStatusResponse {})) +} +/// Persist the exact sandbox policy revision attempted by a supervisor. +/// Unary rollout reports and stream acknowledgements share this path. +pub async fn record_policy_apply_result( + state: &Arc, + sandbox_id: &str, + version: u32, + loaded: bool, + load_error: Option<&str>, + source: &'static str, +) -> Result<(), Status> { + if sandbox_id.is_empty() || version == 0 { + return Err(Status::invalid_argument( + "sandbox_id and policy version are required", + )); + } + let status = if loaded { "loaded" } else { "failed" }; + counter!( + "openshell_supervisor_policy_apply_results_total", + "source" => source, + "outcome" => status, + ) + .increment(1); + let sanitized_error = (!loaded).then(|| { + load_error + .unwrap_or_default() + .chars() + .take(1024) + .collect::() + }); + let version_i64 = i64::from(version); let updated = state .store .update_policy_status( - &req.sandbox_id, - version, - status_str, - load_error, - loaded_at_ms, + sandbox_id, + version_i64, + status, + sanitized_error.as_deref().filter(|error| !error.is_empty()), + loaded.then(current_time_ms), ) .await - .map_err(|e| Status::internal(format!("update policy status failed: {e}")))?; - + .map_err(|error| Status::internal(format!("update policy status failed: {error}")))?; if !updated { return Err(Status::not_found("policy revision not found")); } - - if status_str == "loaded" { + if loaded { + let newest_loaded = { + let Some(_mutation_guard) = state + .compute + .sandbox_mutation_guard_by_id(sandbox_id) + .await + .map_err(|error| { + super::persistence_error_to_status(error, "acquire policy mutation lock") + })? + else { + return Err(Status::not_found("sandbox not found")); + }; + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let current_version = sandbox.current_policy_version(); + if current_version > version { + // A result can arrive after a newer revision loaded, such as + // one queued by a replaced supervisor session. The newer + // policy stays active, and so does its endpoint inventory. + current_version + } else { + let endpoint_reset = endpoint_status::endpoint_status_reset_for_loaded_policy( + state.as_ref(), + &sandbox, + version_i64, + ) + .await?; + let updated = state + .store + .update_message_cas::(sandbox_id, 0, |sandbox| { + if sandbox.current_policy_version() < version { + sandbox.set_current_policy_version(version); + } + if let Some(endpoints) = &endpoint_reset { + endpoint_status::reset_endpoint_statuses( + sandbox, + endpoints, + &prost_types::Timestamp::default(), + ); + } + }) + .await + .map_err(|error| { + super::persistence_error_to_status(error, "update current_policy_version") + })?; + state.sandbox_index.update_from_sandbox(&updated); + state.sandbox_watch_bus.notify(sandbox_id); + updated.current_policy_version() + } + }; + // Supersede below the newest loaded revision, after the current + // version settles. Of two results racing, the one finishing last then + // supersedes the older revision, even when the older revision's status + // write landed after the newer result superseded it. let _ = state .store - .supersede_older_policies(&req.sandbox_id, version) + .supersede_older_policies(sandbox_id, i64::from(newest_loaded)) .await; - - let Some(_mutation_guard) = state - .compute - .sandbox_mutation_guard_by_id(&req.sandbox_id) - .await - .map_err(|error| { - super::persistence_error_to_status(error, "acquire policy mutation lock") - })? - else { - return Err(Status::not_found("sandbox not found")); - }; - let sandbox = state - .store - .get_message::(&req.sandbox_id) - .await - .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - let endpoint_reset = endpoint_status::endpoint_status_reset_for_loaded_policy( - state.as_ref(), - &sandbox, - version, - ) - .await?; - let expected_resource_version = sandbox.get_resource_version(); - let version_to_set = req.version; - let updated = state - .store - .update_message_cas::( - &req.sandbox_id, - expected_resource_version, - |sandbox| { - sandbox.set_current_policy_version(version_to_set); - if let Some(endpoints) = &endpoint_reset { - endpoint_status::reset_endpoint_statuses( - sandbox, - endpoints, - &prost_types::Timestamp::default(), - ); - } - }, - ) - .await - .map_err(|e| super::persistence_error_to_status(e, "update current_policy_version"))?; - - state.sandbox_index.update_from_sandbox(&updated); - state.sandbox_watch_bus.notify(&req.sandbox_id); } - info!( - sandbox_id = %req.sandbox_id, - version = req.version, - status = %status_str, - "ReportPolicyStatus: sandbox reported policy load result" + sandbox_id, + version, status, source, "sandbox reported policy load result" ); - - Ok(Response::new(ReportPolicyStatusResponse {})) + Ok(()) } // --------------------------------------------------------------------------- @@ -5057,7 +5333,7 @@ pub(super) async fn handle_get_sandbox_logs( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::User, ) .await?; @@ -5200,7 +5476,7 @@ pub(super) async fn handle_submit_policy_analysis( let req = request.into_inner(); let workspace = super::workspace::resolve_workspace( state.store.as_ref(), - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, ) .await? .name; @@ -5650,7 +5926,7 @@ pub(super) async fn handle_get_draft_policy( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::User, ) .await?; @@ -5721,7 +5997,7 @@ async fn handle_approve_draft_chunk_inner( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::Admin, ) .await?; @@ -5796,6 +6072,7 @@ async fn handle_approve_draft_chunk_inner( return Err(status); } }; + crate::config_delivery::publish_sandbox(state, &sandbox_id); let chunk_summary = summarize_draft_chunk_rule(&chunk)?; let now_ms = current_time_ms(); @@ -5868,7 +6145,7 @@ async fn handle_reject_draft_chunk_inner( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::Admin, ) .await?; @@ -5912,6 +6189,7 @@ async fn handle_reject_draft_chunk_inner( require_no_global_policy(state).await?; let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; + crate::config_delivery::publish_sandbox(state, &sandbox_id); emit_gateway_policy_audit_log( &sandbox_id, sandbox.object_name(), @@ -5955,7 +6233,7 @@ pub(super) async fn handle_approve_all_draft_chunks( state: &Arc, request: Request, ) -> Result, Status> { - let result = handle_approve_all_draft_chunks_inner(state, request).await; + let result = Box::pin(handle_approve_all_draft_chunks_inner(state, request)).await; if result.is_err() { emit_policy_decision_failure(PolicyDecisionOperation::ApproveAll, 0); } @@ -5973,7 +6251,7 @@ async fn handle_approve_all_draft_chunks_inner( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::Admin, ) .await?; @@ -6067,7 +6345,7 @@ async fn handle_approve_all_draft_chunks_inner( .await { Ok(evaluation) => evaluation, - Err(status) if status.code() == tonic::Code::FailedPrecondition => { + Err(status) if status.code() == Code::FailedPrecondition => { info!( sandbox_id = %sandbox_id, chunk_id = %chunk.id, @@ -6198,6 +6476,10 @@ async fn handle_approve_all_draft_chunks_inner( } }; + if !accepted.is_empty() { + crate::config_delivery::publish_sandbox(state, &sandbox_id); + } + for (chunk, _, chunk_summary) in &accepted { let now_ms = current_time_ms(); clear_pending_application_error(state, &chunk.id).await; @@ -6273,7 +6555,7 @@ pub(super) async fn handle_edit_draft_chunk( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::Admin, ) .await?; @@ -6349,7 +6631,7 @@ async fn handle_undo_draft_chunk_inner( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::Admin, ) .await?; @@ -6386,6 +6668,7 @@ async fn handle_undo_draft_chunk_inner( ); let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?; + crate::config_delivery::publish_sandbox(state, &sandbox_id); // Clear any prior rejection_reason on the way back to "pending" so an // agent reading the chunk via policy.local cannot see a stale guidance @@ -6438,7 +6721,7 @@ pub(super) async fn handle_clear_draft_chunks( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::Admin, ) .await?; @@ -6474,7 +6757,7 @@ pub(super) async fn handle_get_draft_history( state, &principal, &req.sandbox, - crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?, + selected_workspace_name(req.workspace_scope.as_ref())?, MinWorkspaceRole::User, ) .await?; @@ -7934,7 +8217,6 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; - use tonic::Code; /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise @@ -8891,7 +9173,7 @@ mod tests { } #[tokio::test] - async fn get_sandbox_config_backfills_canonical_spec_policy_bytes_and_hash() { + async fn startup_repair_backfills_canonical_spec_policy_bytes_and_hash() { let state = test_server_state().await; let sandbox_id = "stored-canonical-backfill"; let raw = mcp_policy_with_versions(&["2025-11-25", "2025-03-26", "2025-06-18"]); @@ -8927,6 +9209,15 @@ mod tests { assert_eq!(response.policy_hash, canonical_hash); assert_eq!(response.version, 1); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .is_none() + ); + backfill_legacy_policy_history(&state).await.unwrap(); let persisted = state .store .get_latest_policy(sandbox_id) @@ -8939,7 +9230,7 @@ mod tests { } #[tokio::test] - async fn get_sandbox_config_backfills_defaulted_mcp_policy_as_canonical_bytes_and_hash() { + async fn startup_repair_backfills_defaulted_mcp_policy_as_canonical_bytes_and_hash() { let state = test_server_state().await; let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"])) .expect("explicit default MCP policy must canonicalize"); @@ -8986,6 +9277,15 @@ mod tests { "{case}" ); + assert!( + state + .store + .get_latest_policy(&sandbox_id) + .await + .unwrap() + .is_none() + ); + backfill_legacy_policy_history(&state).await.unwrap(); let persisted = state .store .get_latest_policy(&sandbox_id) @@ -9259,6 +9559,14 @@ mod tests { .await .expect("effective-policy lookup must bypass invalid local history"); assert_eq!(effective, global_policy); + + let environment = build_provider_environment_snapshot(&state, &sandbox, true, true) + .await + .expect("provider environment must bypass invalid local history"); + assert_eq!( + environment.policy_hash, + deterministic_policy_hash(&global_policy) + ); } #[tokio::test] @@ -9278,6 +9586,10 @@ mod tests { .await .expect("store sandbox with invalid legacy spec"); + backfill_legacy_policy_history(&state) + .await + .expect("global override must allow startup with an invalid dormant spec"); + let response = handle_get_sandbox_config( &state, with_sandbox( @@ -9334,6 +9646,23 @@ mod tests { .is_none(), "effective-policy lookup must not create policy history" ); + + let environment = build_provider_environment_snapshot(&state, &sandbox, true, true) + .await + .expect("provider environment must bypass invalid local spec"); + assert_eq!( + environment.policy_hash, + deterministic_policy_hash(&global_policy) + ); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none(), + "provider environment build must not create policy history" + ); } #[tokio::test] @@ -12775,44 +13104,166 @@ mod tests { } #[tokio::test] - async fn update_config_accepts_sigv4_bound_to_endpointless_aws_profile() { - let state = test_server_state().await; - state - .store - .put_message(&test_aws_provider("aws-prod", "aws")) - .await - .unwrap(); + async fn update_config_accepts_sigv4_bound_to_endpointless_aws_profile() { + let state = test_server_state().await; + state + .store + .put_message(&test_aws_provider("aws-prod", "aws")) + .await + .unwrap(); + let mut sandbox = test_sandbox( + "sb-signing-bound-aws", + "signing-bound-aws", + ProtoSandboxPolicy::default(), + vec!["aws-prod".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + sandbox: "signing-bound-aws".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), + policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))), + ..Default::default() + })), + ) + .await + .expect("bound endpointless AWS profile supplies SigV4 credentials"); + + assert!( + state + .store + .get_latest_policy("sb-signing-bound-aws") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn rejected_policy_update_does_not_publish_configuration() { + let state = test_server_state().await; + let mut sandbox = test_sandbox( + "sb-rejected-policy", + "rejected-policy", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel(); + state.supervisor_sessions.register( + "sb-rejected-policy".to_string(), + "session-1".to_string(), + tx, + shutdown_tx, + ); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + sandbox: "rejected-policy".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(test_policy_with_rule("rejected", "api.example.com")), + expected_resource_version: u64::MAX, + ..Default::default() + })), + ) + .await + .expect_err("stale resource version must reject the update"); + + assert_eq!(error.code(), Code::Aborted); + assert!(rx.try_recv().is_err()); + assert!( + state + .store + .get_latest_policy("sb-rejected-policy") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn committed_policy_update_publishes_complete_snapshot() { + let mut state = test_server_state().await; + Arc::get_mut(&mut state) + .unwrap() + .config + .config_delivery_mode = openshell_core::config::ConfigDeliveryMode::Push; let mut sandbox = test_sandbox( - "sb-signing-bound-aws", - "signing-bound-aws", + "sb-published-policy", + "published-policy", ProtoSandboxPolicy::default(), - vec!["aws-prod".to_string()], + Vec::new(), ); sandbox.spec.as_mut().unwrap().policy = None; state.store.put_message(&sandbox).await.unwrap(); + let mut session = + crate::config_delivery::register_test_apply_session(&state, &sandbox, "session-1"); + let owner_index = crate::supervisor_owner::SupervisorOwnerIndex::new( + Arc::clone(&state.store), + crate::supervisor_owner::OWNER_TTL, + ); + owner_index + .publish( + "sb-published-policy", + "session-1", + "test-supervisor", + 1, + &state.replica_id, + "local://test", + ) + .await + .unwrap(); + handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - sandbox: "signing-bound-aws".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), - policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))), + sandbox: "published-policy".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(test_policy_with_rule("published", "api.example.com")), ..Default::default() })), ) .await - .expect("bound endpointless AWS profile supplies SigV4 credentials"); + .unwrap(); - assert!( - state - .store - .get_latest_policy("sb-signing-bound-aws") - .await - .unwrap() - .is_some() - ); + let persisted = state + .store + .get_latest_policy("sb-published-policy") + .await + .unwrap() + .expect("committed policy"); + let snapshot = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let message = session + .outbound + .recv() + .await + .expect("configuration stream closed"); + let Some(openshell_core::proto::gateway_message::Payload::ConfigUpdate(update)) = + message.payload + else { + panic!("expected ConfigUpdate"); + }; + if let Some(snapshot) = update.sandbox_config { + break snapshot; + } + } + }) + .await + .expect("configuration publication timed out"); + assert_eq!(snapshot.version, u32::try_from(persisted.version).unwrap()); + assert_eq!(snapshot.policy_hash, persisted.policy_hash); + assert!(snapshot.policy.is_some()); } #[tokio::test] @@ -12976,7 +13427,6 @@ mod tests { Vec::new(), ); state.store.put_message(&sandbox).await.unwrap(); - let error = super::super::sandbox::handle_attach_sandbox_provider( &state, authed_request(openshell_core::proto::AttachSandboxProviderRequest { @@ -13146,23 +13596,147 @@ mod tests { .contains_key("_provider_work_github") ); - let persisted = state + assert!( + state + .store + .get_latest_policy("sb-jit") + .await + .unwrap() + .is_none(), + "snapshot reads must not create policy history" + ); + } + + #[tokio::test] + async fn legacy_policy_history_repair_skips_invalid_stored_policy() { + use openshell_core::proto::LandlockPolicy; + + let state = test_server_state().await; + let valid_policy = test_policy_with_rule("valid", "valid.example.com"); + state + .store + .put_message(&test_sandbox( + "sb-valid-legacy", + "valid-legacy", + valid_policy.clone(), + Vec::new(), + )) + .await + .unwrap(); + let mut invalid_policy = test_policy_with_rule("invalid", "invalid.example.com"); + invalid_policy.landlock = Some(LandlockPolicy { + compatibility: "best-effort".to_string(), + }); + state + .store + .put_message(&test_sandbox( + "sb-invalid-legacy", + "invalid-legacy", + invalid_policy, + Vec::new(), + )) + .await + .unwrap(); + + backfill_legacy_policy_history(&state) + .await + .expect("one invalid stored policy must not block startup repair"); + + let repaired = state .store - .get_latest_policy("sb-jit") + .get_latest_policy("sb-valid-legacy") .await .unwrap() - .expect("sandbox policy should be lazily backfilled"); - let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice()) - .expect("persisted sandbox policy should decode"); - assert!( - persisted_policy - .network_policies - .contains_key("sandbox_only") + .expect("valid legacy sandbox gets a baseline"); + assert_eq!(repaired.version, 1); + assert_eq!(repaired.status, "loaded"); + assert_eq!( + repaired.policy_hash, + deterministic_policy_hash(&valid_policy) ); assert!( - !persisted_policy - .network_policies - .contains_key("_provider_work_github") + state + .store + .get_latest_policy("sb-invalid-legacy") + .await + .unwrap() + .is_none(), + "invalid stored policy must not be persisted as history" + ); + + let rejected = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: "invalid-legacy".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + "sb-invalid-legacy", + ), + ) + .await + .expect("invalid stored policy returns a repairable admission snapshot") + .into_inner(); + assert!(!rejected.configuration_admitted); + assert!(rejected.policy.is_none()); + assert_eq!(rejected.workspace, "default"); + assert_eq!( + rejected.configuration_error, + "Stored policy structure or safety validation failed; submit a complete valid replacement policy" + ); + } + + #[tokio::test] + async fn legacy_policy_history_repair_is_idempotent() { + let state = test_server_state().await; + let policy = test_policy_with_rule("legacy", "legacy.example.com"); + state + .store + .put_message(&test_sandbox( + "sb-legacy-policy", + "legacy-policy", + policy.clone(), + Vec::new(), + )) + .await + .unwrap(); + + backfill_legacy_policy_history(&state).await.unwrap(); + let initial = state + .store + .get_latest_policy("sb-legacy-policy") + .await + .unwrap() + .expect("legacy policy baseline"); + assert_eq!(initial.status, "loaded"); + state + .store + .update_policy_status("sb-legacy-policy", 1, "failed", Some("apply failed"), None) + .await + .unwrap(); + + backfill_legacy_policy_history(&state).await.unwrap(); + let repaired_again = state + .store + .get_latest_policy("sb-legacy-policy") + .await + .unwrap() + .expect("legacy policy baseline"); + assert_eq!(repaired_again.version, 1); + assert_eq!( + repaired_again.policy_hash, + deterministic_policy_hash(&policy) + ); + assert_eq!(repaired_again.status, "failed"); + assert_eq!(repaired_again.load_error.as_deref(), Some("apply failed")); + assert_eq!( + state + .store + .list_policies("sb-legacy-policy", 10, 0) + .await + .unwrap() + .len(), + 1 ); } @@ -13291,24 +13865,14 @@ mod tests { assert_eq!(persisted_provider.r#type, provider.r#type); assert_eq!(persisted_provider.credentials, provider.credentials); - let persisted_policy = state - .store - .get_latest_policy("sb-custom-policy-update") - .await - .unwrap() - .expect("sandbox policy should be lazily backfilled"); - let persisted_policy = - ProtoSandboxPolicy::decode(persisted_policy.policy_payload.as_slice()) - .expect("persisted sandbox policy should decode"); - assert!( - persisted_policy - .network_policies - .contains_key("sandbox_only") - ); assert!( - !persisted_policy - .network_policies - .contains_key("_provider_work_custom") + state + .store + .get_latest_policy("sb-custom-policy-update") + .await + .unwrap() + .is_none(), + "config and profile reads must not create policy history" ); } @@ -14302,6 +14866,139 @@ mod tests { assert_eq!(revisions.len(), 1); } + #[tokio::test] + async fn captured_inputs_pin_one_policy_for_both_components() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let sandbox_id = "sb-captured-inputs"; + let sandbox = test_sandbox( + sandbox_id, + "captured-inputs", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + ); + state.store.put_message(&sandbox).await.unwrap(); + + let captured = load_sandbox_config_inputs(&state, sandbox.clone()) + .await + .unwrap(); + async fn build_both( + state: &ServerState, + inputs: &SandboxConfigInputs, + ) -> (SandboxConfigSnapshot, ProviderEnvironmentSnapshot) { + ( + build_sandbox_config_snapshot_from_inputs(state, inputs) + .await + .unwrap(), + build_provider_environment_snapshot_from_inputs(state, inputs, true, true) + .await + .unwrap(), + ) + } + let (config, environment) = build_both(&state, &captured).await; + let effective = config.policy.as_ref().expect("spec policy"); + assert!( + effective + .network_policies + .contains_key(&openshell_policy::provider_rule_name("work-github")), + "provider layers must compose into the effective policy" + ); + assert_eq!(config.version, 1); + assert_eq!(environment.policy_hash, config.policy_hash); + assert_eq!( + environment.provider_env_revision, + config.provider_env_revision + ); + assert_eq!( + environment.provider_attachment_epoch, + config.provider_attachment_epoch + ); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .unwrap() + .is_none(), + "building from a legacy spec must not create policy history" + ); + + let updated = test_policy_with_rule("sandbox_only", "updated.example.com"); + state + .store + .put_policy_revision( + "captured-inputs-v2", + sandbox_id, + "default", + 2, + &updated.encode_to_vec(), + &deterministic_policy_hash(&updated), + ) + .await + .unwrap(); + + let (pinned_config, pinned_environment) = build_both(&state, &captured).await; + assert_eq!(pinned_config.version, 1); + assert_eq!(pinned_config.policy_hash, config.policy_hash); + assert_eq!(pinned_environment.policy_hash, config.policy_hash); + + let fresh = load_sandbox_config_inputs(&state, sandbox).await.unwrap(); + let (next_config, next_environment) = build_both(&state, &fresh).await; + assert_eq!(next_config.version, 2); + assert_ne!(next_config.policy_hash, config.policy_hash); + assert_eq!(next_environment.policy_hash, next_config.policy_hash); + } + + #[tokio::test] + async fn sandbox_settings_failure_leaves_provider_environment_buildable() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let sandbox = test_sandbox( + "sb-settings-failure", + "settings-failure", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + ); + state.store.put_message(&sandbox).await.unwrap(); + state + .store + .put_if( + SANDBOX_SETTINGS_OBJECT_TYPE, + "settings-failure-settings", + "settings-failure", + "default", + b"not settings json", + None, + crate::persistence::WriteCondition::Unconditional, + ) + .await + .unwrap(); + + let inputs = load_sandbox_config_inputs(&state, sandbox).await.unwrap(); + let error = build_sandbox_config_snapshot_from_inputs(&state, &inputs) + .await + .expect_err("malformed sandbox settings fail the configuration component"); + assert_eq!(error.code(), Code::Internal); + let environment = + build_provider_environment_snapshot_from_inputs(&state, &inputs, true, true) + .await + .expect("sandbox settings are not a provider environment input"); + assert!( + environment + .values + .iter() + .any(|value| value.name == "GITHUB_TOKEN") + ); + } + #[tokio::test] async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() { use openshell_core::proto::{ @@ -14415,7 +15112,6 @@ mod tests { state.store.put_message(&replacement).await.unwrap(); let first_environment = crate::grpc::provider::resolve_provider_environment_from_records( - state.store.as_ref(), &catalog, &first_records, ) @@ -14456,7 +15152,6 @@ mod tests { compute_provider_env_revision_from_records(&catalog, &replacement_records).unwrap(); let replacement_environment = crate::grpc::provider::resolve_provider_environment_from_records( - state.store.as_ref(), &catalog, &replacement_records, ) @@ -23144,7 +23839,8 @@ mod tests { #[tokio::test] async fn update_config_same_policy_hash_with_new_provenance_creates_revision() { - let state = test_server_state().await; + let mut state = test_server_state().await; + crate::grpc::test_support::enable_push_delivery(&mut state); let policy = test_policy_with_rule("sandbox_only", "sandbox.example.com"); let hash = deterministic_policy_hash(&policy); let sandbox = test_sandbox("sb-same-hash", "same-hash", policy.clone(), Vec::new()); @@ -24650,4 +25346,41 @@ mod tests { response.unwrap_err() ); } + + #[test] + fn provider_stream_values_preserve_credential_metadata() { + let response = provider_environment_response(ProviderEnvironmentSnapshot { + provider_env_revision: 9, + values: vec![ + ProviderEnvironmentValue { + name: "REGION".into(), + value: "west".into(), + classification: ProviderEnvironmentValueClassification::NonSecret.into(), + ..Default::default() + }, + ProviderEnvironmentValue { + name: "TOKEN".into(), + value: "secret".into(), + expiration_time: openshell_core::time::timestamp_from_millis(123).ok(), + classification: ProviderEnvironmentValueClassification::StaticCredential.into(), + static_credential_binding: Some(StaticCredentialBinding::default()), + stable_placeholder: true, + }, + ], + dynamic_credentials: HashMap::new(), + ..Default::default() + }); + + assert_eq!(response.provider_env_revision, 9); + assert_eq!(response.environment["REGION"], "west"); + assert_eq!(response.environment["TOKEN"], "secret"); + assert_eq!( + response.credential_expiration_times["TOKEN"], + openshell_core::time::timestamp_from_millis(123).unwrap() + ); + assert_eq!(response.non_secret_environment_keys, ["REGION"]); + assert!(response.static_credential_bindings.contains_key("TOKEN")); + assert_eq!(response.stable_placeholder_environment_keys, ["TOKEN"]); + assert!(response.supports_stable_placeholder_environment_keys); + } } diff --git a/crates/openshell-server/src/grpc/policy/config_inputs.rs b/crates/openshell-server/src/grpc/policy/config_inputs.rs new file mode 100644 index 0000000000..10b9a98255 --- /dev/null +++ b/crates/openshell-server/src/grpc/policy/config_inputs.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Store state shared by the sandbox configuration and provider environment +//! builders. +//! +//! Both builders read the provider catalog, provider records, global settings, +//! and the latest sandbox policy. Loading them once per build attempt lets one +//! attempt hand both components the same view of those sources. The reads are +//! independent, not one database transaction, and credential values are +//! resolved later by the provider builder alone. + +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 crate::ServerState; +use crate::persistence::{ObjectId, ObjectWorkspace, PolicyRecord}; +use crate::policy_store::PolicyStoreExt as _; +use crate::provider_profile_sources::EffectiveProviderProfileCatalog; + +/// Inputs captured for one sandbox and one build attempt. +/// +/// Callers must authorize access to `sandbox` before loading. Load again for +/// every later build or retry so a newer source is never hidden. +pub struct SandboxConfigInputs { + pub(super) sandbox: Sandbox, + pub(super) catalog: EffectiveProviderProfileCatalog, + /// Records in `spec.providers` order, which the provider revision hashes. + pub(super) provider_records: Vec, + pub(super) global_settings: StoredSettings, + /// Observed for its version metadata even under a global policy, whose + /// dormant sandbox payload is never decoded. + pub(super) latest_policy: Option, +} + +impl SandboxConfigInputs { + pub fn sandbox(&self) -> &Sandbox { + &self.sandbox + } +} + +/// Load the inputs both configuration builders share. Independent reads run +/// concurrently; a failure here fails every component built from them. +pub async fn load_sandbox_config_inputs( + state: &ServerState, + sandbox: Sandbox, +) -> Result { + let store = state.store.as_ref(); + 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!( + state + .provider_profile_sources + .snapshot_catalog(store, workspace), + load_provider_environment_records(store, workspace, provider_names), + load_global_settings(store), + async { + store + .get_latest_policy(sandbox.object_id()) + .await + .map_err(|e| Status::internal(format!("fetch policy history failed: {e}"))) + }, + )?; + Ok(SandboxConfigInputs { + sandbox, + catalog, + provider_records, + global_settings, + latest_policy, + }) +} diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs index 88f21ae626..680aa369d6 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -405,6 +405,19 @@ fn unknown_endpoint_reports( .collect() } +async fn endpoint_reports_for_session_reset( + state: &ServerState, + sandbox: &Sandbox, +) -> Result>, Status> { + match active_endpoint_context(state, sandbox).await { + Ok(context) => Ok(Some(unknown_endpoint_reports(&context.endpoints))), + // Invalid configuration must not block its rejection bootstrap or retain + // evidence from an ended session. + Err(error) if super::is_configuration_rejection(&error) => Ok(None), + Err(error) => Err(error), + } +} + /// Reset endpoint evidence before acknowledging a newly authenticated supervisor. pub async fn reset_endpoint_status_for_supervisor_session( state: &Arc, @@ -438,15 +451,18 @@ pub async fn reset_endpoint_status_for_supervisor_session( .await .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; - let context = active_endpoint_context(state.as_ref(), &sandbox).await?; - let reports = unknown_endpoint_reports(&context.endpoints); + let reports = endpoint_reports_for_session_reset(state, &sandbox).await?; let now = openshell_core::time::timestamp_from_system_time(std::time::SystemTime::now()) .map_err(|error| Status::internal(format!("create endpoint reset timestamp: {error}")))?; let expected_resource_version = sandbox.get_resource_version(); let updated = state .store .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { - reset_endpoint_statuses(sandbox, &reports, &now); + if let Some(reports) = &reports { + reset_endpoint_statuses(sandbox, reports, &now); + } else { + invalidate_endpoint_status_without_session(sandbox); + } }) .await .map_err(|error| { @@ -498,15 +514,18 @@ pub async fn reset_endpoint_status_after_supervisor_disconnect( .await .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; - let context = active_endpoint_context(state.as_ref(), &sandbox).await?; - let reports = unknown_endpoint_reports(&context.endpoints); + let reports = endpoint_reports_for_session_reset(state, &sandbox).await?; let now = openshell_core::time::timestamp_from_system_time(std::time::SystemTime::now()) .map_err(|error| Status::internal(format!("create endpoint reset timestamp: {error}")))?; let expected_resource_version = sandbox.get_resource_version(); let updated = state .store .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { - reset_endpoint_statuses(sandbox, &reports, &now); + if let Some(reports) = &reports { + reset_endpoint_statuses(sandbox, reports, &now); + } else { + invalidate_endpoint_status_without_session(sandbox); + } }) .await .map_err(|error| { diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs index d579665614..eb36132fa2 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -1860,6 +1860,68 @@ async fn gateway_client(state: Arc) -> OpenShellClient Result { let records = load_provider_environment_records(store, workspace, provider_names).await?; - resolve_provider_environment_from_records_with_credentials( - store, - catalog, - &records, - credentials, - ) - .await + resolve_provider_environment_from_records_with_credentials(catalog, &records, credentials).await } pub(super) async fn load_provider_environment_records( @@ -1038,8 +1032,8 @@ pub(super) async fn load_provider_environment_records( workspace: &str, provider_names: &[String], ) -> Result, Status> { - let mut records = Vec::with_capacity(provider_names.len()); - for name in provider_names { + // Providers are independent, so load them concurrently. + futures::future::try_join_all(provider_names.iter().map(|name| async move { let record = store .get_by_name(Provider::object_type(), workspace, name) .await @@ -1049,20 +1043,19 @@ pub(super) async fn load_provider_environment_records( .map_err(|e| Status::internal(format!("failed to decode provider '{name}': {e}")))?; let refresh_states = crate::provider_refresh::list_refresh_states_for_provider(store, &record.id).await?; - records.push(ProviderEnvironmentRecord { + Ok(ProviderEnvironmentRecord { name: name.clone(), object_id: record.id, resource_version: record.resource_version, provider, refresh_states, - }); - } - Ok(records) + }) + })) + .await } #[cfg(test)] pub(super) async fn resolve_provider_environment_from_records( - store: &Store, catalog: &EffectiveProviderProfileCatalog, records: &[ProviderEnvironmentRecord], ) -> Result { @@ -1071,7 +1064,6 @@ pub(super) async fn resolve_provider_environment_from_records( ) .map_err(|err| Status::internal(format!("initialize credential runtime failed: {err}")))?; resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - store, catalog, records, &HashMap::new(), @@ -1083,13 +1075,11 @@ pub(super) async fn resolve_provider_environment_from_records( #[cfg(test)] pub(super) async fn resolve_provider_environment_from_records_with_credentials( - store: &Store, catalog: &EffectiveProviderProfileCatalog, records: &[ProviderEnvironmentRecord], credentials: &crate::credentials::CredentialRuntime, ) -> Result { resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - store, catalog, records, &HashMap::new(), @@ -1101,7 +1091,6 @@ pub(super) async fn resolve_provider_environment_from_records_with_credentials( #[cfg(test)] pub(super) async fn resolve_provider_environment_from_records_with_policy_bindings( - store: &Store, catalog: &EffectiveProviderProfileCatalog, records: &[ProviderEnvironmentRecord], policy_bindings: &HashMap>, @@ -1111,7 +1100,6 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin ) .map_err(|err| Status::internal(format!("initialize credential runtime failed: {err}")))?; resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - store, catalog, records, policy_bindings, @@ -1122,7 +1110,6 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin } pub(super) async fn resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - store: &Store, catalog: &EffectiveProviderProfileCatalog, records: &[ProviderEnvironmentRecord], policy_bindings: &HashMap>, @@ -1143,7 +1130,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut readiness_reason = openshell_core::proto::ProviderReadinessReason::Unspecified; let mut expired_static_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; + validate_provider_environment_records_unique_at(catalog, records, now_ms)?; let registry = openshell_providers::ProviderRegistry::new(); for record in records { @@ -2079,8 +2066,7 @@ async fn validate_provider_environment_keys_unique_at( Ok(()) } -async fn validate_provider_environment_records_unique_at( - store: &Store, +fn validate_provider_environment_records_unique_at( catalog: &EffectiveProviderProfileCatalog, records: &[ProviderEnvironmentRecord], now_ms: i64, @@ -2094,14 +2080,7 @@ async fn validate_provider_environment_records_unique_at( &mut seen_credentials, &mut seen_plugin_config, &record.name, - active_provider_environment_keys_for_identity( - store, - catalog, - provider, - &record.object_id, - now_ms, - ) - .await?, + active_keys_with_refresh_states(catalog, provider, &record.refresh_states, now_ms), provider_plugin_environment_keys(catalog, provider), )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( @@ -2376,27 +2355,41 @@ async fn active_provider_environment_keys_for_identity( provider_identity: &str, now_ms: i64, ) -> Result, Status> { + let refresh_states = if provider_identity.is_empty() { + Vec::new() + } else { + crate::provider_refresh::list_refresh_states_for_provider(store, provider_identity).await? + }; + Ok(active_keys_with_refresh_states( + catalog, + provider, + &refresh_states, + now_ms, + )) +} + +fn active_keys_with_refresh_states( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, + refresh_states: &[StoredProviderCredentialRefreshState], + now_ms: i64, +) -> Vec { let broker_only_credential_keys = broker_only_provider_credential_keys_for_provider(catalog, provider); let mut keys = active_provider_credential_keys(provider, now_ms, &broker_only_credential_keys); - if !provider_identity.is_empty() { - for state in - crate::provider_refresh::list_refresh_states_for_provider(store, provider_identity) - .await? - { - // The primary key plus every co-minted output key this refresh owns, - // so a configured-but-not-yet-minted refresh reserves all of them. - keys.extend( - std::iter::once(state.credential_key) - .chain(state.additional_output_keys.into_values()) - .filter(|key| !broker_only_credential_keys.contains(key)) - .filter(|key| is_valid_env_key(key)), - ); - } + for state in refresh_states { + // The primary key plus every co-minted output key this refresh owns, + // so a configured-but-not-yet-minted refresh reserves all of them. + keys.extend( + std::iter::once(state.credential_key.clone()) + .chain(state.additional_output_keys.values().cloned()) + .filter(|key| !broker_only_credential_keys.contains(key)) + .filter(|key| is_valid_env_key(key)), + ); } keys.sort(); keys.dedup(); - Ok(keys) + keys } fn active_provider_credential_keys( @@ -2629,6 +2622,20 @@ async fn authorize_and_resolve_profile_workspace( } } +/// Publish a provider record change to the sandboxes that attach it. +fn publish_provider_change(state: &Arc, workspace: &str, provider_name: &str) { + crate::config_delivery::publish_provider(state, workspace, provider_name); +} + +/// A profile change affects every provider of its type in scope. +fn publish_provider_profile_change(state: &Arc, workspace: &str) { + if workspace.is_empty() { + crate::config_delivery::publish_all_connected(state); + } else { + crate::config_delivery::publish_workspace(state, workspace); + } +} + fn selected_profile_workspace( workspace_scope: Option<&openshell_core::proto::WorkspaceSelector>, ) -> Result<&str, Status> { @@ -2702,6 +2709,7 @@ pub(super) async fn handle_create_provider( LifecycleOperation::Create, TelemetryOutcome::Success, ); + publish_provider_change(state, &workspace, provider.object_name()); Ok(Response::new(ProviderResponse { provider: Some(provider), ..Default::default() @@ -2979,6 +2987,7 @@ pub(super) async fn handle_import_provider_profiles( stored.profile.unwrap_or_default(), resource_version, )); + publish_provider_profile_change(state, &workspace); } Ok(Response::new(ImportProviderProfilesResponse { @@ -3115,6 +3124,7 @@ pub(super) async fn handle_update_provider_profiles( replay_facts.resource(&stored)?; let resource_version = stored_profile_resource_version(&stored); let profile = profile_response_payload(stored.profile.unwrap_or_default(), resource_version); + publish_provider_profile_change(state, &workspace); Ok(Response::new(UpdateProviderProfilesResponse { diagnostics: Vec::new(), @@ -3211,6 +3221,7 @@ pub(super) async fn handle_delete_provider_profile( .delete(StoredProviderProfile::object_type(), existing.object_id()) .await .map_err(|e| Status::internal(format!("delete provider profile failed: {e}")))?; + publish_provider_profile_change(state, &workspace); Ok(Response::new(DeleteProviderProfileResponse { outcome: openshell_core::proto::DeletionOutcome::Completed.into(), @@ -4068,6 +4079,7 @@ pub(super) async fn handle_update_provider( LifecycleOperation::Update, TelemetryOutcome::Success, ); + publish_provider_change(state, &workspace, provider.object_name()); Ok(Response::new(ProviderResponse { provider: Some(provider), target_receipts, @@ -5150,8 +5162,17 @@ pub(super) async fn handle_configure_provider_refresh( profile_workspace: String::new(), credential_handles: HashMap::new(), }; - update_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, updated) - .await?; + let result = update_provider_record_with_catalog( + state.store.as_ref(), + &catalog, + &workspace, + updated, + ) + .await; + publish_provider_change(state, &workspace, provider_name); + result?; + } else { + publish_provider_change(state, &workspace, provider_name); } replay_facts.refresh(&state_record)?; @@ -5197,6 +5218,7 @@ pub(super) async fn handle_rotate_provider_credential( credential_key, ) .await?; + publish_provider_change(state, &workspace, provider_name); replay_facts.refresh(&refresh_state)?; Ok(Response::new(RotateProviderCredentialResponse { @@ -5285,7 +5307,6 @@ pub(super) async fn handle_delete_provider_refresh( refresh_state.clone(), ) .await?; - // A refresh co-manages the expiry of its primary credential and every pinned // additional output. Clear each expiry this refresh still owns, leaving // independently updated ones in place. The equality check and removal run @@ -5307,8 +5328,10 @@ pub(super) async fn handle_delete_provider_refresh( Status::internal(format!( "clear refresh-owned credential expiries failed: {e}" )) - })?; + }) + .map(|_| ())?; } + publish_provider_change(state, &workspace, provider_name); Ok(Response::new(DeleteProviderRefreshResponse { outcome: openshell_core::proto::DeletionOutcome::Completed.into(), @@ -5362,6 +5385,9 @@ pub(super) async fn handle_delete_provider( LifecycleOperation::Delete, outcome, ); + if deleted { + publish_provider_change(state, &workspace, &name); + } Ok(Response::new(DeleteProviderResponse { outcome: super::deletion_outcome(deleted, req.allow_missing, "provider")?, })) @@ -6050,13 +6076,13 @@ mod tests { let mut records = load_provider_environment_records(store, "default", &["provider".into()]) .await .unwrap(); - let valid = resolve_provider_environment_from_records(store, &catalog, &records) + let valid = resolve_provider_environment_from_records(&catalog, &records) .await .expect("valid dynamic environment"); assert_eq!(valid.dynamic_credentials.len(), 1); for malformed_name in ["provider\tother", "provider\n", "provider:other"] { records[0].name = malformed_name.into(); - let error = resolve_provider_environment_from_records(store, &catalog, &records) + let error = resolve_provider_environment_from_records(&catalog, &records) .await .expect_err("invalid record key must reject the whole environment"); assert_eq!(error.code(), Code::FailedPrecondition); @@ -12310,7 +12336,6 @@ mod tests { .await .unwrap(); let first = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - state.store.as_ref(), &catalog, &records, &HashMap::new(), @@ -12378,7 +12403,6 @@ mod tests { .unwrap(); let unchanged_environment = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - state.store.as_ref(), &catalog, &unchanged_records, &HashMap::new(), @@ -12430,7 +12454,6 @@ mod tests { .unwrap(); let second = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - state.store.as_ref(), &catalog, &records, &HashMap::new(), @@ -12494,7 +12517,6 @@ mod tests { .await .unwrap(); let third = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - state.store.as_ref(), &catalog, &records, &HashMap::new(), @@ -12684,7 +12706,6 @@ mod tests { let result = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - &store, &catalog, &records, &HashMap::new(), @@ -12784,7 +12805,6 @@ mod tests { )]); let result = resolve_provider_environment_from_records_with_policy_bindings( - &store, &catalog, &records, &policy_bindings, @@ -12854,7 +12874,6 @@ mod tests { .expect("test credential runtime"); let result = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - &store, &catalog, &records, &policy_bindings, @@ -12931,7 +12950,6 @@ mod tests { let unbound = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - &store, &catalog, &records, &HashMap::new(), @@ -12956,7 +12974,6 @@ mod tests { }], )]); let bound = resolve_provider_environment_from_records_with_policy_bindings_and_credentials( - &store, &catalog, &records, &policy_bindings, diff --git a/crates/openshell-server/src/grpc/provider_readiness.rs b/crates/openshell-server/src/grpc/provider_readiness.rs index 5c15f8a2ea..8a690c6c74 100644 --- a/crates/openshell-server/src/grpc/provider_readiness.rs +++ b/crates/openshell-server/src/grpc/provider_readiness.rs @@ -258,12 +258,19 @@ async fn load_current_snapshot( provider_name: &str, ) -> Result<(ProviderDesiredIdentity, ProviderReadinessReason), Status> { let mut desired = current_target_identity(state, sandbox, provider_name).await?; - let config = super::policy::load_sandbox_config(state, sandbox).await?; // This is the desired gateway snapshot, not a supervisor delivery. Include // all supported credential forms; the supervisor's applied report proves // whether it installed that snapshot with its negotiated capabilities. - let environment = - super::policy::load_sandbox_provider_environment(state, sandbox, true, true).await?; + let (config, environment) = Box::pin(async { + let inputs = super::policy::load_sandbox_config_inputs(state, sandbox.clone()).await?; + tokio::try_join!( + super::policy::build_sandbox_config_snapshot_from_inputs(state, &inputs), + super::policy::build_provider_environment_snapshot_from_inputs( + state, &inputs, true, true + ), + ) + }) + .await?; let current = state .store .get_message::(sandbox.object_id()) diff --git a/crates/openshell-server/src/grpc/provider_readiness_tests.rs b/crates/openshell-server/src/grpc/provider_readiness_tests.rs index 4adfea48a9..c046b320dd 100644 --- a/crates/openshell-server/src/grpc/provider_readiness_tests.rs +++ b/crates/openshell-server/src/grpc/provider_readiness_tests.rs @@ -72,6 +72,7 @@ fn hello() -> SupervisorHello { instance_id: Uuid::new_v4().to_string(), connection_epoch: 0, supports_provider_readiness: true, + supports_config_push: false, redirected: false, supports_session_redirect: true, } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 463176fb65..c1a7d3034d 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1518,6 +1518,10 @@ pub(super) async fn handle_attach_sandbox_provider( .map_err(|e| super::persistence_error_to_status(e, "attach sandbox provider"))?; let attached = attached.load(Ordering::Relaxed); + if attached { + crate::config_delivery::publish_sandbox(state, &sandbox_id); + state.sandbox_watch_bus.notify(&sandbox_id); + } let receipt = super::provider_readiness::record_provider_mutation( state, &sandbox, @@ -1645,6 +1649,10 @@ pub(super) async fn handle_detach_sandbox_provider( .map_err(|e| super::persistence_error_to_status(e, "detach sandbox provider"))?; let detached = detached.load(Ordering::Relaxed); + if detached { + crate::config_delivery::publish_sandbox(state, &sandbox_id); + state.sandbox_watch_bus.notify(&sandbox_id); + } let receipt = super::provider_readiness::record_provider_mutation( state, &sandbox, diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 2fbaf63881..f57905b3e3 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -17,6 +17,7 @@ mod auth; pub mod certgen; pub mod cli; mod compute; +mod config_delivery; pub mod config_file; mod config_update_operation; mod credentials; @@ -298,6 +299,8 @@ pub struct ServerState { /// Set once graceful gateway shutdown begins so stream handlers can /// distinguish expected transport closes from runtime failures. pub(crate) gateway_shutting_down: AtomicBool, + /// Per-session delivery tasks for supervisor configuration. + pub(crate) config_delivery: config_delivery::ConfigDelivery, /// Stable identity for this gateway process. pub replica_id: String, @@ -430,6 +433,8 @@ impl ServerState { .oidc .as_ref() .map_or_else(String::new, |oidc| oidc.admin_role.clone()); + let config_delivery = + config_delivery::ConfigDelivery::for_db_connections(store.max_connections()); Self { config, store, @@ -443,6 +448,7 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), supervisor_sessions, gateway_shutting_down: AtomicBool::new(false), + config_delivery, replica_id, peer_endpoint, peer_routes: Arc::new(supervisor_session::PeerRouteCache::default()), @@ -694,9 +700,13 @@ pub(crate) async fn run_server( openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()).await } .map_err(|e| Error::config(format!("gateway interceptor initialization failed: {e}")))?; + // Push delivery needs the refresh to notice catalog changes, so only push + // mode serves interceptor profiles from a cached snapshot. let provider_profile_sources = provider_profile_sources::ProviderProfileSources::from_config( &config.provider_profile_sources, gateway_interceptors.as_ref(), + (config.config_delivery_mode == openshell_core::config::ConfigDeliveryMode::Push) + .then_some(provider_profile_sources::PROVIDER_PROFILE_SOURCE_REFRESH_INTERVAL), ) .map_err(|err| { Error::config(format!( @@ -707,6 +717,8 @@ pub(crate) async fn run_server( sources = ?provider_profile_sources.source_ids(), "provider profile sources configured" ); + // Builds read cached interceptor snapshots, so fetch them before serving. + provider_profile_sources.refresh_cached(&store).await; let mut state = ServerState::new_with_credentials( config.clone(), store.clone(), @@ -841,6 +853,10 @@ pub(crate) async fn run_server( .transpose() .map_err(|e| Error::config(format!("failed to install metrics recorder: {e}")))?; + grpc::policy::backfill_legacy_policy_history(&state) + .await + .map_err(|error| Error::execution(error.to_string()))?; + // Reconcile local-driver running intent before watchers spawn so their // first snapshots observe the post-start backend state. Explicitly stopped // sandboxes remain stopped. @@ -995,6 +1011,7 @@ pub(crate) async fn run_server( ); ssh_sessions::spawn_session_reaper(store.clone(), Duration::from_hours(1)); supervisor_session::spawn_relay_reaper(state.clone(), Duration::from_secs(30)); + provider_profile_sources::spawn_refresh(state.clone()); provider_refresh::spawn_refresh_worker(state.clone(), Duration::from_mins(1)); shutdown_signal().await; diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index faa6fdb444..929e1e72e2 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -316,6 +316,14 @@ impl Store { matches!(self, Self::Sqlite(_)) } + /// Maximum number of pooled database connections for this backend. + pub fn max_connections(&self) -> u32 { + match self { + Self::Postgres(store) => store.max_connections(), + Self::Sqlite(store) => store.max_connections(), + } + } + /// Configured size of the `PostgreSQL` mutation lock pool, or `None` on /// `SQLite`, which has no lock pool. pub fn mutation_lock_connections(&self) -> Option { @@ -1502,6 +1510,14 @@ impl Store { Self::Postgres(_) => unreachable!("tests use SQLite"), } } + + /// Runs raw SQL against the test database. + pub(crate) async fn execute_for_test(&self, sql: &'static str) { + match self { + Self::Sqlite(store) => store.execute_for_test(sql).await, + Self::Postgres(_) => unreachable!("tests use SQLite"), + } + } } #[cfg(test)] diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 64142e05b5..ad5b2bec4c 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -343,6 +343,10 @@ impl PostgresStore { }) } + pub fn max_connections(&self) -> u32 { + self.pool.options().get_max_connections() + } + pub async fn migrate(&self) -> PersistenceResult<()> { POSTGRES_MIGRATOR .run(&self.pool) @@ -1449,6 +1453,50 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8) Ok(()) } + pub async fn put_initial_policy_revision( + &self, + record: &PolicyRecord, + workspace: &str, + ) -> PersistenceResult<()> { + let wrapped_payload = policy_payload_from_record(record)?; + let mut tx = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + + let sandbox_exists = sqlx::query( + "SELECT id FROM objects WHERE object_type = 'sandbox' AND id = $1 FOR UPDATE", + ) + .bind(&record.sandbox_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| map_db_error(&e))? + .is_some(); + + if sandbox_exists { + sqlx::query( + r" +INSERT INTO objects ( + object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms, workspace +) +SELECT $1, $2, $3, 1, $4, $5, $6, $6, $7 +WHERE NOT EXISTS (SELECT 1 FROM objects WHERE object_type = $1 AND scope = $3) +ON CONFLICT DO NOTHING +", + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&record.id) + .bind(&record.sandbox_id) + .bind(&record.status) + .bind(wrapped_payload) + .bind(record.created_at_ms) + .bind(workspace) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + } + + tx.commit().await.map_err(|e| map_db_error(&e))?; + Ok(()) + } + pub async fn put_policy_revision_atomic( &self, write: &AtomicPolicyRevisionWrite, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 77b13434e4..cb76e3fa6f 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -209,9 +209,17 @@ impl SqliteStore { self.close().await; } - /// Test support only: the pool's effective connection ceiling. + /// Runs raw SQL against the test database. #[cfg(test)] - pub(crate) fn max_connections_for_test(&self) -> u32 { + pub(crate) async fn execute_for_test(&self, sql: &'static str) { + sqlx::raw_sql(sql) + .execute(&self.pool) + .await + .expect("test SQL"); + } + + /// The pool's effective connection ceiling. + pub fn max_connections(&self) -> u32 { self.pool.options().get_max_connections() } @@ -1287,6 +1295,36 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8) Ok(()) } + pub async fn put_initial_policy_revision( + &self, + record: &PolicyRecord, + workspace: &str, + ) -> PersistenceResult<()> { + let wrapped_payload = policy_payload_from_record(record)?; + sqlx::query( + r#" +INSERT INTO "objects" ( + "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms", "workspace" +) +SELECT ?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?7 +WHERE EXISTS (SELECT 1 FROM "objects" WHERE "object_type" = 'sandbox' AND "id" = ?3) + AND NOT EXISTS (SELECT 1 FROM "objects" WHERE "object_type" = ?1 AND "scope" = ?3) +ON CONFLICT DO NOTHING +"#, + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&record.id) + .bind(&record.sandbox_id) + .bind(&record.status) + .bind(wrapped_payload) + .bind(record.created_at_ms) + .bind(workspace) + .execute(&self.pool) + .await + .map_err(|e| map_db_error(&e))?; + Ok(()) + } + pub async fn put_policy_revision_atomic( &self, write: &AtomicPolicyRevisionWrite, diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index 93a1d94c8e..be86441245 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use super::{ObjectListQuery, ObjectType, PersistenceError, Store, generate_name, test_store}; +use super::{ + ObjectListQuery, ObjectType, PersistenceError, PolicyRecord, Store, generate_name, test_store, +}; use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; use openshell_core::proto::datamodel::v1::ObjectMeta as ProtoObjectMeta; use openshell_core::proto::{ObjectForTest, Sandbox, SandboxPolicy, SandboxSpec}; @@ -293,12 +295,12 @@ async fn sqlite_pool_ceiling_defaults_and_honours_an_override() { let unset = SqliteStore::connect(&url, None) .await .expect("connect to sqlite"); - assert_eq!(unset.max_connections_for_test(), DEFAULT_MAX_CONNECTIONS); + assert_eq!(unset.max_connections(), DEFAULT_MAX_CONNECTIONS); let raised = SqliteStore::connect(&url, Some(32)) .await .expect("connect to sqlite"); - assert_eq!(raised.max_connections_for_test(), 32); + assert_eq!(raised.max_connections(), 32); } /// An in-memory database lives inside its single connection: a second one @@ -310,7 +312,7 @@ async fn in_memory_sqlite_pins_the_pool_to_one_connection() { let store = SqliteStore::connect("sqlite::memory:", Some(32)) .await .expect("connect to in-memory sqlite"); - assert_eq!(store.max_connections_for_test(), 1); + assert_eq!(store.max_connections(), 1); } /// The SSH identity lock keeps one data connection while it queries the @@ -1418,6 +1420,85 @@ fn policy_test_sandbox(id: &str, name: &str) -> Sandbox { } } +#[tokio::test] +async fn initial_policy_history_is_insert_only() { + assert_initial_policy_history_is_insert_only(&test_store().await).await; +} + +#[tokio::test] +#[ignore = "requires OPENSHELL_TEST_POSTGRES_URL pointing to a test database"] +async fn postgres_initial_policy_history_is_insert_only() { + let url = std::env::var("OPENSHELL_TEST_POSTGRES_URL").expect("test database URL"); + let store = Store::connect(&url).await.unwrap(); + assert_initial_policy_history_is_insert_only(&store).await; +} + +async fn assert_initial_policy_history_is_insert_only(store: &Store) { + let id = uuid::Uuid::new_v4().to_string(); + let sandbox = policy_test_sandbox(&id, &id); + let record = PolicyRecord { + id: uuid::Uuid::new_v4().to_string(), + sandbox_id: id.clone(), + version: 1, + policy_payload: SandboxPolicy::default().encode_to_vec(), + policy_hash: "initial-hash".into(), + status: "loaded".into(), + load_error: None, + created_at_ms: 1, + loaded_at_ms: None, + provenance: StdHashMap::new(), + }; + + store + .put_initial_policy_revision(&record, "default") + .await + .unwrap(); + assert!(store.get_latest_policy(&id).await.unwrap().is_none()); + + store.put_message(&sandbox).await.unwrap(); + let (first, second) = tokio::join!( + store.put_initial_policy_revision(&record, "default"), + store.put_initial_policy_revision(&record, "default"), + ); + first.unwrap(); + second.unwrap(); + let initial = store.get_latest_policy(&id).await.unwrap().unwrap(); + assert_eq!(initial.status, "loaded"); + assert_eq!(initial.policy_hash, record.policy_hash); + assert_eq!(store.list_policies(&id, 10, 0).await.unwrap().len(), 1); + + store + .update_policy_status(&id, 1, "failed", Some("apply failed"), None) + .await + .unwrap(); + store + .put_initial_policy_revision(&record, "default") + .await + .unwrap(); + let failed = store.get_latest_policy(&id).await.unwrap().unwrap(); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.load_error.as_deref(), Some("apply failed")); + + store + .put_policy_revision( + &uuid::Uuid::new_v4().to_string(), + &id, + "default", + 2, + &record.policy_payload, + "new-hash", + ) + .await + .unwrap(); + store + .put_initial_policy_revision(&record, "default") + .await + .unwrap(); + let latest = store.get_latest_policy(&id).await.unwrap().unwrap(); + assert_eq!(latest.version, 2); + assert_eq!(store.list_policies(&id, 10, 0).await.unwrap().len(), 2); +} + #[tokio::test] async fn policy_atomic_write_commits_revision_provenance_and_sandbox_projection() { let store = test_store().await; diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 8ac1092802..c610f21fb8 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -136,6 +136,14 @@ pub fn project_policy_revision_onto_sandbox( } pub trait PolicyStoreExt { + /// Insert version-one policy history when the sandbox still has no policy + /// revisions. Existing history and apply status are left untouched. + async fn put_initial_policy_revision( + &self, + record: &PolicyRecord, + workspace: &str, + ) -> PersistenceResult<()>; + async fn put_policy_revision( &self, id: &str, @@ -270,6 +278,17 @@ pub trait PolicyStoreExt { } impl PolicyStoreExt for Store { + async fn put_initial_policy_revision( + &self, + record: &PolicyRecord, + workspace: &str, + ) -> PersistenceResult<()> { + match self { + Self::Postgres(store) => store.put_initial_policy_revision(record, workspace).await, + Self::Sqlite(store) => store.put_initial_policy_revision(record, workspace).await, + } + } + async fn put_policy_revision( &self, id: &str, diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index d304efb2bc..77230e5cce 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -5,8 +5,11 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; +use std::time::Duration; +use arc_swap::ArcSwapOption; use async_trait::async_trait; +use metrics::{counter, gauge}; use openshell_core::GatewayProviderProfileSourceConfig; use openshell_core::mcp::normalize_provider_profile_mcp_fields; use openshell_core::policy_identity::canonical_rule_bytes; @@ -20,13 +23,23 @@ use openshell_providers::{ }; use prost::Message as _; use sha2::{Digest, Sha256}; +use tokio::time::Instant; use tonic::Status; -use tracing::debug; +use tracing::{debug, warn}; +use crate::ServerState; use crate::persistence::{ObjectListQuery, ObjectType, Store}; use crate::storage_proto::StoredProviderProfile; const USER_SOURCE_ID: &str = "user"; +/// How often the gateway re-fetches interceptor provider-profile sources. +/// Matches the supervisor configuration poll interval. +pub const PROVIDER_PROFILE_SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(10); + +/// A cached snapshot older than this many refresh intervals fails closed: a +/// stale profile could keep injecting credentials for an endpoint its source +/// has withdrawn. +const CACHED_SNAPSHOT_MAX_AGE_INTERVALS: u32 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProfileScope { @@ -167,7 +180,7 @@ impl ProviderProfileSource for GatewayInterceptorProfileSource { "provider profile source '{}' snapshot failed: {err}", self.source_id() ), - std::time::Duration::from_secs(1), + Duration::from_secs(1), ) })?; let profiles = profiles @@ -181,9 +194,98 @@ impl ProviderProfileSource for GatewayInterceptorProfileSource { } } +/// A source served from a snapshot that a background task refreshes, so +/// configuration builds and polls do not call the source on every read. +#[derive(Debug)] +struct CachedProviderProfileSource { + inner: Arc, + cached: ArcSwapOption, + max_age: Duration, +} + +#[derive(Debug)] +struct CachedSnapshot { + snapshot: ProviderProfileSnapshot, + fetched_at: Instant, +} + +impl CachedProviderProfileSource { + fn new(inner: Arc, refresh_interval: Duration) -> Self { + Self { + inner, + cached: ArcSwapOption::empty(), + max_age: refresh_interval.saturating_mul(CACHED_SNAPSHOT_MAX_AGE_INTERVALS), + } + } + + /// Fetch a new snapshot. Returns whether its revision differs from the + /// cached one; a failure keeps serving the cached snapshot until it ages + /// out. + async fn refresh(&self, store: &Store) -> Result { + let snapshot = self.inner.snapshot(store, "").await?; + let changed = self + .cached + .load() + .as_ref() + .is_none_or(|cached| cached.snapshot.revision != snapshot.revision); + self.cached.store(Some(Arc::new(CachedSnapshot { + snapshot, + fetched_at: Instant::now(), + }))); + Ok(changed) + } + + fn age(&self) -> Option { + self.cached + .load() + .as_ref() + .map(|cached| cached.fetched_at.elapsed()) + } +} + +#[async_trait] +impl ProviderProfileSource for CachedProviderProfileSource { + fn source_id(&self) -> &str { + self.inner.source_id() + } + + fn user_managed(&self) -> bool { + self.inner.user_managed() + } + + fn allow_empty(&self) -> bool { + self.inner.allow_empty() + } + + async fn snapshot( + &self, + _store: &Store, + _workspace: &str, + ) -> Result { + let cached = self.cached.load(); + let Some(cached) = cached + .as_ref() + .filter(|cached| cached.fetched_at.elapsed() <= self.max_age) + else { + return Err(openshell_core::rpc_error::unavailable( + "PROFILE_SOURCE_UNAVAILABLE", + format!( + "provider profile source '{}' has no current snapshot", + self.source_id() + ), + Duration::from_secs(1), + )); + }; + Ok(cached.snapshot.clone()) + } +} + #[derive(Debug, Clone)] pub struct ProviderProfileSources { sources: Vec>, + /// Sources served from a refreshed snapshot, also present in `sources`. + cached: Vec>, + refresh_interval: Duration, } #[derive(Debug, Clone)] @@ -224,14 +326,24 @@ impl ProviderProfileSources { /// A gateway with nothing imported serves an empty catalog, which is a /// valid state, not a startup failure. pub fn with_default_sources() -> Self { + Self::uncached(vec![Arc::new(UserProviderProfileSource)]) + } + + fn uncached(sources: Vec>) -> Self { Self { - sources: vec![Arc::new(UserProviderProfileSource)], + sources, + cached: Vec::new(), + refresh_interval: Duration::ZERO, } } + /// With a `refresh_interval`, interceptor sources are served from a + /// snapshot that [`spawn_refresh`] refreshes at that interval. Without + /// one, every read calls the interceptor. pub fn from_config( configured: &[GatewayProviderProfileSourceConfig], runtime: Option<&GatewayInterceptorRuntime>, + refresh_interval: Option, ) -> Result { if configured.is_empty() { return Err("provider_profile_sources must contain at least one source".to_string()); @@ -239,6 +351,7 @@ impl ProviderProfileSources { let mut source_ids = BTreeSet::new(); let mut sources: Vec> = Vec::with_capacity(configured.len()); + let mut cached = Vec::new(); for source in configured { let source: Arc = match source { GatewayProviderProfileSourceConfig::User => Arc::new(UserProviderProfileSource), @@ -254,7 +367,18 @@ impl ProviderProfileSources { "provider profile source interceptor '{name}' is not configured or does not advertise provider_profiles" ) })?; - Arc::new(source) + let source: Arc = Arc::new(source); + match refresh_interval { + Some(refresh_interval) => { + let source = Arc::new(CachedProviderProfileSource::new( + source, + refresh_interval, + )); + cached.push(Arc::clone(&source)); + source + } + None => source, + } } }; let source_id = source.source_id().to_string(); @@ -265,7 +389,40 @@ impl ProviderProfileSources { } sources.push(source); } - Ok(Self { sources }) + Ok(Self { + sources, + cached, + refresh_interval: refresh_interval.unwrap_or_default(), + }) + } + + /// Refresh every cached source. Returns whether any revision changed. + pub(crate) async fn refresh_cached(&self, store: &Store) -> bool { + if self.cached.is_empty() { + return false; + } + let mut changed = false; + let mut oldest = Duration::ZERO; + for source in &self.cached { + match source.refresh(store).await { + Ok(source_changed) => { + changed |= source_changed; + record_refresh("ok"); + } + Err(error) => { + record_refresh("failed"); + warn!( + source_id = source.source_id(), + error = error.message(), + "provider profile source refresh failed" + ); + } + } + oldest = oldest.max(source.age().unwrap_or(Duration::MAX)); + } + gauge!("openshell_server_provider_profile_source_snapshot_age_seconds") + .set(oldest.as_secs_f64()); + changed } pub fn source_ids(&self) -> Vec<&str> { @@ -285,14 +442,12 @@ impl ProviderProfileSources { profile, }) .collect(); - Self { - sources: vec![Arc::new(StaticProviderProfileSource { - snapshot: ProviderProfileSnapshot { - revision, - profiles: scoped, - }, - })], - } + Self::uncached(vec![Arc::new(StaticProviderProfileSource { + snapshot: ProviderProfileSnapshot { + revision, + profiles: scoped, + }, + })]) } /// A source-managed catalog composed with the user-managed source. @@ -304,7 +459,7 @@ impl ProviderProfileSources { pub(crate) fn from_test_profiles_with_user_source(profiles: Vec) -> Self { let mut sources = Self::from_test_profiles(profiles).sources; sources.push(Arc::new(UserProviderProfileSource)); - Self { sources } + Self::uncached(sources) } #[cfg(test)] @@ -316,24 +471,22 @@ impl ProviderProfileSources { !snapshots.is_empty(), "test snapshot sequence must not be empty" ); - Self { - sources: vec![Arc::new(SequencedProviderProfileSource { - snapshots: snapshots - .into_iter() - .map(|(revision, profiles)| ProviderProfileSnapshot { - revision, - profiles: profiles - .into_iter() - .map(|profile| ScopedSnapshotProfile { - scope: ProfileScope::Static, - profile, - }) - .collect(), - }) - .collect(), - fetch_count, - })], - } + Self::uncached(vec![Arc::new(SequencedProviderProfileSource { + snapshots: snapshots + .into_iter() + .map(|(revision, profiles)| ProviderProfileSnapshot { + revision, + profiles: profiles + .into_iter() + .map(|profile| ScopedSnapshotProfile { + scope: ProfileScope::Static, + profile, + }) + .collect(), + }) + .collect(), + fetch_count, + })]) } pub(crate) async fn snapshot_catalog( @@ -372,6 +525,41 @@ impl ProviderProfileSources { } } +fn record_refresh(outcome: &'static str) { + counter!( + "openshell_server_provider_profile_source_refreshes_total", + "outcome" => outcome, + ) + .increment(1); +} + +/// Keep cached provider profile sources fresh, and push a changed catalog to +/// this replica's supervisor sessions. Every replica runs its own refresh, so +/// none notifies its peers. The first refresh runs before the gateway serves, +/// in `run_server`. +pub fn spawn_refresh(state: Arc) { + let sources = &state.provider_profile_sources; + if sources.cached.is_empty() { + return; + } + let interval = sources.refresh_interval; + tokio::spawn(async move { + let mut timer = tokio::time::interval(interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + timer.tick().await; + loop { + timer.tick().await; + if state + .provider_profile_sources + .refresh_cached(&state.store) + .await + { + crate::config_delivery::publish_all_local(&state); + } + } + }); +} + impl EffectiveProviderProfileCatalog { pub(crate) fn revision(&self) -> &str { &self.revision @@ -1147,6 +1335,7 @@ mod tests { Arc::clone(&fetch_count), ); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; let first = sources.snapshot_catalog(&store, "default").await.unwrap(); assert_eq!(fetch_count.load(Ordering::SeqCst), 1); @@ -1286,6 +1475,7 @@ mod tests { GatewayProviderProfileSourceConfig::User, ], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); @@ -1293,9 +1483,53 @@ mod tests { task.abort(); } + #[tokio::test] + async fn interceptor_sources_are_cached_only_with_a_refresh_interval() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "external".to_string(), + profiles: vec![profile("governed-github")], + }, + true, + ) + .await; + let store = crate::persistence::test_store().await; + let configured = [GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }]; + + // A cached source fails closed until its first refresh, so a read + // that succeeds without one went to the interceptor. + let uncached = + ProviderProfileSources::from_config(&configured, Some(&runtime), None).unwrap(); + assert!(uncached.cached.is_empty()); + uncached + .snapshot_catalog(&store, "default") + .await + .expect("an uncached interceptor source is read on request"); + + let cached = ProviderProfileSources::from_config( + &configured, + Some(&runtime), + Some(Duration::from_secs(10)), + ) + .unwrap(); + assert_eq!(cached.cached.len(), 1); + assert_eq!( + cached + .snapshot_catalog(&store, "default") + .await + .unwrap_err() + .code(), + tonic::Code::Unavailable + ); + task.abort(); + } + #[test] fn configured_sources_must_not_be_empty() { - let err = ProviderProfileSources::from_config(&[], None).unwrap_err(); + let err = ProviderProfileSources::from_config(&[], None, Some(Duration::from_secs(10))) + .unwrap_err(); assert!(err.contains("at least one source")); } @@ -1307,6 +1541,7 @@ mod tests { GatewayProviderProfileSourceConfig::User, ], None, + Some(Duration::from_secs(10)), ) .unwrap_err(); assert!(err.contains("duplicate provider profile source 'user'")); @@ -1319,6 +1554,7 @@ mod tests { name: "governance".to_string(), }], None, + Some(Duration::from_secs(10)), ) .unwrap_err(); assert!(err.contains("not configured or does not advertise provider_profiles")); @@ -1384,9 +1620,11 @@ mod tests { name: "governance".to_string(), }], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; let profiles = sources .snapshot_catalog(&store, "default") @@ -1420,9 +1658,11 @@ mod tests { name: "governance".to_string(), }], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; let err = sources .snapshot_catalog(&store, "default") @@ -1447,9 +1687,11 @@ mod tests { name: "governance".to_string(), }], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; let err = sources .snapshot_catalog(&store, "default") @@ -1477,9 +1719,11 @@ mod tests { }, ], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; store .put_message(&stored_provider_profile(profile("github"))) .await @@ -1517,9 +1761,11 @@ mod tests { }, ], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; store .put_message(&stored_provider_profile(profile("github"))) .await @@ -1551,9 +1797,11 @@ mod tests { name: "governance".to_string(), }], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; let err = sources .snapshot_catalog(&store, "default") @@ -1581,9 +1829,11 @@ mod tests { }, ], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap(); let store = crate::persistence::test_store().await; + sources.refresh_cached(&store).await; store .put_message(&stored_provider_profile(profile("github"))) .await @@ -1606,6 +1856,7 @@ mod tests { name: "governance".to_string(), }], Some(&runtime), + Some(Duration::from_secs(10)), ) .unwrap_err(); @@ -2210,4 +2461,109 @@ mod tests { assert_eq!(resolved.id, "github"); assert_eq!(resolved.display_name, "Private GitHub"); } + + /// Serves `revision` until `fail` is set. + #[derive(Debug, Default)] + struct SwitchableSource { + revision: std::sync::Mutex, + fail: std::sync::atomic::AtomicBool, + fetches: AtomicUsize, + } + + #[async_trait] + impl ProviderProfileSource for SwitchableSource { + fn source_id(&self) -> &'static str { + "switchable" + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot( + &self, + _store: &Store, + _workspace: &str, + ) -> Result { + self.fetches.fetch_add(1, Ordering::SeqCst); + if self.fail.load(Ordering::SeqCst) { + return Err(Status::unavailable("source down")); + } + Ok(ProviderProfileSnapshot { + revision: self.revision.lock().unwrap().clone(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Static, + profile: profile("github"), + }], + }) + } + } + + fn cached_source(inner: &Arc) -> CachedProviderProfileSource { + *inner.revision.lock().unwrap() = "r1".to_string(); + CachedProviderProfileSource::new( + Arc::::clone(inner), + Duration::from_secs(10), + ) + } + + #[tokio::test] + async fn cached_source_fails_closed_before_its_first_refresh() { + let store = crate::persistence::test_store().await; + let inner = Arc::new(SwitchableSource::default()); + let cached = cached_source(&inner); + + let error = cached.snapshot(&store, "default").await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::Unavailable); + assert_eq!(inner.fetches.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn cached_source_reads_do_not_fetch_and_refresh_reports_revision_changes() { + let store = crate::persistence::test_store().await; + let inner = Arc::new(SwitchableSource::default()); + let cached = cached_source(&inner); + + assert!(cached.refresh(&store).await.unwrap(), "first snapshot"); + for _ in 0..3 { + assert_eq!( + cached.snapshot(&store, "default").await.unwrap().revision, + "r1" + ); + } + assert_eq!(inner.fetches.load(Ordering::SeqCst), 1); + + assert!(!cached.refresh(&store).await.unwrap(), "unchanged revision"); + *inner.revision.lock().unwrap() = "r2".to_string(); + assert!(cached.refresh(&store).await.unwrap(), "changed revision"); + assert_eq!( + cached.snapshot(&store, "default").await.unwrap().revision, + "r2" + ); + } + + #[tokio::test] + async fn cached_source_serves_a_failed_refresh_until_the_snapshot_ages_out() { + let store = crate::persistence::test_store().await; + let inner = Arc::new(SwitchableSource::default()); + let cached = cached_source(&inner); + tokio::time::pause(); + cached.refresh(&store).await.unwrap(); + inner.fail.store(true, Ordering::SeqCst); + + tokio::time::advance(Duration::from_secs(20)).await; + assert!(cached.refresh(&store).await.is_err()); + assert_eq!( + cached.snapshot(&store, "default").await.unwrap().revision, + "r1" + ); + + tokio::time::advance(Duration::from_secs(11)).await; + let error = cached.snapshot(&store, "default").await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::Unavailable); + } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index c2c3cb37a9..2e24579450 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -2236,24 +2236,37 @@ pub fn spawn_refresh_worker(state: std::sync::Arc, interval: ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; - if let Err(err) = Box::pin(run_refresh_worker_tick( + match Box::pin(run_refresh_worker_tick( state.store.as_ref(), Some(&state.credentials), Some(&state.compute), )) .await { - warn!(error = %err, "provider credential refresh worker tick failed"); + Ok(providers) => { + for (workspace, provider_name) in providers { + crate::config_delivery::publish_provider( + &state, + &workspace, + &provider_name, + ); + } + } + Err(err) => { + warn!(error = %err, "provider credential refresh worker tick failed"); + } } } }); } +/// Returns the `(workspace, provider)` pairs whose provider records changed. async fn run_refresh_worker_tick( store: &Store, credentials: Option<&crate::credentials::CredentialRuntime>, compute: Option<&crate::compute::ComputeRuntime>, -) -> Result<(), Status> { +) -> Result, Status> { + let changed_providers = std::collections::HashSet::new(); let now_ms = current_time_ms(); let states = list_all_refresh_states(store).await?; let watched_count = states.len(); @@ -2273,7 +2286,7 @@ async fn run_refresh_worker_tick( .iter() .any(|state| refresh_state_has_work(state, now_ms)) { - return Ok(()); + return Ok(changed_providers); } let span = tracing::info_span!( "refresh", @@ -2281,8 +2294,10 @@ async fn run_refresh_worker_tick( watched_count, due_count, ); - Box::pin(refresh_states(store, credentials, compute, states, now_ms).instrument(span)).await; - Ok(()) + Ok( + Box::pin(refresh_states(store, credentials, compute, states, now_ms).instrument(span)) + .await, + ) } fn refresh_state_has_work(state: &StoredProviderCredentialRefreshState, now_ms: i64) -> bool { @@ -2302,7 +2317,8 @@ async fn refresh_states( compute: Option<&crate::compute::ComputeRuntime>, states: Vec, now_ms: i64, -) { +) -> std::collections::HashSet<(String, String)> { + let mut changed_providers = std::collections::HashSet::new(); for state in states { if state .metadata @@ -2332,6 +2348,8 @@ async fn refresh_states( error = %err, "failed to finalize tombstoned provider refresh; retrying on the next sweep" ); + } else { + changed_providers.insert(changed_provider(&state)); } continue; } @@ -2410,8 +2428,18 @@ async fn refresh_states( error = %err, "provider credential refresh failed" ); + } else { + changed_providers.insert(changed_provider(&state)); } } + changed_providers +} + +fn changed_provider(state: &StoredProviderCredentialRefreshState) -> (String, String) { + ( + state.object_workspace().to_string(), + state.provider_name.clone(), + ) } #[cfg(test)] @@ -4092,10 +4120,18 @@ mod tests { put_refresh_state(&store, &state).await.unwrap(); assert_eq!(credentials.stored_credential_count(), Some(1)); - Box::pin(run_refresh_worker_tick(&store, Some(&credentials), None)) + let changed = Box::pin(run_refresh_worker_tick(&store, Some(&credentials), None)) .await .unwrap(); + assert_eq!( + changed, + std::collections::HashSet::from([( + "default".to_string(), + "tombstoned-refresh".to_string() + )]), + "only the refreshed provider's attached sandboxes need an update" + ); assert!( get_refresh_state( &store, diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 7490513692..808c47cab3 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -123,7 +123,8 @@ mod tests { // Unspecified (treated as Never), zero count, and absent timestamps. // ProviderProfileFile is reachable from stored provider profiles. Its // additive declaration changes the durable and public/durable overlap - // inventories; the provider-environment file map is public-only. The + // inventories; the provider-environment file map and peer configuration + // notification are public-only. The // request has no provider-file capability field: older supervisors ignore // the additive file map while retaining the rest of the response. // Preparation timing adds two optional timestamps to SandboxProvisioning. @@ -138,7 +139,8 @@ mod tests { // decoding or deadline updates cannot claim an existing attempt. // Stable placeholders add a stored credential flag whose absent value is // false. Capability negotiation and delivered key lists are public-only; - // the set of shared message and enum types remains unchanged. + // the set of shared message and enum types remains unchanged. Delivered + // provider snapshots carry the same flag per value and are never stored. // SessionRedirect and SupervisorHello.redirected are supervisor control // traffic and are never stored. // Token-grant owner fields add gateway-derived metadata to endpoints and @@ -155,7 +157,7 @@ mod tests { // closures and TokenGrantStatus to public reports. Older rows have no grant // record; absent report extensions retain compatibility without a rewrite. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "cc06b38c9ecb4c6518e0d4963f40c4f16f992483ce111c7b23cbcb729db946ea"; + "a91e2244a2c1a8e369efe85bd0e4f2769f0f29f6263137280fe38e8a8da3cc6e"; const DURABLE_SCHEMA_SHA256: &str = "d4dc7abdb91b919db1f7be0d4652e30cbfda86f3b2ed9b59d5de1f76a2bf691d"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = @@ -204,6 +206,9 @@ mod tests { "openshell.v1.OpenShell/PeerReportEndpointStatus|.openshell.v1.ReportEndpointStatusRequest|.openshell.v1.ReportEndpointStatusResponse|false|false", "openshell.v1.OpenShell/PeerReportProviderReadiness|.openshell.v1.ReportProviderReadinessRequest|.openshell.v1.ReportProviderReadinessResponse|false|false", ]; + const PEER_CONFIG_RPC_SIGNATURES: [&str; 1] = [ + "openshell.v1.OpenShell/PeerNotifyConfigUpdate|.openshell.v1.PeerNotifyConfigUpdateRequest|.openshell.v1.PeerNotifyConfigUpdateResponse|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 = @@ -601,17 +606,26 @@ mod tests { "peer owner RPC is missing or changed: {signature}" ); } + for signature in PEER_CONFIG_RPC_SIGNATURES { + assert!( + methods.iter().any(|method| method == signature), + "peer config RPC is missing or changed: {signature}" + ); + } assert_eq!( compiled_method_count, 103 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + + PEER_CONFIG_RPC_SIGNATURES.len() + isolation_methods.len() + sandbox_protocol_methods.len(), "classify every compiled RPC" ); assert_eq!( methods.len(), - 78 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len(), + 78 + PROVIDER_READINESS_RPC_SIGNATURES.len() + + PEER_OWNER_RPC_SIGNATURES.len() + + PEER_CONFIG_RPC_SIGNATURES.len(), "inventory every public gateway RPC" ); assert_eq!( @@ -619,7 +633,9 @@ mod tests { .iter() .filter(|method| method.starts_with("openshell.v1.OpenShell/")) .count(), - 78 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() + 78 + PROVIDER_READINESS_RPC_SIGNATURES.len() + + PEER_OWNER_RPC_SIGNATURES.len() + + PEER_CONFIG_RPC_SIGNATURES.len() ); assert!(methods.iter().all(|method| !method.contains(".storage."))); @@ -663,7 +679,7 @@ mod tests { overlap_hash.as_str(), ), ( - (314, 28), + (328, 29), (95, 22), (83, 22), PUBLIC_RPC_SCHEMA_SHA256, diff --git a/crates/openshell-server/src/supervisor_owner.rs b/crates/openshell-server/src/supervisor_owner.rs index 8486b5f48c..11a79f2a3a 100644 --- a/crates/openshell-server/src/supervisor_owner.rs +++ b/crates/openshell-server/src/supervisor_owner.rs @@ -3,7 +3,7 @@ //! Shared supervisor-session ownership index for HA gateway replicas. -use crate::persistence::{PersistenceError, Store, WriteCondition}; +use crate::persistence::{ObjectRecord, PersistenceError, Store, WriteCondition}; use openshell_core::time::now_ms; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -49,6 +49,16 @@ struct OwnerPayload { connected_at_ms: i64, } +/// Marks an owner record written by a gateway that advertises no peer endpoint. +/// Only that gateway can serve such a session, so no peer should dial it. +pub const LOCAL_OWNER_ENDPOINT_SCHEME: &str = "local://"; + +/// `endpoint`, unless it is empty or a `local://` placeholder that no other +/// replica can dial. +pub fn dialable_peer_endpoint(endpoint: &str) -> Option<&str> { + (!endpoint.is_empty() && !endpoint.starts_with(LOCAL_OWNER_ENDPOINT_SCHEME)).then_some(endpoint) +} + #[derive(Debug, Clone)] pub struct OwnerRecord { pub session_id: String, @@ -68,6 +78,11 @@ impl OwnerRecord { let ttl_ms = i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX); now_ms().saturating_sub(self.updated_at_ms).max(0) < ttl_ms } + + /// The endpoint other replicas dial to reach this owner, if it has one. + pub fn peer_endpoint(&self) -> Option<&str> { + dialable_peer_endpoint(&self.owner_peer_endpoint) + } } #[derive(Debug, Clone)] @@ -222,18 +237,7 @@ impl SupervisorOwnerIndex { return Ok(None); }; - let payload: OwnerPayload = serde_json::from_slice(&record.payload) - .map_err(|err| PersistenceError::Decode(err.to_string()))?; - Ok(Some(OwnerRecord { - session_id: payload.session_id, - supervisor_instance_id: payload.supervisor_instance_id, - connection_epoch: payload.connection_epoch, - owner_replica_id: payload.owner_replica_id, - owner_peer_endpoint: payload.owner_peer_endpoint, - connected_at_ms: payload.connected_at_ms, - updated_at_ms: record.updated_at_ms, - resource_version: record.resource_version, - })) + Ok(Some(decode_owner_record(&record)?.1)) } async fn write_payload( @@ -266,6 +270,29 @@ impl SupervisorOwnerIndex { } } +fn decode_owner_record(record: &ObjectRecord) -> Result<(String, OwnerRecord), OwnerError> { + let payload: OwnerPayload = serde_json::from_slice(&record.payload) + .map_err(|err| PersistenceError::Decode(err.to_string()))?; + if payload.sandbox_id != record.name || record.id != owner_object_id(&payload.sandbox_id) { + return Err(OwnerError::Store(PersistenceError::Decode( + "supervisor owner identity mismatch".to_string(), + ))); + } + Ok(( + payload.sandbox_id, + OwnerRecord { + session_id: payload.session_id, + supervisor_instance_id: payload.supervisor_instance_id, + connection_epoch: payload.connection_epoch, + owner_replica_id: payload.owner_replica_id, + owner_peer_endpoint: payload.owner_peer_endpoint, + connected_at_ms: payload.connected_at_ms, + updated_at_ms: record.updated_at_ms, + resource_version: record.resource_version, + }, + )) +} + pub fn can_supersede( existing: &OwnerRecord, supervisor_instance_id: &str, diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 35862d4547..a0fb2654be 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use metrics::counter; +use prost::Message; use tokio::sync::{OwnedRwLockReadGuard, RwLock, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tonic::metadata::{Ascii, MetadataValue}; @@ -17,23 +19,25 @@ use uuid::Uuid; use openshell_core::proto::{ GatewayMessage, GetSandboxProviderStatusRequest, GetSandboxProviderStatusResponse, - PeerRelayFrame, PeerRelayInit, ProviderReadinessObservation, RelayFrame, RelayInit, RelayOpen, - ReportEndpointStatusRequest, ReportEndpointStatusResponse, ReportMainProcessExitRequest, - ReportMainProcessExitResponse, ReportProviderReadinessRequest, ReportProviderReadinessResponse, - Sandbox, SandboxPhase, SessionAccepted, SessionRedirect, SshRelayTarget, SupervisorHello, - SupervisorMessage, gateway_message, open_shell_client, peer_relay_frame, relay_open, - supervisor_message, + PeerNotifyConfigUpdateRequest, PeerNotifyConfigUpdateResponse, PeerRelayFrame, PeerRelayInit, + ProviderReadinessObservation, RelayFrame, RelayInit, RelayOpen, ReportEndpointStatusRequest, + ReportEndpointStatusResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, + ReportProviderReadinessRequest, ReportProviderReadinessResponse, Sandbox, SandboxPhase, + SessionAccepted, SessionRedirect, SshRelayTarget, SupervisorHello, SupervisorMessage, + gateway_message, open_shell_client, peer_relay_frame, relay_open, supervisor_message, }; use openshell_core::transport_errors::is_expected_transport_close_status; use crate::ServerState; use crate::auth::principal::Principal; +use crate::config_delivery::MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES; +use crate::config_delivery::session::{self as config_session, ConfigSessionState}; use crate::gateway_metrics::{ self, GaugeSlot, PeerRpc, RelayCapacity, RelayKind, RelayRejection, RelayRoute, RoutedRequestTimer, }; use crate::grpc::provider_readiness::ProviderReadinessEvidence; -use crate::persistence::ObjectId; +use crate::persistence::{ObjectId, ObjectWorkspace}; use crate::supervisor_owner::{ OWNER_TTL, OwnerError, OwnerGuard, SupervisorOwnerIndex, can_supersede, }; @@ -51,6 +55,16 @@ pub(crate) const SUPERVISOR_SESSION_SHUTDOWN_TIMEOUT: Duration = Duration::from_ // least as much budget after the grace as the grace itself. const _: () = assert!(SHUTDOWN_HANDOFF_GRACE.as_secs() * 2 <= SUPERVISOR_SESSION_SHUTDOWN_TIMEOUT.as_secs()); + +/// How a supervisor session receives configuration. +#[derive(Clone, Copy, Debug)] +pub(crate) enum SessionMode { + /// The supervisor polls for configuration. + Poll, + /// Applies and acknowledges configuration streamed on the session. + Push, +} + /// Initial backoff between session-availability polls in `wait_for_session`. const SESSION_WAIT_INITIAL_BACKOFF: Duration = Duration::from_millis(100); /// Maximum backoff between session-availability polls in `wait_for_session`. @@ -76,9 +90,6 @@ const PEER_TLS_KEY_FILE_ENV: &str = "OPENSHELL_PEER_TLS_KEY_FILE"; const PEER_TLS_SERVER_NAME_ENV: &str = "OPENSHELL_PEER_TLS_SERVER_NAME"; /// How long a resolved owner record is reused before rereading the store. /// Well below `OWNER_TTL` so a cache hit can never outlive the record itself. -/// Marks an owner record written by a gateway that advertises no peer endpoint. -/// Only that gateway can serve such a session, so no peer should dial it. -const LOCAL_OWNER_ENDPOINT_SCHEME: &str = "local://"; const OWNER_CACHE_TTL: Duration = Duration::from_secs(3); /// How often the owner cache reclaims expired entries. Rate-limited so an /// insert never scans the whole map. @@ -296,12 +307,16 @@ struct LiveSession { /// removing a session that has since been superseded by a reconnect. session_id: String, tx: mpsc::Sender, + /// Present only for sessions that apply streamed configuration. + config: Option, /// Fires when this session is superseded by a reconnect so the old session /// task can exit promptly — dropping its own `tx` clone and closing the /// outbound stream. Without this, a concurrent `open_relay` that grabbed /// the old session's `tx` just before supersede could still enqueue a /// `RelayOpen` onto the stale stream and sit until the relay timeout. - shutdown: oneshot::Sender<()>, + /// Taken when the gateway closes a session that stays registered until + /// its task cleans up. + shutdown: Option>, /// Set after the supervisor confirms that every expected foreground /// attachment has closed and terminal output delivery is complete. terminal_delivery_finalized: bool, @@ -431,6 +446,25 @@ impl SupervisorSessionRegistry { tx: mpsc::Sender, shutdown: oneshot::Sender<()>, ) -> bool { + self.register_with_mode(sandbox_id, session_id, tx, shutdown, SessionMode::Poll) + } + + /// Register a session with its configuration delivery mode. + /// + /// A push session acknowledges each update before the next one is + /// released. + pub(crate) fn register_with_mode( + &self, + sandbox_id: String, + session_id: String, + tx: mpsc::Sender, + shutdown: oneshot::Sender<()>, + mode: SessionMode, + ) -> bool { + let config = match mode { + SessionMode::Push => Some(ConfigSessionState::new(tx.clone())), + SessionMode::Poll => None, + }; let mut sessions = self.sessions.lock().unwrap(); let previous = sessions.remove(&sandbox_id); sessions.insert( @@ -439,7 +473,8 @@ impl SupervisorSessionRegistry { sandbox_id, session_id, tx, - shutdown, + config, + shutdown: Some(shutdown), terminal_delivery_finalized: false, endpoint_status_initialized: false, endpoint_report_cursor: None, @@ -451,7 +486,9 @@ impl SupervisorSessionRegistry { match previous { Some(prev) => { // Best-effort — the old task may have already exited. - let _ = prev.shutdown.send(()); + if let Some(shutdown) = prev.shutdown { + let _ = shutdown.send(()); + } true } None => false, @@ -470,13 +507,31 @@ impl SupervisorSessionRegistry { pub fn disconnect(&self, sandbox_id: &str) -> bool { let session = self.sessions.lock().unwrap().remove(sandbox_id); if let Some(session) = session { - let _ = session.shutdown.send(()); + if let Some(shutdown) = session.shutdown { + let _ = shutdown.send(()); + } true } else { false } } + /// End this exact session's task while leaving it registered, so the + /// task's own cleanup records the disconnect as for any other session + /// end. + pub(crate) fn close_if_current(&self, sandbox_id: &str, session_id: &str) { + let shutdown = self + .sessions + .lock() + .unwrap() + .get_mut(sandbox_id) + .filter(|session| session.session_id == session_id) + .and_then(|session| session.shutdown.take()); + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); + } + } + /// Remove the session only if its `session_id` matches the one we are /// cleaning up. Returns `true` if the entry was removed. /// @@ -555,6 +610,32 @@ impl SupervisorSessionRegistry { true } + /// Run `update` on the streamed configuration state of this exact session, + /// or return `None` when that session was replaced or removed. `update` + /// receives `None` for a session that keeps polling. + pub(crate) fn with_config_session( + &self, + sandbox_id: &str, + session_id: &str, + update: impl FnOnce(Option<&mut ConfigSessionState>) -> T, + ) -> Option { + let mut sessions = self.sessions.lock().unwrap(); + let session = sessions + .get_mut(sandbox_id) + .filter(|session| session.session_id == session_id)?; + Some(update(session.config.as_mut())) + } + + /// Whether the current local session applies streamed configuration, or + /// `None` when this replica has no session for the sandbox. + pub fn current_session_applies_config(&self, sandbox_id: &str) -> Option { + self.sessions + .lock() + .unwrap() + .get(sandbox_id) + .map(|session| session.config.is_some()) + } + pub fn is_current_session(&self, sandbox_id: &str, session_id: &str) -> bool { self.sessions .lock() @@ -1089,20 +1170,16 @@ fn owner_error_to_status(err: OwnerError) -> Status { } } -async fn require_persisted_sandbox( +pub(crate) async fn require_persisted_sandbox( store: &Arc, sandbox_id: &str, -) -> Result<(), Status> { +) -> Result { let sandbox = store .get_message::(sandbox_id) .await .map_err(|err| Status::internal(format!("failed to load sandbox: {err}")))?; - if sandbox.is_none() { - return Err(Status::not_found("sandbox not found")); - } - - Ok(()) + sandbox.ok_or_else(|| Status::not_found("sandbox not found")) } // --------------------------------------------------------------------------- @@ -1391,6 +1468,19 @@ async fn peer_rpc_client( )) } +pub(crate) async fn forward_config_notify_to_peer( + state: &Arc, + endpoint: &str, + request: PeerNotifyConfigUpdateRequest, +) -> Result { + let mut client = peer_rpc_client(state, endpoint).await?; + client + .peer_notify_config_update(request) + .await + .map(Response::into_inner) + .inspect_err(|_| state.peer_routes.evict_channel(endpoint)) +} + pub(crate) async fn remote_supervisor_owner( state: &Arc, sandbox_id: &str, @@ -1416,7 +1506,7 @@ pub(crate) async fn remote_supervisor_owner( if owner.owner_replica_id == state.replica_id { return Ok(None); } - if owner_endpoint_is_local_only(&owner.owner_peer_endpoint) { + if owner.peer_endpoint().is_none() { return Err(Status::failed_precondition(format!( "sandbox is owned by gateway replica {} which advertises no peer endpoint", owner.owner_replica_id @@ -1625,7 +1715,7 @@ pub async fn open_routed_relay_with_message( backoff = (backoff * 2).min(SESSION_WAIT_MAX_BACKOFF); continue; } - if owner_endpoint_is_local_only(&owner.owner_peer_endpoint) { + if owner.peer_endpoint().is_none() { return Err(Status::failed_precondition(format!( "sandbox is owned by gateway replica {} which advertises no peer endpoint; \ set OPENSHELL_PEER_ENDPOINT on every replica to route across replicas", @@ -1724,12 +1814,10 @@ fn owner_hint( /// Endpoint recorded when this replica advertises none. fn local_owner_endpoint(replica_id: &str) -> String { - format!("{LOCAL_OWNER_ENDPOINT_SCHEME}{replica_id}") -} - -/// True when an owner record names a gateway that no peer can dial. -fn owner_endpoint_is_local_only(endpoint: &str) -> bool { - endpoint.starts_with(LOCAL_OWNER_ENDPOINT_SCHEME) + format!( + "{}{replica_id}", + crate::supervisor_owner::LOCAL_OWNER_ENDPOINT_SCHEME + ) } /// Where the ring says this sandbox belongs, when that is not this replica. @@ -1781,11 +1869,7 @@ fn ring_redirect( return None; } let peer_endpoint = preferred_peer_endpoint?; - // `local://` endpoints are placeholders used when no peer endpoint is - // configured, so they are not dialable from another replica. - if peer_endpoint.is_empty() || owner_endpoint_is_local_only(&peer_endpoint) { - return None; - } + crate::supervisor_owner::dialable_peer_endpoint(&peer_endpoint)?; Some(SessionRedirect { peer_endpoint, @@ -2067,108 +2151,93 @@ fn spawn_peer_owner_bridge( // ConnectSupervisor gRPC handler // --------------------------------------------------------------------------- -pub async fn handle_connect_supervisor( - state: &Arc, - request: Request>, -) -> Result< - Response< - Pin> + Send + 'static>>, - >, - Status, -> { - let principal = request.extensions().get::().cloned(); - let mut inbound = request.into_inner(); - - // Step 1: Wait for SupervisorHello. - let hello = match inbound.message().await? { - Some(msg) => match msg.payload { - Some(supervisor_message::Payload::Hello(hello)) => hello, - _ => return Err(Status::invalid_argument("expected SupervisorHello")), - }, - None => return Err(Status::invalid_argument("stream closed before hello")), - }; - - let sandbox_id = hello.sandbox_id.clone(); - if sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - if let Some(principal) = principal.as_ref() { - crate::auth::guard::ensure_sandbox_principal_scope(principal, &sandbox_id)?; - } - require_persisted_sandbox(&state.store, &sandbox_id).await?; - // Validate readiness identities before replacing a healthy session. Older - // supervisors remain usable but cannot assert provider installation. - let provider_readiness = ProviderReadinessEvidence::from_hello(&hello)?; - - // If the ring places this sandbox on a different live replica, send the - // supervisor there instead of claiming ownership here. Decided before the - // session is tracked so a redirect never consumes a session slot. An - // established session is only relocated when this replica shuts down. - if may_redirect(&hello, state.gateway_shutting_down.load(Ordering::Acquire)) - && let Some(redirect) = preferred_peer_redirect(state, &sandbox_id) - { - info!( - sandbox_id = %sandbox_id, - owner_replica_id = %redirect.owner_replica_id, - replica_id = %state.replica_id, - "supervisor session: redirecting to ring-preferred replica" - ); - if state.supervisor_sessions.has_session(&sandbox_id) { - let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); - let supersedes = match owner_index.read(&sandbox_id).await { - Ok(Some(owner)) => can_supersede( - &owner, - &hello.instance_id, - hello.connection_epoch, - OWNER_TTL, - ), - Ok(None) => true, - Err(_) => false, - }; - if supersedes { - state.supervisor_sessions.disconnect(&sandbox_id); - } - } - let (tx, rx) = mpsc::channel::>(1); - let message = GatewayMessage { - payload: Some(gateway_message::Payload::SessionRedirect(redirect)), - }; - // Capacity 1 and a fresh receiver, so this cannot block. Dropping the - // sender ends the stream, which closes the supervisor's connection. - let _ = tx.send(Ok(message)).await; - drop(tx); - return Ok(Response::new(Box::pin(ReceiverStream::new(rx)))); - } - - let session_lifetime = state.supervisor_sessions.track_session()?; - let state = Arc::clone(state); - // Keep setup alive if the RPC caller disconnects after publication. The - // tracking guard moves into the session task or outlives early cleanup. - tokio::spawn(establish_supervisor_session( - state, - inbound, - hello, - provider_readiness, - session_lifetime, - )) - .await - .map_err(|error| Status::internal(format!("supervisor session setup failed: {error}")))? +/// Push-mode state captured before the bootstrap inputs are read. +struct ConfigPushSetup { + captured_seq: u64, + workspace: String, + providers: HashSet, + /// The admission the sandbox record holds, so a reconnect that reports + /// the same admission writes nothing. + persisted_admission: Option, } -async fn establish_supervisor_session( +/// Everything needed to accept a supervisor session once its bootstrap is +/// final. Setup runs in its own task so a caller disconnect after owner +/// publication cannot strand the record; the lifetime guard moves into the +/// session task or drops with early cleanup. +struct SessionSetup { state: Arc, - mut inbound: tonic::Streaming, - hello: SupervisorHello, + sandbox_id: String, + instance_id: String, + connection_epoch: u64, + supports_session_redirect: bool, + mode: SessionMode, + bootstrap: Option, provider_readiness: ProviderReadinessEvidence, + config_push: Option, + outbound_tx: mpsc::Sender, + inbound: tonic::Streaming, session_lifetime: OwnedRwLockReadGuard<()>, -) -> Result< - Response< - Pin> + Send + 'static>>, - >, - Status, -> { - let sandbox_id = hello.sandbox_id.clone(); +} + +async fn accept_supervisor_session(setup: SessionSetup) -> Result<(), Status> { + let SessionSetup { + state, + sandbox_id, + instance_id, + connection_epoch, + supports_session_redirect, + mode, + bootstrap, + provider_readiness, + config_push, + outbound_tx, + mut inbound, + session_lifetime, + } = setup; + let pushes_config = matches!(mode, SessionMode::Push); + // The registration seeds the session's provider environment cache, and + // rebuilds at once what the bootstrap could not resolve. + let bootstrap_provider_environment = bootstrap + .as_ref() + .and_then(config_session::SessionBootstrap::resolved_provider_environment); + let rebuild = bootstrap + .as_ref() + .is_some_and(config_session::SessionBootstrap::needs_rebuild); + let bootstrap = bootstrap.map(|bootstrap| bootstrap.config); let session_id = Uuid::new_v4().to_string(); + let mut acceptance = SessionAccepted { + session_id: session_id.clone(), + config_push_enabled: pushes_config, + heartbeat_interval: openshell_core::time::duration_from_std(Duration::from_secs( + u64::from(HEARTBEAT_INTERVAL_SECS), + )) + .ok(), + ..Default::default() + }; + let (bootstrap, bootstrap_in_flight) = bootstrap + .map(|config| config_session::bootstrap_update(config, &acceptance)) + .unzip(); + acceptance.bootstrap = bootstrap; + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + // Keep the session unroutable to the response stream until every fallible + // acceptance step completes. Registry traffic can safely queue here while + // endpoint-status authority is initialized; the forwarder starts only + // after SessionAccepted has been queued on the public stream. + let (session_tx, mut session_rx) = mpsc::channel::(64); + let accepted = GatewayMessage { + payload: Some(gateway_message::Payload::SessionAccepted(acceptance)), + }; + if accepted.encoded_len() > MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES { + counter!( + "openshell_supervisor_config_bootstrap_total", + "outcome" => "payload_too_large" + ) + .increment(1); + return Err(Status::resource_exhausted( + "supervisor configuration bootstrap exceeds the stream message limit", + )); + } let owner_peer_endpoint = state.peer_endpoint.as_deref().map_or_else( || local_owner_endpoint(&state.replica_id), ToString::to_string, @@ -2178,30 +2247,19 @@ async fn establish_supervisor_session( .publish( &sandbox_id, &session_id, - &hello.instance_id, - hello.connection_epoch, + &instance_id, + connection_epoch, &state.replica_id, &owner_peer_endpoint, ) .await .map_err(owner_error_to_status)?; - info!( - sandbox_id = %sandbox_id, - session_id = %session_id, - instance_id = %hello.instance_id, - connection_epoch = hello.connection_epoch, - replica_id = %state.replica_id, - "supervisor session: accepted" - ); - - // Step 2: Create and register the outbound channel. - let (tx, rx) = mpsc::channel::(64); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let superseded = state.supervisor_sessions.register( + let superseded = state.supervisor_sessions.register_with_mode( sandbox_id.clone(), session_id.clone(), - tx.clone(), + session_tx.clone(), shutdown_tx, + mode, ); if superseded { info!( @@ -2210,6 +2268,16 @@ async fn establish_supervisor_session( "supervisor session: superseded previous session" ); } + if let Some(bootstrap) = bootstrap_in_flight { + state.supervisor_sessions.begin_config_bootstrap( + &sandbox_id, + &session_id, + bootstrap, + config_push + .as_ref() + .and_then(|setup| setup.persisted_admission.clone()), + ); + } // A replacement stream is a new observation authority. Reset its endpoint // results before acknowledging the session so it cannot inherit evidence @@ -2259,6 +2327,9 @@ async fn establish_supervisor_session( .supervisor_sessions .initialize_endpoint_status_authority(&sandbox_id, &session_id) { + state + .supervisor_sessions + .remove_if_current(&sandbox_id, &session_id); if let Err(err) = owner_index.release_if_current(&owner_guard).await { warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release superseded owner during endpoint status initialization"); } @@ -2281,33 +2352,16 @@ async fn establish_supervisor_session( return Err(error); } - // Step 3: Send SessionAccepted. - let accepted = GatewayMessage { - payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { - session_id: session_id.clone(), - heartbeat_interval: openshell_core::time::duration_from_std(Duration::from_secs( - u64::from(HEARTBEAT_INTERVAL_SECS), - )) - .ok(), - })), - }; - if tx.send(accepted).await.is_err() { - // Only evict ourselves — a faster reconnect may already have - // superseded this registration. - abandon_session_setup(&state, &owner_index, &owner_guard, "accept send").await; - return Err(Status::internal("failed to send session accepted")); - } - + // Do not expose SessionAccepted to the supervisor when the gateway could + // not durably record the connection. Dropping the buffered response forces + // a reconnect, which gives the state transition a fresh chance instead of + // leaving a healthy-looking supervisor tied to a sandbox that never + // reaches Ready. if let Err(err) = state .compute - .supervisor_session_connected(&sandbox_id, &hello.instance_id) + .supervisor_session_connected(&sandbox_id, &instance_id) .await { - // Do not expose SessionAccepted to the supervisor when the gateway - // could not durably record the connection. Dropping the buffered - // response forces a reconnect, which gives the state transition a - // fresh chance instead of leaving a healthy-looking supervisor tied - // to a sandbox that never reaches Ready. abandon_session_setup(&state, &owner_index, &owner_guard, "lifecycle persistence").await; warn!( sandbox_id = %sandbox_id, @@ -2321,52 +2375,209 @@ async fn establish_supervisor_session( } state.telemetry.sandbox_session_connected(&sandbox_id); + if outbound_tx.send(accepted).await.is_err() { + // Only evict ourselves — a faster reconnect may already have + // superseded this registration. + abandon_session_setup(&state, &owner_index, &owner_guard, "accept send").await; + return Err(Status::internal("failed to send session accepted")); + } + info!( + sandbox_id = %sandbox_id, + session_id = %session_id, + instance_id = %instance_id, + connection_epoch, + replica_id = %state.replica_id, + config_push = pushes_config, + "supervisor session: accepted" + ); + + let public_tx = outbound_tx.clone(); + tokio::spawn(async move { + while let Some(message) = session_rx.recv().await { + if public_tx.send(message).await.is_err() { + break; + } + } + }); + // A disconnected session may already have removed its registration while // an unclaimed RelayOpen remains pending. Replay on every accepted session, // including reconnects that did not supersede a live registration. state .supervisor_sessions - .replay_pending_relays(&sandbox_id, &session_id, &tx) + .replay_pending_relays(&sandbox_id, &session_id, &session_tx) .await; - // Step 4: Spawn the session loop that reads inbound messages. - let state_clone = Arc::clone(&state); - let sandbox_id_clone = sandbox_id.clone(); - let supports_session_redirect = hello.supports_session_redirect; + if let Some(setup) = config_push { + crate::config_delivery::register_session( + &state, + crate::config_delivery::Registration { + sandbox_id: sandbox_id.clone(), + session_id: session_id.clone(), + workspace: setup.workspace, + providers: setup.providers, + provider_environment: bootstrap_provider_environment, + captured_seq: setup.captured_seq, + rebuild, + }, + ); + } + + let session = AcceptedSession { + state: Arc::clone(&state), + sandbox_id: sandbox_id.clone(), + session_id: session_id.clone(), + instance_id, + tx: session_tx, + supports_session_redirect, + pushes_config, + }; tokio::spawn(async move { let _session_lifetime = session_lifetime; let mut owner_guard = owner_guard; - let exit = run_session_loop( - &state_clone, - &sandbox_id_clone, - &session_id, - supports_session_redirect, - &tx, - &mut inbound, - shutdown_rx, - &mut owner_guard, - ) - .await; + let exit = run_session_loop(&session, &mut inbound, shutdown_rx, &mut owner_guard).await; // Close both directions before cleanup. Queued messages, including a // redirect, still reach the supervisor, and one without a redirect sees // EOF at once instead of waiting out any handoff below. - drop(tx); + drop(session); drop(inbound); - finish_supervisor_session( - &state_clone, - &sandbox_id_clone, - &session_id, - exit, - &owner_guard, - ) - .await; + // Every removal path ends this task, including supersede and disconnect. + crate::config_delivery::unregister_session(&state, &sandbox_id, &session_id); + finish_supervisor_session(&state, &sandbox_id, &session_id, exit, &owner_guard).await; + }); + + Ok(()) +} + +pub async fn handle_connect_supervisor( + state: &Arc, + request: Request>, +) -> Result< + Response< + Pin> + Send + 'static>>, + >, + Status, +> { + let principal = request.extensions().get::().cloned(); + let mut inbound = request.into_inner(); + + // Step 1: Wait for SupervisorHello. + let hello = match inbound.message().await? { + Some(msg) => match msg.payload { + Some(supervisor_message::Payload::Hello(hello)) => hello, + _ => return Err(Status::invalid_argument("expected SupervisorHello")), + }, + None => return Err(Status::invalid_argument("stream closed before hello")), + }; + + let sandbox_id = hello.sandbox_id.clone(); + if sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if let Some(principal) = principal.as_ref() { + crate::auth::guard::ensure_sandbox_principal_scope(principal, &sandbox_id)?; + } + // Configuration is pushed only in push mode and only to supervisors that + // apply it; the rest keep polling. + let pushes_config = state.config.config_delivery_mode + == openshell_core::config::ConfigDeliveryMode::Push + && hello.supports_config_push; + let mode = if pushes_config { + SessionMode::Push + } else { + SessionMode::Poll + }; + // Captured before reading any bootstrap input, including the sandbox + // record, so registration detects publications the bootstrap may miss. + let captured_seq = pushes_config.then(|| state.config_delivery.publications()); + let sandbox = require_persisted_sandbox(&state.store, &sandbox_id).await?; + // Validate readiness identities before replacing a healthy session. + let provider_readiness = ProviderReadinessEvidence::from_hello(&hello)?; + + // If the ring places this sandbox on a different live replica, send the + // supervisor there instead of claiming ownership here. Decided before the + // session is tracked so a redirect never consumes a session slot. An + // established session is only relocated when this replica shuts down. + if may_redirect(&hello, state.gateway_shutting_down.load(Ordering::Acquire)) + && let Some(redirect) = preferred_peer_redirect(state, &sandbox_id) + { + info!( + sandbox_id = %sandbox_id, + owner_replica_id = %redirect.owner_replica_id, + replica_id = %state.replica_id, + "supervisor session: redirecting to ring-preferred replica" + ); + if state.supervisor_sessions.has_session(&sandbox_id) { + let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); + let supersedes = match owner_index.read(&sandbox_id).await { + Ok(Some(owner)) => can_supersede( + &owner, + &hello.instance_id, + hello.connection_epoch, + OWNER_TTL, + ), + Ok(None) => true, + Err(_) => false, + }; + if supersedes { + state.supervisor_sessions.disconnect(&sandbox_id); + } + } + let (tx, rx) = mpsc::channel::>(1); + let message = GatewayMessage { + payload: Some(gateway_message::Payload::SessionRedirect(redirect)), + }; + // Capacity 1 and a fresh receiver, so this cannot block. Dropping the + // sender ends the stream, which closes the supervisor's connection. + let _ = tx.send(Ok(message)).await; + drop(tx); + return Ok(Response::new(Box::pin(ReceiverStream::new(rx)))); + } + + // Build the bootstrap only for a session this replica keeps. + let config_push = captured_seq.map(|captured_seq| ConfigPushSetup { + captured_seq, + workspace: sandbox.object_workspace().to_string(), + providers: sandbox + .spec + .as_ref() + .map(|spec| spec.providers.iter().cloned().collect()) + .unwrap_or_default(), + persisted_admission: sandbox + .status + .as_ref() + .and_then(|status| status.configuration_admission.clone()), }); + let bootstrap = if pushes_config { + Some(config_session::build_session_bootstrap(state, &sandbox).await?) + } else { + None + }; - // Return the outbound stream. - let stream = ReceiverStream::new(rx); + let session_lifetime = state.supervisor_sessions.track_session()?; + let (tx, rx) = mpsc::channel::(64); let stream: Pin< Box> + Send + 'static>, - > = Box::pin(tokio_stream::StreamExt::map(stream, Ok)); + > = Box::pin(tokio_stream::StreamExt::map(ReceiverStream::new(rx), Ok)); + + let setup = SessionSetup { + state: Arc::clone(state), + sandbox_id, + instance_id: hello.instance_id, + connection_epoch: hello.connection_epoch, + supports_session_redirect: hello.supports_session_redirect, + mode, + bootstrap, + provider_readiness, + config_push, + outbound_tx: tx, + inbound, + session_lifetime, + }; + // Keep setup alive if the RPC caller disconnects after publication. + tokio::spawn(accept_supervisor_session(setup)) + .await + .map_err(|error| Status::internal(format!("supervisor session setup failed: {error}")))??; Ok(Response::new(stream)) } @@ -2609,40 +2820,72 @@ fn queue_shutdown_redirect( }) } -#[allow(clippy::too_many_arguments)] +/// An accepted supervisor session, shared by the handlers for its messages. +pub(crate) struct AcceptedSession { + pub(crate) state: Arc, + pub(crate) sandbox_id: String, + pub(crate) session_id: String, + pub(crate) instance_id: String, + /// The session's ordered outbound channel, shared by relays, + /// configuration updates, and admissions. + pub(crate) tx: mpsc::Sender, + pub(crate) supports_session_redirect: bool, + /// The supervisor applies streamed configuration and must answer the + /// bootstrap it was accepted with. False for a supervisor that keeps + /// polling. + pub(crate) pushes_config: bool, +} + async fn run_session_loop( - state: &Arc, - sandbox_id: &str, - session_id: &str, - supports_session_redirect: bool, - tx: &mpsc::Sender, + session: &AcceptedSession, inbound: &mut (impl tokio_stream::Stream> + Unpin), mut shutdown_rx: oneshot::Receiver<()>, owner_guard: &mut OwnerGuard, ) -> SessionLoopExit { + let AcceptedSession { + state, + sandbox_id, + session_id, + tx, + supports_session_redirect, + .. + } = session; let mut gateway_shutdown = state.supervisor_sessions.shutdown.subscribe(); let heartbeat_interval = Duration::from_secs(u64::from(HEARTBEAT_INTERVAL_SECS)); let mut heartbeat_timer = tokio::time::interval(heartbeat_interval); // Skip the first immediate tick. heartbeat_timer.tick().await; + let bootstrap_timeout = tokio::time::sleep(Duration::from_mins(2)); + tokio::pin!(bootstrap_timeout); + let mut bootstrap_complete = !session.pushes_config; loop { tokio::select! { () = async { let _ = gateway_shutdown.wait_for(|shutdown| *shutdown).await; } => { info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: gateway shutting down"); - let redirected = queue_shutdown_redirect(tx, supports_session_redirect, || { + let redirected = queue_shutdown_redirect(tx, *supports_session_redirect, || { preferred_peer_redirect(state, sandbox_id) }); return SessionLoopExit::GatewayShutdown { redirected }; } _ = &mut shutdown_rx => { - info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: superseded by reconnect, shutting down"); + info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: superseded or closed by the gateway, shutting down"); break; } msg = tokio_stream::StreamExt::next(inbound) => { match msg.transpose() { Ok(Some(msg)) => { - if !handle_supervisor_message(state, sandbox_id, session_id, msg, owner_guard).await { + if matches!(msg.payload.as_ref(), Some(supervisor_message::Payload::Heartbeat(_))) + && !renew_supervisor_owner(state, sandbox_id, session_id, owner_guard).await + { + break; + } + // One update is in flight at a time, so the first + // result answers the bootstrap. + if matches!(msg.payload.as_ref(), Some(supervisor_message::Payload::ConfigUpdateResult(_))) { + bootstrap_complete = true; + } + if !handle_supervisor_message(session, msg.payload).await { break; } } @@ -2683,71 +2926,70 @@ async fn run_session_loop( break; } } + () = &mut bootstrap_timeout, if !bootstrap_complete => { + warn!(sandbox_id, session_id, "supervisor configuration bootstrap timed out"); + break; + } } } SessionLoopExit::Other } -async fn handle_supervisor_message( +async fn renew_supervisor_owner( state: &Arc, sandbox_id: &str, session_id: &str, - msg: SupervisorMessage, owner_guard: &mut OwnerGuard, ) -> bool { - match msg.payload { - Some(supervisor_message::Payload::Heartbeat(_)) => { - let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); - match tokio::time::timeout(OWNER_RENEW_TIMEOUT, owner_index.renew(owner_guard)).await { - Ok(Ok(())) => {} - // Only a real ownership change ends the session. A store error - // means the database did not answer, and closing on that would - // drop every session heartbeating during the outage. - Ok(Err(err)) if err.is_ownership_lost() => { - warn!( - sandbox_id = %sandbox_id, - session_id = %session_id, - error = %err, - "supervisor session: ownership lost; closing session" - ); - return false; - } - // Past the TTL our record is stale, so another replica may - // already have superseded it. Close rather than serve a - // session we can no longer claim. - Ok(Err(err)) if owner_guard.claim_expired(OWNER_TTL) => { - warn!( - sandbox_id = %sandbox_id, - session_id = %session_id, - error = %err, - "supervisor session: owner renewal failed past the ownership TTL; \ - closing session" - ); - return false; - } - Ok(Err(err)) => warn!( - sandbox_id = %sandbox_id, - session_id = %session_id, - error = %err, - "supervisor session: owner renewal failed; retrying on next heartbeat" - ), - Err(_) if owner_guard.claim_expired(OWNER_TTL) => { - warn!( - sandbox_id = %sandbox_id, - session_id = %session_id, - timeout_ms = OWNER_RENEW_TIMEOUT.as_millis(), - "supervisor session: owner renewal timed out past the ownership TTL; closing session" - ); - return false; - } - Err(_) => warn!( - sandbox_id = %sandbox_id, - session_id = %session_id, - timeout_ms = OWNER_RENEW_TIMEOUT.as_millis(), - "supervisor session: owner renewal timed out; retrying on next heartbeat" - ), - } + let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); + match tokio::time::timeout(OWNER_RENEW_TIMEOUT, owner_index.renew(owner_guard)).await { + Ok(Ok(())) => true, + Ok(Err(err)) if err.is_ownership_lost() => { + warn!(sandbox_id, session_id, error = %err, "supervisor session: ownership lost; closing session"); + false + } + Ok(Err(err)) if owner_guard.claim_expired(OWNER_TTL) => { + warn!(sandbox_id, session_id, error = %err, "supervisor session: owner renewal failed past the ownership TTL; closing session"); + false + } + Ok(Err(err)) => { + warn!(sandbox_id, session_id, error = %err, "supervisor session: owner renewal failed; retrying on next heartbeat"); + true + } + Err(_) if owner_guard.claim_expired(OWNER_TTL) => { + warn!( + sandbox_id, + session_id, + timeout_ms = OWNER_RENEW_TIMEOUT.as_millis(), + "supervisor session: owner renewal timed out past the ownership TTL; closing session" + ); + false + } + Err(_) => { + warn!( + sandbox_id, + session_id, + timeout_ms = OWNER_RENEW_TIMEOUT.as_millis(), + "supervisor session: owner renewal timed out; retrying on next heartbeat" + ); + true } + } +} + +/// Handle one supervisor message. Returns false when the session must close. +async fn handle_supervisor_message( + session: &AcceptedSession, + payload: Option, +) -> bool { + let AcceptedSession { + state, + sandbox_id, + session_id, + .. + } = session; + match payload { + Some(supervisor_message::Payload::Heartbeat(_)) => {} Some(supervisor_message::Payload::RelayOpenResult(result)) => { if result.success { info!( @@ -2779,6 +3021,16 @@ async fn handle_supervisor_message( "supervisor session: relay closed by supervisor" ); } + Some(supervisor_message::Payload::ConfigUpdateResult(result)) => { + if !session.pushes_config { + debug!( + sandbox_id, + session_id, "ignored configuration result from a polling supervisor" + ); + } else if !config_session::handle_config_update_result(session, &result).await { + return false; + } + } _ => { warn!( sandbox_id = %sandbox_id, @@ -2800,11 +3052,57 @@ mod tests { use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal}; use crate::gateway_metrics::MetricsCapture; + use crate::grpc::test_support::StreamFeatures; use crate::persistence::Store; use bytes::Bytes; use http_body::Frame; use http_body_util::{BodyExt, Empty, StreamBody}; use std::convert::Infallible; + + async fn state_with_sandbox(sandbox_id: &str) -> Arc { + let state = crate::grpc::test_support::test_server_state().await; + state + .store + .put_message(&sandbox_record(sandbox_id, sandbox_id)) + .await + .unwrap(); + state + } + + async fn first_gateway_message( + harness: &mut crate::grpc::test_support::SupervisorStreamHarness, + ) -> GatewayMessage { + tokio::time::timeout(Duration::from_secs(5), harness.inbound.message()) + .await + .expect("gateway response before timeout") + .expect("stream open") + .expect("gateway message") + } + + #[tokio::test] + async fn supervisor_without_config_push_is_accepted() { + let state = state_with_sandbox("sb-legacy").await; + let mut harness = crate::grpc::test_support::connect_supervisor_stream( + &state, + "sb-legacy", + StreamFeatures::Legacy, + ) + .await + .expect("supervisor without configuration apply must connect"); + + let Some(gateway_message::Payload::SessionAccepted(accepted)) = + first_gateway_message(&mut harness).await.payload + else { + panic!("expected SessionAccepted"); + }; + assert!(accepted.bootstrap.is_none()); + assert!( + state + .supervisor_sessions + .is_current_session("sb-legacy", &accepted.session_id) + ); + } + use tokio::io::{AsyncReadExt, AsyncWriteExt}; async fn test_store() -> Arc { @@ -2866,13 +3164,18 @@ mod tests { state.supervisor_sessions.shutdown.send_replace(true); let (tx, mut rx) = mpsc::channel(4); let mut inbound = futures::stream::pending::>(); + let session = AcceptedSession { + state: Arc::clone(&state), + sandbox_id: "sb-no-peer".into(), + session_id: "session-no-peer".into(), + instance_id: String::new(), + tx, + supports_session_redirect: true, + pushes_config: false, + }; let exit = run_session_loop( - &state, - "sb-no-peer", - "session-no-peer", - true, - &tx, + &session, &mut inbound, oneshot::channel().1, &mut owner_guard, @@ -4422,13 +4725,21 @@ mod tests { fn a_gateway_without_a_peer_endpoint_still_records_ownership() { let endpoint = local_owner_endpoint("gw-0"); assert_eq!(endpoint, "local://gw-0"); - assert!(owner_endpoint_is_local_only(&endpoint)); + assert!(crate::supervisor_owner::dialable_peer_endpoint(&endpoint).is_none()); } #[test] - fn a_dialable_owner_endpoint_is_not_local_only() { - assert!(!owner_endpoint_is_local_only("https://10.0.0.1:8080")); - assert!(!owner_endpoint_is_local_only("http://10.0.0.1:8080")); + fn only_advertised_peer_endpoints_are_dialable() { + use crate::supervisor_owner::dialable_peer_endpoint; + assert!(dialable_peer_endpoint("").is_none()); + assert_eq!( + dialable_peer_endpoint("https://10.0.0.1:8080"), + Some("https://10.0.0.1:8080") + ); + assert_eq!( + dialable_peer_endpoint("http://10.0.0.1:8080"), + Some("http://10.0.0.1:8080") + ); } #[test] diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 146169d7ba..be52bdd7a2 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -53,6 +53,13 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index dfb89f18c3..910a54daf8 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -50,6 +50,13 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn peer_notify_config_update( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn peer_report_provider_readiness( &self, _request: tonic::Request, diff --git a/crates/openshell-supervisor-process/src/delegated.rs b/crates/openshell-supervisor-process/src/delegated.rs index b6b58ab1fd..3529683403 100644 --- a/crates/openshell-supervisor-process/src/delegated.rs +++ b/crates/openshell-supervisor-process/src/delegated.rs @@ -90,6 +90,8 @@ pub async fn start_boundary_access( port_forward: Arc, agent: Arc, supervisor_session_updates: Option>>, + config_apply_tx: tokio::sync::mpsc::Sender, + config_push_updates: tokio::sync::watch::Sender, host_key: Option, ) -> Result { let instance_id = uuid::Uuid::new_v4().to_string(); @@ -126,6 +128,8 @@ pub async fn start_boundary_access( crate::supervisor_session::SessionRuntimeContext { instance_id: instance_id.clone(), session_id_updates: supervisor_session_updates, + config_apply_tx, + config_push_updates, }, ); (Some(task), Some(accepted)) @@ -358,6 +362,8 @@ mod tests { rpc_test::loopback(boundary.clone()), rpc_test::process(boundary), None, + tokio::sync::mpsc::channel(1).0, + tokio::sync::watch::channel(false).0, None, ) .await @@ -397,6 +403,8 @@ mod tests { rpc_test::loopback(boundary.clone()), rpc_test::process(boundary), None, + tokio::sync::mpsc::channel(1).0, + tokio::sync::watch::channel(false).0, None, ) .await; diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 632694e301..1f1c7767ff 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -19,11 +19,15 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use openshell_core::grpc_client::SettingsPollResult; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::{ - FinalizeMainProcessExitRequest, GatewayMessage, RelayFrame, RelayInit, RelayOpen, - RelayOpenResult, ReportMainProcessExitRequest, SupervisorHeartbeat, SupervisorHello, - SupervisorMessage, TcpRelayTarget, gateway_message, relay_open, supervisor_message, + ConfigApplyFailure, ConfigApplyOutcome, ConfigSnapshotRevision, ConfigUpdate, + ConfigUpdateResult, ConfigurationAdmissionState, FinalizeMainProcessExitRequest, + GatewayMessage, RelayFrame, RelayInit, RelayOpen, RelayOpenResult, + ReportMainProcessExitRequest, SandboxConfigRevision, SandboxConfigurationAdmission, + SupervisorHeartbeat, SupervisorHello, SupervisorMessage, TcpRelayTarget, + config_snapshot_revision, gateway_message, relay_open, supervisor_message, }; use openshell_isolation_interface::contract::LoopbackTarget; use openshell_ocsf::{ @@ -40,6 +44,79 @@ use openshell_core::transport_errors::is_expected_transport_close_status; const INITIAL_BACKOFF: Duration = Duration::from_secs(1); const MAX_BACKOFF: Duration = Duration::from_secs(30); +const CONFIG_APPLY_TIMEOUT: Duration = Duration::from_mins(1); + +/// A stream-delivered configuration update awaiting application by the +/// supervisor runtime, including a session's bootstrap. The response travels +/// back over `ConnectSupervisor`. +pub struct ConfigApplyRequest { + pub update: ConfigUpdate, + pub response: tokio::sync::oneshot::Sender, +} + +/// Revision token the gateway correlates with a sandbox configuration result. +pub fn sandbox_config_revision(snapshot: &SettingsPollResult) -> ConfigSnapshotRevision { + ConfigSnapshotRevision { + component: Some(config_snapshot_revision::Component::SandboxConfig( + SandboxConfigRevision { + config_revision: snapshot.config_revision, + policy_version: snapshot.version, + policy_source: snapshot.policy_source.into(), + global_policy_version: snapshot.global_policy_version, + settings_revision: snapshot.settings_revision, + }, + )), + } +} + +/// Sanitized failure for a configuration update result. The message is +/// bounded for the wire. +pub fn config_apply_failure(code: &str, message: &str, retryable: bool) -> ConfigApplyFailure { + ConfigApplyFailure { + code: code.to_string(), + message: message.chars().take(1024).collect(), + retryable, + } +} + +/// Admission for a sandbox configuration snapshot. Only a gateway-admitted +/// snapshot that the runtime activated is accepted. +pub fn config_admission( + snapshot: &SettingsPollResult, + activated: bool, +) -> SandboxConfigurationAdmission { + let accepted = snapshot.configuration_admitted && activated; + SandboxConfigurationAdmission { + instance_id: snapshot.configuration_instance_id.clone(), + state: if accepted { + ConfigurationAdmissionState::Accepted.into() + } else { + ConfigurationAdmissionState::Rejected.into() + }, + policy_version: snapshot.version, + policy_hash: snapshot.policy_hash.clone(), + config_revision: snapshot.config_revision, + provider_env_revision: snapshot.provider_env_revision, + error: if accepted { + String::new() + } else if snapshot.configuration_error.is_empty() { + "effective configuration could not be activated".to_string() + } else { + snapshot.configuration_error.clone() + }, + } +} + +/// Result for an update the runtime did not apply. +fn unapplied_update(update_id: String, code: &str, message: &str) -> ConfigUpdateResult { + ConfigUpdateResult { + update_id, + outcome: ConfigApplyOutcome::Unsupported.into(), + failure: Some(config_apply_failure(code, message, false)), + admission: None, + policy_load: None, + } +} /// Runtime identity and status channel shared with a supervisor session task. pub struct SessionRuntimeContext { @@ -47,6 +124,11 @@ pub struct SessionRuntimeContext { pub instance_id: String, /// Publishes the currently accepted gateway session to sibling reporters. pub session_id_updates: Option>>, + /// Applies streamed configuration updates to the running supervisor. + pub config_apply_tx: mpsc::Sender, + /// Publishes whether the current session pushes configuration, so the + /// runtime stops polling while it does. + pub config_push_updates: watch::Sender, } /// Parse a gRPC endpoint URI into an OCSF `Endpoint` (host + port). Falls back @@ -339,6 +421,8 @@ pub fn spawn_with_readiness( terminating, instance_id: runtime.instance_id, session_id_updates: runtime.session_id_updates, + config_apply_tx: runtime.config_apply_tx, + config_push_updates: runtime.config_push_updates, ready_tx, }; (tokio::spawn(run_session_loop(config)), ready_rx) @@ -354,6 +438,10 @@ struct SessionConfig { instance_id: String, /// Publishes the currently accepted session to sibling control-plane reporters. session_id_updates: Option>>, + config_apply_tx: mpsc::Sender, + /// Publishes whether the current session pushes configuration. A + /// reconnect to a gateway that does not push resumes polling. + config_push_updates: watch::Sender, ready_tx: watch::Sender, } @@ -503,7 +591,8 @@ async fn run_single_session( let channel = grpc_client::connect_channel_pub(target) .await .map_err(|e| format!("connect failed: {e}"))?; - let mut client = OpenShellClient::new(channel.clone()); + let mut client = OpenShellClient::new(channel.clone()) + .max_decoding_message_size(grpc_client::MAX_SUPERVISOR_STREAM_MESSAGE_BYTES); // Create the outbound message stream. let (tx, rx) = mpsc::channel::(64); @@ -518,6 +607,7 @@ async fn run_single_session( supports_provider_readiness: true, redirected, supports_session_redirect: true, + supports_config_push: true, })), }) .await @@ -549,6 +639,24 @@ async fn run_single_session( } _ => return Err("expected SessionAccepted or SessionRejected".into()), }; + let bootstrap = if accepted.config_push_enabled { + let bootstrap = accepted + .bootstrap + .ok_or("gateway enabled config push without a bootstrap")?; + info!( + sandbox_id = %config.sandbox_id, + session_id = %accepted.session_id, + "supervisor session: gateway pushes configuration over the session" + ); + Some(bootstrap) + } else { + debug!( + sandbox_id = %config.sandbox_id, + session_id = %accepted.session_id, + "supervisor session: gateway did not enable streamed configuration; polling remains active" + ); + None + }; let heartbeat_secs = accepted .heartbeat_interval @@ -567,6 +675,18 @@ async fn run_single_session( ocsf_emit!(event); config.ready_tx.send_replace(true); + // The runtime stops polling while this session pushes configuration, and + // applies the bootstrap as an ordinary update. + config + .config_push_updates + .send_replace(accepted.config_push_enabled); + let config_apply_tx = accepted + .config_push_enabled + .then_some(&config.config_apply_tx); + if let (Some(apply_tx), Some(bootstrap)) = (config_apply_tx, bootstrap) { + spawn_config_apply(apply_tx.clone(), bootstrap, tx.clone(), &config.sandbox_id); + } + // Main loop: receive gateway messages + send heartbeats. let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(heartbeat_secs)); heartbeat_interval.tick().await; // skip immediate tick @@ -601,11 +721,9 @@ async fn run_single_session( channel: &channel, tx: &tx, terminating: &config.terminating, + config_apply_tx, }; - handle_gateway_message( - &msg, - &context, - ); + handle_gateway_message(&msg, &context); } _ = heartbeat_interval.tick() => { let hb = SupervisorMessage { @@ -621,6 +739,72 @@ async fn run_single_session( } } +/// Apply one delivered update in the background and send its result. The +/// gateway sends the next update only after this one's result, so updates +/// reach the runtime in order. +fn spawn_config_apply( + apply_tx: mpsc::Sender, + update: ConfigUpdate, + tx: mpsc::Sender, + sandbox_id: &str, +) { + let sandbox_id = sandbox_id.to_string(); + tokio::spawn(async move { + let result = apply_update(&apply_tx, update).await; + if tx + .send(SupervisorMessage { + payload: Some(supervisor_message::Payload::ConfigUpdateResult(result)), + }) + .await + .is_err() + { + debug!( + sandbox_id, + "configuration result dropped after session close" + ); + } + }); +} + +/// Hand one delivered update to the supervisor runtime and wait for its +/// result. +async fn apply_update( + apply_tx: &mpsc::Sender, + update: ConfigUpdate, +) -> ConfigUpdateResult { + if update.update_id.is_empty() + || update.sandbox_config.is_none() + || update.provider_environment.is_none() + { + return unapplied_update( + update.update_id, + "invalid_update", + "configuration update identity and both configuration parts are required", + ); + } + let update_id = update.update_id.clone(); + let (response, receiver) = tokio::sync::oneshot::channel(); + if apply_tx + .send(ConfigApplyRequest { update, response }) + .await + .is_err() + { + return unapplied_update( + update_id, + "apply_unavailable", + "configuration apply service is unavailable", + ); + } + match tokio::time::timeout(CONFIG_APPLY_TIMEOUT, receiver).await { + Ok(Ok(result)) => result, + _ => unapplied_update( + update_id, + "apply_timeout", + "configuration application timed out", + ), + } +} + /// Report the canonical process result and wait for durable handling. pub async fn report_main_process_exit( endpoint: &str, @@ -685,6 +869,8 @@ struct GatewayMessageContext<'a> { channel: &'a grpc_client::AuthedChannel, tx: &'a mpsc::Sender, terminating: &'a Arc, + /// Present while the session pushes configuration. + config_apply_tx: Option<&'a mpsc::Sender>, } fn handle_gateway_message(msg: &GatewayMessage, context: &GatewayMessageContext<'_>) { @@ -692,6 +878,21 @@ fn handle_gateway_message(msg: &GatewayMessage, context: &GatewayMessageContext< Some(gateway_message::Payload::Heartbeat(_)) => { // Gateway heartbeat — nothing to do. } + Some(gateway_message::Payload::ConfigUpdate(update)) => { + let Some(apply_tx) = context.config_apply_tx.cloned() else { + warn!( + sandbox_id = %context.sandbox_id, + "supervisor session: ignored configuration update on a session without config push" + ); + return; + }; + spawn_config_apply( + apply_tx, + update.clone(), + context.tx.clone(), + context.sandbox_id, + ); + } Some(gateway_message::Payload::RelayOpen(open)) => { let channel_id = open.channel_id.clone(); let relay_open = open.clone(); @@ -1055,6 +1256,58 @@ fn normalize_tcp_target_host(target: &TcpRelayTarget) -> Result mod target_tests { use super::*; + fn update() -> ConfigUpdate { + ConfigUpdate { + update_id: "update-1".into(), + sandbox_config: Some(openshell_core::proto::SandboxConfigSnapshot { + config_revision: 7, + version: 2, + ..Default::default() + }), + provider_environment: Some(openshell_core::proto::ProviderEnvironmentSnapshot { + provider_env_revision: 3, + ..Default::default() + }), + } + } + + fn assert_unapplied(result: &ConfigUpdateResult, code: &str) { + assert_eq!(result.update_id, "update-1"); + assert_eq!(result.outcome, i32::from(ConfigApplyOutcome::Unsupported)); + assert_eq!(result.failure.as_ref().unwrap().code, code); + assert!(result.admission.is_none()); + } + + #[tokio::test] + async fn unavailable_runtime_answers_the_update() { + let (apply_tx, apply_rx) = mpsc::channel(1); + drop(apply_rx); + + let result = apply_update(&apply_tx, update()).await; + + assert_unapplied(&result, "apply_unavailable"); + } + + #[tokio::test(start_paused = true)] + async fn apply_timeout_answers_the_update() { + let (apply_tx, _apply_rx) = mpsc::channel(1); + + let result = apply_update(&apply_tx, update()).await; + + assert_unapplied(&result, "apply_timeout"); + } + + #[tokio::test] + async fn update_without_both_parts_is_answered_without_the_runtime() { + let (apply_tx, _apply_rx) = mpsc::channel(1); + let mut partial = update(); + partial.provider_environment = None; + + let result = apply_update(&apply_tx, partial).await; + + assert_unapplied(&result, "invalid_update"); + } + fn tcp(host: &str, port: u32) -> TcpRelayTarget { TcpRelayTarget { host: host.to_string(), diff --git a/crates/openshell-supervisor/Cargo.toml b/crates/openshell-supervisor/Cargo.toml index 32bef3db6f..84865bbd03 100644 --- a/crates/openshell-supervisor/Cargo.toml +++ b/crates/openshell-supervisor/Cargo.toml @@ -56,7 +56,7 @@ bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] [dev-dependencies] openshell-sandbox-backend = { path = "../openshell-sandbox-backend", features = ["test-support"] } - +tonic-prost = { workspace = true } tokio = { workspace = true, features = ["test-util"] } futures = { workspace = true } temp-env = "0.3" diff --git a/crates/openshell-supervisor/src/config_runtime.rs b/crates/openshell-supervisor/src/config_runtime.rs new file mode 100644 index 0000000000..62e7ffb6d4 --- /dev/null +++ b/crates/openshell-supervisor/src/config_runtime.rs @@ -0,0 +1,4074 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime gateway configuration: applies polled and streamed sandbox +//! configuration and provider environments after startup, and reports apply +//! results back to the gateway. + +use std::sync::atomic::AtomicBool; +use std::time::Duration; + +use miette::Result; +use openshell_core::PolicyValidationFailureMode; +use openshell_core::proposals::AgentProposals; +use openshell_core::proto::{ + ConfigApplyFailure, ConfigApplyOutcome, ConfigUpdateResult, PolicyLoadResult, + ProviderReadinessReason, SandboxConfigurationAdmission, +}; +use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; +use openshell_supervisor_network::opa::PolicyGenerationGuard; +use openshell_supervisor_process::skills; +use openshell_supervisor_process::supervisor_session::{ + ConfigApplyRequest, config_admission, config_apply_failure, sandbox_config_revision, +}; +use tracing::{debug, info, warn}; + +use crate::provider_readiness::EnvironmentIdentity; +use crate::{ + CapturedProviderEnvironment, FailedRuntimeRevision, GatewayRuntimeFailureDisposition, + InitialPollDisposition, LoadedPolicyOrigin, MiddlewareAuthentication, + MiddlewareRegistryReconciliation, MiddlewareRegistryStatus, MiddlewareReloadContext, + PolicyGatewayClient, PolicyPollLoopContext, PolicyStatusUpdate, RejectedPolicyGeneration, + apply_gateway_runtime_reload_failure, apply_policy_validation_failure, + emit_policy_validation_failure, endpoint_status, enqueue_policy_status, + gateway_policy_runtime_needs_reconciliation, initial_poll_disposition, + middleware_registry_needs_rebuild, next_poll_delay, ocsf_ctx, + provider_environment_is_installable, reconcile_middleware_registry, + reload_gateway_configuration_runtime, retain_extension_credentials, run_policy_status_reporter, + unchanged_policy_revision_candidate, unchanged_policy_revision_ready_to_ack, +}; + +async fn receive_config_apply( + receiver: &mut Option>, +) -> Option { + match receiver { + Some(receiver) => receiver.recv().await, + None => std::future::pending().await, + } +} + +/// What applying one delivered update produced, before its update ID is +/// attached. +#[derive(Clone, Debug)] +struct DeliveredResult { + outcome: ConfigApplyOutcome, + failure: Option, + admission: SandboxConfigurationAdmission, + policy_load: Option, +} + +impl DeliveredResult { + fn into_update_result(self, update_id: String) -> ConfigUpdateResult { + ConfigUpdateResult { + update_id, + outcome: self.outcome.into(), + failure: self.failure, + admission: Some(self.admission), + policy_load: self.policy_load, + } + } +} + +fn policy_load_result(status: PolicyStatusUpdate) -> PolicyLoadResult { + PolicyLoadResult { + version: status.version, + loaded: status.loaded, + error: status.error, + } +} + +#[derive(Debug)] +struct RejectedStreamSandbox { + configuration_instance_id: String, + requested_revision: openshell_core::proto::ConfigSnapshotRevision, + environment: EnvironmentIdentity, + error: String, + failure_mode: PolicyValidationFailureMode, + result: DeliveredResult, +} + +impl RejectedStreamSandbox { + fn matches( + &self, + snapshot: &openshell_core::grpc_client::SettingsPollResult, + requested_revision: &openshell_core::proto::ConfigSnapshotRevision, + environment: &EnvironmentIdentity, + ) -> bool { + self.configuration_instance_id == snapshot.configuration_instance_id + && self.requested_revision == *requested_revision + && self.environment == *environment + && self.error == snapshot.configuration_error + && self.failure_mode == snapshot.policy_validation_failure_mode + } +} + +/// Configuration delivered over the supervisor session. Each update carries +/// the sandbox configuration and the provider environment it names. +#[derive(Default)] +struct StreamConfigurationState { + /// Last rejected configuration, so a redelivery does not quarantine the + /// runtime again. + rejected_sandbox: Option>, +} + +/// Where a reconciled sandbox configuration's provider environment comes from. +enum ProviderSource { + /// Policy polling fetches the environment when the requested one changed + /// or the last installation needs a retry. + Fetch, + /// Session delivery prepared the environment its update carried, or + /// `None` when the installed environment matches. + Delivered(Option), +} + +/// Why a sandbox configuration snapshot was not activated. +struct ConfigRejection { + /// Stable failure code reported on the supervisor session. + code: &'static str, + /// Diagnostic reported on the supervisor session. + detail: String, + /// Error reported with the rejected configuration admission. + admission_error: String, +} + +enum ReconcileOutcome { + /// The runtime already enforces the snapshot. + Unchanged, + /// The runtime now enforces the snapshot. + Applied, + Rejected(ConfigRejection), +} + +struct Reconciled { + outcome: ReconcileOutcome, + /// Policy load status for `ReportPolicyStatus`. Session delivery reports + /// the same status through its acknowledgement instead. + policy_status: Option, +} + +impl Reconciled { + fn rejected(code: &'static str, detail: String, admission_error: String) -> Self { + Self { + outcome: ReconcileOutcome::Rejected(ConfigRejection { + code, + detail, + admission_error, + }), + policy_status: None, + } + } +} + +/// Configuration the supervisor runtime has applied. +/// +/// Policy polling and session delivery share this state and one reconcile +/// path. They differ only in where snapshots and provider environments come +/// from and in how results reach the gateway. +struct ConfigRuntime { + reloads_gateway_policy: bool, + config_revision: u64, + /// Last applied sandbox revision. A failed delivery reports it as the + /// retained revision while its policy remains enforced. + applied_revision: Option, + provider_env_revision: u64, + policy_version: u32, + policy_hash: String, + policy_generation: Option, + endpoint_policy: Option, + middleware_services: Vec, + extension_authentication_enabled: bool, + middleware_registry_status: MiddlewareRegistryStatus, + settings: std::collections::HashMap, + last_failed_runtime_revision: Option, + rejected_policy_generation: Option, + has_last_valid_policy: bool, + stream: StreamConfigurationState, +} + +impl ConfigRuntime { + /// Seed the trackers from startup; the first poll records what startup + /// loaded. + fn new(ctx: &PolicyPollLoopContext) -> Self { + Self { + reloads_gateway_policy: ctx.loaded_policy_origin.allows_gateway_policy_reload(), + config_revision: 0, + applied_revision: None, + provider_env_revision: ctx.provider_credentials.snapshot().revision, + policy_version: 0, + policy_hash: String::new(), + policy_generation: None, + endpoint_policy: ctx.endpoint_policy.clone(), + middleware_services: Vec::new(), + extension_authentication_enabled: ctx.extension_authentication_enabled, + middleware_registry_status: ctx.middleware_registry_status, + settings: std::collections::HashMap::new(), + last_failed_runtime_revision: None, + rejected_policy_generation: None, + has_last_valid_policy: ctx.loaded_policy_origin.has_last_valid_policy(), + stream: StreamConfigurationState::default(), + } + } + + /// Bring the runtime to one sandbox configuration snapshot. + /// + /// The caller reports the outcome, then records an applied snapshot with + /// [`Self::commit`]. A failed report leaves it to be reconciled again. + async fn reconcile( + &mut self, + ctx: &PolicyPollLoopContext, + client: &C, + result: &openshell_core::grpc_client::SettingsPollResult, + provider: ProviderSource, + ) -> Result { + use openshell_core::proto::PolicySource; + use std::sync::atomic::Ordering; + + let reloads_gateway_policy = self.reloads_gateway_policy; + let mut policy_status = None; + + // Reuse installed per-service credentials, rotating only when one is + // missing or due. Rotation happens on the existing gateway channel and + // updates slots in place, so it is independent of config revision and + // registry equality. + let middleware_credentials = if result.extension_authentication_enabled { + match client + .extension_credentials_for(&result.supervisor_middleware_services) + .await + { + Ok(credentials) => credentials, + Err(error) => { + warn!(error = %error, "Settings poll: extension credential refresh failed"); + std::collections::HashMap::new() + } + } + } else { + std::collections::HashMap::new() + }; + + if reloads_gateway_policy && !result.configuration_admitted { + let disposition = apply_policy_validation_failure( + &ctx.opa_engine, + result.policy_validation_failure_mode, + self.has_last_valid_policy, + result.version, + &result.configuration_error, + )?; + emit_policy_validation_failure( + &disposition, + result.version, + &result.policy_hash, + &result.configuration_error, + ); + self.rejected_policy_generation = Some(RejectedPolicyGeneration { + version: result.version, + policy_hash: result.policy_hash.clone(), + validation_error: result.configuration_error.clone(), + configured_mode: result.policy_validation_failure_mode, + }); + return Ok(Reconciled::rejected( + "configuration_rejected", + result.configuration_error.clone(), + result.configuration_error.clone(), + )); + } + + let config_changed = result.config_revision != self.config_revision; + let desired_identity = EnvironmentIdentity::from_settings(result); + let provider_env_changed = match &provider { + ProviderSource::Fetch => { + result.provider_env_revision != self.provider_env_revision + || ctx.provider_readiness.needs_environment(&desired_identity) + } + ProviderSource::Delivered(provider) => provider.is_some(), + }; + let policy_changed = result.policy_hash != self.policy_hash; + let extension_authentication_changed = + self.extension_authentication_enabled != result.extension_authentication_enabled; + let middleware_registry_changed = extension_authentication_changed + || middleware_registry_needs_rebuild( + self.middleware_registry_status, + &self.middleware_services, + &result.supervisor_middleware_services, + ); + // A valid candidate may intentionally restore byte-for-byte policy + // content that was active before a rejected update. Its hash then + // equals `policy_hash`, but the runtime is still quarantined and must + // reload (or it would remain deny-all indefinitely). + let recovering_rejected_policy = reloads_gateway_policy + && self.rejected_policy_generation.is_some() + && result.configuration_admitted; + let policy_runtime_changed = (reloads_gateway_policy + && (provider_env_changed + || self + .policy_generation + .as_ref() + .is_some_and(PolicyGenerationGuard::is_stale))) + || recovering_rejected_policy + || extension_authentication_changed + || gateway_policy_runtime_needs_reconciliation( + reloads_gateway_policy, + &self.policy_hash, + &result.policy_hash, + &self.middleware_services, + &result.supervisor_middleware_services, + self.middleware_registry_status, + ); + // Recovery already has its own acknowledgement path below. Giving it + // precedence here prevents a restored last-known-good policy from + // also being acknowledged as an ordinary same-hash revision. + let unchanged_policy_revision = unchanged_policy_revision_candidate( + reloads_gateway_policy, + recovering_rejected_policy, + self.policy_version, + &self.policy_hash, + result, + ); + let mut policy_runtime_reconciled = false; + + // A local policy override is not coupled to the gateway policy + // snapshot, so its service registry can still be reconciled alone. + // Gateway policy snapshots, however, must install policy and registry + // as one generation below. + if !reloads_gateway_policy { + reconcile_middleware_registry( + &ctx.opa_engine, + &ctx.middleware_connector, + MiddlewareRegistryReconciliation { + desired_services: &result.supervisor_middleware_services, + authentication: MiddlewareAuthentication { + credentials: middleware_credentials.clone(), + enabled: result.extension_authentication_enabled, + }, + registry_changed: middleware_registry_changed, + extension_credentials: &ctx.extension_credentials, + current_services: &mut self.middleware_services, + status: &mut self.middleware_registry_status, + }, + ) + .await; + if self.middleware_registry_status == MiddlewareRegistryStatus::Synchronized { + self.extension_authentication_enabled = result.extension_authentication_enabled; + } + } + + if !config_changed + && !provider_env_changed + && !policy_runtime_changed + && unchanged_policy_revision.is_none() + { + return Ok(Reconciled { + outcome: ReconcileOutcome::Unchanged, + policy_status: None, + }); + } + + if config_changed || provider_env_changed { + // Log which settings changed. + log_setting_changes(&self.settings, &result.settings); + + // A posture change after a rejected update takes effect immediately. + // The compiled last-known-good engine remains available beneath a + // fail-closed quarantine, so an explicit retain_last_valid selection + // can reactivate it without accepting any part of the invalid policy. + if !policy_changed && let Some(rejected) = self.rejected_policy_generation.as_mut() { + let mode = result.policy_validation_failure_mode; + if mode != rejected.configured_mode { + let disposition = apply_policy_validation_failure( + &ctx.opa_engine, + mode, + self.has_last_valid_policy, + rejected.version, + &rejected.validation_error, + )?; + emit_policy_validation_failure( + &disposition, + rejected.version, + &rejected.policy_hash, + &rejected.validation_error, + ); + rejected.configured_mode = mode; + } + } + + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Other, "detected") + .unmapped("old_config_revision", serde_json::json!(self.config_revision)) + .unmapped("new_config_revision", serde_json::json!(result.config_revision)) + .unmapped("policy_changed", serde_json::json!(policy_changed)) + .unmapped("provider_env_changed", serde_json::json!(provider_env_changed)) + .message(format!( + "Settings poll: config change detected [old_revision:{} new_revision:{} policy_changed:{policy_changed} provider_env_changed:{provider_env_changed}]", + self.config_revision, + result.config_revision + )) + .build()); + } + + // Prepare the matching environment before activation. Failed refreshes + // revoke static credentials while preserving independently bound dynamic grants. + let prepared_provider = match provider { + _ if !provider_env_changed => None, + ProviderSource::Fetch => { + match self + .fetch_provider_environment(ctx, client, result, &desired_identity) + .await + { + Ok(prepared) => Some(prepared), + Err(rejection) => { + return Ok(Reconciled { + outcome: ReconcileOutcome::Rejected(rejection), + policy_status: None, + }); + } + } + } + ProviderSource::Delivered(provider) => provider, + }; + + let mut runtime_error = String::new(); + if policy_runtime_changed { + let pid = ctx.entrypoint_pid.load(Ordering::Acquire); + let runtime_result = reload_gateway_configuration_runtime( + &ctx.opa_engine, + result.policy.as_ref(), + pid, + MiddlewareReloadContext { + desired_services: &result.supervisor_middleware_services, + authentication: &MiddlewareAuthentication { + credentials: middleware_credentials.clone(), + enabled: result.extension_authentication_enabled, + }, + registry_changed: middleware_registry_changed, + connector: &ctx.middleware_connector, + }, + ctx.vm_identity, + || { + if let Some(prepared) = prepared_provider.as_ref() { + ctx.provider_credentials + .install_prepared(&prepared.credentials); + } + }, + ) + .await; + + match runtime_result { + Ok(generation) => { + policy_runtime_reconciled = true; + if let Some(prepared) = prepared_provider.as_ref() { + ctx.provider_readiness.credentials_installed( + prepared.identity.clone(), + &ctx.provider_credentials, + prepared.expires_at_ms, + ); + } + ctx.provider_readiness.policy_activated( + &desired_identity, + result.config_revision, + generation.clone(), + ); + self.policy_generation = Some(generation); + let policy = result + .policy + .as_ref() + .expect("successful runtime reload requires a policy payload"); + self.has_last_valid_policy = true; + self.rejected_policy_generation = None; + if policy_changed { + if let Some(policy_local_ctx) = ctx.policy_local_ctx.as_ref() { + policy_local_ctx.set_current_policy(policy.clone()).await; + } + if result.global_policy_version > 0 { + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) + .unmapped("global_version", serde_json::json!(result.global_policy_version)) + .message(format!( + "Policy reloaded successfully (global) [policy_hash:{} global_version:{}]", + result.policy_hash, + result.global_policy_version + )) + .build()); + } else { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) + .message(format!( + "Policy reloaded successfully [policy_hash:{}]", + result.policy_hash + )) + .build() + ); + } + if result.version > 0 && result.policy_source == PolicySource::Sandbox { + policy_status = Some(PolicyStatusUpdate::loaded(result.version)); + self.policy_version = result.version; + } + } else if recovering_rejected_policy + && result.version > 0 + && result.policy_source == PolicySource::Sandbox + { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) + .message(format!( + "Policy reloaded successfully and fail-closed quarantine cleared [policy_hash:{}]", + result.policy_hash + )) + .build() + ); + policy_status = Some(PolicyStatusUpdate::loaded(result.version)); + self.policy_version = result.version; + } + + if middleware_registry_changed { + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped( + "supervisor_middleware_service_count", + serde_json::json!(result.supervisor_middleware_services.len()) + ) + .message(format!( + "Supervisor policy runtime reloaded atomically [service_count:{}]", + result.supervisor_middleware_services.len() + )) + .build()); + } + + self.policy_hash.clone_from(&result.policy_hash); + self.endpoint_policy.clone_from(&result.policy); + self.middleware_services + .clone_from(&result.supervisor_middleware_services); + self.extension_authentication_enabled = result.extension_authentication_enabled; + retain_extension_credentials( + &ctx.extension_credentials, + &result.supervisor_middleware_services, + result.extension_authentication_enabled, + ); + self.middleware_registry_status = MiddlewareRegistryStatus::Synchronized; + self.last_failed_runtime_revision = None; + } + Err(failure) => { + runtime_error = failure.to_string(); + ctx.provider_readiness + .policy_install_failed(desired_identity.clone(), result.config_revision); + let failed_revision = FailedRuntimeRevision::new( + result.config_revision, + &result.policy_hash, + &failure, + ); + if self.last_failed_runtime_revision.as_ref() != Some(&failed_revision) { + let failure_mode = result.policy_validation_failure_mode; + match apply_gateway_runtime_reload_failure( + &ctx.opa_engine, + failure, + failure_mode, + self.has_last_valid_policy, + result.version, + )? { + GatewayRuntimeFailureDisposition::PolicyRejected { + error, + disposition, + } => { + emit_policy_validation_failure( + &disposition, + result.version, + &result.policy_hash, + &error, + ); + self.rejected_policy_generation = Some(RejectedPolicyGeneration { + version: result.version, + policy_hash: result.policy_hash.clone(), + validation_error: error.clone(), + configured_mode: failure_mode, + }); + if policy_changed + && result.version > 0 + && result.policy_source == PolicySource::Sandbox + { + policy_status = + Some(PolicyStatusUpdate::failed(result.version, error)); + } + } + GatewayRuntimeFailureDisposition::MiddlewareUnavailable { error } => { + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Other, "failed") + .unmapped("version", serde_json::json!(result.version)) + .unmapped("error", serde_json::json!(&error)) + .unmapped("previous_policy_active", serde_json::json!(true)) + .message(format!( + "Supervisor middleware registry unavailable, keeping last-known-good policy runtime active [version:{} error:{error}]", + result.version + )) + .build()); + } + } + } + self.last_failed_runtime_revision = Some(failed_revision); + // Nothing was installed, so the registry status still + // describes the live registry. The retry is driven by the + // persisting hash/service-set mismatch (or an existing + // NeedsReconciliation), not by degrading the status here. + } + } + } + + if policy_runtime_changed && !policy_runtime_reconciled { + return Ok(Reconciled { + outcome: ReconcileOutcome::Rejected(ConfigRejection { + code: "runtime_apply_failed", + detail: runtime_error, + admission_error: "Effective configuration failed runtime preparation; prior credentials remain installed".to_string(), + }), + policy_status, + }); + } + if !reloads_gateway_policy && let Some(prepared) = prepared_provider.as_ref() { + ctx.provider_credentials + .install_prepared(&prepared.credentials); + ctx.provider_readiness.credentials_installed( + prepared.identity.clone(), + &ctx.provider_credentials, + prepared.expires_at_ms, + ); + } + if provider_env_changed || policy_runtime_reconciled { + self.provider_env_revision = result.provider_env_revision; + } + + if let Some(version) = unchanged_policy_revision_ready_to_ack( + unchanged_policy_revision, + policy_runtime_changed, + policy_runtime_reconciled, + ) { + policy_status = Some(PolicyStatusUpdate::unchanged_loaded( + version, + result.policy_hash.clone(), + )); + self.policy_version = version; + } + + if reloads_gateway_policy + && !policy_runtime_changed + && let Some(generation) = self + .policy_generation + .as_ref() + .filter(|generation| !generation.is_stale()) + { + // The same installed policy may serve a new attachment/configuration + // identity. Its generation is retained, never inferred from a cursor. + ctx.provider_readiness.policy_activated( + &desired_identity, + result.config_revision, + generation.clone(), + ); + } + + if policy_runtime_reconciled || provider_env_changed { + endpoint_status::reset( + ctx.endpoint_observation_tx.as_ref(), + self.endpoint_policy.as_ref(), + &self.policy_hash, + ctx.provider_credentials.snapshot().revision, + ) + .await; + } + + // Apply OCSF JSON toggle from the `ocsf_json_enabled` setting. + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); + + // Apply the agent-proposals feature toggle. On a false→true transition + // we lazily install the skill so a sandbox that started with the flag + // off picks up the surface without a recreate. We never uninstall on + // a true→false transition: stale skill content on disk is harmless + // because route_request and agent_next_steps both gate on the live + // shared flag, so the agent that reads the skill will see 404s and an + // empty `next_steps` array regardless. + apply_agent_proposals_enabled( + &ctx.agent_proposals, + agent_proposals_enabled_from_settings(&result.settings), + "settings poll", + Some(result.config_revision), + skills::install_static_skills, + ); + + Ok(Reconciled { + outcome: ReconcileOutcome::Applied, + policy_status, + }) + } + + /// Record a reconciled snapshot as applied. + fn commit(&mut self, result: openshell_core::grpc_client::SettingsPollResult) { + if self.reloads_gateway_policy { + self.applied_revision = Some(sandbox_config_revision(&result)); + } + self.config_revision = result.config_revision; + if !self.reloads_gateway_policy { + self.policy_hash = result.policy_hash; + } + self.settings = result.settings; + } + + /// Fetch and prepare the provider environment a polled snapshot requests. + async fn fetch_provider_environment( + &self, + ctx: &PolicyPollLoopContext, + client: &C, + result: &openshell_core::grpc_client::SettingsPollResult, + desired_identity: &EnvironmentIdentity, + ) -> std::result::Result { + ctx.provider_readiness.credentials_failed( + desired_identity.clone(), + ProviderReadinessReason::WaitingForCredentials, + ); + let provider = match client + .fetch_provider_environment( + &ctx.endpoint, + &ctx.sandbox_id, + ctx.provider_credentials.requires_stable_placeholders(), + ) + .await + { + Ok(provider) + if EnvironmentIdentity::from_environment(&provider) == *desired_identity + && provider_environment_is_installable(provider.readiness_reason) => + { + provider + } + failed => { + let reason = match failed { + Ok(provider) + if provider.readiness_reason != ProviderReadinessReason::Unspecified => + { + provider.readiness_reason + } + Ok(_) => ProviderReadinessReason::SnapshotMismatch, + Err(_) => ProviderReadinessReason::CredentialInstallFailed, + }; + self.revoke_provider_environment( + ctx, + desired_identity.clone(), + reason, + result.provider_env_revision, + ) + .await; + let error = "Provider environment is unavailable or changed during preparation"; + return Err(ConfigRejection { + code: "provider_environment_unavailable", + detail: error.to_string(), + admission_error: error.to_string(), + }); + } + }; + if let Ok(prepared) = CapturedProviderEnvironment::prepare(&provider) { + return Ok(prepared); + } + self.reject_provider_bindings(ctx, desired_identity.clone(), provider) + .await; + let error = "Provider environment bindings failed validation"; + Err(ConfigRejection { + code: "invalid_provider_environment", + detail: error.to_string(), + admission_error: error.to_string(), + }) + } + + /// Revoke static provider credentials after the requested environment + /// could not be obtained. Previous dynamic grants remain active. + async fn revoke_provider_environment( + &self, + ctx: &PolicyPollLoopContext, + identity: EnvironmentIdentity, + reason: ProviderReadinessReason, + revision: u64, + ) { + ctx.provider_readiness.credentials_failed(identity, reason); + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message("Provider environment refresh failed; static credentials were revoked and previous dynamic grants remain active") + .build()); + ctx.provider_credentials + .revoke_static_provider_environment(revision); + endpoint_status::reset( + ctx.endpoint_observation_tx.as_ref(), + self.endpoint_policy.as_ref(), + &self.policy_hash, + revision, + ) + .await; + } + + /// Revoke static credentials whose bindings failed validation while + /// keeping the environment's dynamic grants. + async fn reject_provider_bindings( + &self, + ctx: &PolicyPollLoopContext, + identity: EnvironmentIdentity, + provider: openshell_core::grpc_client::ProviderEnvironmentResult, + ) { + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message("Provider environment bindings failed validation; static credentials were revoked and fetched dynamic grants remain active") + .build()); + ctx.provider_readiness + .credentials_failed(identity, ProviderReadinessReason::CredentialInstallFailed); + let revision = provider.provider_env_revision; + // Repeat the rejected binding validation on the live state to + // revoke static material and retain the fetched dynamic grants. + let _ = ctx + .provider_credentials + .install_bound_environment_with_stable_placeholders( + provider.provider_env_revision, + provider.environment, + provider.credential_expires_at_ms, + provider.dynamic_credentials, + provider.static_credential_bindings, + provider.non_secret_environment_keys, + provider.stable_placeholder_environment_keys, + ); + endpoint_status::reset( + ctx.endpoint_observation_tx.as_ref(), + self.endpoint_policy.as_ref(), + &self.policy_hash, + revision, + ) + .await; + } + + /// Apply one payload delivered over the supervisor session and answer it. + async fn apply_delivered( + &mut self, + ctx: &PolicyPollLoopContext, + client: &C, + request: ConfigApplyRequest, + ) { + let ConfigApplyRequest { update, response } = request; + let openshell_core::proto::ConfigUpdate { + update_id, + sandbox_config, + provider_environment, + } = update; + let (Some(sandbox_config), Some(provider_environment)) = + (sandbox_config, provider_environment) + else { + // The session validates updates before they reach the runtime. + let _ = response.send(ConfigUpdateResult { + update_id, + outcome: ConfigApplyOutcome::Unsupported.into(), + failure: Some(config_apply_failure( + "invalid_update", + "configuration update is missing a configuration part", + false, + )), + admission: None, + policy_load: None, + }); + return; + }; + let result = self + .apply_delivered_sandbox(ctx, client, sandbox_config.into(), provider_environment) + .await; + let _ = response.send(result.into_update_result(update_id)); + } + + /// Apply a delivered sandbox configuration with the provider environment + /// it names. The environment is installed only when the runtime does not + /// already hold it; one that cannot be installed rejects the update, as a + /// failed fetch does when polling. + async fn apply_delivered_sandbox( + &mut self, + ctx: &PolicyPollLoopContext, + client: &C, + snapshot: openshell_core::grpc_client::SettingsPollResult, + provider_environment: openshell_core::proto::ProviderEnvironmentSnapshot, + ) -> DeliveredResult { + let requested_revision = sandbox_config_revision(&snapshot); + let desired_environment = EnvironmentIdentity::from_settings(&snapshot); + let provider = if !snapshot.configuration_admitted { + if !self.reloads_gateway_policy { + return DeliveredResult { + outcome: ConfigApplyOutcome::FailedRetainedLastKnownGood, + failure: Some(config_apply_failure( + "configuration_rejected", + &snapshot.configuration_error, + true, + )), + admission: config_admission(&snapshot, false), + policy_load: None, + }; + } + if self.reloads_gateway_policy + && let Some(rejected) = self.stream.rejected_sandbox.as_ref() + && rejected.matches(&snapshot, &requested_revision, &desired_environment) + { + return DeliveredResult { + admission: config_admission(&snapshot, false), + ..rejected.result.clone() + }; + } + None + } else if !ctx + .provider_readiness + .needs_environment(&desired_environment) + { + None + } else { + match self + .prepare_delivered_provider(ctx, provider_environment, &desired_environment) + .await + { + Ok(prepared) => Some(prepared), + Err(failure) => { + // Static credentials were revoked, so this configuration + // is not applied until its environment installs. + let outcome = if self.applied_revision.is_some() { + ConfigApplyOutcome::FailedRetainedLastKnownGood + } else { + ConfigApplyOutcome::FailedClosed + }; + return DeliveredResult { + outcome, + failure: Some(failure), + admission: config_admission(&snapshot, false), + policy_load: None, + }; + } + } + }; + + let consumes_provider = provider.is_some(); + let (rejection, policy_status) = match self + .reconcile(ctx, client, &snapshot, ProviderSource::Delivered(provider)) + .await + { + Ok(Reconciled { + outcome: ReconcileOutcome::Rejected(rejection), + policy_status, + }) => (rejection, policy_status), + Ok(Reconciled { + outcome, + policy_status, + }) => { + self.stream.rejected_sandbox = None; + let _ = ctx.workspace_tx.send(snapshot.workspace.clone()); + let outcome = if !self.reloads_gateway_policy { + ConfigApplyOutcome::RetainedLocalOverride + } else if matches!(outcome, ReconcileOutcome::Unchanged) { + ConfigApplyOutcome::IgnoredDuplicate + } else { + ConfigApplyOutcome::Applied + }; + let admission = config_admission(&snapshot, true); + self.commit(snapshot); + return DeliveredResult { + outcome, + failure: None, + admission, + policy_load: policy_status.map(policy_load_result), + }; + } + Err(error) => { + // Polling ends its loop on this error; session delivery + // reports it instead, so record it here. + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Other, "failed") + .unmapped("config_revision", serde_json::json!(snapshot.config_revision)) + .unmapped("policy_hash", serde_json::json!(&snapshot.policy_hash)) + .message(format!( + "Pushed sandbox configuration failed to apply [config_revision:{} error:{error}]", + snapshot.config_revision + )) + .build()); + return DeliveredResult { + outcome: ConfigApplyOutcome::FailedClosed, + failure: Some(config_apply_failure( + "configuration_rejected", + &error.to_string(), + true, + )), + admission: config_admission(&snapshot, false), + policy_load: None, + }; + } + }; + + // The previous revision is retained only while its policy is enforced. + let retained_revision = self + .applied_revision + .clone() + .filter(|_| ctx.opa_engine.fail_closed_reason().is_none()); + let outcome = match retained_revision.as_ref() { + // The requested configuration is still the enforced one, so only + // its runtime upgrade failed. + Some(retained) + if *retained == requested_revision + && !consumes_provider + && snapshot.configuration_admitted => + { + ConfigApplyOutcome::Degraded + } + Some(_) => ConfigApplyOutcome::FailedRetainedLastKnownGood, + None => ConfigApplyOutcome::FailedClosed, + }; + let detail = if rejection.detail.is_empty() { + "effective configuration was rejected by the gateway" + } else { + &rejection.detail + }; + let (failure, admission) = if outcome == ConfigApplyOutcome::Degraded { + self.rebind_enforced_generation(ctx, &snapshot); + (None, config_admission(&snapshot, true)) + } else { + ( + Some(config_apply_failure(rejection.code, detail, true)), + config_admission(&snapshot, false), + ) + }; + let result = DeliveredResult { + outcome, + failure, + admission, + policy_load: policy_status.map(policy_load_result), + }; + if !snapshot.configuration_admitted { + self.stream.rejected_sandbox = Some(Box::new(RejectedStreamSandbox { + configuration_instance_id: snapshot.configuration_instance_id.clone(), + requested_revision, + environment: desired_environment, + error: snapshot.configuration_error.clone(), + failure_mode: snapshot.policy_validation_failure_mode, + result: result.clone(), + })); + } + result + } + + /// Prepare the provider environment an update carried. One that does not + /// match the configuration naming it, or cannot be installed, revokes + /// static credentials as a failed refresh does when polling. + async fn prepare_delivered_provider( + &self, + ctx: &PolicyPollLoopContext, + snapshot: openshell_core::proto::ProviderEnvironmentSnapshot, + desired: &EnvironmentIdentity, + ) -> std::result::Result { + let identity = EnvironmentIdentity { + attachment_epoch: snapshot.provider_attachment_epoch.clone(), + revision: snapshot.provider_env_revision, + policy_hash: snapshot.policy_hash.clone(), + }; + let revision = desired.revision; + let failure = if identity == *desired { + match openshell_core::grpc_client::ProviderEnvironmentResult::try_from(snapshot) { + Err(error) => { + self.revoke_provider_environment( + ctx, + desired.clone(), + ProviderReadinessReason::CredentialInstallFailed, + revision, + ) + .await; + config_apply_failure("invalid_provider_environment", &error.to_string(), false) + } + Ok(provider) if !provider_environment_is_installable(provider.readiness_reason) => { + self.revoke_provider_environment( + ctx, + desired.clone(), + provider.readiness_reason, + revision, + ) + .await; + config_apply_failure( + "provider_environment_unavailable", + "provider environment is not ready for installation", + true, + ) + } + Ok(provider) => match CapturedProviderEnvironment::prepare(&provider) { + Ok(prepared) => return Ok(prepared), + Err(error) => { + self.reject_provider_bindings(ctx, desired.clone(), provider) + .await; + config_apply_failure( + "invalid_provider_environment", + &error.to_string(), + false, + ) + } + }, + } + } else { + self.revoke_provider_environment( + ctx, + desired.clone(), + ProviderReadinessReason::SnapshotMismatch, + revision, + ) + .await; + config_apply_failure( + "provider_environment_mismatch", + "provider environment does not match the sandbox configuration", + false, + ) + }; + Err(failure) + } + + /// A failed upgrade of the configuration the runtime already enforces + /// leaves that configuration active, so readiness stays bound to the + /// generation that enforces it rather than reporting a failed policy. + fn rebind_enforced_generation( + &self, + ctx: &PolicyPollLoopContext, + snapshot: &openshell_core::grpc_client::SettingsPollResult, + ) { + if let Some(generation) = self + .policy_generation + .as_ref() + .filter(|generation| !generation.is_stale()) + { + ctx.provider_readiness.policy_activated( + &EnvironmentIdentity::from_settings(snapshot), + snapshot.config_revision, + generation.clone(), + ); + } + } +} + +pub async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { + let client = openshell_core::grpc_client::CachedOpenShellClient::connect_with_credentials( + &ctx.endpoint, + ctx.extension_credentials.clone(), + ) + .await?; + run_policy_poll_loop_with_client(ctx, client).await +} + +async fn report_runtime_configuration( + ctx: &PolicyPollLoopContext, + snapshot: &openshell_core::grpc_client::SettingsPollResult, + accepted: bool, + error: &str, +) -> bool { + let LoadedPolicyOrigin::Gateway { + revision: Some(revision), + .. + } = &ctx.loaded_policy_origin + else { + return true; + }; + let Some(instance_id) = revision.admission_instance_id.as_deref() else { + return true; + }; + let state = if accepted { + openshell_core::proto::ConfigurationAdmissionState::Accepted + } else { + openshell_core::proto::ConfigurationAdmissionState::Rejected + }; + if !accepted { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Other, "configuration_error") + .message(error) + .build() + ); + } + openshell_core::grpc_client::report_sandbox_configuration( + &ctx.endpoint, + &ctx.sandbox_id, + instance_id, + Some(snapshot), + state, + error, + ) + .await + .is_ok() +} + +async fn run_policy_poll_loop_with_client( + mut ctx: PolicyPollLoopContext, + client: C, +) -> Result<()> { + let mut config_apply_rx = ctx.config_apply_rx.take(); + let (status_sender, status_receiver) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(run_policy_status_reporter( + client.clone(), + ctx.sandbox_id.clone(), + status_receiver, + )); + if let Some(endpoint_status_receiver) = ctx.endpoint_status_rx.take() { + tokio::spawn(endpoint_status::run_reporter( + client.clone(), + ctx.sandbox_id.clone(), + endpoint_status_receiver, + ctx.supervisor_session_id.clone(), + )); + } + + // The runtime stops polling while its current session pushes + // configuration. A reconnect to a gateway that does not push, such as + // after a rollback to poll mode, resumes polling. Keep the last value if + // the session task ends. + let mut config_push_updates = ctx.config_push_enabled.take(); + let mut config_push_enabled = config_push_updates + .as_ref() + .is_some_and(|updates| *updates.borrow()); + let mut runtime = ConfigRuntime::new(&ctx); + + // A first poll that does not match the policy already loaded into OPA must + // pass through the normal reconciliation path immediately. It must never + // seed the applied-state trackers before OPA actually loads it. + let mut pending_result = None; + // Bind startup evidence before awaiting the gateway. A generation changed + // during that request no longer proves which policy startup installed. + let initial_generation = ctx + .opa_engine + .generation_guard(ctx.opa_engine.current_generation()) + .ok(); + + // Initialize revision from the first poll and acknowledge the initial + // policy revision the supervisor actually loaded. A mismatched result is + // reconciled below instead of being recorded as already applied. + match client.poll_settings(&ctx.sandbox).await { + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + match ( + initial_poll_disposition(&ctx.loaded_policy_origin, &result), + initial_generation.as_ref(), + ) { + (InitialPollDisposition::Acknowledge(candidate), Some(generation)) + if runtime.middleware_registry_status + == MiddlewareRegistryStatus::Synchronized + && !generation.is_stale() => + { + ctx.provider_readiness.policy_activated( + &EnvironmentIdentity::from_settings(&result), + result.config_revision, + generation.clone(), + ); + runtime.policy_generation = Some(generation.clone()); + // Startup enforces this revision, so an update that fails + // later retains it. + if runtime.reloads_gateway_policy { + runtime.applied_revision = Some(sandbox_config_revision(&result)); + } + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); + apply_agent_proposals_enabled( + &ctx.agent_proposals, + agent_proposals_enabled_from_settings(&result.settings), + "initial settings poll", + Some(candidate.config_revision), + skills::install_static_skills, + ); + runtime.config_revision = candidate.config_revision; + runtime.policy_version = candidate.version; + runtime.policy_hash.clone_from(&candidate.policy_hash); + runtime.endpoint_policy.clone_from(&result.policy); + endpoint_status::reset( + ctx.endpoint_observation_tx.as_ref(), + runtime.endpoint_policy.as_ref(), + &runtime.policy_hash, + ctx.provider_credentials.snapshot().revision, + ) + .await; + runtime.middleware_services = result.supervisor_middleware_services; + runtime.extension_authentication_enabled = + result.extension_authentication_enabled; + runtime.settings = result.settings; + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::initial_loaded(&candidate), + ); + debug!( + config_revision = runtime.config_revision, + "Settings poll: initial policy matches loaded revision" + ); + } + (InitialPollDisposition::Acknowledge(_) | InitialPollDisposition::Reconcile, _) => { + // Matching policy bytes cannot prove an unavailable registry + // or a replaced generation. Install this snapshot immediately. + pending_result = Some(result); + } + (InitialPollDisposition::TrackOnly, _) => { + apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); + apply_agent_proposals_enabled( + &ctx.agent_proposals, + agent_proposals_enabled_from_settings(&result.settings), + "initial settings poll", + Some(result.config_revision), + skills::install_static_skills, + ); + runtime.config_revision = result.config_revision; + runtime.policy_hash = result.policy_hash.clone(); + runtime.middleware_services = result.supervisor_middleware_services; + runtime.extension_authentication_enabled = + result.extension_authentication_enabled; + runtime.settings = result.settings; + debug!( + config_revision = runtime.config_revision, + "Settings poll: tracking gateway config while preserving local policy override" + ); + } + } + } + Err(e) => { + warn!(error = %e, "Settings poll: failed to fetch initial version, will retry"); + } + } + + let interval = Duration::from_secs(ctx.interval_secs); + loop { + if let Some(updates) = config_push_updates.as_ref() { + config_push_enabled = *updates.borrow(); + } + if config_push_enabled { + // The session's bootstrap supersedes a polled result that was + // not reconciled yet. + pending_result = None; + let delay = next_poll_delay(&ctx.extension_credentials, interval); + tokio::select! { + changed = async { + match config_push_updates.as_mut() { + Some(updates) => updates.changed().await.is_ok(), + None => std::future::pending().await, + } + } => { + if !changed { + config_push_updates = None; + } + } + request = receive_config_apply(&mut config_apply_rx) => { + let Some(request) = request else { + return Err(miette::miette!("stream configuration apply channel closed")); + }; + runtime.apply_delivered(&ctx, &client, request).await; + } + () = tokio::time::sleep(delay) => { + if runtime.extension_authentication_enabled + && let Err(error) = client.refresh_installed_extension_credentials().await + { + warn!(error = %error, "Extension credential refresh failed"); + } + } + } + continue; + } + let result = if let Some(result) = pending_result.take() { + result + } else { + let delay = next_poll_delay(&ctx.extension_credentials, interval); + tokio::select! { + request = receive_config_apply(&mut config_apply_rx) => { + if let Some(request) = request { + runtime.apply_delivered(&ctx, &client, request).await; + continue; + } + } + () = tokio::time::sleep(delay) => {} + } + match client.poll_settings(&ctx.sandbox).await { + Ok(result) => { + let _ = ctx.workspace_tx.send(client.workspace()); + result + } + Err(e) => { + debug!(error = %e, "Settings poll: server unreachable, will retry"); + if runtime.extension_authentication_enabled + && let Err(refresh_error) = + client.refresh_installed_extension_credentials().await + { + warn!( + error = %refresh_error, + "Settings poll: extension credential refresh failed while configuration was unavailable" + ); + } + continue; + } + } + }; + + let reconciled = runtime + .reconcile(&ctx, &client, &result, ProviderSource::Fetch) + .await?; + if let Some(status) = reconciled.policy_status { + enqueue_policy_status(&status_sender, status); + } + match reconciled.outcome { + ReconcileOutcome::Unchanged => {} + ReconcileOutcome::Rejected(rejection) => { + report_runtime_configuration(&ctx, &result, false, &rejection.admission_error) + .await; + } + // Retry the exact status tuple on the next poll before advancing + // the observed revision; an old instance cannot claim readiness. + ReconcileOutcome::Applied => { + if report_runtime_configuration(&ctx, &result, true, "").await { + runtime.commit(result); + } + } + } + } +} + +fn apply_ocsf_json_setting( + enabled: &AtomicBool, + settings: &std::collections::HashMap, +) { + use std::sync::atomic::Ordering; + + let new_ocsf = extract_bool_setting(settings, "ocsf_json_enabled").unwrap_or(false); + let prev_ocsf = enabled.swap(new_ocsf, Ordering::Relaxed); + if new_ocsf != prev_ocsf { + info!(ocsf_json_enabled = new_ocsf, "OCSF JSONL logging toggled"); + } +} + +/// Extract a bool value from an effective setting, if present. +fn extract_bool_setting( + settings: &std::collections::HashMap, + key: &str, +) -> Option { + use openshell_core::proto::setting_value; + settings + .get(key) + .and_then(|es| es.value.as_ref()) + .and_then(|sv| sv.value.as_ref()) + .and_then(|v| match v { + setting_value::Value::BoolValue(b) => Some(*b), + _ => None, + }) +} + +fn apply_ocsf_schema_version_setting( + version: &std::sync::Mutex, + settings: &std::collections::HashMap, +) { + let new_version = extract_string_setting(settings, "ocsf_schema_version").unwrap_or_default(); + if let Ok(mut current) = version.lock() + && *current != new_version + { + info!( + ocsf_schema_version = %new_version, + "OCSF schema version target changed" + ); + *current = new_version; + } +} + +/// Extract a string value from an effective setting, if present. +fn extract_string_setting( + settings: &std::collections::HashMap, + key: &str, +) -> Option { + use openshell_core::proto::setting_value; + settings + .get(key) + .and_then(|es| es.value.as_ref()) + .and_then(|sv| sv.value.as_ref()) + .and_then(|v| match v { + setting_value::Value::StringValue(value) => Some(value.clone()), + _ => None, + }) +} + +pub fn agent_proposals_enabled_from_settings( + settings: &std::collections::HashMap, +) -> bool { + extract_bool_setting( + settings, + openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY, + ) + .unwrap_or(false) +} + +fn apply_agent_proposals_enabled( + agent_proposals: &AgentProposals, + enabled: bool, + source: &'static str, + config_revision: Option, + install_static_skills: impl FnOnce() -> Result, +) { + let previously_enabled = agent_proposals.swap_enabled(enabled); + if enabled == previously_enabled { + return; + } + + info!( + agent_policy_proposals_enabled = enabled, + source, config_revision, "agent-driven policy proposals toggled" + ); + + if enabled && !previously_enabled { + match install_static_skills() { + Ok(installed) => info!( + path = %installed.policy_advisor.display(), + "Installed sandbox agent skill on toggle-on" + ), + Err(error) => warn!( + error = %error, + "Failed to install sandbox agent skill on toggle-on" + ), + } + } +} + +/// Log individual setting changes between two snapshots. +fn log_setting_changes( + old: &std::collections::HashMap, + new: &std::collections::HashMap, +) { + for (key, new_es) in new { + let new_val = format_setting_value(new_es); + match old.get(key) { + Some(old_es) => { + let old_val = format_setting_value(old_es); + if old_val != new_val { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "updated") + .unmapped("key", serde_json::json!(key)) + .unmapped("old", serde_json::json!(old_val.clone())) + .unmapped("new", serde_json::json!(new_val.clone())) + .message(format!( + "Setting changed [key:{key} old:{old_val} new:{new_val}]" + )) + .build() + ); + } + } + None => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "enabled") + .unmapped("key", serde_json::json!(key)) + .unmapped("value", serde_json::json!(new_val.clone())) + .message(format!("Setting added [key:{key} value:{new_val}]")) + .build() + ); + } + } + } + for key in old.keys() { + if !new.contains_key(key) { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Disabled, "disabled") + .unmapped("key", serde_json::json!(key)) + .message(format!("Setting removed [key:{key}]")) + .build() + ); + } + } +} + +/// Format an `EffectiveSetting` value for log display. +fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String { + use openshell_core::proto::setting_value; + match es.value.as_ref().and_then(|sv| sv.value.as_ref()) { + None => "".to_string(), + Some(setting_value::Value::StringValue(v)) => v.clone(), + Some(setting_value::Value::BoolValue(v)) => v.to_string(), + Some(setting_value::Value::IntValue(v)) => v.to_string(), + Some(setting_value::Value::BytesValue(_)) => "".to_string(), + } +} + +#[cfg(test)] +#[allow( + clippy::needless_raw_string_hashes, + clippy::iter_on_single_items, + clippy::similar_names, + clippy::manual_string_new, + clippy::doc_markdown, + reason = "Test code: test fixtures often use idiomatic forms not flagged in production." +)] +mod tests { + use super::*; + use crate::tests::{ + proto_policy_fixture, proto_tcp_policy_fixture, settings_poll_result, startup_provider, + }; + use crate::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn effective_bool(value: bool) -> openshell_core::proto::EffectiveSetting { + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::BoolValue( + value, + )), + }), + scope: openshell_core::proto::SettingScope::Global.into(), + } + } + + #[test] + fn apply_agent_proposals_enabled_installs_only_on_false_to_true() { + let agent_proposals = AgentProposals::default(); + let installs = AtomicUsize::new(0); + + apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(1), || { + installs.fetch_add(1, Ordering::Relaxed); + Ok(skills::InstalledSkills { + policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + agents: None, + }) + }); + assert!(agent_proposals.enabled()); + assert_eq!(installs.load(Ordering::Relaxed), 1); + + apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(2), || { + installs.fetch_add(1, Ordering::Relaxed); + Ok(skills::InstalledSkills { + policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + agents: None, + }) + }); + assert_eq!(installs.load(Ordering::Relaxed), 1); + + apply_agent_proposals_enabled(&agent_proposals, false, "test", Some(3), || { + installs.fetch_add(1, Ordering::Relaxed); + Ok(skills::InstalledSkills { + policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + agents: None, + }) + }); + assert!(!agent_proposals.enabled()); + assert_eq!(installs.load(Ordering::Relaxed), 1); + } + + #[test] + fn apply_ocsf_json_setting_enables_from_initial_settings_snapshot() { + let enabled = AtomicBool::new(false); + let mut settings = std::collections::HashMap::new(); + settings.insert("ocsf_json_enabled".to_string(), effective_bool(true)); + + apply_ocsf_json_setting(&enabled, &settings); + + assert!(enabled.load(Ordering::Relaxed)); + } + + #[test] + fn apply_ocsf_json_setting_disables_when_setting_is_unset() { + let enabled = AtomicBool::new(true); + let settings = std::collections::HashMap::new(); + + apply_ocsf_json_setting(&enabled, &settings); + + assert!(!enabled.load(Ordering::Relaxed)); + } + + #[test] + fn apply_ocsf_schema_version_setting_updates_from_initial_settings_snapshot() { + let version = std::sync::Mutex::new(String::new()); + let mut settings = std::collections::HashMap::new(); + settings.insert( + "ocsf_schema_version".to_string(), + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "1.3".into(), + )), + }), + scope: openshell_core::proto::SettingScope::Sandbox.into(), + }, + ); + + apply_ocsf_schema_version_setting(&version, &settings); + + assert_eq!(*version.lock().unwrap(), "1.3"); + } + + #[test] + fn apply_ocsf_schema_version_setting_clears_when_setting_is_unset() { + let version = std::sync::Mutex::new("1.1".to_string()); + let settings = std::collections::HashMap::new(); + + apply_ocsf_schema_version_setting(&version, &settings); + + assert_eq!(*version.lock().unwrap(), ""); + } + + #[test] + fn agent_proposals_setting_enables_from_initial_settings_snapshot() { + let mut settings = std::collections::HashMap::new(); + settings.insert( + openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + effective_bool(true), + ); + + assert!(agent_proposals_enabled_from_settings(&settings)); + } + + #[test] + fn agent_proposals_setting_defaults_false_when_unset() { + let settings = std::collections::HashMap::new(); + + assert!(!agent_proposals_enabled_from_settings(&settings)); + } + + // ---- Policy disk discovery tests ---- + + #[derive(Clone)] + struct ScriptedPolicyGateway { + polls: Arc< + tokio::sync::Mutex< + tokio::sync::mpsc::UnboundedReceiver< + openshell_core::grpc_client::SettingsPollResult, + >, + >, + >, + reports: UnboundedSender<(u32, bool, String)>, + environment_identity: Arc>, + poll_calls: Arc, + polled_sandboxes: Arc>>, + } + + impl PolicyGatewayClient for ScriptedPolicyGateway { + async fn poll_settings( + &self, + sandbox: &str, + ) -> Result { + self.poll_calls.fetch_add(1, Ordering::SeqCst); + self.polled_sandboxes.lock().await.push(sandbox.to_string()); + let result = self + .polls + .lock() + .await + .recv() + .await + .ok_or_else(|| miette::miette!("scripted policy poll channel closed"))?; + *self.environment_identity.lock().unwrap() = + EnvironmentIdentity::from_settings(&result); + Ok(result) + } + + async fn fetch_provider_environment( + &self, + _endpoint: &str, + _sandbox_id: &str, + _requires_stable_placeholders: bool, + ) -> Result { + let identity = self.environment_identity.lock().unwrap().clone(); + let mut provider = startup_provider(identity.revision); + provider.provider_attachment_epoch = identity.attachment_epoch; + provider.policy_hash = identity.policy_hash; + if provider.policy_hash.is_empty() { + provider.readiness_reason = ProviderReadinessReason::SnapshotMismatch; + } + Ok(provider) + } + + async fn report_policy_status( + &self, + _sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.reports + .send((version, loaded, error.to_string())) + .map_err(|_| miette::miette!("scripted policy report channel closed")) + } + + fn workspace(&self) -> String { + "test-workspace".to_string() + } + } + + #[derive(Clone)] + struct CredentialRejectingPolicyGateway { + inner: ScriptedPolicyGateway, + credential_requests: Arc, + } + + impl PolicyGatewayClient for CredentialRejectingPolicyGateway { + async fn poll_settings( + &self, + sandbox: &str, + ) -> Result { + self.inner.poll_settings(sandbox).await + } + + async fn fetch_provider_environment( + &self, + endpoint: &str, + sandbox_id: &str, + requires_stable_placeholders: bool, + ) -> Result { + self.inner + .fetch_provider_environment(endpoint, sandbox_id, requires_stable_placeholders) + .await + } + + async fn report_policy_status( + &self, + sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.inner + .report_policy_status(sandbox_id, version, loaded, error) + .await + } + + async fn extension_credentials_for( + &self, + _services: &[openshell_core::proto::SupervisorMiddlewareService], + ) -> Result> + { + self.credential_requests.fetch_add(1, Ordering::SeqCst); + Err(miette::miette!( + "gateway extension authentication is unavailable" + )) + } + + fn workspace(&self) -> String { + self.inner.workspace() + } + } + + fn scripted_policy_gateway() -> ( + ScriptedPolicyGateway, + UnboundedSender, + tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, + ) { + let (poll_tx, poll_rx) = tokio::sync::mpsc::unbounded_channel(); + let (report_tx, report_rx) = tokio::sync::mpsc::unbounded_channel(); + ( + ScriptedPolicyGateway { + polls: Arc::new(tokio::sync::Mutex::new(poll_rx)), + reports: report_tx, + environment_identity: Arc::new(std::sync::Mutex::new( + EnvironmentIdentity::default(), + )), + poll_calls: Arc::new(AtomicUsize::new(0)), + polled_sandboxes: Arc::new(tokio::sync::Mutex::new(Vec::new())), + }, + poll_tx, + report_rx, + ) + } + + fn static_provider_environment( + revision: u64, + value: Option<&str>, + ) -> openshell_core::grpc_client::ProviderEnvironmentResult { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + use std::collections::HashMap; + + let mut result = openshell_core::grpc_client::ProviderEnvironmentResult { + files: HashMap::new(), + environment: HashMap::new(), + provider_env_revision: revision, + provider_attachment_epoch: String::new(), + policy_hash: "hash-v1".to_string(), + readiness_reason: ProviderReadinessReason::Unspecified, + credential_expires_at_ms: HashMap::new(), + dynamic_credentials: HashMap::new(), + static_credential_bindings: HashMap::new(), + non_secret_environment_keys: Vec::new(), + stable_placeholder_environment_keys: Vec::new(), + }; + if let Some(value) = value { + result + .environment + .insert("EXTERNAL_TOKEN".into(), value.into()); + result.static_credential_bindings.insert( + "EXTERNAL_TOKEN".into(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "tools.example.com".into(), + port: 443, + path: "/v1/**".into(), + }], + credential_identity: "provider-a:EXTERNAL_TOKEN".into(), + workload_credential_handle: String::new(), + }, + ); + } + result + } + + #[test] + fn initial_provider_credentials_preserve_revision_scoped_delivery() { + let readiness = ProviderReadinessTracker::new(); + let state = initial_provider_credentials( + static_provider_environment(1, Some("initial")), + &readiness, + ); + let (revision, child_env) = state.child_env_snapshot_with_gcp_resolved().unwrap(); + let reference = &child_env["EXTERNAL_TOKEN"]; + assert_eq!(revision, 1); + assert_eq!(reference, "openshell:resolve:env:v1_EXTERNAL_TOKEN"); + assert_eq!( + state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .unwrap() + .resolve_placeholder(reference), + Some("initial"), + ); + assert!(readiness.observation(&state).credentials_installed); + assert!(!readiness.observation(&state).launch_environment_installed); + // The boundary receives the prepared snapshot without gaining resolver authority. + let boundary = + ProviderCredentialState::from_child_env_snapshot(revision, child_env.clone()); + assert_eq!(boundary.snapshot().child_env, child_env); + assert!(boundary.resolver().is_none()); + + let mut invalid = static_provider_environment(2, Some("invalid")); + invalid.static_credential_bindings.clear(); + let rejected = initial_provider_credentials(invalid, &readiness); + assert!(rejected.snapshot().child_env.is_empty()); + assert!(rejected.resolver().is_none()); + assert_eq!( + readiness.observation(&rejected).reason, + i32::from(ProviderReadinessReason::CredentialInstallFailed) + ); + } + + fn stable_provider_environment( + revision: u64, + value: Option<&str>, + ) -> openshell_core::grpc_client::ProviderEnvironmentResult { + let mut result = static_provider_environment(revision, value); + if let Some(binding) = result.static_credential_bindings.get_mut("EXTERNAL_TOKEN") { + binding.workload_credential_handle = "a".repeat(64); + result + .stable_placeholder_environment_keys + .push("EXTERNAL_TOKEN".into()); + } + result + } + + #[test] + fn initial_provider_credentials_preserve_stable_delivery_requirement() { + let readiness = ProviderReadinessTracker::new(); + let policy = proto_policy_fixture(); + let mut startup = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + startup.provider_env_revision = 1; + let (_, _, prepared) = prepare_startup_configuration( + &startup, + &policy, + &stable_provider_environment(1, Some("initial")), + ) + .expect("stable credentials prepare for admitted startup"); + assert!(prepared.requires_stable_placeholders()); + let state = initial_provider_credentials( + stable_provider_environment(1, Some("initial")), + &readiness, + ); + assert!(state.requires_stable_placeholders()); + assert!(readiness.observation(&state).credentials_installed); + let (revision, child_env) = state.child_env_snapshot_with_gcp_resolved().unwrap(); + assert_eq!(prepared.snapshot().child_env, child_env); + let reference = &child_env["EXTERNAL_TOKEN"]; + assert_eq!(revision, 1); + assert_eq!( + reference, + &format!("openshell:resolve:env:s{}_EXTERNAL_TOKEN", "a".repeat(64)) + ); + assert_eq!( + state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .unwrap() + .resolve_placeholder(reference), + Some("initial"), + ); + // The remote workload receives the prepared snapshot, with no resolver + // authority and no second placeholder transformation at its boundary. + let boundary = + ProviderCredentialState::from_child_env_snapshot(revision, child_env.clone()); + assert_eq!(boundary.snapshot().child_env, child_env); + assert!(boundary.resolver().is_none()); + + let mut invalid = stable_provider_environment(2, Some("invalid")); + invalid + .static_credential_bindings + .get_mut("EXTERNAL_TOKEN") + .unwrap() + .workload_credential_handle + .clear(); + startup.provider_env_revision = 2; + assert!(prepare_startup_configuration(&startup, &policy, &invalid).is_err()); + let rejected = initial_provider_credentials(invalid, &readiness); + assert!(rejected.snapshot().child_env.is_empty()); + assert!(rejected.resolver().is_none()); + assert_eq!( + readiness.observation(&rejected).reason, + i32::from(ProviderReadinessReason::CredentialInstallFailed) + ); + } + + type ProviderFetchRequest = ( + bool, + tokio::sync::oneshot::Sender< + Result, + >, + ); + + #[derive(Clone)] + struct ScriptedProviderGateway { + policy: ScriptedPolicyGateway, + requests: UnboundedSender, + } + + impl PolicyGatewayClient for ScriptedProviderGateway { + async fn poll_settings( + &self, + sandbox: &str, + ) -> Result { + self.policy.poll_settings(sandbox).await + } + + async fn report_policy_status( + &self, + sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.policy + .report_policy_status(sandbox_id, version, loaded, error) + .await + } + + async fn fetch_provider_environment( + &self, + _endpoint: &str, + sandbox_id: &str, + requires_stable_placeholders: bool, + ) -> Result { + assert_eq!(sandbox_id, "sandbox-test"); + let (response, received) = tokio::sync::oneshot::channel(); + self.requests + .send((requires_stable_placeholders, response)) + .map_err(|_| miette::miette!("provider request channel closed"))?; + received + .await + .map_err(|_| miette::miette!("provider response channel closed"))? + } + + fn workspace(&self) -> String { + self.policy.workspace() + } + } + + #[tokio::test] + async fn provider_readiness_initial_poll_waits_for_middleware_reconciliation() { + let policy = proto_policy_fixture(); + let mut settings = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + settings.provider_attachment_epoch = "epoch".to_string(); + settings.provider_env_revision = 9; + settings.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "scripted-guard".to_string(), + grpc_endpoint: "http://scripted.invalid".to_string(), + ..Default::default() + }]; + let (attempt_tx, mut attempts) = tokio::sync::mpsc::unbounded_channel(); + let (complete, completions) = tokio::sync::mpsc::unbounded_channel::(); + let completions = Arc::new(tokio::sync::Mutex::new(completions)); + let connector: MiddlewareConnector = Arc::new(move |services, _authentication| { + assert_eq!(services.len(), 1); + assert_eq!(services[0].name, "scripted-guard"); + attempt_tx.send(()).unwrap(); + let completions = completions.clone(); + Box::pin(async move { + if completions.lock().await.recv().await.unwrap() { + connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await + } else { + Err(miette::miette!("scripted middleware connection failure")) + } + }) + }); + let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); + let mut context = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), + has_last_valid_policy: true, + }, + connector, + ); + context.middleware_registry_status = MiddlewareRegistryStatus::NeedsReconciliation; + context.provider_credentials = ProviderCredentialState::from_child_env_snapshot( + 9, + std::collections::HashMap::default(), + ); + let credentials = context.provider_credentials.clone(); + let tracker = context.provider_readiness.clone(); + tracker.credentials_installed( + EnvironmentIdentity::from_settings(&settings), + &credentials, + None, + ); + let (gateway, polls, mut reports) = scripted_policy_gateway(); + let task = tokio::spawn(run_policy_poll_loop_with_client(context, gateway)); + polls.send(settings.clone()).unwrap(); + timeout(Duration::from_secs(1), attempts.recv()) + .await + .expect("initial snapshot must reconcile without a second poll") + .unwrap(); + let observed = tracker.observation(&credentials); + assert!(observed.credentials_installed); + assert!(!observed.policy_active); + assert!( + !observed.launch_environment_installed, + "process evidence requires its own boundary acknowledgment" + ); + expect_no_policy_report(&mut reports).await; + + complete.send(false).unwrap(); + timeout(Duration::from_secs(1), async { + while tracker.observation(&credentials).reason + != i32::from(ProviderReadinessReason::PolicyActivationFailed) + { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!tracker.observation(&credentials).policy_active); + assert_eq!(engine.current_generation(), 0); + expect_no_policy_report(&mut reports).await; + + polls.send(settings).unwrap(); + timeout(Duration::from_secs(1), attempts.recv()) + .await + .unwrap() + .unwrap(); + assert!(!tracker.observation(&credentials).policy_active); + complete.send(true).unwrap(); + expect_policy_report(&mut reports, 1).await; + assert!(tracker.observation(&credentials).policy_active); + assert_eq!(engine.current_generation(), 1); + task.abort(); + let _ = task.await; + } + + #[derive(Clone)] + struct GenerationChangingPolicyGateway { + inner: ScriptedPolicyGateway, + engine: Arc, + first_poll: Arc, + } + + impl PolicyGatewayClient for GenerationChangingPolicyGateway { + async fn poll_settings( + &self, + sandbox: &str, + ) -> Result { + let result = self.inner.poll_settings(sandbox).await?; + if self.first_poll.swap(false, Ordering::SeqCst) { + self.engine + .enter_fail_closed("generation replaced while first poll was pending")?; + } + Ok(result) + } + + async fn fetch_provider_environment( + &self, + endpoint: &str, + sandbox_id: &str, + requires_stable_placeholders: bool, + ) -> Result { + self.inner + .fetch_provider_environment(endpoint, sandbox_id, requires_stable_placeholders) + .await + } + + async fn report_policy_status( + &self, + sandbox_id: &str, + version: u32, + loaded: bool, + error: &str, + ) -> Result<()> { + self.inner + .report_policy_status(sandbox_id, version, loaded, error) + .await + } + + fn workspace(&self) -> String { + self.inner.workspace() + } + } + + #[tokio::test] + async fn provider_readiness_initial_poll_reconciles_a_replaced_policy_generation() { + let policy = proto_policy_fixture(); + let settings = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); + let context = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let credentials = context.provider_credentials.clone(); + let tracker = context.provider_readiness.clone(); + tracker.credentials_installed( + EnvironmentIdentity::from_settings(&settings), + &credentials, + None, + ); + let (inner, polls, mut reports) = scripted_policy_gateway(); + let gateway = GenerationChangingPolicyGateway { + inner, + engine: engine.clone(), + first_poll: Arc::new(AtomicBool::new(true)), + }; + let task = tokio::spawn(run_policy_poll_loop_with_client(context, gateway)); + polls.send(settings).unwrap(); + expect_policy_report(&mut reports, 1).await; + assert!( + engine.fail_closed_reason().is_none(), + "the delivered policy must replace the intervening quarantine before acknowledgment" + ); + assert_eq!( + engine.current_generation(), + 2, + "generation 1 was not the policy startup installed" + ); + assert!(tracker.observation(&credentials).policy_active); + task.abort(); + let _ = task.await; + } + + #[tokio::test] + async fn provider_readiness_poll_waits_for_installation_and_retries_same_fingerprint() { + let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); + let mut initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + initial.provider_env_revision = 6; + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let credentials = ctx.provider_credentials.clone(); + let tracker = ctx.provider_readiness.clone(); + let (policy, polls, mut reports) = scripted_policy_gateway(); + let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); + polls.send(initial.clone()).unwrap(); + let task = tokio::spawn(run_policy_poll_loop_with_client( + ctx, + ScriptedProviderGateway { policy, requests }, + )); + expect_policy_report(&mut reports, 1).await; + + polls.send(initial.clone()).unwrap(); + let (_, response) = timeout(Duration::from_secs(1), received.recv()) + .await + .unwrap() + .unwrap(); + assert!( + !tracker.observation(&credentials).credentials_installed, + "fetching desired credentials does not install them" + ); + assert!(response.send(Err(miette::miette!("unavailable"))).is_ok()); + timeout(Duration::from_secs(1), async { + while tracker.observation(&credentials).reason + != i32::from(ProviderReadinessReason::CredentialInstallFailed) + { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let failed_id = credentials.snapshot().installation_id.clone(); + + polls.send(initial.clone()).unwrap(); + let (_, response) = timeout(Duration::from_secs(1), received.recv()) + .await + .unwrap() + .unwrap(); + assert!( + response + .send(Ok(static_provider_environment(6, Some("repaired")))) + .is_ok() + ); + timeout(Duration::from_secs(1), async { + while !tracker.observation(&credentials).credentials_installed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let observed = tracker.observation(&credentials); + assert_ne!(failed_id, credentials.snapshot().installation_id); + assert!(observed.policy_active); + assert!( + !observed.launch_environment_installed, + "a separate boundary acknowledgment is still required" + ); + + // A policy change can preserve the provider content fingerprint while + // changing the credential authority. Fetch and install that identity too. + let mut changed = initial; + changed.version = 2; + changed.config_revision = 200; + changed.policy_hash = "hash-v2".to_string(); + polls.send(changed).unwrap(); + let (_, response) = timeout(Duration::from_secs(1), received.recv()) + .await + .unwrap() + .unwrap(); + assert!(!tracker.observation(&credentials).policy_active); + let mut environment = static_provider_environment(6, Some("repaired")); + environment.policy_hash = "hash-v2".to_string(); + assert!(response.send(Ok(environment)).is_ok()); + expect_policy_report(&mut reports, 2).await; + let observed = tracker.observation(&credentials); + assert!(observed.credentials_installed && observed.policy_active); + assert_eq!(observed.provider_env_revision, 6); + assert_eq!(observed.config_revision, 200); + assert_eq!(observed.policy_hash, "hash-v2"); + task.abort(); + let _ = task.await; + } + + #[tokio::test] + async fn provider_readiness_startup_environment_avoids_unchanged_refresh() { + let policy = proto_policy_fixture(); + let mut settings = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + settings.provider_env_revision = 6; + let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); + let mut ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let mut provider = static_provider_environment(6, Some("initial")); + provider.policy_hash.clone_from(&settings.policy_hash); + let credentials = prepare_provider_environment(&provider).unwrap(); + ctx.provider_credentials = CapturedProviderEnvironment::new(credentials, &provider) + .install(&ctx.provider_readiness); + let generation = engine.current_generation(); + let guard = engine.generation_guard(generation).unwrap(); + let (policy_gateway, polls, mut reports) = scripted_policy_gateway(); + let observed_polls = policy_gateway.polled_sandboxes.clone(); + let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); + let task = tokio::spawn(run_policy_poll_loop_with_client( + ctx, + ScriptedProviderGateway { + policy: policy_gateway, + requests, + }, + )); + + polls.send(settings.clone()).unwrap(); + expect_policy_report(&mut reports, 1).await; + polls.send(settings).unwrap(); + timeout(Duration::from_secs(1), async { + while observed_polls.lock().await.len() < 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + assert!( + timeout(Duration::from_millis(50), received.recv()) + .await + .is_err(), + "unchanged settings must not refetch the startup environment" + ); + assert_eq!(engine.current_generation(), generation); + assert!(!guard.is_stale()); + task.abort(); + let _ = task.await; + } + + #[tokio::test] + async fn provider_poll_installs_fail_closed_environment_and_acknowledges_policy() { + let policy = proto_policy_fixture(); + let initial = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let credentials = ctx.provider_credentials.clone(); + let (policy_gateway, polls, mut reports) = scripted_policy_gateway(); + let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); + polls.send(initial).unwrap(); + let task = tokio::spawn(run_policy_poll_loop_with_client( + ctx, + ScriptedProviderGateway { + policy: policy_gateway, + requests, + }, + )); + expect_policy_report(&mut reports, 1).await; + + let mut changed = settings_poll_result( + Some(policy), + 2, + openshell_core::proto::PolicySource::Sandbox, + ); + changed.provider_env_revision = 1; + polls.send(changed).unwrap(); + let (_, response) = timeout(Duration::from_secs(1), received.recv()) + .await + .expect("provider refresh requested") + .expect("poll loop active"); + let mut withheld = static_provider_environment(1, None); + withheld.policy_hash = "hash-v2".to_string(); + withheld.readiness_reason = ProviderReadinessReason::CredentialsWithheld; + withheld + .environment + .insert("PROJECT_ID".to_string(), "example-project".to_string()); + withheld + .non_secret_environment_keys + .push("PROJECT_ID".to_string()); + assert!(response.send(Ok(withheld)).is_ok()); + + expect_policy_report(&mut reports, 2).await; + assert_eq!(credentials.revision(), 1); + assert!( + credentials.snapshot().child_env.contains_key("PROJECT_ID"), + "the reduced snapshot retains non-secret provider configuration" + ); + + task.abort(); + let _ = task.await; + } + + #[tokio::test] + async fn provider_poll_preserves_static_references_across_rotation_failure_and_detach() { + let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); + let initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let state = ctx.provider_credentials.clone(); + let tracker = ctx.provider_readiness.clone(); + let (policy, polls, mut reports) = scripted_policy_gateway(); + let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); + polls.send(initial.clone()).unwrap(); + let task = tokio::spawn(run_policy_poll_loop_with_client( + ctx, + ScriptedProviderGateway { policy, requests }, + )); + expect_policy_report(&mut reports, 1).await; + + // Rotation gives future processes a new reference; the retained old + // reference continues to resolve its original value until revocation. + let old_reference = "openshell:resolve:env:v1_EXTERNAL_TOKEN"; + for (revision, value, fail) in [ + (1, Some("initial"), false), + (2, Some("rotated"), false), + (3, None, true), + (3, Some("recovered"), false), + (4, None, false), + (5, None, false), + ] { + let mut poll = initial.clone(); + poll.provider_env_revision = revision; + polls.send(poll).unwrap(); + let (_, response) = timeout(Duration::from_secs(5), received.recv()) + .await + .expect("provider refresh requested") + .expect("poll loop active"); + let result = if fail { + Err(miette::miette!("provider snapshot unavailable")) + } else { + Ok(static_provider_environment(revision, value)) + }; + assert!(response.send(result).is_ok()); + let reference = format!("openshell:resolve:env:v{revision}_EXTERNAL_TOKEN"); + timeout(Duration::from_secs(5), async { + loop { + let resolved = state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .and_then(|resolver| { + resolver.resolve_placeholder(&reference).map(str::to_owned) + }); + let observed = tracker.observation(&state); + if state.revision() == revision + && resolved.as_deref() == value + && (fail || observed.credentials_installed) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("provider snapshot installed or revoked"); + if value.is_some() { + assert_eq!(state.snapshot().child_env["EXTERNAL_TOKEN"], reference); + if revision == 2 { + assert_ne!(reference, old_reference); + assert_eq!( + state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .unwrap() + .resolve_placeholder(old_reference), + Some("initial") + ); + } + } else { + assert!(state.snapshot().child_env.is_empty()); + assert!(state.resolver().is_none()); + } + } + task.abort(); + let _ = task.await; + } + + #[tokio::test] + async fn provider_poll_preserves_stable_delivery_across_rotation_failure_and_detach() { + let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); + let initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let state = ctx.provider_credentials.clone(); + let tracker = ctx.provider_readiness.clone(); + let (policy, polls, mut reports) = scripted_policy_gateway(); + let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); + // Startup acknowledges the policy already loaded before the refresh + // loop consumes later provider revisions. + polls.send(initial.clone()).unwrap(); + let task = tokio::spawn(run_policy_poll_loop_with_client( + ctx, + ScriptedProviderGateway { policy, requests }, + )); + expect_policy_report(&mut reports, 1).await; + let reference = format!("openshell:resolve:env:s{}_EXTERNAL_TOKEN", "a".repeat(64)); + + // A failed refresh must remain eligible for retry at the same revision, + // carrying the prior support requirement despite revoking its secrets. + for (revision, value, fail, required_before, required_after) in [ + (1, Some("initial"), false, false, true), + (2, Some("rotated"), false, true, true), + (3, None, true, true, true), + (3, Some("recovered"), false, true, true), + (4, None, false, true, false), + (5, None, false, false, false), + ] { + let mut poll = initial.clone(); + poll.provider_env_revision = revision; + polls.send(poll).unwrap(); + let (required, response) = timeout(Duration::from_secs(5), received.recv()) + .await + .expect("provider refresh requested") + .expect("poll loop active"); + assert_eq!(required, required_before, "revision {revision}"); + let result = if fail { + Err(miette::miette!("gateway support acknowledgment missing")) + } else { + Ok(stable_provider_environment(revision, value)) + }; + assert!(response.send(result).is_ok()); + timeout(Duration::from_secs(5), async { + loop { + let resolved = state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .and_then(|resolver| { + resolver.resolve_placeholder(&reference).map(str::to_owned) + }); + let observed = tracker.observation(&state); + if state.revision() == revision + && resolved.as_deref() == value + && (fail || observed.credentials_installed) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("provider snapshot installed or revoked"); + assert_eq!(state.requires_stable_placeholders(), required_after); + if value.is_some() { + assert_eq!(state.snapshot().child_env["EXTERNAL_TOKEN"], reference); + } else { + assert!(state.snapshot().child_env.is_empty()); + assert!(state.resolver().is_none()); + } + } + task.abort(); + let _ = task.await; + } + + fn policy_poll_test_context( + opa_engine: Arc, + loaded_policy_origin: LoadedPolicyOrigin, + middleware_connector: MiddlewareConnector, + ) -> PolicyPollLoopContext { + let (workspace_tx, _workspace_rx) = tokio::sync::watch::channel(String::new()); + let provider_credentials = + ProviderCredentialState::from_child_env_snapshot(0, std::collections::HashMap::new()); + let provider_readiness = ProviderReadinessTracker::new(); + // This fixture models a successfully installed initial launch snapshot. + if let LoadedPolicyOrigin::Gateway { + revision: Some(revision), + .. + } = &loaded_policy_origin + { + provider_readiness.credentials_installed( + EnvironmentIdentity { + revision: 0, + attachment_epoch: String::new(), + policy_hash: revision.policy_hash.clone(), + }, + &provider_credentials, + None, + ); + } + PolicyPollLoopContext { + endpoint: String::new(), + sandbox_id: "sandbox-test".to_string(), + sandbox: "sandbox-test-name".to_string(), + opa_engine, + loaded_policy_origin, + vm_identity: None, + entrypoint_pid: Arc::new(AtomicU32::new(0)), + interval_secs: 0, + ocsf_enabled: Arc::new(AtomicBool::new(false)), + ocsf_schema_version: Arc::new(std::sync::Mutex::new(String::new())), + provider_credentials, + provider_readiness, + policy_local_ctx: None, + agent_proposals: AgentProposals::default(), + middleware_registry_status: MiddlewareRegistryStatus::Synchronized, + workspace_tx, + extension_credentials: openshell_extension_core::ExtensionCredentialStore::new(), + extension_authentication_enabled: false, + middleware_connector, + config_apply_rx: None, + config_push_enabled: None, + endpoint_observation_tx: None, + endpoint_status_rx: None, + endpoint_policy: None, + supervisor_session_id: tokio::sync::watch::channel(None).1, + } + } + + async fn expect_policy_report( + reports: &mut tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, + version: u32, + ) { + let report = timeout(Duration::from_secs(1), reports.recv()) + .await + .expect("policy report timed out") + .expect("policy reporter stopped"); + assert_eq!(report, (version, true, String::new())); + } + + async fn expect_no_policy_report( + reports: &mut tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, + ) { + assert!( + timeout(Duration::from_millis(50), reports.recv()) + .await + .is_err(), + "unexpected policy status report" + ); + } + + #[tokio::test] + async fn same_hash_poll_revision_is_acknowledged_once_without_opa_reload() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, polls, mut reports) = scripted_policy_gateway(); + let observed_client = client.clone(); + polls.send(v1).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + assert_eq!( + observed_client.polled_sandboxes.lock().await.as_slice(), + &["sandbox-test-name"], + "settings polling must use the canonical sandbox reference, not its ID" + ); + + polls.send(v2.clone()).unwrap(); + expect_policy_report(&mut reports, 2).await; + polls.send(v2).unwrap(); + expect_no_policy_report(&mut reports).await; + + assert_eq!( + engine.current_generation(), + 0, + "same-hash acknowledgement must not reload OPA" + ); + handle.abort(); + } + + #[tokio::test] + async fn same_hash_ack_waits_for_failed_middleware_reconciliation_and_retries_once() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + v2.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "scripted-guard".to_string(), + grpc_endpoint: "http://scripted.invalid".to_string(), + ..Default::default() + }]; + + let connector_attempts = Arc::new(AtomicUsize::new(0)); + let (attempt_tx, mut attempt_rx) = tokio::sync::mpsc::unbounded_channel(); + let middleware_connector: MiddlewareConnector = { + let connector_attempts = connector_attempts.clone(); + Arc::new(move |_services, _authentication| { + let attempt = connector_attempts.fetch_add(1, Ordering::SeqCst) + 1; + attempt_tx.send(attempt).unwrap(); + Box::pin(async move { + if attempt == 1 { + Err(miette::miette!("scripted middleware connection failure")) + } else { + connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await + } + }) + }) + }; + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + middleware_connector, + ); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(v1).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + + polls.send(v2.clone()).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), attempt_rx.recv()) + .await + .unwrap(), + Some(1) + ); + expect_no_policy_report(&mut reports).await; + assert_eq!(engine.current_generation(), 0); + + polls.send(v2.clone()).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), attempt_rx.recv()) + .await + .unwrap(), + Some(2) + ); + expect_policy_report(&mut reports, 2).await; + assert_eq!(engine.current_generation(), 1); + + polls.send(v2).unwrap(); + expect_no_policy_report(&mut reports).await; + assert_eq!(connector_attempts.load(Ordering::SeqCst), 2); + handle.abort(); + } + + #[tokio::test] + async fn no_signer_capability_uses_legacy_middleware_connector_without_credentials() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + v2.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "legacy-guard".to_string(), + grpc_endpoint: "http://legacy.invalid".to_string(), + ..Default::default() + }]; + assert!(!v2.extension_authentication_enabled); + + let (inner, polls, mut reports) = scripted_policy_gateway(); + let credential_requests = Arc::new(AtomicUsize::new(0)); + let client = CredentialRejectingPolicyGateway { + inner, + credential_requests: credential_requests.clone(), + }; + let (connector_tx, mut connector_rx) = tokio::sync::mpsc::unbounded_channel(); + let connector: MiddlewareConnector = Arc::new(move |_services, authentication| { + connector_tx + .send((authentication.credentials.len(), authentication.enabled)) + .unwrap(); + Box::pin(async move { + connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await + }) + }); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + connector, + ); + + polls.send(v1).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), connector_rx.recv()) + .await + .unwrap(), + Some((0, false)) + ); + expect_policy_report(&mut reports, 2).await; + assert_eq!(credential_requests.load(Ordering::SeqCst), 0); + handle.abort(); + } + + #[tokio::test] + async fn enabled_extension_authentication_keeps_credential_failure_fail_closed() { + let mut v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + v1.policy_hash = "same-policy".to_string(); + let mut v2 = v1.clone(); + v2.version = 2; + v2.config_revision = 200; + v2.extension_authentication_enabled = true; + v2.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "authenticated-guard".to_string(), + grpc_endpoint: "https://guard.invalid".to_string(), + ..Default::default() + }]; + + let (inner, polls, mut reports) = scripted_policy_gateway(); + let credential_requests = Arc::new(AtomicUsize::new(0)); + let client = CredentialRejectingPolicyGateway { + inner, + credential_requests: credential_requests.clone(), + }; + let (connector_tx, mut connector_rx) = tokio::sync::mpsc::unbounded_channel(); + let connector: MiddlewareConnector = Arc::new(move |_services, authentication| { + connector_tx + .send((authentication.credentials.len(), authentication.enabled)) + .unwrap(); + Box::pin(async move { + if authentication.enabled && authentication.credentials.is_empty() { + Err(miette::miette!( + "missing authenticated middleware credential" + )) + } else { + connect_middleware_registry(&[], &authentication).await + } + }) + }); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + connector, + ); + + polls.send(v1).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), connector_rx.recv()) + .await + .unwrap(), + Some((0, true)) + ); + expect_no_policy_report(&mut reports).await; + assert_eq!(credential_requests.load(Ordering::SeqCst), 1); + handle.abort(); + } + + async fn assert_poll_does_not_use_same_hash_acknowledgement( + initial: openshell_core::grpc_client::SettingsPollResult, + next: openshell_core::grpc_client::SettingsPollResult, + origin: LoadedPolicyOrigin, + initial_report: Option, + ) { + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context(engine.clone(), origin, default_middleware_connector()); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(initial).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + + if let Some(version) = initial_report { + expect_policy_report(&mut reports, version).await; + } else { + expect_no_policy_report(&mut reports).await; + } + + polls.send(next).unwrap(); + expect_no_policy_report(&mut reports).await; + assert_eq!( + engine.current_generation(), + 0, + "negative same-hash scope must not reload OPA" + ); + handle.abort(); + } + + #[tokio::test] + async fn same_hash_ack_poll_loop_rejects_local_global_empty_equal_and_older_scopes() { + let mut sandbox_v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + sandbox_v1.policy_hash = "same-policy".to_string(); + let loaded_v1 = LoadedPolicyRevision::from_snapshot(&sandbox_v1); + let mut sandbox_v2 = sandbox_v1.clone(); + sandbox_v2.version = 2; + sandbox_v2.config_revision = 200; + + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v1.clone(), + sandbox_v2.clone(), + LoadedPolicyOrigin::LocalOverride, + None, + ) + .await; + + let mut global_v2 = sandbox_v2.clone(); + global_v2.policy_source = openshell_core::proto::PolicySource::Global; + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v1.clone(), + global_v2, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_v1.clone()), + has_last_valid_policy: true, + }, + Some(1), + ) + .await; + + let mut empty_v1 = sandbox_v1.clone(); + empty_v1.policy_hash.clear(); + let empty_loaded = LoadedPolicyRevision::from_snapshot(&empty_v1); + let mut empty_v2 = sandbox_v2.clone(); + empty_v2.policy_hash.clear(); + assert_poll_does_not_use_same_hash_acknowledgement( + empty_v1, + empty_v2, + LoadedPolicyOrigin::Gateway { + revision: Some(empty_loaded), + has_last_valid_policy: true, + }, + Some(1), + ) + .await; + + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v1.clone(), + sandbox_v1.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_v1.clone()), + has_last_valid_policy: true, + }, + Some(1), + ) + .await; + + let loaded_v2 = LoadedPolicyRevision::from_snapshot(&sandbox_v2); + assert_poll_does_not_use_same_hash_acknowledgement( + sandbox_v2, + sandbox_v1, + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_v2), + has_last_valid_policy: true, + }, + Some(2), + ) + .await; + } + + #[tokio::test] + async fn changed_hash_poll_uses_normal_opa_reload_and_status_path() { + let v1 = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let v2 = settings_poll_result( + Some(proto_policy_fixture()), + 2, + openshell_core::proto::PolicySource::Sandbox, + ); + let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine.clone(), + LoadedPolicyOrigin::Gateway { + revision: Some(loaded_revision), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(v1).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + + expect_policy_report(&mut reports, 1).await; + polls.send(v2).unwrap(); + expect_policy_report(&mut reports, 2).await; + assert_eq!( + engine.current_generation(), + 1, + "changed policy content must still reload OPA" + ); + handle.abort(); + } + + /// A runtime whose startup loaded `initial`, as recorded once the first + /// poll acknowledges it. + fn started_runtime( + ctx: &PolicyPollLoopContext, + initial: &openshell_core::grpc_client::SettingsPollResult, + ) -> ConfigRuntime { + let mut runtime = ConfigRuntime::new(ctx); + runtime.config_revision = initial.config_revision; + if runtime.reloads_gateway_policy { + runtime.applied_revision = Some(sandbox_config_revision(initial)); + } + runtime.policy_version = initial.version; + runtime.policy_hash.clone_from(&initial.policy_hash); + runtime + .middleware_services + .clone_from(&initial.supervisor_middleware_services); + runtime.extension_authentication_enabled = initial.extension_authentication_enabled; + runtime.settings.clone_from(&initial.settings); + ctx.provider_readiness.credentials_installed( + EnvironmentIdentity::from_settings(initial), + &ctx.provider_credentials, + None, + ); + runtime + } + + /// Apply `snapshot` with the provider environment it names, as one update + /// carries them. + async fn apply_with_provider( + runtime: &mut ConfigRuntime, + ctx: &PolicyPollLoopContext, + client: &ScriptedPolicyGateway, + snapshot: &openshell_core::grpc_client::SettingsPollResult, + ) -> DeliveredResult { + runtime + .apply_delivered_sandbox( + ctx, + client, + snapshot.clone(), + provider_snapshot_for(snapshot, "REGION", "west"), + ) + .await + } + + #[tokio::test] + async fn duplicate_stream_snapshot_preserves_local_policy_override() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let engine = Arc::new( + OpaEngine::from_proto(&proto_policy_fixture()).expect("build local OPA engine"), + ); + let ctx = policy_poll_test_context( + Arc::clone(&engine), + LoadedPolicyOrigin::LocalOverride, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let initial_generation = engine.current_generation(); + let candidate = + settings_poll_result(Some(proto_tcp_policy_fixture()), 2, PolicySource::Sandbox); + let mut runtime = started_runtime(&ctx, &candidate); + // The first delivery is new to the runtime; the second is a duplicate. + runtime.config_revision = 0; + + for _ in 0..2 { + let result = apply_with_provider(&mut runtime, &ctx, &client, &candidate).await; + + assert_eq!(engine.current_generation(), initial_generation); + assert_eq!(result.outcome, ConfigApplyOutcome::RetainedLocalOverride); + assert!(runtime.applied_revision.is_none()); + } + } + + #[tokio::test] + async fn failed_stream_snapshot_remains_retryable() { + let initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let mut desired = settings_poll_result( + Some(proto_policy_fixture()), + 2, + openshell_core::proto::PolicySource::Sandbox, + ); + desired.policy_validation_failure_mode = PolicyValidationFailureMode::RetainLastValid; + desired.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "unavailable-guard".into(), + grpc_endpoint: "http://127.0.0.1:1".into(), + max_payload_bytes: 1024, + ..Default::default() + }]; + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + install_builtin_middleware_registry(&engine) + .await + .expect("install built-in middleware registry"); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let initial_revision = sandbox_config_revision(&initial); + let mut runtime = started_runtime(&ctx, &initial); + + let result = apply_with_provider(&mut runtime, &ctx, &client, &desired).await; + + assert_eq!(runtime.config_revision, initial.config_revision); + assert_eq!(runtime.policy_version, initial.version); + assert_eq!(runtime.policy_hash, initial.policy_hash); + assert_eq!(runtime.applied_revision, Some(initial_revision)); + assert_eq!( + result.outcome, + ConfigApplyOutcome::FailedRetainedLastKnownGood + ); + assert_eq!( + result.admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Rejected) + ); + // The environment was not installed, so a redelivery retries it. + assert!( + ctx.provider_readiness + .needs_environment(&EnvironmentIdentity::from_settings(&desired)) + ); + } + + fn provider_snapshot_for( + settings: &openshell_core::grpc_client::SettingsPollResult, + name: &str, + value: &str, + ) -> openshell_core::proto::ProviderEnvironmentSnapshot { + openshell_core::proto::ProviderEnvironmentSnapshot { + provider_env_revision: settings.provider_env_revision, + provider_attachment_epoch: settings.provider_attachment_epoch.clone(), + policy_hash: settings.policy_hash.clone(), + values: vec![openshell_core::proto::ProviderEnvironmentValue { + name: name.to_string(), + value: value.to_string(), + classification: + openshell_core::proto::ProviderEnvironmentValueClassification::NonSecret.into(), + ..Default::default() + }], + ..Default::default() + } + } + + #[tokio::test] + async fn rejected_stream_snapshot_preserves_admitted_settings() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let mut initial = + settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + initial + .settings + .insert("ocsf_json_enabled".to_string(), effective_bool(true)); + initial.settings.insert( + openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + effective_bool(true), + ); + let mut rejected = initial.clone(); + rejected.version = 2; + rejected.config_revision = 200; + rejected.settings_revision = 2; + rejected.configuration_admitted = false; + rejected.configuration_error = "invalid policy".to_string(); + rejected.policy_validation_failure_mode = PolicyValidationFailureMode::RetainLastValid; + rejected + .settings + .insert("ocsf_json_enabled".to_string(), effective_bool(false)); + rejected.settings.insert( + openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + effective_bool(false), + ); + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + ctx.ocsf_enabled.store(true, Ordering::Relaxed); + ctx.agent_proposals.set_enabled(true); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + let initial_revision = sandbox_config_revision(&initial); + let result = apply_with_provider(&mut runtime, &ctx, &client, &rejected).await; + + assert_eq!( + result.outcome, + ConfigApplyOutcome::FailedRetainedLastKnownGood + ); + assert_eq!(runtime.applied_revision, Some(initial_revision)); + assert!(ctx.ocsf_enabled.load(Ordering::Relaxed)); + assert!(ctx.agent_proposals.enabled()); + assert_eq!(runtime.settings, initial.settings); + } + + /// One update carrying `snapshot` and the provider environment it names. + fn config_update( + update_id: &str, + snapshot: &openshell_core::grpc_client::SettingsPollResult, + ) -> openshell_core::proto::ConfigUpdate { + openshell_core::proto::ConfigUpdate { + update_id: update_id.to_string(), + sandbox_config: Some(openshell_core::proto::SandboxConfigSnapshot { + version: snapshot.version, + policy_hash: snapshot.policy_hash.clone(), + config_revision: snapshot.config_revision, + policy_source: snapshot.policy_source.into(), + policy: snapshot.policy.clone(), + provider_env_revision: snapshot.provider_env_revision, + provider_attachment_epoch: snapshot.provider_attachment_epoch.clone(), + supervisor_middleware_services: snapshot.supervisor_middleware_services.clone(), + configuration_admitted: snapshot.configuration_admitted, + ..Default::default() + }), + provider_environment: Some(provider_snapshot_for(snapshot, "REGION", "west")), + } + } + + async fn deliver_update( + config_apply_tx: &tokio::sync::mpsc::Sender, + update: openshell_core::proto::ConfigUpdate, + ) -> ConfigUpdateResult { + let (response, receiver) = tokio::sync::oneshot::channel(); + config_apply_tx + .send(ConfigApplyRequest { update, response }) + .await + .unwrap(); + timeout(Duration::from_secs(5), receiver) + .await + .expect("stream update timed out") + .expect("stream update responder stopped") + } + + /// A poll loop for a runtime whose startup loaded `initial`, with a + /// session that pushes configuration from the start. + fn push_session_poll_loop( + initial: &openshell_core::grpc_client::SettingsPollResult, + ) -> ( + PolicyPollLoopContext, + tokio::sync::mpsc::Sender, + tokio::sync::watch::Sender, + ) { + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let mut ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (config_apply_tx, config_apply_rx) = tokio::sync::mpsc::channel(1); + ctx.config_apply_rx = Some(config_apply_rx); + let (push_tx, push_rx) = tokio::sync::watch::channel(true); + ctx.config_push_enabled = Some(push_rx); + (ctx, config_apply_tx, push_tx) + } + + #[tokio::test] + async fn push_session_stops_polling_after_startup() { + let initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let mut desired = initial.clone(); + desired.provider_env_revision = 2; + let (ctx, config_apply_tx, _push_tx) = push_session_poll_loop(&initial); + let (client, polls, _reports) = scripted_policy_gateway(); + let poll_calls = Arc::clone(&client.poll_calls); + polls.send(initial).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + deliver_update(&config_apply_tx, config_update("bootstrap", &desired)).await; + tokio::time::sleep(Duration::from_millis(50)).await; + + // Only the startup poll reached the gateway. + assert_eq!(poll_calls.load(Ordering::SeqCst), 1); + handle.abort(); + } + + #[tokio::test] + async fn pushed_policy_status_is_reported_only_in_results() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let desired = settings_poll_result(Some(proto_policy_fixture()), 2, PolicySource::Sandbox); + let (ctx, config_apply_tx, _push_tx) = push_session_poll_loop(&initial); + let (client, polls, mut reports) = scripted_policy_gateway(); + polls.send(initial).unwrap(); + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + // The startup poll acknowledges the policy startup loaded. + let startup_report = timeout(Duration::from_secs(5), reports.recv()) + .await + .expect("startup policy report") + .expect("report channel open"); + assert_eq!((startup_report.0, startup_report.1), (1, true)); + + let result = deliver_update(&config_apply_tx, config_update("update-2", &desired)).await; + assert_eq!(result.update_id, "update-2"); + assert_eq!(result.outcome, i32::from(ConfigApplyOutcome::Applied)); + assert_eq!( + result.policy_load, + Some(PolicyLoadResult { + version: 2, + loaded: true, + error: String::new(), + }) + ); + + // The result carries the loaded policy version; the polling status + // RPC must not report it a second time. + assert!( + timeout(Duration::from_millis(200), reports.recv()) + .await + .is_err() + ); + handle.abort(); + } + + #[tokio::test] + async fn settings_only_update_reports_no_policy_load() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let mut desired = initial.clone(); + desired.config_revision = 101; + desired.settings_revision = 2; + desired + .settings + .insert("ocsf_json_enabled".to_string(), effective_bool(true)); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + + let result = apply_with_provider(&mut runtime, &ctx, &client, &desired).await; + + assert_eq!(result.outcome, ConfigApplyOutcome::Applied); + assert_eq!(result.policy_load, None); + assert!(ctx.ocsf_enabled.load(Ordering::Relaxed)); + } + + #[tokio::test] + async fn rejected_reconnect_bootstrap_quarantines_and_recovers_without_polling() { + use openshell_core::proto::{ + ConfigApplyOutcome, ConfigurationAdmissionState, PolicySource, + }; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let (mut ctx, config_apply_tx, _push_tx) = push_session_poll_loop(&initial); + let (session_tx, session_rx) = tokio::sync::watch::channel(Some("session-1".to_string())); + ctx.supervisor_session_id = session_rx; + let engine = Arc::clone(&ctx.opa_engine); + let (client, polls, mut reports) = scripted_policy_gateway(); + let poll_calls = Arc::clone(&client.poll_calls); + polls.send(initial.clone()).unwrap(); + let task = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + expect_policy_report(&mut reports, 1).await; + + session_tx.send_replace(None); + let mut rejected = initial.clone(); + rejected.policy = None; + rejected.configuration_admitted = false; + rejected.configuration_error = "Stored policy structure or safety validation failed".into(); + rejected.policy_validation_failure_mode = PolicyValidationFailureMode::FailClosed; + session_tx.send_replace(Some("session-2".to_string())); + let result = deliver_update( + &config_apply_tx, + config_update("rejected-bootstrap", &rejected), + ) + .await; + assert_eq!(result.outcome, i32::from(ConfigApplyOutcome::FailedClosed)); + assert_eq!( + result.admission.unwrap().state, + i32::from(ConfigurationAdmissionState::Rejected) + ); + assert!(engine.fail_closed_reason().is_some()); + + let mut repaired = initial; + repaired.version = 3; + repaired.config_revision = 300; + let result = deliver_update(&config_apply_tx, config_update("repair", &repaired)).await; + assert_eq!(result.outcome, i32::from(ConfigApplyOutcome::Applied)); + assert!(engine.fail_closed_reason().is_none()); + assert_eq!(poll_calls.load(Ordering::SeqCst), 1); + task.abort(); + } + + #[tokio::test] + async fn runtime_polls_again_when_the_session_stops_pushing() { + let initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let (ctx, _config_apply_tx, push_tx) = push_session_poll_loop(&initial); + let (client, polls, _reports) = scripted_policy_gateway(); + let poll_calls = Arc::clone(&client.poll_calls); + polls.send(initial).unwrap(); + + let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(poll_calls.load(Ordering::SeqCst), 1); + + // A reconnect lands on a gateway that does not push configuration. + push_tx.send_replace(false); + timeout(Duration::from_secs(5), async { + while poll_calls.load(Ordering::SeqCst) < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("a session without push must resume polling"); + handle.abort(); + } + + #[tokio::test] + async fn stream_provider_rotation_rebinds_policy_generation() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let mut desired = initial.clone(); + desired.config_revision = 200; + desired.provider_env_revision = 19; + desired.provider_attachment_epoch = "epoch-19".to_string(); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let initial_generation = engine.current_generation(); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + + let applied = apply_with_provider(&mut runtime, &ctx, &client, &desired).await; + + assert_eq!(applied.outcome, ConfigApplyOutcome::Applied); + // As with polling, the credentials install with a fresh generation of + // the unchanged policy, and readiness binds to that generation. + assert!(ctx.opa_engine.current_generation() > initial_generation); + let observed = ctx + .provider_readiness + .observation(&ctx.provider_credentials); + assert!(observed.credentials_installed); + assert!(observed.policy_active); + assert_eq!(observed.provider_env_revision, 19); + assert_eq!(observed.config_revision, 200); + assert_eq!(observed.attachment_epoch, "epoch-19"); + assert_eq!(observed.policy_hash, desired.policy_hash); + } + + #[tokio::test] + async fn stream_update_installs_its_provider_environment_with_the_configuration() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let mut desired = + settings_poll_result(Some(proto_policy_fixture()), 2, PolicySource::Sandbox); + desired.provider_env_revision = 17; + desired.provider_attachment_epoch = "epoch-b".to_string(); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + + let applied = apply_with_provider(&mut runtime, &ctx, &client, &desired).await; + + assert_eq!(applied.outcome, ConfigApplyOutcome::Applied); + assert_eq!( + applied.admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Accepted) + ); + assert_eq!(runtime.config_revision, desired.config_revision); + assert_eq!(runtime.provider_env_revision, 17); + assert_eq!(ctx.provider_credentials.snapshot().revision, 17); + assert!( + ctx.provider_credentials + .snapshot() + .child_env + .contains_key("REGION") + ); + assert_eq!(client.poll_calls.load(Ordering::SeqCst), 0); + + // Redelivered unchanged, the update is a duplicate and the installed + // environment is not prepared again. + let mut ignored = provider_snapshot_for(&desired, "REGION", "east"); + ignored.values.clear(); + let duplicate = runtime + .apply_delivered_sandbox(&ctx, &client, desired.clone(), ignored) + .await; + assert_eq!(duplicate.outcome, ConfigApplyOutcome::IgnoredDuplicate); + assert!( + ctx.provider_credentials + .snapshot() + .child_env + .contains_key("REGION") + ); + } + + #[tokio::test] + async fn stream_update_rejects_a_provider_environment_it_does_not_name() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let mut desired = + settings_poll_result(Some(proto_policy_fixture()), 2, PolicySource::Sandbox); + desired.provider_env_revision = 17; + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + let mut other = provider_snapshot_for(&desired, "REGION", "west"); + other.provider_env_revision = 18; + + let result = runtime + .apply_delivered_sandbox(&ctx, &client, desired, other) + .await; + + assert_eq!( + result.outcome, + ConfigApplyOutcome::FailedRetainedLastKnownGood + ); + assert_eq!( + result.failure.as_ref().unwrap().code, + "provider_environment_mismatch" + ); + assert_eq!(runtime.config_revision, initial.config_revision); + assert_eq!(result.policy_load, None); + } + + #[tokio::test] + async fn oversized_stream_rejection_obeys_failure_posture_and_recovers() { + use openshell_core::proto::{ + ConfigApplyOutcome, ConfigurationAdmissionState, PolicySource, + ProviderEnvironmentSnapshot, + }; + + for mode in [ + PolicyValidationFailureMode::FailClosed, + PolicyValidationFailureMode::RetainLastValid, + ] { + for has_last_valid in [false, true] { + let initial = + settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let mut rejected = initial.clone(); + rejected.policy = None; + rejected.settings.clear(); + rejected.configuration_admitted = false; + rejected.configuration_error = + "configuration_too_large: encoded configuration exceeds 8388608 bytes".into(); + rejected.policy_validation_failure_mode = mode; + let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); + let ctx = policy_poll_test_context( + Arc::clone(&engine), + LoadedPolicyOrigin::Gateway { + revision: has_last_valid + .then(|| LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: has_last_valid, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = if has_last_valid { + started_runtime(&ctx, &initial) + } else { + ConfigRuntime::new(&ctx) + }; + let expected = + if has_last_valid && mode == PolicyValidationFailureMode::RetainLastValid { + ConfigApplyOutcome::FailedRetainedLastKnownGood + } else { + ConfigApplyOutcome::FailedClosed + }; + let result = runtime + .apply_delivered_sandbox( + &ctx, + &client, + rejected.clone(), + ProviderEnvironmentSnapshot::default(), + ) + .await; + assert_eq!(result.outcome, expected); + assert_eq!( + result.admission.state, + i32::from(ConfigurationAdmissionState::Rejected) + ); + assert_eq!(result.admission.config_revision, initial.config_revision); + assert_eq!( + engine.fail_closed_reason().is_some(), + expected == ConfigApplyOutcome::FailedClosed + ); + let generation = engine.current_generation(); + let duplicate = runtime + .apply_delivered_sandbox( + &ctx, + &client, + rejected, + ProviderEnvironmentSnapshot::default(), + ) + .await; + assert_eq!(duplicate.outcome, expected); + assert_eq!(engine.current_generation(), generation); + let restored = apply_with_provider(&mut runtime, &ctx, &client, &initial).await; + assert_eq!( + restored.admission.state, + i32::from(ConfigurationAdmissionState::Accepted) + ); + assert!(engine.fail_closed_reason().is_none()); + assert_eq!( + runtime.applied_revision, + Some(sandbox_config_revision(&initial)) + ); + } + } + } + + #[tokio::test] + async fn oversized_stream_rejection_preserves_local_policy_and_settings() { + use openshell_core::proto::{ + ConfigApplyOutcome, ConfigurationAdmissionState, PolicySource, + ProviderEnvironmentSnapshot, + }; + + let mut initial = + settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + initial + .settings + .insert("ocsf_json_enabled".into(), effective_bool(true)); + let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); + let ctx = policy_poll_test_context( + Arc::clone(&engine), + LoadedPolicyOrigin::LocalOverride, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + let generation = engine.current_generation(); + let mut rejected = initial.clone(); + rejected.policy = None; + rejected.settings.clear(); + rejected.configuration_admitted = false; + rejected.configuration_error = "configuration_too_large".into(); + let result = runtime + .apply_delivered_sandbox( + &ctx, + &client, + rejected, + ProviderEnvironmentSnapshot::default(), + ) + .await; + assert_eq!( + result.outcome, + ConfigApplyOutcome::FailedRetainedLastKnownGood + ); + assert_eq!( + result.admission.state, + i32::from(ConfigurationAdmissionState::Rejected) + ); + assert_eq!(engine.current_generation(), generation); + assert_eq!(runtime.settings, initial.settings); + } + + #[tokio::test] + async fn duplicate_rejected_stream_snapshot_does_not_advance_fail_closed_generation() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let mut rejected = + settings_poll_result(Some(proto_policy_fixture()), 2, PolicySource::Sandbox); + rejected.config_revision = 200; + rejected.settings_revision = 2; + rejected.configuration_admitted = false; + rejected.configuration_error = "invalid policy".to_string(); + rejected.policy_validation_failure_mode = PolicyValidationFailureMode::FailClosed; + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let initial_generation = engine.current_generation(); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: None, + has_last_valid_policy: false, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = ConfigRuntime::new(&ctx); + + let result = apply_with_provider(&mut runtime, &ctx, &client, &rejected).await; + + assert_eq!(result.outcome, ConfigApplyOutcome::FailedClosed); + let rejected_generation = ctx.opa_engine.current_generation(); + assert!(rejected_generation > initial_generation); + + let duplicate = apply_with_provider(&mut runtime, &ctx, &client, &rejected).await; + + assert_eq!( + (duplicate.outcome, duplicate.failure, duplicate.admission), + (result.outcome, result.failure, result.admission) + ); + assert_eq!(ctx.opa_engine.current_generation(), rejected_generation); + } + + #[tokio::test] + async fn stream_snapshot_restoring_rejected_policy_clears_quarantine() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let mut rejected = initial.clone(); + rejected.version = 2; + rejected.policy_hash = "hash-v2".to_string(); + rejected.config_revision = 200; + rejected.configuration_admitted = false; + rejected.configuration_error = "invalid policy".to_string(); + rejected.policy_validation_failure_mode = PolicyValidationFailureMode::FailClosed; + // A later generation restores byte-identical policy content. + let mut restored = initial.clone(); + restored.version = 3; + restored.config_revision = 300; + + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + Arc::clone(&engine), + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + + let result = apply_with_provider(&mut runtime, &ctx, &client, &rejected).await; + assert_eq!(result.outcome, ConfigApplyOutcome::FailedClosed); + assert!(engine.fail_closed_reason().is_some()); + let quarantined_generation = engine.current_generation(); + + let result = apply_with_provider(&mut runtime, &ctx, &client, &restored).await; + assert_eq!(result.outcome, ConfigApplyOutcome::Applied); + assert_eq!( + runtime.applied_revision, + Some(sandbox_config_revision(&restored)) + ); + assert_eq!( + result.admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Accepted) + ); + // Matching policy bytes must not leave the runtime deny-all. + assert!(engine.fail_closed_reason().is_none()); + assert!(engine.current_generation() > quarantined_generation); + assert_eq!(runtime.policy_version, restored.version); + } + + #[tokio::test] + async fn failed_stream_provider_snapshot_revokes_static_credentials() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let initial = settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let mut ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + for (revision, readiness_reason, classification, expected_code) in [ + ( + 2, + ProviderReadinessReason::UnsupportedSupervisor, + openshell_core::proto::ProviderEnvironmentValueClassification::NonSecret, + "provider_environment_unavailable", + ), + ( + 3, + ProviderReadinessReason::Unspecified, + openshell_core::proto::ProviderEnvironmentValueClassification::Unspecified, + "invalid_provider_environment", + ), + // The gateway could not build the environment. + ( + 4, + ProviderReadinessReason::CredentialInstallFailed, + openshell_core::proto::ProviderEnvironmentValueClassification::NonSecret, + "provider_environment_unavailable", + ), + ] { + ctx.provider_credentials = initial_provider_credentials( + static_provider_environment(1, Some("initial")), + &ctx.provider_readiness, + ); + assert!( + ctx.provider_credentials + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .is_some() + ); + let mut desired = initial.clone(); + desired.provider_env_revision = revision; + let mut snapshot = provider_snapshot_for(&desired, "REGION", "west"); + snapshot.readiness_reason = readiness_reason.into(); + snapshot.values[0].classification = classification.into(); + + let result = runtime + .apply_delivered_sandbox(&ctx, &client, desired, snapshot) + .await; + + assert_eq!( + result.outcome, + ConfigApplyOutcome::FailedRetainedLastKnownGood + ); + assert_eq!(result.failure.as_ref().unwrap().code, expected_code); + assert_eq!( + result.admission.state, + i32::from(openshell_core::proto::ConfigurationAdmissionState::Rejected) + ); + assert_eq!(runtime.config_revision, initial.config_revision); + assert!(ctx.provider_credentials.resolver().is_none()); + assert!( + !ctx.provider_credentials + .snapshot() + .child_env + .contains_key("EXTERNAL_TOKEN") + ); + assert!( + !ctx.provider_readiness + .observation(&ctx.provider_credentials) + .credentials_installed + ); + } + } + + #[tokio::test] + async fn stream_snapshot_drops_credentials_of_removed_middleware() { + use openshell_core::proto::{ConfigApplyOutcome, PolicySource}; + + let mut initial = + settings_poll_result(Some(proto_policy_fixture()), 1, PolicySource::Sandbox); + initial.extension_authentication_enabled = true; + initial.supervisor_middleware_services = + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "removed-guard".to_string(), + grpc_endpoint: "http://removed.invalid".to_string(), + ..Default::default() + }]; + let mut desired = + settings_poll_result(Some(proto_policy_fixture()), 2, PolicySource::Sandbox); + desired.extension_authentication_enabled = true; + let engine = + Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let now_ms = openshell_core::time::now_ms(); + ctx.extension_credentials + .install("removed-guard", "token", now_ms + 3_600_000, now_ms) + .expect("install extension credential"); + let (client, _polls, _reports) = scripted_policy_gateway(); + let mut runtime = started_runtime(&ctx, &initial); + + let result = apply_with_provider(&mut runtime, &ctx, &client, &desired).await; + + assert_eq!(result.outcome, ConfigApplyOutcome::Applied); + assert!(runtime.middleware_services.is_empty()); + assert!(ctx.extension_credentials.get("removed-guard").is_none()); + } +} diff --git a/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs new file mode 100644 index 0000000000..d78c4c04b9 --- /dev/null +++ b/crates/openshell-supervisor/src/legacy_startup_regression_tests.rs @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use backend_setup::{BackendServices, BackendSetup, BuiltBackend, LaunchIdentity, PreparedBackend}; +use openshell_core::proto::{ + GatewayMessage, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, ReportSandboxConfigurationRequest, + ReportSandboxConfigurationResponse, SessionAccepted, SupervisorMessage, UpdateConfigRequest, + UpdateConfigResponse, gateway_message, supervisor_message, +}; +use openshell_isolation_interface::contract::{BackendError, ResolvedWorkloadIdentity}; +use std::sync::{Mutex, atomic::Ordering}; +use tonic::codegen::tokio_stream::{StreamExt as _, wrappers::TcpListenerStream}; +use tonic::codegen::{Body, BoxFuture, BoxStream, Bytes, Service, StdError, http}; + +#[derive(Clone, Default)] +struct LegacyReadyProbe { + ready_event: Arc, + premature_ready: Arc, + discovered: Arc, +} + +#[derive(Clone)] +struct LegacyGateway { + probe: LegacyReadyProbe, +} + +impl tonic::server::NamedService for LegacyGateway { + const NAME: &'static str = "openshell.v1.OpenShell"; +} + +struct LegacyConnect(LegacyReadyProbe); + +impl tonic::server::StreamingService for LegacyConnect { + type Response = GatewayMessage; + type ResponseStream = BoxStream; + type Future = BoxFuture, tonic::Status>; + + fn call( + &mut self, + request: tonic::Request>, + ) -> Self::Future { + let probe = self.0.clone(); + Box::pin(async move { + let message = request + .into_inner() + .message() + .await? + .expect("supervisor hello"); + assert!(matches!( + message.payload, + Some(supervisor_message::Payload::Hello(_)) + )); + + // A pre-push gateway ignores workload_pending and records Ready + // on connection. The restart's existing admission is accepted. + probe.premature_ready.store(true, Ordering::SeqCst); + probe.ready_event.notify_one(); + let accepted = GatewayMessage { + payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { + session_id: "legacy-session".into(), + ..Default::default() + })), + }; + let stream = futures::stream::once(async move { Ok(accepted) }) + .chain(futures::stream::pending()); + let stream: Self::ResponseStream = Box::pin(stream); + Ok(tonic::Response::new(stream)) + }) + } +} + +struct FixedUnary(R); + +impl tonic::server::UnaryService for FixedUnary +where + Q: Send + 'static, + R: Clone + Send + 'static, +{ + type Response = R; + type Future = BoxFuture, tonic::Status>; + + fn call(&mut self, _request: tonic::Request) -> Self::Future { + let response = self.0.clone(); + Box::pin(async move { Ok(tonic::Response::new(response)) }) + } +} + +async fn legacy_unary( + request: http::Request, + response: R, +) -> http::Response +where + B: Body + Send + 'static, + B::Error: Into + Send, + Q: prost::Message + Default + Send + 'static, + R: prost::Message + Default + Clone + Send + 'static, +{ + tonic::server::Grpc::new(tonic_prost::ProstCodec::::default()) + .unary(FixedUnary(response), request) + .await +} + +impl Service> for LegacyGateway +where + B: Body + Send + 'static, + B::Error: Into + Send + 'static, +{ + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn call(&mut self, request: http::Request) -> Self::Future { + let probe = self.probe.clone(); + Box::pin(async move { + let response = match request.uri().path() { + "/openshell.v1.OpenShell/ConnectSupervisor" => { + tonic::server::Grpc::new(tonic_prost::ProstCodec::< + GatewayMessage, + SupervisorMessage, + >::default()) + .streaming(LegacyConnect(probe), request) + .await + } + "/openshell.v1.OpenShell/GetSandboxConfig" => { + let mut policy = openshell_policy::restrictive_default_policy(); + enrich_proto_baseline_paths(&mut policy); + legacy_unary::( + request, + GetSandboxConfigResponse { + policy: Some(policy), + version: 1, + policy_hash: "legacy-policy".into(), + config_revision: 7, + workspace: "default".into(), + configuration_admitted: true, + configuration_instance_id: "configuration-1".into(), + policy_source: openshell_core::proto::PolicySource::Sandbox.into(), + policy_validation_failure_mode: "fail_closed".into(), + ..Default::default() + }, + ) + .await + } + "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" => { + legacy_unary::( + request, + GetSandboxProviderEnvironmentResponse::default(), + ) + .await + } + "/openshell.v1.OpenShell/ReportSandboxConfiguration" => { + legacy_unary::( + request, + ReportSandboxConfigurationResponse::default(), + ) + .await + } + "/openshell.v1.OpenShell/ReportPolicyStatus" => { + legacy_unary::( + request, + ReportPolicyStatusResponse::default(), + ) + .await + } + "/openshell.v1.OpenShell/UpdateConfig" => { + legacy_unary::( + request, + UpdateConfigResponse { + version: 1, + policy_hash: "legacy-policy".into(), + ..Default::default() + }, + ) + .await + } + _ => http::Response::builder() + .status(200) + .header("grpc-status", "12") + .header("content-type", "application/grpc") + .body(tonic::body::Body::empty()) + .unwrap(), + }; + Ok(response) + }) + } +} + +struct HeldBackendSetup { + session_id: openshell_core::SandboxSessionId, + probe: LegacyReadyProbe, +} + +impl BackendSetup for HeldBackendSetup { + fn backend_name(&self) -> &'static str { + "test-held" + } + + fn decode( + &self, + _payload: &[u8], + ) -> Result<(LaunchIdentity, Box), BackendError> { + Ok(( + LaunchIdentity { + sandbox_id: "sb-legacy-restart".into(), + generation: "generation-1".into(), + session_id: self.session_id, + workload_identity: ResolvedWorkloadIdentity::new( + 1000, + 1000, + Vec::new(), + "test".into(), + "test-image".into(), + )?, + vm_policy_identity: None, + }, + Box::new(HeldPreparedBackend(self.probe.clone())), + )) + } +} + +struct HeldPreparedBackend(LegacyReadyProbe); + +#[tonic::async_trait] +impl PreparedBackend for HeldPreparedBackend { + async fn discover_policy( + &self, + _bearer: openshell_core::jwt::SessionBearerTokenSlot, + ) -> Result<(Option, bool), BackendError> { + self.0.discovered.store(true, Ordering::SeqCst); + Ok((None, false)) + } + + fn build(self: Box, _services: BackendServices) -> Result { + Err(BackendError::Attach("test boundary remains held".into())) + } +} + +#[tokio::test] +async fn config_push_regression_legacy_gateway_never_observes_ready_before_workload() { + 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(); + let server = tokio::spawn( + tonic::transport::Server::builder() + .add_service(LegacyGateway { + probe: probe.clone(), + }) + .serve_with_incoming(TcpListenerStream::new(listener)), + ); + let session_id = openshell_core::SandboxSessionId::new(); + let auth_bundle = openshell_core::jwt::SupervisorAuthBundle { + session_id, + runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( + "generation-1", + ) + .unwrap(), + session_rotation: openshell_core::jwt::SessionRotation::new(1).unwrap(), + auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).unwrap(), + gateway_token: openshell_core::jwt::SecretJwt::parse("test-gateway-token").unwrap(), + gateway_expires_at: 0, + sandbox_token: openshell_core::jwt::SecretJwt::parse("test-sandbox-token").unwrap(), + sandbox_expires_at: 0, + ssh_host_private_key: None, + ocsf_otlp_headers: None, + }; + let setup = HeldBackendSetup { + session_id, + probe: probe.clone(), + }; + let startup = run_sandbox_with_backend( + &setup, + SandboxRunConfig { + command: vec!["true".into()], + workdir: None, + timeout_secs: 0, + interactive: false, + await_main_process_attachment: false, + sandbox_id: Some("sb-legacy-restart".into()), + sandbox: Some("legacy-restart".into()), + openshell_endpoint: Some(endpoint), + policy_rules: None, + policy_data: None, + ssh_socket_path: None, + health_socket_path: None, + health_port: None, + ocsf_enabled: Arc::new(AtomicBool::new(false)), + ocsf_schema_version: Arc::new(Mutex::new(String::new())), + upstream_proxy_args: + openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs::default(), + additional_ca_bundle: None, + backend_descriptor: openshell_isolation_interface::contract::BackendDescriptor { + backend_name: "test-held".into(), + payload: Vec::new(), + }, + auth_bundle, + admitted_isolation_backend: Some("test-held".into()), + main_exit_marker: None, + }, + ); + tokio::pin!(startup); + let result = tokio::select! { + () = probe.ready_event.notified() => None, + result = &mut startup => Some(result), + () = tokio::time::sleep(Duration::from_secs(5)) => panic!("startup must reach the held boundary or the legacy readiness event"), + }; + server.abort(); + let _ = server.await; + assert!( + probe.discovered.load(Ordering::SeqCst), + "fixture reaches authenticated image discovery" + ); + assert!( + !probe.premature_ready.load(Ordering::SeqCst), + "a legacy gateway must not observe a Ready event while the workload is still unstarted" + ); + let error = result + .expect("startup reaches the deliberately held backend") + .unwrap_err(); + assert!(error.to_string().contains("test boundary remains held")); +} diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 7ad309fde4..9628d811a0 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -19,11 +19,13 @@ compile_error!( mod activity_aggregator; pub mod backend_setup; +mod config_runtime; mod denial_aggregator; mod endpoint_status; mod mechanistic_mapper; mod provider_readiness; +use config_runtime::{agent_proposals_enabled_from_settings, run_policy_poll_loop}; use miette::{IntoDiagnostic, Result, WrapErr}; use std::future::Future; use std::io::Write as _; @@ -380,7 +382,7 @@ use openshell_core::proto::ProviderReadinessReason; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_supervisor_network::opa::{OpaEngine, PolicyGenerationGuard}; use openshell_supervisor_network::proxy::ProxyHandle; -use openshell_supervisor_process::skills; +use openshell_supervisor_process::supervisor_session::ConfigApplyRequest; use provider_readiness::{EnvironmentIdentity, Tracker as ProviderReadinessTracker}; use tokio::sync::mpsc::UnboundedSender; use tokio::time::timeout; @@ -1073,6 +1075,11 @@ pub async fn run_sandbox_with_backend( // GetSandboxConfig and broadcasts it. Flush tasks and the policy.local // API read the current value so proposals target the correct workspace. let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); + // The supervisor session hands pushed configuration to the poll loop and + // tells it when to stop polling. + let (config_apply_tx, config_apply_rx) = tokio::sync::mpsc::channel(16); + let mut config_apply_rx = Some(config_apply_rx); + let (config_push_updates, config_push_enabled) = tokio::sync::watch::channel(false); let remote_network_source = proxy_listener .is_none() @@ -1267,6 +1274,8 @@ pub async fn run_sandbox_with_backend( extension_credentials: extension_credentials.clone(), extension_authentication_enabled: initial_extension_authentication_enabled, middleware_connector: default_middleware_connector(), + config_apply_rx: config_apply_rx.take(), + config_push_enabled: Some(config_push_enabled), endpoint_observation_tx, endpoint_status_rx, endpoint_policy: poll_endpoint_policy, @@ -1278,7 +1287,7 @@ pub async fn run_sandbox_with_backend( if workload_started.wait_for(|started| *started).await.is_err() { return; } - if let Err(e) = run_policy_poll_loop(poll_ctx).await { + if let Err(e) = Box::pin(run_policy_poll_loop(poll_ctx)).await { ocsf_emit!( AppLifecycleBuilder::new(ocsf_ctx()) .activity(ActivityId::Fail) @@ -1327,6 +1336,8 @@ pub async fn run_sandbox_with_backend( running.loopback_connector(), agent.clone(), Some(supervisor_session_updates), + config_apply_tx, + config_push_updates, ssh_host_key, ) .instrument(tracing::info_span!(parent: &startup, "supervisor.access.start")) @@ -2242,6 +2253,7 @@ impl VmPolicyIdentity { } } +#[derive(Clone)] struct CapturedProviderEnvironment { credentials: ProviderCredentialState, expires_at_ms: Option, @@ -2254,6 +2266,11 @@ impl CapturedProviderEnvironment { self.credentials } + /// Validate the environment's bindings without touching live credentials. + fn prepare(provider: &openshell_core::grpc_client::ProviderEnvironmentResult) -> Result { + prepare_provider_environment(provider).map(|credentials| Self::new(credentials, provider)) + } + fn new( credentials: ProviderCredentialState, provider: &openshell_core::grpc_client::ProviderEnvironmentResult, @@ -3029,6 +3046,14 @@ enum GatewayRuntimeFailureClass { MiddlewareRegistry, } +impl std::fmt::Display for GatewayRuntimeReloadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PolicyValidation(error) | Self::MiddlewareRegistry(error) => error.fmt(f), + } + } +} + impl GatewayRuntimeReloadError { fn class(&self) -> GatewayRuntimeFailureClass { match self { @@ -3748,6 +3773,11 @@ struct PolicyPollLoopContext { extension_credentials: openshell_extension_core::ExtensionCredentialStore, extension_authentication_enabled: bool, middleware_connector: MiddlewareConnector, + /// Payloads delivered over the supervisor session. + config_apply_rx: Option>, + /// Whether the current supervisor session pushes configuration. The + /// runtime polls while it is false. + config_push_enabled: Option>, /// Producer shared with network enforcement and policy installation. endpoint_observation_tx: Option, /// Single FIFO consumed by the endpoint status reporter. @@ -4100,2766 +4130,880 @@ fn emit_policy_validation_failure( } } -async fn report_runtime_configuration( - ctx: &PolicyPollLoopContext, - snapshot: &openshell_core::grpc_client::SettingsPollResult, - accepted: bool, - error: &str, -) -> bool { - let LoadedPolicyOrigin::Gateway { - revision: Some(revision), - .. - } = &ctx.loaded_policy_origin - else { - return true; - }; - let Some(instance_id) = revision.admission_instance_id.as_deref() else { - return true; - }; - let state = if accepted { - openshell_core::proto::ConfigurationAdmissionState::Accepted - } else { - openshell_core::proto::ConfigurationAdmissionState::Rejected - }; - if !accepted { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Other, "configuration_error") - .message(error) - .build() +#[cfg(test)] +#[allow( + clippy::needless_raw_string_hashes, + clippy::iter_on_single_items, + clippy::similar_names, + clippy::manual_string_new, + clippy::doc_markdown, + reason = "Test code: test fixtures often use idiomatic forms not flagged in production." +)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Admission must reject this input before any backend decodes its payload + /// or contacts a workload. Keep this fixture independent of setup helpers + /// so it can also reproduce the ordering defect on earlier revisions. + #[tokio::test] + async fn startup_rejects_missing_admission_before_decoding_payload() { + let auth_bundle = openshell_core::jwt::SupervisorAuthBundle { + session_id: openshell_core::SandboxSessionId::new(), + runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( + "generation-1", + ) + .expect("generation"), + session_rotation: openshell_core::jwt::SessionRotation::new(1).expect("rotation"), + auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).expect("epoch"), + gateway_token: openshell_core::jwt::SecretJwt::parse("test-gateway-token") + .expect("token"), + gateway_expires_at: 0, + sandbox_token: openshell_core::jwt::SecretJwt::parse("test-sandbox-token") + .expect("token"), + sandbox_expires_at: 0, + ssh_host_private_key: None, + ocsf_otlp_headers: None, + }; + let error = run_sandbox( + vec!["true".to_string()], + None, + 0, + false, + false, + Some("sandbox-1".to_string()), + None, + None, + None, + None, + None, + None, + None, + Arc::new(AtomicBool::new(false)), + Arc::new(std::sync::Mutex::new(String::new())), + openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs::default(), + None, + openshell_isolation_interface::contract::BackendDescriptor { + backend_name: "in-process-test".to_string(), + payload: b"in-process-only-launch-data".to_vec(), + }, + auth_bundle, + None, + None, + ) + .await + .expect_err("missing admission cannot authorize startup"); + + assert_eq!( + error.to_string(), + "runtime descriptor supplied without an admitted isolation backend" ); } - openshell_core::grpc_client::report_sandbox_configuration( - &ctx.endpoint, - &ctx.sandbox_id, - instance_id, - Some(snapshot), - state, - error, - ) - .await - .is_ok() -} - -async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { - let client = openshell_core::grpc_client::CachedOpenShellClient::connect_with_credentials( - &ctx.endpoint, - ctx.extension_credentials.clone(), - ) - .await?; - run_policy_poll_loop_with_client(ctx, client).await -} -async fn run_policy_poll_loop_with_client( - mut ctx: PolicyPollLoopContext, - client: C, -) -> Result<()> { - use openshell_core::proto::PolicySource; - use std::sync::atomic::Ordering; - - let (status_sender, status_receiver) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(run_policy_status_reporter( - client.clone(), - ctx.sandbox_id.clone(), - status_receiver, - )); - if let Some(endpoint_status_receiver) = ctx.endpoint_status_rx.take() { - tokio::spawn(endpoint_status::run_reporter( - client.clone(), - ctx.sandbox_id.clone(), - endpoint_status_receiver, - ctx.supervisor_session_id.clone(), - )); + #[test] + fn shared_ssh_socket_setting_is_explicit() { + assert!(shared_ssh_socket_value("1")); + assert!(shared_ssh_socket_value("true")); + assert!(shared_ssh_socket_value("TRUE")); + assert!(!shared_ssh_socket_value("0")); + assert!(!shared_ssh_socket_value("yes")); } - let mut current_config_revision: u64 = 0; - let mut current_provider_env_revision: u64 = ctx.provider_credentials.snapshot().revision; - let mut current_policy_version: u32 = 0; - let mut current_policy_hash = String::new(); - let mut current_policy_generation = None; - let mut current_endpoint_policy = ctx.endpoint_policy.clone(); - let mut current_middleware_services = Vec::new(); - let mut current_extension_authentication_enabled = ctx.extension_authentication_enabled; - let mut middleware_registry_status = ctx.middleware_registry_status; - let mut current_settings: std::collections::HashMap< - String, - openshell_core::proto::EffectiveSetting, - > = std::collections::HashMap::new(); - let reloads_gateway_policy = ctx.loaded_policy_origin.allows_gateway_policy_reload(); - let mut last_failed_runtime_revision: Option = None; - let mut rejected_policy_generation: Option = None; - let mut has_last_valid_policy = ctx.loaded_policy_origin.has_last_valid_policy(); - - // A first poll that does not match the policy already loaded into OPA must - // pass through the normal reconciliation path immediately. It must never - // seed the applied-state trackers before OPA actually loads it. - let mut pending_result = None; - // Bind startup evidence before awaiting the gateway. A generation changed - // during that request no longer proves which policy startup installed. - let initial_generation = ctx - .opa_engine - .generation_guard(ctx.opa_engine.current_generation()) - .ok(); - - // Initialize revision from the first poll and acknowledge the initial - // policy revision the supervisor actually loaded. A mismatched result is - // reconciled below instead of being recorded as already applied. - match client.poll_settings(&ctx.sandbox).await { - Ok(result) => { - let _ = ctx.workspace_tx.send(client.workspace()); - match ( - initial_poll_disposition(&ctx.loaded_policy_origin, &result), - initial_generation.as_ref(), - ) { - (InitialPollDisposition::Acknowledge(candidate), Some(generation)) - if middleware_registry_status == MiddlewareRegistryStatus::Synchronized - && !generation.is_stale() => - { - ctx.provider_readiness.policy_activated( - &EnvironmentIdentity::from_settings(&result), - result.config_revision, - generation.clone(), - ); - current_policy_generation = Some(generation.clone()); - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); - apply_agent_proposals_enabled( - &ctx.agent_proposals, - agent_proposals_enabled_from_settings(&result.settings), - "initial settings poll", - Some(candidate.config_revision), - skills::install_static_skills, - ); - current_config_revision = candidate.config_revision; - current_policy_version = candidate.version; - current_policy_hash.clone_from(&candidate.policy_hash); - current_endpoint_policy.clone_from(&result.policy); - endpoint_status::reset( - ctx.endpoint_observation_tx.as_ref(), - current_endpoint_policy.as_ref(), - ¤t_policy_hash, - ctx.provider_credentials.snapshot().revision, - ) - .await; - current_middleware_services = result.supervisor_middleware_services; - current_extension_authentication_enabled = - result.extension_authentication_enabled; - current_settings = result.settings; - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::initial_loaded(&candidate), - ); - debug!( - config_revision = current_config_revision, - "Settings poll: initial policy matches loaded revision" - ); - } - (InitialPollDisposition::Acknowledge(_) | InitialPollDisposition::Reconcile, _) => { - // Matching policy bytes cannot prove an unavailable registry - // or a replaced generation. Install this snapshot immediately. - pending_result = Some(result); - } - (InitialPollDisposition::TrackOnly, _) => { - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); - apply_agent_proposals_enabled( - &ctx.agent_proposals, - agent_proposals_enabled_from_settings(&result.settings), - "initial settings poll", - Some(result.config_revision), - skills::install_static_skills, - ); - current_config_revision = result.config_revision; - current_policy_hash = result.policy_hash.clone(); - current_middleware_services = result.supervisor_middleware_services; - current_extension_authentication_enabled = - result.extension_authentication_enabled; - current_settings = result.settings; - debug!( - config_revision = current_config_revision, - "Settings poll: tracking gateway config while preserving local policy override" - ); - } - } - } - Err(e) => { - warn!(error = %e, "Settings poll: failed to fetch initial version, will retry"); - } + #[cfg(unix)] + #[test] + fn network_proxy_tls_directory_is_private_and_not_symlinked() { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + + let automatic = prepare_network_proxy_tls_dir(None).expect("private default directory"); + let mode = std::fs::metadata(&automatic.path) + .expect("default directory metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0); + + let root = tempfile::tempdir().expect("temporary root"); + let target = root.path().join("target"); + std::fs::create_dir(&target).expect("target directory"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)) + .expect("private target permissions"); + let link = root.path().join("link"); + symlink(&target, &link).expect("TLS directory symlink"); + assert!(prepare_network_proxy_tls_dir(Some(link)).is_err()); + + let writable = root.path().join("writable"); + std::fs::create_dir(&writable).expect("writable directory"); + std::fs::set_permissions(&writable, std::fs::Permissions::from_mode(0o777)) + .expect("writable permissions"); + assert!(prepare_network_proxy_tls_dir(Some(writable)).is_err()); } - let interval = Duration::from_secs(ctx.interval_secs); - loop { - let result = if let Some(result) = pending_result.take() { - result - } else { - tokio::time::sleep(next_poll_delay(&ctx.extension_credentials, interval)).await; - match client.poll_settings(&ctx.sandbox).await { - Ok(result) => { - let _ = ctx.workspace_tx.send(client.workspace()); - result - } - Err(e) => { - debug!(error = %e, "Settings poll: server unreachable, will retry"); - if current_extension_authentication_enabled - && let Err(refresh_error) = - client.refresh_installed_extension_credentials().await - { - warn!( - error = %refresh_error, - "Settings poll: extension credential refresh failed while configuration was unavailable" - ); - } - continue; - } - } - }; + #[cfg(unix)] + #[tokio::test] + async fn control_readiness_exists_only_while_guard_is_live() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("health.sock"); + let readiness = ControlReadiness::start(ReadinessEndpoint::Unix(path.clone()), None) + .expect("start readiness listener"); + check_control_readiness(&path).expect("running supervisor accepts readiness probes"); - // Reuse installed per-service credentials, rotating only when one is - // missing or due. Rotation happens on the existing gateway channel and - // updates slots in place, so it is independent of config revision and - // registry equality. - let middleware_credentials = if result.extension_authentication_enabled { - match client - .extension_credentials_for(&result.supervisor_middleware_services) - .await - { - Ok(credentials) => credentials, - Err(error) => { - warn!(error = %error, "Settings poll: extension credential refresh failed"); - std::collections::HashMap::new() - } - } - } else { - std::collections::HashMap::new() - }; + drop(readiness); + tokio::task::yield_now().await; + assert!(check_control_readiness(&path).is_err()); + } - if reloads_gateway_policy && !result.configuration_admitted { - let disposition = apply_policy_validation_failure( - &ctx.opa_engine, - result.policy_validation_failure_mode, - has_last_valid_policy, - result.version, - &result.configuration_error, - )?; - emit_policy_validation_failure( - &disposition, - result.version, - &result.policy_hash, - &result.configuration_error, - ); - rejected_policy_generation = Some(RejectedPolicyGeneration { - version: result.version, - policy_hash: result.policy_hash.clone(), - validation_error: result.configuration_error.clone(), - configured_mode: result.policy_validation_failure_mode, - }); - report_runtime_configuration(&ctx, &result, false, &result.configuration_error).await; - continue; - } + #[cfg(unix)] + #[tokio::test] + async fn control_readiness_tracks_supervisor_session() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("health.sock"); + let (session_tx, session_rx) = tokio::sync::watch::channel(false); + let _readiness = + ControlReadiness::start(ReadinessEndpoint::Unix(path.clone()), Some(session_rx)) + .expect("start readiness listener"); + assert!(check_control_readiness(&path).is_err()); - let config_changed = result.config_revision != current_config_revision; - let desired_identity = EnvironmentIdentity::from_settings(&result); - let provider_env_changed = result.provider_env_revision != current_provider_env_revision - || ctx.provider_readiness.needs_environment(&desired_identity); - let policy_changed = result.policy_hash != current_policy_hash; - let extension_authentication_changed = - current_extension_authentication_enabled != result.extension_authentication_enabled; - let middleware_registry_changed = extension_authentication_changed - || middleware_registry_needs_rebuild( - middleware_registry_status, - ¤t_middleware_services, - &result.supervisor_middleware_services, - ); - // A valid candidate may intentionally restore byte-for-byte policy - // content that was active before a rejected update. Its hash then - // equals `current_policy_hash`, but the runtime is still quarantined - // and must reload (or it would remain deny-all indefinitely). - let recovering_rejected_policy = reloads_gateway_policy - && rejected_policy_generation.is_some() - && result.configuration_admitted; - let policy_runtime_changed = (reloads_gateway_policy - && (provider_env_changed - || current_policy_generation - .as_ref() - .is_some_and(PolicyGenerationGuard::is_stale))) - || recovering_rejected_policy - || extension_authentication_changed - || gateway_policy_runtime_needs_reconciliation( - reloads_gateway_policy, - ¤t_policy_hash, - &result.policy_hash, - ¤t_middleware_services, - &result.supervisor_middleware_services, - middleware_registry_status, - ); - // Recovery already has its own acknowledgement path below. Giving it - // precedence here prevents a restored last-known-good policy from - // also being acknowledged as an ordinary same-hash revision. - let unchanged_policy_revision = unchanged_policy_revision_candidate( - reloads_gateway_policy, - recovering_rejected_policy, - current_policy_version, - ¤t_policy_hash, - &result, - ); - let mut policy_runtime_reconciled = false; - - // A local policy override is not coupled to the gateway policy - // snapshot, so its service registry can still be reconciled alone. - // Gateway policy snapshots, however, must install policy and registry - // as one generation below. - if !reloads_gateway_policy { - reconcile_middleware_registry( - &ctx.opa_engine, - &ctx.middleware_connector, - MiddlewareRegistryReconciliation { - desired_services: &result.supervisor_middleware_services, - authentication: MiddlewareAuthentication { - credentials: middleware_credentials.clone(), - enabled: result.extension_authentication_enabled, - }, - registry_changed: middleware_registry_changed, - extension_credentials: &ctx.extension_credentials, - current_services: &mut current_middleware_services, - status: &mut middleware_registry_status, - }, - ) - .await; - if middleware_registry_status == MiddlewareRegistryStatus::Synchronized { - current_extension_authentication_enabled = result.extension_authentication_enabled; + session_tx.send_replace(true); + timeout(Duration::from_secs(1), async { + while check_control_readiness(&path).is_err() { + tokio::task::yield_now().await; } - } - - if !config_changed - && !provider_env_changed - && !policy_runtime_changed - && unchanged_policy_revision.is_none() - { - continue; - } + }) + .await + .expect("first accepted session creates readiness socket"); - if config_changed || provider_env_changed { - // Log which settings changed. - log_setting_changes(¤t_settings, &result.settings); - - // A posture change after a rejected update takes effect immediately. - // The compiled last-known-good engine remains available beneath a - // fail-closed quarantine, so an explicit retain_last_valid selection - // can reactivate it without accepting any part of the invalid policy. - if !policy_changed && let Some(rejected) = rejected_policy_generation.as_mut() { - let mode = result.policy_validation_failure_mode; - if mode != rejected.configured_mode { - let disposition = apply_policy_validation_failure( - &ctx.opa_engine, - mode, - has_last_valid_policy, - rejected.version, - &rejected.validation_error, - )?; - emit_policy_validation_failure( - &disposition, - rejected.version, - &rejected.policy_hash, - &rejected.validation_error, - ); - rejected.configured_mode = mode; - } + session_tx.send_replace(false); + timeout(Duration::from_secs(1), async { + while check_control_readiness(&path).is_ok() { + tokio::task::yield_now().await; } + }) + .await + .expect("lost session removes readiness socket"); - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Other, "detected") - .unmapped("old_config_revision", serde_json::json!(current_config_revision)) - .unmapped("new_config_revision", serde_json::json!(result.config_revision)) - .unmapped("policy_changed", serde_json::json!(policy_changed)) - .unmapped("provider_env_changed", serde_json::json!(provider_env_changed)) - .message(format!( - "Settings poll: config change detected [old_revision:{current_config_revision} new_revision:{} policy_changed:{policy_changed} provider_env_changed:{provider_env_changed}]", - result.config_revision - )) - .build()); - } + session_tx.send_replace(true); + timeout(Duration::from_secs(1), async { + while check_control_readiness(&path).is_err() { + tokio::task::yield_now().await; + } + }) + .await + .expect("replacement session restores readiness socket"); + } - // Prepare the matching environment before activation. Failed refreshes - // revoke static credentials while preserving independently bound dynamic grants. - let mut prepared_identity = None; - let prepared_provider = if provider_env_changed { - ctx.provider_readiness.credentials_failed( - desired_identity.clone(), - ProviderReadinessReason::WaitingForCredentials, + #[test] + fn tcp_readiness_listener_accepts_ipv4_regardless_of_bindv6only() { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .and_then(|reserved| reserved.local_addr()) + .expect("reserve loopback port") + .port(); + let listener = bind_readiness_tcp(port).expect("bind readiness listener"); + if listener.local_addr().expect("local address").is_ipv6() { + assert!( + !socket2::SockRef::from(&listener) + .only_v6() + .expect("read IPV6_V6ONLY"), + "net.ipv6.bindv6only=1 must not make the wildcard listener IPv6-only" ); - let provider = match client - .fetch_provider_environment( - &ctx.endpoint, - &ctx.sandbox_id, - ctx.provider_credentials.requires_stable_placeholders(), - ) - .await - { - Ok(provider) - if EnvironmentIdentity::from_environment(&provider) == desired_identity - && provider_environment_is_installable(provider.readiness_reason) => - { - provider - } - failed => { - let reason = match failed { - Ok(provider) - if provider.readiness_reason - != ProviderReadinessReason::Unspecified => - { - provider.readiness_reason - } - Ok(_) => ProviderReadinessReason::SnapshotMismatch, - Err(_) => ProviderReadinessReason::CredentialInstallFailed, - }; - ctx.provider_readiness - .credentials_failed(desired_identity.clone(), reason); - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "fail_closed") - .message("Provider environment refresh failed; static credentials were revoked and previous dynamic grants remain active") - .build()); - ctx.provider_credentials - .revoke_static_provider_environment(result.provider_env_revision); - endpoint_status::reset( - ctx.endpoint_observation_tx.as_ref(), - current_endpoint_policy.as_ref(), - ¤t_policy_hash, - result.provider_env_revision, - ) - .await; - report_runtime_configuration( - &ctx, - &result, - false, - "Provider environment is unavailable or changed during preparation", - ) - .await; - continue; - } - }; - if let Ok(prepared) = prepare_provider_environment(&provider) { - prepared_identity = Some(( - EnvironmentIdentity::from_environment(&provider), - provider - .credential_expires_at_ms - .values() - .copied() - .filter(|expiry| *expiry > 0) - .min(), - )); - Some(prepared) - } else { - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "fail_closed") - .message("Provider environment bindings failed validation; static credentials were revoked and fetched dynamic grants remain active") - .build()); - ctx.provider_readiness.credentials_failed( - desired_identity.clone(), - ProviderReadinessReason::CredentialInstallFailed, - ); - // Repeat the rejected binding validation on the live state to - // revoke static material and retain the fetched dynamic grants. - let _ = ctx - .provider_credentials - .install_bound_environment_with_stable_placeholders( - provider.provider_env_revision, - provider.environment, - provider.credential_expires_at_ms, - provider.dynamic_credentials, - provider.static_credential_bindings, - provider.non_secret_environment_keys, - provider.stable_placeholder_environment_keys, - ); - endpoint_status::reset( - ctx.endpoint_observation_tx.as_ref(), - current_endpoint_policy.as_ref(), - ¤t_policy_hash, - result.provider_env_revision, - ) - .await; - report_runtime_configuration( - &ctx, - &result, - false, - "Provider environment bindings failed validation", - ) - .await; - continue; - } - } else { - None - }; + } + std::net::TcpStream::connect(("127.0.0.1", port)) + .expect("IPv4 kubelet probe reaches the readiness listener"); + } - if policy_runtime_changed { - let pid = ctx.entrypoint_pid.load(Ordering::Acquire); - let runtime_result = reload_gateway_configuration_runtime( - &ctx.opa_engine, - result.policy.as_ref(), - pid, - MiddlewareReloadContext { - desired_services: &result.supervisor_middleware_services, - authentication: &MiddlewareAuthentication { - credentials: middleware_credentials.clone(), - enabled: result.extension_authentication_enabled, - }, - registry_changed: middleware_registry_changed, - connector: &ctx.middleware_connector, - }, - ctx.vm_identity, - || { - if let Some(prepared) = prepared_provider.as_ref() { - ctx.provider_credentials.install_prepared(prepared); - } - }, + #[tokio::test] + async fn tcp_control_readiness_tracks_supervisor_session() { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .and_then(|reserved| reserved.local_addr()) + .expect("reserve loopback port") + .port(); + let (session_tx, session_rx) = tokio::sync::watch::channel(true); + let readiness = ControlReadiness::start(ReadinessEndpoint::Tcp(port), Some(session_rx)) + .expect("start TCP readiness listener"); + let connects = || async { + timeout( + Duration::from_millis(100), + tokio::net::TcpStream::connect(("127.0.0.1", port)), ) - .await; - - match runtime_result { - Ok(generation) => { - policy_runtime_reconciled = true; - if let Some((identity, expires_at_ms)) = prepared_identity.as_ref() { - ctx.provider_readiness.credentials_installed( - identity.clone(), - &ctx.provider_credentials, - *expires_at_ms, - ); - } - ctx.provider_readiness.policy_activated( - &desired_identity, - result.config_revision, - generation.clone(), - ); - current_policy_generation = Some(generation); - let policy = result - .policy - .as_ref() - .expect("successful runtime reload requires a policy payload"); - has_last_valid_policy = true; - rejected_policy_generation = None; - if policy_changed { - if let Some(policy_local_ctx) = ctx.policy_local_ctx.as_ref() { - policy_local_ctx.set_current_policy(policy.clone()).await; - } - if result.global_policy_version > 0 { - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) - .unmapped("global_version", serde_json::json!(result.global_policy_version)) - .message(format!( - "Policy reloaded successfully (global) [policy_hash:{} global_version:{}]", - result.policy_hash, - result.global_policy_version - )) - .build()); - } else { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) - .message(format!( - "Policy reloaded successfully [policy_hash:{}]", - result.policy_hash - )) - .build() - ); - } - if result.version > 0 && result.policy_source == PolicySource::Sandbox { - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::loaded(result.version), - ); - current_policy_version = result.version; - } - } else if recovering_rejected_policy - && result.version > 0 - && result.policy_source == PolicySource::Sandbox - { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) - .message(format!( - "Policy reloaded successfully and fail-closed quarantine cleared [policy_hash:{}]", - result.policy_hash - )) - .build() - ); - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::loaded(result.version), - ); - current_policy_version = result.version; - } - - if middleware_registry_changed { - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped( - "supervisor_middleware_service_count", - serde_json::json!(result.supervisor_middleware_services.len()) - ) - .message(format!( - "Supervisor policy runtime reloaded atomically [service_count:{}]", - result.supervisor_middleware_services.len() - )) - .build()); - } + .await + .is_ok_and(|result| result.is_ok()) + }; + assert!(connects().await, "accepted session is ready"); - current_policy_hash.clone_from(&result.policy_hash); - current_endpoint_policy.clone_from(&result.policy); - current_middleware_services.clone_from(&result.supervisor_middleware_services); - current_extension_authentication_enabled = - result.extension_authentication_enabled; - retain_extension_credentials( - &ctx.extension_credentials, - &result.supervisor_middleware_services, - result.extension_authentication_enabled, - ); - middleware_registry_status = MiddlewareRegistryStatus::Synchronized; - last_failed_runtime_revision = None; - } - Err(failure) => { - ctx.provider_readiness - .policy_install_failed(desired_identity.clone(), result.config_revision); - let failed_revision = FailedRuntimeRevision::new( - result.config_revision, - &result.policy_hash, - &failure, - ); - if last_failed_runtime_revision.as_ref() != Some(&failed_revision) { - let failure_mode = result.policy_validation_failure_mode; - match apply_gateway_runtime_reload_failure( - &ctx.opa_engine, - failure, - failure_mode, - has_last_valid_policy, - result.version, - )? { - GatewayRuntimeFailureDisposition::PolicyRejected { - error, - disposition, - } => { - emit_policy_validation_failure( - &disposition, - result.version, - &result.policy_hash, - &error, - ); - rejected_policy_generation = Some(RejectedPolicyGeneration { - version: result.version, - policy_hash: result.policy_hash.clone(), - validation_error: error.clone(), - configured_mode: failure_mode, - }); - if policy_changed - && result.version > 0 - && result.policy_source == PolicySource::Sandbox - { - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::failed(result.version, error), - ); - } - } - GatewayRuntimeFailureDisposition::MiddlewareUnavailable { error } => { - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .state(StateId::Other, "failed") - .unmapped("version", serde_json::json!(result.version)) - .unmapped("error", serde_json::json!(&error)) - .unmapped("previous_policy_active", serde_json::json!(true)) - .message(format!( - "Supervisor middleware registry unavailable, keeping last-known-good policy runtime active [version:{} error:{error}]", - result.version - )) - .build()); - } - } - } - last_failed_runtime_revision = Some(failed_revision); - // Nothing was installed, so the registry status still - // describes the live registry. The retry is driven by the - // persisting hash/service-set mismatch (or an existing - // NeedsReconciliation), not by degrading the status here. - } + session_tx.send_replace(false); + timeout(Duration::from_secs(1), async { + while connects().await { + tokio::task::yield_now().await; } - } + }) + .await + .expect("lost session closes readiness listener"); - if policy_runtime_changed && !policy_runtime_reconciled { - report_runtime_configuration(&ctx, &result, false, "Effective configuration failed runtime preparation; prior credentials remain installed").await; - continue; - } - if !reloads_gateway_policy && let Some(prepared) = prepared_provider.as_ref() { - ctx.provider_credentials.install_prepared(prepared); - if let Some((identity, expires_at_ms)) = prepared_identity.as_ref() { - ctx.provider_readiness.credentials_installed( - identity.clone(), - &ctx.provider_credentials, - *expires_at_ms, - ); + session_tx.send_replace(true); + timeout(Duration::from_secs(1), async { + while !connects().await { + tokio::task::yield_now().await; } - } - if provider_env_changed || policy_runtime_reconciled { - current_provider_env_revision = result.provider_env_revision; - } - - if let Some(version) = unchanged_policy_revision_ready_to_ack( - unchanged_policy_revision, - policy_runtime_changed, - policy_runtime_reconciled, - ) { - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::unchanged_loaded(version, result.policy_hash.clone()), - ); - current_policy_version = version; - } + }) + .await + .expect("replacement session reopens readiness listener"); - if reloads_gateway_policy - && !policy_runtime_changed - && let Some(generation) = current_policy_generation - .as_ref() - .filter(|generation| !generation.is_stale()) - { - // The same installed policy may serve a new attachment/configuration - // identity. Its generation is retained, never inferred from a cursor. - ctx.provider_readiness.policy_activated( - &desired_identity, - result.config_revision, - generation.clone(), - ); - } + drop(readiness); + timeout(Duration::from_secs(1), async { + while connects().await { + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped guard closes readiness listener"); + } - if policy_runtime_reconciled || provider_env_changed { - endpoint_status::reset( - ctx.endpoint_observation_tx.as_ref(), - current_endpoint_policy.as_ref(), - ¤t_policy_hash, - ctx.provider_credentials.snapshot().revision, + #[tokio::test] + async fn tcp_readiness_waits_for_initial_session_acceptance() { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let (tx, rx) = tokio::sync::watch::channel(false); + let readiness = ControlReadiness::start(ReadinessEndpoint::Tcp(port), Some(rx)).unwrap(); + assert!( + !timeout( + Duration::from_millis(100), + tokio::net::TcpStream::connect(("127.0.0.1", port)) ) - .await; - } - - // Apply OCSF JSON toggle from the `ocsf_json_enabled` setting. - apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); - apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings); - - // Apply the agent-proposals feature toggle. On a false→true transition - // we lazily install the skill so a sandbox that started with the flag - // off picks up the surface without a recreate. We never uninstall on - // a true→false transition: stale skill content on disk is harmless - // because route_request and agent_next_steps both gate on the live - // shared flag, so the agent that reads the skill will see 404s and an - // empty `next_steps` array regardless. - apply_agent_proposals_enabled( - &ctx.agent_proposals, - agent_proposals_enabled_from_settings(&result.settings), - "settings poll", - Some(result.config_revision), - skills::install_static_skills, + .await + .is_ok_and(|result| result.is_ok()) ); - - if !report_runtime_configuration(&ctx, &result, true, "").await { - // Retry the exact status tuple on the next poll before advancing - // the observed revision; an old instance cannot claim readiness. - continue; - } - current_config_revision = result.config_revision; - if !reloads_gateway_policy { - current_policy_hash = result.policy_hash; - } - current_settings = result.settings; + tx.send_replace(true); + timeout(Duration::from_secs(2), async { + while !timeout( + Duration::from_millis(100), + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await + .is_ok_and(|result| result.is_ok()) + { + tokio::task::yield_now().await; + } + }) + .await + .expect("accepted session opens TCP readiness"); + drop(readiness); } -} -fn apply_ocsf_json_setting( - enabled: &AtomicBool, - settings: &std::collections::HashMap, -) { - use std::sync::atomic::Ordering; + #[cfg(not(unix))] + #[tokio::test] + async fn unix_readiness_is_rejected_without_disabling_tcp() { + let result = ControlReadiness::start(ReadinessEndpoint::Unix("health.sock".into()), None); + assert!( + result + .err() + .expect("Unix sockets unsupported") + .to_string() + .contains("unsupported") + ); + let (_tx, rx) = tokio::sync::watch::channel(false); + assert!( + ControlReadiness::start(ReadinessEndpoint::Unix("health.sock".into()), Some(rx)) + .is_err(), + "invalid adapter must fail even before session acceptance" + ); + } - let new_ocsf = extract_bool_setting(settings, "ocsf_json_enabled").unwrap_or(false); - let prev_ocsf = enabled.swap(new_ocsf, Ordering::Relaxed); - if new_ocsf != prev_ocsf { - info!(ocsf_json_enabled = new_ocsf, "OCSF JSONL logging toggled"); + #[test] + #[cfg(unix)] + fn control_readiness_rejects_relative_path() { + let error = prepare_control_readiness_path(std::path::Path::new("health.sock")) + .expect_err("relative readiness path must be rejected"); + assert!(error.to_string().contains("must be absolute")); } -} -/// Extract a bool value from an effective setting, if present. -fn extract_bool_setting( - settings: &std::collections::HashMap, - key: &str, -) -> Option { - use openshell_core::proto::setting_value; - settings - .get(key) - .and_then(|es| es.value.as_ref()) - .and_then(|sv| sv.value.as_ref()) - .and_then(|v| match v { - setting_value::Value::BoolValue(b) => Some(*b), - _ => None, - }) -} + #[test] + fn main_exit_marker_atomically_replaces_previous_value() { + let directory = tempfile::tempdir().unwrap(); + let marker = directory.path().join("main-exited"); + std::fs::write(&marker, b"stale\n").unwrap(); -fn apply_ocsf_schema_version_setting( - version: &std::sync::Mutex, - settings: &std::collections::HashMap, -) { - let new_version = extract_string_setting(settings, "ocsf_schema_version").unwrap_or_default(); - if let Ok(mut current) = version.lock() - && *current != new_version - { - info!( - ocsf_schema_version = %new_version, - "OCSF schema version target changed" + persist_main_exit_marker(&marker, 23).unwrap(); + + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "exit_code=23\n"); + assert!( + !directory + .path() + .join(format!(".main-exited.tmp-{}", std::process::id())) + .exists() ); - *current = new_version; } -} - -/// Extract a string value from an effective setting, if present. -fn extract_string_setting( - settings: &std::collections::HashMap, - key: &str, -) -> Option { - use openshell_core::proto::setting_value; - settings - .get(key) - .and_then(|es| es.value.as_ref()) - .and_then(|sv| sv.value.as_ref()) - .and_then(|v| match v { - setting_value::Value::StringValue(value) => Some(value.clone()), - _ => None, - }) -} -fn agent_proposals_enabled_from_settings( - settings: &std::collections::HashMap, -) -> bool { - extract_bool_setting( - settings, - openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY, - ) - .unwrap_or(false) -} + #[tokio::test] + async fn remote_access_plane_outlives_main_completion_until_teardown() { + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let retained = retain_remote_access_plane(std::future::pending(), async { + let _ = shutdown_rx.await; + }); + tokio::pin!(retained); -fn apply_agent_proposals_enabled( - agent_proposals: &AgentProposals, - enabled: bool, - source: &'static str, - config_revision: Option, - install_static_skills: impl FnOnce() -> Result, -) { - let previously_enabled = agent_proposals.swap_enabled(enabled); - if enabled == previously_enabled { - return; + assert!( + timeout(Duration::from_millis(10), &mut retained) + .await + .is_err(), + "access plane must remain live after canonical process completion" + ); + shutdown_tx.send(()).expect("request teardown"); + timeout(Duration::from_secs(1), &mut retained) + .await + .expect("teardown should release retained access plane") + .expect("clean teardown"); } - info!( - agent_policy_proposals_enabled = enabled, - source, config_revision, "agent-driven policy proposals toggled" - ); + #[tokio::test] + async fn completion_retry_phase_is_cancelled_by_shutdown() { + let mut shutdown = Box::pin(std::future::ready(())); + assert!( + completion_phase_or_shutdown(std::future::pending(), shutdown.as_mut()).await, + "shutdown must cancel an indefinitely retrying completion phase" + ); + } - if enabled && !previously_enabled { - match install_static_skills() { - Ok(installed) => info!( - path = %installed.policy_advisor.display(), - "Installed sandbox agent skill on toggle-on" - ), - Err(error) => warn!( - error = %error, - "Failed to install sandbox agent skill on toggle-on" - ), - } + #[test] + fn discover_policy_from_nonexistent_path_is_missing() { + let path = std::path::Path::new("/nonexistent/policy.yaml"); + assert!(matches!( + discover_image_policy_from_path(path), + ImagePolicyDiscovery::Missing + )); } -} -/// Log individual setting changes between two snapshots. -fn log_setting_changes( - old: &std::collections::HashMap, - new: &std::collections::HashMap, -) { - for (key, new_es) in new { - let new_val = format_setting_value(new_es); - match old.get(key) { - Some(old_es) => { - let old_val = format_setting_value(old_es); - if old_val != new_val { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "updated") - .unmapped("key", serde_json::json!(key)) - .unmapped("old", serde_json::json!(old_val.clone())) - .unmapped("new", serde_json::json!(new_val.clone())) - .message(format!( - "Setting changed [key:{key} old:{old_val} new:{new_val}]" - )) - .build() - ); - } - } - None => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "enabled") - .unmapped("key", serde_json::json!(key)) - .unmapped("value", serde_json::json!(new_val.clone())) - .message(format!("Setting added [key:{key} value:{new_val}]")) - .build() - ); - } - } - } - for key in old.keys() { - if !new.contains_key(key) { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Disabled, "disabled") - .unmapped("key", serde_json::json!(key)) - .message(format!("Setting removed [key:{key}]")) - .build() - ); - } - } -} + #[test] + fn discover_policy_from_valid_yaml_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.yaml"); + std::fs::write( + &path, + r#" +version: 1 +filesystem_policy: + include_workdir: false + read_only: + - /usr + read_write: + - /tmp +network_policies: + test: + name: test + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: /usr/bin/curl } +"#, + ) + .unwrap(); -/// Format an `EffectiveSetting` value for log display. -fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String { - use openshell_core::proto::setting_value; - match es.value.as_ref().and_then(|sv| sv.value.as_ref()) { - None => "".to_string(), - Some(setting_value::Value::StringValue(v)) => v.clone(), - Some(setting_value::Value::BoolValue(v)) => v.to_string(), - Some(setting_value::Value::IntValue(v)) => v.to_string(), - Some(setting_value::Value::BytesValue(_)) => "".to_string(), + let ImagePolicyDiscovery::Policy(policy) = discover_image_policy_from_path(&path) else { + panic!("expected parsed policy") + }; + assert_eq!(policy.network_policies.len(), 1); + assert!(policy.network_policies.contains_key("test")); + let fs = policy.filesystem.unwrap(); + assert!(!fs.include_workdir); } -} - -#[cfg(test)] -#[allow( - clippy::needless_raw_string_hashes, - clippy::iter_on_single_items, - clippy::similar_names, - clippy::manual_string_new, - clippy::doc_markdown, - reason = "Test code: test fixtures often use idiomatic forms not flagged in production." -)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - /// Admission must reject this input before any backend decodes its payload - /// or contacts a workload. Keep this fixture independent of setup helpers - /// so it can also reproduce the ordering defect on earlier revisions. - #[tokio::test] - async fn startup_rejects_missing_admission_before_decoding_payload() { - let auth_bundle = openshell_core::jwt::SupervisorAuthBundle { - session_id: openshell_core::SandboxSessionId::new(), - runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( - "generation-1", - ) - .expect("generation"), - session_rotation: openshell_core::jwt::SessionRotation::new(1).expect("rotation"), - auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).expect("epoch"), - gateway_token: openshell_core::jwt::SecretJwt::parse("test-gateway-token") - .expect("token"), - gateway_expires_at: 0, - sandbox_token: openshell_core::jwt::SecretJwt::parse("test-sandbox-token") - .expect("token"), - sandbox_expires_at: 0, - ssh_host_private_key: None, - ocsf_otlp_headers: None, - }; - let error = run_sandbox( - vec!["true".to_string()], - None, - 0, - false, - false, - Some("sandbox-1".to_string()), - None, - None, - None, - None, - None, - None, - None, - Arc::new(AtomicBool::new(false)), - Arc::new(std::sync::Mutex::new(String::new())), - openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs::default(), - None, - openshell_isolation_interface::contract::BackendDescriptor { - backend_name: "in-process-test".to_string(), - payload: b"in-process-only-launch-data".to_vec(), - }, - auth_bundle, - None, - None, - ) - .await - .expect_err("missing admission cannot authorize startup"); + #[test] + fn discover_policy_from_invalid_yaml_remains_invalid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.yaml"); + std::fs::write(&path, "this is not valid yaml: [[[").unwrap(); - assert_eq!( - error.to_string(), - "runtime descriptor supplied without an admitted isolation backend" - ); + assert!(matches!( + discover_image_policy_from_path(&path), + ImagePolicyDiscovery::Invalid + )); } - fn effective_bool(value: bool) -> openshell_core::proto::EffectiveSetting { - openshell_core::proto::EffectiveSetting { - value: Some(openshell_core::proto::SettingValue { - value: Some(openshell_core::proto::setting_value::Value::BoolValue( - value, - )), - }), - scope: openshell_core::proto::SettingScope::Global.into(), - } + #[test] + fn discover_policy_from_oversized_file_remains_invalid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.yaml"); + let oversized = format!("version: 1\n{}", " ".repeat(4 * 1024 * 1024)); + std::fs::write(&path, oversized).unwrap(); + assert!(matches!( + discover_image_policy_from_path(&path), + ImagePolicyDiscovery::Invalid + )); } #[test] - fn shared_ssh_socket_setting_is_explicit() { - assert!(shared_ssh_socket_value("1")); - assert!(shared_ssh_socket_value("true")); - assert!(shared_ssh_socket_value("TRUE")); - assert!(!shared_ssh_socket_value("0")); - assert!(!shared_ssh_socket_value("yes")); + fn discover_policy_from_unsafe_yaml_preserves_candidate_for_admission() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.yaml"); + std::fs::write( + &path, + r#" +version: 1 +process: + run_as_user: root + run_as_group: root +filesystem_policy: + include_workdir: true + read_only: + - /usr + read_write: + - /tmp +"#, + ) + .unwrap(); + + let ImagePolicyDiscovery::Policy(policy) = discover_image_policy_from_path(&path) else { + panic!("expected parsed policy") + }; + assert!(openshell_policy::validate_sandbox_policy(&policy).is_err()); } - #[cfg(unix)] #[test] - fn network_proxy_tls_directory_is_private_and_not_symlinked() { - use std::os::unix::fs::{PermissionsExt as _, symlink}; - - let automatic = prepare_network_proxy_tls_dir(None).expect("private default directory"); - let mode = std::fs::metadata(&automatic.path) - .expect("default directory metadata") - .permissions() - .mode(); - assert_eq!(mode & 0o077, 0); + fn discover_policy_restrictive_default_blocks_network() { + // In cluster mode we keep proxy mode enabled so all egress passes + // through proxy/OPA controls. + let proto = openshell_policy::restrictive_default_policy(); + let local_policy = SandboxPolicy::try_from(proto).expect("conversion should succeed"); + assert!(matches!(local_policy.network.mode, NetworkMode::Proxy)); + } - let root = tempfile::tempdir().expect("temporary root"); - let target = root.path().join("target"); - std::fs::create_dir(&target).expect("target directory"); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)) - .expect("private target permissions"); - let link = root.path().join("link"); - symlink(&target, &link).expect("TLS directory symlink"); - assert!(prepare_network_proxy_tls_dir(Some(link)).is_err()); + // ---- Initial policy acknowledgement tests ---- - let writable = root.path().join("writable"); - std::fs::create_dir(&writable).expect("writable directory"); - std::fs::set_permissions(&writable, std::fs::Permissions::from_mode(0o777)) - .expect("writable permissions"); - assert!(prepare_network_proxy_tls_dir(Some(writable)).is_err()); + pub fn proto_policy_fixture() -> openshell_core::proto::SandboxPolicy { + openshell_policy::restrictive_default_policy() } - #[cfg(unix)] - #[tokio::test] - async fn control_readiness_exists_only_while_guard_is_live() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("health.sock"); - let readiness = ControlReadiness::start(ReadinessEndpoint::Unix(path.clone()), None) - .expect("start readiness listener"); - check_control_readiness(&path).expect("running supervisor accepts readiness probes"); + pub fn proto_tcp_policy_fixture() -> openshell_core::proto::SandboxPolicy { + openshell_policy::parse_sandbox_policy( + r#" +version: 1 +network_policies: + redis: + name: redis + endpoints: + - host: redis.example.com + port: 6379 + protocol: tcp + binaries: + - path: /usr/bin/redis-cli +"#, + ) + .expect("parse TCP policy") + } - drop(readiness); - tokio::task::yield_now().await; - assert!(check_control_readiness(&path).is_err()); + pub fn settings_poll_result( + policy: Option, + version: u32, + source: openshell_core::proto::PolicySource, + ) -> openshell_core::grpc_client::SettingsPollResult { + openshell_core::grpc_client::SettingsPollResult { + policy, + version, + policy_hash: format!("hash-v{version}"), + config_revision: u64::from(version) * 100, + policy_source: source, + settings: std::collections::HashMap::new(), + global_policy_version: 0, + provider_env_revision: 0, + provider_attachment_epoch: String::new(), + supervisor_middleware_services: Vec::new(), + workspace: String::new(), + policy_validation_failure_mode: PolicyValidationFailureMode::default(), + extension_authentication_enabled: false, + configuration_admitted: true, + configuration_error: String::new(), + configuration_instance_id: String::new(), + settings_revision: 0, + } } - #[cfg(unix)] - #[tokio::test] - async fn control_readiness_tracks_supervisor_session() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("health.sock"); - let (session_tx, session_rx) = tokio::sync::watch::channel(false); - let _readiness = - ControlReadiness::start(ReadinessEndpoint::Unix(path.clone()), Some(session_rx)) - .expect("start readiness listener"); - assert!(check_control_readiness(&path).is_err()); - - session_tx.send_replace(true); - timeout(Duration::from_secs(1), async { - while check_control_readiness(&path).is_err() { - tokio::task::yield_now().await; - } - }) - .await - .expect("first accepted session creates readiness socket"); - - session_tx.send_replace(false); - timeout(Duration::from_secs(1), async { - while check_control_readiness(&path).is_ok() { - tokio::task::yield_now().await; - } - }) - .await - .expect("lost session removes readiness socket"); - - session_tx.send_replace(true); - timeout(Duration::from_secs(1), async { - while check_control_readiness(&path).is_err() { - tokio::task::yield_now().await; - } - }) - .await - .expect("replacement session restores readiness socket"); - } - - #[test] - fn tcp_readiness_listener_accepts_ipv4_regardless_of_bindv6only() { - let port = std::net::TcpListener::bind("127.0.0.1:0") - .and_then(|reserved| reserved.local_addr()) - .expect("reserve loopback port") - .port(); - let listener = bind_readiness_tcp(port).expect("bind readiness listener"); - if listener.local_addr().expect("local address").is_ipv6() { - assert!( - !socket2::SockRef::from(&listener) - .only_v6() - .expect("read IPV6_V6ONLY"), - "net.ipv6.bindv6only=1 must not make the wildcard listener IPv6-only" - ); - } - std::net::TcpStream::connect(("127.0.0.1", port)) - .expect("IPv4 kubelet probe reaches the readiness listener"); + #[derive(Clone)] + struct TestStartupGateway { + desired: Arc>, + reports: UnboundedSender, + reject_next_accept: Arc, + snapshot_error: Option, + report_error: Option, + pending_snapshot: bool, + pending_acceptance: bool, } - #[tokio::test] - async fn tcp_control_readiness_tracks_supervisor_session() { - let port = std::net::TcpListener::bind("127.0.0.1:0") - .and_then(|reserved| reserved.local_addr()) - .expect("reserve loopback port") - .port(); - let (session_tx, session_rx) = tokio::sync::watch::channel(true); - let readiness = ControlReadiness::start(ReadinessEndpoint::Tcp(port), Some(session_rx)) - .expect("start TCP readiness listener"); - let connects = || async { - timeout( - Duration::from_millis(100), - tokio::net::TcpStream::connect(("127.0.0.1", port)), - ) - .await - .is_ok_and(|result| result.is_ok()) - }; - assert!(connects().await, "accepted session is ready"); - - session_tx.send_replace(false); - timeout(Duration::from_secs(1), async { - while connects().await { - tokio::task::yield_now().await; + impl StartupGateway for TestStartupGateway { + async fn snapshot( + &self, + _id: &str, + ) -> Result { + if self.pending_snapshot { + return std::future::pending().await; } - }) - .await - .expect("lost session closes readiness listener"); - - session_tx.send_replace(true); - timeout(Duration::from_secs(1), async { - while !connects().await { - tokio::task::yield_now().await; + if let Some(code) = self.snapshot_error { + return Err(openshell_core::grpc_client::grpc_status_error( + tonic::Status::new(code, "snapshot unavailable"), + )); } - }) - .await - .expect("replacement session reopens readiness listener"); - - drop(readiness); - timeout(Duration::from_secs(1), async { - while connects().await { - tokio::task::yield_now().await; + Ok(self.desired.lock().unwrap().clone()) + } + async fn provider( + &self, + _id: &str, + ) -> Result { + Ok(startup_provider( + self.desired.lock().unwrap().provider_env_revision, + )) + } + async fn sync( + &self, + _sandbox: &str, + _policy: &openshell_core::proto::SandboxPolicy, + _workspace: &str, + ) -> Result { + self.snapshot("").await + } + async fn report( + &self, + _id: &str, + _instance_id: &str, + snapshot: Option<&openshell_core::grpc_client::SettingsPollResult>, + state: openshell_core::proto::ConfigurationAdmissionState, + _error: &str, + ) -> Result<()> { + use openshell_core::proto::ConfigurationAdmissionState; + if let Some(code) = self.report_error { + return Err(openshell_core::grpc_client::grpc_status_error( + tonic::Status::new(code, "registration fence changed"), + )); } - }) - .await - .expect("dropped guard closes readiness listener"); - } - - #[tokio::test] - async fn tcp_readiness_waits_for_initial_session_acceptance() { - let port = std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port(); - let (tx, rx) = tokio::sync::watch::channel(false); - let readiness = ControlReadiness::start(ReadinessEndpoint::Tcp(port), Some(rx)).unwrap(); - assert!( - !timeout( - Duration::from_millis(100), - tokio::net::TcpStream::connect(("127.0.0.1", port)) - ) - .await - .is_ok_and(|result| result.is_ok()) - ); - tx.send_replace(true); - timeout(Duration::from_secs(2), async { - while !timeout( - Duration::from_millis(100), - tokio::net::TcpStream::connect(("127.0.0.1", port)), - ) - .await - .is_ok_and(|result| result.is_ok()) - { - tokio::task::yield_now().await; + self.reports.send(state).unwrap(); + if state == ConfigurationAdmissionState::Accepted { + if self.pending_acceptance { + return std::future::pending().await; + } + if self.reject_next_accept.swap(false, Ordering::SeqCst) { + return Err(miette::miette!( + "desired generation changed before activation" + )); + } + assert_eq!( + snapshot.unwrap().config_revision, + self.desired.lock().unwrap().config_revision + ); } - }) - .await - .expect("accepted session opens TCP readiness"); - drop(readiness); - } - - #[cfg(not(unix))] - #[tokio::test] - async fn unix_readiness_is_rejected_without_disabling_tcp() { - let result = ControlReadiness::start(ReadinessEndpoint::Unix("health.sock".into()), None); - assert!( - result - .err() - .expect("Unix sockets unsupported") - .to_string() - .contains("unsupported") - ); - let (_tx, rx) = tokio::sync::watch::channel(false); - assert!( - ControlReadiness::start(ReadinessEndpoint::Unix("health.sock".into()), Some(rx)) - .is_err(), - "invalid adapter must fail even before session acceptance" - ); - } - - #[test] - #[cfg(unix)] - fn control_readiness_rejects_relative_path() { - let error = prepare_control_readiness_path(std::path::Path::new("health.sock")) - .expect_err("relative readiness path must be rejected"); - assert!(error.to_string().contains("must be absolute")); + Ok(()) + } } #[test] - fn main_exit_marker_atomically_replaces_previous_value() { - let directory = tempfile::tempdir().unwrap(); - let marker = directory.path().join("main-exited"); - std::fs::write(&marker, b"stale\n").unwrap(); - - persist_main_exit_marker(&marker, 23).unwrap(); - - assert_eq!(std::fs::read_to_string(&marker).unwrap(), "exit_code=23\n"); - assert!( - !directory - .path() - .join(format!(".main-exited.tmp-{}", std::process::id())) - .exists() - ); - } - - #[tokio::test] - async fn remote_access_plane_outlives_main_completion_until_teardown() { - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let retained = retain_remote_access_plane(std::future::pending(), async { - let _ = shutdown_rx.await; - }); - tokio::pin!(retained); - - assert!( - timeout(Duration::from_millis(10), &mut retained) - .await - .is_err(), - "access plane must remain live after canonical process completion" + fn startup_rejection_logs_only_changed_configuration_or_error() { + let mut snapshot = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, ); - shutdown_tx.send(()).expect("request teardown"); - timeout(Duration::from_secs(1), &mut retained) - .await - .expect("teardown should release retained access plane") - .expect("clean teardown"); - } - - #[tokio::test] - async fn completion_retry_phase_is_cancelled_by_shutdown() { - let mut shutdown = Box::pin(std::future::ready(())); - assert!( - completion_phase_or_shutdown(std::future::pending(), shutdown.as_mut()).await, - "shutdown must cancel an indefinitely retrying completion phase" - ); - } - - #[test] - fn apply_agent_proposals_enabled_installs_only_on_false_to_true() { - let agent_proposals = AgentProposals::default(); - let installs = AtomicUsize::new(0); - - apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(1), || { - installs.fetch_add(1, Ordering::Relaxed); - Ok(skills::InstalledSkills { - policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), - policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), - agents: None, - }) - }); - assert!(agent_proposals.enabled()); - assert_eq!(installs.load(Ordering::Relaxed), 1); - - apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(2), || { - installs.fetch_add(1, Ordering::Relaxed); - Ok(skills::InstalledSkills { - policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), - policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), - agents: None, - }) - }); - assert_eq!(installs.load(Ordering::Relaxed), 1); - - apply_agent_proposals_enabled(&agent_proposals, false, "test", Some(3), || { - installs.fetch_add(1, Ordering::Relaxed); - Ok(skills::InstalledSkills { - policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), - policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), - agents: None, - }) - }); - assert!(!agent_proposals.enabled()); - assert_eq!(installs.load(Ordering::Relaxed), 1); - } - - #[test] - fn apply_ocsf_json_setting_enables_from_initial_settings_snapshot() { - let enabled = AtomicBool::new(false); - let mut settings = std::collections::HashMap::new(); - settings.insert("ocsf_json_enabled".to_string(), effective_bool(true)); - - apply_ocsf_json_setting(&enabled, &settings); - - assert!(enabled.load(Ordering::Relaxed)); - } - - #[test] - fn apply_ocsf_json_setting_disables_when_setting_is_unset() { - let enabled = AtomicBool::new(true); - let settings = std::collections::HashMap::new(); - - apply_ocsf_json_setting(&enabled, &settings); - - assert!(!enabled.load(Ordering::Relaxed)); - } - - #[test] - fn apply_ocsf_schema_version_setting_updates_from_initial_settings_snapshot() { - let version = std::sync::Mutex::new(String::new()); - let mut settings = std::collections::HashMap::new(); - settings.insert( - "ocsf_schema_version".to_string(), - openshell_core::proto::EffectiveSetting { - value: Some(openshell_core::proto::SettingValue { - value: Some(openshell_core::proto::setting_value::Value::StringValue( - "1.3".into(), - )), - }), - scope: openshell_core::proto::SettingScope::Sandbox.into(), - }, - ); - - apply_ocsf_schema_version_setting(&version, &settings); - - assert_eq!(*version.lock().unwrap(), "1.3"); - } - - #[test] - fn apply_ocsf_schema_version_setting_clears_when_setting_is_unset() { - let version = std::sync::Mutex::new("1.1".to_string()); - let settings = std::collections::HashMap::new(); - - apply_ocsf_schema_version_setting(&version, &settings); - - assert_eq!(*version.lock().unwrap(), ""); - } - - #[test] - fn agent_proposals_setting_enables_from_initial_settings_snapshot() { - let mut settings = std::collections::HashMap::new(); - settings.insert( - openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), - effective_bool(true), - ); - - assert!(agent_proposals_enabled_from_settings(&settings)); - } - - #[test] - fn agent_proposals_setting_defaults_false_when_unset() { - let settings = std::collections::HashMap::new(); - - assert!(!agent_proposals_enabled_from_settings(&settings)); - } - - // ---- Policy disk discovery tests ---- - - #[test] - fn discover_policy_from_nonexistent_path_is_missing() { - let path = std::path::Path::new("/nonexistent/policy.yaml"); - assert!(matches!( - discover_image_policy_from_path(path), - ImagePolicyDiscovery::Missing - )); - } - - #[test] - fn discover_policy_from_valid_yaml_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - std::fs::write( - &path, - r#" -version: 1 -filesystem_policy: - include_workdir: false - read_only: - - /usr - read_write: - - /tmp -network_policies: - test: - name: test - endpoints: - - { host: example.com, port: 443 } - binaries: - - { path: /usr/bin/curl } -"#, - ) - .unwrap(); - - let ImagePolicyDiscovery::Policy(policy) = discover_image_policy_from_path(&path) else { - panic!("expected parsed policy") - }; - assert_eq!(policy.network_policies.len(), 1); - assert!(policy.network_policies.contains_key("test")); - let fs = policy.filesystem.unwrap(); - assert!(!fs.include_workdir); - } - - #[test] - fn discover_policy_from_invalid_yaml_remains_invalid() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - std::fs::write(&path, "this is not valid yaml: [[[").unwrap(); - - assert!(matches!( - discover_image_policy_from_path(&path), - ImagePolicyDiscovery::Invalid - )); - } - - #[test] - fn discover_policy_from_oversized_file_remains_invalid() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - let oversized = format!("version: 1\n{}", " ".repeat(4 * 1024 * 1024)); - std::fs::write(&path, oversized).unwrap(); - assert!(matches!( - discover_image_policy_from_path(&path), - ImagePolicyDiscovery::Invalid - )); - } - - #[test] - fn discover_policy_from_unsafe_yaml_preserves_candidate_for_admission() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("policy.yaml"); - std::fs::write( - &path, - r#" -version: 1 -process: - run_as_user: root - run_as_group: root -filesystem_policy: - include_workdir: true - read_only: - - /usr - read_write: - - /tmp -"#, - ) - .unwrap(); - - let ImagePolicyDiscovery::Policy(policy) = discover_image_policy_from_path(&path) else { - panic!("expected parsed policy") - }; - assert!(openshell_policy::validate_sandbox_policy(&policy).is_err()); - } - - #[test] - fn discover_policy_restrictive_default_blocks_network() { - // In cluster mode we keep proxy mode enabled so all egress passes - // through proxy/OPA controls. - let proto = openshell_policy::restrictive_default_policy(); - let local_policy = SandboxPolicy::try_from(proto).expect("conversion should succeed"); - assert!(matches!(local_policy.network.mode, NetworkMode::Proxy)); - } - - // ---- Initial policy acknowledgement tests ---- - - fn proto_policy_fixture() -> openshell_core::proto::SandboxPolicy { - openshell_policy::restrictive_default_policy() - } - - fn proto_tcp_policy_fixture() -> openshell_core::proto::SandboxPolicy { - openshell_policy::parse_sandbox_policy( - r#" -version: 1 -network_policies: - redis: - name: redis - endpoints: - - host: redis.example.com - port: 6379 - protocol: tcp - binaries: - - path: /usr/bin/redis-cli -"#, - ) - .expect("parse TCP policy") - } - - fn settings_poll_result( - policy: Option, - version: u32, - source: openshell_core::proto::PolicySource, - ) -> openshell_core::grpc_client::SettingsPollResult { - openshell_core::grpc_client::SettingsPollResult { - policy, - version, - policy_hash: format!("hash-v{version}"), - config_revision: u64::from(version) * 100, - policy_source: source, - settings: std::collections::HashMap::new(), - global_policy_version: 0, - provider_env_revision: 0, - provider_attachment_epoch: String::new(), - supervisor_middleware_services: Vec::new(), - workspace: String::new(), - policy_validation_failure_mode: PolicyValidationFailureMode::default(), - extension_authentication_enabled: false, - configuration_admitted: true, - configuration_error: String::new(), - configuration_instance_id: String::new(), - } - } - - #[derive(Clone)] - struct TestStartupGateway { - desired: Arc>, - reports: UnboundedSender, - reject_next_accept: Arc, - snapshot_error: Option, - report_error: Option, - pending_snapshot: bool, - pending_acceptance: bool, - } - - impl StartupGateway for TestStartupGateway { - async fn snapshot( - &self, - _id: &str, - ) -> Result { - if self.pending_snapshot { - return std::future::pending().await; - } - if let Some(code) = self.snapshot_error { - return Err(openshell_core::grpc_client::grpc_status_error( - tonic::Status::new(code, "snapshot unavailable"), - )); - } - Ok(self.desired.lock().unwrap().clone()) - } - async fn provider( - &self, - _id: &str, - ) -> Result { - Ok(startup_provider( - self.desired.lock().unwrap().provider_env_revision, - )) - } - async fn sync( - &self, - _sandbox: &str, - _policy: &openshell_core::proto::SandboxPolicy, - _workspace: &str, - ) -> Result { - self.snapshot("").await - } - async fn report( - &self, - _id: &str, - _instance_id: &str, - snapshot: Option<&openshell_core::grpc_client::SettingsPollResult>, - state: openshell_core::proto::ConfigurationAdmissionState, - _error: &str, - ) -> Result<()> { - use openshell_core::proto::ConfigurationAdmissionState; - if let Some(code) = self.report_error { - return Err(openshell_core::grpc_client::grpc_status_error( - tonic::Status::new(code, "registration fence changed"), - )); - } - self.reports.send(state).unwrap(); - if state == ConfigurationAdmissionState::Accepted { - if self.pending_acceptance { - return std::future::pending().await; - } - if self.reject_next_accept.swap(false, Ordering::SeqCst) { - return Err(miette::miette!( - "desired generation changed before activation" - )); - } - assert_eq!( - snapshot.unwrap().config_revision, - self.desired.lock().unwrap().config_revision - ); - } - Ok(()) - } - } - - #[test] - fn startup_rejection_logs_only_changed_configuration_or_error() { - let mut snapshot = settings_poll_result( - Some(proto_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - let mut log = StartupRejectionLog::default(); - assert!(log.changed(&snapshot, "invalid policy")); - for _ in 0..10 { - assert!(!log.changed(&snapshot, "invalid policy")); - } - snapshot.config_revision += 1; - assert!(log.changed(&snapshot, "invalid policy")); - snapshot.provider_env_revision += 1; - assert!(log.changed(&snapshot, "invalid policy")); - snapshot.policy_hash.push_str("changed"); - assert!(log.changed(&snapshot, "invalid policy")); - snapshot.version += 1; - assert!(log.changed(&snapshot, "invalid policy")); - assert!(log.changed(&snapshot, "invalid provider")); - assert!(!log.changed(&snapshot, "invalid provider")); - assert!(log.changed(&snapshot, "invalid policy")); - } - - #[tokio::test(start_paused = true)] - async fn startup_rejects_vm_identity_until_matching_policy_is_available() { - use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; - let mut policy = proto_policy_fixture(); - enrich_proto_baseline_paths(&mut policy); - policy.process = Some(openshell_core::proto::ProcessPolicy { - run_as_user: "10000".into(), - run_as_group: "1001".into(), - }); - let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel(); - let gateway = TestStartupGateway { - desired: Arc::new(std::sync::Mutex::new(settings_poll_result( - Some(policy), - 1, - PolicySource::Sandbox, - ))), - reports, - reject_next_accept: Arc::new(AtomicBool::new(false)), - snapshot_error: None, - report_error: None, - pending_snapshot: false, - pending_acceptance: false, - }; - let active_gateway = gateway.clone(); - let handle = tokio::spawn(async move { - load_policy_with_gateway( - Some("sandbox-id".into()), - Some("sandbox".into()), - Some("http://unused.invalid".into()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - Some(VmPolicyIdentity { - uid: 1000, - gid: 1001, - }), - Some(ImagePolicyDiscovery::Missing), - &active_gateway, - ) - .await - }); - assert_eq!( - reported.recv().await, - Some(ConfigurationAdmissionState::Pending) - ); - assert_eq!( - reported.recv().await, - Some(ConfigurationAdmissionState::Rejected) - ); - assert!( - !handle.is_finished(), - "mismatched policy must never be returned as effective" - ); - { - let mut desired = gateway.desired.lock().unwrap(); - desired - .policy - .as_mut() - .unwrap() - .process - .as_mut() - .unwrap() - .run_as_user = "sandbox".into(); - desired.config_revision += 1; - } - assert_eq!( - reported.recv().await, - Some(ConfigurationAdmissionState::Accepted) - ); - handle - .await - .unwrap() - .expect("matching repair should permit startup"); - } - - #[test] - fn vm_startup_identity_validates_selectors_independently() { - let identity = VmPolicyIdentity { - uid: 1000, - gid: 1001, - }; - for (user, group, accepted) in [ - ("", "", true), - ("1000", "", true), - ("", "1001", true), - ("sandbox", "sandbox", true), - ("10000", "", false), - ("", "10001", false), - ] { - let policy = openshell_core::proto::SandboxPolicy { - process: Some(openshell_core::proto::ProcessPolicy { - run_as_user: user.into(), - run_as_group: group.into(), - }), - ..Default::default() - }; - assert_eq!( - identity.validate(&policy).is_ok(), - accepted, - "{user}:{group}" - ); - } - } - - #[tokio::test] - async fn vm_identity_conflict_prevents_runtime_policy_and_credential_commit() { - let mut policy = proto_policy_fixture(); - let engine = OpaEngine::from_proto(&policy).unwrap(); - let before = engine.current_generation(); - policy.process = Some(openshell_core::proto::ProcessPolicy { - run_as_user: "sandbox".into(), - run_as_group: "10000".into(), - }); - let committed = AtomicBool::new(false); - let result = reload_gateway_configuration_runtime( - &engine, - Some(&policy), - 0, - MiddlewareReloadContext { - desired_services: &[], - authentication: &MiddlewareAuthentication::default(), - registry_changed: false, - connector: &default_middleware_connector(), - }, - Some(VmPolicyIdentity { - uid: 1000, - gid: 1001, - }), - || { - committed.store(true, Ordering::SeqCst); - }, - ) - .await; - let Err(GatewayRuntimeReloadError::PolicyValidation(error)) = result else { - panic!("conflicting VM group should fail policy validation"); - }; - assert!(error.to_string().contains("run_as_group '10000'")); - assert!(error.to_string().contains("1000:1001")); - assert_eq!(engine.current_generation(), before); - assert!(!committed.load(Ordering::SeqCst)); - } - - #[tokio::test(start_paused = true)] - async fn startup_pending_gateway_calls_exhaust_their_budgets() { - for pending_snapshot in [true, false] { - let mut policy = proto_policy_fixture(); - enrich_proto_baseline_paths(&mut policy); - let (reports, _reported) = tokio::sync::mpsc::unbounded_channel(); - let gateway = TestStartupGateway { - desired: Arc::new(std::sync::Mutex::new(settings_poll_result( - Some(policy), - 1, - openshell_core::proto::PolicySource::Sandbox, - ))), - reports, - reject_next_accept: Arc::new(AtomicBool::new(false)), - snapshot_error: None, - report_error: None, - pending_snapshot, - pending_acceptance: !pending_snapshot, - }; - let result = timeout( - Duration::from_mins(2), - load_policy_with_gateway( - Some("sandbox-id".to_string()), - Some("sandbox".to_string()), - Some("http://unused.invalid".to_string()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - None, - Some(ImagePolicyDiscovery::Missing), - &gateway, - ), - ) - .await - .expect("pending RPC must not hang startup"); - let Err(error) = result else { - panic!("pending gateway unexpectedly admitted startup") - }; - assert!( - error.to_string().contains(if pending_snapshot { - "failed after 5 attempts" - } else { - "did not stabilize after 5 attempts" - }), - "{error}" - ); - } - } - - #[tokio::test] - async fn startup_transient_gateway_errors_exhaust_retry_budget() { - let calls = AtomicUsize::new(0); - let result: Result<()> = timeout( - Duration::from_secs(15), - grpc_retry("Startup configuration fetch", || { - calls.fetch_add(1, Ordering::SeqCst); - async { - Err(openshell_core::grpc_client::grpc_status_error( - tonic::Status::unavailable("gateway down"), - )) - } - }), - ) - .await - .expect("transient failure must have a bounded retry budget"); - assert!( - result - .unwrap_err() - .to_string() - .contains("failed after 5 attempts") - ); - assert_eq!(calls.load(Ordering::SeqCst), 5); - } - - #[tokio::test] - async fn startup_returns_permanent_gateway_errors_without_waiting_for_policy_repair() { - for (snapshot_error, report_error) in [ - (Some(tonic::Code::PermissionDenied), None), - (Some(tonic::Code::NotFound), None), - (None, Some(tonic::Code::FailedPrecondition)), - ] { - let (reports, _reported) = tokio::sync::mpsc::unbounded_channel(); - let gateway = TestStartupGateway { - desired: Arc::new(std::sync::Mutex::new(settings_poll_result( - None, - 1, - openshell_core::proto::PolicySource::Sandbox, - ))), - reports, - reject_next_accept: Arc::new(AtomicBool::new(false)), - snapshot_error, - report_error, - pending_snapshot: false, - pending_acceptance: false, - }; - let result = timeout( - Duration::from_secs(1), - load_policy_with_gateway( - Some("sandbox-id".to_string()), - Some("sandbox".to_string()), - Some("http://unused.invalid".to_string()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - None, - Some(ImagePolicyDiscovery::Missing), - &gateway, - ), - ) - .await - .expect("permanent errors must terminate startup"); - let Err(error) = result else { - panic!("startup unexpectedly succeeded") - }; - assert!(!is_retryable_error(&error)); - assert!(error.to_string().contains(if report_error.is_some() { - "registration fence changed" - } else { - "snapshot unavailable" - })); - } - } - - #[tokio::test] - async fn startup_waits_for_repair_and_retries_stale_activation_before_returning() { - use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; - let mut policy = proto_policy_fixture(); - enrich_proto_baseline_paths(&mut policy); - let mut rejected = settings_poll_result(Some(policy), 1, PolicySource::Sandbox); - rejected.configuration_admitted = false; - let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel(); - let gateway = TestStartupGateway { - desired: Arc::new(std::sync::Mutex::new(rejected)), - reports, - reject_next_accept: Arc::new(AtomicBool::new(true)), - snapshot_error: None, - report_error: None, - pending_snapshot: false, - pending_acceptance: false, - }; - let active_gateway = gateway.clone(); - let handle = tokio::spawn(async move { - load_policy_with_gateway( - Some("sandbox-id".to_string()), - Some("sandbox".to_string()), - Some("http://unused.invalid".to_string()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - None, - Some(ImagePolicyDiscovery::Missing), - &active_gateway, - ) - .await - }); - assert_eq!( - reported.recv().await, - Some(ConfigurationAdmissionState::Pending) - ); - assert_eq!( - reported.recv().await, - Some(ConfigurationAdmissionState::Rejected) - ); - assert!( - !handle.is_finished(), - "rejected configuration must not return a launch bundle" - ); - assert_eq!( - timeout(Duration::from_secs(5), reported.recv()) - .await - .unwrap(), - Some(ConfigurationAdmissionState::Rejected), - "unchanged rejection must still report readiness on subsequent polls" - ); - { - let mut desired = gateway.desired.lock().unwrap(); - desired.configuration_admitted = true; - desired.config_revision += 1; - } - assert_eq!( - timeout(Duration::from_secs(5), reported.recv()) - .await - .unwrap(), - Some(ConfigurationAdmissionState::Accepted) - ); - assert!( - !handle.is_finished(), - "a stale activation acknowledgement must not return a launch bundle" - ); - assert_eq!( - timeout(Duration::from_secs(5), reported.recv()) - .await - .unwrap(), - Some(ConfigurationAdmissionState::Accepted) - ); - let bundle = timeout(Duration::from_secs(5), handle) - .await - .unwrap() - .unwrap() - .expect("repair returns one launch bundle"); - assert!( - bundle.7.is_some(), - "launch bundle retains matching provider state" - ); - } - - // ---- Startup policy write refusal tests ---- - - /// `UpdateConfig` diagnostic for a credential binding whose provider is not - /// attached to the sandbox. The gateway returns it as `FAILED_PRECONDITION`. - const UNATTACHED_PROVIDER_DIAGNOSTIC: &str = "credential_binding references provider 'github', but that provider is not attached to the sandbox"; - - /// The same refusal naming a second unattached provider of one policy. The - /// gateway reports whichever unattached provider it meets first, so one - /// policy can be refused with either message. - const UNATTACHED_SECOND_PROVIDER_DIAGNOSTIC: &str = "credential_binding references provider 'gitlab', but that provider is not attached to the sandbox"; - - /// `UpdateConfig` diagnostic for middleware configuration the gateway - /// cannot validate. The gateway returns it as `INVALID_ARGUMENT`. - const INVALID_MIDDLEWARE_DIAGNOSTIC: &str = - "policy middleware validation failed: binding 'redact' has an invalid config"; - - /// `UpdateConfig` diagnostic for any sandbox policy write while a global - /// policy is active. The gateway returns it as `FAILED_PRECONDITION`. - const GLOBAL_POLICY_DIAGNOSTIC: &str = - "policy is managed globally; delete global policy before sandbox policy update"; - - /// One gateway call made by startup, recorded in call order. - #[derive(Clone, Debug, PartialEq, Eq)] - enum StartupCall { - Snapshot, - Sync, - Report(StartupReport), - } - - /// One admission report startup sent to the gateway. - #[derive(Clone, Debug, PartialEq, Eq)] - struct StartupReport { - state: openshell_core::proto::ConfigurationAdmissionState, - error: String, - /// Generation of the snapshot sent with the report. The gateway - /// answers `ABORTED` to a `Rejected` or `Accepted` report whose - /// generation is no longer current. - generation: Option, - } - - /// `(version, policy_hash, config_revision, provider_env_revision)` of a - /// snapshot, the fields the gateway compares when it admits a report. - type ReportGeneration = (u32, String, u64, u64); - - fn report_generation( - snapshot: &openshell_core::grpc_client::SettingsPollResult, - ) -> ReportGeneration { - ( - snapshot.version, - snapshot.policy_hash.clone(), - snapshot.config_revision, - snapshot.provider_env_revision, - ) - } - - /// Snapshot of a sandbox with no stored policy, as the gateway returns it: - /// version 0 and an empty policy hash. The configuration and provider - /// revisions are nonzero, so a report built from any other snapshot names - /// a different generation. - fn unset_policy_snapshot() -> openshell_core::grpc_client::SettingsPollResult { - let mut snapshot = - settings_poll_result(None, 0, openshell_core::proto::PolicySource::Sandbox); - snapshot.policy_hash = String::new(); - snapshot.config_revision = 7; - snapshot.provider_env_revision = 3; - snapshot - } - - /// A scripted refusal of one startup `UpdateConfig` write. - #[derive(Clone)] - struct SyncRefusal { - code: tonic::Code, - message: &'static str, - } - - impl SyncRefusal { - fn new(code: tonic::Code, message: &'static str) -> Self { - Self { code, message } - } - } - - /// Startup gateway double for the two startup policy writes: the image - /// policy upload and the baseline-path write-back. - /// - /// `sync` consumes the next scripted refusal and fails with its status, - /// wrapped the way the remote client wraps it. While the desired policy is - /// global, it refuses every write the way the gateway does. Otherwise, - /// without a refusal, it stores the payload as the next policy revision and - /// returns the new snapshot, as the gateway does for an accepted write. - /// Every call is recorded, including the generation each report names. - /// Reports for obsolete generations fail with `ABORTED`, as the gateway - /// requires. Tests can install a repair before a refused write returns or - /// script rejection-report errors to exercise retries and fatal failures. - #[derive(Clone)] - struct WriteRefusingStartupGateway { - desired: Arc>, - refusals: Arc>>, - repair_after_refusal: Option, - rejection_report_errors: Arc>>, - calls: UnboundedSender, - } - - impl StartupGateway for WriteRefusingStartupGateway { - async fn snapshot( - &self, - _sandbox: &str, - ) -> Result { - self.calls.send(StartupCall::Snapshot).unwrap(); - Ok(self.desired.lock().unwrap().clone()) - } - async fn provider( - &self, - _id: &str, - ) -> Result { - Ok(startup_provider( - self.desired.lock().unwrap().provider_env_revision, - )) - } - async fn sync( - &self, - _sandbox: &str, - policy: &openshell_core::proto::SandboxPolicy, - _workspace: &str, - ) -> 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 - // payload, so this refusal does not consume a scripted one. - let refusal = if desired.policy_source == openshell_core::proto::PolicySource::Global { - Some(SyncRefusal::new( - tonic::Code::FailedPrecondition, - GLOBAL_POLICY_DIAGNOSTIC, - )) - } else { - self.refusals.lock().unwrap().pop_front() - }; - if let Some(refusal) = refusal { - if let Some(repaired) = &self.repair_after_refusal { - *desired = repaired.clone(); - } - return Err( - openshell_core::grpc_client::grpc_status_error(tonic::Status::new( - refusal.code, - refusal.message, - )) - .wrap_err("failed to sync policy to server"), - ); - } - let version = desired.version + 1; - *desired = settings_poll_result( - Some(policy.clone()), - version, - openshell_core::proto::PolicySource::Sandbox, - ); - Ok(desired.clone()) - } - async fn report( - &self, - _id: &str, - _instance_id: &str, - snapshot: Option<&openshell_core::grpc_client::SettingsPollResult>, - state: openshell_core::proto::ConfigurationAdmissionState, - error: &str, - ) -> Result<()> { - self.calls - .send(StartupCall::Report(StartupReport { - state, - error: error.to_owned(), - generation: snapshot.map(report_generation), - })) - .unwrap(); - if state == openshell_core::proto::ConfigurationAdmissionState::Rejected - && let Some(code) = self.rejection_report_errors.lock().unwrap().pop_front() - { - return Err( - openshell_core::grpc_client::grpc_status_error(tonic::Status::new( - code, - "rejection report failed", - )) - .wrap_err("failed to report sandbox configuration"), - ); - } - if matches!( - state, - openshell_core::proto::ConfigurationAdmissionState::Rejected - | openshell_core::proto::ConfigurationAdmissionState::Accepted - ) && snapshot.map(report_generation) - != Some(report_generation(&self.desired.lock().unwrap())) - { - return Err(openshell_core::grpc_client::grpc_status_error( - tonic::Status::aborted("configuration generation has changed"), - ) - .wrap_err("failed to report sandbox configuration")); - } - Ok(()) - } - } - - /// Waits for startup's next admission report and returns it together with - /// the snapshot and write calls startup made since the previous report. If - /// startup returns first, the test fails with startup's result. - async fn next_startup_report( - calls: &mut tokio::sync::mpsc::UnboundedReceiver, - startup: &mut tokio::task::JoinHandle>, - ) -> (Vec, StartupReport) { - let mut preceding = Vec::new(); - loop { - tokio::select! { - // Drain recorded calls before startup's result so a report sent - // just before startup returned is still observed. - biased; - call = calls.recv() => match call { - Some(StartupCall::Report(report)) => return (preceding, report), - Some(call) => preceding.push(call), - None => panic!("the test gateway call channel closed"), - }, - result = &mut *startup => match result.expect("startup task panicked") { - Ok(_) => panic!("startup accepted a configuration before the expected report"), - Err(error) => panic!( - "startup exited instead of reporting: {}", - startup_error_chain(&error) - ), - }, - } - } - } - - /// Formats startup's error chain on one line. The gRPC status wrapper - /// displays the same text as the status it wraps, so repeats are dropped. - fn startup_error_chain(error: &miette::Report) -> String { - let mut causes: Vec = error.chain().map(ToString::to_string).collect(); - causes.dedup(); - causes.join(": ") - } - - /// Starts a sandbox against a gateway that refuses the startup policy write - /// described by `write` once for each entry of `refusals`, then checks the - /// repair flow the policy documentation promises. On every refused pass, - /// startup reads a fresh snapshot, sends the write again, and reports - /// `Rejected` with the gateway's diagnostic against `initial`, the snapshot - /// the refused write was built from, while it keeps waiting. Once an - /// operator stores `repaired`, the next snapshot is accepted and startup - /// installs that policy. - async fn assert_refused_startup_write_waits_for_repair( - initial: openshell_core::grpc_client::SettingsPollResult, - discovery: ImagePolicyDiscovery, - write: &str, - refusals: Vec, - repaired: openshell_core::proto::SandboxPolicy, - ) { - use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; - let refused_generation = report_generation(&initial); - let diagnostics: Vec = refusals - .iter() - .map(|refusal| format!("Gateway rejected the {write}: {}", refusal.message)) - .collect(); - let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); - let gateway = WriteRefusingStartupGateway { - desired: Arc::new(std::sync::Mutex::new(initial)), - refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from( - refusals, - ))), - repair_after_refusal: None, - rejection_report_errors: Arc::default(), - calls, - }; - let startup_gateway = gateway.clone(); - let mut startup = tokio::spawn(async move { - load_policy_with_gateway( - Some("sandbox-id".to_string()), - Some("sandbox".to_string()), - Some("http://unused.invalid".to_string()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - None, - Some(discovery), - &startup_gateway, - ) - .await - }); - // A regression that keeps looping without the expected report fails at - // this deadline instead of hanging; the paused clock makes it free. - timeout(Duration::from_mins(1), async { - let (_, registration) = next_startup_report(&mut observed, &mut startup).await; - assert_eq!(registration.state, ConfigurationAdmissionState::Pending); - for (pass, diagnostic) in diagnostics.into_iter().enumerate() { - let (preceding, report) = next_startup_report(&mut observed, &mut startup).await; - assert_eq!( - preceding, - [StartupCall::Snapshot, StartupCall::Sync], - "refused pass {pass}: startup must read a fresh snapshot and send the write again" - ); - assert_eq!( - report, - StartupReport { - state: ConfigurationAdmissionState::Rejected, - error: diagnostic, - generation: Some(refused_generation.clone()), - }, - "refused pass {pass}: the rejection must carry the gateway diagnostic and name the snapshot the refused write was built from" - ); - assert!( - !startup.is_finished(), - "refused pass {pass}: a refused startup write must leave the sandbox waiting for repair" - ); - } - { - let mut desired = gateway.desired.lock().unwrap(); - let version = desired.version + 1; - *desired = - settings_poll_result(Some(repaired.clone()), version, PolicySource::Sandbox); - } - let (_, accepted) = next_startup_report(&mut observed, &mut startup).await; - assert_eq!(accepted.state, ConfigurationAdmissionState::Accepted); - let bundle = (&mut startup) - .await - .unwrap() - .expect("the repaired configuration returns one launch bundle"); - assert_eq!( - bundle.2, - Some(repaired), - "startup must install the repaired policy" - ); - }) - .await - .expect("startup must report every refusal and then accept the repair"); - } - - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_failed_precondition_waits_for_repair() { - // The gateway has no policy, so startup uploads the image policy. - assert_refused_startup_write_waits_for_repair( - unset_policy_snapshot(), - ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), - "image policy", - vec![SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_PROVIDER_DIAGNOSTIC, - )], - proto_policy_fixture(), - ) - .await; - } - - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_invalid_argument_waits_for_repair() { - assert_refused_startup_write_waits_for_repair( - unset_policy_snapshot(), - ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), - "image policy", - vec![SyncRefusal::new( - tonic::Code::InvalidArgument, - INVALID_MIDDLEWARE_DIAGNOSTIC, - )], - proto_policy_fixture(), - ) - .await; - } - - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_baseline_write_back_waits_for_repair() { - // The gateway policy has network rules but lacks the proxy baseline - // paths, so startup writes an enriched copy back. The repaired policy - // already carries those paths and needs no write-back. - let mut repaired = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut repaired)); - assert_refused_startup_write_waits_for_repair( - settings_poll_result( - Some(proto_tcp_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ), - ImagePolicyDiscovery::Missing, - "policy update that adds baseline filesystem paths", - vec![SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_PROVIDER_DIAGNOSTIC, - )], - repaired, - ) - .await; - } - - /// Install an operator's repair after the refused write but before its - /// rejection report. The obsolete report must lead straight to a new - /// snapshot, without resending the write or delaying the repaired launch. - async fn assert_startup_repair_before_rejection_refetches( - initial: openshell_core::grpc_client::SettingsPollResult, - discovery: ImagePolicyDiscovery, - repaired: openshell_core::proto::SandboxPolicy, - refusal: SyncRefusal, - write: &str, - ) { - use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; - let initial_generation = report_generation(&initial); - let repaired_snapshot = settings_poll_result( - Some(repaired.clone()), - initial.version + 1, - PolicySource::Sandbox, - ); - let repaired_generation = report_generation(&repaired_snapshot); - let diagnostic = format!("Gateway rejected the {write}: {}", refusal.message); - let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); - let gateway = WriteRefusingStartupGateway { - desired: Arc::new(std::sync::Mutex::new(initial)), - refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from([ - refusal, - ]))), - repair_after_refusal: Some(repaired_snapshot), - rejection_report_errors: Arc::default(), - calls, - }; - let started = tokio::time::Instant::now(); - let bundle = timeout( - Duration::from_secs(30), - load_policy_with_gateway( - Some("sandbox-id".to_string()), - Some("sandbox".to_string()), - Some("http://unused.invalid".to_string()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - None, - Some(discovery), - &gateway, - ), - ) - .await - .expect("a superseded rejection must not stall startup") - .unwrap_or_else(|error| { - panic!( - "a repair that precedes its rejection report must resume startup: {}", - startup_error_chain(&error) - ) - }); - assert_eq!( - started.elapsed(), - Duration::ZERO, - "a superseded rejection must fetch the repaired generation immediately" - ); - let mut trace = Vec::new(); - while let Ok(call) = observed.try_recv() { - trace.push(call); - } - assert_eq!( - trace, - [ - StartupCall::Snapshot, - StartupCall::Report(StartupReport { - state: ConfigurationAdmissionState::Pending, - error: String::new(), - generation: Some(initial_generation.clone()), - }), - StartupCall::Snapshot, - StartupCall::Sync, - StartupCall::Report(StartupReport { - state: ConfigurationAdmissionState::Rejected, - error: diagnostic, - generation: Some(initial_generation), - }), - StartupCall::Snapshot, - StartupCall::Report(StartupReport { - state: ConfigurationAdmissionState::Accepted, - error: String::new(), - generation: Some(repaired_generation), - }), - ], - "startup must discard the obsolete rejection and validate the repair" - ); - assert_eq!(bundle.2, Some(repaired)); - } - - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_image_repair_before_report_refetches() { - Box::pin(assert_startup_repair_before_rejection_refetches( - unset_policy_snapshot(), - ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), - proto_policy_fixture(), - SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_PROVIDER_DIAGNOSTIC, - ), - "image policy", - )) - .await; - } - - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_baseline_repair_before_report_refetches() { - let mut repaired = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut repaired)); - Box::pin(assert_startup_repair_before_rejection_refetches( - settings_poll_result( - Some(proto_tcp_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ), - ImagePolicyDiscovery::Missing, - repaired, - SyncRefusal::new(tonic::Code::InvalidArgument, INVALID_MIDDLEWARE_DIAGNOSTIC), - "policy update that adds baseline filesystem paths", - )) - .await; - } - - /// Exercise rejection reporting directly so transport and identity errors - /// cannot be mistaken for policy-write failures or registration failures. - async fn startup_rejection_report_with_errors( - errors: Vec, - ) -> (Result<()>, Vec, Duration, StartupRejectionLog) { - let snapshot = unset_policy_snapshot(); - let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); - let gateway = WriteRefusingStartupGateway { - desired: Arc::new(std::sync::Mutex::new(snapshot.clone())), - refusals: Arc::default(), - repair_after_refusal: None, - rejection_report_errors: Arc::new(std::sync::Mutex::new(errors.into())), - calls, - }; let mut log = StartupRejectionLog::default(); - let started = tokio::time::Instant::now(); - let result = reject_startup_configuration( - &gateway, - &mut log, - "sandbox-id", - "instance-id", - &snapshot, - "policy write refused", - None, - ) - .await; - let mut trace = Vec::new(); - while let Ok(call) = observed.try_recv() { - trace.push(call); + assert!(log.changed(&snapshot, "invalid policy")); + for _ in 0..10 { + assert!(!log.changed(&snapshot, "invalid policy")); } - assert!(trace.iter().all(|call| matches!( - call, - StartupCall::Report(report) - if report.state == openshell_core::proto::ConfigurationAdmissionState::Rejected - && report.generation == Some(report_generation(&snapshot)) - && report.error == "policy write refused" - ))); - (result, trace, started.elapsed(), log) - } - - #[tokio::test(start_paused = true)] - async fn startup_rejection_report_aborted_after_transient_failure_refetches() { - let (result, trace, elapsed, log) = startup_rejection_report_with_errors(vec![ - tonic::Code::Unavailable, - tonic::Code::Aborted, - ]) - .await; - result.expect("a superseded report must return to reconciliation"); - assert_eq!(trace.len(), 2, "an obsolete report must not be resent"); - assert_eq!(elapsed, Duration::from_secs(1)); - assert!(log.0.is_none(), "an obsolete rejection must not be logged"); - } - - #[tokio::test(start_paused = true)] - async fn startup_rejection_report_transient_failure_retries_and_logs() { - let (result, trace, elapsed, log) = - startup_rejection_report_with_errors(vec![tonic::Code::Unavailable]).await; - result.expect("a transient report failure must retry"); - assert_eq!(trace.len(), 2); - assert_eq!(elapsed, Duration::from_secs(3)); - assert_eq!( - log.0, - Some(( - LoadedPolicyRevision::from_snapshot(&unset_policy_snapshot()), - "policy write refused".to_string(), - )), - "a recorded rejection must retain its diagnostic for log suppression" - ); + snapshot.config_revision += 1; + assert!(log.changed(&snapshot, "invalid policy")); + snapshot.provider_env_revision += 1; + assert!(log.changed(&snapshot, "invalid policy")); + snapshot.policy_hash.push_str("changed"); + assert!(log.changed(&snapshot, "invalid policy")); + snapshot.version += 1; + assert!(log.changed(&snapshot, "invalid policy")); + assert!(log.changed(&snapshot, "invalid provider")); + assert!(!log.changed(&snapshot, "invalid provider")); + assert!(log.changed(&snapshot, "invalid policy")); } #[tokio::test(start_paused = true)] - async fn startup_rejection_report_transient_exhaustion_stays_fatal() { - let (result, trace, elapsed, log) = - startup_rejection_report_with_errors(vec![tonic::Code::Unavailable; 5]).await; - let error = result.expect_err("exhausted report retries must terminate startup"); - assert!(error.to_string().contains("failed after 5 attempts")); - assert_eq!(trace.len(), 5); - assert_eq!(elapsed, Duration::from_secs(11)); - assert!(log.0.is_none()); + async fn startup_rejects_vm_identity_until_matching_policy_is_available() { + use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + let mut policy = proto_policy_fixture(); + enrich_proto_baseline_paths(&mut policy); + policy.process = Some(openshell_core::proto::ProcessPolicy { + run_as_user: "10000".into(), + run_as_group: "1001".into(), + }); + let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel(); + let gateway = TestStartupGateway { + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + Some(policy), + 1, + PolicySource::Sandbox, + ))), + reports, + reject_next_accept: Arc::new(AtomicBool::new(false)), + snapshot_error: None, + report_error: None, + pending_snapshot: false, + pending_acceptance: false, + }; + let active_gateway = gateway.clone(); + let handle = tokio::spawn(async move { + load_policy_with_gateway( + Some("sandbox-id".into()), + Some("sandbox".into()), + Some("http://unused.invalid".into()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + Some(VmPolicyIdentity { + uid: 1000, + gid: 1001, + }), + Some(ImagePolicyDiscovery::Missing), + &active_gateway, + ) + .await + }); + assert_eq!( + reported.recv().await, + Some(ConfigurationAdmissionState::Pending) + ); + assert_eq!( + reported.recv().await, + Some(ConfigurationAdmissionState::Rejected) + ); + assert!( + !handle.is_finished(), + "mismatched policy must never be returned as effective" + ); + { + let mut desired = gateway.desired.lock().unwrap(); + desired + .policy + .as_mut() + .unwrap() + .process + .as_mut() + .unwrap() + .run_as_user = "sandbox".into(); + desired.config_revision += 1; + } + assert_eq!( + reported.recv().await, + Some(ConfigurationAdmissionState::Accepted) + ); + handle + .await + .unwrap() + .expect("matching repair should permit startup"); } - #[tokio::test(start_paused = true)] - async fn startup_rejection_report_permanent_failure_stays_fatal() { - for code in [ - tonic::Code::FailedPrecondition, - tonic::Code::InvalidArgument, - tonic::Code::PermissionDenied, - tonic::Code::NotFound, - tonic::Code::Unauthenticated, + #[test] + fn vm_startup_identity_validates_selectors_independently() { + let identity = VmPolicyIdentity { + uid: 1000, + gid: 1001, + }; + for (user, group, accepted) in [ + ("", "", true), + ("1000", "", true), + ("", "1001", true), + ("sandbox", "sandbox", true), + ("10000", "", false), + ("", "10001", false), ] { - let (result, trace, elapsed, log) = - startup_rejection_report_with_errors(vec![code]).await; - let error = result.expect_err("a permanent report failure must terminate startup"); - assert!(error.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|status| status.code() == code) - })); - assert_eq!(trace.len(), 1, "{code:?} must not be retried"); - assert_eq!(elapsed, Duration::ZERO); - assert!(log.0.is_none()); + let policy = openshell_core::proto::SandboxPolicy { + process: Some(openshell_core::proto::ProcessPolicy { + run_as_user: user.into(), + run_as_group: group.into(), + }), + ..Default::default() + }; + assert_eq!( + identity.validate(&policy).is_ok(), + accepted, + "{user}:{group}" + ); } } - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_image_upload_outlasts_the_attempt_limit() { - // Startup gives up after five passes that neither accept nor reject a - // configuration. A refused upload is a rejection, so the same refusal - // repeated seven times must keep the sandbox waiting for repair. - assert_refused_startup_write_waits_for_repair( - unset_policy_snapshot(), - ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), - "image policy", - vec![ - SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_PROVIDER_DIAGNOSTIC - ); - 7 - ], - proto_policy_fixture(), + #[tokio::test] + async fn vm_identity_conflict_prevents_runtime_policy_and_credential_commit() { + let mut policy = proto_policy_fixture(); + let engine = OpaEngine::from_proto(&policy).unwrap(); + let before = engine.current_generation(); + policy.process = Some(openshell_core::proto::ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: "10000".into(), + }); + let committed = AtomicBool::new(false); + let result = reload_gateway_configuration_runtime( + &engine, + Some(&policy), + 0, + MiddlewareReloadContext { + desired_services: &[], + authentication: &MiddlewareAuthentication::default(), + registry_changed: false, + connector: &default_middleware_connector(), + }, + Some(VmPolicyIdentity { + uid: 1000, + gid: 1001, + }), + || { + committed.store(true, Ordering::SeqCst); + }, ) .await; + let Err(GatewayRuntimeReloadError::PolicyValidation(error)) = result else { + panic!("conflicting VM group should fail policy validation"); + }; + assert!(error.to_string().contains("run_as_group '10000'")); + assert!(error.to_string().contains("1000:1001")); + assert_eq!(engine.current_generation(), before); + assert!(!committed.load(Ordering::SeqCst)); } #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_write_back_outlasts_the_attempt_limit() { - // The same limit applies to a refused baseline-path write-back. - let mut repaired = proto_tcp_policy_fixture(); - assert!(enrich_proto_baseline_paths(&mut repaired)); - assert_refused_startup_write_waits_for_repair( - settings_poll_result( - Some(proto_tcp_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ), - ImagePolicyDiscovery::Missing, - "policy update that adds baseline filesystem paths", - vec![ - SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_PROVIDER_DIAGNOSTIC - ); - 7 - ], - repaired, - ) - .await; - } - - /// Collects the sandbox log lines that a test's OCSF events render to. - #[derive(Clone, Default)] - struct CapturedLog(Arc>>); - - impl std::io::Write for CapturedLog { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) + async fn startup_pending_gateway_calls_exhaust_their_budgets() { + for pending_snapshot in [true, false] { + let mut policy = proto_policy_fixture(); + enrich_proto_baseline_paths(&mut policy); + let (reports, _reported) = tokio::sync::mpsc::unbounded_channel(); + let gateway = TestStartupGateway { + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + Some(policy), + 1, + openshell_core::proto::PolicySource::Sandbox, + ))), + reports, + reject_next_accept: Arc::new(AtomicBool::new(false)), + snapshot_error: None, + report_error: None, + pending_snapshot, + pending_acceptance: !pending_snapshot, + }; + let result = timeout( + Duration::from_mins(2), + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(ImagePolicyDiscovery::Missing), + &gateway, + ), + ) + .await + .expect("pending RPC must not hang startup"); + let Err(error) = result else { + panic!("pending gateway unexpectedly admitted startup") + }; + assert!( + error.to_string().contains(if pending_snapshot { + "failed after 5 attempts" + } else { + "did not stabilize after 5 attempts" + }), + "{error}" + ); } } - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_logs_a_varying_gateway_message_once() { - use tracing_subscriber::layer::SubscriberExt as _; - use tracing_subscriber::util::SubscriberInitExt as _; - // One policy with two unattached providers is refused with either - // provider's name on each attempt. The snapshot does not change, so the - // sandbox log must record the refusal once, with the first message, - // while every report still carries the message of its own attempt. - let log = CapturedLog::default(); - // Keep two dispatchers alive so tracing consults registered subscribers - // when caching callsite interest. With only one, a parallel test thread - // without a default subscriber can cache `Interest::never` for our events. - let _other_dispatch = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default()); - let _subscriber = tracing_subscriber::registry() - .with(openshell_ocsf::OcsfShorthandLayer::new(log.clone()).with_non_ocsf(false)) - .set_default(); - let first = SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_PROVIDER_DIAGNOSTIC, - ); - let second = SyncRefusal::new( - tonic::Code::FailedPrecondition, - UNATTACHED_SECOND_PROVIDER_DIAGNOSTIC, - ); - assert_refused_startup_write_waits_for_repair( - unset_policy_snapshot(), - ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), - "image policy", - vec![first.clone(), second.clone(), first, second], - proto_policy_fixture(), + #[tokio::test] + async fn startup_transient_gateway_errors_exhaust_retry_budget() { + let calls = AtomicUsize::new(0); + let result: Result<()> = timeout( + Duration::from_secs(15), + grpc_retry("Startup configuration fetch", || { + calls.fetch_add(1, Ordering::SeqCst); + async { + Err(openshell_core::grpc_client::grpc_status_error( + tonic::Status::unavailable("gateway down"), + )) + } + }), ) - .await; - let log = String::from_utf8(log.0.lock().unwrap().clone()).unwrap(); - let logged: Vec<&str> = log - .lines() - .filter(|line| line.contains("Gateway rejected the image policy")) - .collect(); - assert_eq!( - logged.len(), - 1, - "an unchanged refusal must be logged once; log:\n{log}" - ); + .await + .expect("transient failure must have a bounded retry budget"); assert!( - logged[0].contains(UNATTACHED_PROVIDER_DIAGNOSTIC), - "the log must keep the gateway's first message; log:\n{log}" + result + .unwrap_err() + .to_string() + .contains("failed after 5 attempts") ); + assert_eq!(calls.load(Ordering::SeqCst), 5); } - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_global_policy_skips_baseline_write_back() { + #[tokio::test] + async fn startup_returns_permanent_gateway_errors_without_waiting_for_policy_repair() { + for (snapshot_error, report_error) in [ + (Some(tonic::Code::PermissionDenied), None), + (Some(tonic::Code::NotFound), None), + (None, Some(tonic::Code::FailedPrecondition)), + ] { + let (reports, _reported) = tokio::sync::mpsc::unbounded_channel(); + let gateway = TestStartupGateway { + desired: Arc::new(std::sync::Mutex::new(settings_poll_result( + None, + 1, + openshell_core::proto::PolicySource::Sandbox, + ))), + reports, + reject_next_accept: Arc::new(AtomicBool::new(false)), + snapshot_error, + report_error, + pending_snapshot: false, + pending_acceptance: false, + }; + let result = timeout( + Duration::from_secs(1), + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(ImagePolicyDiscovery::Missing), + &gateway, + ), + ) + .await + .expect("permanent errors must terminate startup"); + let Err(error) = result else { + panic!("startup unexpectedly succeeded") + }; + assert!(!is_retryable_error(&error)); + assert!(error.to_string().contains(if report_error.is_some() { + "registration fence changed" + } else { + "snapshot unavailable" + })); + } + } + + #[tokio::test] + async fn startup_waits_for_repair_and_retries_stale_activation_before_returning() { 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 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 { - desired: Arc::new(std::sync::Mutex::new(global)), - refusals: Arc::default(), - repair_after_refusal: None, - rejection_report_errors: Arc::default(), - calls, + let mut policy = proto_policy_fixture(); + enrich_proto_baseline_paths(&mut policy); + let mut rejected = settings_poll_result(Some(policy), 1, PolicySource::Sandbox); + rejected.configuration_admitted = false; + let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel(); + let gateway = TestStartupGateway { + desired: Arc::new(std::sync::Mutex::new(rejected)), + reports, + reject_next_accept: Arc::new(AtomicBool::new(true)), + snapshot_error: None, + report_error: None, + pending_snapshot: false, + pending_acceptance: false, }; - let bundle = timeout( - Duration::from_mins(1), + let active_gateway = gateway.clone(); + let handle = tokio::spawn(async move { load_policy_with_gateway( Some("sandbox-id".to_string()), Some("sandbox".to_string()), @@ -6870,1734 +5014,1096 @@ network_policies: LocalPolicyIdentity::Required, None, Some(ImagePolicyDiscovery::Missing), - &gateway, - ), - ) - .await - .expect("startup must not wait for repair under a global policy") - .unwrap_or_else(|error| { - panic!( - "startup exited under a global policy: {}", - startup_error_chain(&error) + &active_gateway, ) + .await }); - let mut trace = Vec::new(); - while let Ok(call) = observed.try_recv() { - trace.push(call); - } - let report = |state| { - StartupCall::Report(StartupReport { - state, - error: String::new(), - generation: Some(global_generation.clone()), - }) - }; assert_eq!( - trace, - [ - StartupCall::Snapshot, - report(ConfigurationAdmissionState::Pending), - StartupCall::Snapshot, - report(ConfigurationAdmissionState::Accepted), - ], - "startup must not write a global policy back as a sandbox policy" + reported.recv().await, + Some(ConfigurationAdmissionState::Pending) ); assert_eq!( - bundle.2, - Some(enriched), - "startup installs the global policy with the baseline paths added" + reported.recv().await, + Some(ConfigurationAdmissionState::Rejected) ); - let LoadedPolicyOrigin::Gateway { - revision: Some(revision), - .. - } = bundle.4 - else { - panic!("startup must bind the global policy to its gateway revision"); - }; - assert_eq!(revision.policy_source, PolicySource::Global); - } - - #[tokio::test(start_paused = true)] - async fn startup_policy_write_refusal_permanent_codes_still_exit() { - use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; - // No policy or provider change repairs these refusals, so startup - // keeps exiting instead of holding the sandbox in its repair window. - // Both writes are checked: the image policy upload to a gateway with - // no policy, and the write-back of a stored policy that lacks the - // proxy baseline paths. - let writes = [ - ( - "image policy upload", - unset_policy_snapshot(), - ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), - ), - ( - "baseline write-back", - settings_poll_result(Some(proto_tcp_policy_fixture()), 1, PolicySource::Sandbox), - ImagePolicyDiscovery::Missing, - ), - ]; - for (write, initial, discovery) in writes { - for code in [ - tonic::Code::PermissionDenied, - tonic::Code::NotFound, - tonic::Code::Unauthenticated, - ] { - let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); - let gateway = WriteRefusingStartupGateway { - 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"), - ]))), - repair_after_refusal: None, - rejection_report_errors: Arc::default(), - calls, - }; - let result = timeout( - Duration::from_mins(1), - load_policy_with_gateway( - Some("sandbox-id".to_string()), - Some("sandbox".to_string()), - Some("http://unused.invalid".to_string()), - None, - None, - &openshell_extension_core::ExtensionCredentialStore::new(), - LocalPolicyIdentity::Required, - None, - Some(discovery.clone()), - &gateway, - ), - ) + assert!( + !handle.is_finished(), + "rejected configuration must not return a launch bundle" + ); + assert_eq!( + timeout(Duration::from_secs(5), reported.recv()) .await - .expect("a permanent write error must end startup"); - let Err(error) = result else { - panic!("{write}: startup accepted a configuration after {code:?}"); - }; - assert!( - startup_error_chain(&error).contains("sandbox caller cannot write this policy"), - "{write}, {code:?}: {}", - startup_error_chain(&error) - ); - let mut trace = Vec::new(); - while let Ok(call) = observed.try_recv() { - trace.push(call); - } - assert!( - !trace.iter().any(|call| matches!( - call, - StartupCall::Report(report) - if report.state == ConfigurationAdmissionState::Rejected - )), - "{write}, {code:?} is not a configuration rejection; calls: {trace:?}" - ); - } + .unwrap(), + Some(ConfigurationAdmissionState::Rejected), + "unchanged rejection must still report readiness on subsequent polls" + ); + { + let mut desired = gateway.desired.lock().unwrap(); + desired.configuration_admitted = true; + desired.config_revision += 1; } - } - - #[test] - fn startup_policy_write_refusal_bounds_the_gateway_message() { - let refused = openshell_core::grpc_client::grpc_status_error( - tonic::Status::invalid_argument(format!("bad\nrule {}", "x".repeat(600))), - ) - .wrap_err("failed to sync policy to server"); - // The log key leaves out the gateway's message, so a refusal whose - // wording changes between attempts is logged once per snapshot. assert_eq!( - startup_write_rejection("image policy", &refused), - Some(StartupWriteRejection { - diagnostic: format!( - "Gateway rejected the image policy: badrule {}", - "x".repeat(504) - ), - log_key: "Gateway rejected the image policy (InvalidArgument)".to_owned(), - }) + timeout(Duration::from_secs(5), reported.recv()) + .await + .unwrap(), + Some(ConfigurationAdmissionState::Accepted) + ); + assert!( + !handle.is_finished(), + "a stale activation acknowledgement must not return a launch bundle" ); - // Exhausted transient retries and connection failures carry no status - // and stay fatal. assert_eq!( - startup_write_rejection( - "image policy", - &miette::miette!("Image policy synchronization failed after 5 attempts") - ), - None + timeout(Duration::from_secs(5), reported.recv()) + .await + .unwrap(), + Some(ConfigurationAdmissionState::Accepted) + ); + let bundle = timeout(Duration::from_secs(5), handle) + .await + .unwrap() + .unwrap() + .expect("repair returns one launch bundle"); + assert!( + bundle.7.is_some(), + "launch bundle retains matching provider state" ); } - fn startup_provider(revision: u64) -> openshell_core::grpc_client::ProviderEnvironmentResult { - openshell_core::grpc_client::ProviderEnvironmentResult { - files: std::collections::HashMap::new(), - provider_env_revision: revision, - provider_attachment_epoch: String::new(), - policy_hash: String::new(), - readiness_reason: ProviderReadinessReason::Unspecified, - environment: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - dynamic_credentials: std::collections::HashMap::new(), - static_credential_bindings: std::collections::HashMap::new(), - non_secret_environment_keys: Vec::new(), - stable_placeholder_environment_keys: Vec::new(), - } - } + // ---- Startup policy write refusal tests ---- - #[test] - fn startup_configuration_rejects_mixed_provider_revision() { - let policy = proto_policy_fixture(); - let mut snapshot = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - snapshot.provider_env_revision = 10; - assert!(prepare_startup_configuration(&snapshot, &policy, &startup_provider(11)).is_err()); - let (_, _, credentials) = - prepare_startup_configuration(&snapshot, &policy, &startup_provider(10)) - .expect("matching generation is admitted"); - assert_eq!(credentials.revision(), 10); - } + /// `UpdateConfig` diagnostic for a credential binding whose provider is not + /// attached to the sandbox. The gateway returns it as `FAILED_PRECONDITION`. + const UNATTACHED_PROVIDER_DIAGNOSTIC: &str = "credential_binding references provider 'github', but that provider is not attached to the sandbox"; - #[test] - fn startup_environment_seeds_provider_readiness() { - let mut provider = startup_provider(10); - provider.provider_attachment_epoch = "epoch".to_string(); - provider.policy_hash = "policy".to_string(); - let identity = EnvironmentIdentity::from_environment(&provider); - let credentials = prepare_provider_environment(&provider).unwrap(); - let readiness = ProviderReadinessTracker::new(); + /// The same refusal naming a second unattached provider of one policy. The + /// gateway reports whichever unattached provider it meets first, so one + /// policy can be refused with either message. + const UNATTACHED_SECOND_PROVIDER_DIAGNOSTIC: &str = "credential_binding references provider 'gitlab', but that provider is not attached to the sandbox"; - let credentials = - CapturedProviderEnvironment::new(credentials, &provider).install(&readiness); + /// `UpdateConfig` diagnostic for middleware configuration the gateway + /// cannot validate. The gateway returns it as `INVALID_ARGUMENT`. + const INVALID_MIDDLEWARE_DIAGNOSTIC: &str = + "policy middleware validation failed: binding 'redact' has an invalid config"; - assert_eq!(credentials.revision(), 10); - assert!(!readiness.needs_environment(&identity)); - } + /// `UpdateConfig` diagnostic for any sandbox policy write while a global + /// policy is active. The gateway returns it as `FAILED_PRECONDITION`. + const GLOBAL_POLICY_DIAGNOSTIC: &str = + "policy is managed globally; delete global policy before sandbox policy update"; - #[test] - fn startup_configuration_accepts_fail_closed_provider_environment() { - let policy = proto_policy_fixture(); - let mut snapshot = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - snapshot.provider_env_revision = 10; + /// One gateway call made by startup, recorded in call order. + #[derive(Clone, Debug, PartialEq, Eq)] + enum StartupCall { + Snapshot, + Sync, + Report(StartupReport), + } - for reason in [ - ProviderReadinessReason::CredentialsWithheld, - ProviderReadinessReason::CredentialExpired, - ] { - let mut provider = startup_provider(10); - provider.readiness_reason = reason; - provider - .environment - .insert("PROJECT_ID".to_string(), "example-project".to_string()); - provider - .non_secret_environment_keys - .push("PROJECT_ID".to_string()); - let (_, _, credentials) = prepare_startup_configuration(&snapshot, &policy, &provider) - .expect("withheld credentials preserve fail-closed startup"); - assert_eq!(credentials.revision(), 10); - assert!(credentials.snapshot().child_env.contains_key("PROJECT_ID")); - } + /// One admission report startup sent to the gateway. + #[derive(Clone, Debug, PartialEq, Eq)] + struct StartupReport { + state: openshell_core::proto::ConfigurationAdmissionState, + error: String, + /// Generation of the snapshot sent with the report. The gateway + /// answers `ABORTED` to a `Rejected` or `Accepted` report whose + /// generation is no longer current. + generation: Option, } - #[test] - fn startup_configuration_rejects_provider_installation_failure() { - let policy = proto_policy_fixture(); - let snapshot = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - let mut provider = startup_provider(0); - provider.readiness_reason = ProviderReadinessReason::CredentialInstallFailed; + /// `(version, policy_hash, config_revision, provider_env_revision)` of a + /// snapshot, the fields the gateway compares when it admits a report. + type ReportGeneration = (u32, String, u64, u64); - assert!(prepare_startup_configuration(&snapshot, &policy, &provider).is_err()); + fn report_generation( + snapshot: &openshell_core::grpc_client::SettingsPollResult, + ) -> ReportGeneration { + ( + snapshot.version, + snapshot.policy_hash.clone(), + snapshot.config_revision, + snapshot.provider_env_revision, + ) } - #[test] - fn startup_configuration_revalidates_on_restart_and_accepts_repair() { - let policy = proto_policy_fixture(); - let mut snapshot = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - for _restart in 0..2 { - snapshot.configuration_admitted = false; - assert!( - prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)).is_err() - ); - snapshot.configuration_admitted = true; - assert!( - prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)).is_ok() - ); - } + /// Snapshot of a sandbox with no stored policy, as the gateway returns it: + /// version 0 and an empty policy hash. The configuration and provider + /// revisions are nonzero, so a report built from any other snapshot names + /// a different generation. + fn unset_policy_snapshot() -> openshell_core::grpc_client::SettingsPollResult { + let mut snapshot = + settings_poll_result(None, 0, openshell_core::proto::PolicySource::Sandbox); + snapshot.policy_hash = String::new(); + snapshot.config_revision = 7; + snapshot.provider_env_revision = 3; + snapshot } - #[test] - fn startup_configuration_does_not_substitute_invalid_opa_policy() { - let mut policy = proto_tcp_policy_fixture(); - policy - .network_policies - .values_mut() - .next() - .expect("fixture has policy") - .endpoints[0] - .protocol = "invalid-protocol".to_string(); - let snapshot = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - assert!(prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)).is_err()); + /// A scripted refusal of one startup `UpdateConfig` write. + #[derive(Clone)] + struct SyncRefusal { + code: tonic::Code, + message: &'static str, + } + + impl SyncRefusal { + fn new(code: tonic::Code, message: &'static str) -> Self { + Self { code, message } + } } + /// Startup gateway double for the two startup policy writes: the image + /// policy upload and the baseline-path write-back. + /// + /// `sync` consumes the next scripted refusal and fails with its status, + /// wrapped the way the remote client wraps it. While the desired policy is + /// global, it refuses every write the way the gateway does. Otherwise, + /// without a refusal, it stores the payload as the next policy revision and + /// returns the new snapshot, as the gateway does for an accepted write. + /// Every call is recorded, including the generation each report names. + /// Reports for obsolete generations fail with `ABORTED`, as the gateway + /// requires. Tests can install a repair before a refused write returns or + /// script rejection-report errors to exercise retries and fatal failures. #[derive(Clone)] - struct ScriptedPolicyGateway { - polls: Arc< - tokio::sync::Mutex< - tokio::sync::mpsc::UnboundedReceiver< - openshell_core::grpc_client::SettingsPollResult, - >, - >, - >, - reports: UnboundedSender<(u32, bool, String)>, - environment_identity: Arc>, - polled_sandboxes: Arc>>, + struct WriteRefusingStartupGateway { + desired: Arc>, + refusals: Arc>>, + repair_after_refusal: Option, + rejection_report_errors: Arc>>, + calls: UnboundedSender, } - impl PolicyGatewayClient for ScriptedPolicyGateway { - async fn poll_settings( + impl StartupGateway for WriteRefusingStartupGateway { + async fn snapshot( &self, - sandbox: &str, + _sandbox: &str, ) -> Result { - self.polled_sandboxes.lock().await.push(sandbox.to_string()); - let result = self - .polls - .lock() - .await - .recv() - .await - .ok_or_else(|| miette::miette!("scripted policy poll channel closed"))?; - *self.environment_identity.lock().unwrap() = - EnvironmentIdentity::from_settings(&result); - Ok(result) + self.calls.send(StartupCall::Snapshot).unwrap(); + Ok(self.desired.lock().unwrap().clone()) } - - async fn fetch_provider_environment( + async fn provider( &self, - _endpoint: &str, - _sandbox_id: &str, - _requires_stable_placeholders: bool, + _id: &str, ) -> Result { - let identity = self.environment_identity.lock().unwrap().clone(); - let mut provider = startup_provider(identity.revision); - provider.provider_attachment_epoch = identity.attachment_epoch; - provider.policy_hash = identity.policy_hash; - if provider.policy_hash.is_empty() { - provider.readiness_reason = ProviderReadinessReason::SnapshotMismatch; + Ok(startup_provider( + self.desired.lock().unwrap().provider_env_revision, + )) + } + async fn sync( + &self, + _sandbox: &str, + policy: &openshell_core::proto::SandboxPolicy, + _workspace: &str, + ) -> 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 + // payload, so this refusal does not consume a scripted one. + let refusal = if desired.policy_source == openshell_core::proto::PolicySource::Global { + Some(SyncRefusal::new( + tonic::Code::FailedPrecondition, + GLOBAL_POLICY_DIAGNOSTIC, + )) + } else { + self.refusals.lock().unwrap().pop_front() + }; + if let Some(refusal) = refusal { + if let Some(repaired) = &self.repair_after_refusal { + *desired = repaired.clone(); + } + return Err( + openshell_core::grpc_client::grpc_status_error(tonic::Status::new( + refusal.code, + refusal.message, + )) + .wrap_err("failed to sync policy to server"), + ); } - Ok(provider) + let version = desired.version + 1; + *desired = settings_poll_result( + Some(policy.clone()), + version, + openshell_core::proto::PolicySource::Sandbox, + ); + Ok(desired.clone()) } - - async fn report_policy_status( + async fn report( &self, - _sandbox_id: &str, - version: u32, - loaded: bool, + _id: &str, + _instance_id: &str, + snapshot: Option<&openshell_core::grpc_client::SettingsPollResult>, + state: openshell_core::proto::ConfigurationAdmissionState, error: &str, ) -> Result<()> { - self.reports - .send((version, loaded, error.to_string())) - .map_err(|_| miette::miette!("scripted policy report channel closed")) + self.calls + .send(StartupCall::Report(StartupReport { + state, + error: error.to_owned(), + generation: snapshot.map(report_generation), + })) + .unwrap(); + if state == openshell_core::proto::ConfigurationAdmissionState::Rejected + && let Some(code) = self.rejection_report_errors.lock().unwrap().pop_front() + { + return Err( + openshell_core::grpc_client::grpc_status_error(tonic::Status::new( + code, + "rejection report failed", + )) + .wrap_err("failed to report sandbox configuration"), + ); + } + if matches!( + state, + openshell_core::proto::ConfigurationAdmissionState::Rejected + | openshell_core::proto::ConfigurationAdmissionState::Accepted + ) && snapshot.map(report_generation) + != Some(report_generation(&self.desired.lock().unwrap())) + { + return Err(openshell_core::grpc_client::grpc_status_error( + tonic::Status::aborted("configuration generation has changed"), + ) + .wrap_err("failed to report sandbox configuration")); + } + Ok(()) } + } - fn workspace(&self) -> String { - "test-workspace".to_string() + /// Waits for startup's next admission report and returns it together with + /// the snapshot and write calls startup made since the previous report. If + /// startup returns first, the test fails with startup's result. + async fn next_startup_report( + calls: &mut tokio::sync::mpsc::UnboundedReceiver, + startup: &mut tokio::task::JoinHandle>, + ) -> (Vec, StartupReport) { + let mut preceding = Vec::new(); + loop { + tokio::select! { + // Drain recorded calls before startup's result so a report sent + // just before startup returned is still observed. + biased; + call = calls.recv() => match call { + Some(StartupCall::Report(report)) => return (preceding, report), + Some(call) => preceding.push(call), + None => panic!("the test gateway call channel closed"), + }, + result = &mut *startup => match result.expect("startup task panicked") { + Ok(_) => panic!("startup accepted a configuration before the expected report"), + Err(error) => panic!( + "startup exited instead of reporting: {}", + startup_error_chain(&error) + ), + }, + } } } - #[derive(Clone)] - struct CredentialRejectingPolicyGateway { - inner: ScriptedPolicyGateway, - credential_requests: Arc, + /// Formats startup's error chain on one line. The gRPC status wrapper + /// displays the same text as the status it wraps, so repeats are dropped. + fn startup_error_chain(error: &miette::Report) -> String { + let mut causes: Vec = error.chain().map(ToString::to_string).collect(); + causes.dedup(); + causes.join(": ") } - impl PolicyGatewayClient for CredentialRejectingPolicyGateway { - async fn poll_settings( - &self, - sandbox: &str, - ) -> Result { - self.inner.poll_settings(sandbox).await - } - - async fn fetch_provider_environment( - &self, - endpoint: &str, - sandbox_id: &str, - requires_stable_placeholders: bool, - ) -> Result { - self.inner - .fetch_provider_environment(endpoint, sandbox_id, requires_stable_placeholders) - .await - } - - async fn report_policy_status( - &self, - sandbox_id: &str, - version: u32, - loaded: bool, - error: &str, - ) -> Result<()> { - self.inner - .report_policy_status(sandbox_id, version, loaded, error) + /// Starts a sandbox against a gateway that refuses the startup policy write + /// described by `write` once for each entry of `refusals`, then checks the + /// repair flow the policy documentation promises. On every refused pass, + /// startup reads a fresh snapshot, sends the write again, and reports + /// `Rejected` with the gateway's diagnostic against `initial`, the snapshot + /// the refused write was built from, while it keeps waiting. Once an + /// operator stores `repaired`, the next snapshot is accepted and startup + /// installs that policy. + async fn assert_refused_startup_write_waits_for_repair( + initial: openshell_core::grpc_client::SettingsPollResult, + discovery: ImagePolicyDiscovery, + write: &str, + refusals: Vec, + repaired: openshell_core::proto::SandboxPolicy, + ) { + use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + let refused_generation = report_generation(&initial); + let diagnostics: Vec = refusals + .iter() + .map(|refusal| format!("Gateway rejected the {write}: {}", refusal.message)) + .collect(); + let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = WriteRefusingStartupGateway { + desired: Arc::new(std::sync::Mutex::new(initial)), + refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from( + refusals, + ))), + repair_after_refusal: None, + rejection_report_errors: Arc::default(), + calls, + }; + let startup_gateway = gateway.clone(); + let mut startup = tokio::spawn(async move { + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(discovery), + &startup_gateway, + ) + .await + }); + // A regression that keeps looping without the expected report fails at + // this deadline instead of hanging; the paused clock makes it free. + timeout(Duration::from_mins(1), async { + let (_, registration) = next_startup_report(&mut observed, &mut startup).await; + assert_eq!(registration.state, ConfigurationAdmissionState::Pending); + for (pass, diagnostic) in diagnostics.into_iter().enumerate() { + let (preceding, report) = next_startup_report(&mut observed, &mut startup).await; + assert_eq!( + preceding, + [StartupCall::Snapshot, StartupCall::Sync], + "refused pass {pass}: startup must read a fresh snapshot and send the write again" + ); + assert_eq!( + report, + StartupReport { + state: ConfigurationAdmissionState::Rejected, + error: diagnostic, + generation: Some(refused_generation.clone()), + }, + "refused pass {pass}: the rejection must carry the gateway diagnostic and name the snapshot the refused write was built from" + ); + assert!( + !startup.is_finished(), + "refused pass {pass}: a refused startup write must leave the sandbox waiting for repair" + ); + } + { + let mut desired = gateway.desired.lock().unwrap(); + let version = desired.version + 1; + *desired = + settings_poll_result(Some(repaired.clone()), version, PolicySource::Sandbox); + } + let (_, accepted) = next_startup_report(&mut observed, &mut startup).await; + assert_eq!(accepted.state, ConfigurationAdmissionState::Accepted); + let bundle = (&mut startup) .await - } - - async fn extension_credentials_for( - &self, - _services: &[openshell_core::proto::SupervisorMiddlewareService], - ) -> Result> - { - self.credential_requests.fetch_add(1, Ordering::SeqCst); - Err(miette::miette!( - "gateway extension authentication is unavailable" - )) - } - - fn workspace(&self) -> String { - self.inner.workspace() - } + .unwrap() + .expect("the repaired configuration returns one launch bundle"); + assert_eq!( + bundle.2, + Some(repaired), + "startup must install the repaired policy" + ); + }) + .await + .expect("startup must report every refusal and then accept the repair"); } - fn scripted_policy_gateway() -> ( - ScriptedPolicyGateway, - UnboundedSender, - tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, - ) { - let (poll_tx, poll_rx) = tokio::sync::mpsc::unbounded_channel(); - let (report_tx, report_rx) = tokio::sync::mpsc::unbounded_channel(); - ( - ScriptedPolicyGateway { - polls: Arc::new(tokio::sync::Mutex::new(poll_rx)), - reports: report_tx, - environment_identity: Arc::new(std::sync::Mutex::new( - EnvironmentIdentity::default(), - )), - polled_sandboxes: Arc::new(tokio::sync::Mutex::new(Vec::new())), - }, - poll_tx, - report_rx, + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_failed_precondition_waits_for_repair() { + // The gateway has no policy, so startup uploads the image policy. + assert_refused_startup_write_waits_for_repair( + unset_policy_snapshot(), + ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), + "image policy", + vec![SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_PROVIDER_DIAGNOSTIC, + )], + proto_policy_fixture(), ) + .await; } - fn static_provider_environment( - revision: u64, - value: Option<&str>, - ) -> openshell_core::grpc_client::ProviderEnvironmentResult { - use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; - use std::collections::HashMap; - - let mut result = openshell_core::grpc_client::ProviderEnvironmentResult { - files: HashMap::new(), - environment: HashMap::new(), - provider_env_revision: revision, - provider_attachment_epoch: String::new(), - policy_hash: "hash-v1".to_string(), - readiness_reason: ProviderReadinessReason::Unspecified, - credential_expires_at_ms: HashMap::new(), - dynamic_credentials: HashMap::new(), - static_credential_bindings: HashMap::new(), - non_secret_environment_keys: Vec::new(), - stable_placeholder_environment_keys: Vec::new(), - }; - if let Some(value) = value { - result - .environment - .insert("EXTERNAL_TOKEN".into(), value.into()); - result.static_credential_bindings.insert( - "EXTERNAL_TOKEN".into(), - StaticCredentialBinding { - endpoints: vec![StaticCredentialEndpointBinding { - host: "tools.example.com".into(), - port: 443, - path: "/v1/**".into(), - }], - credential_identity: "provider-a:EXTERNAL_TOKEN".into(), - workload_credential_handle: String::new(), - }, - ); - } - result - } - - #[test] - fn initial_provider_credentials_preserve_revision_scoped_delivery() { - let readiness = ProviderReadinessTracker::new(); - let state = initial_provider_credentials( - static_provider_environment(1, Some("initial")), - &readiness, - ); - let (revision, child_env) = state.child_env_snapshot_with_gcp_resolved().unwrap(); - let reference = &child_env["EXTERNAL_TOKEN"]; - assert_eq!(revision, 1); - assert_eq!(reference, "openshell:resolve:env:v1_EXTERNAL_TOKEN"); - assert_eq!( - state - .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") - .unwrap() - .resolve_placeholder(reference), - Some("initial"), - ); - assert!(readiness.observation(&state).credentials_installed); - assert!(!readiness.observation(&state).launch_environment_installed); - // The boundary receives the prepared snapshot without gaining resolver authority. - let boundary = - ProviderCredentialState::from_child_env_snapshot(revision, child_env.clone()); - assert_eq!(boundary.snapshot().child_env, child_env); - assert!(boundary.resolver().is_none()); - - let mut invalid = static_provider_environment(2, Some("invalid")); - invalid.static_credential_bindings.clear(); - let rejected = initial_provider_credentials(invalid, &readiness); - assert!(rejected.snapshot().child_env.is_empty()); - assert!(rejected.resolver().is_none()); - assert_eq!( - readiness.observation(&rejected).reason, - i32::from(ProviderReadinessReason::CredentialInstallFailed) - ); + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_invalid_argument_waits_for_repair() { + assert_refused_startup_write_waits_for_repair( + unset_policy_snapshot(), + ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), + "image policy", + vec![SyncRefusal::new( + tonic::Code::InvalidArgument, + INVALID_MIDDLEWARE_DIAGNOSTIC, + )], + proto_policy_fixture(), + ) + .await; } - fn stable_provider_environment( - revision: u64, - value: Option<&str>, - ) -> openshell_core::grpc_client::ProviderEnvironmentResult { - let mut result = static_provider_environment(revision, value); - if let Some(binding) = result.static_credential_bindings.get_mut("EXTERNAL_TOKEN") { - binding.workload_credential_handle = "a".repeat(64); - result - .stable_placeholder_environment_keys - .push("EXTERNAL_TOKEN".into()); - } - result + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_baseline_write_back_waits_for_repair() { + // The gateway policy has network rules but lacks the proxy baseline + // paths, so startup writes an enriched copy back. The repaired policy + // already carries those paths and needs no write-back. + let mut repaired = proto_tcp_policy_fixture(); + assert!(enrich_proto_baseline_paths(&mut repaired)); + assert_refused_startup_write_waits_for_repair( + settings_poll_result( + Some(proto_tcp_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ), + ImagePolicyDiscovery::Missing, + "policy update that adds baseline filesystem paths", + vec![SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_PROVIDER_DIAGNOSTIC, + )], + repaired, + ) + .await; } - #[test] - fn initial_provider_credentials_preserve_stable_delivery_requirement() { - let readiness = ProviderReadinessTracker::new(); - let policy = proto_policy_fixture(); - let mut startup = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, + /// Install an operator's repair after the refused write but before its + /// rejection report. The obsolete report must lead straight to a new + /// snapshot, without resending the write or delaying the repaired launch. + async fn assert_startup_repair_before_rejection_refetches( + initial: openshell_core::grpc_client::SettingsPollResult, + discovery: ImagePolicyDiscovery, + repaired: openshell_core::proto::SandboxPolicy, + refusal: SyncRefusal, + write: &str, + ) { + use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + let initial_generation = report_generation(&initial); + let repaired_snapshot = settings_poll_result( + Some(repaired.clone()), + initial.version + 1, + PolicySource::Sandbox, ); - startup.provider_env_revision = 1; - let (_, _, prepared) = prepare_startup_configuration( - &startup, - &policy, - &stable_provider_environment(1, Some("initial")), + let repaired_generation = report_generation(&repaired_snapshot); + let diagnostic = format!("Gateway rejected the {write}: {}", refusal.message); + let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = WriteRefusingStartupGateway { + desired: Arc::new(std::sync::Mutex::new(initial)), + refusals: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from([ + refusal, + ]))), + repair_after_refusal: Some(repaired_snapshot), + rejection_report_errors: Arc::default(), + calls, + }; + let started = tokio::time::Instant::now(); + let bundle = timeout( + Duration::from_secs(30), + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(discovery), + &gateway, + ), ) - .expect("stable credentials prepare for admitted startup"); - assert!(prepared.requires_stable_placeholders()); - let state = initial_provider_credentials( - stable_provider_environment(1, Some("initial")), - &readiness, - ); - assert!(state.requires_stable_placeholders()); - assert!(readiness.observation(&state).credentials_installed); - let (revision, child_env) = state.child_env_snapshot_with_gcp_resolved().unwrap(); - assert_eq!(prepared.snapshot().child_env, child_env); - let reference = &child_env["EXTERNAL_TOKEN"]; - assert_eq!(revision, 1); - assert_eq!( - reference, - &format!("openshell:resolve:env:s{}_EXTERNAL_TOKEN", "a".repeat(64)) - ); + .await + .expect("a superseded rejection must not stall startup") + .unwrap_or_else(|error| { + panic!( + "a repair that precedes its rejection report must resume startup: {}", + startup_error_chain(&error) + ) + }); assert_eq!( - state - .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") - .unwrap() - .resolve_placeholder(reference), - Some("initial"), + started.elapsed(), + Duration::ZERO, + "a superseded rejection must fetch the repaired generation immediately" ); - // The remote workload receives the prepared snapshot, with no resolver - // authority and no second placeholder transformation at its boundary. - let boundary = - ProviderCredentialState::from_child_env_snapshot(revision, child_env.clone()); - assert_eq!(boundary.snapshot().child_env, child_env); - assert!(boundary.resolver().is_none()); - - let mut invalid = stable_provider_environment(2, Some("invalid")); - invalid - .static_credential_bindings - .get_mut("EXTERNAL_TOKEN") - .unwrap() - .workload_credential_handle - .clear(); - startup.provider_env_revision = 2; - assert!(prepare_startup_configuration(&startup, &policy, &invalid).is_err()); - let rejected = initial_provider_credentials(invalid, &readiness); - assert!(rejected.snapshot().child_env.is_empty()); - assert!(rejected.resolver().is_none()); + let mut trace = Vec::new(); + while let Ok(call) = observed.try_recv() { + trace.push(call); + } assert_eq!( - readiness.observation(&rejected).reason, - i32::from(ProviderReadinessReason::CredentialInstallFailed) + trace, + [ + StartupCall::Snapshot, + StartupCall::Report(StartupReport { + state: ConfigurationAdmissionState::Pending, + error: String::new(), + generation: Some(initial_generation.clone()), + }), + StartupCall::Snapshot, + StartupCall::Sync, + StartupCall::Report(StartupReport { + state: ConfigurationAdmissionState::Rejected, + error: diagnostic, + generation: Some(initial_generation), + }), + StartupCall::Snapshot, + StartupCall::Report(StartupReport { + state: ConfigurationAdmissionState::Accepted, + error: String::new(), + generation: Some(repaired_generation), + }), + ], + "startup must discard the obsolete rejection and validate the repair" ); + assert_eq!(bundle.2, Some(repaired)); } - type ProviderFetchRequest = ( - bool, - tokio::sync::oneshot::Sender< - Result, - >, - ); - - #[derive(Clone)] - struct ScriptedProviderGateway { - policy: ScriptedPolicyGateway, - requests: UnboundedSender, - } - - impl PolicyGatewayClient for ScriptedProviderGateway { - async fn poll_settings( - &self, - sandbox: &str, - ) -> Result { - self.policy.poll_settings(sandbox).await - } - - async fn report_policy_status( - &self, - sandbox_id: &str, - version: u32, - loaded: bool, - error: &str, - ) -> Result<()> { - self.policy - .report_policy_status(sandbox_id, version, loaded, error) - .await - } - - async fn fetch_provider_environment( - &self, - _endpoint: &str, - sandbox_id: &str, - requires_stable_placeholders: bool, - ) -> Result { - assert_eq!(sandbox_id, "sandbox-test"); - let (response, received) = tokio::sync::oneshot::channel(); - self.requests - .send((requires_stable_placeholders, response)) - .map_err(|_| miette::miette!("provider request channel closed"))?; - received - .await - .map_err(|_| miette::miette!("provider response channel closed"))? - } - - fn workspace(&self) -> String { - self.policy.workspace() - } - } - - #[tokio::test] - async fn provider_readiness_initial_poll_waits_for_middleware_reconciliation() { - let policy = proto_policy_fixture(); - let mut settings = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - settings.provider_attachment_epoch = "epoch".to_string(); - settings.provider_env_revision = 9; - settings.supervisor_middleware_services = - vec![openshell_core::proto::SupervisorMiddlewareService { - name: "scripted-guard".to_string(), - grpc_endpoint: "http://scripted.invalid".to_string(), - ..Default::default() - }]; - let (attempt_tx, mut attempts) = tokio::sync::mpsc::unbounded_channel(); - let (complete, completions) = tokio::sync::mpsc::unbounded_channel::(); - let completions = Arc::new(tokio::sync::Mutex::new(completions)); - let connector: MiddlewareConnector = Arc::new(move |services, _authentication| { - assert_eq!(services.len(), 1); - assert_eq!(services[0].name, "scripted-guard"); - attempt_tx.send(()).unwrap(); - let completions = completions.clone(); - Box::pin(async move { - if completions.lock().await.recv().await.unwrap() { - connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await - } else { - Err(miette::miette!("scripted middleware connection failure")) - } - }) - }); - let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); - let mut context = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), - has_last_valid_policy: true, - }, - connector, - ); - context.middleware_registry_status = MiddlewareRegistryStatus::NeedsReconciliation; - context.provider_credentials = ProviderCredentialState::from_child_env_snapshot( - 9, - std::collections::HashMap::default(), - ); - let credentials = context.provider_credentials.clone(); - let tracker = context.provider_readiness.clone(); - tracker.credentials_installed( - EnvironmentIdentity::from_settings(&settings), - &credentials, - None, - ); - let (gateway, polls, mut reports) = scripted_policy_gateway(); - let task = tokio::spawn(run_policy_poll_loop_with_client(context, gateway)); - polls.send(settings.clone()).unwrap(); - timeout(Duration::from_secs(1), attempts.recv()) - .await - .expect("initial snapshot must reconcile without a second poll") - .unwrap(); - let observed = tracker.observation(&credentials); - assert!(observed.credentials_installed); - assert!(!observed.policy_active); - assert!( - !observed.launch_environment_installed, - "process evidence requires its own boundary acknowledgment" - ); - expect_no_policy_report(&mut reports).await; - - complete.send(false).unwrap(); - timeout(Duration::from_secs(1), async { - while tracker.observation(&credentials).reason - != i32::from(ProviderReadinessReason::PolicyActivationFailed) - { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert!(!tracker.observation(&credentials).policy_active); - assert_eq!(engine.current_generation(), 0); - expect_no_policy_report(&mut reports).await; - - polls.send(settings).unwrap(); - timeout(Duration::from_secs(1), attempts.recv()) - .await - .unwrap() - .unwrap(); - assert!(!tracker.observation(&credentials).policy_active); - complete.send(true).unwrap(); - expect_policy_report(&mut reports, 1).await; - assert!(tracker.observation(&credentials).policy_active); - assert_eq!(engine.current_generation(), 1); - task.abort(); - let _ = task.await; + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_image_repair_before_report_refetches() { + Box::pin(assert_startup_repair_before_rejection_refetches( + unset_policy_snapshot(), + ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), + proto_policy_fixture(), + SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_PROVIDER_DIAGNOSTIC, + ), + "image policy", + )) + .await; } - #[derive(Clone)] - struct GenerationChangingPolicyGateway { - inner: ScriptedPolicyGateway, - engine: Arc, - first_poll: Arc, + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_baseline_repair_before_report_refetches() { + let mut repaired = proto_tcp_policy_fixture(); + assert!(enrich_proto_baseline_paths(&mut repaired)); + Box::pin(assert_startup_repair_before_rejection_refetches( + settings_poll_result( + Some(proto_tcp_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ), + ImagePolicyDiscovery::Missing, + repaired, + SyncRefusal::new(tonic::Code::InvalidArgument, INVALID_MIDDLEWARE_DIAGNOSTIC), + "policy update that adds baseline filesystem paths", + )) + .await; } - impl PolicyGatewayClient for GenerationChangingPolicyGateway { - async fn poll_settings( - &self, - sandbox: &str, - ) -> Result { - let result = self.inner.poll_settings(sandbox).await?; - if self.first_poll.swap(false, Ordering::SeqCst) { - self.engine - .enter_fail_closed("generation replaced while first poll was pending")?; - } - Ok(result) - } - - async fn fetch_provider_environment( - &self, - endpoint: &str, - sandbox_id: &str, - requires_stable_placeholders: bool, - ) -> Result { - self.inner - .fetch_provider_environment(endpoint, sandbox_id, requires_stable_placeholders) - .await - } - - async fn report_policy_status( - &self, - sandbox_id: &str, - version: u32, - loaded: bool, - error: &str, - ) -> Result<()> { - self.inner - .report_policy_status(sandbox_id, version, loaded, error) - .await + /// Exercise rejection reporting directly so transport and identity errors + /// cannot be mistaken for policy-write failures or registration failures. + async fn startup_rejection_report_with_errors( + errors: Vec, + ) -> (Result<()>, Vec, Duration, StartupRejectionLog) { + let snapshot = unset_policy_snapshot(); + let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = WriteRefusingStartupGateway { + desired: Arc::new(std::sync::Mutex::new(snapshot.clone())), + refusals: Arc::default(), + repair_after_refusal: None, + rejection_report_errors: Arc::new(std::sync::Mutex::new(errors.into())), + calls, + }; + let mut log = StartupRejectionLog::default(); + let started = tokio::time::Instant::now(); + let result = reject_startup_configuration( + &gateway, + &mut log, + "sandbox-id", + "instance-id", + &snapshot, + "policy write refused", + None, + ) + .await; + let mut trace = Vec::new(); + while let Ok(call) = observed.try_recv() { + trace.push(call); } + assert!(trace.iter().all(|call| matches!( + call, + StartupCall::Report(report) + if report.state == openshell_core::proto::ConfigurationAdmissionState::Rejected + && report.generation == Some(report_generation(&snapshot)) + && report.error == "policy write refused" + ))); + (result, trace, started.elapsed(), log) + } - fn workspace(&self) -> String { - self.inner.workspace() - } + #[tokio::test(start_paused = true)] + async fn startup_rejection_report_aborted_after_transient_failure_refetches() { + let (result, trace, elapsed, log) = startup_rejection_report_with_errors(vec![ + tonic::Code::Unavailable, + tonic::Code::Aborted, + ]) + .await; + result.expect("a superseded report must return to reconciliation"); + assert_eq!(trace.len(), 2, "an obsolete report must not be resent"); + assert_eq!(elapsed, Duration::from_secs(1)); + assert!(log.0.is_none(), "an obsolete rejection must not be logged"); } - #[tokio::test] - async fn provider_readiness_initial_poll_reconciles_a_replaced_policy_generation() { - let policy = proto_policy_fixture(); - let settings = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); - let context = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let credentials = context.provider_credentials.clone(); - let tracker = context.provider_readiness.clone(); - tracker.credentials_installed( - EnvironmentIdentity::from_settings(&settings), - &credentials, - None, - ); - let (inner, polls, mut reports) = scripted_policy_gateway(); - let gateway = GenerationChangingPolicyGateway { - inner, - engine: engine.clone(), - first_poll: Arc::new(AtomicBool::new(true)), - }; - let task = tokio::spawn(run_policy_poll_loop_with_client(context, gateway)); - polls.send(settings).unwrap(); - expect_policy_report(&mut reports, 1).await; - assert!( - engine.fail_closed_reason().is_none(), - "the delivered policy must replace the intervening quarantine before acknowledgment" - ); + #[tokio::test(start_paused = true)] + async fn startup_rejection_report_transient_failure_retries_and_logs() { + let (result, trace, elapsed, log) = + startup_rejection_report_with_errors(vec![tonic::Code::Unavailable]).await; + result.expect("a transient report failure must retry"); + assert_eq!(trace.len(), 2); + assert_eq!(elapsed, Duration::from_secs(3)); assert_eq!( - engine.current_generation(), - 2, - "generation 1 was not the policy startup installed" + log.0, + Some(( + LoadedPolicyRevision::from_snapshot(&unset_policy_snapshot()), + "policy write refused".to_string(), + )), + "a recorded rejection must retain its diagnostic for log suppression" ); - assert!(tracker.observation(&credentials).policy_active); - task.abort(); - let _ = task.await; } - #[tokio::test] - async fn provider_readiness_poll_waits_for_installation_and_retries_same_fingerprint() { - let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); - let mut initial = settings_poll_result( - Some(proto_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - initial.provider_env_revision = 6; - let ctx = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let credentials = ctx.provider_credentials.clone(); - let tracker = ctx.provider_readiness.clone(); - let (policy, polls, mut reports) = scripted_policy_gateway(); - let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); - polls.send(initial.clone()).unwrap(); - let task = tokio::spawn(run_policy_poll_loop_with_client( - ctx, - ScriptedProviderGateway { policy, requests }, - )); - expect_policy_report(&mut reports, 1).await; - - polls.send(initial.clone()).unwrap(); - let (_, response) = timeout(Duration::from_secs(1), received.recv()) - .await - .unwrap() - .unwrap(); - assert!( - !tracker.observation(&credentials).credentials_installed, - "fetching desired credentials does not install them" - ); - assert!(response.send(Err(miette::miette!("unavailable"))).is_ok()); - timeout(Duration::from_secs(1), async { - while tracker.observation(&credentials).reason - != i32::from(ProviderReadinessReason::CredentialInstallFailed) - { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let failed_id = credentials.snapshot().installation_id.clone(); + #[tokio::test(start_paused = true)] + async fn startup_rejection_report_transient_exhaustion_stays_fatal() { + let (result, trace, elapsed, log) = + startup_rejection_report_with_errors(vec![tonic::Code::Unavailable; 5]).await; + let error = result.expect_err("exhausted report retries must terminate startup"); + assert!(error.to_string().contains("failed after 5 attempts")); + assert_eq!(trace.len(), 5); + assert_eq!(elapsed, Duration::from_secs(11)); + assert!(log.0.is_none()); + } - polls.send(initial.clone()).unwrap(); - let (_, response) = timeout(Duration::from_secs(1), received.recv()) - .await - .unwrap() - .unwrap(); - assert!( - response - .send(Ok(static_provider_environment(6, Some("repaired")))) - .is_ok() - ); - timeout(Duration::from_secs(1), async { - while !tracker.observation(&credentials).credentials_installed { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let observed = tracker.observation(&credentials); - assert_ne!(failed_id, credentials.snapshot().installation_id); - assert!(observed.policy_active); - assert!( - !observed.launch_environment_installed, - "a separate boundary acknowledgment is still required" - ); + #[tokio::test(start_paused = true)] + async fn startup_rejection_report_permanent_failure_stays_fatal() { + for code in [ + tonic::Code::FailedPrecondition, + tonic::Code::InvalidArgument, + tonic::Code::PermissionDenied, + tonic::Code::NotFound, + tonic::Code::Unauthenticated, + ] { + let (result, trace, elapsed, log) = + startup_rejection_report_with_errors(vec![code]).await; + let error = result.expect_err("a permanent report failure must terminate startup"); + assert!(error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|status| status.code() == code) + })); + assert_eq!(trace.len(), 1, "{code:?} must not be retried"); + assert_eq!(elapsed, Duration::ZERO); + assert!(log.0.is_none()); + } + } - // A policy change can preserve the provider content fingerprint while - // changing the credential authority. Fetch and install that identity too. - let mut changed = initial; - changed.version = 2; - changed.config_revision = 200; - changed.policy_hash = "hash-v2".to_string(); - polls.send(changed).unwrap(); - let (_, response) = timeout(Duration::from_secs(1), received.recv()) - .await - .unwrap() - .unwrap(); - assert!(!tracker.observation(&credentials).policy_active); - let mut environment = static_provider_environment(6, Some("repaired")); - environment.policy_hash = "hash-v2".to_string(); - assert!(response.send(Ok(environment)).is_ok()); - expect_policy_report(&mut reports, 2).await; - let observed = tracker.observation(&credentials); - assert!(observed.credentials_installed && observed.policy_active); - assert_eq!(observed.provider_env_revision, 6); - assert_eq!(observed.config_revision, 200); - assert_eq!(observed.policy_hash, "hash-v2"); - task.abort(); - let _ = task.await; + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_image_upload_outlasts_the_attempt_limit() { + // Startup gives up after five passes that neither accept nor reject a + // configuration. A refused upload is a rejection, so the same refusal + // repeated seven times must keep the sandbox waiting for repair. + assert_refused_startup_write_waits_for_repair( + unset_policy_snapshot(), + ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), + "image policy", + vec![ + SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_PROVIDER_DIAGNOSTIC + ); + 7 + ], + proto_policy_fixture(), + ) + .await; } - #[tokio::test] - async fn provider_readiness_startup_environment_avoids_unchanged_refresh() { - let policy = proto_policy_fixture(); - let mut settings = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - settings.provider_env_revision = 6; - let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); - let mut ctx = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&settings)), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let mut provider = static_provider_environment(6, Some("initial")); - provider.policy_hash.clone_from(&settings.policy_hash); - let credentials = prepare_provider_environment(&provider).unwrap(); - ctx.provider_credentials = CapturedProviderEnvironment::new(credentials, &provider) - .install(&ctx.provider_readiness); - let generation = engine.current_generation(); - let guard = engine.generation_guard(generation).unwrap(); - let (policy_gateway, polls, mut reports) = scripted_policy_gateway(); - let observed_polls = policy_gateway.polled_sandboxes.clone(); - let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); - let task = tokio::spawn(run_policy_poll_loop_with_client( - ctx, - ScriptedProviderGateway { - policy: policy_gateway, - requests, - }, - )); + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_write_back_outlasts_the_attempt_limit() { + // The same limit applies to a refused baseline-path write-back. + let mut repaired = proto_tcp_policy_fixture(); + assert!(enrich_proto_baseline_paths(&mut repaired)); + assert_refused_startup_write_waits_for_repair( + settings_poll_result( + Some(proto_tcp_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ), + ImagePolicyDiscovery::Missing, + "policy update that adds baseline filesystem paths", + vec![ + SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_PROVIDER_DIAGNOSTIC + ); + 7 + ], + repaired, + ) + .await; + } - polls.send(settings.clone()).unwrap(); - expect_policy_report(&mut reports, 1).await; - polls.send(settings).unwrap(); - timeout(Duration::from_secs(1), async { - while observed_polls.lock().await.len() < 2 { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); + /// Collects the sandbox log lines that a test's OCSF events render to. + #[derive(Clone, Default)] + struct CapturedLog(Arc>>); - assert!( - timeout(Duration::from_millis(50), received.recv()) - .await - .is_err(), - "unchanged settings must not refetch the startup environment" - ); - assert_eq!(engine.current_generation(), generation); - assert!(!guard.is_stale()); - task.abort(); - let _ = task.await; + impl std::io::Write for CapturedLog { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } } - #[tokio::test] - async fn provider_poll_installs_fail_closed_environment_and_acknowledges_policy() { - let policy = proto_policy_fixture(); - let initial = settings_poll_result( - Some(policy.clone()), - 1, - openshell_core::proto::PolicySource::Sandbox, + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_logs_a_varying_gateway_message_once() { + use tracing_subscriber::layer::SubscriberExt as _; + use tracing_subscriber::util::SubscriberInitExt as _; + // One policy with two unattached providers is refused with either + // provider's name on each attempt. The snapshot does not change, so the + // sandbox log must record the refusal once, with the first message, + // while every report still carries the message of its own attempt. + let log = CapturedLog::default(); + // Keep two dispatchers alive so tracing consults registered subscribers + // when caching callsite interest. With only one, a parallel test thread + // without a default subscriber can cache `Interest::never` for our events. + let _other_dispatch = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default()); + let _subscriber = tracing_subscriber::registry() + .with(openshell_ocsf::OcsfShorthandLayer::new(log.clone()).with_non_ocsf(false)) + .set_default(); + let first = SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_PROVIDER_DIAGNOSTIC, ); - let engine = Arc::new(OpaEngine::from_proto(&policy).unwrap()); - let ctx = policy_poll_test_context( - engine, - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), - has_last_valid_policy: true, - }, - default_middleware_connector(), + let second = SyncRefusal::new( + tonic::Code::FailedPrecondition, + UNATTACHED_SECOND_PROVIDER_DIAGNOSTIC, ); - let credentials = ctx.provider_credentials.clone(); - let (policy_gateway, polls, mut reports) = scripted_policy_gateway(); - let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); - polls.send(initial).unwrap(); - let task = tokio::spawn(run_policy_poll_loop_with_client( - ctx, - ScriptedProviderGateway { - policy: policy_gateway, - requests, - }, - )); - expect_policy_report(&mut reports, 1).await; - - let mut changed = settings_poll_result( - Some(policy), - 2, - openshell_core::proto::PolicySource::Sandbox, + assert_refused_startup_write_waits_for_repair( + unset_policy_snapshot(), + ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), + "image policy", + vec![first.clone(), second.clone(), first, second], + proto_policy_fixture(), + ) + .await; + let log = String::from_utf8(log.0.lock().unwrap().clone()).unwrap(); + let logged: Vec<&str> = log + .lines() + .filter(|line| line.contains("Gateway rejected the image policy")) + .collect(); + assert_eq!( + logged.len(), + 1, + "an unchanged refusal must be logged once; log:\n{log}" ); - changed.provider_env_revision = 1; - polls.send(changed).unwrap(); - let (_, response) = timeout(Duration::from_secs(1), received.recv()) - .await - .expect("provider refresh requested") - .expect("poll loop active"); - let mut withheld = static_provider_environment(1, None); - withheld.policy_hash = "hash-v2".to_string(); - withheld.readiness_reason = ProviderReadinessReason::CredentialsWithheld; - withheld - .environment - .insert("PROJECT_ID".to_string(), "example-project".to_string()); - withheld - .non_secret_environment_keys - .push("PROJECT_ID".to_string()); - assert!(response.send(Ok(withheld)).is_ok()); - - expect_policy_report(&mut reports, 2).await; - assert_eq!(credentials.revision(), 1); assert!( - credentials.snapshot().child_env.contains_key("PROJECT_ID"), - "the reduced snapshot retains non-secret provider configuration" + logged[0].contains(UNATTACHED_PROVIDER_DIAGNOSTIC), + "the log must keep the gateway's first message; log:\n{log}" ); - - task.abort(); - let _ = task.await; } - #[tokio::test] - async fn provider_poll_preserves_static_references_across_rotation_failure_and_detach() { - let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); - let initial = settings_poll_result( - Some(proto_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - let ctx = policy_poll_test_context( - engine, - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let state = ctx.provider_credentials.clone(); - let tracker = ctx.provider_readiness.clone(); - let (policy, polls, mut reports) = scripted_policy_gateway(); - let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); - polls.send(initial.clone()).unwrap(); - let task = tokio::spawn(run_policy_poll_loop_with_client( - ctx, - ScriptedProviderGateway { policy, requests }, - )); - expect_policy_report(&mut reports, 1).await; - - // Rotation gives future processes a new reference; the retained old - // reference continues to resolve its original value until revocation. - let old_reference = "openshell:resolve:env:v1_EXTERNAL_TOKEN"; - for (revision, value, fail) in [ - (1, Some("initial"), false), - (2, Some("rotated"), false), - (3, None, true), - (3, Some("recovered"), false), - (4, None, false), - (5, None, false), - ] { - let mut poll = initial.clone(); - poll.provider_env_revision = revision; - polls.send(poll).unwrap(); - let (_, response) = timeout(Duration::from_secs(5), received.recv()) - .await - .expect("provider refresh requested") - .expect("poll loop active"); - let result = if fail { - Err(miette::miette!("provider snapshot unavailable")) - } else { - Ok(static_provider_environment(revision, value)) - }; - assert!(response.send(result).is_ok()); - let reference = format!("openshell:resolve:env:v{revision}_EXTERNAL_TOKEN"); - timeout(Duration::from_secs(5), async { - loop { - let resolved = state - .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") - .and_then(|resolver| { - resolver.resolve_placeholder(&reference).map(str::to_owned) - }); - let observed = tracker.observation(&state); - if state.revision() == revision - && resolved.as_deref() == value - && (fail || observed.credentials_installed) - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("provider snapshot installed or revoked"); - if value.is_some() { - assert_eq!(state.snapshot().child_env["EXTERNAL_TOKEN"], reference); - if revision == 2 { - assert_ne!(reference, old_reference); - assert_eq!( - state - .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") - .unwrap() - .resolve_placeholder(old_reference), - Some("initial") - ); - } - } else { - assert!(state.snapshot().child_env.is_empty()); - assert!(state.resolver().is_none()); - } + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_global_policy_skips_baseline_write_back() { + 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 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 { + desired: Arc::new(std::sync::Mutex::new(global)), + refusals: Arc::default(), + repair_after_refusal: None, + rejection_report_errors: Arc::default(), + calls, + }; + let bundle = timeout( + Duration::from_mins(1), + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(ImagePolicyDiscovery::Missing), + &gateway, + ), + ) + .await + .expect("startup must not wait for repair under a global policy") + .unwrap_or_else(|error| { + panic!( + "startup exited under a global policy: {}", + startup_error_chain(&error) + ) + }); + let mut trace = Vec::new(); + while let Ok(call) = observed.try_recv() { + trace.push(call); } - task.abort(); - let _ = task.await; - } - - #[tokio::test] - async fn provider_poll_preserves_stable_delivery_across_rotation_failure_and_detach() { - let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); - let initial = settings_poll_result( - Some(proto_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, + let report = |state| { + StartupCall::Report(StartupReport { + state, + error: String::new(), + generation: Some(global_generation.clone()), + }) + }; + assert_eq!( + trace, + [ + StartupCall::Snapshot, + report(ConfigurationAdmissionState::Pending), + StartupCall::Snapshot, + report(ConfigurationAdmissionState::Accepted), + ], + "startup must not write a global policy back as a sandbox policy" ); - let ctx = policy_poll_test_context( - engine, - LoadedPolicyOrigin::Gateway { - revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), - has_last_valid_policy: true, - }, - default_middleware_connector(), + assert_eq!( + bundle.2, + Some(enriched), + "startup installs the global policy with the baseline paths added" ); - let state = ctx.provider_credentials.clone(); - let tracker = ctx.provider_readiness.clone(); - let (policy, polls, mut reports) = scripted_policy_gateway(); - let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); - // Startup acknowledges the policy already loaded before the refresh - // loop consumes later provider revisions. - polls.send(initial.clone()).unwrap(); - let task = tokio::spawn(run_policy_poll_loop_with_client( - ctx, - ScriptedProviderGateway { policy, requests }, - )); - expect_policy_report(&mut reports, 1).await; - let reference = format!("openshell:resolve:env:s{}_EXTERNAL_TOKEN", "a".repeat(64)); - - // A failed refresh must remain eligible for retry at the same revision, - // carrying the prior support requirement despite revoking its secrets. - for (revision, value, fail, required_before, required_after) in [ - (1, Some("initial"), false, false, true), - (2, Some("rotated"), false, true, true), - (3, None, true, true, true), - (3, Some("recovered"), false, true, true), - (4, None, false, true, false), - (5, None, false, false, false), - ] { - let mut poll = initial.clone(); - poll.provider_env_revision = revision; - polls.send(poll).unwrap(); - let (required, response) = timeout(Duration::from_secs(5), received.recv()) - .await - .expect("provider refresh requested") - .expect("poll loop active"); - assert_eq!(required, required_before, "revision {revision}"); - let result = if fail { - Err(miette::miette!("gateway support acknowledgment missing")) - } else { - Ok(stable_provider_environment(revision, value)) - }; - assert!(response.send(result).is_ok()); - timeout(Duration::from_secs(5), async { - loop { - let resolved = state - .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") - .and_then(|resolver| { - resolver.resolve_placeholder(&reference).map(str::to_owned) - }); - let observed = tracker.observation(&state); - if state.revision() == revision - && resolved.as_deref() == value - && (fail || observed.credentials_installed) - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("provider snapshot installed or revoked"); - assert_eq!(state.requires_stable_placeholders(), required_after); - if value.is_some() { - assert_eq!(state.snapshot().child_env["EXTERNAL_TOKEN"], reference); - } else { - assert!(state.snapshot().child_env.is_empty()); - assert!(state.resolver().is_none()); - } - } - task.abort(); - let _ = task.await; - } - - fn policy_poll_test_context( - opa_engine: Arc, - loaded_policy_origin: LoadedPolicyOrigin, - middleware_connector: MiddlewareConnector, - ) -> PolicyPollLoopContext { - let (workspace_tx, _workspace_rx) = tokio::sync::watch::channel(String::new()); - let provider_credentials = - ProviderCredentialState::from_child_env_snapshot(0, std::collections::HashMap::new()); - let provider_readiness = ProviderReadinessTracker::new(); - // This fixture models a successfully installed initial launch snapshot. - if let LoadedPolicyOrigin::Gateway { + let LoadedPolicyOrigin::Gateway { revision: Some(revision), .. - } = &loaded_policy_origin - { - provider_readiness.credentials_installed( - EnvironmentIdentity { - revision: 0, - attachment_epoch: String::new(), - policy_hash: revision.policy_hash.clone(), - }, - &provider_credentials, - None, - ); - } - PolicyPollLoopContext { - endpoint: String::new(), - sandbox_id: "sandbox-test".to_string(), - sandbox: "sandbox-test-name".to_string(), - opa_engine, - loaded_policy_origin, - vm_identity: None, - entrypoint_pid: Arc::new(AtomicU32::new(0)), - interval_secs: 0, - ocsf_enabled: Arc::new(AtomicBool::new(false)), - ocsf_schema_version: Arc::new(std::sync::Mutex::new(String::new())), - provider_credentials, - provider_readiness, - policy_local_ctx: None, - agent_proposals: AgentProposals::default(), - middleware_registry_status: MiddlewareRegistryStatus::Synchronized, - workspace_tx, - extension_credentials: openshell_extension_core::ExtensionCredentialStore::new(), - extension_authentication_enabled: false, - middleware_connector, - endpoint_observation_tx: None, - endpoint_status_rx: None, - endpoint_policy: None, - supervisor_session_id: tokio::sync::watch::channel(None).1, - } - } - - async fn expect_policy_report( - reports: &mut tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, - version: u32, - ) { - let report = timeout(Duration::from_secs(1), reports.recv()) - .await - .expect("policy report timed out") - .expect("policy reporter stopped"); - assert_eq!(report, (version, true, String::new())); + } = bundle.4 + else { + panic!("startup must bind the global policy to its gateway revision"); + }; + assert_eq!(revision.policy_source, PolicySource::Global); } - async fn expect_no_policy_report( - reports: &mut tokio::sync::mpsc::UnboundedReceiver<(u32, bool, String)>, - ) { - assert!( - timeout(Duration::from_millis(50), reports.recv()) + #[tokio::test(start_paused = true)] + async fn startup_policy_write_refusal_permanent_codes_still_exit() { + use openshell_core::proto::{ConfigurationAdmissionState, PolicySource}; + // No policy or provider change repairs these refusals, so startup + // keeps exiting instead of holding the sandbox in its repair window. + // Both writes are checked: the image policy upload to a gateway with + // no policy, and the write-back of a stored policy that lacks the + // proxy baseline paths. + let writes = [ + ( + "image policy upload", + unset_policy_snapshot(), + ImagePolicyDiscovery::Policy(Box::new(proto_tcp_policy_fixture())), + ), + ( + "baseline write-back", + settings_poll_result(Some(proto_tcp_policy_fixture()), 1, PolicySource::Sandbox), + ImagePolicyDiscovery::Missing, + ), + ]; + for (write, initial, discovery) in writes { + for code in [ + tonic::Code::PermissionDenied, + tonic::Code::NotFound, + tonic::Code::Unauthenticated, + ] { + let (calls, mut observed) = tokio::sync::mpsc::unbounded_channel(); + let gateway = WriteRefusingStartupGateway { + 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"), + ]))), + repair_after_refusal: None, + rejection_report_errors: Arc::default(), + calls, + }; + let result = timeout( + Duration::from_mins(1), + load_policy_with_gateway( + Some("sandbox-id".to_string()), + Some("sandbox".to_string()), + Some("http://unused.invalid".to_string()), + None, + None, + &openshell_extension_core::ExtensionCredentialStore::new(), + LocalPolicyIdentity::Required, + None, + Some(discovery.clone()), + &gateway, + ), + ) .await - .is_err(), - "unexpected policy status report" - ); - } - - #[tokio::test] - async fn same_hash_poll_revision_is_acknowledged_once_without_opa_reload() { - let mut v1 = settings_poll_result( - Some(proto_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - v1.policy_hash = "same-policy".to_string(); - let mut v2 = v1.clone(); - v2.version = 2; - v2.config_revision = 200; - - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); - let ctx = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_revision), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let (client, polls, mut reports) = scripted_policy_gateway(); - let observed_client = client.clone(); - polls.send(v1).unwrap(); + .expect("a permanent write error must end startup"); + let Err(error) = result else { + panic!("{write}: startup accepted a configuration after {code:?}"); + }; + assert!( + startup_error_chain(&error).contains("sandbox caller cannot write this policy"), + "{write}, {code:?}: {}", + startup_error_chain(&error) + ); + let mut trace = Vec::new(); + while let Ok(call) = observed.try_recv() { + trace.push(call); + } + assert!( + !trace.iter().any(|call| matches!( + call, + StartupCall::Report(report) + if report.state == ConfigurationAdmissionState::Rejected + )), + "{write}, {code:?} is not a configuration rejection; calls: {trace:?}" + ); + } + } + } - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - expect_policy_report(&mut reports, 1).await; + #[test] + fn startup_policy_write_refusal_bounds_the_gateway_message() { + let refused = openshell_core::grpc_client::grpc_status_error( + tonic::Status::invalid_argument(format!("bad\nrule {}", "x".repeat(600))), + ) + .wrap_err("failed to sync policy to server"); + // The log key leaves out the gateway's message, so a refusal whose + // wording changes between attempts is logged once per snapshot. assert_eq!( - observed_client.polled_sandboxes.lock().await.as_slice(), - &["sandbox-test-name"], - "settings polling must use the canonical sandbox reference, not its ID" + startup_write_rejection("image policy", &refused), + Some(StartupWriteRejection { + diagnostic: format!( + "Gateway rejected the image policy: badrule {}", + "x".repeat(504) + ), + log_key: "Gateway rejected the image policy (InvalidArgument)".to_owned(), + }) ); - - polls.send(v2.clone()).unwrap(); - expect_policy_report(&mut reports, 2).await; - polls.send(v2).unwrap(); - expect_no_policy_report(&mut reports).await; - + // Exhausted transient retries and connection failures carry no status + // and stay fatal. assert_eq!( - engine.current_generation(), - 0, - "same-hash acknowledgement must not reload OPA" + startup_write_rejection( + "image policy", + &miette::miette!("Image policy synchronization failed after 5 attempts") + ), + None ); - handle.abort(); } - #[tokio::test] - async fn poll_installs_tcp_policy_reload() { - let v1 = settings_poll_result( - Some(proto_policy_fixture()), - 1, - openshell_core::proto::PolicySource::Sandbox, - ); - let v2 = settings_poll_result( - Some(proto_tcp_policy_fixture()), - 2, - openshell_core::proto::PolicySource::Sandbox, - ); - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let active_generation = engine.current_generation(); - let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); - let ctx = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_revision), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let (client, polls, mut reports) = scripted_policy_gateway(); - polls.send(v1).unwrap(); - - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - expect_policy_report(&mut reports, 1).await; - polls.send(v2).unwrap(); - let report = timeout(Duration::from_secs(1), reports.recv()) - .await - .expect("TCP reload report timed out") - .expect("policy reporter stopped"); - - assert_eq!(report.0, 2); - assert!(report.1, "{}", report.2); - assert!(engine.current_generation() > active_generation); - assert!(engine.fail_closed_reason().is_none()); - handle.abort(); + pub fn startup_provider( + revision: u64, + ) -> openshell_core::grpc_client::ProviderEnvironmentResult { + openshell_core::grpc_client::ProviderEnvironmentResult { + files: std::collections::HashMap::new(), + provider_env_revision: revision, + provider_attachment_epoch: String::new(), + policy_hash: String::new(), + readiness_reason: ProviderReadinessReason::Unspecified, + environment: std::collections::HashMap::new(), + credential_expires_at_ms: std::collections::HashMap::new(), + dynamic_credentials: std::collections::HashMap::new(), + static_credential_bindings: std::collections::HashMap::new(), + non_secret_environment_keys: Vec::new(), + stable_placeholder_environment_keys: Vec::new(), + } } - #[tokio::test] - async fn same_hash_ack_waits_for_failed_middleware_reconciliation_and_retries_once() { - let mut v1 = settings_poll_result( - Some(proto_policy_fixture()), + #[test] + fn startup_configuration_rejects_mixed_provider_revision() { + let policy = proto_policy_fixture(); + let mut snapshot = settings_poll_result( + Some(policy.clone()), 1, openshell_core::proto::PolicySource::Sandbox, ); - v1.policy_hash = "same-policy".to_string(); - let mut v2 = v1.clone(); - v2.version = 2; - v2.config_revision = 200; - v2.supervisor_middleware_services = - vec![openshell_core::proto::SupervisorMiddlewareService { - name: "scripted-guard".to_string(), - grpc_endpoint: "http://scripted.invalid".to_string(), - ..Default::default() - }]; - - let connector_attempts = Arc::new(AtomicUsize::new(0)); - let (attempt_tx, mut attempt_rx) = tokio::sync::mpsc::unbounded_channel(); - let middleware_connector: MiddlewareConnector = { - let connector_attempts = connector_attempts.clone(); - Arc::new(move |_services, _authentication| { - let attempt = connector_attempts.fetch_add(1, Ordering::SeqCst) + 1; - attempt_tx.send(attempt).unwrap(); - Box::pin(async move { - if attempt == 1 { - Err(miette::miette!("scripted middleware connection failure")) - } else { - connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await - } - }) - }) - }; - - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); - let ctx = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_revision), - has_last_valid_policy: true, - }, - middleware_connector, - ); - let (client, polls, mut reports) = scripted_policy_gateway(); - polls.send(v1).unwrap(); - - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - expect_policy_report(&mut reports, 1).await; + snapshot.provider_env_revision = 10; + assert!(prepare_startup_configuration(&snapshot, &policy, &startup_provider(11)).is_err()); + let (_, _, credentials) = + prepare_startup_configuration(&snapshot, &policy, &startup_provider(10)) + .expect("matching generation is admitted"); + assert_eq!(credentials.revision(), 10); + } - polls.send(v2.clone()).unwrap(); - assert_eq!( - timeout(Duration::from_secs(1), attempt_rx.recv()) - .await - .unwrap(), - Some(1) - ); - expect_no_policy_report(&mut reports).await; - assert_eq!(engine.current_generation(), 0); + #[test] + fn startup_environment_seeds_provider_readiness() { + let mut provider = startup_provider(10); + provider.provider_attachment_epoch = "epoch".to_string(); + provider.policy_hash = "policy".to_string(); + let identity = EnvironmentIdentity::from_environment(&provider); + let credentials = prepare_provider_environment(&provider).unwrap(); + let readiness = ProviderReadinessTracker::new(); - polls.send(v2.clone()).unwrap(); - assert_eq!( - timeout(Duration::from_secs(1), attempt_rx.recv()) - .await - .unwrap(), - Some(2) - ); - expect_policy_report(&mut reports, 2).await; - assert_eq!(engine.current_generation(), 1); + let credentials = + CapturedProviderEnvironment::new(credentials, &provider).install(&readiness); - polls.send(v2).unwrap(); - expect_no_policy_report(&mut reports).await; - assert_eq!(connector_attempts.load(Ordering::SeqCst), 2); - handle.abort(); + assert_eq!(credentials.revision(), 10); + assert!(!readiness.needs_environment(&identity)); } - #[tokio::test] - async fn no_signer_capability_uses_legacy_middleware_connector_without_credentials() { - let mut v1 = settings_poll_result( - Some(proto_policy_fixture()), + #[test] + fn startup_configuration_accepts_fail_closed_provider_environment() { + let policy = proto_policy_fixture(); + let mut snapshot = settings_poll_result( + Some(policy.clone()), 1, openshell_core::proto::PolicySource::Sandbox, ); - v1.policy_hash = "same-policy".to_string(); - let mut v2 = v1.clone(); - v2.version = 2; - v2.config_revision = 200; - v2.supervisor_middleware_services = - vec![openshell_core::proto::SupervisorMiddlewareService { - name: "legacy-guard".to_string(), - grpc_endpoint: "http://legacy.invalid".to_string(), - ..Default::default() - }]; - assert!(!v2.extension_authentication_enabled); - - let (inner, polls, mut reports) = scripted_policy_gateway(); - let credential_requests = Arc::new(AtomicUsize::new(0)); - let client = CredentialRejectingPolicyGateway { - inner, - credential_requests: credential_requests.clone(), - }; - let (connector_tx, mut connector_rx) = tokio::sync::mpsc::unbounded_channel(); - let connector: MiddlewareConnector = Arc::new(move |_services, authentication| { - connector_tx - .send((authentication.credentials.len(), authentication.enabled)) - .unwrap(); - Box::pin(async move { - connect_middleware_registry(&[], &MiddlewareAuthentication::default()).await - }) - }); - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); - let ctx = policy_poll_test_context( - engine, - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_revision), - has_last_valid_policy: true, - }, - connector, - ); + snapshot.provider_env_revision = 10; - polls.send(v1).unwrap(); - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - expect_policy_report(&mut reports, 1).await; - polls.send(v2).unwrap(); - assert_eq!( - timeout(Duration::from_secs(1), connector_rx.recv()) - .await - .unwrap(), - Some((0, false)) - ); - expect_policy_report(&mut reports, 2).await; - assert_eq!(credential_requests.load(Ordering::SeqCst), 0); - handle.abort(); + for reason in [ + ProviderReadinessReason::CredentialsWithheld, + ProviderReadinessReason::CredentialExpired, + ] { + let mut provider = startup_provider(10); + provider.readiness_reason = reason; + provider + .environment + .insert("PROJECT_ID".to_string(), "example-project".to_string()); + provider + .non_secret_environment_keys + .push("PROJECT_ID".to_string()); + let (_, _, credentials) = prepare_startup_configuration(&snapshot, &policy, &provider) + .expect("withheld credentials preserve fail-closed startup"); + assert_eq!(credentials.revision(), 10); + assert!(credentials.snapshot().child_env.contains_key("PROJECT_ID")); + } } - #[tokio::test] - async fn enabled_extension_authentication_keeps_credential_failure_fail_closed() { - let mut v1 = settings_poll_result( - Some(proto_policy_fixture()), + #[test] + fn startup_configuration_rejects_provider_installation_failure() { + let policy = proto_policy_fixture(); + let snapshot = settings_poll_result( + Some(policy.clone()), 1, openshell_core::proto::PolicySource::Sandbox, ); - v1.policy_hash = "same-policy".to_string(); - let mut v2 = v1.clone(); - v2.version = 2; - v2.config_revision = 200; - v2.extension_authentication_enabled = true; - v2.supervisor_middleware_services = - vec![openshell_core::proto::SupervisorMiddlewareService { - name: "authenticated-guard".to_string(), - grpc_endpoint: "https://guard.invalid".to_string(), - ..Default::default() - }]; - - let (inner, polls, mut reports) = scripted_policy_gateway(); - let credential_requests = Arc::new(AtomicUsize::new(0)); - let client = CredentialRejectingPolicyGateway { - inner, - credential_requests: credential_requests.clone(), - }; - let (connector_tx, mut connector_rx) = tokio::sync::mpsc::unbounded_channel(); - let connector: MiddlewareConnector = Arc::new(move |_services, authentication| { - connector_tx - .send((authentication.credentials.len(), authentication.enabled)) - .unwrap(); - Box::pin(async move { - if authentication.enabled && authentication.credentials.is_empty() { - Err(miette::miette!( - "missing authenticated middleware credential" - )) - } else { - connect_middleware_registry(&[], &authentication).await - } - }) - }); - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); - let ctx = policy_poll_test_context( - engine, - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_revision), - has_last_valid_policy: true, - }, - connector, - ); - - polls.send(v1).unwrap(); - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - expect_policy_report(&mut reports, 1).await; - polls.send(v2).unwrap(); - assert_eq!( - timeout(Duration::from_secs(1), connector_rx.recv()) - .await - .unwrap(), - Some((0, true)) - ); - expect_no_policy_report(&mut reports).await; - assert_eq!(credential_requests.load(Ordering::SeqCst), 1); - handle.abort(); - } - - async fn assert_poll_does_not_use_same_hash_acknowledgement( - initial: openshell_core::grpc_client::SettingsPollResult, - next: openshell_core::grpc_client::SettingsPollResult, - origin: LoadedPolicyOrigin, - initial_report: Option, - ) { - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let ctx = policy_poll_test_context(engine.clone(), origin, default_middleware_connector()); - let (client, polls, mut reports) = scripted_policy_gateway(); - polls.send(initial).unwrap(); - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - - if let Some(version) = initial_report { - expect_policy_report(&mut reports, version).await; - } else { - expect_no_policy_report(&mut reports).await; - } + let mut provider = startup_provider(0); + provider.readiness_reason = ProviderReadinessReason::CredentialInstallFailed; - polls.send(next).unwrap(); - expect_no_policy_report(&mut reports).await; - assert_eq!( - engine.current_generation(), - 0, - "negative same-hash scope must not reload OPA" - ); - handle.abort(); + assert!(prepare_startup_configuration(&snapshot, &policy, &provider).is_err()); } - #[tokio::test] - async fn same_hash_ack_poll_loop_rejects_local_global_empty_equal_and_older_scopes() { - let mut sandbox_v1 = settings_poll_result( - Some(proto_policy_fixture()), + #[test] + fn startup_configuration_revalidates_on_restart_and_accepts_repair() { + let policy = proto_policy_fixture(); + let mut snapshot = settings_poll_result( + Some(policy.clone()), 1, openshell_core::proto::PolicySource::Sandbox, ); - sandbox_v1.policy_hash = "same-policy".to_string(); - let loaded_v1 = LoadedPolicyRevision::from_snapshot(&sandbox_v1); - let mut sandbox_v2 = sandbox_v1.clone(); - sandbox_v2.version = 2; - sandbox_v2.config_revision = 200; - - assert_poll_does_not_use_same_hash_acknowledgement( - sandbox_v1.clone(), - sandbox_v2.clone(), - LoadedPolicyOrigin::LocalOverride, - None, - ) - .await; - - let mut global_v2 = sandbox_v2.clone(); - global_v2.policy_source = openshell_core::proto::PolicySource::Global; - assert_poll_does_not_use_same_hash_acknowledgement( - sandbox_v1.clone(), - global_v2, - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_v1.clone()), - has_last_valid_policy: true, - }, - Some(1), - ) - .await; - - let mut empty_v1 = sandbox_v1.clone(); - empty_v1.policy_hash.clear(); - let empty_loaded = LoadedPolicyRevision::from_snapshot(&empty_v1); - let mut empty_v2 = sandbox_v2.clone(); - empty_v2.policy_hash.clear(); - assert_poll_does_not_use_same_hash_acknowledgement( - empty_v1, - empty_v2, - LoadedPolicyOrigin::Gateway { - revision: Some(empty_loaded), - has_last_valid_policy: true, - }, - Some(1), - ) - .await; - - assert_poll_does_not_use_same_hash_acknowledgement( - sandbox_v1.clone(), - sandbox_v1.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_v1.clone()), - has_last_valid_policy: true, - }, - Some(1), - ) - .await; - - let loaded_v2 = LoadedPolicyRevision::from_snapshot(&sandbox_v2); - assert_poll_does_not_use_same_hash_acknowledgement( - sandbox_v2, - sandbox_v1, - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_v2), - has_last_valid_policy: true, - }, - Some(2), - ) - .await; + for _restart in 0..2 { + snapshot.configuration_admitted = false; + assert!( + prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)).is_err() + ); + snapshot.configuration_admitted = true; + assert!( + prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)).is_ok() + ); + } } - #[tokio::test] - async fn changed_hash_poll_uses_normal_opa_reload_and_status_path() { - let v1 = settings_poll_result( - Some(proto_policy_fixture()), + #[test] + fn startup_configuration_does_not_substitute_invalid_opa_policy() { + let mut policy = proto_tcp_policy_fixture(); + policy + .network_policies + .values_mut() + .next() + .expect("fixture has policy") + .endpoints[0] + .protocol = "invalid-protocol".to_string(); + let snapshot = settings_poll_result( + Some(policy.clone()), 1, openshell_core::proto::PolicySource::Sandbox, ); - let v2 = settings_poll_result( - Some(proto_policy_fixture()), - 2, - openshell_core::proto::PolicySource::Sandbox, - ); - let loaded_revision = LoadedPolicyRevision::from_snapshot(&v1); - let engine = - Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine")); - let ctx = policy_poll_test_context( - engine.clone(), - LoadedPolicyOrigin::Gateway { - revision: Some(loaded_revision), - has_last_valid_policy: true, - }, - default_middleware_connector(), - ); - let (client, polls, mut reports) = scripted_policy_gateway(); - polls.send(v1).unwrap(); - let handle = tokio::spawn(run_policy_poll_loop_with_client(ctx, client)); - - expect_policy_report(&mut reports, 1).await; - polls.send(v2).unwrap(); - expect_policy_report(&mut reports, 2).await; - assert_eq!( - engine.current_generation(), - 1, - "changed policy content must still reload OPA" - ); - handle.abort(); + assert!(prepare_startup_configuration(&snapshot, &policy, &startup_provider(0)).is_err()); } fn proto_provenance_policy_fixture() -> openshell_core::proto::SandboxPolicy { @@ -9531,3 +7037,6 @@ network_policies: ); } } + +#[cfg(test)] +mod legacy_startup_regression_tests; diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index aab1204eb5..7f16929759 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -188,6 +188,11 @@ image_preparation_timeout_seconds = 1800 # "retain_last_valid" only when availability takes priority. policy_validation_failure_mode = "fail_closed" +# How supervisors receive configuration. "poll" keeps periodic polling. +# "push" sends configuration over the supervisor session to supervisors that +# apply it; supervisors that do not support streamed configuration keep polling. +config_delivery_mode = "poll" + # Subject Alternative Names baked into the gateway server certificate. # Wildcard DNS SANs (e.g. "*.dev.openshell.localhost") also enable sandbox # service URLs under that domain. diff --git a/docs/observability/gateway-metrics.mdx b/docs/observability/gateway-metrics.mdx index 12bf904db1..3f0e640af3 100644 --- a/docs/observability/gateway-metrics.mdx +++ b/docs/observability/gateway-metrics.mdx @@ -155,9 +155,10 @@ name label, so use your scrape configuration to tell installations apart. ## Histograms and Summaries -The multi-replica latency metrics are Prometheus histograms with `_bucket`, -`_sum`, and `_count` series and buckets from 1 millisecond to 15 seconds, so -`histogram_quantile` can aggregate them across replicas. The older +The multi-replica latency metrics and the configuration build and apply +durations are Prometheus histograms with `_bucket`, `_sum`, and `_count` series +and buckets from 1 millisecond to 15 seconds, so `histogram_quantile` can +aggregate them across replicas. The older `*_duration_seconds` metrics and the interceptor latency metric are summaries with `quantile` labels that each replica computes on its own. Do not average summary quantiles across pods. @@ -262,6 +263,44 @@ The gateway also records every API request and its background database check: | `openshell_server_readiness_database_healthy` | Gauge | None | `1` when the last background database check succeeded, otherwise `0`. | | `openshell_server_readiness_database_probe_duration_seconds` | Summary | `outcome` | Duration of the background database check. `outcome` is `success`, `db_error`, or `timeout`. | +## Configuration Delivery Metrics + +With `config_delivery_mode = "push"`, the gateway builds each supervisor's +configuration and sends it over the supervisor session. These metrics stay +empty in poll mode, where `GetSandboxConfig` and +`GetSandboxProviderEnvironment` in the request metrics carry the same work. + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `openshell_supervisor_config_builds_total` | Counter | `outcome` | Configuration builds for sessions on this replica. | +| `openshell_supervisor_config_build_duration_seconds` | Histogram | `outcome` | Time to build one configuration, including store reads and credential resolution. | +| `openshell_supervisor_config_queue_wait_seconds` | Summary | None | Time from a session needing a rebuild to its build starting. | +| `openshell_supervisor_config_dirty_sessions` | Gauge | None | Sessions with a rebuild pending. | +| `openshell_supervisor_config_deliveries_total` | Counter | `outcome` | What happened to each built configuration. | +| `openshell_supervisor_config_apply_results_total` | Counter | `outcome` | Apply results that supervisors reported. | +| `openshell_supervisor_config_apply_duration_seconds` | Histogram | `outcome` | Time from queuing an update on the supervisor session to receiving its apply result. | +| `openshell_supervisor_policy_apply_results_total` | Counter | `source`, `outcome` | Sandbox policy load results that supervisors reported. `source` is `polling` or `stream`, and `outcome` is `loaded` or `failed`. | +| `openshell_supervisor_config_bootstrap_total` | Counter | `outcome` | Session bootstraps: `built`, `rejected` (stored configuration invalid), `provider_environment_failed` (delivered unresolved and rebuilt), `build_failed`, or `payload_too_large`. | +| `openshell_supervisor_config_peer_notifications_total` | Counter | `outcome` | Notifications sent to the replica that owns a sandbox's session. | + +The labels take these values: + +- Each configuration carries the sandbox configuration and the provider + environment it names. Credentials are resolved only when that provider + environment changed. +- On builds, `outcome` is `ok`, `failed`, or `timeout`. A failed build retries + with a per-session backoff from 1 to 30 seconds. Each session also rebuilds + its configuration every 30 seconds to repair a missed change. +- On deliveries, `outcome` is `queued`, `coalesced` (held until the + supervisor answers its previous update), `unchanged`, `no_active_session`, + `unsupported_session`, `payload_too_large`, `stream_closed` (the session's + stream stopped draining, so the session was closed for the supervisor to + reconnect), `owner_moved`, or `owner_lookup_failed`. +- On `apply_results_total` and `apply_duration_seconds`, `outcome` is a + `ConfigApplyOutcome` name, such as `CONFIG_APPLY_OUTCOME_APPLIED`. +- On peer notifications, `outcome` is `accepted`, `stale_owner`, + `unsupported_peer`, `peer_error`, or `timeout`. + ## Gateway Interceptor Metrics Gateways with [gateway interceptors](/extensibility/gateway-interceptors) @@ -275,6 +314,8 @@ record their evaluations: | `openshell_gateway_interceptor_fail_open_total` | Counter | None | Interceptor failures that let the request continue. | | `openshell_gateway_interceptor_fail_closed_total` | Counter | None | Interceptor failures that rejected the request. | | `openshell_gateway_interceptor_post_commit_observation_failures_total` | Counter | `stage` | Failed post-commit observations. `stage` is `response_body` or `evaluation`. | +| `openshell_server_provider_profile_source_refreshes_total` | Counter | `outcome` | Refreshes of interceptor provider-profile snapshots. `outcome` is `ok` or `failed`. | +| `openshell_server_provider_profile_source_snapshot_age_seconds` | Gauge | None | Age of the oldest cached provider-profile snapshot after the last refresh. Reads fail closed past three refresh intervals. | ## OCSF Log Metrics diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 7659015625..c6ca7c4828 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -27,6 +27,8 @@ e2e = [] e2e-host-gateway = ["e2e"] e2e-local-container-driver = ["e2e"] e2e-docker = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] +# Selects tests that need a Docker gateway with config_delivery_mode = "push". +e2e-config-push = ["e2e-docker"] e2e-gpu = ["e2e"] e2e-docker-gpu = ["e2e-docker", "e2e-gpu"] e2e-kubernetes = ["e2e"] @@ -39,6 +41,11 @@ e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] e2e-oidc-pkce = [] e2e-vm = ["e2e", "e2e-host-gateway"] +[[test]] +name = "config_push" +path = "tests/config_push.rs" +required-features = ["e2e-config-push"] + [[test]] name = "policy_activation" path = "tests/policy_activation.rs" diff --git a/e2e/rust/e2e-docker.sh b/e2e/rust/e2e-docker.sh index 559a001786..5a502dd8ec 100755 --- a/e2e/rust/e2e-docker.sh +++ b/e2e/rust/e2e-docker.sh @@ -30,9 +30,12 @@ run_e2e() { cargo test --manifest-path "${ROOT}/e2e/rust/Cargo.toml" --features "${E2E_FEATURES}" ) - if [ -n "${E2E_TEST}" ]; then - cargo_args+=(--test "${E2E_TEST}") - fi + # OPENSHELL_E2E_DOCKER_TEST may name several space-separated test targets. + local test_names test_name + read -ra test_names <<<"${E2E_TEST}" + for test_name in "${test_names[@]}"; do + cargo_args+=(--test "${test_name}") + done cargo_args+=(-- --nocapture) "${cargo_args[@]}" } diff --git a/e2e/rust/tests/config_push.rs b/e2e/rust/tests/config_push.rs new file mode 100644 index 0000000000..8fb0d6ab65 --- /dev/null +++ b/e2e/rust/tests/config_push.rs @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! E2E tests for configuration delivered over the supervisor session. +//! +//! Runs only in the `e2e:rust:push` lane, where the gateway starts with +//! `config_delivery_mode = "push"`. A supervisor logs when its session pushes +//! configuration, so these tests fail if the lane silently falls back to +//! polling. + +#![cfg(feature = "e2e-config-push")] + +use std::io::Write; +use std::process::Stdio; + +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::output::{extract_field, strip_ansi}; +use openshell_e2e::harness::sandbox::SandboxGuard; +use tempfile::NamedTempFile; + +const BASE_POLICY: &str = r"version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /bin + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort +"; + +const NETWORK_RULE: &str = r" +network_policies: + example: + name: example + endpoints: + - host: example.com + port: 443 + binaries: + - path: /** +"; + +fn write_policy(network_rules: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("create temp policy file"); + file.write_all(format!("{BASE_POLICY}{network_rules}").as_bytes()) + .expect("write temp policy file"); + file.flush().expect("flush temp policy file"); + file +} + +struct CliResult { + success: bool, + output: String, +} + +async fn run_cli(args: &[&str]) -> CliResult { + let output = openshell_cmd() + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .expect("spawn openshell command"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + CliResult { + success: output.status.success(), + output: strip_ansi(&format!("{stdout}{stderr}")), + } +} + +async fn policy_version(sandbox: &str) -> u32 { + let result = run_cli(&["policy", "get", sandbox]).await; + assert!(result.success, "policy get failed:\n{}", result.output); + extract_field(&result.output, "Version") + .or_else(|| extract_field(&result.output, "Revision")) + .and_then(|version| version.parse().ok()) + .unwrap_or_else(|| panic!("no policy version in:\n{}", result.output)) +} + +/// Wait until the sandbox's supervisor reports that its session pushes +/// configuration. +async fn assert_push_session(sandbox: &str) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let logs = run_cli(&["logs", sandbox, "-n", "500", "--source", "sandbox"]).await; + if logs.success + && logs + .output + .contains("gateway pushes configuration over the session") + { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "supervisor never reported a pushed-configuration session; is the gateway in push mode?\n{}", + logs.output + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + +async fn set_policy_and_wait(sandbox: &str, policy: &NamedTempFile) { + let path = policy.path().to_str().expect("UTF-8 policy path"); + let result = run_cli(&[ + "policy", + "set", + sandbox, + "--policy", + path, + "--wait", + "--timeout", + "120", + ]) + .await; + assert!( + result.success, + "policy set --wait failed:\n{}", + result.output + ); +} + +/// A live policy change reaches the supervisor over its session, and the wait +/// completes once the supervisor reports that exact revision loaded. +#[tokio::test] +async fn streamed_policy_update_reaches_the_supervisor() { + let base = write_policy(""); + let mut guard = SandboxGuard::create_keep_with_args( + &[ + "--policy", + base.path().to_str().expect("UTF-8 policy path"), + "--no-tty", + ], + &["sh", "-c", "echo Ready && sleep infinity"], + "Ready", + ) + .await + .expect("create sandbox"); + assert_push_session(&guard.name).await; + let initial_version = policy_version(&guard.name).await; + + let updated = write_policy(NETWORK_RULE); + set_policy_and_wait(&guard.name, &updated).await; + let updated_version = policy_version(&guard.name).await; + assert!( + updated_version > initial_version, + "a new network rule should create a revision after {initial_version}, got {updated_version}" + ); + + let list = run_cli(&["policy", "list", &guard.name]).await; + assert!(list.success, "policy list failed:\n{}", list.output); + assert!( + !list.output.to_lowercase().contains("pending"), + "the acknowledged revision must not remain pending:\n{}", + list.output + ); + + // Resubmitting the effective policy creates no revision. + let path = updated.path().to_str().expect("UTF-8 policy path"); + let unchanged = run_cli(&[ + "policy", + "set", + &guard.name, + "--policy", + path, + "--wait", + "--timeout", + "120", + ]) + .await; + assert!( + unchanged.success && unchanged.output.contains("Policy unchanged"), + "resubmitting the effective policy should complete unchanged:\n{}", + unchanged.output + ); + assert_eq!(policy_version(&guard.name).await, updated_version); + + guard.cleanup().await; +} diff --git a/proto/openshell.proto b/proto/openshell.proto index f4186ea440..52f4235e70 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -430,7 +430,9 @@ service OpenShell { }; } - // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + // Get sandbox settings by id (called by users, and by supervisors at startup + // and in their poll loop). A supervisor whose session pushes configuration + // receives later updates over ConnectSupervisor and stops polling. rpc GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) returns (openshell.sandbox.v1.GetSandboxConfigResponse) { option (openshell.options.v1.authorization) = { @@ -516,7 +518,9 @@ service OpenShell { option (openshell.options.v1.authorization) = { auth_mode: "sandbox" }; } - // Get provider environment for a sandbox (called by sandbox supervisor at startup). + // Get provider environment for a sandbox (called by the sandbox supervisor at + // startup and by its poll loop). Polling projection of the provider + // environment delivered over ConnectSupervisor; removed with the polling RPCs. rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) returns (GetSandboxProviderEnvironmentResponse) { option (openshell.options.v1.authorization) = { @@ -553,9 +557,11 @@ service OpenShell { // // The supervisor opens this stream at startup and keeps it alive for the // sandbox lifetime. The gateway uses it to coordinate relay channels for - // SSH connect, ExecSandbox, and targetable sandbox services. Raw service - // bytes flow over RelayStream calls (separate HTTP/2 streams on the same - // connection), not over this stream. + // SSH connect, ExecSandbox, targetable sandbox services, and configuration + // delivery. Supervisors advertise optional stream features in + // SupervisorHello, and the gateway sends only payloads the supervisor + // advertises. Raw service bytes flow over RelayStream calls (separate HTTP/2 + // streams on the same connection), not over this stream. rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage) { option (openshell.options.v1.authorization) = { auth_mode: "sandbox" @@ -629,6 +635,15 @@ service OpenShell { }; } + // Best-effort notification to the replica that owns a supervisor session. + // The receiver rebuilds snapshots from authoritative state. + rpc PeerNotifyConfigUpdate(PeerNotifyConfigUpdateRequest) + returns (PeerNotifyConfigUpdateResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "peer" + }; + } + // Watch a sandbox and stream updates. // // This stream can include: @@ -2684,7 +2699,9 @@ message StaticCredentialBinding { string workload_credential_handle = 3; } -// Get sandbox provider environment response. +// Polling projection of ProviderEnvironmentSnapshot. The parallel maps below +// are keyed by the same environment variable names. Removed with the polling +// RPCs; new provider environment fields belong on the snapshot first. message GetSandboxProviderEnvironmentResponse { reserved 3; reserved "credential_expires_at_ms"; @@ -2725,6 +2742,46 @@ message GetSandboxProviderEnvironmentResponse { bool supports_stable_placeholder_environment_keys = 12; } +// Receivers treat UNSPECIFIED and unknown values as secret. +enum ProviderEnvironmentValueClassification { + PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED = 0; + PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_NON_SECRET = 1; + PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_STATIC_CREDENTIAL = 2; +} + +// One environment value and all metadata that shares its key. Names are unique +// within a snapshot. static_credential_binding is present exactly when +// classification is STATIC_CREDENTIAL. Snapshots that violate either rule are +// invalid. +message ProviderEnvironmentValue { + string name = 1; + string value = 2 [(openshell.options.v1.secret) = true]; + ProviderEnvironmentValueClassification classification = 4; + StaticCredentialBinding static_credential_binding = 5; + // Absolute expiration time. Absence means no expiry. + google.protobuf.Timestamp expiration_time = 3; + // The static credential keeps one opaque resolver handle across value-only + // provider revisions. Set only for STATIC_CREDENTIAL values whose binding + // carries a workload_credential_handle. + bool stable_placeholder = 6; +} + +// Complete provider environment state delivered to a supervisor. This is the +// canonical provider environment shape; GetSandboxProviderEnvironmentResponse +// is its polling projection and is removed with the polling RPCs. Dynamic +// credentials are endpoint selectors rather than environment values and stay +// in their own collection. +message ProviderEnvironmentSnapshot { + uint64 provider_env_revision = 1; + repeated ProviderEnvironmentValue values = 2; + map dynamic_credentials = 3; + string provider_attachment_epoch = 4; + string policy_hash = 5; + ProviderReadinessReason readiness_reason = 6; + // Complete desired set of non-secret managed files, keyed by absolute path. + map files = 7; +} + message ExchangeProviderSubjectTokenRequest { // The sandbox ID. Must match the authenticated sandbox principal. string sandbox_id = 1; @@ -3040,6 +3097,9 @@ message GetSandboxLogsResponse { // Supervisor session messages // --------------------------------------------------------------------------- +// These messages form an internal, version-locked deployment protocol between +// the gateway and the supervisor. They are not a public sandbox client API. + // Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream. message SupervisorMessage { oneof payload { @@ -3047,6 +3107,7 @@ message SupervisorMessage { SupervisorHeartbeat heartbeat = 2; RelayOpenResult relay_open_result = 3; RelayClose relay_close = 4; + ConfigUpdateResult config_update_result = 5; } } @@ -3059,6 +3120,7 @@ message GatewayMessage { RelayOpen relay_open = 4; RelayClose relay_close = 5; SessionRedirect session_redirect = 6; + ConfigUpdate config_update = 7; } } @@ -3081,6 +3143,11 @@ message SupervisorHello { // The supervisor understands SessionRedirect. Gateways never redirect a // supervisor that does not set this. bool supports_session_redirect = 6; + // The supervisor can receive its configuration over this stream instead of + // polling for it: it applies the bootstrap ConfigUpdate and later + // ConfigUpdate payloads, and answers each with its result and admission. + // Gateways push configuration only to supervisors that set this. + bool supports_config_push = 7; } // Gateway accepts the supervisor session. @@ -3089,10 +3156,62 @@ message SessionAccepted { reserved "heartbeat_interval_secs"; // Gateway-assigned session ID for this connection. string session_id = 1; + // The session's first configuration update. Present exactly when + // config_push_enabled is set. + ConfigUpdate bootstrap = 3; + // The gateway pushes configuration authoritatively on this stream instead + // of the supervisor polling for it: the bootstrap is required, ConfigUpdate + // payloads are applied and acknowledged, and the supervisor stops polling. + // Set only for supervisors that set SupervisorHello.supports_config_push. + bool config_push_enabled = 4; // Recommended heartbeat interval. google.protobuf.Duration heartbeat_interval = 102; } +// Complete gateway-owned configuration for a supervisor session. Each update +// replaces the previous one. The supervisor applies the two parts together +// and answers every update with one ConfigUpdateResult before the gateway +// sends the next. +message ConfigUpdate { + // Opaque non-empty identifier scoped to the active supervisor session. + string update_id = 1; + openshell.sandbox.v1.SandboxConfigSnapshot sandbox_config = 2; + // The provider environment the sandbox configuration names through its + // provider revision, attachment epoch, and policy hash. + ProviderEnvironmentSnapshot provider_environment = 3; +} + +// Sanitized application failure. Messages must not contain configuration +// payloads, credentials, or provider values. +message ConfigApplyFailure { + string code = 1; + string message = 2; + bool retryable = 3; +} + +// Application result for one ConfigUpdate, including the bootstrap. +message ConfigUpdateResult { + // Echoes ConfigUpdate.update_id for session-local correlation. + string update_id = 1; + ConfigApplyOutcome outcome = 2; + // Why the update did not take effect. Omitted when it did. + ConfigApplyFailure failure = 3; + // Admission state of the delivered sandbox configuration. + SandboxConfigurationAdmission admission = 4; + // Load result of the sandbox policy revision the update changed, as a + // polling supervisor reports it with ReportPolicyStatus. Omitted when the + // update did not change the sandbox policy. + PolicyLoadResult policy_load = 5; +} + +// Load result of one sandbox policy revision. +message PolicyLoadResult { + uint32 version = 1; + bool loaded = 2; + // Sanitized load error. Empty when loaded. + string error = 3; +} + // Gateway rejects the supervisor session. message SessionRejected { // Human-readable rejection reason. @@ -3205,6 +3324,32 @@ message PeerRelayFrame { } } +message PeerConfigSandboxTarget { + string sandbox_id = 1; + // Fences a targeted notification to the session observed by the sending replica. + string session_id = 2; +} + +// Sandboxes in a workspace that attach the named provider. +message PeerConfigProviderTarget { + string workspace = 1; + string name = 2; +} + +message PeerNotifyConfigUpdateRequest { + oneof scope { + PeerConfigSandboxTarget sandbox = 1; + string workspace = 2; + bool all_connected = 3; + PeerConfigProviderTarget provider = 4; + } +} + +message PeerNotifyConfigUpdateResponse { + // The targeted session moved before the receiving replica accepted the notification. + bool stale_owner = 1; +} + // Supervisor reports the result of a relay open request. message RelayOpenResult { // Channel identifier from the RelayOpen request. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 7f7f407c3c..bf1652389a 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -397,14 +397,40 @@ message EffectiveSetting { SettingScope scope = 2; } -// Source used for the policy payload in GetSandboxConfigResponse. +// Source used for a sandbox configuration payload. enum PolicySource { POLICY_SOURCE_UNSPECIFIED = 0; POLICY_SOURCE_SANDBOX = 1; POLICY_SOURCE_GLOBAL = 2; } -// Response containing effective sandbox settings and policy. +// Complete effective sandbox configuration delivered to a supervisor. Fields +// have the same meaning as the identically numbered GetSandboxConfigResponse +// fields, including fail-closed handling of an unknown +// policy_validation_failure_mode and treating absent configuration_admitted as +// not admitted. Move those field comments here when the polling RPC is removed. +message SandboxConfigSnapshot { + SandboxPolicy policy = 1; + uint32 version = 2; + string policy_hash = 3; + map settings = 4; + uint64 config_revision = 5; + PolicySource policy_source = 6; + uint32 global_policy_version = 7; + uint64 provider_env_revision = 8; + repeated SupervisorMiddlewareService supervisor_middleware_services = 9; + string workspace = 10; + string policy_validation_failure_mode = 11; + bool extension_authentication_enabled = 12; + bool configuration_admitted = 13; + string provider_attachment_epoch = 14; + string configuration_instance_id = 15; + string configuration_error = 16; + uint64 settings_revision = 17; +} + +// Response containing effective sandbox settings and policy, projected from +// the SandboxConfigSnapshot that push supervisors receive. message GetSandboxConfigResponse { // The sandbox policy configuration. SandboxPolicy policy = 1; @@ -449,6 +475,8 @@ message GetSandboxConfigResponse { string configuration_error = 16; // Registration fence for a new supervisor; capture once and retain on retry. string configuration_instance_id = 15; + // Monotonic revision of the sandbox-scoped settings row. + uint64 settings_revision = 17; } // Connection details for one operator-registered supervisor middleware service. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 68a550bdec..ae9b03a42b 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -729,6 +729,56 @@ func (ProviderProfileCategory) EnumDescriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{10} } +// Receivers treat UNSPECIFIED and unknown values as secret. +type ProviderEnvironmentValueClassification int32 + +const ( + ProviderEnvironmentValueClassification_PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED ProviderEnvironmentValueClassification = 0 + ProviderEnvironmentValueClassification_PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_NON_SECRET ProviderEnvironmentValueClassification = 1 + ProviderEnvironmentValueClassification_PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_STATIC_CREDENTIAL ProviderEnvironmentValueClassification = 2 +) + +// Enum value maps for ProviderEnvironmentValueClassification. +var ( + ProviderEnvironmentValueClassification_name = map[int32]string{ + 0: "PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED", + 1: "PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_NON_SECRET", + 2: "PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_STATIC_CREDENTIAL", + } + ProviderEnvironmentValueClassification_value = map[string]int32{ + "PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED": 0, + "PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_NON_SECRET": 1, + "PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_STATIC_CREDENTIAL": 2, + } +) + +func (x ProviderEnvironmentValueClassification) Enum() *ProviderEnvironmentValueClassification { + p := new(ProviderEnvironmentValueClassification) + *p = x + return p +} + +func (x ProviderEnvironmentValueClassification) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderEnvironmentValueClassification) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[11].Descriptor() +} + +func (ProviderEnvironmentValueClassification) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[11] +} + +func (x ProviderEnvironmentValueClassification) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderEnvironmentValueClassification.Descriptor instead. +func (ProviderEnvironmentValueClassification) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{11} +} + type ConfigurationAdmissionState int32 const ( @@ -765,11 +815,11 @@ func (x ConfigurationAdmissionState) String() string { } func (ConfigurationAdmissionState) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[11].Descriptor() + return file_openshell_proto_enumTypes[12].Descriptor() } func (ConfigurationAdmissionState) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[11] + return &file_openshell_proto_enumTypes[12] } func (x ConfigurationAdmissionState) Number() protoreflect.EnumNumber { @@ -778,7 +828,7 @@ func (x ConfigurationAdmissionState) Number() protoreflect.EnumNumber { // Deprecated: Use ConfigurationAdmissionState.Descriptor instead. func (ConfigurationAdmissionState) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{11} + return file_openshell_proto_rawDescGZIP(), []int{12} } // Policy load status. @@ -827,11 +877,11 @@ func (x PolicyStatus) String() string { } func (PolicyStatus) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[12].Descriptor() + return file_openshell_proto_enumTypes[13].Descriptor() } func (PolicyStatus) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[12] + return &file_openshell_proto_enumTypes[13] } func (x PolicyStatus) Number() protoreflect.EnumNumber { @@ -840,7 +890,7 @@ func (x PolicyStatus) Number() protoreflect.EnumNumber { // Deprecated: Use PolicyStatus.Descriptor instead. func (PolicyStatus) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{12} + return file_openshell_proto_rawDescGZIP(), []int{13} } // Service status enum. @@ -880,11 +930,11 @@ func (x ServiceStatus) String() string { } func (ServiceStatus) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[13].Descriptor() + return file_openshell_proto_enumTypes[14].Descriptor() } func (ServiceStatus) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[13] + return &file_openshell_proto_enumTypes[14] } func (x ServiceStatus) Number() protoreflect.EnumNumber { @@ -893,7 +943,7 @@ func (x ServiceStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ServiceStatus.Descriptor instead. func (ServiceStatus) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{13} + return file_openshell_proto_rawDescGZIP(), []int{14} } // Workspace-scoped role for members. @@ -930,11 +980,11 @@ func (x WorkspaceRole) String() string { } func (WorkspaceRole) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[14].Descriptor() + return file_openshell_proto_enumTypes[15].Descriptor() } func (WorkspaceRole) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[14] + return &file_openshell_proto_enumTypes[15] } func (x WorkspaceRole) Number() protoreflect.EnumNumber { @@ -943,7 +993,7 @@ func (x WorkspaceRole) Number() protoreflect.EnumNumber { // Deprecated: Use WorkspaceRole.Descriptor instead. func (WorkspaceRole) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{14} + return file_openshell_proto_rawDescGZIP(), []int{15} } // Stable recovery action for the most recent provider credential refresh @@ -989,11 +1039,11 @@ func (x ProviderCredentialRefreshRecoveryAction) String() string { } func (ProviderCredentialRefreshRecoveryAction) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[15].Descriptor() + return file_openshell_proto_enumTypes[16].Descriptor() } func (ProviderCredentialRefreshRecoveryAction) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[15] + return &file_openshell_proto_enumTypes[16] } func (x ProviderCredentialRefreshRecoveryAction) Number() protoreflect.EnumNumber { @@ -1002,7 +1052,7 @@ func (x ProviderCredentialRefreshRecoveryAction) Number() protoreflect.EnumNumbe // Deprecated: Use ProviderCredentialRefreshRecoveryAction.Descriptor instead. func (ProviderCredentialRefreshRecoveryAction) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{15} + return file_openshell_proto_rawDescGZIP(), []int{16} } // Policy applied when the canonical main process exits outside an intentional @@ -1044,11 +1094,11 @@ func (x SandboxRestartPolicy) String() string { } func (SandboxRestartPolicy) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[16].Descriptor() + return file_openshell_proto_enumTypes[17].Descriptor() } func (SandboxRestartPolicy) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[16] + return &file_openshell_proto_enumTypes[17] } func (x SandboxRestartPolicy) Number() protoreflect.EnumNumber { @@ -1057,7 +1107,7 @@ func (x SandboxRestartPolicy) Number() protoreflect.EnumNumber { // Deprecated: Use SandboxRestartPolicy.Descriptor instead. func (SandboxRestartPolicy) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{16} + return file_openshell_proto_rawDescGZIP(), []int{17} } // Result of a public delete, membership removal, or session revocation. @@ -1107,11 +1157,11 @@ func (x DeletionOutcome) String() string { } func (DeletionOutcome) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[17].Descriptor() + return file_openshell_proto_enumTypes[18].Descriptor() } func (DeletionOutcome) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[17] + return &file_openshell_proto_enumTypes[18] } func (x DeletionOutcome) Number() protoreflect.EnumNumber { @@ -1120,7 +1170,7 @@ func (x DeletionOutcome) Number() protoreflect.EnumNumber { // Deprecated: Use DeletionOutcome.Descriptor instead. func (DeletionOutcome) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{17} + return file_openshell_proto_rawDescGZIP(), []int{18} } // Last observed network result for a configured external tool endpoint. @@ -1181,11 +1231,11 @@ func (x EndpointResult) String() string { } func (EndpointResult) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[18].Descriptor() + return file_openshell_proto_enumTypes[19].Descriptor() } func (EndpointResult) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[18] + return &file_openshell_proto_enumTypes[19] } func (x EndpointResult) Number() protoreflect.EnumNumber { @@ -1194,7 +1244,7 @@ func (x EndpointResult) Number() protoreflect.EnumNumber { // Deprecated: Use EndpointResult.Descriptor instead. func (EndpointResult) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{18} + return file_openshell_proto_rawDescGZIP(), []int{19} } // Controls whether an exposed service receives the request's application @@ -1235,11 +1285,11 @@ func (x ServiceAuthorizationMode) String() string { } func (ServiceAuthorizationMode) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[19].Descriptor() + return file_openshell_proto_enumTypes[20].Descriptor() } func (ServiceAuthorizationMode) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[19] + return &file_openshell_proto_enumTypes[20] } func (x ServiceAuthorizationMode) Number() protoreflect.EnumNumber { @@ -1248,7 +1298,7 @@ func (x ServiceAuthorizationMode) Number() protoreflect.EnumNumber { // Deprecated: Use ServiceAuthorizationMode.Descriptor instead. func (ServiceAuthorizationMode) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{19} + return file_openshell_proto_rawDescGZIP(), []int{20} } // Subsystem whose closed code set explains an endpoint failure. Each domain @@ -1288,11 +1338,11 @@ func (x EndpointFailureDomain) String() string { } func (EndpointFailureDomain) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[20].Descriptor() + return file_openshell_proto_enumTypes[21].Descriptor() } func (EndpointFailureDomain) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[20] + return &file_openshell_proto_enumTypes[21] } func (x EndpointFailureDomain) Number() protoreflect.EnumNumber { @@ -1301,7 +1351,7 @@ func (x EndpointFailureDomain) Number() protoreflect.EnumNumber { // Deprecated: Use EndpointFailureDomain.Descriptor instead. func (EndpointFailureDomain) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{20} + return file_openshell_proto_rawDescGZIP(), []int{21} } // IssueSandboxToken request. Empty body; identity is established by the @@ -11469,7 +11519,9 @@ func (x *StaticCredentialBinding) GetWorkloadCredentialHandle() string { return "" } -// Get sandbox provider environment response. +// Polling projection of ProviderEnvironmentSnapshot. The parallel maps below +// are keyed by the same environment variable names. Removed with the polling +// RPCs; new provider environment fields belong on the snapshot first. type GetSandboxProviderEnvironmentResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Provider credential environment variables. @@ -11625,6 +11677,196 @@ func (x *GetSandboxProviderEnvironmentResponse) GetSupportsStablePlaceholderEnvi return false } +// One environment value and all metadata that shares its key. Names are unique +// within a snapshot. static_credential_binding is present exactly when +// classification is STATIC_CREDENTIAL. Snapshots that violate either rule are +// invalid. +type ProviderEnvironmentValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Classification ProviderEnvironmentValueClassification `protobuf:"varint,4,opt,name=classification,proto3,enum=openshell.v1.ProviderEnvironmentValueClassification" json:"classification,omitempty"` + StaticCredentialBinding *StaticCredentialBinding `protobuf:"bytes,5,opt,name=static_credential_binding,json=staticCredentialBinding,proto3" json:"static_credential_binding,omitempty"` + // Absolute expiration time. Absence means no expiry. + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + // The static credential keeps one opaque resolver handle across value-only + // provider revisions. Set only for STATIC_CREDENTIAL values whose binding + // carries a workload_credential_handle. + StablePlaceholder bool `protobuf:"varint,6,opt,name=stable_placeholder,json=stablePlaceholder,proto3" json:"stable_placeholder,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderEnvironmentValue) Reset() { + *x = ProviderEnvironmentValue{} + mi := &file_openshell_proto_msgTypes[141] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderEnvironmentValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderEnvironmentValue) ProtoMessage() {} + +func (x *ProviderEnvironmentValue) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[141] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderEnvironmentValue.ProtoReflect.Descriptor instead. +func (*ProviderEnvironmentValue) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{141} +} + +func (x *ProviderEnvironmentValue) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProviderEnvironmentValue) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *ProviderEnvironmentValue) GetClassification() ProviderEnvironmentValueClassification { + if x != nil { + return x.Classification + } + return ProviderEnvironmentValueClassification_PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED +} + +func (x *ProviderEnvironmentValue) GetStaticCredentialBinding() *StaticCredentialBinding { + if x != nil { + return x.StaticCredentialBinding + } + return nil +} + +func (x *ProviderEnvironmentValue) GetExpirationTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpirationTime + } + return nil +} + +func (x *ProviderEnvironmentValue) GetStablePlaceholder() bool { + if x != nil { + return x.StablePlaceholder + } + return false +} + +// Complete provider environment state delivered to a supervisor. This is the +// canonical provider environment shape; GetSandboxProviderEnvironmentResponse +// is its polling projection and is removed with the polling RPCs. Dynamic +// credentials are endpoint selectors rather than environment values and stay +// in their own collection. +type ProviderEnvironmentSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProviderEnvRevision uint64 `protobuf:"varint,1,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + Values []*ProviderEnvironmentValue `protobuf:"bytes,2,rep,name=values,proto3" json:"values,omitempty"` + DynamicCredentials map[string]*ProviderProfileCredential `protobuf:"bytes,3,rep,name=dynamic_credentials,json=dynamicCredentials,proto3" json:"dynamic_credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ProviderAttachmentEpoch string `protobuf:"bytes,4,opt,name=provider_attachment_epoch,json=providerAttachmentEpoch,proto3" json:"provider_attachment_epoch,omitempty"` + PolicyHash string `protobuf:"bytes,5,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + ReadinessReason ProviderReadinessReason `protobuf:"varint,6,opt,name=readiness_reason,json=readinessReason,proto3,enum=openshell.v1.ProviderReadinessReason" json:"readiness_reason,omitempty"` + // Complete desired set of non-secret managed files, keyed by absolute path. + Files map[string]string `protobuf:"bytes,7,rep,name=files,proto3" json:"files,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderEnvironmentSnapshot) Reset() { + *x = ProviderEnvironmentSnapshot{} + mi := &file_openshell_proto_msgTypes[142] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderEnvironmentSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderEnvironmentSnapshot) ProtoMessage() {} + +func (x *ProviderEnvironmentSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[142] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderEnvironmentSnapshot.ProtoReflect.Descriptor instead. +func (*ProviderEnvironmentSnapshot) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{142} +} + +func (x *ProviderEnvironmentSnapshot) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +func (x *ProviderEnvironmentSnapshot) GetValues() []*ProviderEnvironmentValue { + if x != nil { + return x.Values + } + return nil +} + +func (x *ProviderEnvironmentSnapshot) GetDynamicCredentials() map[string]*ProviderProfileCredential { + if x != nil { + return x.DynamicCredentials + } + return nil +} + +func (x *ProviderEnvironmentSnapshot) GetProviderAttachmentEpoch() string { + if x != nil { + return x.ProviderAttachmentEpoch + } + return "" +} + +func (x *ProviderEnvironmentSnapshot) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *ProviderEnvironmentSnapshot) GetReadinessReason() ProviderReadinessReason { + if x != nil { + return x.ReadinessReason + } + return ProviderReadinessReason_PROVIDER_READINESS_REASON_UNSPECIFIED +} + +func (x *ProviderEnvironmentSnapshot) GetFiles() map[string]string { + if x != nil { + return x.Files + } + return nil +} + type ExchangeProviderSubjectTokenRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The sandbox ID. Must match the authenticated sandbox principal. @@ -11642,7 +11884,7 @@ type ExchangeProviderSubjectTokenRequest struct { func (x *ExchangeProviderSubjectTokenRequest) Reset() { *x = ExchangeProviderSubjectTokenRequest{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11654,7 +11896,7 @@ func (x *ExchangeProviderSubjectTokenRequest) String() string { func (*ExchangeProviderSubjectTokenRequest) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11667,7 +11909,7 @@ func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use ExchangeProviderSubjectTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *ExchangeProviderSubjectTokenRequest) GetSandboxId() string { @@ -11709,7 +11951,7 @@ type ExchangeProviderSubjectTokenResponse struct { func (x *ExchangeProviderSubjectTokenResponse) Reset() { *x = ExchangeProviderSubjectTokenResponse{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11721,7 +11963,7 @@ func (x *ExchangeProviderSubjectTokenResponse) String() string { func (*ExchangeProviderSubjectTokenResponse) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11734,7 +11976,7 @@ func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use ExchangeProviderSubjectTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { @@ -11807,7 +12049,7 @@ type UpdateConfigRequest struct { func (x *UpdateConfigRequest) Reset() { *x = UpdateConfigRequest{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11819,7 +12061,7 @@ func (x *UpdateConfigRequest) String() string { func (*UpdateConfigRequest) ProtoMessage() {} func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11832,7 +12074,7 @@ func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -11929,7 +12171,7 @@ type PolicyMergeOperation struct { func (x *PolicyMergeOperation) Reset() { *x = PolicyMergeOperation{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11941,7 +12183,7 @@ func (x *PolicyMergeOperation) String() string { func (*PolicyMergeOperation) ProtoMessage() {} func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11954,7 +12196,7 @@ func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { @@ -12068,7 +12310,7 @@ type AddNetworkRule struct { func (x *AddNetworkRule) Reset() { *x = AddNetworkRule{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12080,7 +12322,7 @@ func (x *AddNetworkRule) String() string { func (*AddNetworkRule) ProtoMessage() {} func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12093,7 +12335,7 @@ func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. func (*AddNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *AddNetworkRule) GetRuleName() string { @@ -12121,7 +12363,7 @@ type RemoveNetworkEndpoint struct { func (x *RemoveNetworkEndpoint) Reset() { *x = RemoveNetworkEndpoint{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12133,7 +12375,7 @@ func (x *RemoveNetworkEndpoint) String() string { func (*RemoveNetworkEndpoint) ProtoMessage() {} func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12146,7 +12388,7 @@ func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *RemoveNetworkEndpoint) GetRuleName() string { @@ -12179,7 +12421,7 @@ type RemoveNetworkRule struct { func (x *RemoveNetworkRule) Reset() { *x = RemoveNetworkRule{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12191,7 +12433,7 @@ func (x *RemoveNetworkRule) String() string { func (*RemoveNetworkRule) ProtoMessage() {} func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12204,7 +12446,7 @@ func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *RemoveNetworkRule) GetRuleName() string { @@ -12233,7 +12475,7 @@ type L7RuleTarget struct { func (x *L7RuleTarget) Reset() { *x = L7RuleTarget{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12245,7 +12487,7 @@ func (x *L7RuleTarget) String() string { func (*L7RuleTarget) ProtoMessage() {} func (x *L7RuleTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12258,7 +12500,7 @@ func (x *L7RuleTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RuleTarget.ProtoReflect.Descriptor instead. func (*L7RuleTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{150} } func (x *L7RuleTarget) GetRuleName() string { @@ -12313,7 +12555,7 @@ type AddDenyRules struct { func (x *AddDenyRules) Reset() { *x = AddDenyRules{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12325,7 +12567,7 @@ func (x *AddDenyRules) String() string { func (*AddDenyRules) ProtoMessage() {} func (x *AddDenyRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12338,7 +12580,7 @@ func (x *AddDenyRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. func (*AddDenyRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *AddDenyRules) GetDenyRules() []*sandboxv1.L7DenyRule { @@ -12365,7 +12607,7 @@ type AddAllowRules struct { func (x *AddAllowRules) Reset() { *x = AddAllowRules{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12377,7 +12619,7 @@ func (x *AddAllowRules) String() string { func (*AddAllowRules) ProtoMessage() {} func (x *AddAllowRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12390,7 +12632,7 @@ func (x *AddAllowRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. func (*AddAllowRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *AddAllowRules) GetRules() []*sandboxv1.L7Rule { @@ -12417,7 +12659,7 @@ type RemoveNetworkBinary struct { func (x *RemoveNetworkBinary) Reset() { *x = RemoveNetworkBinary{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12429,7 +12671,7 @@ func (x *RemoveNetworkBinary) String() string { func (*RemoveNetworkBinary) ProtoMessage() {} func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12442,7 +12684,7 @@ func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *RemoveNetworkBinary) GetRuleName() string { @@ -12478,7 +12720,7 @@ type UpdateConfigResponse struct { func (x *UpdateConfigResponse) Reset() { *x = UpdateConfigResponse{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12490,7 +12732,7 @@ func (x *UpdateConfigResponse) String() string { func (*UpdateConfigResponse) ProtoMessage() {} func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12503,7 +12745,7 @@ func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{154} } func (x *UpdateConfigResponse) GetVersion() uint32 { @@ -12557,7 +12799,7 @@ type GetSandboxPolicyStatusRequest struct { func (x *GetSandboxPolicyStatusRequest) Reset() { *x = GetSandboxPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12569,7 +12811,7 @@ func (x *GetSandboxPolicyStatusRequest) String() string { func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12582,7 +12824,7 @@ func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *GetSandboxPolicyStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -12626,7 +12868,7 @@ type GetSandboxPolicyStatusResponse struct { func (x *GetSandboxPolicyStatusResponse) Reset() { *x = GetSandboxPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12638,7 +12880,7 @@ func (x *GetSandboxPolicyStatusResponse) String() string { func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12651,7 +12893,7 @@ func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{156} } func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { @@ -12688,7 +12930,7 @@ type ListSandboxPoliciesRequest struct { func (x *ListSandboxPoliciesRequest) Reset() { *x = ListSandboxPoliciesRequest{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12700,7 +12942,7 @@ func (x *ListSandboxPoliciesRequest) String() string { func (*ListSandboxPoliciesRequest) ProtoMessage() {} func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12713,7 +12955,7 @@ func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{157} } func (x *ListSandboxPoliciesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -12765,7 +13007,7 @@ type ListSandboxPoliciesResponse struct { func (x *ListSandboxPoliciesResponse) Reset() { *x = ListSandboxPoliciesResponse{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12777,7 +13019,7 @@ func (x *ListSandboxPoliciesResponse) String() string { func (*ListSandboxPoliciesResponse) ProtoMessage() {} func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12790,7 +13032,7 @@ func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { @@ -12824,7 +13066,7 @@ type ReportPolicyStatusRequest struct { func (x *ReportPolicyStatusRequest) Reset() { *x = ReportPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12836,7 +13078,7 @@ func (x *ReportPolicyStatusRequest) String() string { func (*ReportPolicyStatusRequest) ProtoMessage() {} func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12849,7 +13091,7 @@ func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *ReportPolicyStatusRequest) GetSandboxId() string { @@ -12889,7 +13131,7 @@ type ReportPolicyStatusResponse struct { func (x *ReportPolicyStatusResponse) Reset() { *x = ReportPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12901,7 +13143,7 @@ func (x *ReportPolicyStatusResponse) String() string { func (*ReportPolicyStatusResponse) ProtoMessage() {} func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12914,7 +13156,7 @@ func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{160} } type SandboxConfigurationAdmission struct { @@ -12932,7 +13174,7 @@ type SandboxConfigurationAdmission struct { func (x *SandboxConfigurationAdmission) Reset() { *x = SandboxConfigurationAdmission{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12944,7 +13186,7 @@ func (x *SandboxConfigurationAdmission) String() string { func (*SandboxConfigurationAdmission) ProtoMessage() {} func (x *SandboxConfigurationAdmission) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12957,7 +13199,7 @@ func (x *SandboxConfigurationAdmission) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxConfigurationAdmission.ProtoReflect.Descriptor instead. func (*SandboxConfigurationAdmission) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{161} } func (x *SandboxConfigurationAdmission) GetInstanceId() string { @@ -13021,7 +13263,7 @@ type ReportSandboxConfigurationRequest struct { func (x *ReportSandboxConfigurationRequest) Reset() { *x = ReportSandboxConfigurationRequest{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13033,7 +13275,7 @@ func (x *ReportSandboxConfigurationRequest) String() string { func (*ReportSandboxConfigurationRequest) ProtoMessage() {} func (x *ReportSandboxConfigurationRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13046,7 +13288,7 @@ func (x *ReportSandboxConfigurationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ReportSandboxConfigurationRequest.ProtoReflect.Descriptor instead. func (*ReportSandboxConfigurationRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{162} } func (x *ReportSandboxConfigurationRequest) GetSandboxId() string { @@ -13078,7 +13320,7 @@ type ReportSandboxConfigurationResponse struct { func (x *ReportSandboxConfigurationResponse) Reset() { *x = ReportSandboxConfigurationResponse{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13090,7 +13332,7 @@ func (x *ReportSandboxConfigurationResponse) String() string { func (*ReportSandboxConfigurationResponse) ProtoMessage() {} func (x *ReportSandboxConfigurationResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13103,7 +13345,7 @@ func (x *ReportSandboxConfigurationResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ReportSandboxConfigurationResponse.ProtoReflect.Descriptor instead. func (*ReportSandboxConfigurationResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{163} } // A versioned policy revision with metadata. @@ -13136,7 +13378,7 @@ type SandboxPolicyRevision struct { func (x *SandboxPolicyRevision) Reset() { *x = SandboxPolicyRevision{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13148,7 +13390,7 @@ func (x *SandboxPolicyRevision) String() string { func (*SandboxPolicyRevision) ProtoMessage() {} func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13161,7 +13403,7 @@ func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{164} } func (x *SandboxPolicyRevision) GetVersion() uint32 { @@ -13241,7 +13483,7 @@ type GetSandboxLogsRequest struct { func (x *GetSandboxLogsRequest) Reset() { *x = GetSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13253,7 +13495,7 @@ func (x *GetSandboxLogsRequest) String() string { func (*GetSandboxLogsRequest) ProtoMessage() {} func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13266,7 +13508,7 @@ func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *GetSandboxLogsRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -13324,7 +13566,7 @@ type PushSandboxLogsRequest struct { func (x *PushSandboxLogsRequest) Reset() { *x = PushSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13336,7 +13578,7 @@ func (x *PushSandboxLogsRequest) String() string { func (*PushSandboxLogsRequest) ProtoMessage() {} func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13349,7 +13591,7 @@ func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *PushSandboxLogsRequest) GetSandboxId() string { @@ -13375,7 +13617,7 @@ type PushSandboxLogsResponse struct { func (x *PushSandboxLogsResponse) Reset() { *x = PushSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13387,7 +13629,7 @@ func (x *PushSandboxLogsResponse) String() string { func (*PushSandboxLogsResponse) ProtoMessage() {} func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13400,7 +13642,7 @@ func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{167} } // Get sandbox logs response. @@ -13416,7 +13658,7 @@ type GetSandboxLogsResponse struct { func (x *GetSandboxLogsResponse) Reset() { *x = GetSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13428,7 +13670,7 @@ func (x *GetSandboxLogsResponse) String() string { func (*GetSandboxLogsResponse) ProtoMessage() {} func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13441,7 +13683,7 @@ func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { @@ -13467,6 +13709,7 @@ type SupervisorMessage struct { // *SupervisorMessage_Heartbeat // *SupervisorMessage_RelayOpenResult // *SupervisorMessage_RelayClose + // *SupervisorMessage_ConfigUpdateResult Payload isSupervisorMessage_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -13474,7 +13717,7 @@ type SupervisorMessage struct { func (x *SupervisorMessage) Reset() { *x = SupervisorMessage{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13486,7 +13729,7 @@ func (x *SupervisorMessage) String() string { func (*SupervisorMessage) ProtoMessage() {} func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13499,7 +13742,7 @@ func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. func (*SupervisorMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{169} } func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { @@ -13545,6 +13788,15 @@ func (x *SupervisorMessage) GetRelayClose() *RelayClose { return nil } +func (x *SupervisorMessage) GetConfigUpdateResult() *ConfigUpdateResult { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_ConfigUpdateResult); ok { + return x.ConfigUpdateResult + } + } + return nil +} + type isSupervisorMessage_Payload interface { isSupervisorMessage_Payload() } @@ -13565,6 +13817,10 @@ type SupervisorMessage_RelayClose struct { RelayClose *RelayClose `protobuf:"bytes,4,opt,name=relay_close,json=relayClose,proto3,oneof"` } +type SupervisorMessage_ConfigUpdateResult struct { + ConfigUpdateResult *ConfigUpdateResult `protobuf:"bytes,5,opt,name=config_update_result,json=configUpdateResult,proto3,oneof"` +} + func (*SupervisorMessage_Hello) isSupervisorMessage_Payload() {} func (*SupervisorMessage_Heartbeat) isSupervisorMessage_Payload() {} @@ -13573,6 +13829,8 @@ func (*SupervisorMessage_RelayOpenResult) isSupervisorMessage_Payload() {} func (*SupervisorMessage_RelayClose) isSupervisorMessage_Payload() {} +func (*SupervisorMessage_ConfigUpdateResult) isSupervisorMessage_Payload() {} + // Envelope for gateway-to-supervisor messages on the ConnectSupervisor stream. type GatewayMessage struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -13584,6 +13842,7 @@ type GatewayMessage struct { // *GatewayMessage_RelayOpen // *GatewayMessage_RelayClose // *GatewayMessage_SessionRedirect + // *GatewayMessage_ConfigUpdate Payload isGatewayMessage_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -13591,7 +13850,7 @@ type GatewayMessage struct { func (x *GatewayMessage) Reset() { *x = GatewayMessage{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13603,7 +13862,7 @@ func (x *GatewayMessage) String() string { func (*GatewayMessage) ProtoMessage() {} func (x *GatewayMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13616,7 +13875,7 @@ func (x *GatewayMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. func (*GatewayMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { @@ -13680,6 +13939,15 @@ func (x *GatewayMessage) GetSessionRedirect() *SessionRedirect { return nil } +func (x *GatewayMessage) GetConfigUpdate() *ConfigUpdate { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_ConfigUpdate); ok { + return x.ConfigUpdate + } + } + return nil +} + type isGatewayMessage_Payload interface { isGatewayMessage_Payload() } @@ -13708,6 +13976,10 @@ type GatewayMessage_SessionRedirect struct { SessionRedirect *SessionRedirect `protobuf:"bytes,6,opt,name=session_redirect,json=sessionRedirect,proto3,oneof"` } +type GatewayMessage_ConfigUpdate struct { + ConfigUpdate *ConfigUpdate `protobuf:"bytes,7,opt,name=config_update,json=configUpdate,proto3,oneof"` +} + func (*GatewayMessage_SessionAccepted) isGatewayMessage_Payload() {} func (*GatewayMessage_SessionRejected) isGatewayMessage_Payload() {} @@ -13720,6 +13992,8 @@ func (*GatewayMessage_RelayClose) isGatewayMessage_Payload() {} func (*GatewayMessage_SessionRedirect) isGatewayMessage_Payload() {} +func (*GatewayMessage_ConfigUpdate) isGatewayMessage_Payload() {} + // Supervisor identifies itself and the sandbox it manages. type SupervisorHello struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -13740,13 +14014,18 @@ type SupervisorHello struct { // The supervisor understands SessionRedirect. Gateways never redirect a // supervisor that does not set this. SupportsSessionRedirect bool `protobuf:"varint,6,opt,name=supports_session_redirect,json=supportsSessionRedirect,proto3" json:"supports_session_redirect,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The supervisor can receive its configuration over this stream instead of + // polling for it: it applies the bootstrap ConfigUpdate and later + // ConfigUpdate payloads, and answers each with its result and admission. + // Gateways push configuration only to supervisors that set this. + SupportsConfigPush bool `protobuf:"varint,7,opt,name=supports_config_push,json=supportsConfigPush,proto3" json:"supports_config_push,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SupervisorHello) Reset() { *x = SupervisorHello{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13758,7 +14037,7 @@ func (x *SupervisorHello) String() string { func (*SupervisorHello) ProtoMessage() {} func (x *SupervisorHello) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13771,7 +14050,7 @@ func (x *SupervisorHello) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. func (*SupervisorHello) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *SupervisorHello) GetSandboxId() string { @@ -13816,11 +14095,26 @@ func (x *SupervisorHello) GetSupportsSessionRedirect() bool { return false } +func (x *SupervisorHello) GetSupportsConfigPush() bool { + if x != nil { + return x.SupportsConfigPush + } + return false +} + // Gateway accepts the supervisor session. type SessionAccepted struct { state protoimpl.MessageState `protogen:"open.v1"` // Gateway-assigned session ID for this connection. SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // The session's first configuration update. Present exactly when + // config_push_enabled is set. + Bootstrap *ConfigUpdate `protobuf:"bytes,3,opt,name=bootstrap,proto3" json:"bootstrap,omitempty"` + // The gateway pushes configuration authoritatively on this stream instead + // of the supervisor polling for it: the bootstrap is required, ConfigUpdate + // payloads are applied and acknowledged, and the supervisor stops polling. + // Set only for supervisors that set SupervisorHello.supports_config_push. + ConfigPushEnabled bool `protobuf:"varint,4,opt,name=config_push_enabled,json=configPushEnabled,proto3" json:"config_push_enabled,omitempty"` // Recommended heartbeat interval. HeartbeatInterval *durationpb.Duration `protobuf:"bytes,102,opt,name=heartbeat_interval,json=heartbeatInterval,proto3" json:"heartbeat_interval,omitempty"` unknownFields protoimpl.UnknownFields @@ -13829,7 +14123,7 @@ type SessionAccepted struct { func (x *SessionAccepted) Reset() { *x = SessionAccepted{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13841,7 +14135,7 @@ func (x *SessionAccepted) String() string { func (*SessionAccepted) ProtoMessage() {} func (x *SessionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13854,7 +14148,7 @@ func (x *SessionAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. func (*SessionAccepted) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *SessionAccepted) GetSessionId() string { @@ -13864,6 +14158,20 @@ func (x *SessionAccepted) GetSessionId() string { return "" } +func (x *SessionAccepted) GetBootstrap() *ConfigUpdate { + if x != nil { + return x.Bootstrap + } + return nil +} + +func (x *SessionAccepted) GetConfigPushEnabled() bool { + if x != nil { + return x.ConfigPushEnabled + } + return false +} + func (x *SessionAccepted) GetHeartbeatInterval() *durationpb.Duration { if x != nil { return x.HeartbeatInterval @@ -13871,30 +14179,37 @@ func (x *SessionAccepted) GetHeartbeatInterval() *durationpb.Duration { return nil } -// Gateway rejects the supervisor session. -type SessionRejected struct { +// Complete gateway-owned configuration for a supervisor session. Each update +// replaces the previous one. The supervisor applies the two parts together +// and answers every update with one ConfigUpdateResult before the gateway +// sends the next. +type ConfigUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` - // Human-readable rejection reason. - Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Opaque non-empty identifier scoped to the active supervisor session. + UpdateId string `protobuf:"bytes,1,opt,name=update_id,json=updateId,proto3" json:"update_id,omitempty"` + SandboxConfig *sandboxv1.SandboxConfigSnapshot `protobuf:"bytes,2,opt,name=sandbox_config,json=sandboxConfig,proto3" json:"sandbox_config,omitempty"` + // The provider environment the sandbox configuration names through its + // provider revision, attachment epoch, and policy hash. + ProviderEnvironment *ProviderEnvironmentSnapshot `protobuf:"bytes,3,opt,name=provider_environment,json=providerEnvironment,proto3" json:"provider_environment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SessionRejected) Reset() { - *x = SessionRejected{} - mi := &file_openshell_proto_msgTypes[171] +func (x *ConfigUpdate) Reset() { + *x = ConfigUpdate{} + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SessionRejected) String() string { +func (x *ConfigUpdate) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SessionRejected) ProtoMessage() {} +func (*ConfigUpdate) ProtoMessage() {} -func (x *SessionRejected) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] +func (x *ConfigUpdate) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13905,50 +14220,58 @@ func (x *SessionRejected) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. -func (*SessionRejected) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} +// Deprecated: Use ConfigUpdate.ProtoReflect.Descriptor instead. +func (*ConfigUpdate) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{173} } -func (x *SessionRejected) GetReason() string { +func (x *ConfigUpdate) GetUpdateId() string { if x != nil { - return x.Reason + return x.UpdateId } return "" } -// Gateway declines to own this session and names the replica that should. -// -// Sent instead of SessionAccepted when the placement ring puts the sandbox on -// a different live replica, and to established sessions when their gateway -// shuts down. The supervisor reconnects to peer_endpoint once with -// SupervisorHello.redirected set, and falls back to its configured gateway -// address if that fails. -type SessionRedirect struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Peer endpoint of the replica that should own this sandbox. - PeerEndpoint string `protobuf:"bytes,1,opt,name=peer_endpoint,json=peerEndpoint,proto3" json:"peer_endpoint,omitempty"` - // Replica ID that should own this sandbox, for logging. - OwnerReplicaId string `protobuf:"bytes,2,opt,name=owner_replica_id,json=ownerReplicaId,proto3" json:"owner_replica_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *ConfigUpdate) GetSandboxConfig() *sandboxv1.SandboxConfigSnapshot { + if x != nil { + return x.SandboxConfig + } + return nil } -func (x *SessionRedirect) Reset() { - *x = SessionRedirect{} - mi := &file_openshell_proto_msgTypes[172] +func (x *ConfigUpdate) GetProviderEnvironment() *ProviderEnvironmentSnapshot { + if x != nil { + return x.ProviderEnvironment + } + return nil +} + +// Sanitized application failure. Messages must not contain configuration +// payloads, credentials, or provider values. +type ConfigApplyFailure struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigApplyFailure) Reset() { + *x = ConfigApplyFailure{} + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SessionRedirect) String() string { +func (x *ConfigApplyFailure) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SessionRedirect) ProtoMessage() {} +func (*ConfigApplyFailure) ProtoMessage() {} -func (x *SessionRedirect) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] +func (x *ConfigApplyFailure) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13959,35 +14282,294 @@ func (x *SessionRedirect) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SessionRedirect.ProtoReflect.Descriptor instead. -func (*SessionRedirect) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} +// Deprecated: Use ConfigApplyFailure.ProtoReflect.Descriptor instead. +func (*ConfigApplyFailure) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{174} } -func (x *SessionRedirect) GetPeerEndpoint() string { +func (x *ConfigApplyFailure) GetCode() string { if x != nil { - return x.PeerEndpoint + return x.Code } return "" } -func (x *SessionRedirect) GetOwnerReplicaId() string { +func (x *ConfigApplyFailure) GetMessage() string { if x != nil { - return x.OwnerReplicaId + return x.Message } return "" } -// Supervisor heartbeat. -type SupervisorHeartbeat struct { - state protoimpl.MessageState `protogen:"open.v1"` +func (x *ConfigApplyFailure) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +// Application result for one ConfigUpdate, including the bootstrap. +type ConfigUpdateResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Echoes ConfigUpdate.update_id for session-local correlation. + UpdateId string `protobuf:"bytes,1,opt,name=update_id,json=updateId,proto3" json:"update_id,omitempty"` + Outcome ConfigApplyOutcome `protobuf:"varint,2,opt,name=outcome,proto3,enum=openshell.v1.ConfigApplyOutcome" json:"outcome,omitempty"` + // Why the update did not take effect. Omitted when it did. + Failure *ConfigApplyFailure `protobuf:"bytes,3,opt,name=failure,proto3" json:"failure,omitempty"` + // Admission state of the delivered sandbox configuration. + Admission *SandboxConfigurationAdmission `protobuf:"bytes,4,opt,name=admission,proto3" json:"admission,omitempty"` + // Load result of the sandbox policy revision the update changed, as a + // polling supervisor reports it with ReportPolicyStatus. Omitted when the + // update did not change the sandbox policy. + PolicyLoad *PolicyLoadResult `protobuf:"bytes,5,opt,name=policy_load,json=policyLoad,proto3" json:"policy_load,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *SupervisorHeartbeat) Reset() { +func (x *ConfigUpdateResult) Reset() { + *x = ConfigUpdateResult{} + mi := &file_openshell_proto_msgTypes[175] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigUpdateResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigUpdateResult) ProtoMessage() {} + +func (x *ConfigUpdateResult) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[175] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigUpdateResult.ProtoReflect.Descriptor instead. +func (*ConfigUpdateResult) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{175} +} + +func (x *ConfigUpdateResult) GetUpdateId() string { + if x != nil { + return x.UpdateId + } + return "" +} + +func (x *ConfigUpdateResult) GetOutcome() ConfigApplyOutcome { + if x != nil { + return x.Outcome + } + return ConfigApplyOutcome_CONFIG_APPLY_OUTCOME_UNSPECIFIED +} + +func (x *ConfigUpdateResult) GetFailure() *ConfigApplyFailure { + if x != nil { + return x.Failure + } + return nil +} + +func (x *ConfigUpdateResult) GetAdmission() *SandboxConfigurationAdmission { + if x != nil { + return x.Admission + } + return nil +} + +func (x *ConfigUpdateResult) GetPolicyLoad() *PolicyLoadResult { + if x != nil { + return x.PolicyLoad + } + return nil +} + +// Load result of one sandbox policy revision. +type PolicyLoadResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + Loaded bool `protobuf:"varint,2,opt,name=loaded,proto3" json:"loaded,omitempty"` + // Sanitized load error. Empty when loaded. + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyLoadResult) Reset() { + *x = PolicyLoadResult{} + mi := &file_openshell_proto_msgTypes[176] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyLoadResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyLoadResult) ProtoMessage() {} + +func (x *PolicyLoadResult) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[176] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyLoadResult.ProtoReflect.Descriptor instead. +func (*PolicyLoadResult) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{176} +} + +func (x *PolicyLoadResult) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *PolicyLoadResult) GetLoaded() bool { + if x != nil { + return x.Loaded + } + return false +} + +func (x *PolicyLoadResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// Gateway rejects the supervisor session. +type SessionRejected struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable rejection reason. + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRejected) Reset() { + *x = SessionRejected{} + mi := &file_openshell_proto_msgTypes[177] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRejected) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRejected) ProtoMessage() {} + +func (x *SessionRejected) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[177] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. +func (*SessionRejected) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{177} +} + +func (x *SessionRejected) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Gateway declines to own this session and names the replica that should. +// +// Sent instead of SessionAccepted when the placement ring puts the sandbox on +// a different live replica, and to established sessions when their gateway +// shuts down. The supervisor reconnects to peer_endpoint once with +// SupervisorHello.redirected set, and falls back to its configured gateway +// address if that fails. +type SessionRedirect struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Peer endpoint of the replica that should own this sandbox. + PeerEndpoint string `protobuf:"bytes,1,opt,name=peer_endpoint,json=peerEndpoint,proto3" json:"peer_endpoint,omitempty"` + // Replica ID that should own this sandbox, for logging. + OwnerReplicaId string `protobuf:"bytes,2,opt,name=owner_replica_id,json=ownerReplicaId,proto3" json:"owner_replica_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRedirect) Reset() { + *x = SessionRedirect{} + mi := &file_openshell_proto_msgTypes[178] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRedirect) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRedirect) ProtoMessage() {} + +func (x *SessionRedirect) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[178] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRedirect.ProtoReflect.Descriptor instead. +func (*SessionRedirect) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{178} +} + +func (x *SessionRedirect) GetPeerEndpoint() string { + if x != nil { + return x.PeerEndpoint + } + return "" +} + +func (x *SessionRedirect) GetOwnerReplicaId() string { + if x != nil { + return x.OwnerReplicaId + } + return "" +} + +// Supervisor heartbeat. +type SupervisorHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorHeartbeat) Reset() { *x = SupervisorHeartbeat{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13999,7 +14581,7 @@ func (x *SupervisorHeartbeat) String() string { func (*SupervisorHeartbeat) ProtoMessage() {} func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14012,7 +14594,7 @@ func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{179} } // Gateway heartbeat. @@ -14024,7 +14606,7 @@ type GatewayHeartbeat struct { func (x *GatewayHeartbeat) Reset() { *x = GatewayHeartbeat{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14036,7 +14618,7 @@ func (x *GatewayHeartbeat) String() string { func (*GatewayHeartbeat) ProtoMessage() {} func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14049,7 +14631,7 @@ func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{180} } // Terminal result reported before the supervisor shuts down. A successful RPC @@ -14066,7 +14648,7 @@ type ReportMainProcessExitRequest struct { func (x *ReportMainProcessExitRequest) Reset() { *x = ReportMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14078,7 +14660,7 @@ func (x *ReportMainProcessExitRequest) String() string { func (*ReportMainProcessExitRequest) ProtoMessage() {} func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14091,7 +14673,7 @@ func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *ReportMainProcessExitRequest) GetSandboxId() string { @@ -14123,7 +14705,7 @@ type ReportMainProcessExitResponse struct { func (x *ReportMainProcessExitResponse) Reset() { *x = ReportMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14135,7 +14717,7 @@ func (x *ReportMainProcessExitResponse) String() string { func (*ReportMainProcessExitResponse) ProtoMessage() {} func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14148,7 +14730,7 @@ func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{182} } // Terminal-delivery completion reported after all expected foreground SSH @@ -14163,7 +14745,7 @@ type FinalizeMainProcessExitRequest struct { func (x *FinalizeMainProcessExitRequest) Reset() { *x = FinalizeMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14175,7 +14757,7 @@ func (x *FinalizeMainProcessExitRequest) String() string { func (*FinalizeMainProcessExitRequest) ProtoMessage() {} func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14188,7 +14770,7 @@ func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *FinalizeMainProcessExitRequest) GetSandboxId() string { @@ -14213,7 +14795,7 @@ type FinalizeMainProcessExitResponse struct { func (x *FinalizeMainProcessExitResponse) Reset() { *x = FinalizeMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14225,7 +14807,7 @@ func (x *FinalizeMainProcessExitResponse) String() string { func (*FinalizeMainProcessExitResponse) ProtoMessage() {} func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14238,7 +14820,7 @@ func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{184} } // Gateway requests the supervisor to open a relay channel. @@ -14267,7 +14849,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14279,7 +14861,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14292,7 +14874,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *RelayOpen) GetChannelId() string { @@ -14359,7 +14941,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14371,7 +14953,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14384,7 +14966,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{186} } // TCP target dialed by the supervisor from inside the sandbox. @@ -14400,7 +14982,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14412,7 +14994,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14425,7 +15007,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *TcpRelayTarget) GetHost() string { @@ -14453,7 +15035,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14465,7 +15047,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14478,7 +15060,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{188} } func (x *RelayInit) GetChannelId() string { @@ -14505,7 +15087,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14517,7 +15099,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14530,7 +15112,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -14590,7 +15172,7 @@ type PeerRelayInit struct { func (x *PeerRelayInit) Reset() { *x = PeerRelayInit{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14602,7 +15184,7 @@ func (x *PeerRelayInit) String() string { func (*PeerRelayInit) ProtoMessage() {} func (x *PeerRelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14615,7 +15197,7 @@ func (x *PeerRelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerRelayInit.ProtoReflect.Descriptor instead. func (*PeerRelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *PeerRelayInit) GetSandboxId() string { @@ -14653,7 +15235,7 @@ type PeerRelayFrame struct { func (x *PeerRelayFrame) Reset() { *x = PeerRelayFrame{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14665,7 +15247,7 @@ func (x *PeerRelayFrame) String() string { func (*PeerRelayFrame) ProtoMessage() {} func (x *PeerRelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14678,7 +15260,7 @@ func (x *PeerRelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerRelayFrame.ProtoReflect.Descriptor instead. func (*PeerRelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *PeerRelayFrame) GetPayload() isPeerRelayFrame_Payload { @@ -14722,6 +15304,271 @@ func (*PeerRelayFrame_Init) isPeerRelayFrame_Payload() {} func (*PeerRelayFrame_Data) isPeerRelayFrame_Payload() {} +type PeerConfigSandboxTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Fences a targeted notification to the session observed by the sending replica. + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerConfigSandboxTarget) Reset() { + *x = PeerConfigSandboxTarget{} + mi := &file_openshell_proto_msgTypes[192] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerConfigSandboxTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerConfigSandboxTarget) ProtoMessage() {} + +func (x *PeerConfigSandboxTarget) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[192] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerConfigSandboxTarget.ProtoReflect.Descriptor instead. +func (*PeerConfigSandboxTarget) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{192} +} + +func (x *PeerConfigSandboxTarget) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PeerConfigSandboxTarget) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// Sandboxes in a workspace that attach the named provider. +type PeerConfigProviderTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workspace string `protobuf:"bytes,1,opt,name=workspace,proto3" json:"workspace,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerConfigProviderTarget) Reset() { + *x = PeerConfigProviderTarget{} + mi := &file_openshell_proto_msgTypes[193] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerConfigProviderTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerConfigProviderTarget) ProtoMessage() {} + +func (x *PeerConfigProviderTarget) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[193] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerConfigProviderTarget.ProtoReflect.Descriptor instead. +func (*PeerConfigProviderTarget) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{193} +} + +func (x *PeerConfigProviderTarget) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +func (x *PeerConfigProviderTarget) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type PeerNotifyConfigUpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Scope: + // + // *PeerNotifyConfigUpdateRequest_Sandbox + // *PeerNotifyConfigUpdateRequest_Workspace + // *PeerNotifyConfigUpdateRequest_AllConnected + // *PeerNotifyConfigUpdateRequest_Provider + Scope isPeerNotifyConfigUpdateRequest_Scope `protobuf_oneof:"scope"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerNotifyConfigUpdateRequest) Reset() { + *x = PeerNotifyConfigUpdateRequest{} + mi := &file_openshell_proto_msgTypes[194] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerNotifyConfigUpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerNotifyConfigUpdateRequest) ProtoMessage() {} + +func (x *PeerNotifyConfigUpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[194] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerNotifyConfigUpdateRequest.ProtoReflect.Descriptor instead. +func (*PeerNotifyConfigUpdateRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{194} +} + +func (x *PeerNotifyConfigUpdateRequest) GetScope() isPeerNotifyConfigUpdateRequest_Scope { + if x != nil { + return x.Scope + } + return nil +} + +func (x *PeerNotifyConfigUpdateRequest) GetSandbox() *PeerConfigSandboxTarget { + if x != nil { + if x, ok := x.Scope.(*PeerNotifyConfigUpdateRequest_Sandbox); ok { + return x.Sandbox + } + } + return nil +} + +func (x *PeerNotifyConfigUpdateRequest) GetWorkspace() string { + if x != nil { + if x, ok := x.Scope.(*PeerNotifyConfigUpdateRequest_Workspace); ok { + return x.Workspace + } + } + return "" +} + +func (x *PeerNotifyConfigUpdateRequest) GetAllConnected() bool { + if x != nil { + if x, ok := x.Scope.(*PeerNotifyConfigUpdateRequest_AllConnected); ok { + return x.AllConnected + } + } + return false +} + +func (x *PeerNotifyConfigUpdateRequest) GetProvider() *PeerConfigProviderTarget { + if x != nil { + if x, ok := x.Scope.(*PeerNotifyConfigUpdateRequest_Provider); ok { + return x.Provider + } + } + return nil +} + +type isPeerNotifyConfigUpdateRequest_Scope interface { + isPeerNotifyConfigUpdateRequest_Scope() +} + +type PeerNotifyConfigUpdateRequest_Sandbox struct { + Sandbox *PeerConfigSandboxTarget `protobuf:"bytes,1,opt,name=sandbox,proto3,oneof"` +} + +type PeerNotifyConfigUpdateRequest_Workspace struct { + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3,oneof"` +} + +type PeerNotifyConfigUpdateRequest_AllConnected struct { + AllConnected bool `protobuf:"varint,3,opt,name=all_connected,json=allConnected,proto3,oneof"` +} + +type PeerNotifyConfigUpdateRequest_Provider struct { + Provider *PeerConfigProviderTarget `protobuf:"bytes,4,opt,name=provider,proto3,oneof"` +} + +func (*PeerNotifyConfigUpdateRequest_Sandbox) isPeerNotifyConfigUpdateRequest_Scope() {} + +func (*PeerNotifyConfigUpdateRequest_Workspace) isPeerNotifyConfigUpdateRequest_Scope() {} + +func (*PeerNotifyConfigUpdateRequest_AllConnected) isPeerNotifyConfigUpdateRequest_Scope() {} + +func (*PeerNotifyConfigUpdateRequest_Provider) isPeerNotifyConfigUpdateRequest_Scope() {} + +type PeerNotifyConfigUpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The targeted session moved before the receiving replica accepted the notification. + StaleOwner bool `protobuf:"varint,1,opt,name=stale_owner,json=staleOwner,proto3" json:"stale_owner,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerNotifyConfigUpdateResponse) Reset() { + *x = PeerNotifyConfigUpdateResponse{} + mi := &file_openshell_proto_msgTypes[195] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerNotifyConfigUpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerNotifyConfigUpdateResponse) ProtoMessage() {} + +func (x *PeerNotifyConfigUpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[195] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerNotifyConfigUpdateResponse.ProtoReflect.Descriptor instead. +func (*PeerNotifyConfigUpdateResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{195} +} + +func (x *PeerNotifyConfigUpdateResponse) GetStaleOwner() bool { + if x != nil { + return x.StaleOwner + } + return false +} + // Supervisor reports the result of a relay open request. type RelayOpenResult struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14737,7 +15584,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[196] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14749,7 +15596,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[196] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14762,7 +15609,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{196} } func (x *RelayOpenResult) GetChannelId() string { @@ -14799,7 +15646,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[197] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14811,7 +15658,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[197] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14824,7 +15671,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{197} } func (x *RelayClose) GetChannelId() string { @@ -14858,7 +15705,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[198] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14870,7 +15717,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[198] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14883,7 +15730,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{198} } func (x *L7RequestSample) GetMethod() string { @@ -14957,7 +15804,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[199] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14969,7 +15816,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[199] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14982,7 +15829,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{189} + return file_openshell_proto_rawDescGZIP(), []int{199} } func (x *DenialSummary) GetSandboxId() string { @@ -15117,7 +15964,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[200] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15129,7 +15976,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[200] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15142,7 +15989,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{190} + return file_openshell_proto_rawDescGZIP(), []int{200} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -15175,7 +16022,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[201] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15187,7 +16034,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[201] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15200,7 +16047,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{191} + return file_openshell_proto_rawDescGZIP(), []int{201} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -15288,7 +16135,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[202] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15300,7 +16147,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[202] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15313,7 +16160,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{192} + return file_openshell_proto_rawDescGZIP(), []int{202} } func (x *PolicyChunk) GetId() string { @@ -15501,7 +16348,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[203] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15513,7 +16360,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[203] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15526,7 +16373,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{193} + return file_openshell_proto_rawDescGZIP(), []int{203} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -15585,7 +16432,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[204] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15597,7 +16444,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[204] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15610,7 +16457,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{194} + return file_openshell_proto_rawDescGZIP(), []int{204} } func (x *SubmitPolicyAnalysisRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15673,7 +16520,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[205] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15685,7 +16532,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[205] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15698,7 +16545,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{195} + return file_openshell_proto_rawDescGZIP(), []int{205} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -15743,7 +16590,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[206] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15755,7 +16602,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[206] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15768,7 +16615,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{196} + return file_openshell_proto_rawDescGZIP(), []int{206} } func (x *GetDraftPolicyRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15808,7 +16655,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[207] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15820,7 +16667,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[207] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15833,7 +16680,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{197} + return file_openshell_proto_rawDescGZIP(), []int{207} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -15884,7 +16731,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[208] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15896,7 +16743,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[208] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15909,7 +16756,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{198} + return file_openshell_proto_rawDescGZIP(), []int{208} } func (x *ApproveDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15959,7 +16806,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[209] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15971,7 +16818,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[209] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15984,7 +16831,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{199} + return file_openshell_proto_rawDescGZIP(), []int{209} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -16020,7 +16867,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[210] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16032,7 +16879,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[210] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16045,7 +16892,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{200} + return file_openshell_proto_rawDescGZIP(), []int{210} } func (x *RejectDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16091,7 +16938,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[211] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16103,7 +16950,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[211] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16116,7 +16963,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{201} + return file_openshell_proto_rawDescGZIP(), []int{211} } // Approve all pending chunks. @@ -16130,7 +16977,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[212] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16142,7 +16989,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[212] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16155,7 +17002,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{202} + return file_openshell_proto_rawDescGZIP(), []int{212} } func (x *DraftChunkApproval) GetChunkId() string { @@ -16191,7 +17038,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[213] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16203,7 +17050,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[213] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16216,7 +17063,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{203} + return file_openshell_proto_rawDescGZIP(), []int{213} } func (x *ApproveAllDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16271,7 +17118,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[214] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16283,7 +17130,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[214] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16296,7 +17143,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{204} + return file_openshell_proto_rawDescGZIP(), []int{214} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -16346,7 +17193,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[215] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16358,7 +17205,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[215] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16371,7 +17218,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{205} + return file_openshell_proto_rawDescGZIP(), []int{215} } func (x *EditDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16417,7 +17264,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[216] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16429,7 +17276,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[216] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16442,7 +17289,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{206} + return file_openshell_proto_rawDescGZIP(), []int{216} } // Reverse an approval (remove merged rule from active policy). @@ -16462,7 +17309,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[217] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16474,7 +17321,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[217] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16487,7 +17334,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{207} + return file_openshell_proto_rawDescGZIP(), []int{217} } func (x *UndoDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16530,7 +17377,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[218] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16542,7 +17389,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[218] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16555,7 +17402,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{208} + return file_openshell_proto_rawDescGZIP(), []int{218} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -16587,7 +17434,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[219] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16599,7 +17446,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[219] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16612,7 +17459,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{209} + return file_openshell_proto_rawDescGZIP(), []int{219} } func (x *ClearDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16646,7 +17493,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[220] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16658,7 +17505,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[220] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16671,7 +17518,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{210} + return file_openshell_proto_rawDescGZIP(), []int{220} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -16693,7 +17540,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[221] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16705,7 +17552,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[221] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16718,7 +17565,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{211} + return file_openshell_proto_rawDescGZIP(), []int{221} } func (x *GetDraftHistoryRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16752,7 +17599,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[222] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16764,7 +17611,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[222] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16777,7 +17624,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{212} + return file_openshell_proto_rawDescGZIP(), []int{222} } func (x *DraftHistoryEntry) GetEventTime() *timestamppb.Timestamp { @@ -16818,7 +17665,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[223] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16830,7 +17677,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[223] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16843,7 +17690,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{213} + return file_openshell_proto_rawDescGZIP(), []int{223} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -16868,7 +17715,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[214] + mi := &file_openshell_proto_msgTypes[224] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16880,7 +17727,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[214] + mi := &file_openshell_proto_msgTypes[224] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16893,7 +17740,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{214} + return file_openshell_proto_rawDescGZIP(), []int{224} } func (x *CreateWorkspaceRequest) GetName() string { @@ -16927,7 +17774,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[215] + mi := &file_openshell_proto_msgTypes[225] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16939,7 +17786,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[215] + mi := &file_openshell_proto_msgTypes[225] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16952,7 +17799,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{215} + return file_openshell_proto_rawDescGZIP(), []int{225} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -16973,7 +17820,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[216] + mi := &file_openshell_proto_msgTypes[226] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16985,7 +17832,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[216] + mi := &file_openshell_proto_msgTypes[226] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16998,7 +17845,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{216} + return file_openshell_proto_rawDescGZIP(), []int{226} } func (x *GetWorkspaceRequest) GetName() string { @@ -17018,7 +17865,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[217] + mi := &file_openshell_proto_msgTypes[227] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17030,7 +17877,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[217] + mi := &file_openshell_proto_msgTypes[227] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17043,7 +17890,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{217} + return file_openshell_proto_rawDescGZIP(), []int{227} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -17070,7 +17917,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[218] + mi := &file_openshell_proto_msgTypes[228] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17082,7 +17929,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[218] + mi := &file_openshell_proto_msgTypes[228] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17095,7 +17942,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{218} + return file_openshell_proto_rawDescGZIP(), []int{228} } func (x *ListWorkspacesRequest) GetPageSize() int32 { @@ -17131,7 +17978,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[219] + mi := &file_openshell_proto_msgTypes[229] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17143,7 +17990,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[219] + mi := &file_openshell_proto_msgTypes[229] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17156,7 +18003,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{219} + return file_openshell_proto_rawDescGZIP(), []int{229} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -17187,7 +18034,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[220] + mi := &file_openshell_proto_msgTypes[230] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17199,7 +18046,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[220] + mi := &file_openshell_proto_msgTypes[230] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17212,7 +18059,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{220} + return file_openshell_proto_rawDescGZIP(), []int{230} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -17246,7 +18093,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[221] + mi := &file_openshell_proto_msgTypes[231] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17258,7 +18105,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[221] + mi := &file_openshell_proto_msgTypes[231] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17271,7 +18118,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{221} + return file_openshell_proto_rawDescGZIP(), []int{231} } func (x *DeleteWorkspaceResponse) GetOutcome() DeletionOutcome { @@ -17295,7 +18142,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[222] + mi := &file_openshell_proto_msgTypes[232] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17307,7 +18154,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[222] + mi := &file_openshell_proto_msgTypes[232] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17320,7 +18167,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{222} + return file_openshell_proto_rawDescGZIP(), []int{232} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -17361,7 +18208,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[223] + mi := &file_openshell_proto_msgTypes[233] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17373,7 +18220,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[223] + mi := &file_openshell_proto_msgTypes[233] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17386,7 +18233,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{223} + return file_openshell_proto_rawDescGZIP(), []int{233} } func (x *AddWorkspaceMemberRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17427,7 +18274,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[224] + mi := &file_openshell_proto_msgTypes[234] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17439,7 +18286,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[224] + mi := &file_openshell_proto_msgTypes[234] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17452,7 +18299,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{224} + return file_openshell_proto_rawDescGZIP(), []int{234} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -17478,7 +18325,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[225] + mi := &file_openshell_proto_msgTypes[235] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17490,7 +18337,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[225] + mi := &file_openshell_proto_msgTypes[235] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17503,7 +18350,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{225} + return file_openshell_proto_rawDescGZIP(), []int{235} } func (x *RemoveWorkspaceMemberRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17544,7 +18391,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[226] + mi := &file_openshell_proto_msgTypes[236] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17556,7 +18403,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[226] + mi := &file_openshell_proto_msgTypes[236] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17569,7 +18416,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{226} + return file_openshell_proto_rawDescGZIP(), []int{236} } func (x *RemoveWorkspaceMemberResponse) GetOutcome() DeletionOutcome { @@ -17596,7 +18443,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[227] + mi := &file_openshell_proto_msgTypes[237] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17608,7 +18455,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[227] + mi := &file_openshell_proto_msgTypes[237] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17621,7 +18468,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{227} + return file_openshell_proto_rawDescGZIP(), []int{237} } func (x *ListWorkspaceMembersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -17657,7 +18504,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[228] + mi := &file_openshell_proto_msgTypes[238] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17669,7 +18516,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[228] + mi := &file_openshell_proto_msgTypes[238] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17682,7 +18529,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{228} + return file_openshell_proto_rawDescGZIP(), []int{238} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -17717,7 +18564,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[229] + mi := &file_openshell_proto_msgTypes[239] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17729,7 +18576,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[229] + mi := &file_openshell_proto_msgTypes[239] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17742,7 +18589,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{229} + return file_openshell_proto_rawDescGZIP(), []int{239} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -17782,7 +18629,7 @@ type EndpointObservation struct { func (x *EndpointObservation) Reset() { *x = EndpointObservation{} - mi := &file_openshell_proto_msgTypes[230] + mi := &file_openshell_proto_msgTypes[240] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17794,7 +18641,7 @@ func (x *EndpointObservation) String() string { func (*EndpointObservation) ProtoMessage() {} func (x *EndpointObservation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[230] + mi := &file_openshell_proto_msgTypes[240] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17807,7 +18654,7 @@ func (x *EndpointObservation) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointObservation.ProtoReflect.Descriptor instead. func (*EndpointObservation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{230} + return file_openshell_proto_rawDescGZIP(), []int{240} } func (x *EndpointObservation) GetEndpointId() string { @@ -17860,7 +18707,7 @@ type ReportEndpointStatusRequest struct { func (x *ReportEndpointStatusRequest) Reset() { *x = ReportEndpointStatusRequest{} - mi := &file_openshell_proto_msgTypes[231] + mi := &file_openshell_proto_msgTypes[241] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17872,7 +18719,7 @@ func (x *ReportEndpointStatusRequest) String() string { func (*ReportEndpointStatusRequest) ProtoMessage() {} func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[231] + mi := &file_openshell_proto_msgTypes[241] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17885,7 +18732,7 @@ func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportEndpointStatusRequest.ProtoReflect.Descriptor instead. func (*ReportEndpointStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{231} + return file_openshell_proto_rawDescGZIP(), []int{241} } func (x *ReportEndpointStatusRequest) GetSandboxId() string { @@ -17954,7 +18801,7 @@ type TokenGrantStatus struct { func (x *TokenGrantStatus) Reset() { *x = TokenGrantStatus{} - mi := &file_openshell_proto_msgTypes[232] + mi := &file_openshell_proto_msgTypes[242] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -17966,7 +18813,7 @@ func (x *TokenGrantStatus) String() string { func (*TokenGrantStatus) ProtoMessage() {} func (x *TokenGrantStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[232] + mi := &file_openshell_proto_msgTypes[242] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -17979,7 +18826,7 @@ func (x *TokenGrantStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenGrantStatus.ProtoReflect.Descriptor instead. func (*TokenGrantStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{232} + return file_openshell_proto_rawDescGZIP(), []int{242} } func (x *TokenGrantStatus) GetLastFailure() *TokenGrantFailureObservation { @@ -18006,7 +18853,7 @@ type TokenGrantFailureObservation struct { func (x *TokenGrantFailureObservation) Reset() { *x = TokenGrantFailureObservation{} - mi := &file_openshell_proto_msgTypes[233] + mi := &file_openshell_proto_msgTypes[243] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18018,7 +18865,7 @@ func (x *TokenGrantFailureObservation) String() string { func (*TokenGrantFailureObservation) ProtoMessage() {} func (x *TokenGrantFailureObservation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[233] + mi := &file_openshell_proto_msgTypes[243] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18031,7 +18878,7 @@ func (x *TokenGrantFailureObservation) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenGrantFailureObservation.ProtoReflect.Descriptor instead. func (*TokenGrantFailureObservation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{233} + return file_openshell_proto_rawDescGZIP(), []int{243} } func (x *TokenGrantFailureObservation) GetDestinationHost() string { @@ -18064,7 +18911,7 @@ type ReportEndpointStatusResponse struct { func (x *ReportEndpointStatusResponse) Reset() { *x = ReportEndpointStatusResponse{} - mi := &file_openshell_proto_msgTypes[234] + mi := &file_openshell_proto_msgTypes[244] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18076,7 +18923,7 @@ func (x *ReportEndpointStatusResponse) String() string { func (*ReportEndpointStatusResponse) ProtoMessage() {} func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[234] + mi := &file_openshell_proto_msgTypes[244] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18089,7 +18936,7 @@ func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportEndpointStatusResponse.ProtoReflect.Descriptor instead. func (*ReportEndpointStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{234} + return file_openshell_proto_rawDescGZIP(), []int{244} } // A configured endpoint and its last accepted network result in one record. @@ -18123,7 +18970,7 @@ type EndpointStatus struct { func (x *EndpointStatus) Reset() { *x = EndpointStatus{} - mi := &file_openshell_proto_msgTypes[235] + mi := &file_openshell_proto_msgTypes[245] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18135,7 +18982,7 @@ func (x *EndpointStatus) String() string { func (*EndpointStatus) ProtoMessage() {} func (x *EndpointStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[235] + mi := &file_openshell_proto_msgTypes[245] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18148,7 +18995,7 @@ func (x *EndpointStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointStatus.ProtoReflect.Descriptor instead. func (*EndpointStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{235} + return file_openshell_proto_rawDescGZIP(), []int{245} } func (x *EndpointStatus) GetEndpointId() string { @@ -18243,7 +19090,7 @@ type SandboxProvisioning struct { func (x *SandboxProvisioning) Reset() { *x = SandboxProvisioning{} - mi := &file_openshell_proto_msgTypes[236] + mi := &file_openshell_proto_msgTypes[246] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18255,7 +19102,7 @@ func (x *SandboxProvisioning) String() string { func (*SandboxProvisioning) ProtoMessage() {} func (x *SandboxProvisioning) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[236] + mi := &file_openshell_proto_msgTypes[246] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18268,7 +19115,7 @@ func (x *SandboxProvisioning) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxProvisioning.ProtoReflect.Descriptor instead. func (*SandboxProvisioning) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{236} + return file_openshell_proto_rawDescGZIP(), []int{246} } func (x *SandboxProvisioning) GetAttemptId() string { @@ -18391,7 +19238,7 @@ type SandboxServiceExposure struct { func (x *SandboxServiceExposure) Reset() { *x = SandboxServiceExposure{} - mi := &file_openshell_proto_msgTypes[237] + mi := &file_openshell_proto_msgTypes[247] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18403,7 +19250,7 @@ func (x *SandboxServiceExposure) String() string { func (*SandboxServiceExposure) ProtoMessage() {} func (x *SandboxServiceExposure) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[237] + mi := &file_openshell_proto_msgTypes[247] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18416,7 +19263,7 @@ func (x *SandboxServiceExposure) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxServiceExposure.ProtoReflect.Descriptor instead. func (*SandboxServiceExposure) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{237} + return file_openshell_proto_rawDescGZIP(), []int{247} } func (x *SandboxServiceExposure) GetService() string { @@ -18470,7 +19317,7 @@ type EndpointFailure struct { func (x *EndpointFailure) Reset() { *x = EndpointFailure{} - mi := &file_openshell_proto_msgTypes[238] + mi := &file_openshell_proto_msgTypes[248] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -18482,7 +19329,7 @@ func (x *EndpointFailure) String() string { func (*EndpointFailure) ProtoMessage() {} func (x *EndpointFailure) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[238] + mi := &file_openshell_proto_msgTypes[248] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -18495,7 +19342,7 @@ func (x *EndpointFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointFailure.ProtoReflect.Descriptor instead. func (*EndpointFailure) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{238} + return file_openshell_proto_rawDescGZIP(), []int{248} } func (x *EndpointFailure) GetDomain() EndpointFailureDomain { @@ -19404,7 +20251,30 @@ const file_openshell_proto_rawDesc = "" + "\n" + "FilesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x03\x10\x04R\x18credential_expires_at_ms\"\xbd\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x03\x10\x04R\x18credential_expires_at_ms\"\xff\x02\n" + + "\x18ProviderEnvironmentValue\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n" + + "\x05value\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05value\x12\\\n" + + "\x0eclassification\x18\x04 \x01(\x0e24.openshell.v1.ProviderEnvironmentValueClassificationR\x0eclassification\x12a\n" + + "\x19static_credential_binding\x18\x05 \x01(\v2%.openshell.v1.StaticCredentialBindingR\x17staticCredentialBinding\x12C\n" + + "\x0fexpiration_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\x12-\n" + + "\x12stable_placeholder\x18\x06 \x01(\bR\x11stablePlaceholder\"\xaa\x05\n" + + "\x1bProviderEnvironmentSnapshot\x122\n" + + "\x15provider_env_revision\x18\x01 \x01(\x04R\x13providerEnvRevision\x12>\n" + + "\x06values\x18\x02 \x03(\v2&.openshell.v1.ProviderEnvironmentValueR\x06values\x12r\n" + + "\x13dynamic_credentials\x18\x03 \x03(\v2A.openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntryR\x12dynamicCredentials\x12:\n" + + "\x19provider_attachment_epoch\x18\x04 \x01(\tR\x17providerAttachmentEpoch\x12\x1f\n" + + "\vpolicy_hash\x18\x05 \x01(\tR\n" + + "policyHash\x12P\n" + + "\x10readiness_reason\x18\x06 \x01(\x0e2%.openshell.v1.ProviderReadinessReasonR\x0freadinessReason\x12J\n" + + "\x05files\x18\a \x03(\v24.openshell.v1.ProviderEnvironmentSnapshot.FilesEntryR\x05files\x1an\n" + + "\x17DynamicCredentialsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + + "\x05value\x18\x02 \x01(\v2'.openshell.v1.ProviderProfileCredentialR\x05value:\x028\x01\x1a8\n" + + "\n" + + "FilesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbd\x01\n" + "#ExchangeProviderSubjectTokenRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1a\n" + @@ -19557,14 +20427,15 @@ const file_openshell_proto_rawDesc = "" + "\x17PushSandboxLogsResponse\"m\n" + "\x16GetSandboxLogsResponse\x120\n" + "\x04logs\x18\x01 \x03(\v2\x1c.openshell.v1.SandboxLogLineR\x04logs\x12!\n" + - "\fbuffer_total\x18\x02 \x01(\rR\vbufferTotal\"\xa2\x02\n" + + "\fbuffer_total\x18\x02 \x01(\rR\vbufferTotal\"\xf8\x02\n" + "\x11SupervisorMessage\x125\n" + "\x05hello\x18\x01 \x01(\v2\x1d.openshell.v1.SupervisorHelloH\x00R\x05hello\x12A\n" + "\theartbeat\x18\x02 \x01(\v2!.openshell.v1.SupervisorHeartbeatH\x00R\theartbeat\x12K\n" + "\x11relay_open_result\x18\x03 \x01(\v2\x1d.openshell.v1.RelayOpenResultH\x00R\x0frelayOpenResult\x12;\n" + "\vrelay_close\x18\x04 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + - "relayCloseB\t\n" + - "\apayload\"\xb6\x03\n" + + "relayClose\x12T\n" + + "\x14config_update_result\x18\x05 \x01(\v2 .openshell.v1.ConfigUpdateResultH\x00R\x12configUpdateResultB\t\n" + + "\apayload\"\xf9\x03\n" + "\x0eGatewayMessage\x12J\n" + "\x10session_accepted\x18\x01 \x01(\v2\x1d.openshell.v1.SessionAcceptedH\x00R\x0fsessionAccepted\x12J\n" + "\x10session_rejected\x18\x02 \x01(\v2\x1d.openshell.v1.SessionRejectedH\x00R\x0fsessionRejected\x12>\n" + @@ -19573,8 +20444,9 @@ const file_openshell_proto_rawDesc = "" + "relay_open\x18\x04 \x01(\v2\x17.openshell.v1.RelayOpenH\x00R\trelayOpen\x12;\n" + "\vrelay_close\x18\x05 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + "relayClose\x12J\n" + - "\x10session_redirect\x18\x06 \x01(\v2\x1d.openshell.v1.SessionRedirectH\x00R\x0fsessionRedirectB\t\n" + - "\apayload\"\x98\x02\n" + + "\x10session_redirect\x18\x06 \x01(\v2\x1d.openshell.v1.SessionRedirectH\x00R\x0fsessionRedirect\x12A\n" + + "\rconfig_update\x18\a \x01(\v2\x1a.openshell.v1.ConfigUpdateH\x00R\fconfigUpdateB\t\n" + + "\apayload\"\xca\x02\n" + "\x0fSupervisorHello\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + @@ -19585,11 +20457,33 @@ const file_openshell_proto_rawDesc = "" + "\n" + "redirected\x18\x05 \x01(\bR\n" + "redirected\x12:\n" + - "\x19supports_session_redirect\x18\x06 \x01(\bR\x17supportsSessionRedirect\"\x99\x01\n" + + "\x19supports_session_redirect\x18\x06 \x01(\bR\x17supportsSessionRedirect\x120\n" + + "\x14supports_config_push\x18\a \x01(\bR\x12supportsConfigPush\"\x83\x02\n" + "\x0fSessionAccepted\x12\x1d\n" + "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12H\n" + - "\x12heartbeat_interval\x18f \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatIntervalJ\x04\b\x02\x10\x03R\x17heartbeat_interval_secs\")\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x128\n" + + "\tbootstrap\x18\x03 \x01(\v2\x1a.openshell.v1.ConfigUpdateR\tbootstrap\x12.\n" + + "\x13config_push_enabled\x18\x04 \x01(\bR\x11configPushEnabled\x12H\n" + + "\x12heartbeat_interval\x18f \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatIntervalJ\x04\b\x02\x10\x03R\x17heartbeat_interval_secs\"\xdd\x01\n" + + "\fConfigUpdate\x12\x1b\n" + + "\tupdate_id\x18\x01 \x01(\tR\bupdateId\x12R\n" + + "\x0esandbox_config\x18\x02 \x01(\v2+.openshell.sandbox.v1.SandboxConfigSnapshotR\rsandboxConfig\x12\\\n" + + "\x14provider_environment\x18\x03 \x01(\v2).openshell.v1.ProviderEnvironmentSnapshotR\x13providerEnvironment\"`\n" + + "\x12ConfigApplyFailure\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + + "\tretryable\x18\x03 \x01(\bR\tretryable\"\xb5\x02\n" + + "\x12ConfigUpdateResult\x12\x1b\n" + + "\tupdate_id\x18\x01 \x01(\tR\bupdateId\x12:\n" + + "\aoutcome\x18\x02 \x01(\x0e2 .openshell.v1.ConfigApplyOutcomeR\aoutcome\x12:\n" + + "\afailure\x18\x03 \x01(\v2 .openshell.v1.ConfigApplyFailureR\afailure\x12I\n" + + "\tadmission\x18\x04 \x01(\v2+.openshell.v1.SandboxConfigurationAdmissionR\tadmission\x12?\n" + + "\vpolicy_load\x18\x05 \x01(\v2\x1e.openshell.v1.PolicyLoadResultR\n" + + "policyLoad\"Z\n" + + "\x10PolicyLoadResult\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12\x16\n" + + "\x06loaded\x18\x02 \x01(\bR\x06loaded\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\")\n" + "\x0fSessionRejected\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\"`\n" + "\x0fSessionRedirect\x12#\n" + @@ -19639,7 +20533,24 @@ const file_openshell_proto_rawDesc = "" + "\x0ePeerRelayFrame\x121\n" + "\x04init\x18\x01 \x01(\v2\x1b.openshell.v1.PeerRelayInitH\x00R\x04init\x12\x14\n" + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + - "\apayload\"`\n" + + "\apayload\"W\n" + + "\x17PeerConfigSandboxTarget\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1d\n" + + "\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\"L\n" + + "\x18PeerConfigProviderTarget\x12\x1c\n" + + "\tworkspace\x18\x01 \x01(\tR\tworkspace\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\xf8\x01\n" + + "\x1dPeerNotifyConfigUpdateRequest\x12A\n" + + "\asandbox\x18\x01 \x01(\v2%.openshell.v1.PeerConfigSandboxTargetH\x00R\asandbox\x12\x1e\n" + + "\tworkspace\x18\x02 \x01(\tH\x00R\tworkspace\x12%\n" + + "\rall_connected\x18\x03 \x01(\bH\x00R\fallConnected\x12D\n" + + "\bprovider\x18\x04 \x01(\v2&.openshell.v1.PeerConfigProviderTargetH\x00R\bproviderB\a\n" + + "\x05scope\"A\n" + + "\x1ePeerNotifyConfigUpdateResponse\x12\x1f\n" + + "\vstale_owner\x18\x01 \x01(\bR\n" + + "staleOwner\"`\n" + "\x0fRelayOpenResult\x12\x1d\n" + "\n" + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x18\n" + @@ -20039,7 +20950,11 @@ const file_openshell_proto_rawDesc = "" + "(PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL\x10\x04\x12'\n" + "#PROVIDER_PROFILE_CATEGORY_MESSAGING\x10\x05\x12\"\n" + "\x1ePROVIDER_PROFILE_CATEGORY_DATA\x10\x06\x12'\n" + - "#PROVIDER_PROFILE_CATEGORY_KNOWLEDGE\x10\a*\xcf\x01\n" + + "#PROVIDER_PROFILE_CATEGORY_KNOWLEDGE\x10\a*\xde\x01\n" + + "&ProviderEnvironmentValueClassification\x129\n" + + "5PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED\x10\x00\x128\n" + + "4PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_NON_SECRET\x10\x01\x12?\n" + + ";PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_STATIC_CREDENTIAL\x10\x02*\xcf\x01\n" + "\x1bConfigurationAdmissionState\x12-\n" + ")CONFIGURATION_ADMISSION_STATE_UNSPECIFIED\x10\x00\x12)\n" + "%CONFIGURATION_ADMISSION_STATE_PENDING\x10\x01\x12*\n" + @@ -20091,7 +21006,7 @@ const file_openshell_proto_rawDesc = "" + "-SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH\x10\x02*i\n" + "\x15EndpointFailureDomain\x12'\n" + "#ENDPOINT_FAILURE_DOMAIN_UNSPECIFIED\x10\x00\x12'\n" + - "#ENDPOINT_FAILURE_DOMAIN_TOKEN_GRANT\x10\x012\xb5V\n" + + "#ENDPOINT_FAILURE_DOMAIN_TOKEN_GRANT\x10\x012\xb6W\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -20228,6 +21143,9 @@ const file_openshell_proto_rawDesc = "" + "\x04peer\x12\x89\x01\n" + "\x1cPeerGetSandboxProviderStatus\x12-.openshell.v1.GetSandboxProviderStatusRequest\x1a..openshell.v1.GetSandboxProviderStatusResponse\"\n" + "\x82\xb5\x18\x06\n" + + "\x04peer\x12\x7f\n" + + "\x16PeerNotifyConfigUpdate\x12+.openshell.v1.PeerNotifyConfigUpdateRequest\x1a,.openshell.v1.PeerNotifyConfigUpdateResponse\"\n" + + "\x82\xb5\x18\x06\n" + "\x04peer\x12w\n" + "\fWatchSandbox\x12!.openshell.v1.WatchSandboxRequest\x1a .openshell.v1.SandboxStreamEvent\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read0\x01\x12|\n" + @@ -20280,8 +21198,8 @@ func file_openshell_proto_rawDescGZIP() []byte { return file_openshell_proto_rawDescData } -var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 21) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 263) +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 22) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 275) var file_openshell_proto_goTypes = []any{ (ExtensionKind)(0), // 0: openshell.v1.ExtensionKind (SandboxPhase)(0), // 1: openshell.v1.SandboxPhase @@ -20294,811 +21212,846 @@ var file_openshell_proto_goTypes = []any{ (ProviderCredentialTokenGrantType)(0), // 8: openshell.v1.ProviderCredentialTokenGrantType (ProviderCredentialRefreshStrategy)(0), // 9: openshell.v1.ProviderCredentialRefreshStrategy (ProviderProfileCategory)(0), // 10: openshell.v1.ProviderProfileCategory - (ConfigurationAdmissionState)(0), // 11: openshell.v1.ConfigurationAdmissionState - (PolicyStatus)(0), // 12: openshell.v1.PolicyStatus - (ServiceStatus)(0), // 13: openshell.v1.ServiceStatus - (WorkspaceRole)(0), // 14: openshell.v1.WorkspaceRole - (ProviderCredentialRefreshRecoveryAction)(0), // 15: openshell.v1.ProviderCredentialRefreshRecoveryAction - (SandboxRestartPolicy)(0), // 16: openshell.v1.SandboxRestartPolicy - (DeletionOutcome)(0), // 17: openshell.v1.DeletionOutcome - (EndpointResult)(0), // 18: openshell.v1.EndpointResult - (ServiceAuthorizationMode)(0), // 19: openshell.v1.ServiceAuthorizationMode - (EndpointFailureDomain)(0), // 20: openshell.v1.EndpointFailureDomain - (*IssueSandboxTokenRequest)(nil), // 21: openshell.v1.IssueSandboxTokenRequest - (*IssueSandboxTokenResponse)(nil), // 22: openshell.v1.IssueSandboxTokenResponse - (*RefreshSandboxTokenRequest)(nil), // 23: openshell.v1.RefreshSandboxTokenRequest - (*RefreshSandboxTokenResponse)(nil), // 24: openshell.v1.RefreshSandboxTokenResponse - (*HealthRequest)(nil), // 25: openshell.v1.HealthRequest - (*HealthResponse)(nil), // 26: openshell.v1.HealthResponse - (*GetCurrentUserRequest)(nil), // 27: openshell.v1.GetCurrentUserRequest - (*GetCurrentUserResponse)(nil), // 28: openshell.v1.GetCurrentUserResponse - (*GetGatewayInfoRequest)(nil), // 29: openshell.v1.GetGatewayInfoRequest - (*GetGatewayInfoResponse)(nil), // 30: openshell.v1.GetGatewayInfoResponse - (*NegotiatedExtensionInfo)(nil), // 31: openshell.v1.NegotiatedExtensionInfo - (*ComputeDriverInfo)(nil), // 32: openshell.v1.ComputeDriverInfo - (*ComputeDriverCapabilities)(nil), // 33: openshell.v1.ComputeDriverCapabilities - (*ResourceCapabilities)(nil), // 34: openshell.v1.ResourceCapabilities - (*CpuResourceCapabilities)(nil), // 35: openshell.v1.CpuResourceCapabilities - (*MemoryResourceCapabilities)(nil), // 36: openshell.v1.MemoryResourceCapabilities - (*GpuResourceCapabilities)(nil), // 37: openshell.v1.GpuResourceCapabilities - (*Sandbox)(nil), // 38: openshell.v1.Sandbox - (*SandboxSpec)(nil), // 39: openshell.v1.SandboxSpec - (*ResourceRequirements)(nil), // 40: openshell.v1.ResourceRequirements - (*GpuResourceRequirements)(nil), // 41: openshell.v1.GpuResourceRequirements - (*SandboxTemplate)(nil), // 42: openshell.v1.SandboxTemplate - (*SandboxWorkloadTemplate)(nil), // 43: openshell.v1.SandboxWorkloadTemplate - (*SandboxWorkloadTemplateSpec)(nil), // 44: openshell.v1.SandboxWorkloadTemplateSpec - (*SandboxWorkloadConfig)(nil), // 45: openshell.v1.SandboxWorkloadConfig - (*SandboxResources)(nil), // 46: openshell.v1.SandboxResources - (*SandboxServiceLevel)(nil), // 47: openshell.v1.SandboxServiceLevel - (*SandboxStartup)(nil), // 48: openshell.v1.SandboxStartup - (*SandboxWorkloadTemplateProvenance)(nil), // 49: openshell.v1.SandboxWorkloadTemplateProvenance - (*SandboxStatus)(nil), // 50: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 51: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 52: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 53: openshell.v1.CreateSandboxRequest - (*CreateSandboxTemplateRequest)(nil), // 54: openshell.v1.CreateSandboxTemplateRequest - (*GetSandboxTemplateRequest)(nil), // 55: openshell.v1.GetSandboxTemplateRequest - (*ListSandboxTemplatesRequest)(nil), // 56: openshell.v1.ListSandboxTemplatesRequest - (*DeleteSandboxTemplateRequest)(nil), // 57: openshell.v1.DeleteSandboxTemplateRequest - (*SandboxTemplateResponse)(nil), // 58: openshell.v1.SandboxTemplateResponse - (*ListSandboxTemplatesResponse)(nil), // 59: openshell.v1.ListSandboxTemplatesResponse - (*DeleteSandboxTemplateResponse)(nil), // 60: openshell.v1.DeleteSandboxTemplateResponse - (*BeginRootfsTarStagingRequest)(nil), // 61: openshell.v1.BeginRootfsTarStagingRequest - (*BeginRootfsTarStagingResponse)(nil), // 62: openshell.v1.BeginRootfsTarStagingResponse - (*GetSandboxRequest)(nil), // 63: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 64: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 65: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 66: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 67: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 68: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 69: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 70: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 71: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 72: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 73: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 74: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 75: openshell.v1.DetachSandboxProviderResponse - (*ProviderDesiredIdentity)(nil), // 76: openshell.v1.ProviderDesiredIdentity - (*ConfigSnapshotRevision)(nil), // 77: openshell.v1.ConfigSnapshotRevision - (*SandboxConfigRevision)(nil), // 78: openshell.v1.SandboxConfigRevision - (*ConfigUpdateOperation)(nil), // 79: openshell.v1.ConfigUpdateOperation - (*ProviderMutationReceipt)(nil), // 80: openshell.v1.ProviderMutationReceipt - (*ProviderReadinessObservation)(nil), // 81: openshell.v1.ProviderReadinessObservation - (*ProviderReadinessStatus)(nil), // 82: openshell.v1.ProviderReadinessStatus - (*GetSandboxProviderStatusRequest)(nil), // 83: openshell.v1.GetSandboxProviderStatusRequest - (*GetSandboxProviderStatusResponse)(nil), // 84: openshell.v1.GetSandboxProviderStatusResponse - (*ReportProviderReadinessRequest)(nil), // 85: openshell.v1.ReportProviderReadinessRequest - (*ReportProviderReadinessResponse)(nil), // 86: openshell.v1.ReportProviderReadinessResponse - (*DeleteSandboxResponse)(nil), // 87: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 88: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 89: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 90: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 91: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 92: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 93: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 94: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 95: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 96: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 97: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 98: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 99: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 100: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 101: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 102: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 103: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 104: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 105: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 106: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 107: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 108: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 109: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 110: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 111: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 112: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 113: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 114: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 115: openshell.v1.GetProviderRequest - (*GetProviderCredentialsRequest)(nil), // 116: openshell.v1.GetProviderCredentialsRequest - (*ProviderCredentialValue)(nil), // 117: openshell.v1.ProviderCredentialValue - (*GetProviderCredentialsResponse)(nil), // 118: openshell.v1.GetProviderCredentialsResponse - (*ListProvidersRequest)(nil), // 119: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 120: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 121: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 122: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 123: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 124: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 125: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 126: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 127: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 128: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrantSubjectToken)(nil), // 129: openshell.v1.ProviderCredentialTokenGrantSubjectToken - (*ProviderCredentialTokenGrant)(nil), // 130: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 131: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 132: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 133: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 134: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 135: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 136: openshell.v1.ProviderProfileDiscovery - (*GetProviderRefreshStatusRequest)(nil), // 137: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 138: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 139: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 140: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 141: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 142: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 143: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 144: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 145: openshell.v1.ProviderProfile - (*ProviderProfileFile)(nil), // 146: openshell.v1.ProviderProfileFile - (*ProviderProfileResponse)(nil), // 147: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 148: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 149: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 150: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 151: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 152: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 153: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 154: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 155: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 156: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 157: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 158: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 159: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 160: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 161: openshell.v1.GetSandboxProviderEnvironmentResponse - (*ExchangeProviderSubjectTokenRequest)(nil), // 162: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 163: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 164: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 165: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 166: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 167: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 168: openshell.v1.RemoveNetworkRule - (*L7RuleTarget)(nil), // 169: openshell.v1.L7RuleTarget - (*AddDenyRules)(nil), // 170: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 171: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 172: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 173: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 174: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 175: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 176: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 177: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 178: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 179: openshell.v1.ReportPolicyStatusResponse - (*SandboxConfigurationAdmission)(nil), // 180: openshell.v1.SandboxConfigurationAdmission - (*ReportSandboxConfigurationRequest)(nil), // 181: openshell.v1.ReportSandboxConfigurationRequest - (*ReportSandboxConfigurationResponse)(nil), // 182: openshell.v1.ReportSandboxConfigurationResponse - (*SandboxPolicyRevision)(nil), // 183: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 184: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 185: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 186: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 187: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 188: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 189: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 190: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 191: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 192: openshell.v1.SessionRejected - (*SessionRedirect)(nil), // 193: openshell.v1.SessionRedirect - (*SupervisorHeartbeat)(nil), // 194: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 195: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 196: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 197: openshell.v1.ReportMainProcessExitResponse - (*FinalizeMainProcessExitRequest)(nil), // 198: openshell.v1.FinalizeMainProcessExitRequest - (*FinalizeMainProcessExitResponse)(nil), // 199: openshell.v1.FinalizeMainProcessExitResponse - (*RelayOpen)(nil), // 200: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 201: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 202: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 203: openshell.v1.RelayInit - (*RelayFrame)(nil), // 204: openshell.v1.RelayFrame - (*PeerRelayInit)(nil), // 205: openshell.v1.PeerRelayInit - (*PeerRelayFrame)(nil), // 206: openshell.v1.PeerRelayFrame - (*RelayOpenResult)(nil), // 207: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 208: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 209: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 210: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 211: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 212: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 213: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 214: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 215: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 216: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 217: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 218: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 219: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 220: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 221: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 222: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 223: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 224: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 225: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 226: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 227: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 228: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 229: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 230: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 231: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 232: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 233: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 234: openshell.v1.GetDraftHistoryResponse - (*CreateWorkspaceRequest)(nil), // 235: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 236: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 237: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 238: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 239: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 240: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 241: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 242: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 243: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 244: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 245: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 246: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 247: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 248: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 249: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 250: openshell.v1.ExtensionServiceCredential - (*EndpointObservation)(nil), // 251: openshell.v1.EndpointObservation - (*ReportEndpointStatusRequest)(nil), // 252: openshell.v1.ReportEndpointStatusRequest - (*TokenGrantStatus)(nil), // 253: openshell.v1.TokenGrantStatus - (*TokenGrantFailureObservation)(nil), // 254: openshell.v1.TokenGrantFailureObservation - (*ReportEndpointStatusResponse)(nil), // 255: openshell.v1.ReportEndpointStatusResponse - (*EndpointStatus)(nil), // 256: openshell.v1.EndpointStatus - (*SandboxProvisioning)(nil), // 257: openshell.v1.SandboxProvisioning - (*SandboxServiceExposure)(nil), // 258: openshell.v1.SandboxServiceExposure - (*EndpointFailure)(nil), // 259: openshell.v1.EndpointFailure - nil, // 260: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 261: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 262: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 263: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 264: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - nil, // 265: openshell.v1.PlatformEvent.MetadataEntry - nil, // 266: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 267: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 268: openshell.v1.SandboxResponse.ServiceUrlsEntry - nil, // 269: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 270: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 271: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry - nil, // 272: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry - nil, // 273: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 274: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 275: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 276: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry - nil, // 277: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 278: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 279: openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry - nil, // 280: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 281: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 282: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 283: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*timestamppb.Timestamp)(nil), // 284: google.protobuf.Timestamp - (*datamodelv1.ObjectMeta)(nil), // 285: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 286: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 287: google.protobuf.Struct - (*durationpb.Duration)(nil), // 288: google.protobuf.Duration - (*datamodelv1.WorkspaceSelector)(nil), // 289: openshell.datamodel.v1.WorkspaceSelector - (*datamodelv1.Provider)(nil), // 290: openshell.datamodel.v1.Provider - (sandboxv1.PolicySource)(0), // 291: openshell.sandbox.v1.PolicySource - (*sandboxv1.NetworkEndpoint)(nil), // 292: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 293: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 294: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 295: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 296: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 297: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 298: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 299: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 300: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 301: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 302: openshell.sandbox.v1.GetGatewayConfigResponse + (ProviderEnvironmentValueClassification)(0), // 11: openshell.v1.ProviderEnvironmentValueClassification + (ConfigurationAdmissionState)(0), // 12: openshell.v1.ConfigurationAdmissionState + (PolicyStatus)(0), // 13: openshell.v1.PolicyStatus + (ServiceStatus)(0), // 14: openshell.v1.ServiceStatus + (WorkspaceRole)(0), // 15: openshell.v1.WorkspaceRole + (ProviderCredentialRefreshRecoveryAction)(0), // 16: openshell.v1.ProviderCredentialRefreshRecoveryAction + (SandboxRestartPolicy)(0), // 17: openshell.v1.SandboxRestartPolicy + (DeletionOutcome)(0), // 18: openshell.v1.DeletionOutcome + (EndpointResult)(0), // 19: openshell.v1.EndpointResult + (ServiceAuthorizationMode)(0), // 20: openshell.v1.ServiceAuthorizationMode + (EndpointFailureDomain)(0), // 21: openshell.v1.EndpointFailureDomain + (*IssueSandboxTokenRequest)(nil), // 22: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 23: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 24: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 25: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 26: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 27: openshell.v1.HealthResponse + (*GetCurrentUserRequest)(nil), // 28: openshell.v1.GetCurrentUserRequest + (*GetCurrentUserResponse)(nil), // 29: openshell.v1.GetCurrentUserResponse + (*GetGatewayInfoRequest)(nil), // 30: openshell.v1.GetGatewayInfoRequest + (*GetGatewayInfoResponse)(nil), // 31: openshell.v1.GetGatewayInfoResponse + (*NegotiatedExtensionInfo)(nil), // 32: openshell.v1.NegotiatedExtensionInfo + (*ComputeDriverInfo)(nil), // 33: openshell.v1.ComputeDriverInfo + (*ComputeDriverCapabilities)(nil), // 34: openshell.v1.ComputeDriverCapabilities + (*ResourceCapabilities)(nil), // 35: openshell.v1.ResourceCapabilities + (*CpuResourceCapabilities)(nil), // 36: openshell.v1.CpuResourceCapabilities + (*MemoryResourceCapabilities)(nil), // 37: openshell.v1.MemoryResourceCapabilities + (*GpuResourceCapabilities)(nil), // 38: openshell.v1.GpuResourceCapabilities + (*Sandbox)(nil), // 39: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 40: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 41: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 42: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 43: openshell.v1.SandboxTemplate + (*SandboxWorkloadTemplate)(nil), // 44: openshell.v1.SandboxWorkloadTemplate + (*SandboxWorkloadTemplateSpec)(nil), // 45: openshell.v1.SandboxWorkloadTemplateSpec + (*SandboxWorkloadConfig)(nil), // 46: openshell.v1.SandboxWorkloadConfig + (*SandboxResources)(nil), // 47: openshell.v1.SandboxResources + (*SandboxServiceLevel)(nil), // 48: openshell.v1.SandboxServiceLevel + (*SandboxStartup)(nil), // 49: openshell.v1.SandboxStartup + (*SandboxWorkloadTemplateProvenance)(nil), // 50: openshell.v1.SandboxWorkloadTemplateProvenance + (*SandboxStatus)(nil), // 51: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 52: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 53: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 54: openshell.v1.CreateSandboxRequest + (*CreateSandboxTemplateRequest)(nil), // 55: openshell.v1.CreateSandboxTemplateRequest + (*GetSandboxTemplateRequest)(nil), // 56: openshell.v1.GetSandboxTemplateRequest + (*ListSandboxTemplatesRequest)(nil), // 57: openshell.v1.ListSandboxTemplatesRequest + (*DeleteSandboxTemplateRequest)(nil), // 58: openshell.v1.DeleteSandboxTemplateRequest + (*SandboxTemplateResponse)(nil), // 59: openshell.v1.SandboxTemplateResponse + (*ListSandboxTemplatesResponse)(nil), // 60: openshell.v1.ListSandboxTemplatesResponse + (*DeleteSandboxTemplateResponse)(nil), // 61: openshell.v1.DeleteSandboxTemplateResponse + (*BeginRootfsTarStagingRequest)(nil), // 62: openshell.v1.BeginRootfsTarStagingRequest + (*BeginRootfsTarStagingResponse)(nil), // 63: openshell.v1.BeginRootfsTarStagingResponse + (*GetSandboxRequest)(nil), // 64: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 65: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 66: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 67: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 68: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 69: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 70: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 71: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 72: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 73: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 74: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 75: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 76: openshell.v1.DetachSandboxProviderResponse + (*ProviderDesiredIdentity)(nil), // 77: openshell.v1.ProviderDesiredIdentity + (*ConfigSnapshotRevision)(nil), // 78: openshell.v1.ConfigSnapshotRevision + (*SandboxConfigRevision)(nil), // 79: openshell.v1.SandboxConfigRevision + (*ConfigUpdateOperation)(nil), // 80: openshell.v1.ConfigUpdateOperation + (*ProviderMutationReceipt)(nil), // 81: openshell.v1.ProviderMutationReceipt + (*ProviderReadinessObservation)(nil), // 82: openshell.v1.ProviderReadinessObservation + (*ProviderReadinessStatus)(nil), // 83: openshell.v1.ProviderReadinessStatus + (*GetSandboxProviderStatusRequest)(nil), // 84: openshell.v1.GetSandboxProviderStatusRequest + (*GetSandboxProviderStatusResponse)(nil), // 85: openshell.v1.GetSandboxProviderStatusResponse + (*ReportProviderReadinessRequest)(nil), // 86: openshell.v1.ReportProviderReadinessRequest + (*ReportProviderReadinessResponse)(nil), // 87: openshell.v1.ReportProviderReadinessResponse + (*DeleteSandboxResponse)(nil), // 88: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 89: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 90: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 91: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 92: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 93: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 94: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 95: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 96: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 97: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 98: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 99: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 100: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 101: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 102: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 103: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 104: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 105: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 106: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 107: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 108: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 109: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 110: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 111: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 112: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 113: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 114: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 115: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 116: openshell.v1.GetProviderRequest + (*GetProviderCredentialsRequest)(nil), // 117: openshell.v1.GetProviderCredentialsRequest + (*ProviderCredentialValue)(nil), // 118: openshell.v1.ProviderCredentialValue + (*GetProviderCredentialsResponse)(nil), // 119: openshell.v1.GetProviderCredentialsResponse + (*ListProvidersRequest)(nil), // 120: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 121: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 122: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 123: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 124: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 125: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 126: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 127: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 128: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 129: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 130: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 131: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 132: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 133: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 134: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 135: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 136: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 137: openshell.v1.ProviderProfileDiscovery + (*GetProviderRefreshStatusRequest)(nil), // 138: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 139: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 140: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 141: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 142: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 143: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 144: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 145: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 146: openshell.v1.ProviderProfile + (*ProviderProfileFile)(nil), // 147: openshell.v1.ProviderProfileFile + (*ProviderProfileResponse)(nil), // 148: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 149: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 150: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 151: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 152: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 153: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 154: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 155: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 156: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 157: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 158: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 159: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 160: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 161: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 162: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ProviderEnvironmentValue)(nil), // 163: openshell.v1.ProviderEnvironmentValue + (*ProviderEnvironmentSnapshot)(nil), // 164: openshell.v1.ProviderEnvironmentSnapshot + (*ExchangeProviderSubjectTokenRequest)(nil), // 165: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 166: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 167: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 168: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 169: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 170: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 171: openshell.v1.RemoveNetworkRule + (*L7RuleTarget)(nil), // 172: openshell.v1.L7RuleTarget + (*AddDenyRules)(nil), // 173: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 174: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 175: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 176: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 177: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 178: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 179: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 180: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 181: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 182: openshell.v1.ReportPolicyStatusResponse + (*SandboxConfigurationAdmission)(nil), // 183: openshell.v1.SandboxConfigurationAdmission + (*ReportSandboxConfigurationRequest)(nil), // 184: openshell.v1.ReportSandboxConfigurationRequest + (*ReportSandboxConfigurationResponse)(nil), // 185: openshell.v1.ReportSandboxConfigurationResponse + (*SandboxPolicyRevision)(nil), // 186: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 187: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 188: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 189: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 190: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 191: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 192: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 193: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 194: openshell.v1.SessionAccepted + (*ConfigUpdate)(nil), // 195: openshell.v1.ConfigUpdate + (*ConfigApplyFailure)(nil), // 196: openshell.v1.ConfigApplyFailure + (*ConfigUpdateResult)(nil), // 197: openshell.v1.ConfigUpdateResult + (*PolicyLoadResult)(nil), // 198: openshell.v1.PolicyLoadResult + (*SessionRejected)(nil), // 199: openshell.v1.SessionRejected + (*SessionRedirect)(nil), // 200: openshell.v1.SessionRedirect + (*SupervisorHeartbeat)(nil), // 201: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 202: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 203: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 204: openshell.v1.ReportMainProcessExitResponse + (*FinalizeMainProcessExitRequest)(nil), // 205: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 206: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 207: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 208: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 209: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 210: openshell.v1.RelayInit + (*RelayFrame)(nil), // 211: openshell.v1.RelayFrame + (*PeerRelayInit)(nil), // 212: openshell.v1.PeerRelayInit + (*PeerRelayFrame)(nil), // 213: openshell.v1.PeerRelayFrame + (*PeerConfigSandboxTarget)(nil), // 214: openshell.v1.PeerConfigSandboxTarget + (*PeerConfigProviderTarget)(nil), // 215: openshell.v1.PeerConfigProviderTarget + (*PeerNotifyConfigUpdateRequest)(nil), // 216: openshell.v1.PeerNotifyConfigUpdateRequest + (*PeerNotifyConfigUpdateResponse)(nil), // 217: openshell.v1.PeerNotifyConfigUpdateResponse + (*RelayOpenResult)(nil), // 218: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 219: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 220: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 221: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 222: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 223: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 224: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 225: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 226: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 227: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 228: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 229: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 230: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 231: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 232: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 233: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 234: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 235: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 236: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 237: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 238: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 239: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 240: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 241: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 242: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 243: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 244: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 245: openshell.v1.GetDraftHistoryResponse + (*CreateWorkspaceRequest)(nil), // 246: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 247: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 248: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 249: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 250: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 251: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 252: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 253: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 254: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 255: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 256: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 257: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 258: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 259: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 260: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 261: openshell.v1.ExtensionServiceCredential + (*EndpointObservation)(nil), // 262: openshell.v1.EndpointObservation + (*ReportEndpointStatusRequest)(nil), // 263: openshell.v1.ReportEndpointStatusRequest + (*TokenGrantStatus)(nil), // 264: openshell.v1.TokenGrantStatus + (*TokenGrantFailureObservation)(nil), // 265: openshell.v1.TokenGrantFailureObservation + (*ReportEndpointStatusResponse)(nil), // 266: openshell.v1.ReportEndpointStatusResponse + (*EndpointStatus)(nil), // 267: openshell.v1.EndpointStatus + (*SandboxProvisioning)(nil), // 268: openshell.v1.SandboxProvisioning + (*SandboxServiceExposure)(nil), // 269: openshell.v1.SandboxServiceExposure + (*EndpointFailure)(nil), // 270: openshell.v1.EndpointFailure + nil, // 271: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 272: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 273: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 274: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 275: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + nil, // 276: openshell.v1.PlatformEvent.MetadataEntry + nil, // 277: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 278: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 279: openshell.v1.SandboxResponse.ServiceUrlsEntry + nil, // 280: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 281: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 282: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry + nil, // 283: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + nil, // 284: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 285: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 286: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 287: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + nil, // 288: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 289: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 290: openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry + nil, // 291: openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry + nil, // 292: openshell.v1.ProviderEnvironmentSnapshot.FilesEntry + nil, // 293: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 294: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 295: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 296: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*timestamppb.Timestamp)(nil), // 297: google.protobuf.Timestamp + (*datamodelv1.ObjectMeta)(nil), // 298: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 299: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 300: google.protobuf.Struct + (*durationpb.Duration)(nil), // 301: google.protobuf.Duration + (*datamodelv1.WorkspaceSelector)(nil), // 302: openshell.datamodel.v1.WorkspaceSelector + (*datamodelv1.Provider)(nil), // 303: openshell.datamodel.v1.Provider + (sandboxv1.PolicySource)(0), // 304: openshell.sandbox.v1.PolicySource + (*sandboxv1.NetworkEndpoint)(nil), // 305: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 306: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 307: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 308: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 309: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 310: openshell.sandbox.v1.L7Rule + (*sandboxv1.SandboxConfigSnapshot)(nil), // 311: openshell.sandbox.v1.SandboxConfigSnapshot + (*datamodelv1.Workspace)(nil), // 312: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 313: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 314: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 315: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 316: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 284, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp - 284, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp - 250, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential - 284, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp - 13, // 4: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus - 13, // 5: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 32, // 6: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 31, // 7: openshell.v1.GetGatewayInfoResponse.extensions:type_name -> openshell.v1.NegotiatedExtensionInfo + 297, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 297, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 261, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 297, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp + 14, // 4: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 14, // 5: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus + 33, // 6: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 32, // 7: openshell.v1.GetGatewayInfoResponse.extensions:type_name -> openshell.v1.NegotiatedExtensionInfo 0, // 8: openshell.v1.NegotiatedExtensionInfo.kind:type_name -> openshell.v1.ExtensionKind - 33, // 9: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 34, // 10: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities - 35, // 11: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities - 36, // 12: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities - 37, // 13: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities - 285, // 14: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 39, // 15: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 50, // 16: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 49, // 17: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance - 260, // 18: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 42, // 19: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 286, // 20: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 40, // 21: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 16, // 22: openshell.v1.SandboxSpec.restart_policy:type_name -> openshell.v1.SandboxRestartPolicy - 41, // 23: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 261, // 24: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 262, // 25: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 263, // 26: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 287, // 27: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 287, // 28: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 285, // 29: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 44, // 30: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec - 45, // 31: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig - 287, // 32: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct - 47, // 33: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel - 264, // 34: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - 46, // 35: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources - 41, // 36: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements - 48, // 37: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup - 288, // 38: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration - 51, // 39: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 34, // 9: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 35, // 10: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities + 36, // 11: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities + 37, // 12: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities + 38, // 13: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities + 298, // 14: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 40, // 15: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 51, // 16: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 50, // 17: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance + 271, // 18: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 43, // 19: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 299, // 20: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 41, // 21: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 17, // 22: openshell.v1.SandboxSpec.restart_policy:type_name -> openshell.v1.SandboxRestartPolicy + 42, // 23: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 272, // 24: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 273, // 25: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 274, // 26: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 300, // 27: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 300, // 28: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 298, // 29: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 45, // 30: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec + 46, // 31: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig + 300, // 32: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 48, // 33: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel + 275, // 34: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 47, // 35: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources + 42, // 36: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements + 49, // 37: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup + 301, // 38: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 52, // 39: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 1, // 40: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 256, // 41: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus - 180, // 42: openshell.v1.SandboxStatus.configuration_admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 257, // 43: openshell.v1.SandboxStatus.provisioning:type_name -> openshell.v1.SandboxProvisioning - 284, // 44: openshell.v1.SandboxStatus.next_restart_time:type_name -> google.protobuf.Timestamp - 284, // 45: openshell.v1.SandboxStatus.main_process_started_time:type_name -> google.protobuf.Timestamp - 254, // 46: openshell.v1.SandboxStatus.last_token_grant_failure:type_name -> openshell.v1.TokenGrantFailureObservation - 284, // 47: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp - 284, // 48: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp - 265, // 49: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 289, // 50: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 39, // 51: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 266, // 52: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 267, // 53: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 258, // 54: openshell.v1.CreateSandboxRequest.service_exposures:type_name -> openshell.v1.SandboxServiceExposure - 289, // 55: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 43, // 56: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 289, // 57: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 58: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 59: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 43, // 60: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 43, // 61: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate - 17, // 62: openshell.v1.DeleteSandboxTemplateResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 289, // 63: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 284, // 64: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp - 289, // 65: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 66: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 67: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 68: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 69: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 70: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 71: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 72: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 38, // 73: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 268, // 74: openshell.v1.SandboxResponse.service_urls:type_name -> openshell.v1.SandboxResponse.ServiceUrlsEntry - 38, // 75: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 290, // 76: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 38, // 77: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 80, // 78: openshell.v1.AttachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt - 38, // 79: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 80, // 80: openshell.v1.DetachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt - 78, // 81: openshell.v1.ConfigSnapshotRevision.sandbox_config:type_name -> openshell.v1.SandboxConfigRevision - 76, // 82: openshell.v1.ConfigSnapshotRevision.provider_target:type_name -> openshell.v1.ProviderDesiredIdentity - 291, // 83: openshell.v1.SandboxConfigRevision.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 267, // 41: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus + 183, // 42: openshell.v1.SandboxStatus.configuration_admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 268, // 43: openshell.v1.SandboxStatus.provisioning:type_name -> openshell.v1.SandboxProvisioning + 297, // 44: openshell.v1.SandboxStatus.next_restart_time:type_name -> google.protobuf.Timestamp + 297, // 45: openshell.v1.SandboxStatus.main_process_started_time:type_name -> google.protobuf.Timestamp + 265, // 46: openshell.v1.SandboxStatus.last_token_grant_failure:type_name -> openshell.v1.TokenGrantFailureObservation + 297, // 47: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp + 297, // 48: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp + 276, // 49: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 302, // 50: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 40, // 51: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 277, // 52: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 278, // 53: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 269, // 54: openshell.v1.CreateSandboxRequest.service_exposures:type_name -> openshell.v1.SandboxServiceExposure + 302, // 55: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 44, // 56: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 302, // 57: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 58: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 59: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 44, // 60: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 44, // 61: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 18, // 62: openshell.v1.DeleteSandboxTemplateResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 302, // 63: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 297, // 64: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp + 302, // 65: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 66: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 67: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 68: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 69: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 70: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 71: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 72: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 39, // 73: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 279, // 74: openshell.v1.SandboxResponse.service_urls:type_name -> openshell.v1.SandboxResponse.ServiceUrlsEntry + 39, // 75: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 303, // 76: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 39, // 77: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 81, // 78: openshell.v1.AttachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt + 39, // 79: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 81, // 80: openshell.v1.DetachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt + 79, // 81: openshell.v1.ConfigSnapshotRevision.sandbox_config:type_name -> openshell.v1.SandboxConfigRevision + 77, // 82: openshell.v1.ConfigSnapshotRevision.provider_target:type_name -> openshell.v1.ProviderDesiredIdentity + 304, // 83: openshell.v1.SandboxConfigRevision.policy_source:type_name -> openshell.sandbox.v1.PolicySource 5, // 84: openshell.v1.ConfigUpdateOperation.component:type_name -> openshell.v1.ConfigComponent - 77, // 85: openshell.v1.ConfigUpdateOperation.target_revision:type_name -> openshell.v1.ConfigSnapshotRevision + 78, // 85: openshell.v1.ConfigUpdateOperation.target_revision:type_name -> openshell.v1.ConfigSnapshotRevision 7, // 86: openshell.v1.ConfigUpdateOperation.state:type_name -> openshell.v1.ConfigUpdateOperationState 6, // 87: openshell.v1.ConfigUpdateOperation.outcome:type_name -> openshell.v1.ConfigApplyOutcome - 284, // 88: openshell.v1.ConfigUpdateOperation.created_time:type_name -> google.protobuf.Timestamp - 284, // 89: openshell.v1.ConfigUpdateOperation.updated_time:type_name -> google.protobuf.Timestamp - 284, // 90: openshell.v1.ConfigUpdateOperation.completed_time:type_name -> google.protobuf.Timestamp + 297, // 88: openshell.v1.ConfigUpdateOperation.created_time:type_name -> google.protobuf.Timestamp + 297, // 89: openshell.v1.ConfigUpdateOperation.updated_time:type_name -> google.protobuf.Timestamp + 297, // 90: openshell.v1.ConfigUpdateOperation.completed_time:type_name -> google.protobuf.Timestamp 2, // 91: openshell.v1.ProviderMutationReceipt.kind:type_name -> openshell.v1.ProviderMutationKind - 76, // 92: openshell.v1.ProviderMutationReceipt.desired:type_name -> openshell.v1.ProviderDesiredIdentity - 284, // 93: openshell.v1.ProviderMutationReceipt.persisted_time:type_name -> google.protobuf.Timestamp + 77, // 92: openshell.v1.ProviderMutationReceipt.desired:type_name -> openshell.v1.ProviderDesiredIdentity + 297, // 93: openshell.v1.ProviderMutationReceipt.persisted_time:type_name -> google.protobuf.Timestamp 4, // 94: openshell.v1.ProviderReadinessObservation.reason:type_name -> openshell.v1.ProviderReadinessReason - 80, // 95: openshell.v1.ProviderReadinessStatus.receipt:type_name -> openshell.v1.ProviderMutationReceipt + 81, // 95: openshell.v1.ProviderReadinessStatus.receipt:type_name -> openshell.v1.ProviderMutationReceipt 3, // 96: openshell.v1.ProviderReadinessStatus.state:type_name -> openshell.v1.ProviderReadinessState 4, // 97: openshell.v1.ProviderReadinessStatus.reason:type_name -> openshell.v1.ProviderReadinessReason - 81, // 98: openshell.v1.ProviderReadinessStatus.observed:type_name -> openshell.v1.ProviderReadinessObservation - 284, // 99: openshell.v1.ProviderReadinessStatus.observed_time:type_name -> google.protobuf.Timestamp - 284, // 100: openshell.v1.ProviderReadinessStatus.evaluated_time:type_name -> google.protobuf.Timestamp - 79, // 101: openshell.v1.ProviderReadinessStatus.operation:type_name -> openshell.v1.ConfigUpdateOperation - 289, // 102: openshell.v1.GetSandboxProviderStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 82, // 103: openshell.v1.GetSandboxProviderStatusResponse.status:type_name -> openshell.v1.ProviderReadinessStatus - 81, // 104: openshell.v1.ReportProviderReadinessRequest.observation:type_name -> openshell.v1.ProviderReadinessObservation - 288, // 105: openshell.v1.ReportProviderReadinessResponse.report_interval:type_name -> google.protobuf.Duration - 288, // 106: openshell.v1.ReportProviderReadinessResponse.observation_ttl:type_name -> google.protobuf.Duration - 17, // 107: openshell.v1.DeleteSandboxResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 289, // 108: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 284, // 109: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp - 289, // 110: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 19, // 111: openshell.v1.ExposeServiceRequest.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode - 289, // 112: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 113: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 97, // 114: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 289, // 115: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 17, // 116: openshell.v1.DeleteServiceResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 285, // 117: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 19, // 118: openshell.v1.ServiceEndpoint.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode - 96, // 119: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 17, // 120: openshell.v1.RevokeSshSessionResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 289, // 121: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 269, // 122: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 288, // 123: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration - 101, // 124: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 102, // 125: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 103, // 126: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 201, // 127: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 202, // 128: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 105, // 129: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 100, // 130: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 108, // 131: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 285, // 132: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 284, // 133: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp - 289, // 134: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 284, // 135: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp - 38, // 136: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 112, // 137: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 52, // 138: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 113, // 139: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 214, // 140: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 284, // 141: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp - 270, // 142: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 289, // 143: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 290, // 144: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 289, // 145: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 146: openshell.v1.GetProviderCredentialsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 288, // 147: openshell.v1.GetProviderCredentialsRequest.minimum_remaining_lifetime:type_name -> google.protobuf.Duration - 284, // 148: openshell.v1.ProviderCredentialValue.expiration_time:type_name -> google.protobuf.Timestamp - 271, // 149: openshell.v1.GetProviderCredentialsResponse.credentials:type_name -> openshell.v1.GetProviderCredentialsResponse.CredentialsEntry - 289, // 150: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 151: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 290, // 152: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 272, // 153: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry - 289, // 154: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 290, // 155: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 80, // 156: openshell.v1.ProviderResponse.target_receipts:type_name -> openshell.v1.ProviderMutationReceipt - 290, // 157: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 289, // 158: openshell.v1.ListProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 159: openshell.v1.GetProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 145, // 160: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 288, // 161: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration - 128, // 162: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 82, // 98: openshell.v1.ProviderReadinessStatus.observed:type_name -> openshell.v1.ProviderReadinessObservation + 297, // 99: openshell.v1.ProviderReadinessStatus.observed_time:type_name -> google.protobuf.Timestamp + 297, // 100: openshell.v1.ProviderReadinessStatus.evaluated_time:type_name -> google.protobuf.Timestamp + 80, // 101: openshell.v1.ProviderReadinessStatus.operation:type_name -> openshell.v1.ConfigUpdateOperation + 302, // 102: openshell.v1.GetSandboxProviderStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 83, // 103: openshell.v1.GetSandboxProviderStatusResponse.status:type_name -> openshell.v1.ProviderReadinessStatus + 82, // 104: openshell.v1.ReportProviderReadinessRequest.observation:type_name -> openshell.v1.ProviderReadinessObservation + 301, // 105: openshell.v1.ReportProviderReadinessResponse.report_interval:type_name -> google.protobuf.Duration + 301, // 106: openshell.v1.ReportProviderReadinessResponse.observation_ttl:type_name -> google.protobuf.Duration + 18, // 107: openshell.v1.DeleteSandboxResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 302, // 108: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 297, // 109: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp + 302, // 110: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 20, // 111: openshell.v1.ExposeServiceRequest.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode + 302, // 112: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 113: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 98, // 114: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 302, // 115: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 18, // 116: openshell.v1.DeleteServiceResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 298, // 117: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 20, // 118: openshell.v1.ServiceEndpoint.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode + 97, // 119: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 18, // 120: openshell.v1.RevokeSshSessionResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 302, // 121: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 280, // 122: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 301, // 123: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration + 102, // 124: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 103, // 125: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 104, // 126: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 208, // 127: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 209, // 128: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 106, // 129: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 101, // 130: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 109, // 131: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 298, // 132: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 297, // 133: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp + 302, // 134: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 297, // 135: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp + 39, // 136: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 113, // 137: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 53, // 138: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 114, // 139: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 225, // 140: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 297, // 141: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp + 281, // 142: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 302, // 143: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 303, // 144: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 302, // 145: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 146: openshell.v1.GetProviderCredentialsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 301, // 147: openshell.v1.GetProviderCredentialsRequest.minimum_remaining_lifetime:type_name -> google.protobuf.Duration + 297, // 148: openshell.v1.ProviderCredentialValue.expiration_time:type_name -> google.protobuf.Timestamp + 282, // 149: openshell.v1.GetProviderCredentialsResponse.credentials:type_name -> openshell.v1.GetProviderCredentialsResponse.CredentialsEntry + 302, // 150: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 151: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 303, // 152: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 283, // 153: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + 302, // 154: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 303, // 155: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 81, // 156: openshell.v1.ProviderResponse.target_receipts:type_name -> openshell.v1.ProviderMutationReceipt + 303, // 157: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 302, // 158: openshell.v1.ListProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 159: openshell.v1.GetProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 146, // 160: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 301, // 161: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration + 129, // 162: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride 8, // 163: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 129, // 164: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 134, // 165: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 130, // 166: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 130, // 164: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 135, // 165: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 131, // 166: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant 9, // 167: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 288, // 168: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration - 288, // 169: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration - 132, // 170: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 133, // 171: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 301, // 168: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration + 301, // 169: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration + 133, // 170: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 134, // 171: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput 9, // 172: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 284, // 173: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp - 284, // 174: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp - 284, // 175: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp - 15, // 176: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 284, // 177: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp - 289, // 178: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 135, // 179: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 289, // 180: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 297, // 173: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp + 297, // 174: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp + 297, // 175: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp + 16, // 176: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 297, // 177: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp + 302, // 178: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 136, // 179: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 302, // 180: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector 9, // 181: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 273, // 182: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 284, // 183: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp - 135, // 184: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 289, // 185: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 135, // 186: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 289, // 187: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 17, // 188: openshell.v1.DeleteProviderRefreshResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 284, // 182: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 297, // 183: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp + 136, // 184: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 302, // 185: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 136, // 186: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 302, // 187: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 18, // 188: openshell.v1.DeleteProviderRefreshResponse.outcome:type_name -> openshell.v1.DeletionOutcome 10, // 189: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 131, // 190: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 292, // 191: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 293, // 192: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 136, // 193: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 274, // 194: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 146, // 195: openshell.v1.ProviderProfile.files:type_name -> openshell.v1.ProviderProfileFile - 145, // 196: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 145, // 197: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 289, // 198: openshell.v1.ImportProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 126, // 199: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 127, // 200: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 145, // 201: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 289, // 202: openshell.v1.UpdateProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 126, // 203: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 127, // 204: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 145, // 205: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 289, // 206: openshell.v1.LintProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 126, // 207: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 127, // 208: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 17, // 209: openshell.v1.DeleteProviderResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 289, // 210: openshell.v1.DeleteProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 17, // 211: openshell.v1.DeleteProviderProfileResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 159, // 212: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 275, // 213: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 276, // 214: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry - 277, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 278, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 132, // 190: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 305, // 191: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 306, // 192: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 137, // 193: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 285, // 194: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 147, // 195: openshell.v1.ProviderProfile.files:type_name -> openshell.v1.ProviderProfileFile + 146, // 196: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 146, // 197: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 302, // 198: openshell.v1.ImportProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 127, // 199: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 128, // 200: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 146, // 201: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 302, // 202: openshell.v1.UpdateProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 127, // 203: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 128, // 204: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 146, // 205: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 302, // 206: openshell.v1.LintProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 127, // 207: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 128, // 208: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 18, // 209: openshell.v1.DeleteProviderResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 302, // 210: openshell.v1.DeleteProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 18, // 211: openshell.v1.DeleteProviderProfileResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 160, // 212: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 286, // 213: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 287, // 214: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + 288, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 289, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry 4, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason - 279, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.files:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry - 288, // 219: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration - 289, // 220: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 286, // 221: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 294, // 222: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 165, // 223: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 280, // 224: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 166, // 225: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 167, // 226: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 168, // 227: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 170, // 228: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 171, // 229: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 172, // 230: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 295, // 231: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 293, // 232: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 296, // 233: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 169, // 234: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget - 297, // 235: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 169, // 236: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget - 281, // 237: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 289, // 238: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 183, // 239: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 289, // 240: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 183, // 241: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 12, // 242: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 11, // 243: openshell.v1.SandboxConfigurationAdmission.state:type_name -> openshell.v1.ConfigurationAdmissionState - 180, // 244: openshell.v1.ReportSandboxConfigurationRequest.admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 12, // 245: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 284, // 246: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp - 284, // 247: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp - 286, // 248: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 282, // 249: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 289, // 250: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 284, // 251: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp - 112, // 252: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 112, // 253: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 190, // 254: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 194, // 255: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 207, // 256: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 208, // 257: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 191, // 258: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 192, // 259: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 195, // 260: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 200, // 261: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 208, // 262: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 193, // 263: openshell.v1.GatewayMessage.session_redirect:type_name -> openshell.v1.SessionRedirect - 288, // 264: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration - 201, // 265: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 202, // 266: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 203, // 267: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 200, // 268: openshell.v1.PeerRelayInit.relay_open:type_name -> openshell.v1.RelayOpen - 205, // 269: openshell.v1.PeerRelayFrame.init:type_name -> openshell.v1.PeerRelayInit - 284, // 270: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp - 284, // 271: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp - 209, // 272: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 211, // 273: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 295, // 274: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 284, // 275: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp - 284, // 276: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp - 284, // 277: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp - 284, // 278: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp - 286, // 279: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 286, // 280: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 289, // 281: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 210, // 282: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 213, // 283: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 212, // 284: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 289, // 285: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 213, // 286: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 284, // 287: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp - 289, // 288: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 289: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 290: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 223, // 291: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 289, // 292: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 295, // 293: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 289, // 294: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 295: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 289, // 296: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 284, // 297: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp - 233, // 298: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 283, // 299: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 298, // 300: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 298, // 301: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 298, // 302: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 17, // 303: openshell.v1.DeleteWorkspaceResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 285, // 304: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 14, // 305: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 289, // 306: openshell.v1.AddWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 14, // 307: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 243, // 308: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 289, // 309: openshell.v1.RemoveWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 17, // 310: openshell.v1.RemoveWorkspaceMemberResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 289, // 311: openshell.v1.ListWorkspaceMembersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 243, // 312: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 284, // 313: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp - 18, // 314: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult - 259, // 315: openshell.v1.EndpointObservation.failure:type_name -> openshell.v1.EndpointFailure - 251, // 316: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation - 253, // 317: openshell.v1.ReportEndpointStatusRequest.token_grant_status:type_name -> openshell.v1.TokenGrantStatus - 254, // 318: openshell.v1.TokenGrantStatus.last_failure:type_name -> openshell.v1.TokenGrantFailureObservation - 259, // 319: openshell.v1.TokenGrantFailureObservation.failure:type_name -> openshell.v1.EndpointFailure - 18, // 320: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult - 284, // 321: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp - 259, // 322: openshell.v1.EndpointStatus.last_failure:type_name -> openshell.v1.EndpointFailure - 284, // 323: openshell.v1.SandboxProvisioning.configuration_change_time:type_name -> google.protobuf.Timestamp - 284, // 324: openshell.v1.SandboxProvisioning.first_rejection_time:type_name -> google.protobuf.Timestamp - 284, // 325: openshell.v1.SandboxProvisioning.deadline:type_name -> google.protobuf.Timestamp - 284, // 326: openshell.v1.SandboxProvisioning.timeout_time:type_name -> google.protobuf.Timestamp - 284, // 327: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp - 284, // 328: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp - 284, // 329: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp - 284, // 330: openshell.v1.SandboxProvisioning.preparation_deadline:type_name -> google.protobuf.Timestamp - 284, // 331: openshell.v1.SandboxProvisioning.admission_start_time:type_name -> google.protobuf.Timestamp - 19, // 332: openshell.v1.SandboxServiceExposure.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode - 20, // 333: openshell.v1.EndpointFailure.domain:type_name -> openshell.v1.EndpointFailureDomain - 15, // 334: openshell.v1.EndpointFailure.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 117, // 335: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry.value:type_name -> openshell.v1.ProviderCredentialValue - 284, // 336: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp - 284, // 337: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp - 131, // 338: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 160, // 339: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 25, // 340: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 27, // 341: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 29, // 342: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 53, // 343: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 61, // 344: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 63, // 345: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 64, // 346: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 54, // 347: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 55, // 348: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 56, // 349: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 57, // 350: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 65, // 351: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 66, // 352: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 67, // 353: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 83, // 354: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest - 68, // 355: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 69, // 356: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 70, // 357: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 88, // 358: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 90, // 359: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 91, // 360: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 92, // 361: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 94, // 362: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 98, // 363: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 100, // 364: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 106, // 365: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 107, // 366: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 114, // 367: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 115, // 368: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 116, // 369: openshell.v1.OpenShell.GetProviderCredentials:input_type -> openshell.v1.GetProviderCredentialsRequest - 119, // 370: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 124, // 371: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 125, // 372: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 149, // 373: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 151, // 374: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 153, // 375: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 120, // 376: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 137, // 377: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 139, // 378: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 141, // 379: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 143, // 380: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 121, // 381: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 156, // 382: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 299, // 383: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 300, // 384: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 164, // 385: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 174, // 386: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 176, // 387: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 178, // 388: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 252, // 389: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest - 85, // 390: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest - 181, // 391: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest - 158, // 392: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 162, // 393: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 184, // 394: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 185, // 395: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 188, // 396: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 196, // 397: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 198, // 398: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 204, // 399: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 206, // 400: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame - 85, // 401: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest - 252, // 402: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest - 83, // 403: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest - 110, // 404: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 215, // 405: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 217, // 406: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 219, // 407: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 221, // 408: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 224, // 409: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 226, // 410: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 228, // 411: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 230, // 412: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 232, // 413: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 21, // 414: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 23, // 415: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 235, // 416: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 237, // 417: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 239, // 418: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 241, // 419: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 244, // 420: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 246, // 421: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 248, // 422: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 26, // 423: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 28, // 424: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 30, // 425: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 71, // 426: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 62, // 427: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 71, // 428: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 72, // 429: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 58, // 430: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 58, // 431: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 59, // 432: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 60, // 433: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 73, // 434: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 74, // 435: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 75, // 436: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 84, // 437: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse - 87, // 438: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 71, // 439: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 71, // 440: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 89, // 441: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 97, // 442: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 97, // 443: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 93, // 444: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 95, // 445: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 99, // 446: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 104, // 447: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 106, // 448: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 104, // 449: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 122, // 450: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 122, // 451: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 118, // 452: openshell.v1.OpenShell.GetProviderCredentials:output_type -> openshell.v1.GetProviderCredentialsResponse - 123, // 453: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 148, // 454: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 147, // 455: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 150, // 456: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 152, // 457: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 154, // 458: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 122, // 459: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 138, // 460: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 140, // 461: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 142, // 462: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 144, // 463: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 155, // 464: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 157, // 465: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 301, // 466: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 302, // 467: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 173, // 468: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 175, // 469: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 177, // 470: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 179, // 471: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 255, // 472: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse - 86, // 473: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse - 182, // 474: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse - 161, // 475: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 163, // 476: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 187, // 477: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 186, // 478: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 189, // 479: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 197, // 480: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 199, // 481: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 204, // 482: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 206, // 483: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame - 86, // 484: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse - 255, // 485: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse - 84, // 486: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse - 111, // 487: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 216, // 488: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 218, // 489: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 220, // 490: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 222, // 491: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 225, // 492: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 227, // 493: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 229, // 494: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 231, // 495: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 234, // 496: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 22, // 497: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 24, // 498: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 236, // 499: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 238, // 500: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 240, // 501: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 242, // 502: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 245, // 503: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 247, // 504: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 249, // 505: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 423, // [423:506] is the sub-list for method output_type - 340, // [340:423] is the sub-list for method input_type - 340, // [340:340] is the sub-list for extension type_name - 340, // [340:340] is the sub-list for extension extendee - 0, // [0:340] is the sub-list for field type_name + 290, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.files:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntry + 11, // 219: openshell.v1.ProviderEnvironmentValue.classification:type_name -> openshell.v1.ProviderEnvironmentValueClassification + 161, // 220: openshell.v1.ProviderEnvironmentValue.static_credential_binding:type_name -> openshell.v1.StaticCredentialBinding + 297, // 221: openshell.v1.ProviderEnvironmentValue.expiration_time:type_name -> google.protobuf.Timestamp + 163, // 222: openshell.v1.ProviderEnvironmentSnapshot.values:type_name -> openshell.v1.ProviderEnvironmentValue + 291, // 223: openshell.v1.ProviderEnvironmentSnapshot.dynamic_credentials:type_name -> openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry + 4, // 224: openshell.v1.ProviderEnvironmentSnapshot.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason + 292, // 225: openshell.v1.ProviderEnvironmentSnapshot.files:type_name -> openshell.v1.ProviderEnvironmentSnapshot.FilesEntry + 301, // 226: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration + 302, // 227: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 299, // 228: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 307, // 229: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 168, // 230: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 293, // 231: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 169, // 232: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 170, // 233: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 171, // 234: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 173, // 235: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 174, // 236: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 175, // 237: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 308, // 238: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 306, // 239: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 309, // 240: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 172, // 241: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget + 310, // 242: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 172, // 243: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget + 294, // 244: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 302, // 245: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 186, // 246: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 302, // 247: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 186, // 248: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 13, // 249: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 12, // 250: openshell.v1.SandboxConfigurationAdmission.state:type_name -> openshell.v1.ConfigurationAdmissionState + 183, // 251: openshell.v1.ReportSandboxConfigurationRequest.admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 13, // 252: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 297, // 253: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp + 297, // 254: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp + 299, // 255: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 295, // 256: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 302, // 257: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 297, // 258: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp + 113, // 259: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 113, // 260: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 193, // 261: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 201, // 262: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 218, // 263: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 219, // 264: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 197, // 265: openshell.v1.SupervisorMessage.config_update_result:type_name -> openshell.v1.ConfigUpdateResult + 194, // 266: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 199, // 267: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 202, // 268: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 207, // 269: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 219, // 270: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 200, // 271: openshell.v1.GatewayMessage.session_redirect:type_name -> openshell.v1.SessionRedirect + 195, // 272: openshell.v1.GatewayMessage.config_update:type_name -> openshell.v1.ConfigUpdate + 195, // 273: openshell.v1.SessionAccepted.bootstrap:type_name -> openshell.v1.ConfigUpdate + 301, // 274: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration + 311, // 275: openshell.v1.ConfigUpdate.sandbox_config:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot + 164, // 276: openshell.v1.ConfigUpdate.provider_environment:type_name -> openshell.v1.ProviderEnvironmentSnapshot + 6, // 277: openshell.v1.ConfigUpdateResult.outcome:type_name -> openshell.v1.ConfigApplyOutcome + 196, // 278: openshell.v1.ConfigUpdateResult.failure:type_name -> openshell.v1.ConfigApplyFailure + 183, // 279: openshell.v1.ConfigUpdateResult.admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 198, // 280: openshell.v1.ConfigUpdateResult.policy_load:type_name -> openshell.v1.PolicyLoadResult + 208, // 281: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 209, // 282: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 210, // 283: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 207, // 284: openshell.v1.PeerRelayInit.relay_open:type_name -> openshell.v1.RelayOpen + 212, // 285: openshell.v1.PeerRelayFrame.init:type_name -> openshell.v1.PeerRelayInit + 214, // 286: openshell.v1.PeerNotifyConfigUpdateRequest.sandbox:type_name -> openshell.v1.PeerConfigSandboxTarget + 215, // 287: openshell.v1.PeerNotifyConfigUpdateRequest.provider:type_name -> openshell.v1.PeerConfigProviderTarget + 297, // 288: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp + 297, // 289: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp + 220, // 290: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 222, // 291: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 308, // 292: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 297, // 293: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp + 297, // 294: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp + 297, // 295: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp + 297, // 296: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp + 299, // 297: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 299, // 298: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 302, // 299: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 221, // 300: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 224, // 301: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 223, // 302: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 302, // 303: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 224, // 304: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 297, // 305: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp + 302, // 306: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 307: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 308: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 234, // 309: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 302, // 310: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 308, // 311: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 302, // 312: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 313: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 302, // 314: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 297, // 315: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp + 244, // 316: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 296, // 317: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 312, // 318: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 312, // 319: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 312, // 320: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 18, // 321: openshell.v1.DeleteWorkspaceResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 298, // 322: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 15, // 323: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 302, // 324: openshell.v1.AddWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 15, // 325: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 254, // 326: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 302, // 327: openshell.v1.RemoveWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 18, // 328: openshell.v1.RemoveWorkspaceMemberResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 302, // 329: openshell.v1.ListWorkspaceMembersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 254, // 330: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 297, // 331: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp + 19, // 332: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult + 270, // 333: openshell.v1.EndpointObservation.failure:type_name -> openshell.v1.EndpointFailure + 262, // 334: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation + 264, // 335: openshell.v1.ReportEndpointStatusRequest.token_grant_status:type_name -> openshell.v1.TokenGrantStatus + 265, // 336: openshell.v1.TokenGrantStatus.last_failure:type_name -> openshell.v1.TokenGrantFailureObservation + 270, // 337: openshell.v1.TokenGrantFailureObservation.failure:type_name -> openshell.v1.EndpointFailure + 19, // 338: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult + 297, // 339: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp + 270, // 340: openshell.v1.EndpointStatus.last_failure:type_name -> openshell.v1.EndpointFailure + 297, // 341: openshell.v1.SandboxProvisioning.configuration_change_time:type_name -> google.protobuf.Timestamp + 297, // 342: openshell.v1.SandboxProvisioning.first_rejection_time:type_name -> google.protobuf.Timestamp + 297, // 343: openshell.v1.SandboxProvisioning.deadline:type_name -> google.protobuf.Timestamp + 297, // 344: openshell.v1.SandboxProvisioning.timeout_time:type_name -> google.protobuf.Timestamp + 297, // 345: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp + 297, // 346: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp + 297, // 347: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp + 297, // 348: openshell.v1.SandboxProvisioning.preparation_deadline:type_name -> google.protobuf.Timestamp + 297, // 349: openshell.v1.SandboxProvisioning.admission_start_time:type_name -> google.protobuf.Timestamp + 20, // 350: openshell.v1.SandboxServiceExposure.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode + 21, // 351: openshell.v1.EndpointFailure.domain:type_name -> openshell.v1.EndpointFailureDomain + 16, // 352: openshell.v1.EndpointFailure.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 118, // 353: openshell.v1.GetProviderCredentialsResponse.CredentialsEntry.value:type_name -> openshell.v1.ProviderCredentialValue + 297, // 354: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 297, // 355: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 132, // 356: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 161, // 357: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 132, // 358: openshell.v1.ProviderEnvironmentSnapshot.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 26, // 359: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 28, // 360: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 30, // 361: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 54, // 362: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 62, // 363: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 64, // 364: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 65, // 365: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 55, // 366: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 56, // 367: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 57, // 368: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 58, // 369: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 66, // 370: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 67, // 371: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 68, // 372: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 84, // 373: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest + 69, // 374: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 70, // 375: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 71, // 376: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 89, // 377: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 91, // 378: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 92, // 379: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 93, // 380: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 95, // 381: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 99, // 382: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 101, // 383: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 107, // 384: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 108, // 385: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 115, // 386: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 116, // 387: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 117, // 388: openshell.v1.OpenShell.GetProviderCredentials:input_type -> openshell.v1.GetProviderCredentialsRequest + 120, // 389: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 125, // 390: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 126, // 391: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 150, // 392: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 152, // 393: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 154, // 394: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 121, // 395: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 138, // 396: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 140, // 397: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 142, // 398: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 144, // 399: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 122, // 400: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 157, // 401: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 313, // 402: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 314, // 403: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 167, // 404: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 177, // 405: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 179, // 406: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 181, // 407: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 263, // 408: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 86, // 409: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest + 184, // 410: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest + 159, // 411: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 165, // 412: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 187, // 413: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 188, // 414: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 191, // 415: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 203, // 416: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 205, // 417: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 211, // 418: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 213, // 419: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame + 86, // 420: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest + 263, // 421: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 84, // 422: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest + 216, // 423: openshell.v1.OpenShell.PeerNotifyConfigUpdate:input_type -> openshell.v1.PeerNotifyConfigUpdateRequest + 111, // 424: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 226, // 425: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 228, // 426: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 230, // 427: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 232, // 428: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 235, // 429: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 237, // 430: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 239, // 431: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 241, // 432: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 243, // 433: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 22, // 434: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 24, // 435: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 246, // 436: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 248, // 437: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 250, // 438: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 252, // 439: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 255, // 440: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 257, // 441: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 259, // 442: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 27, // 443: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 29, // 444: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 31, // 445: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 72, // 446: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 63, // 447: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 72, // 448: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 73, // 449: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 59, // 450: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 59, // 451: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 60, // 452: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 61, // 453: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 74, // 454: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 75, // 455: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 76, // 456: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 85, // 457: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse + 88, // 458: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 72, // 459: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 72, // 460: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 90, // 461: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 98, // 462: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 98, // 463: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 94, // 464: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 96, // 465: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 100, // 466: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 105, // 467: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 107, // 468: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 105, // 469: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 123, // 470: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 123, // 471: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 119, // 472: openshell.v1.OpenShell.GetProviderCredentials:output_type -> openshell.v1.GetProviderCredentialsResponse + 124, // 473: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 149, // 474: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 148, // 475: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 151, // 476: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 153, // 477: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 155, // 478: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 123, // 479: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 139, // 480: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 141, // 481: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 143, // 482: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 145, // 483: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 156, // 484: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 158, // 485: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 315, // 486: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 316, // 487: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 176, // 488: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 178, // 489: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 180, // 490: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 182, // 491: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 266, // 492: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 87, // 493: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse + 185, // 494: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse + 162, // 495: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 166, // 496: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 190, // 497: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 189, // 498: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 192, // 499: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 204, // 500: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 206, // 501: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 211, // 502: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 213, // 503: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame + 87, // 504: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse + 266, // 505: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 85, // 506: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse + 217, // 507: openshell.v1.OpenShell.PeerNotifyConfigUpdate:output_type -> openshell.v1.PeerNotifyConfigUpdateResponse + 112, // 508: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 227, // 509: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 229, // 510: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 231, // 511: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 233, // 512: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 236, // 513: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 238, // 514: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 240, // 515: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 242, // 516: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 245, // 517: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 23, // 518: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 25, // 519: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 247, // 520: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 249, // 521: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 251, // 522: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 253, // 523: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 256, // 524: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 258, // 525: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 260, // 526: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 443, // [443:527] is the sub-list for method output_type + 359, // [359:443] is the sub-list for method input_type + 359, // [359:359] is the sub-list for extension type_name + 359, // [359:359] is the sub-list for extension extendee + 0, // [0:359] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -21139,7 +22092,7 @@ func file_openshell_proto_init() { (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[144].OneofWrappers = []any{ + file_openshell_proto_msgTypes[146].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), (*PolicyMergeOperation_RemoveRule)(nil), @@ -21147,41 +22100,49 @@ func file_openshell_proto_init() { (*PolicyMergeOperation_AddAllowRules)(nil), (*PolicyMergeOperation_RemoveBinary)(nil), } - file_openshell_proto_msgTypes[148].OneofWrappers = []any{} - file_openshell_proto_msgTypes[167].OneofWrappers = []any{ + file_openshell_proto_msgTypes[150].OneofWrappers = []any{} + file_openshell_proto_msgTypes[169].OneofWrappers = []any{ (*SupervisorMessage_Hello)(nil), (*SupervisorMessage_Heartbeat)(nil), (*SupervisorMessage_RelayOpenResult)(nil), (*SupervisorMessage_RelayClose)(nil), + (*SupervisorMessage_ConfigUpdateResult)(nil), } - file_openshell_proto_msgTypes[168].OneofWrappers = []any{ + file_openshell_proto_msgTypes[170].OneofWrappers = []any{ (*GatewayMessage_SessionAccepted)(nil), (*GatewayMessage_SessionRejected)(nil), (*GatewayMessage_Heartbeat)(nil), (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), (*GatewayMessage_SessionRedirect)(nil), + (*GatewayMessage_ConfigUpdate)(nil), } - file_openshell_proto_msgTypes[179].OneofWrappers = []any{ + file_openshell_proto_msgTypes[185].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[183].OneofWrappers = []any{ + file_openshell_proto_msgTypes[189].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[185].OneofWrappers = []any{ + file_openshell_proto_msgTypes[191].OneofWrappers = []any{ (*PeerRelayFrame_Init)(nil), (*PeerRelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[238].OneofWrappers = []any{} + file_openshell_proto_msgTypes[194].OneofWrappers = []any{ + (*PeerNotifyConfigUpdateRequest_Sandbox)(nil), + (*PeerNotifyConfigUpdateRequest_Workspace)(nil), + (*PeerNotifyConfigUpdateRequest_AllConnected)(nil), + (*PeerNotifyConfigUpdateRequest_Provider)(nil), + } + file_openshell_proto_msgTypes[248].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), - NumEnums: 21, - NumMessages: 263, + NumEnums: 22, + NumMessages: 275, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 5ca018daf6..57f17c466b 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -87,6 +87,7 @@ const ( OpenShell_PeerReportProviderReadiness_FullMethodName = "/openshell.v1.OpenShell/PeerReportProviderReadiness" OpenShell_PeerReportEndpointStatus_FullMethodName = "/openshell.v1.OpenShell/PeerReportEndpointStatus" OpenShell_PeerGetSandboxProviderStatus_FullMethodName = "/openshell.v1.OpenShell/PeerGetSandboxProviderStatus" + OpenShell_PeerNotifyConfigUpdate_FullMethodName = "/openshell.v1.OpenShell/PeerNotifyConfigUpdate" OpenShell_WatchSandbox_FullMethodName = "/openshell.v1.OpenShell/WatchSandbox" OpenShell_SubmitPolicyAnalysis_FullMethodName = "/openshell.v1.OpenShell/SubmitPolicyAnalysis" OpenShell_GetDraftPolicy_FullMethodName = "/openshell.v1.OpenShell/GetDraftPolicy" @@ -217,7 +218,9 @@ type OpenShellClient interface { DeleteProvider(ctx context.Context, in *DeleteProviderRequest, opts ...grpc.CallOption) (*DeleteProviderResponse, error) // Delete a custom provider type profile by id. DeleteProviderProfile(ctx context.Context, in *DeleteProviderProfileRequest, opts ...grpc.CallOption) (*DeleteProviderProfileResponse, error) - // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + // Get sandbox settings by id (called by users, and by supervisors at startup + // and in their poll loop). A supervisor whose session pushes configuration + // receives later updates over ConnectSupervisor and stops polling. GetSandboxConfig(ctx context.Context, in *sandboxv1.GetSandboxConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetSandboxConfigResponse, error) // Get gateway-global settings (read-only runtime configuration; any // authenticated user may read these without requiring Platform Admin). @@ -242,7 +245,9 @@ type OpenShellClient interface { ReportProviderReadiness(ctx context.Context, in *ReportProviderReadinessRequest, opts ...grpc.CallOption) (*ReportProviderReadinessResponse, error) // Register startup and acknowledge an exact validated runtime configuration. ReportSandboxConfiguration(ctx context.Context, in *ReportSandboxConfigurationRequest, opts ...grpc.CallOption) (*ReportSandboxConfigurationResponse, error) - // Get provider environment for a sandbox (called by sandbox supervisor at startup). + // Get provider environment for a sandbox (called by the sandbox supervisor at + // startup and by its poll loop). Polling projection of the provider + // environment delivered over ConnectSupervisor; removed with the polling RPCs. GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) // Exchange a stored provider subject token for an intermediate token scoped // to the calling supervisor's SPIFFE identity. @@ -255,9 +260,11 @@ type OpenShellClient interface { // // The supervisor opens this stream at startup and keeps it alive for the // sandbox lifetime. The gateway uses it to coordinate relay channels for - // SSH connect, ExecSandbox, and targetable sandbox services. Raw service - // bytes flow over RelayStream calls (separate HTTP/2 streams on the same - // connection), not over this stream. + // SSH connect, ExecSandbox, targetable sandbox services, and configuration + // delivery. Supervisors advertise optional stream features in + // SupervisorHello, and the gateway sends only payloads the supervisor + // advertises. Raw service bytes flow over RelayStream calls (separate HTTP/2 + // streams on the same connection), not over this stream. ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) // Persist the canonical main process result before the supervisor exits. ReportMainProcessExit(ctx context.Context, in *ReportMainProcessExitRequest, opts ...grpc.CallOption) (*ReportMainProcessExitResponse, error) @@ -287,6 +294,9 @@ type OpenShellClient interface { PeerReportProviderReadiness(ctx context.Context, in *ReportProviderReadinessRequest, opts ...grpc.CallOption) (*ReportProviderReadinessResponse, error) PeerReportEndpointStatus(ctx context.Context, in *ReportEndpointStatusRequest, opts ...grpc.CallOption) (*ReportEndpointStatusResponse, error) PeerGetSandboxProviderStatus(ctx context.Context, in *GetSandboxProviderStatusRequest, opts ...grpc.CallOption) (*GetSandboxProviderStatusResponse, error) + // Best-effort notification to the replica that owns a supervisor session. + // The receiver rebuilds snapshots from authoritative state. + PeerNotifyConfigUpdate(ctx context.Context, in *PeerNotifyConfigUpdateRequest, opts ...grpc.CallOption) (*PeerNotifyConfigUpdateResponse, error) // Watch a sandbox and stream updates. // // This stream can include: @@ -1016,6 +1026,16 @@ func (c *openShellClient) PeerGetSandboxProviderStatus(ctx context.Context, in * return out, nil } +func (c *openShellClient) PeerNotifyConfigUpdate(ctx context.Context, in *PeerNotifyConfigUpdateRequest, opts ...grpc.CallOption) (*PeerNotifyConfigUpdateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PeerNotifyConfigUpdateResponse) + err := c.cc.Invoke(ctx, OpenShell_PeerNotifyConfigUpdate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) WatchSandbox(ctx context.Context, in *WatchSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxStreamEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[7], OpenShell_WatchSandbox_FullMethodName, cOpts...) @@ -1324,7 +1344,9 @@ type OpenShellServer interface { DeleteProvider(context.Context, *DeleteProviderRequest) (*DeleteProviderResponse, error) // Delete a custom provider type profile by id. DeleteProviderProfile(context.Context, *DeleteProviderProfileRequest) (*DeleteProviderProfileResponse, error) - // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + // Get sandbox settings by id (called by users, and by supervisors at startup + // and in their poll loop). A supervisor whose session pushes configuration + // receives later updates over ConnectSupervisor and stops polling. GetSandboxConfig(context.Context, *sandboxv1.GetSandboxConfigRequest) (*sandboxv1.GetSandboxConfigResponse, error) // Get gateway-global settings (read-only runtime configuration; any // authenticated user may read these without requiring Platform Admin). @@ -1349,7 +1371,9 @@ type OpenShellServer interface { ReportProviderReadiness(context.Context, *ReportProviderReadinessRequest) (*ReportProviderReadinessResponse, error) // Register startup and acknowledge an exact validated runtime configuration. ReportSandboxConfiguration(context.Context, *ReportSandboxConfigurationRequest) (*ReportSandboxConfigurationResponse, error) - // Get provider environment for a sandbox (called by sandbox supervisor at startup). + // Get provider environment for a sandbox (called by the sandbox supervisor at + // startup and by its poll loop). Polling projection of the provider + // environment delivered over ConnectSupervisor; removed with the polling RPCs. GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) // Exchange a stored provider subject token for an intermediate token scoped // to the calling supervisor's SPIFFE identity. @@ -1362,9 +1386,11 @@ type OpenShellServer interface { // // The supervisor opens this stream at startup and keeps it alive for the // sandbox lifetime. The gateway uses it to coordinate relay channels for - // SSH connect, ExecSandbox, and targetable sandbox services. Raw service - // bytes flow over RelayStream calls (separate HTTP/2 streams on the same - // connection), not over this stream. + // SSH connect, ExecSandbox, targetable sandbox services, and configuration + // delivery. Supervisors advertise optional stream features in + // SupervisorHello, and the gateway sends only payloads the supervisor + // advertises. Raw service bytes flow over RelayStream calls (separate HTTP/2 + // streams on the same connection), not over this stream. ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error // Persist the canonical main process result before the supervisor exits. ReportMainProcessExit(context.Context, *ReportMainProcessExitRequest) (*ReportMainProcessExitResponse, error) @@ -1394,6 +1420,9 @@ type OpenShellServer interface { PeerReportProviderReadiness(context.Context, *ReportProviderReadinessRequest) (*ReportProviderReadinessResponse, error) PeerReportEndpointStatus(context.Context, *ReportEndpointStatusRequest) (*ReportEndpointStatusResponse, error) PeerGetSandboxProviderStatus(context.Context, *GetSandboxProviderStatusRequest) (*GetSandboxProviderStatusResponse, error) + // Best-effort notification to the replica that owns a supervisor session. + // The receiver rebuilds snapshots from authoritative state. + PeerNotifyConfigUpdate(context.Context, *PeerNotifyConfigUpdateRequest) (*PeerNotifyConfigUpdateResponse, error) // Watch a sandbox and stream updates. // // This stream can include: @@ -1648,6 +1677,9 @@ func (UnimplementedOpenShellServer) PeerReportEndpointStatus(context.Context, *R func (UnimplementedOpenShellServer) PeerGetSandboxProviderStatus(context.Context, *GetSandboxProviderStatusRequest) (*GetSandboxProviderStatusResponse, error) { return nil, status.Error(codes.Unimplemented, "method PeerGetSandboxProviderStatus not implemented") } +func (UnimplementedOpenShellServer) PeerNotifyConfigUpdate(context.Context, *PeerNotifyConfigUpdateRequest) (*PeerNotifyConfigUpdateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PeerNotifyConfigUpdate not implemented") +} func (UnimplementedOpenShellServer) WatchSandbox(*WatchSandboxRequest, grpc.ServerStreamingServer[SandboxStreamEvent]) error { return status.Error(codes.Unimplemented, "method WatchSandbox not implemented") } @@ -2805,6 +2837,24 @@ func _OpenShell_PeerGetSandboxProviderStatus_Handler(srv interface{}, ctx contex return interceptor(ctx, in, info, handler) } +func _OpenShell_PeerNotifyConfigUpdate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PeerNotifyConfigUpdateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).PeerNotifyConfigUpdate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_PeerNotifyConfigUpdate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).PeerNotifyConfigUpdate(ctx, req.(*PeerNotifyConfigUpdateRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_WatchSandbox_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(WatchSandboxRequest) if err := stream.RecvMsg(m); err != nil { @@ -3375,6 +3425,10 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "PeerGetSandboxProviderStatus", Handler: _OpenShell_PeerGetSandboxProviderStatus_Handler, }, + { + MethodName: "PeerNotifyConfigUpdate", + Handler: _OpenShell_PeerNotifyConfigUpdate_Handler, + }, { MethodName: "SubmitPolicyAnalysis", Handler: _OpenShell_SubmitPolicyAnalysis_Handler, diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 237c2c94ba..03c490d193 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -248,7 +248,7 @@ func (SettingScope) EnumDescriptor() ([]byte, []int) { return file_sandbox_proto_rawDescGZIP(), []int{3} } -// Source used for the policy payload in GetSandboxConfigResponse. +// Source used for a sandbox configuration payload. type PolicySource int32 const ( @@ -1981,7 +1981,185 @@ func (x *EffectiveSetting) GetScope() SettingScope { return SettingScope_SETTING_SCOPE_UNSPECIFIED } -// Response containing effective sandbox settings and policy. +// Complete effective sandbox configuration delivered to a supervisor. Fields +// have the same meaning as the identically numbered GetSandboxConfigResponse +// fields, including fail-closed handling of an unknown +// policy_validation_failure_mode and treating absent configuration_admitted as +// not admitted. Move those field comments here when the polling RPC is removed. +type SandboxConfigSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Policy *SandboxPolicy `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + PolicyHash string `protobuf:"bytes,3,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + Settings map[string]*EffectiveSetting `protobuf:"bytes,4,rep,name=settings,proto3" json:"settings,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ConfigRevision uint64 `protobuf:"varint,5,opt,name=config_revision,json=configRevision,proto3" json:"config_revision,omitempty"` + PolicySource PolicySource `protobuf:"varint,6,opt,name=policy_source,json=policySource,proto3,enum=openshell.sandbox.v1.PolicySource" json:"policy_source,omitempty"` + GlobalPolicyVersion uint32 `protobuf:"varint,7,opt,name=global_policy_version,json=globalPolicyVersion,proto3" json:"global_policy_version,omitempty"` + ProviderEnvRevision uint64 `protobuf:"varint,8,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + SupervisorMiddlewareServices []*SupervisorMiddlewareService `protobuf:"bytes,9,rep,name=supervisor_middleware_services,json=supervisorMiddlewareServices,proto3" json:"supervisor_middleware_services,omitempty"` + Workspace string `protobuf:"bytes,10,opt,name=workspace,proto3" json:"workspace,omitempty"` + PolicyValidationFailureMode string `protobuf:"bytes,11,opt,name=policy_validation_failure_mode,json=policyValidationFailureMode,proto3" json:"policy_validation_failure_mode,omitempty"` + ExtensionAuthenticationEnabled bool `protobuf:"varint,12,opt,name=extension_authentication_enabled,json=extensionAuthenticationEnabled,proto3" json:"extension_authentication_enabled,omitempty"` + ConfigurationAdmitted bool `protobuf:"varint,13,opt,name=configuration_admitted,json=configurationAdmitted,proto3" json:"configuration_admitted,omitempty"` + ProviderAttachmentEpoch string `protobuf:"bytes,14,opt,name=provider_attachment_epoch,json=providerAttachmentEpoch,proto3" json:"provider_attachment_epoch,omitempty"` + ConfigurationInstanceId string `protobuf:"bytes,15,opt,name=configuration_instance_id,json=configurationInstanceId,proto3" json:"configuration_instance_id,omitempty"` + ConfigurationError string `protobuf:"bytes,16,opt,name=configuration_error,json=configurationError,proto3" json:"configuration_error,omitempty"` + SettingsRevision uint64 `protobuf:"varint,17,opt,name=settings_revision,json=settingsRevision,proto3" json:"settings_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxConfigSnapshot) Reset() { + *x = SandboxConfigSnapshot{} + mi := &file_sandbox_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxConfigSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxConfigSnapshot) ProtoMessage() {} + +func (x *SandboxConfigSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxConfigSnapshot.ProtoReflect.Descriptor instead. +func (*SandboxConfigSnapshot) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{21} +} + +func (x *SandboxConfigSnapshot) GetPolicy() *SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *SandboxConfigSnapshot) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *SandboxConfigSnapshot) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *SandboxConfigSnapshot) GetSettings() map[string]*EffectiveSetting { + if x != nil { + return x.Settings + } + return nil +} + +func (x *SandboxConfigSnapshot) GetConfigRevision() uint64 { + if x != nil { + return x.ConfigRevision + } + return 0 +} + +func (x *SandboxConfigSnapshot) GetPolicySource() PolicySource { + if x != nil { + return x.PolicySource + } + return PolicySource_POLICY_SOURCE_UNSPECIFIED +} + +func (x *SandboxConfigSnapshot) GetGlobalPolicyVersion() uint32 { + if x != nil { + return x.GlobalPolicyVersion + } + return 0 +} + +func (x *SandboxConfigSnapshot) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +func (x *SandboxConfigSnapshot) GetSupervisorMiddlewareServices() []*SupervisorMiddlewareService { + if x != nil { + return x.SupervisorMiddlewareServices + } + return nil +} + +func (x *SandboxConfigSnapshot) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +func (x *SandboxConfigSnapshot) GetPolicyValidationFailureMode() string { + if x != nil { + return x.PolicyValidationFailureMode + } + return "" +} + +func (x *SandboxConfigSnapshot) GetExtensionAuthenticationEnabled() bool { + if x != nil { + return x.ExtensionAuthenticationEnabled + } + return false +} + +func (x *SandboxConfigSnapshot) GetConfigurationAdmitted() bool { + if x != nil { + return x.ConfigurationAdmitted + } + return false +} + +func (x *SandboxConfigSnapshot) GetProviderAttachmentEpoch() string { + if x != nil { + return x.ProviderAttachmentEpoch + } + return "" +} + +func (x *SandboxConfigSnapshot) GetConfigurationInstanceId() string { + if x != nil { + return x.ConfigurationInstanceId + } + return "" +} + +func (x *SandboxConfigSnapshot) GetConfigurationError() string { + if x != nil { + return x.ConfigurationError + } + return "" +} + +func (x *SandboxConfigSnapshot) GetSettingsRevision() uint64 { + if x != nil { + return x.SettingsRevision + } + return 0 +} + +// Response containing effective sandbox settings and policy, projected from +// the SandboxConfigSnapshot that push supervisors receive. type GetSandboxConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The sandbox policy configuration. @@ -2027,13 +2205,15 @@ type GetSandboxConfigResponse struct { ConfigurationError string `protobuf:"bytes,16,opt,name=configuration_error,json=configurationError,proto3" json:"configuration_error,omitempty"` // Registration fence for a new supervisor; capture once and retain on retry. ConfigurationInstanceId string `protobuf:"bytes,15,opt,name=configuration_instance_id,json=configurationInstanceId,proto3" json:"configuration_instance_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Monotonic revision of the sandbox-scoped settings row. + SettingsRevision uint64 `protobuf:"varint,17,opt,name=settings_revision,json=settingsRevision,proto3" json:"settings_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxConfigResponse) Reset() { *x = GetSandboxConfigResponse{} - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2045,7 +2225,7 @@ func (x *GetSandboxConfigResponse) String() string { func (*GetSandboxConfigResponse) ProtoMessage() {} func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2058,7 +2238,7 @@ func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead. func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{21} + return file_sandbox_proto_rawDescGZIP(), []int{22} } func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { @@ -2173,6 +2353,13 @@ func (x *GetSandboxConfigResponse) GetConfigurationInstanceId() string { return "" } +func (x *GetSandboxConfigResponse) GetSettingsRevision() uint64 { + if x != nil { + return x.SettingsRevision + } + return 0 +} + // Connection details for one operator-registered supervisor middleware service. // V1 supports plaintext and server-authenticated TLS gRPC. type SupervisorMiddlewareService struct { @@ -2205,7 +2392,7 @@ type SupervisorMiddlewareService struct { func (x *SupervisorMiddlewareService) Reset() { *x = SupervisorMiddlewareService{} - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2217,7 +2404,7 @@ func (x *SupervisorMiddlewareService) String() string { func (*SupervisorMiddlewareService) ProtoMessage() {} func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2230,7 +2417,7 @@ func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMiddlewareService.ProtoReflect.Descriptor instead. func (*SupervisorMiddlewareService) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{22} + return file_sandbox_proto_rawDescGZIP(), []int{23} } func (x *SupervisorMiddlewareService) GetName() string { @@ -2436,7 +2623,30 @@ const file_sandbox_proto_rawDesc = "" + "\x05value\"\x86\x01\n" + "\x10EffectiveSetting\x128\n" + "\x05value\x18\x01 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value\x128\n" + - "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xb1\b\n" + + "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xd8\b\n" + + "\x15SandboxConfigSnapshot\x12;\n" + + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x03 \x01(\tR\n" + + "policyHash\x12U\n" + + "\bsettings\x18\x04 \x03(\v29.openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntryR\bsettings\x12'\n" + + "\x0fconfig_revision\x18\x05 \x01(\x04R\x0econfigRevision\x12G\n" + + "\rpolicy_source\x18\x06 \x01(\x0e2\".openshell.sandbox.v1.PolicySourceR\fpolicySource\x122\n" + + "\x15global_policy_version\x18\a \x01(\rR\x13globalPolicyVersion\x122\n" + + "\x15provider_env_revision\x18\b \x01(\x04R\x13providerEnvRevision\x12w\n" + + "\x1esupervisor_middleware_services\x18\t \x03(\v21.openshell.sandbox.v1.SupervisorMiddlewareServiceR\x1csupervisorMiddlewareServices\x12\x1c\n" + + "\tworkspace\x18\n" + + " \x01(\tR\tworkspace\x12C\n" + + "\x1epolicy_validation_failure_mode\x18\v \x01(\tR\x1bpolicyValidationFailureMode\x12H\n" + + " extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x125\n" + + "\x16configuration_admitted\x18\r \x01(\bR\x15configurationAdmitted\x12:\n" + + "\x19provider_attachment_epoch\x18\x0e \x01(\tR\x17providerAttachmentEpoch\x12:\n" + + "\x19configuration_instance_id\x18\x0f \x01(\tR\x17configurationInstanceId\x12/\n" + + "\x13configuration_error\x18\x10 \x01(\tR\x12configurationError\x12+\n" + + "\x11settings_revision\x18\x11 \x01(\x04R\x10settingsRevision\x1ac\n" + + "\rSettingsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\xde\b\n" + "\x18GetSandboxConfigResponse\x12;\n" + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" + "\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" + @@ -2455,7 +2665,8 @@ const file_sandbox_proto_rawDesc = "" + "\x19provider_attachment_epoch\x18\x0e \x01(\tR\x17providerAttachmentEpoch\x125\n" + "\x16configuration_admitted\x18\r \x01(\bR\x15configurationAdmitted\x12/\n" + "\x13configuration_error\x18\x10 \x01(\tR\x12configurationError\x12:\n" + - "\x19configuration_instance_id\x18\x0f \x01(\tR\x17configurationInstanceId\x1ac\n" + + "\x19configuration_instance_id\x18\x0f \x01(\tR\x17configurationInstanceId\x12+\n" + + "\x11settings_revision\x18\x11 \x01(\x04R\x10settingsRevision\x1ac\n" + "\rSettingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\xd2\x02\n" + @@ -2503,7 +2714,7 @@ func file_sandbox_proto_rawDescGZIP() []byte { } var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 34) var file_sandbox_proto_goTypes = []any{ (NetworkTlsMode)(0), // 0: openshell.sandbox.v1.NetworkTlsMode (NetworkEnforcementMode)(0), // 1: openshell.sandbox.v1.NetworkEnforcementMode @@ -2531,67 +2742,74 @@ var file_sandbox_proto_goTypes = []any{ (*GetGatewayConfigResponse)(nil), // 23: openshell.sandbox.v1.GetGatewayConfigResponse (*SettingValue)(nil), // 24: openshell.sandbox.v1.SettingValue (*EffectiveSetting)(nil), // 25: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 26: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 27: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 30: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 31: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 32: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 33: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 34: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 35: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 36: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 37: google.protobuf.Struct - (*datamodelv1.WorkspaceSelector)(nil), // 38: openshell.datamodel.v1.WorkspaceSelector - (*durationpb.Duration)(nil), // 39: google.protobuf.Duration + (*SandboxConfigSnapshot)(nil), // 26: openshell.sandbox.v1.SandboxConfigSnapshot + (*GetSandboxConfigResponse)(nil), // 27: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 28: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 31: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 32: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 33: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 34: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 35: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 36: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 37: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry + nil, // 38: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 39: google.protobuf.Struct + (*datamodelv1.WorkspaceSelector)(nil), // 40: openshell.datamodel.v1.WorkspaceSelector + (*durationpb.Duration)(nil), // 41: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ 6, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy 7, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy 8, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 28, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 29, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 29, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 30, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint 20, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 37, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 39, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct 11, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector 0, // 9: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode 1, // 10: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode 2, // 11: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset 17, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule 16, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 30, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 31, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry 14, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions 12, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 31, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 32, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 32, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 33, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry 18, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 33, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 34, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 38, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 35, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 34, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 35, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 40, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 36, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry 24, // 24: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue 3, // 25: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 5, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 36, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 4, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 27, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 39, // 30: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 9, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 10, // 32: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 15, // 33: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 19, // 34: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 35: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 36: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 37: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 24, // 38: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 25, // 39: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 40, // [40:40] is the sub-list for method output_type - 40, // [40:40] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name + 5, // 26: openshell.sandbox.v1.SandboxConfigSnapshot.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 37, // 27: openshell.sandbox.v1.SandboxConfigSnapshot.settings:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry + 4, // 28: openshell.sandbox.v1.SandboxConfigSnapshot.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 28, // 29: openshell.sandbox.v1.SandboxConfigSnapshot.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 5, // 30: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 38, // 31: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 4, // 32: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 28, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 41, // 34: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 9, // 35: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 10, // 36: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 15, // 37: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 19, // 38: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 19, // 39: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 19, // 40: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 19, // 41: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 24, // 42: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 25, // 43: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 25, // 44: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 45, // [45:45] is the sub-list for method output_type + 45, // [45:45] is the sub-list for method input_type + 45, // [45:45] is the sub-list for extension type_name + 45, // [45:45] is the sub-list for extension extendee + 0, // [0:45] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2612,7 +2830,7 @@ func file_sandbox_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), NumEnums: 5, - NumMessages: 32, + NumMessages: 34, NumExtensions: 0, NumServices: 0, }, diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index ddf042c67c..87ab4d9cb6 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -1015,6 +1015,20 @@ the sandbox over per-sandbox TLS with server-certificate verification plus bootstrap-token client authentication, and owns gateway policy, provider credentials, DNS, and mediated upstream connections. +The supervisor loads its startup configuration through the polling RPCs and +opens `ConnectSupervisor` once the workload runs. When the gateway runs with +`config_delivery_mode = "push"`, it accepts the session with +`config_push_enabled` and a bootstrap update. The supervisor applies the +bootstrap, stops polling, and receives later updates on that stream. Every +update must contain both the sandbox configuration and the provider +environment it names. A reconnect to a gateway in poll mode resumes polling. + +If live policy or provider changes do not reach a sandbox in push mode, inspect +gateway and supervisor logs for bootstrap build failures, updates missing a +configuration part, apply failures, or the two-minute bootstrap timeout. Do not +add gateway credentials to the workload Pod; configuration delivery terminates +in the separate supervisor. + Inspect all driver-managed resources when a Kubernetes sandbox remains Starting or loses readiness: diff --git a/tasks/test.toml b/tasks/test.toml index 6518bfdbe4..79b246320c 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -157,6 +157,12 @@ run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo t description = "Run the shared OCI image feature suite against a Podman-backed gateway" run = "OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" +["e2e:rust:push"] +description = "Run CLI conformance and policy e2e tests against a Docker gateway that pushes configuration" +depends = ["e2e:conformance:build"] +env = { OPENSHELL_CONFIG_DELIVERY_MODE = "push", OPENSHELL_E2E_DOCKER_FEATURES = "e2e-config-push", OPENSHELL_E2E_DOCKER_TEST = "config_push live_policy_update policy_activation" } +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" + ["e2e:workload:build"] description = "Build the Docker E2E workload fixture" run = "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh"