diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 075cf5b2c8..41e1013d88 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -220,6 +220,7 @@ jobs: cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check cargo-lockfiles: name: Cargo lockfiles @@ -275,6 +276,7 @@ jobs: cargo clippy --locked --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) diff --git a/AGENTS.md b/AGENTS.md index 0a21595b85..e7eec57bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O | | `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking | | `crates/openshell-binary-identity/` | Binary identity | Shared trusted procfs executable identity resolution for isolation backends | -| `crates/openshell-isolation-interface/` | Isolation backend interface | RFC 0012 `IsolationBackend` trait and types; the supervisor-facing runtime contract | -| `crates/openshell-sandbox-backend/` | OpenShell sandbox backend | `OpenShellRuntimeBackend` and the authenticated OpenShell Sandbox Protocol shared with `openshell-sandbox` | +| `crates/openshell-isolation-interface/` | Isolation backend interface | Shared data and validation for the Isolation Backend RPC contracts | +| `crates/openshell-sandbox-backend/` | OpenShell sandbox backend | Concrete Isolation Backend RPC client and authenticated Sandbox Protocol shared with `openshell-sandbox` | | `crates/openshell-policy/` | Policy engine | Filesystem, network, and process constraints | | `crates/openshell-policy-schema/` | Authored policy schema | Dependency-light YAML/JSON representation, bounded parsing, and pure authored-language semantics | | `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage | diff --git a/Cargo.lock b/Cargo.lock index 558981a8b9..f9c022162b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4668,7 +4668,6 @@ dependencies = [ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ - "async-trait", "openshell-core", "serde", "serde_json", @@ -4841,12 +4840,13 @@ name = "openshell-sandbox-backend" version = "0.0.0" dependencies = [ "async-trait", + "bytes", "hyper-util", "libc", "openshell-core", "openshell-isolation-interface", "prost", - "protoc-bin-vendored", + "prost-types", "rcgen", "rustix 1.1.4", "rustls", @@ -4859,9 +4859,9 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", + "tokio-util", "tonic", "tonic-prost", - "tonic-prost-build", "tower", "tracing", "uuid", @@ -5101,6 +5101,7 @@ dependencies = [ "openshell-otel", "openshell-policy", "openshell-policy-schema", + "openshell-sandbox-backend", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "opentelemetry_sdk", @@ -5146,6 +5147,7 @@ dependencies = [ "openshell-core", "openshell-isolation-interface", "openshell-ocsf", + "openshell-sandbox-backend", "rand 0.10.2", "russh", "rustix 1.1.4", diff --git a/crates/openshell-core/README.md b/crates/openshell-core/README.md index b4cdb05f5d..1cf1b7e70e 100644 --- a/crates/openshell-core/README.md +++ b/crates/openshell-core/README.md @@ -3,6 +3,13 @@ Shared types, constants, configuration, and helpers used across OpenShell crates. +## Isolation registrations + +`isolation_registration` owns the operator registry's backend names, byte-stream +endpoints, and shared validation. Its fallible conversions carry registrations +through the compute-driver protobuf and back into the supervisor's JSON launch +file. These routes contain no credentials or driver-owned launch coordinates. + ## Object Metadata Top-level user-facing objects use a Kubernetes-style metadata convention. The diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 37d7000929..fdd1797e04 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -73,6 +73,7 @@ fn main() -> Result<(), Box> { // Configure tonic/prost protobuf code generation. tonic_prost_build::configure() + .btree_map(".openshell.sandbox.protocol.v1") .build_server(true) .build_client(true) .include_file("openshell.rs") diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index aef96b46d2..9de0b05187 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -19,6 +19,9 @@ pub const PROTOCOL_MINOR: u32 = 0; /// configured signer before accepting sandbox creation. pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication"; +/// Driver stages operator-selected external isolation routes for the supervisor. +pub const COMPUTE_ISOLATION_BACKEND: &str = "openshell.compute.isolation-backend"; + const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; const MAX_CAPABILITY_BYTES: usize = 128; @@ -121,6 +124,7 @@ pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata { let mut supported_capabilities = vec![contract.clone()]; if family == ExtensionFamily::Compute { supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string()); + supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string()); } PeerMetadata { protocol_version: Some(ProtocolVersion { @@ -157,10 +161,10 @@ pub fn extension_metadata( } /// Protocol version and base capability an extension of `family` must declare. -fn expected_contract(family: ExtensionFamily) -> String { +fn expected_contract(gateway: &PeerMetadata) -> String { format!( "protocol {PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and capability {}", - family.contract_capability() + gateway.required_capabilities.join(", ") ) } @@ -171,8 +175,32 @@ pub fn negotiate( extension: Option, ) -> Result { let configured_name = configured_name.into(); - let family_name = family.as_str(); - let expected = expected_contract(family); + let metadata = negotiate_metadata(family.as_str(), &configured_name, gateway, extension)?; + let version = metadata + .protocol_version + .expect("validated protocol version"); + Ok(NegotiatedExtension { + family, + configured_name, + implementation_name: metadata.implementation_name, + implementation_version: metadata.implementation_version, + protocol_major: version.major, + protocol_minor: version.minor, + supported_capabilities: metadata.supported_capabilities, + required_capabilities: metadata.required_capabilities, + }) +} + +/// Validate a versioned peer contract, including families owned by the supervisor. +/// Both peers must advertise every capability required by the other peer. +pub fn negotiate_metadata( + family_name: &'static str, + configured_name: impl Into, + gateway: &PeerMetadata, + extension: Option, +) -> Result { + let configured_name = configured_name.into(); + let expected = expected_contract(gateway); let extension = extension.ok_or_else(|| NegotiationError::MissingMetadata { family: family_name, name: configured_name.clone(), @@ -265,13 +293,10 @@ pub fn negotiate( }); } - Ok(NegotiatedExtension { - family, - configured_name, + Ok(PeerMetadata { + protocol_version: Some(*version), implementation_name: extension.implementation_name, implementation_version: extension.implementation_version, - protocol_major: version.major, - protocol_minor: version.minor, supported_capabilities: extension_supported.into_iter().collect(), required_capabilities: extension_required.into_iter().collect(), }) diff --git a/crates/openshell-core/src/isolation_registration.rs b/crates/openshell-core/src/isolation_registration.rs new file mode 100644 index 0000000000..1633dbb742 --- /dev/null +++ b/crates/openshell-core/src/isolation_registration.rs @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Operator-owned routes for external isolation backends. + +use std::collections::BTreeSet; +use std::net::SocketAddr; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Reserved selection for drivers that launch the built-in sandbox runtime. +pub const BUILTIN_BACKEND_NAME: &str = "openshell-sandbox"; + +/// Trusted byte-stream endpoint. Generation-specific TLS is supplied by the driver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SandboxTransport { + Unix { + socket_path: PathBuf, + }, + Tcp { + /// Logical HTTP authority used for routing. + authority: String, + /// Concrete connection candidates pinned by the operator or driver. + addresses: Vec, + }, + Vsock { + guest_cid: u32, + port: u32, + }, +} + +impl SandboxTransport { + pub fn validate(&self) -> Result<(), &'static str> { + let valid = match self { + Self::Unix { socket_path } => socket_path.is_absolute(), + Self::Tcp { + authority, + addresses, + } => { + !authority.is_empty() + && !addresses.is_empty() + && addresses + .iter() + .all(|address| address.port() != 0 && !address.ip().is_unspecified()) + } + Self::Vsock { guest_cid, port } => { + *guest_cid >= 3 && *guest_cid != u32::MAX && *port != 0 && *port != u32::MAX + } + }; + if valid { + Ok(()) + } else { + Err("invalid boundary control transport endpoint") + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackendRegistration { + pub name: String, + pub endpoint: SandboxTransport, +} + +pub fn validate_backend_name(name: &str) -> Result<(), &'static str> { + if name == BUILTIN_BACKEND_NAME + || name.is_empty() + || name.len() > 128 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("invalid or reserved delegated backend name"); + } + Ok(()) +} + +/// Validate every entry, including unused registrations. +pub fn validate_registrations(registrations: &[BackendRegistration]) -> Result<(), &'static str> { + let mut names = BTreeSet::new(); + for registration in registrations { + validate_backend_name(®istration.name)?; + if !names.insert(®istration.name) { + return Err("duplicate isolation backend registration"); + } + registration.endpoint.validate()?; + } + Ok(()) +} + +impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::IsolationBackendRegistration { + type Error = &'static str; + + fn try_from(registration: &BackendRegistration) -> Result { + use crate::proto::compute::v1::{ + IsolationBackendTcpEndpoint, IsolationBackendVsockEndpoint, + isolation_backend_registration::Endpoint, + }; + validate_backend_name(®istration.name)?; + registration.endpoint.validate()?; + let endpoint = match ®istration.endpoint { + SandboxTransport::Unix { socket_path } => Endpoint::UnixSocketPath( + socket_path + .to_str() + .ok_or("isolation backend Unix path must be UTF-8")? + .into(), + ), + SandboxTransport::Tcp { + authority, + addresses, + } => Endpoint::Tcp(IsolationBackendTcpEndpoint { + authority: authority.clone(), + addresses: addresses.iter().map(ToString::to_string).collect(), + }), + SandboxTransport::Vsock { guest_cid, port } => { + Endpoint::Vsock(IsolationBackendVsockEndpoint { + guest_cid: *guest_cid, + port: *port, + }) + } + }; + Ok(Self { + name: registration.name.clone(), + endpoint: Some(endpoint), + }) + } +} + +impl TryFrom<&crate::proto::compute::v1::IsolationBackendRegistration> for BackendRegistration { + type Error = &'static str; + + fn try_from( + registration: &crate::proto::compute::v1::IsolationBackendRegistration, + ) -> Result { + use crate::proto::compute::v1::isolation_backend_registration::Endpoint; + let endpoint = match registration + .endpoint + .as_ref() + .ok_or("isolation backend endpoint is missing")? + { + Endpoint::UnixSocketPath(path) => SandboxTransport::Unix { + socket_path: path.into(), + }, + Endpoint::Tcp(tcp) => SandboxTransport::Tcp { + authority: tcp.authority.clone(), + addresses: tcp + .addresses + .iter() + .map(|address| { + address + .parse() + .map_err(|_| "invalid isolation backend TCP address") + }) + .collect::>()?, + }, + Endpoint::Vsock(vsock) => SandboxTransport::Vsock { + guest_cid: vsock.guest_cid, + port: vsock.port, + }, + }; + validate_backend_name(®istration.name)?; + endpoint.validate()?; + Ok(Self { + name: registration.name.clone(), + endpoint, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::compute::v1::IsolationBackendRegistration; + + #[test] + fn operator_routes_survive_compute_protocol_and_supervisor_json() { + for endpoint in [ + SandboxTransport::Unix { + socket_path: "/run/backend.sock".into(), + }, + SandboxTransport::Tcp { + authority: "backend.example:443".into(), + addresses: vec![ + "127.0.0.1:47000".parse().unwrap(), + "[::1]:47000".parse().unwrap(), + ], + }, + SandboxTransport::Vsock { + guest_cid: 3, + port: 47000, + }, + ] { + let original = BackendRegistration { + name: "agent-substrate".into(), + endpoint, + }; + let wire = IsolationBackendRegistration::try_from(&original).unwrap(); + let decoded = BackendRegistration::try_from(&wire).unwrap(); + assert_eq!(decoded, original); + let json = serde_json::to_string(&decoded).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + original + ); + } + } +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 99730a26cc..9e70b309c2 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod gpu; pub mod grpc_client; pub mod host_pattern; pub mod image; +pub mod isolation_registration; pub mod jwt; pub mod local_api_socket; pub mod mcp; diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index c6076620a9..194a69e44d 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -33,7 +33,13 @@ pub mod datamodel { } pub mod sandbox { + pub mod backend { + pub use super::super::generated::openshell::sandbox::backend::v1; + } pub use super::generated::openshell::sandbox::v1; + pub mod protocol { + pub use super::super::generated::openshell::sandbox::protocol::v1; + } } pub mod compute { diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d921b8c348..a817ec484b 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -12,7 +12,7 @@ use std::net::IpAddr; use std::path::PathBuf; use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ @@ -29,35 +29,37 @@ struct DockerOuterFenceEvidence<'a> { } impl DockerOuterFenceEvidence<'_> { - fn project(&self, generation: &str) -> Result { - if self.container_id.is_empty() { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { return Err(BackendError::Descriptor( - "Docker outer fence evidence is incomplete".to_string(), + "Docker provisioning evidence is incomplete".to_string(), )); } - let mut established = Vec::new(); - if self.network_mode == "none" { - // With no container network namespace attachment, workload egress - // remains denied both after revocation and if the supervisor exits. - established.extend([ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ]); - } - if self.unexpected_networks.is_empty() { - established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); - } let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) })?; - let projection = - OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; + let projection = ProvisioningEvidence::from_enforcement_evidence(generation, &encoded)?; projection.validate(generation)?; Ok(projection) } } +/// Bind the validated container fence to the generation being launched. +pub fn provisioning_evidence( + container_id: &str, + generation: &str, +) -> Result { + DockerOuterFenceEvidence { + container_id, + network_mode: "none", + unexpected_networks: &[], + } + .project(generation) +} + /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { pub boundary_id: String, @@ -96,13 +98,8 @@ impl DockerBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let unexpected_networks = Vec::new(); - let outer_fence = DockerOuterFenceEvidence { - container_id: &resource_claims["docker.container_id"], - network_mode: "none", - unexpected_networks: &unexpected_networks, - } - .project(&self.generation)?; + let provisioning_evidence = + provisioning_evidence(&resource_claims["docker.container_id"], &self.generation)?; Ok(DockerBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), @@ -119,7 +116,7 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), - outer_fence: outer_fence.clone(), + provisioning_evidence: provisioning_evidence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -133,7 +130,7 @@ impl DockerBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - outer_fence, + provisioning_evidence, }, }) } @@ -224,13 +221,13 @@ mod tests { "true" ); assert_eq!( - provisioned.boundary_config.outer_fence, - provisioned.runtime_descriptor.outer_fence + provisioned.boundary_config.provisioning_evidence, + provisioned.runtime_descriptor.provisioning_evidence ); assert!( provisioned .runtime_descriptor - .outer_fence + .provisioning_evidence .validate("generation-1") .is_ok() ); diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 97af48a69c..059ba1535c 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -4843,6 +4843,17 @@ async fn refresh_docker_boundary_authentication( .map_err(|error| Status::internal(format!("rotate Docker boundary TLS: {error}")))?; boundary_config.session_id = session_id; boundary_config.generation = authentication.supervisor.runtime_generation.to_string(); + // The stopped container retains its resource identity, but each new launch + // binds both protocol peers and their provisioning evidence to a fresh generation. + let container_id = boundary_config + .resource_claims + .get("docker.container_id") + .ok_or_else(|| Status::failed_precondition("Docker sandbox container claim is missing"))?; + boundary_config.provisioning_evidence = + isolation::provisioning_evidence(container_id, &boundary_config.generation) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + runtime_descriptor.generation = boundary_config.generation.clone(); + runtime_descriptor.provisioning_evidence = boundary_config.provisioning_evidence.clone(); boundary_config.session_rotation = authentication.supervisor.session_rotation; boundary_config.auth_epoch = authentication.supervisor.auth_epoch; boundary_config.gateway_id = authentication.gateway_id; diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index e10b078965..0159ecd99f 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -75,6 +75,117 @@ fn test_launch_authentication() -> Vec { .unwrap() } +#[tokio::test] +async fn restart_rotates_bootstrap_descriptor_and_provisioning_evidence_together() { + let config = runtime_config(); + let sandbox_id = format!("restart-{}", openshell_core::SandboxSessionId::new()); + let directory = docker_boundary_state_dir_by_id(&sandbox_id, &config).unwrap(); + fs::create_dir_all(&directory).unwrap(); + let mut authentication: SandboxLaunchAuthentication = + serde_json::from_slice(&test_launch_authentication()).unwrap(); + let tls = generate_sandbox_tls_material(authentication.supervisor.session_id).unwrap(); + let provisioned = isolation::DockerBoundarySpec { + boundary_id: sandbox_id.clone(), + generation: authentication.supervisor.runtime_generation.to_string(), + session_id: authentication.supervisor.session_id, + session_rotation: authentication.supervisor.session_rotation, + auth_epoch: authentication.supervisor.auth_epoch, + gateway_id: authentication.gateway_id.clone(), + verification_keys: gateway_verification_keys(&authentication.verification_keys).unwrap(), + container_id: "container-test".into(), + image_identity: "image-test".into(), + gpu_requested: false, + listener_socket: BOUNDARY_SOCKET_MOUNT_PATH.into(), + control_socket: BOUNDARY_SOCKET_MOUNT_PATH.into(), + sandbox_tls: SandboxTlsServerConfig { + certificate_chain_path: BOUNDARY_CERTIFICATE_MOUNT_PATH.into(), + private_key_path: BOUNDARY_PRIVATE_KEY_MOUNT_PATH.into(), + }, + supervisor_tls: SandboxTlsClientConfig { + server_name: tls.server_name, + trust_anchor_pem: tls.trust_anchor_pem, + }, + host_gateway_ip: None, + workload_identity: ResolvedWorkloadIdentity::new( + 1000, + 1000, + vec![], + "image".into(), + "image-test".into(), + ) + .unwrap(), + child_env: HashMap::new(), + } + .provision() + .unwrap(); + fs::write( + directory.join(BOUNDARY_CONFIG_FILE), + provisioned.boundary_config.encode().unwrap(), + ) + .unwrap(); + fs::write( + directory.join(RUNTIME_DESCRIPTOR_FILE), + provisioned + .runtime_descriptor + .backend_descriptor() + .unwrap() + .payload, + ) + .unwrap(); + let previous_evidence = provisioned.boundary_config.provisioning_evidence; + authentication.supervisor.runtime_generation = + openshell_core::sandbox_generation::SandboxGenerationId::parse("generation-2").unwrap(); + authentication.supervisor.session_id = openshell_core::SandboxSessionId::new(); + let encoded = serde_json::to_vec(&authentication).unwrap(); + refresh_docker_boundary_authentication(&sandbox_id, &config, &encoded) + .await + .unwrap(); + + let bootstrap: BoundaryConfig = + serde_json::from_slice(&fs::read(directory.join(BOUNDARY_CONFIG_FILE)).unwrap()).unwrap(); + let descriptor = read_docker_runtime_descriptor(&sandbox_id, &config) + .await + .unwrap() + .unwrap(); + let supervisor: SupervisorAuthBundle = + serde_json::from_slice(&fs::read(directory.join(SUPERVISOR_AUTH_BUNDLE_FILE)).unwrap()) + .unwrap(); + assert_eq!(bootstrap.generation, "generation-2"); + assert_eq!(descriptor.generation, bootstrap.generation); + assert_eq!(supervisor.runtime_generation.as_str(), bootstrap.generation); + assert_eq!(bootstrap.session_id, descriptor.session_id); + assert_eq!(bootstrap.session_id, supervisor.session_id); + bootstrap + .provisioning_evidence + .validate("generation-2") + .unwrap(); + assert_eq!( + bootstrap.provisioning_evidence, + descriptor.provisioning_evidence + ); + assert_ne!( + bootstrap.provisioning_evidence.evidence_digest, + previous_evidence.evidence_digest + ); + assert_eq!( + bootstrap.resource_claims, + provisioned.runtime_descriptor.resource_claims + ); + // Retrying this launch keeps its generation/evidence binding stable. + refresh_docker_boundary_authentication(&sandbox_id, &config, &encoded) + .await + .unwrap(); + let retried = read_docker_runtime_descriptor(&sandbox_id, &config) + .await + .unwrap() + .unwrap(); + assert_eq!( + retried.provisioning_evidence, + descriptor.provisioning_evidence + ); + fs::remove_dir_all(directory).unwrap(); +} + fn test_sandbox() -> DriverSandbox { // Mirrors the gateway-supplied request: the public `Sandbox` API no // longer carries `namespace`, so the gateway elides the field and the @@ -101,6 +212,7 @@ fn test_sandbox() -> DriverSandbox { await_main_process_attachment: false, workload_identity: None, launch_authentication: test_launch_authentication(), + ..Default::default() }), status: None, workspace: String::new(), diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index 6a94e6abaa..f4ae89b38b 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -21,7 +21,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, @@ -39,30 +39,21 @@ struct KubernetesOuterFenceEvidence<'a> { } impl KubernetesOuterFenceEvidence<'_> { - fn project(&self, generation: &str) -> Result { - if self.network_policy_uid.is_empty() || self.network_policy_resource_version.is_empty() { + fn project(&self, generation: &str) -> Result { + if self.network_policy_uid.is_empty() + || self.network_policy_resource_version.is_empty() + || !self.ingress_isolated + || !self.egress_isolated + || self.egress_rule_count != 0 + { return Err(BackendError::Descriptor( - "Kubernetes outer fence evidence is incomplete".to_string(), + "Kubernetes provisioning evidence is incomplete".to_string(), )); } - let mut established = Vec::new(); - if self.ingress_isolated && self.egress_isolated && self.egress_rule_count == 0 { - // A persisted policy selecting both directions with no egress rule - // continues to deny direct egress after revocation or controller loss. - established.extend([ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ]); - } - if self.egress_isolated && self.egress_rule_count == 0 { - established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); - } let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) })?; - let projection = - OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; + let projection = ProvisioningEvidence::from_enforcement_evidence(generation, &encoded)?; projection.validate(generation)?; Ok(projection) } @@ -247,7 +238,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.egress_policy_resource_version, ), ]); - let outer_fence = KubernetesOuterFenceEvidence { + let provisioning_evidence = KubernetesOuterFenceEvidence { network_policy_uid: &resource_claims["kubernetes.egress_policy_uid"], network_policy_resource_version: &resource_claims ["kubernetes.egress_policy_resource_version"], @@ -275,7 +266,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), - outer_fence: outer_fence.clone(), + provisioning_evidence: provisioning_evidence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -290,7 +281,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - outer_fence, + provisioning_evidence, }, }) } @@ -403,13 +394,13 @@ mod tests { "1945" ); assert_eq!( - provisioned.boundary_config.outer_fence, - provisioned.runtime_descriptor.outer_fence + provisioned.boundary_config.provisioning_evidence, + provisioned.runtime_descriptor.provisioning_evidence ); assert!( provisioned .runtime_descriptor - .outer_fence + .provisioning_evidence .validate("generation-1") .is_ok() ); diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index cffbeb3599..0ee226b446 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,9 +10,7 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{ - OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, -}; +use openshell_isolation_interface::contract::{ProvisioningEvidence, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, @@ -38,27 +36,16 @@ struct PodmanOuterFenceEvidence<'a> { } impl PodmanOuterFenceEvidence<'_> { - fn project(&self, generation: &str) -> Result { - if self.container_id.is_empty() { - return Err(invalid("Podman outer fence evidence is incomplete")); - } - let mut established = Vec::new(); - if self.network_mode == "none" { - // With no container network namespace attachment, workload egress - // remains denied both after revocation and if the supervisor exits. - established.extend([ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ]); - } - if self.unexpected_networks.is_empty() { - established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(invalid("Podman provisioning evidence is incomplete")); } let encoded = serde_json::to_vec(self).map_err(invalid)?; - let projection = - OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded) - .map_err(invalid)?; + let projection = ProvisioningEvidence::from_enforcement_evidence(generation, &encoded) + .map_err(invalid)?; projection.validate(generation).map_err(invalid)?; Ok(projection) } @@ -242,7 +229,7 @@ pub fn bootstrap_archives( .runtime_generation .to_string(); let unexpected_networks = Vec::new(); - let outer_fence = PodmanOuterFenceEvidence { + let provisioning_evidence = PodmanOuterFenceEvidence { container_id, network_mode: "none", unexpected_networks: &unexpected_networks, @@ -278,7 +265,7 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), - outer_fence: outer_fence.clone(), + provisioning_evidence: provisioning_evidence.clone(), child_env: child_env.clone(), }; let runtime_descriptor = SandboxRuntimeDescriptor { @@ -295,7 +282,7 @@ pub fn bootstrap_archives( host_gateway_ip: Some(host_gateway_ip), resource_claims, workload_identity: identity.clone(), - outer_fence, + provisioning_evidence, }; // Libpod resolves the requested upload destination once for a stopped // container. Archive entries must be relative to the selected named volume, @@ -568,14 +555,17 @@ mod tests { .unwrap(); assert_eq!(config.boundary_id, runtime_descriptor.boundary_id); assert_eq!(config.session_id, runtime_descriptor.session_id); - assert_eq!(config.outer_fence, runtime_descriptor.outer_fence); + assert_eq!( + config.provisioning_evidence, + runtime_descriptor.provisioning_evidence + ); assert_eq!(config.workload_identity, identity); assert_eq!( runtime_descriptor.host_gateway_ip, Some("127.0.0.1".parse().unwrap()) ); runtime_descriptor - .outer_fence + .provisioning_evidence .validate(&runtime_descriptor.generation) .unwrap(); assert!( diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index a660ad98c7..dc7426d229 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,7 +9,7 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ @@ -26,29 +26,17 @@ struct VmOuterFenceEvidence<'a> { } impl VmOuterFenceEvidence<'_> { - fn project(&self) -> Result { - if self.generation.is_empty() { + fn project(&self) -> Result { + if self.generation.is_empty() || self.network_device_count != 0 { return Err(BackendError::Descriptor( - "VM outer fence evidence is incomplete".to_string(), + "VM provisioning evidence is incomplete".to_string(), )); } - let established = (self.network_device_count == 0).then_some([ - // A guest with no NIC has no kernel network path. Closing the - // supervisor-owned channel revokes access, and controller loss - // cannot introduce a device. - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ]); let encoded = serde_json::to_vec(self).map_err(|error| { BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) })?; - let projection = OuterFenceGuarantees::from_enforcement_evidence( - self.generation, - established.into_iter().flatten(), - &encoded, - )?; + let projection = + ProvisioningEvidence::from_enforcement_evidence(self.generation, &encoded)?; projection.validate(self.generation)?; Ok(projection) } @@ -98,7 +86,7 @@ impl VmBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let outer_fence = VmOuterFenceEvidence { + let provisioning_evidence = VmOuterFenceEvidence { generation: &self.generation, network_device_count: 0, } @@ -119,7 +107,7 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), - outer_fence: outer_fence.clone(), + provisioning_evidence: provisioning_evidence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -134,7 +122,7 @@ impl VmBoundarySpec { // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims, - outer_fence, + provisioning_evidence, }, }) } @@ -232,13 +220,13 @@ mod tests { Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) ); assert_eq!( - provisioned.boundary_config.outer_fence, - provisioned.runtime_descriptor.outer_fence + provisioned.boundary_config.provisioning_evidence, + provisioned.runtime_descriptor.provisioning_evidence ); assert!( provisioned .runtime_descriptor - .outer_fence + .provisioning_evidence .validate("generation-1") .is_ok() ); diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 642c927cef..a0c3c94a29 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-isolation-interface" -description = "OpenShell Isolation Backend interface (RFC 0012)" +description = "Shared types and validation for the OpenShell Isolation Backend RPCs" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -12,7 +12,6 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } -async-trait = "0.1" serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/openshell-isolation-interface/README.md b/crates/openshell-isolation-interface/README.md index 2329508ec0..97aa927395 100644 --- a/crates/openshell-isolation-interface/README.md +++ b/crates/openshell-isolation-interface/README.md @@ -1,8 +1,10 @@ -# Isolation backend interface +# Isolation backend shared types -This crate defines the supervisor-facing `IsolationBackend` contract, lifecycle -states, and shared types. It forbids unsafe code, including in its tests. +This crate contains shared data and validation for the Isolation Backend RPC +contracts. It forbids unsafe code, including in its tests. -Linux enforcement mechanisms belong to `openshell-sandbox::linux`. Backends -implement this crate's safe contract without importing Linux mechanisms through -the interface crate. +The extension contracts are [`isolation_backend.proto`](../../proto/isolation_backend.proto) +and [`sandbox_protocol.proto`](../../proto/sandbox_protocol.proto). +The supervisor uses the concrete RPC client in `openshell-sandbox-backend`. +Both in-tree and external backends implement the generated services. +Linux process and enforcement helpers remain private to `openshell-sandbox`. diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 246394d28e..b16dfdd724 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -1,45 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Runtime-selectable Isolation Backend contract (RFC 0012). +//! Validated data shared by the Isolation Backend RPC client and native server. //! -//! This module is the object-safe, runtime-selectable contract the supervisor -//! role drives. A backend registers an [`IsolationBackend`] under a -//! `backend_name`; the supervisor resolves it from a [`BackendRegistry`] -//! against the admitted backend name and advances the boundary through a fixed -//! chain of boxed states: -//! -//! ```text -//! attach backend descriptor + sandbox context -> Bound -> confirm -> Ready -//! -> start_agent -> Running -//! ``` -//! -//! Each transition consumes the prior state by value (`self: Box`). -//! Trusted backend implementations construct confirmation through a validating -//! constructor; the supervisor cannot obtain a ready boundary without confirmed -//! backend-neutral enforcement properties. -//! The supervisor holds no `match`/downcast on concrete backends: the -//! registry is the only lookup by `backend_name`, and everything past it is a -//! `Box` / `Arc`. -//! -//! `attach` is atomic from the caller's perspective: it establishes and binds -//! the boundary, returns `Bound`, or fails closed. It never binds a resource -//! already bound to an active boundary. Binary identity travels on every -//! [`PendingTcpOpen`], resolved for that exact socket and process -//! generation; an unresolved identity denies the open. -//! -//! The contract is transport-neutral. Compute drivers keep runtime placement -//! and coordination details behind these interfaces. +//! Backend extension contracts live in `proto/isolation_backend.proto` and +//! `proto/sandbox_protocol.proto`. This module contains data and validation, +//! not a second trait-based backend interface. -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::BTreeMap; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::str::FromStr; -use std::sync::Arc; use std::time::{Duration, Instant}; -use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::oneshot; @@ -58,8 +32,6 @@ pub use openshell_core::policy::SandboxPolicy; pub enum BackendError { /// Descriptor missing, malformed, unsupported, or mismatched against admission. Descriptor(String), - /// No backend registered for the resolved `backend_name`. - NotRegistered(String), /// Authenticated attachment rejection (incompatible or already-bound resource). Denied(String), /// Boundary temporarily unavailable. @@ -102,7 +74,7 @@ impl BackendError { #[must_use] pub fn kind(&self) -> BackendErrorKind { match self { - Self::Descriptor(_) | Self::NotRegistered(_) => BackendErrorKind::Invalid, + Self::Descriptor(_) => BackendErrorKind::Invalid, Self::Denied(_) => BackendErrorKind::Denied, Self::Unavailable(_) => BackendErrorKind::Unavailable, Self::Unsupported(_) => BackendErrorKind::Unsupported, @@ -116,7 +88,6 @@ impl fmt::Display for BackendError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Descriptor(m) => write!(f, "descriptor error: {m}"), - Self::NotRegistered(m) => write!(f, "backend not registered: {m}"), Self::Denied(m) => write!(f, "attachment denied: {m}"), Self::Unavailable(m) => write!(f, "boundary unavailable: {m}"), Self::Unsupported(m) => write!(f, "operation unsupported: {m}"), @@ -169,29 +140,6 @@ pub struct BackendDescriptor { pub payload: Vec, } -/// A descriptor whose common envelope has passed registry verification. -/// -/// Minted only by [`BackendRegistry::resolve`]; no public constructor, so an -/// unverified descriptor cannot reach a backend. The type does not imply that -/// the opaque payload has been validated: the backend validates the payload and -/// atomically binds it to the sandbox context during `attach`. -pub struct VerifiedBackendDescriptor { - descriptor: BackendDescriptor, -} - -impl VerifiedBackendDescriptor { - /// The verified backend name. - #[must_use] - pub fn backend_name(&self) -> &str { - &self.descriptor.backend_name - } - /// The backend-specific payload (validated by the backend at `attach`). - #[must_use] - pub fn payload(&self) -> &[u8] { - &self.descriptor.payload - } -} - /// Exact non-root identity selected before the immutable workload is created. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedWorkloadIdentity { @@ -264,176 +212,32 @@ pub struct SandboxContext { /// The agent workload to run inside the boundary. use crate::AgentSpec; -/// Maps backend name to its implementation. This is the only lookup by name; -/// supervisor lifecycle never branches on a concrete backend, and resolution -/// never falls back to another backend. -#[derive(Default)] -pub struct BackendRegistry { - backends: HashMap>, -} - -impl BackendRegistry { - /// An empty registry. - #[must_use] - pub fn new() -> Self { - Self { - backends: HashMap::new(), - } - } - - /// Register a backend. Rejects a duplicate name. - /// - /// # Errors - /// - /// Returns [`BackendError::Descriptor`] for a duplicate `backend_name`. - pub fn register(&mut self, backend: Arc) -> Result<(), BackendError> { - let name = backend.backend_name().to_string(); - if self.backends.contains_key(&name) { - return Err(BackendError::Descriptor(format!( - "duplicate backend name {name:?}" - ))); - } - self.backends.insert(name, backend); - Ok(()) - } - - /// Verify the descriptor's common envelope against the admitted backend name - /// and resolve its backend. Fails closed and never falls back: - /// - /// - the descriptor's `backend_name` must equal the admitted name; - /// - a backend must be registered under that name. - /// - /// # Errors - /// - /// Returns [`BackendError::Descriptor`] for an admission mismatch, and - /// [`BackendError::NotRegistered`] when no backend is - /// registered for the admitted name. - pub fn resolve( - &self, - descriptor: BackendDescriptor, - admitted_backend_name: &str, - ) -> Result<(Arc, VerifiedBackendDescriptor), BackendError> { - if descriptor.backend_name != admitted_backend_name { - return Err(BackendError::Descriptor(format!( - "descriptor backend {:?} does not match admitted backend {admitted_backend_name:?}", - descriptor.backend_name - ))); - } - let backend = self - .backends - .get(&descriptor.backend_name) - .ok_or_else(|| BackendError::NotRegistered(descriptor.backend_name.clone()))? - .clone(); - if backend.backend_name() != descriptor.backend_name { - return Err(BackendError::Descriptor(format!( - "registry returned backend {:?} for name {:?}", - backend.backend_name(), - descriptor.backend_name - ))); - } - Ok((backend, VerifiedBackendDescriptor { descriptor })) - } -} - -/// Establishes and operates boundaries for one admitted backend implementation. -#[async_trait] -pub trait IsolationBackend: Send + Sync { - /// The stable registered backend name. - fn backend_name(&self) -> &str; - - /// Validate the opaque payload, establish any boundary-local resources, - /// and atomically bind them to the trusted sandbox context: returns `Bound` - /// or fails closed. Never binds a resource already bound to an active - /// boundary. The authenticated runtime session must match - /// `sandbox.session_id`; a session from an earlier launch is rejected. - /// Durable resource lifecycle remains owned by the compute driver or - /// external orchestrator that supplied the descriptor. - async fn attach( - &self, - descriptor: VerifiedBackendDescriptor, - sandbox: SandboxContext, - ) -> Result, BackendError>; -} - // ============================================================================ // Lifecycle states // ============================================================================ -/// Bound: the backend descriptor and trusted sandbox context are bound to the -/// same resource, and the mediation source is available. No untrusted workload -/// code is running. -#[async_trait] -pub trait BoundBoundary: Send { - /// The mediation service's backend-neutral source of workload network - /// requests. TCP and DNS remain typed operations so consumers cannot mix - /// their framing, decisions, or response semantics. - /// Retained by the supervisor before consuming `Bound`. - fn network_mediation_source(&self) -> Arc; - - /// Trusted host-side dial target for the well-known host-gateway aliases. - /// - /// Backends return this when the mediation service runs outside the - /// workload boundary and therefore cannot use the boundary's resolver - /// view. The supervisor preserves the original hostname for policy, HTTP, - /// and TLS while dialing this backend-provided address. Returning `None` - /// leaves host-gateway discovery to the supervisor's local environment. - fn host_gateway_ip(&self) -> Option { - None - } - - /// Confirm standing enforcement and return measured sandbox evidence. - /// Confirmation fails closed and does not execute untrusted workload code. - async fn confirm(self: Box) -> Result; -} - -/// Backend-neutral guarantees established by the component that owns the outer -/// network fence. +/// Commitment to provisioning facts validated by the trusted compute driver. /// -/// The enforcement owner may be a compute driver or a delegated isolation -/// backend. It owns its native evidence schema and the code that validates it. -/// After validation, it projects that evidence into these guarantees and -/// supplies a digest that binds the original evidence to this generation. The -/// common runtime only validates and compares this projection; it never -/// interprets backend- or runtime-specific fields. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OuterFenceGuarantee { - /// No workload packet can leave without an explicit mediated decision. - DefaultDenyEgress, - /// The enforcement owner found no network path outside the mediated boundary. - NoUnmanagedEgressPath, - /// Previously granted access can be revoked by the enforcement owner. - RevocationVerified, - /// Loss of the fence's controller does not open network access. - ControllerLossFailsClosed, -} - +/// The driver validates its native placement and enforcement requirements before +/// constructing this binding. The digest includes the runtime generation, so +/// confirmation cannot reuse evidence from another launch. It is a binding, +/// not independent proof that the backend enforces policy. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OuterFenceGuarantees { - /// Sandbox generation for which the evidence was collected. +pub struct ProvisioningEvidence { pub generation: String, - /// Complete set of normalized guarantees established by the enforcement owner. - pub established: BTreeSet, - /// Commitment to the enforcement owner's native evidence. pub evidence_digest: Sha256Digest, } -impl OuterFenceGuarantees { - /// Bind guarantees explicitly established by validated enforcement evidence. - /// - /// This constructor deliberately does not infer guarantees from the mere - /// presence of evidence. The enforcement owner must inspect its native - /// state and project each established guarantee before calling this - /// function. +impl ProvisioningEvidence { + /// Bind already-validated native provisioning evidence to one generation. pub fn from_enforcement_evidence( generation: impl Into, - established: impl IntoIterator, native_evidence: &[u8], ) -> Result { let generation = generation.into(); if generation.is_empty() || native_evidence.is_empty() { return Err(BackendError::Descriptor( - "outer fence generation and native evidence are required".to_string(), + "provisioning generation and native evidence are required".to_string(), )); } let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len()); @@ -442,113 +246,43 @@ impl OuterFenceGuarantees { binding.extend_from_slice(native_evidence); Ok(Self { generation, - established: established.into_iter().collect(), evidence_digest: Sha256Digest::compute(&binding), }) } - /// Validate the common guarantees against the admitted generation. + /// Reject evidence bound to a different launch. pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { - let required = BTreeSet::from([ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ]); - let complete = !self.generation.is_empty() - && self.generation == expected_generation - && self.established == required; - if complete { + if !self.generation.is_empty() && self.generation == expected_generation { Ok(()) } else { Err(BackendError::Confirm( - "outer fence guarantees are incomplete or bound to another generation".to_string(), + "provisioning evidence belongs to another generation".to_string(), )) } } } -/// A backend-neutral security property established before agent launch. +/// Successful policy-enforcement confirmation for one admitted launch. /// -/// `mechanism` is diagnostic and audit metadata. It never authorizes launch; -/// the registered backend is responsible for validating its mechanism-specific -/// evidence before setting `enforced`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnforcedProperty { - pub enforced: bool, - pub mechanism: String, -} - -impl EnforcedProperty { - #[must_use] - pub fn new(enforced: bool, mechanism: impl Into) -> Self { - Self { - enforced, - mechanism: mechanism.into(), - } - } - - fn validate(&self, name: &str) -> Result<(), BackendError> { - if self.enforced && !self.mechanism.trim().is_empty() { - Ok(()) - } else { - Err(BackendError::Confirm(format!( - "{name} is not enforced or has no declared mechanism" - ))) - } - } -} - -/// Security properties every isolation backend establishes before launch. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct BoundaryProperties { - pub filesystem_confinement: EnforcedProperty, - pub egress_interception: EnforcedProperty, - pub request_attribution: EnforcedProperty, - pub privilege_floor: EnforcedProperty, -} - -impl BoundaryProperties { - fn validate(&self) -> Result<(), BackendError> { - self.filesystem_confinement - .validate("filesystem confinement")?; - self.egress_interception.validate("egress interception")?; - self.request_attribution.validate("request attribution")?; - self.privilege_floor.validate("privilege floor") - } -} - -/// Per-boundary confirmation produced before agent launch. -/// -/// Common validation binds the confirmation to the admitted workload and -/// checks backend-neutral properties. `backend_audit` remains opaque to this -/// crate; the registered backend owns its schema and validates it before -/// constructing [`ConfirmedBoundary`]. +/// The backend rejects unsupported policy before confirming. Mechanisms are +/// backend-specific; the common client checks identity and launch binding. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BoundaryConfirmation { pub generation: String, pub identity: ResolvedWorkloadIdentity, - pub properties: BoundaryProperties, - pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, - pub outer_fence: OuterFenceGuarantees, - /// The backend-owned containment primitive terminates the workload when its - /// Sandbox Runtime exits. - pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, + pub provisioning_evidence_digest: Sha256Digest, + /// Optional backend-owned audit data; native clients may validate its schema. + #[serde(default)] pub backend_audit: serde_json::Value, } impl BoundaryConfirmation { - /// Validate common security properties and immutable launch binding. + /// Validate the admitted workload identity. The RPC client checks the + /// session, generation, resource claims, and provisioning evidence binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { - self.outer_fence.validate(&self.generation)?; - self.properties.validate()?; - let complete = &self.identity == expected - && self.authenticated_supervisor - && self.runtime_exit_terminates_workload - && !self.generation.is_empty(); - if complete { + if &self.identity == expected && !self.generation.is_empty() { Ok(()) } else { Err(BackendError::Confirm( @@ -558,82 +292,6 @@ impl BoundaryConfirmation { } } -/// Ready boundary paired with the confirmation established by `confirm`. -pub struct ConfirmedBoundary { - boundary: Box, - confirmation: BoundaryConfirmation, -} - -impl ConfirmedBoundary { - /// Construct confirmation after checking backend-neutral properties and - /// immutable identity binding. - /// - /// Backend implementations are trusted to validate their audit evidence and - /// bind this confirmation to their resource. This constructor enforces the - /// common requirements without requiring those implementations to live in - /// the interface crate. - /// - /// # Errors - /// - /// Returns an error if confirmation is incomplete or the identity does not match. - pub fn try_new( - boundary: Box, - confirmation: BoundaryConfirmation, - expected: &ResolvedWorkloadIdentity, - ) -> Result { - confirmation.validate(expected)?; - Ok(Self { - boundary, - confirmation, - }) - } - - /// Return the record carried by this confirmed state. - pub fn confirmation(&self) -> &BoundaryConfirmation { - &self.confirmation - } - - /// Consume confirmation and advance to the sole launch-capable state. - pub fn into_boundary(self) -> Box { - self.boundary - } -} - -/// Ready: standing enforcement is confirmed, and the backend is prepared to -/// ensure the admitted launch-time controls are in force -/// before untrusted execution. Only agent activation is possible from here. -#[async_trait] -pub trait ReadyBoundary: Send { - /// Make the admitted agent runnable behind the boundary and return its - /// handle. `start_agent` is the sole operation that may make the admitted - /// agent runnable, and it fails closed if any `Ready` condition no longer - /// holds. Whether the backend creates the agent process or releases a held, - /// driver-provisioned execution object is backend-specific; every - /// applicable launch-time control is in force before the first untrusted - /// instruction. - async fn start_agent(self: Box) -> Result, BackendError>; -} - -/// Running: the agent is runnable behind the boundary and the returned agent -/// handle represents the admitted agent process. Exec and forwarding are available. -/// -/// All interface accessors return owned `Arc`s so a consumer can retain them -/// past any later state consumption. -#[async_trait] -pub trait RunningBoundary: Send + Sync { - /// The admitted agent process handle. - fn agent(&self) -> Arc; - /// The in-boundary exec interface. - fn exec(&self) -> Arc; - /// The loopback connection interface used by port forwarding and service exposure. - fn loopback_connector(&self) -> Arc; - /// Permanently terminate the boundary's owned process tree and return only - /// after the backend has acknowledged terminal state. A driver may use - /// destruction of the outer runtime as fallback proof when this operation - /// cannot complete. - async fn terminate(&self) -> Result<(), BackendError>; -} - // ============================================================================ // Process and exec // ============================================================================ @@ -660,67 +318,11 @@ pub enum BoundarySignal { Hup, } -/// A process running inside the boundary. `wait` returns one stable status -/// however many times it is called; a local PID is never the process handle. -#[async_trait] -pub trait BoundaryProcess: Send + Sync { - /// Attach to the admitted process's retained standard I/O. The boundary - /// remains the process owner and may permit only one control attachment. - async fn attach(&self) -> Result { - Err(BackendError::Unsupported( - "process attachment is not supported".to_string(), - )) - } - /// Await terminal status (stable across repeated calls). - async fn wait(&self) -> Result; - /// Deliver a signal to the process or its group. - async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError>; - /// Terminate the process and its backend-owned process group. - async fn terminate(&self) -> Result<(), BackendError>; -} - /// A boxed async writer into a boundary process's stdin. pub type BoundaryInput = Box; /// A boxed async reader from a boundary process's stdout or stderr. pub type BoundaryOutput = Box; -/// A control-side attachment to the admitted process's retained I/O. -pub struct ProcessAttachment { - /// Stdin writer. - pub stdin: BoundaryInput, - /// Stdout reader, or the PTY-merged output stream. - pub stdout: BoundaryOutput, - /// Stderr reader, distinct from stdout for non-PTY processes. - pub stderr: Option, - /// PTY control, present when the admitted process owns a terminal. - pub terminal: Option>, -} - -/// A PTY attached to an exec session. -#[async_trait] -pub trait BoundaryTerminal: Send + Sync { - /// Resize the terminal. - async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError>; -} - -/// An owned exec session: the process handle plus its stdio and optional PTY. -/// Owning the process keeps it alive after `exec` returns. -pub struct ExecSession { - /// The spawned process. - pub process: Arc, - /// Stdin writer, if not a PTY-merged stream. - pub stdin: Option, - /// Stdout reader. - pub stdout: BoundaryOutput, - /// Stderr reader, distinct from stdout for non-PTY exec. - pub stderr: Option, - /// PTY control, present when a terminal was requested. - pub terminal: Option>, - /// Final status of the attached output stream, including delivery failure. - /// The process handle's stable wait status remains independent of attachment. - pub output_status: Option>, -} - /// What to run inside the boundary via [`BoundaryExec`]. #[derive(Debug, Clone)] pub struct ExecSpec { @@ -757,30 +359,6 @@ pub struct ShellSpec { pub login: bool, } -/// In-boundary process entry, consumed by the SSH server and supervisor session. -/// -/// Like `start_agent`, every exec ensures the applicable launch-time controls -/// are in force before the new process executes its first untrusted instruction -/// and preserves the provisioned execution environment. -#[async_trait] -pub trait BoundaryExec: Send + Sync { - /// Spawn `spec` inside the boundary, returning an owned session. - async fn exec(&self, spec: ExecSpec) -> Result; - - /// Install the current provider environment for future process launches. - /// - /// Success requires an authenticated acknowledgment from the running - /// boundary. Implementations serialize this operation with exec so an - /// older publication cannot replace the acknowledged environment. - async fn synchronize_provider_environment( - &self, - ) -> Result { - Err(BackendError::Unsupported( - "provider environment installation acknowledgment is unavailable".to_string(), - )) - } -} - /// Evidence that the running workload boundary installed one provider snapshot. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProviderEnvironmentInstallation { @@ -836,17 +414,6 @@ impl DuplexStream for T {} /// An open connection into a boundary loopback target. pub type BoundaryDuplexStream = Box; -/// Protected connector to services listening inside the boundary. -/// -/// Higher layers use this primitive for both end-user port forwarding and -/// service exposure. Authentication, public listeners, routing, and exposure -/// lifecycle remain outside the isolation backend. -#[async_trait] -pub trait BoundaryLoopbackConnector: Send + Sync { - /// Connect to `target` inside the boundary. - async fn connect(&self, target: LoopbackTarget) -> Result; -} - // ============================================================================ // Mediation and binary identity // ============================================================================ @@ -986,8 +553,8 @@ pub enum TcpOpenDenial { /// timestamp is local to the supervisor and is intentionally not serialized. #[derive(Debug, Clone)] pub struct MediationTiming { - /// Time from receiving the sandbox syscall notification to queueing it for - /// the transport. + /// Time from receiving the sandbox syscall notification or guest host call + /// to queueing it for the transport. Zero when unmeasured. pub sandbox_notification_to_queue: Duration, /// Time spent waiting in the sandbox-side mediation queue. pub sandbox_queue_wait: Duration, @@ -1015,10 +582,12 @@ pub struct PendingTcpOpen { pub stream: BoundaryDuplexStream, /// Executable identity, resolved by the backend for this connection. pub binary_identity: Result, - /// Original external destination captured from the blocked syscall. + /// Original external destination captured by the backend before mediation. pub destination: SocketAddr, - /// Socket and process identity bound to this request. - pub socket: NetworkSocketMetadata, + /// Native socket evidence, when the backend mediates a kernel socket. + /// Guests without native sockets supply authoritative executable identity + /// above and leave this absent; never invent kernel cookies or host PIDs. + pub socket: Option, /// Policy generation under which the request was created. pub policy_generation: u64, /// Monotonic stage timing for performance diagnostics. @@ -1027,25 +596,6 @@ pub struct PendingTcpOpen { pub decision: oneshot::Sender, } -/// A logical per-boundary stream of workload connections, consumed by the -/// mediation service wherever that service runs. -/// -/// It may wrap a dedicated listener or a demultiplexed view over shared -/// transport; how it reaches a co-located proxy, a sidecar, or a shared -/// mediation service is backend-private. A trusted backend component associates -/// every returned request with its active boundary without relying solely on a -/// transport tuple or workload-provided identifier. TCP and DNS use separate -/// accepts so they can be consumed concurrently with independent backpressure. -/// An `Err` means that mediation lane is unusable and fails closed. -#[async_trait] -pub trait NetworkMediationSource: Send + Sync { - /// Await the next staged workload TCP open. - async fn accept_tcp(&self) -> Result; - - /// Await the next workload DNS query. - async fn accept_dns(&self) -> Result; -} - /// DNS transport used by one workload exchange. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DnsTransport { diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index bb29678bcc..bfa1b0b546 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -3,34 +3,16 @@ #![forbid(unsafe_code)] -//! The `OpenShell` **Isolation Backend** runtime contract (RFC 0012). +//! Shared data and validation for the Isolation Backend RPC contracts. //! -//! An isolation backend establishes and enforces a workload's isolation boundary; -//! the supervisor role drives it through one contract. The supervisor-facing -//! contract lives in [`contract`]: an object-safe, runtime-selectable backend -//! plus a fixed chain of boxed lifecycle states the supervisor advances without -//! branching on placement. Each driver places a sandbox boundary beside the -//! workload and connects it to a separate supervisor. +//! The backend extension interface is defined by `proto/isolation_backend.proto` +//! and `proto/sandbox_protocol.proto`. The supervisor uses the concrete RPC +//! client in `openshell-sandbox-backend`; backends implement the generated +//! services without implementing a parallel Rust backend trait. //! -//! The backend establishes standing enforcement before untrusted code runs and -//! ensures launch-time controls are in force before each process's first -//! untrusted instruction. It also exposes process operations and supplies -//! workload egress to supervisor-owned network mediation. -//! -//! # Ordering is a security property -//! -//! The lifecycle states run in order: attach -> Bound -> confirm -> Ready -> -//! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it -//! is confirmed ready. This is enforced *by construction*: each transition -//! consumes the prior state by value. Trusted backends construct confirmation -//! through [`contract::ConfirmedBoundary::try_new`], which checks common -//! enforcement properties and immutable launch binding before the supervisor -//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays -//! owned by the backend that can interpret it. -//! -//! [`AgentSpec`] is shared between the workload definition the supervisor -//! submits and the [`contract::SandboxContext`] that `attach` binds to a -//! boundary. +//! [`contract`] retains validated workload identities, confirmation evidence, +//! process specifications, and local byte-stream types shared by client and +//! server. Confirmation and generation checks remain mandatory before launch. /// The agent workload to run inside the boundary. /// diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 2fb1f3fdba..e34e6215bd 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -1,363 +1,9 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Conformance harness for the runtime-selectable contract. -//! -//! Two materially different mock backends (`Primary`, `Secondary`) with -//! distinct concrete state structs (each generic over a marker, so each kind -//! monomorphizes to its own types) prove the registry holds heterogeneous -//! backends behind `dyn` with no enum over concrete state, and that one driver -//! runs both unchanged. - -use std::collections::BTreeMap; -use std::marker::PhantomData; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use openshell_isolation_interface::AgentSpec; +//! Shared data validation; lifecycle and process behavior are tested over RPC. use openshell_isolation_interface::contract::*; -use tokio::sync::oneshot; - -// --------------------------------------------------------------------------- -// Marker kinds: two materially different backends. -// --------------------------------------------------------------------------- - -trait MockKind: Send + Sync + 'static { - const BACKEND_ID: &'static str; - /// Whether this backend can produce a binary digest (a heterogeneity axis: - /// one backend resolves a full identity, the other resolves path-only). - const HAS_DIGEST: bool; -} - -struct Primary; -impl MockKind for Primary { - const BACKEND_ID: &'static str = "mock-primary"; - const HAS_DIGEST: bool = true; -} - -struct Secondary; -impl MockKind for Secondary { - const BACKEND_ID: &'static str = "mock-secondary"; - const HAS_DIGEST: bool = false; -} - -// --------------------------------------------------------------------------- -// Runtime interfaces (shared across kinds where behavior is identical). -// --------------------------------------------------------------------------- - -struct MockProcess { - status: BoundaryExitStatus, - alive: AtomicBool, - signals: Mutex>, -} - -impl MockProcess { - fn new() -> Arc { - Arc::new(Self { - status: BoundaryExitStatus::Exited(0), - alive: AtomicBool::new(true), - signals: Mutex::new(Vec::new()), - }) - } -} - -#[async_trait] -impl BoundaryProcess for MockProcess { - async fn wait(&self) -> Result { - // Stable across repeated calls. - self.alive.store(false, Ordering::SeqCst); - Ok(self.status) - } - async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { - if !self.alive.load(Ordering::SeqCst) { - return Err(BackendError::Terminated("process has exited".to_string())); - } - self.signals.lock().unwrap().push(signal); - Ok(()) - } - async fn terminate(&self) -> Result<(), BackendError> { - self.alive - .swap(false, Ordering::SeqCst) - .then_some(()) - .ok_or_else(|| BackendError::Terminated("process has exited".to_string())) - } -} - -/// Mediation source: hands the mediation service a connection carrying its -/// per-connection identity-resolution result. -struct MockSource(PhantomData); - -#[async_trait] -impl NetworkMediationSource for MockSource { - async fn accept_tcp(&self) -> Result { - let (near, _far) = tokio::io::duplex(64); - let (decision, _decision_rx) = oneshot::channel(); - Ok(PendingTcpOpen { - stream: Box::new(near), - binary_identity: Ok(BinaryIdentity { - executable: ExecutableIdentity { - path: PathBuf::from("/usr/bin/agent"), - digest: K::HAS_DIGEST.then(|| "00".repeat(32).parse().expect("valid digest")), - }, - ancestors: vec![], - cmdline_paths: vec![], - }), - destination: "203.0.113.10:443".parse().unwrap(), - socket: NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, - policy_generation: 1, - timing: MediationTiming::default(), - decision, - }) - } - - async fn accept_dns(&self) -> Result { - let (response, _response_rx) = oneshot::channel(); - Ok(PendingDnsQuery { - message: vec![0; 12], - transport: DnsTransport::Udp, - binary_identity: Err(ResolveError::Failed( - "mock DNS attribution unavailable".to_string(), - )), - timing: MediationTiming::default(), - response, - }) - } -} - -/// An source whose backend cannot attribute the connection: the connection is -/// still delivered, carrying `Err`, so the mediation service denies and audits -/// it. It never authorizes anything. -struct UnattributedSource; - -#[async_trait] -impl NetworkMediationSource for UnattributedSource { - async fn accept_tcp(&self) -> Result { - let (near, _far) = tokio::io::duplex(64); - let (decision, _decision_rx) = oneshot::channel(); - Ok(PendingTcpOpen { - stream: Box::new(near), - binary_identity: Err(ResolveError::Failed("hash unavailable".to_string())), - destination: "203.0.113.10:443".parse().unwrap(), - socket: NetworkSocketMetadata { - socket_cookie: 8, - nonblocking: false, - process_generation: 1, - }, - policy_generation: 1, - timing: MediationTiming::default(), - decision, - }) - } - - async fn accept_dns(&self) -> Result { - Err(BackendError::Unavailable( - "mock DNS mediation unavailable".to_string(), - )) - } -} - -struct MockExec; - -struct MockTerminal { - size: Mutex>, -} - -#[async_trait] -impl BoundaryTerminal for MockTerminal { - async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { - *self.size.lock().unwrap() = Some((cols, rows)); - Ok(()) - } -} - -#[async_trait] -impl BoundaryExec for MockExec { - async fn exec(&self, spec: ExecSpec) -> Result { - let (_near, far) = tokio::io::duplex(64); - let (out_r, _out_w) = tokio::io::duplex(64); - let (err_r, _err_w) = tokio::io::duplex(64); - let stdin: BoundaryInput = Box::new(far); - let stderr: BoundaryOutput = Box::new(err_r); - let terminal: Arc = Arc::new(MockTerminal { - size: Mutex::new(None), - }); - Ok(ExecSession { - process: MockProcess::new(), - stdin: (!spec.pty).then_some(stdin), - stdout: Box::new(out_r), - stderr: (!spec.pty).then_some(stderr), - terminal: spec.pty.then_some(terminal), - output_status: None, - }) - } -} - -struct MockLoopbackConnector; - -#[async_trait] -impl BoundaryLoopbackConnector for MockLoopbackConnector { - async fn connect(&self, _target: LoopbackTarget) -> Result { - let (near, far) = tokio::io::duplex(64); - tokio::spawn(async move { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut far = far; - let mut buf = [0u8; 4]; - if far.read_exact(&mut buf).await.is_ok() { - let _ = far.write_all(&buf).await; - } - }); - Ok(Box::new(near)) - } -} - -// --------------------------------------------------------------------------- -// Boxed lifecycle states (distinct concrete struct per kind). -// --------------------------------------------------------------------------- - -struct MockBound { - source: Arc>, -} -struct MockReady { - _k: PhantomData, -} -struct MockRunning { - process: Arc, - exec: Arc, - loopback_connector: Arc, - _k: PhantomData, -} - -#[async_trait] -impl BoundBoundary for MockBound { - fn network_mediation_source(&self) -> Arc { - self.source.clone() - } - async fn confirm(self: Box) -> Result { - ConfirmedBoundary::try_new( - Box::new(MockReady:: { _k: PhantomData }), - confirmation(), - &workload_identity(), - ) - } -} - -#[async_trait] -impl ReadyBoundary for MockReady { - async fn start_agent(self: Box) -> Result, BackendError> { - Ok(Box::new(MockRunning:: { - process: MockProcess::new(), - exec: Arc::new(MockExec), - loopback_connector: Arc::new(MockLoopbackConnector), - _k: PhantomData, - })) - } -} - -#[async_trait] -impl RunningBoundary for MockRunning { - fn agent(&self) -> Arc { - self.process.clone() - } - fn exec(&self) -> Arc { - self.exec.clone() - } - fn loopback_connector(&self) -> Arc { - self.loopback_connector.clone() - } - async fn terminate(&self) -> Result<(), BackendError> { - self.process.terminate().await - } -} - -/// One backend per boundary resource: `attach` is atomic and never binds a -/// resource that is already bound to an active boundary, so a second attach -/// against the same mock resource is `Denied`. -struct MockBackend { - attached: AtomicBool, - _k: PhantomData, -} - -impl MockBackend { - fn new() -> Self { - Self { - attached: AtomicBool::new(false), - _k: PhantomData, - } - } -} - -#[async_trait] -impl IsolationBackend for MockBackend { - fn backend_name(&self) -> &'static str { - K::BACKEND_ID - } - async fn attach( - &self, - descriptor: VerifiedBackendDescriptor, - sandbox: SandboxContext, - ) -> Result, BackendError> { - assert_eq!(descriptor.backend_name(), K::BACKEND_ID); - assert!(!sandbox.sandbox_id.is_empty()); - if self.attached.swap(true, Ordering::SeqCst) { - return Err(BackendError::Denied( - "resource is already bound to an active boundary".to_string(), - )); - } - Ok(Box::new(MockBound:: { - source: Arc::new(MockSource(PhantomData)), - })) - } -} - -// --------------------------------------------------------------------------- -// Helpers. -// --------------------------------------------------------------------------- - -fn registry() -> BackendRegistry { - let mut reg = BackendRegistry::new(); - reg.register(Arc::new(MockBackend::::new())) - .expect("register primary"); - reg.register(Arc::new(MockBackend::::new())) - .expect("register secondary"); - reg -} - -fn descriptor(backend_name: &str) -> BackendDescriptor { - BackendDescriptor { - backend_name: backend_name.to_string(), - payload: vec![], - } -} - -fn sandbox_ctx() -> SandboxContext { - SandboxContext { - sandbox_id: "sb-1".to_string(), - session_id: "550e8400-e29b-41d4-a716-446655440000" - .parse() - .expect("valid session ID"), - policy: SandboxPolicy { - version: 1, - filesystem: openshell_core::policy::FilesystemPolicy::default(), - network: openshell_core::policy::NetworkPolicy::default(), - landlock: openshell_core::policy::LandlockPolicy::default(), - process: openshell_core::policy::ProcessPolicy::default(), - }, - agent: AgentSpec { - program: "/bin/true".to_string(), - args: vec![], - workdir: None, - timeout_secs: 0, - interactive: false, - }, - identity: workload_identity(), - } -} +use std::collections::BTreeMap; fn workload_identity() -> ResolvedWorkloadIdentity { ResolvedWorkloadIdentity::new( @@ -370,442 +16,42 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } -fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarantees { - OuterFenceGuarantees::from_enforcement_evidence( - generation, - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], - evidence, - ) - .unwrap() +fn provisioning_evidence(generation: &str, evidence: &[u8]) -> ProvisioningEvidence { + ProvisioningEvidence::from_enforcement_evidence(generation, evidence).unwrap() } fn confirmation() -> BoundaryConfirmation { BoundaryConfirmation { generation: "generation-1".to_string(), identity: workload_identity(), - properties: BoundaryProperties { - filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"), - egress_interception: EnforcedProperty::new(true, "mock-egress"), - request_attribution: EnforcedProperty::new(true, "mock-attribution"), - privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"), - }, - authenticated_supervisor: true, + session_id: SandboxSessionId::new(), - outer_fence: complete_outer_fence("generation-1", b"mock-fence-evidence"), - runtime_exit_terminates_workload: true, + provisioning_evidence_digest: provisioning_evidence("generation-1", b"mock-fence-evidence") + .evidence_digest, resource_claims: BTreeMap::new(), backend_audit: serde_json::json!({"backend": "mock"}), } } #[test] -fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { - let fence = complete_outer_fence("generation-1", b"native-driver-evidence"); +fn provisioning_evidence_binds_native_facts_to_the_generation() { + let fence = provisioning_evidence("generation-1", b"native-driver-evidence"); assert!(fence.validate("generation-1").is_ok()); assert_ne!( fence.evidence_digest, - complete_outer_fence("generation-2", b"native-driver-evidence").evidence_digest + provisioning_evidence("generation-2", b"native-driver-evidence").evidence_digest ); let mut wrong_generation = fence.clone(); wrong_generation.generation = "generation-2".to_string(); assert!(wrong_generation.validate("generation-1").is_err()); - for guarantee in [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ] { - let mut incomplete = fence.clone(); - incomplete.established.remove(&guarantee); - assert!(incomplete.validate("generation-1").is_err()); - } - - assert!(OuterFenceGuarantees::from_enforcement_evidence("", [], b"evidence").is_err()); - assert!(OuterFenceGuarantees::from_enforcement_evidence("generation-1", [], b"").is_err()); - - let unproven = OuterFenceGuarantees::from_enforcement_evidence( - "generation-1", - [OuterFenceGuarantee::DefaultDenyEgress], - b"native-driver-evidence", - ) - .unwrap(); - assert!(unproven.validate("generation-1").is_err()); -} - -/// The backend-independent supervisor sequence. Identical for every backend: -/// this is the proof that adding a backend needs no supervisor lifecycle change. -async fn drive( - reg: &BackendRegistry, - descriptor: BackendDescriptor, - admitted: &str, -) -> Result, BackendError> { - let (backend, verified) = reg.resolve(descriptor, admitted)?; - let bound = backend.attach(verified, sandbox_ctx()).await?; - // The mediation source is retained before consuming `Bound` and stays - // usable across the confirm/start transitions. - let _ingress = bound.network_mediation_source(); - assert_eq!(bound.host_gateway_ip(), None); - let confirmed = bound.confirm().await?; - confirmed.confirmation().validate(&sandbox_ctx().identity)?; - confirmed.into_boundary().start_agent().await -} - -// --------------------------------------------------------------------------- -// Registry and descriptor. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn registry_selects_correct_backend() { - let reg = registry(); - let (f, _v) = reg - .resolve(descriptor("mock-secondary"), "mock-secondary") - .expect("resolve"); - assert_eq!(f.backend_name(), "mock-secondary"); -} - -#[test] -fn registry_rejects_duplicate_registration() { - let mut reg = BackendRegistry::new(); - reg.register(Arc::new(MockBackend::::new())) - .expect("first"); - let err = reg - .register(Arc::new(MockBackend::::new())) - .expect_err("duplicate must fail"); - assert!(matches!(err, BackendError::Descriptor(_))); -} - -#[test] -fn registry_rejects_unknown_backend() { - let reg = registry(); - let err = reg - .resolve(descriptor("nope"), "nope") - .map(|_| ()) - .expect_err("unknown must fail"); - assert!(matches!(err, BackendError::NotRegistered(_))); -} - -#[test] -fn registry_rejects_descriptor_admission_mismatch_without_fallback() { - let reg = registry(); - // Descriptor names primary, admission says secondary: must fail, and must - // not silently fall back to either backend. - let err = reg - .resolve(descriptor("mock-primary"), "mock-secondary") - .map(|_| ()) - .expect_err("mismatch must fail"); - assert!(matches!(err, BackendError::Descriptor(_))); -} - -// --------------------------------------------------------------------------- -// Lifecycle: one driver, two heterogeneous backends, no consumer change. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn one_driver_runs_both_backends() { - let reg = registry(); - // The exact same driver code runs a backend with distinct concrete state - // structs; the registry holds them behind `dyn`, no enum. - let primary = drive(®, descriptor("mock-primary"), "mock-primary") - .await - .expect("primary lifecycle"); - let secondary = drive(®, descriptor("mock-secondary"), "mock-secondary") - .await - .expect("secondary lifecycle"); - - // Both expose a usable agent process handle past start_agent. - assert_eq!( - primary.agent().wait().await.expect("wait"), - BoundaryExitStatus::Exited(0) - ); - assert_eq!( - secondary.agent().wait().await.expect("wait"), - BoundaryExitStatus::Exited(0) - ); -} - -#[test] -fn confirmation_constructor_rejects_unenforced_property() { - let mut confirmation = confirmation(); - confirmation.properties.egress_interception.enforced = false; - let result = ConfirmedBoundary::try_new( - Box::new(MockReady:: { _k: PhantomData }), - confirmation, - &workload_identity(), - ); - assert!(matches!(result, Err(BackendError::Confirm(_)))); -} - -#[test] -fn confirmation_constructor_rejects_another_workload_identity() { - let expected = ResolvedWorkloadIdentity::new( - 1001, - 1001, - vec![1001], - "policy".to_string(), - "sha256:test".to_string(), - ) - .unwrap(); - let result = ConfirmedBoundary::try_new( - Box::new(MockReady:: { _k: PhantomData }), - confirmation(), - &expected, - ); - assert!(matches!(result, Err(BackendError::Confirm(_)))); -} - -#[tokio::test] -async fn one_boundary_termination_does_not_change_another_boundary() { - let reg = registry(); - let primary = drive(®, descriptor("mock-primary"), "mock-primary") - .await - .expect("primary lifecycle"); - let secondary = drive(®, descriptor("mock-secondary"), "mock-secondary") - .await - .expect("secondary lifecycle"); - - primary - .agent() - .terminate() - .await - .expect("terminate primary"); - secondary - .agent() - .signal(BoundarySignal::Term) - .await - .expect("secondary remains active"); -} - -#[tokio::test] -async fn attach_never_binds_an_already_bound_resource() { - let reg = registry(); - // First attach binds the mock resource. - drive(®, descriptor("mock-primary"), "mock-primary") - .await - .expect("first lifecycle"); - // A second attach against the same active boundary must be denied, not - // silently create a second binding. - let err = drive(®, descriptor("mock-primary"), "mock-primary") - .await - .map(|_| ()) - .expect_err("second attach must fail"); - assert_eq!(err.kind(), BackendErrorKind::Denied); -} - -#[tokio::test] -async fn runtime_interfaces_survive_lifecycle_consumption() { - let reg = registry(); - let (backend, verified) = reg - .resolve(descriptor("mock-primary"), "mock-primary") - .expect("resolve"); - let bound = backend - .attach(verified, sandbox_ctx()) - .await - .expect("attach"); - - // Retain the source at Bound, then consume the bound state with confirm. - // The retained Arc must remain usable afterward. - let source = bound.network_mediation_source(); - let confirmed = bound.confirm().await.expect("confirm"); - let _running = confirmed - .into_boundary() - .start_agent() - .await - .expect("start"); - - let conn = source.accept_tcp().await.expect("accept after consumption"); - let identity = conn.binary_identity.expect("identity resolves"); - assert_eq!(identity.executable.path, PathBuf::from("/usr/bin/agent")); -} - -// --------------------------------------------------------------------------- -// Process and I/O. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn agent_process_survives_and_wait_is_stable() { - let reg = registry(); - let running = drive(®, descriptor("mock-primary"), "mock-primary") - .await - .expect("lifecycle"); - let agent = running.agent(); - // Survives start_agent returning; wait is stable across repeated calls. - assert_eq!( - agent.wait().await.expect("wait 1"), - BoundaryExitStatus::Exited(0) - ); - assert_eq!( - agent.wait().await.expect("wait 2"), - BoundaryExitStatus::Exited(0) - ); - assert!(matches!( - agent.signal(BoundarySignal::Term).await, - Err(BackendError::Terminated(_)) - )); -} - -#[tokio::test] -async fn every_signal_reaches_the_backend_unchanged() { - let process = MockProcess::new(); - for signal in [ - BoundarySignal::Term, - BoundarySignal::Kill, - BoundarySignal::Int, - BoundarySignal::Hup, - ] { - process.signal(signal).await.expect("signal"); - } - assert_eq!( - *process.signals.lock().unwrap(), - vec![ - BoundarySignal::Term, - BoundarySignal::Kill, - BoundarySignal::Int, - BoundarySignal::Hup, - ] - ); -} - -#[tokio::test] -async fn normal_and_signaled_exit_are_distinct_and_stable() { - let signaled = MockProcess { - status: BoundaryExitStatus::Signaled(9), - alive: AtomicBool::new(false), - signals: Mutex::new(Vec::new()), - }; - assert_eq!( - signaled.wait().await.expect("first wait"), - BoundaryExitStatus::Signaled(9) - ); - assert_eq!( - signaled.wait().await.expect("second wait"), - BoundaryExitStatus::Signaled(9) - ); + assert!(ProvisioningEvidence::from_enforcement_evidence("", b"evidence").is_err()); + assert!(ProvisioningEvidence::from_enforcement_evidence("generation-1", b"").is_err()); assert_ne!( - signaled.wait().await.expect("third wait"), - BoundaryExitStatus::Exited(137) - ); -} - -#[tokio::test] -async fn exec_session_owns_its_process_and_streams() { - let reg = registry(); - let running = drive(®, descriptor("mock-primary"), "mock-primary") - .await - .expect("lifecycle"); - let session = running - .exec() - .exec(ExecSpec { - program: "/bin/sh".to_string(), - args: vec!["-c".to_string(), "true".to_string()], - shell: None, - runtime_helper: None, - env: vec![], - workdir: None, - pty: false, - }) - .await - .expect("exec"); - // The exec'd process survives `exec` returning, and stdout/stderr are distinct. - assert!(session.stderr.is_some()); - assert!(session.stdin.is_some()); - assert_eq!( - session.process.wait().await.expect("exec wait"), - BoundaryExitStatus::Exited(0) - ); -} - -#[tokio::test] -async fn pty_exec_merges_output_and_supports_resize() { - let session = MockExec - .exec(ExecSpec { - program: "/bin/sh".to_string(), - args: vec![], - shell: None, - runtime_helper: None, - env: vec![], - workdir: None, - pty: true, - }) - .await - .expect("pty exec"); - assert!(session.stdin.is_none()); - assert!(session.stderr.is_none()); - session - .terminal - .expect("terminal") - .resize(120, 40) - .await - .expect("resize"); -} - -#[tokio::test] -async fn port_forward_rejects_non_loopback() { - let target = LoopbackTarget::new("8.8.8.8".parse().unwrap(), 53); - assert!(target.is_err()); - let loopback = LoopbackTarget::new("127.0.0.1".parse().unwrap(), 8080).expect("loopback ok"); - assert_eq!(loopback.port(), 8080); -} - -#[tokio::test] -async fn validated_port_forward_stream_remains_usable() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let target = LoopbackTarget::new("127.0.0.1".parse().unwrap(), 8080).unwrap(); - let mut stream = MockLoopbackConnector - .connect(target) - .await - .expect("connect"); - stream.write_all(b"ping").await.expect("write"); - let mut response = [0_u8; 4]; - stream.read_exact(&mut response).await.expect("read"); - assert_eq!(&response, b"ping"); -} - -// --------------------------------------------------------------------------- -// Mediation and binary identity. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn pending_network_open_carries_socket_bound_identity() { - let reg = registry(); - let (backend, verified) = reg - .resolve(descriptor("mock-primary"), "mock-primary") - .expect("resolve"); - let bound = backend - .attach(verified, sandbox_ctx()) - .await - .expect("attach"); - let conn = bound - .network_mediation_source() - .accept_tcp() - .await - .expect("accept"); - let identity = conn.binary_identity.expect("identity resolves"); - assert_eq!(identity.executable.path, PathBuf::from("/usr/bin/agent")); - // A missing digest is `None`, never an empty value. - assert_eq!( - identity.executable.digest.expect("digest").to_string(), - "00".repeat(32) + fence.evidence_digest, + provisioning_evidence("generation-1", b"different-native-evidence").evidence_digest, ); - assert_eq!(conn.destination, "203.0.113.10:443".parse().unwrap()); - assert_eq!(conn.socket.socket_cookie, 7); -} - -#[tokio::test] -async fn missing_digest_is_none_never_empty() { - // The secondary backend resolves path-only identity: the digest is `None`, - // so policy that requires a digest cannot authorize the connection. - let source = MockSource::(PhantomData); - let conn = source.accept_tcp().await.expect("accept"); - let identity = conn.binary_identity.expect("identity resolves"); - assert!(identity.executable.digest.is_none()); } #[test] @@ -815,15 +61,6 @@ fn sha256_digest_rejects_signed_hex_chunks() { assert!("00".repeat(32).parse::().is_ok()); } -#[tokio::test] -async fn unresolved_identity_travels_with_the_pending_open_and_fails_closed() { - // Attribution failure does not tear down the source: the connection is - // delivered carrying `Err`, and the mediation service denies it. - let source = UnattributedSource; - let conn = source.accept_tcp().await.expect("accept"); - assert!(conn.binary_identity.is_err()); -} - #[test] fn workload_identity_rejects_root_and_normalizes_groups() { assert!( @@ -841,18 +78,14 @@ fn workload_identity_rejects_root_and_normalizes_groups() { } #[test] -fn confirmation_rejects_identity_or_property_drift() { +fn confirmation_rejects_missing_generation_or_identity_drift() { let expected = workload_identity(); let baseline = confirmation(); baseline.validate(&expected).unwrap(); - let mut drifted = confirmation(); - drifted.properties.privilege_floor.enforced = false; - assert!(drifted.validate(&expected).is_err()); - - let mut unmanaged = confirmation(); - unmanaged.runtime_exit_terminates_workload = false; - assert!(unmanaged.validate(&expected).is_err()); + let mut missing = confirmation(); + missing.generation.clear(); + assert!(missing.validate(&expected).is_err()); let different = ResolvedWorkloadIdentity::new( 1002, @@ -865,20 +98,12 @@ fn confirmation_rejects_identity_or_property_drift() { assert!(baseline.validate(&different).is_err()); } -// --------------------------------------------------------------------------- -// Errors. -// --------------------------------------------------------------------------- - #[test] fn error_kinds_map_to_supervisor_status_classes() { assert_eq!( BackendError::Descriptor("x".into()).kind(), BackendErrorKind::Invalid ); - assert_eq!( - BackendError::NotRegistered("x".into()).kind(), - BackendErrorKind::Invalid - ); assert_eq!( BackendError::Denied("x".into()).kind(), BackendErrorKind::Denied @@ -904,3 +129,11 @@ fn error_kinds_map_to_supervisor_status_classes() { BackendErrorKind::Terminated ); } + +#[tokio::test] +async fn port_forward_rejects_non_loopback() { + let target = LoopbackTarget::new("8.8.8.8".parse().unwrap(), 53); + assert!(target.is_err()); + let loopback = LoopbackTarget::new("127.0.0.1".parse().unwrap(), 8080).expect("loopback ok"); + assert_eq!(loopback.port(), 8080); +} diff --git a/crates/openshell-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index 6d39fc37d5..1f5908662d 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -10,12 +10,17 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[features] +test-support = [] + [dependencies] openshell-core = { path = "../openshell-core", default-features = false, features = ["jwt"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } async-trait = "0.1" +bytes = { workspace = true } hyper-util = { workspace = true } prost = { workspace = true } +prost-types = { workspace = true } rcgen = { workspace = true } rustls = { workspace = true } rustls-pemfile = { workspace = true } @@ -25,6 +30,7 @@ sha2 = { workspace = true } socket2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-util = { version = "0.7", features = ["io"] } tokio-rustls = { workspace = true } tokio-stream = { workspace = true } tonic = { workspace = true } @@ -39,9 +45,5 @@ libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] rustix = { workspace = true, features = ["fs", "process"] } -[build-dependencies] -protoc-bin-vendored = { workspace = true } -tonic-prost-build = { workspace = true } - [lints] workspace = true diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 2186210cb4..534c4c4589 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -1,13 +1,75 @@ # OpenShell sandbox backend -This crate implements the supervisor-side isolation backend and the private +This crate implements the concrete supervisor-side RPC client and the versioned control protocol shared with `openshell-sandbox`. +The protobuf services replace the Rust backend traits. The supervisor holds +concrete lifecycle, process, exec, forwarding, and mediation handles backed by +RPCs. Confirmation consumes the bound handle, validates enforcement evidence, +and produces the ready handle; starting consumes that ready handle. Both the +in-tree Linux runtime and external backends implement the generated services. +Linux process helpers remain internal to the runtime server. + +## Boundary startup and routing + +The supervisor uses the generated `IsolationBackend` control service to +negotiate capabilities and open a boundary before reading image policy. Each +physical connection repeats that handshake, including credential-epoch changes +and transport recovery. The launch session and generation identify retries; +the descriptor must bind to the same provisioned resource. Reopening returns +the same handle for the runtime's lifetime. Driver deletion terminates the +runtime and invalidates that handle. + +Isolation protocol 1.0 includes boundary routing and requires +`openshell.sandbox.protocol` on both peers. The shared extension +negotiator checks major versions and each peer's required capabilities. The +built-in runtime does not advertise `openshell.sandbox.suspend-resume` and +returns `UNIMPLEMENTED` for those optional RPCs. + +`SandboxProtocol.Exchange` and `.Mediate` begin with the handle from +`OpenBoundary`. The server authenticates the stream's bearer, checks the handle +against its provisioned sandbox and generation, then accepts typed protobuf +messages. `ExchangeFrame` carries control, process I/O, network decisions, and +TCP relay data. `MediateFrame` carries the OpenMediation handshake and DNS messages. +Missing bindings, repeated bindings, and frames that do not match the operation +or direction are rejected. There is no nested JSON or length/channel framing on +the wire. Private in-process adapters retain the Rust byte-stream interfaces. + +Opening a boundary only validates and reserves a route. It neither attaches a +supervisor nor runs workload code. Policy discovery is followed by the existing +attach, mediation setup, confirm, and explicit start sequence. The existing +connection registry continues to govern supervisor replacement and recovery. + +Sandbox and generation identity come from the verified bearer, rather than +duplicate fields on `OpenBoundaryRequest`. Attach supplies policy; confirmation +reports resource claims from backend-owned provisioned state, which the +supervisor compares with its trusted launch metadata. + +Update the supervisor and sandbox runtime together; an older peer fails during +startup instead of using a fallback path. + +## Endpoint-registered backends + +`OpenShellRuntimeBackend::registered` constructs the same client and lifecycle +handles for an operator-registered backend. Trusted endpoint registration and `DelegatedLaunch` +metadata are separate from the driver's opaque descriptor, which crosses +`OpenBoundary` unchanged. Common confirmation checks bind identity, session, +generation, resource claims, and the driver's provisioning evidence digest. Native evidence is +validated by the registered backend; Linux evidence validation remains in the +built-in adapter. + +The public gRPC contracts are in +[`isolation_backend.proto`](../../proto/isolation_backend.proto) and +[`sandbox_protocol.proto`](../../proto/sandbox_protocol.proto). + ## Exec recovery -An exec envelope carries a UUID request ID and an absolute expiration time in -Unix milliseconds, set to 30 seconds after creation. The payload digest includes -the expiration time, and transport recovery preserves the entire envelope. +An exec envelope carries a UUID request ID and a protobuf timestamp set to +30 seconds after creation. Transport recovery preserves the entire request, +including that deadline. Repeated mutations with the same ID and equivalent +decoded fields replay the original result; conflicting reuse is rejected. +Map entry order is immaterial. The receiver computes its local replay +fingerprint; clients do not transmit a digest. The supervisor and sandbox need synchronized wall clocks. The sandbox checks expiration before starting or reattaching an exec. It keeps @@ -30,19 +92,16 @@ and sandbox runtime together when deploying this protocol change. `OpenShellRuntimeBackend` implements the authenticated host side of the shared Sandbox Protocol. -Backend implementations may inject a `BoundaryAuditValidator` to interpret -their opaque confirmation evidence. The default Linux validator rejects -incomplete or foreign evidence. Confirmation compares the asserted properties -with the properties derived by the selected validator; validator injection does -not bypass generation, session, resource, identity, or outer-fence checks. - -Concrete platform validators belong to the implementing backend, not this -shared transport library. +For the built-in Linux backend, the client validates native audit evidence and +rejects incomplete or foreign measurements. External backends validate their own +mechanisms and may omit audit data; they do not need to reproduce Linux +measurements. Optional audit data is a UTF-8 JSON value; empty bytes mean absent. +Successful confirmation asserts that the backend can enforce the +admitted policy; unsupported requirements fail before workload start. Both paths +retain generation, session, resource, identity, and provisioning evidence checks. ## Boundary transport establishment -Backends may supply a `BoundaryTransportConnector` for raw stream establishment. -The shared client retains TLS peer verification, session bearer authentication, -generation checks, and reconnection handling. The connector is reused for policy -discovery and attachment; it does not define platform launch configuration or -replace the isolation interface. +The client connects to the configured Unix socket, TCP, or vsock endpoint. It +owns TLS peer verification, session bearer authentication, generation checks, +and reconnection handling for policy discovery and attachment. diff --git a/crates/openshell-sandbox-backend/build.rs b/crates/openshell-sandbox-backend/build.rs deleted file mode 100644 index a807cefb2a..0000000000 --- a/crates/openshell-sandbox-backend/build.rs +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -fn main() -> Result<(), Box> { - // A caller-supplied PROTOC (for example a distro package's system - // compiler) is used as-is and prost resolves its own includes. Without it, - // use the vendored compiler and include tree. - println!("cargo:rerun-if-env-changed=PROTOC"); - println!("cargo:rerun-if-env-changed=PROTOC_INCLUDE"); - let use_vendored_protoc = std::env::var_os("PROTOC").is_none(); - - // In vendored mode, preserve the vendored include-tree guarantee even when - // the caller has PROTOC_INCLUDE set: prost validates that ambient path - // independently of explicit includes. Re-enter with a child-only - // environment override. - if use_vendored_protoc && std::env::var_os("PROTOC_INCLUDE").is_some() { - let status = std::process::Command::new(std::env::current_exe()?) - .env_remove("PROTOC_INCLUDE") - .status()?; - if !status.success() { - return Err(format!("vendored protobuf build failed: {status}").into()); - } - return Ok(()); - } - - println!("cargo:rerun-if-changed=proto/openshell_sandbox.proto"); - - // Configure the vendored compiler and well-known includes without changing - // the build process environment. With a caller-supplied PROTOC, leave both - // to prost. - let mut proto_config = tonic_prost_build::Config::new(); - let proto_include = if use_vendored_protoc { - proto_config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); - Some(protoc_bin_vendored::include_path()?) - } else { - None - }; - let mut include_dirs = vec![std::path::PathBuf::from("proto")]; - include_dirs.extend(proto_include); - - tonic_prost_build::configure() - .build_server(true) - .build_client(true) - .compile_with_config( - proto_config, - &[std::path::PathBuf::from("proto/openshell_sandbox.proto")], - &include_dirs, - )?; - Ok(()) -} diff --git a/crates/openshell-sandbox-backend/proto/openshell_sandbox.proto b/crates/openshell-sandbox-backend/proto/openshell_sandbox.proto deleted file mode 100644 index a86d911779..0000000000 --- a/crates/openshell-sandbox-backend/proto/openshell_sandbox.proto +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -syntax = "proto3"; - -package openshell.sandbox.protocol.v1; - -// IsolationBoundary multiplexes the OpenShell Sandbox Protocol -// over one authenticated gRPC connection. Each Exchange call is an independent -// HTTP/2 stream; Mediate is the persistent DNS exchange channel. -service IsolationBoundary { - rpc Exchange(stream BoundaryChunk) returns (stream BoundaryChunk); - rpc Mediate(stream BoundaryChunk) returns (stream BoundaryChunk); -} - -// BoundaryChunk carries an ordered fragment of the existing versioned -// isolation protocol. Message boundaries are not semantically significant. -message BoundaryChunk { - bytes data = 1; -} diff --git a/crates/openshell-sandbox-backend/src/audit.rs b/crates/openshell-sandbox-backend/src/audit.rs index 1751ce5ab5..c94b92d49a 100644 --- a/crates/openshell-sandbox-backend/src/audit.rs +++ b/crates/openshell-sandbox-backend/src/audit.rs @@ -1,36 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Backend-selected interpretation of opaque boundary confirmation evidence. +//! Validation of the built-in Linux backend's audit evidence. -use openshell_isolation_interface::contract::{BackendError, BoundaryProperties}; +use openshell_isolation_interface::contract::BackendError; -/// Validate measured evidence and derive the properties it actually supports. -/// Implementations must reject incomplete evidence and unsupported formats. -pub trait BoundaryAuditValidator: std::fmt::Debug + Send + Sync { - /// # Errors - /// Returns an error when evidence cannot establish the boundary guarantees. - fn validate(&self, evidence: &serde_json::Value) -> Result; -} - -/// Default evidence interpreter for the Linux `OpenShell` sandbox. -#[derive(Debug)] -pub struct LinuxBoundaryAuditValidator; - -impl BoundaryAuditValidator for LinuxBoundaryAuditValidator { - fn validate(&self, evidence: &serde_json::Value) -> Result { - let audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = - serde_json::from_value(evidence.clone()).map_err(|error| { - BackendError::Confirm(format!("decode Linux sandbox audit evidence: {error}")) - })?; - audit.validate()?; - Ok(audit.properties()) - } +/// Validate the built-in Linux backend's measured enforcement state. +pub fn validate_native_linux_audit(evidence: &serde_json::Value) -> Result<(), BackendError> { + let audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = + serde_json::from_value(evidence.clone()).map_err(|error| { + BackendError::Confirm(format!("decode Linux sandbox audit evidence: {error}")) + })?; + audit.validate() } #[cfg(test)] mod tests { - use super::{BoundaryAuditValidator as _, LinuxBoundaryAuditValidator}; + use super::validate_native_linux_audit; #[test] fn default_validator_accepts_complete_linux_evidence() { @@ -52,20 +38,17 @@ mod tests { "tcp_allow_round_trip": true, "tcp_deny_round_trip": true, "socket_loopback_confinement": true }); - let properties = LinuxBoundaryAuditValidator.validate(&evidence).unwrap(); - let audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = - serde_json::from_value(evidence.clone()).unwrap(); - assert_eq!(properties, audit.properties()); + validate_native_linux_audit(&evidence).unwrap(); let mut incomplete = evidence; incomplete["no_new_privileges"] = false.into(); - assert!(LinuxBoundaryAuditValidator.validate(&incomplete).is_err()); + assert!(validate_native_linux_audit(&incomplete).is_err()); } #[test] fn default_validator_rejects_unknown_platform_and_incomplete_evidence() { for platform in ["windows_mxc", "unknown", "linux"] { let evidence = serde_json::json!({"platform": platform, "evidence": {}}); - assert!(LinuxBoundaryAuditValidator.validate(&evidence).is_err()); + assert!(validate_native_linux_audit(&evidence).is_err()); } } } diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 70d491d318..4bbcaca879 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -5,9 +5,9 @@ //! //! Drivers choose and provision the transport, but they do not redefine the //! process lifecycle, streaming, identity, or authentication messages. The -//! supervisor and sandbox exchange these length-delimited JSON frames inside -//! gRPC streams on one mutually authenticated connection. The driver chooses -//! the underlying private Unix socket, TCP connection, or virtio-vsock stream. +//! supervisor and sandbox exchange typed protobuf messages over gRPC. The +//! length-delimited helpers below adapt only private, in-process Rust streams. +//! The driver chooses the underlying private Unix socket, TCP connection, or virtio-vsock stream. use std::fmt; use std::io; @@ -25,11 +25,9 @@ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, - BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, ExecutableIdentity, - OuterFenceGuarantees, ResolveError, ShellSpec, + BoundarySignal, ExecSpec, ExecutableIdentity, ProvisioningEvidence, ResolveError, ShellSpec, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -162,42 +160,6 @@ impl NativeLinuxSandboxAuditEvidence { )) } } - - /// Project backend measurements into the common property contract. - #[must_use] - pub fn properties(&self) -> BoundaryProperties { - BoundaryProperties { - filesystem_confinement: EnforcedProperty::new( - self.landlock_abi >= 3 && self.landlock_allow_deny, - format!("landlock-v{}", self.landlock_abi), - ), - egress_interception: EnforcedProperty::new( - self.seccomp.new_listener - && self.seccomp.notification_round_trip - && self.seccomp.addfd_send - && self.udp_dns_round_trip - && self.tcp_dns_round_trip - && self.tcp_allow_round_trip - && self.tcp_deny_round_trip - && self.socket_loopback_confinement, - "seccomp-notify", - ), - request_attribution: EnforcedProperty::new( - self.seccomp.id_validation - && self.seccomp.proc_fd_identity - && self.seccomp.task_memory_read, - "seccomp-notify-procfs", - ), - privilege_floor: EnforcedProperty::new( - self.capabilities.is_empty() - && self.no_new_privileges - && !self.sandbox_dumpable - && self.child_dumpable - && self.core_limit_zero, - "linux-capability-free", - ), - } - } } /// Ephemeral identity of the supervisor process that owns one sandbox runtime. @@ -276,25 +238,7 @@ impl<'de> Deserialize<'de> for SupervisorInstanceId { #[error("supervisor instance ID must be a non-nil canonical lowercase UUID")] pub struct SupervisorInstanceIdError; -/// Driver-selected byte-stream transport for the `OpenShell` Sandbox Protocol. -/// Authentication is configured separately and is identical for every variant. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] -pub enum SandboxTransport { - Unix { - socket_path: PathBuf, - }, - Tcp { - /// Stable logical Kubernetes Service authority used for diagnostics. - authority: String, - /// Explicit connection candidates resolved by the compute driver. - addresses: Vec, - }, - Vsock { - guest_cid: u32, - port: u32, - }, -} +pub use openshell_core::isolation_registration::SandboxTransport; /// Supervisor-side, generation-pinned TLS server authentication. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -445,7 +389,7 @@ pub struct SandboxRuntimeDescriptor { #[serde(default)] pub resource_claims: std::collections::BTreeMap, /// Backend-neutral projection of the validated outer network fence. - pub outer_fence: OuterFenceGuarantees, + pub provisioning_evidence: ProvisioningEvidence, } impl fmt::Debug for SandboxRuntimeDescriptor { @@ -459,7 +403,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) .field("resource_claims", &self.resource_claims) - .field("outer_fence", &self.outer_fence) + .field("provisioning_evidence", &self.provisioning_evidence) .finish() } } @@ -513,7 +457,7 @@ pub struct BoundaryConfig { /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, /// Backend-neutral projection of the validated outer network fence. - pub outer_fence: OuterFenceGuarantees, + pub provisioning_evidence: ProvisioningEvidence, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -541,7 +485,7 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) - .field("outer_fence", &self.outer_fence) + .field("provisioning_evidence", &self.provisioning_evidence) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } @@ -626,12 +570,12 @@ pub async fn read_stream_frame( pub struct RequestEnvelope { /// Cryptographically random idempotency key scoped to one sandbox generation. pub request_id: String, - /// SHA-256 of the canonically serialized request payload. + /// Local replay fingerprint of decoded protobuf fields; never transmitted. pub payload_digest: String, /// Absolute exec admission deadline, preserved across retries and bound to /// the payload digest. Other request kinds do not expire through this field. #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_expires_at_unix_ms: Option, + pub exec_admission_deadline_unix_ms: Option, pub request: Request, } @@ -639,18 +583,18 @@ impl RequestEnvelope { /// Build a request envelope with a fresh idempotency key and normalized /// payload digest. pub fn new(request: Request) -> Result { - let exec_expires_at_unix_ms = if matches!(request, Request::Exec { .. }) { + let exec_admission_deadline_unix_ms = if matches!(request, Request::Exec { .. }) { let window_ms = u64::try_from(EXEC_REQUEST_RETRY_WINDOW.as_millis()).map_err(io::Error::other)?; Some(unix_time_millis()?.saturating_add(window_ms)) } else { None }; - let payload_digest = request_envelope_digest(&request, exec_expires_at_unix_ms)?; + let payload_digest = request_envelope_digest(&request, exec_admission_deadline_unix_ms)?; Ok(Self { request_id: uuid::Uuid::new_v4().to_string(), payload_digest, - exec_expires_at_unix_ms, + exec_admission_deadline_unix_ms, request, }) } @@ -658,7 +602,7 @@ impl RequestEnvelope { /// Verify that the request body still matches the immutable digest bound /// to this idempotency key. pub fn validate_payload_digest(&self) -> Result<(), FrameError> { - let actual = request_envelope_digest(&self.request, self.exec_expires_at_unix_ms)?; + let actual = request_envelope_digest(&self.request, self.exec_admission_deadline_unix_ms)?; if actual == self.payload_digest { Ok(()) } else { @@ -675,19 +619,19 @@ pub fn unix_time_millis() -> Result { u64::try_from(elapsed.as_millis()).map_err(|error| FrameError::Io(io::Error::other(error))) } -fn request_envelope_digest( +pub(crate) fn request_envelope_digest( request: &Request, - exec_expires_at_unix_ms: Option, + exec_admission_deadline_unix_ms: Option, ) -> Result { - // Sort every object explicitly: dependency features may make Value retain - // insertion order. Provider environments must hash identically after - // deserialization and across independently serialized retries. - let mut normalized = serde_json::to_value(request).map_err(FrameError::Serialize)?; - if let Some(expires_at) = exec_expires_at_unix_ms { - normalized["exec_expires_at_unix_ms"] = expires_at.into(); - } - normalized.sort_all_objects(); - let payload = serde_json::to_vec(&normalized).map_err(FrameError::Serialize)?; + // Stable local replay fingerprint of decoded protobuf fields. Maps use + // BTreeMap in generated messages; neither clients nor the wire carry this. + let envelope = RequestEnvelope { + request_id: String::new(), + payload_digest: String::new(), + exec_admission_deadline_unix_ms, + request: request.clone(), + }; + let payload = crate::protobuf::encode(&envelope)?; let digest = Sha256::digest(payload); Ok(format!("{digest:x}")) } @@ -710,15 +654,11 @@ pub enum Request { Attach { supervisor_instance_id: SupervisorInstanceId, policy: Box, - resource_claims: std::collections::BTreeMap, }, Confirm, - /// Verify file-open mediation before sending a file-bearing snapshot. - ProbeProviderFiles, StartAgent { sandbox_id: String, spec: AgentSpecWire, - policy: Box, ca_cert: Option, ca_bundle: Option, provider_env_revision: u64, @@ -753,10 +693,6 @@ pub enum Request { Exec { spec: ExecSpecWire, }, - ExecSignal { - process_id: String, - signal: SignalWire, - }, Resize { process_id: String, cols: u16, @@ -787,7 +723,6 @@ impl Request { | Self::Signal { .. } | Self::Terminate { .. } | Self::TerminateBoundary - | Self::ExecSignal { .. } | Self::Resize { .. } ) } @@ -800,18 +735,14 @@ impl fmt::Debug for Request { Self::Attach { supervisor_instance_id: _, policy: _, - resource_claims, } => formatter .debug_struct("Attach") .field("policy", &"") - .field("resource_claims", resource_claims) .finish(), Self::Confirm => formatter.write_str("Confirm"), - Self::ProbeProviderFiles => formatter.write_str("ProbeProviderFiles"), Self::StartAgent { sandbox_id, spec, - policy: _, ca_cert, ca_bundle, provider_env_revision, @@ -821,7 +752,6 @@ impl fmt::Debug for Request { .debug_struct("StartAgent") .field("sandbox_id", sandbox_id) .field("spec", spec) - .field("policy", &"") .field("ca_cert_present", &ca_cert.is_some()) .field("ca_bundle_present", &ca_bundle.is_some()) .field("provider_env_revision", provider_env_revision) @@ -865,11 +795,6 @@ impl fmt::Debug for Request { .finish(), Self::TerminateBoundary => formatter.write_str("TerminateBoundary"), Self::Exec { spec } => formatter.debug_tuple("Exec").field(spec).finish(), - Self::ExecSignal { process_id, signal } => formatter - .debug_struct("ExecSignal") - .field("process_id", process_id) - .field("signal", signal) - .finish(), Self::Resize { process_id, cols, @@ -912,7 +837,6 @@ pub enum Response { /// before workload launch. confirmation: Box, }, - ProviderFilesSupported, Started { process_id: String, provider_env_revision: u64, @@ -938,12 +862,14 @@ pub enum Response { pty: bool, }, Resized, - PortConnected, + LoopbackConnected, MediationReady, NetworkConnected { identity: BinaryIdentityWire, destination: std::net::SocketAddr, - socket: openshell_isolation_interface::contract::NetworkSocketMetadata, + /// Native evidence is absent for guest host calls without kernel sockets. + #[serde(default, skip_serializing_if = "Option::is_none")] + socket: Option, policy_generation: u64, timing: MediationTimingWire, }, @@ -1008,6 +934,7 @@ pub enum BoundaryErrorKind { Unavailable, Terminated, Process, + Unsupported, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1366,8 +1293,11 @@ impl From for ExitStatusWire { } } -pub fn encode_frame(message: &T) -> Result, FrameError> { - let payload = serde_json::to_vec(message).map_err(FrameError::Serialize)?; +pub fn encode_frame(message: &T) -> Result, FrameError> +where + T::Proto: prost::Message + Default, +{ + let payload = crate::protobuf::encode(message)?; if payload.len() > MAX_CONTROL_FRAME_BYTES { return Err(FrameError::TooLarge(payload.len())); } @@ -1378,7 +1308,10 @@ pub fn encode_frame(message: &T) -> Result, FrameError> { Ok(frame) } -pub fn decode_frame(frame: &[u8]) -> Result { +pub fn decode_frame(frame: &[u8]) -> Result +where + T::Proto: prost::Message + Default, +{ let header: [u8; 4] = frame .get(..4) .ok_or(FrameError::Truncated)? @@ -1395,10 +1328,13 @@ pub fn decode_frame(frame: &[u8]) -> Result actual: payload.len(), }); } - serde_json::from_slice(payload).map_err(FrameError::Deserialize) + crate::protobuf::decode(payload) } -pub fn read_frame(reader: &mut impl Read) -> Result { +pub fn read_frame(reader: &mut impl Read) -> Result +where + T::Proto: prost::Message + Default, +{ let mut header = [0_u8; 4]; reader.read_exact(&mut header)?; let declared = u32::from_be_bytes(header) as usize; @@ -1415,7 +1351,8 @@ pub fn read_frame(reader: &mut impl Read) -> Result(reader: &mut R) -> Result where R: AsyncRead + Unpin, - T: DeserializeOwned, + T: crate::protobuf::WireMessage, + T::Proto: prost::Message + Default, { use tokio::io::AsyncReadExt as _; @@ -1432,7 +1369,13 @@ where decode_frame(&frame) } -pub fn write_frame(writer: &mut impl Write, message: &T) -> Result<(), FrameError> { +pub fn write_frame( + writer: &mut impl Write, + message: &T, +) -> Result<(), FrameError> +where + T::Proto: prost::Message + Default, +{ let frame = encode_frame(message)?; writer.write_all(&frame)?; writer.flush()?; @@ -1441,16 +1384,14 @@ pub fn write_frame(writer: &mut impl Write, message: &T) -> Result #[derive(Debug, thiserror::Error)] pub enum FrameError { + #[error("invalid protobuf message: {0}")] + Protobuf(String), #[error("control frame is truncated")] Truncated, #[error("control frame is too large: {0} bytes")] TooLarge(usize), #[error("control frame declared {declared} bytes but contained {actual}")] LengthMismatch { declared: usize, actual: usize }, - #[error("serialize control frame: {0}")] - Serialize(serde_json::Error), - #[error("deserialize control frame: {0}")] - Deserialize(serde_json::Error), #[error("control request payload digest does not match its envelope")] PayloadDigestMismatch, #[error("read or write control frame: {0}")] @@ -1495,24 +1436,11 @@ mod tests { } } - #[test] - fn native_linux_audit_evidence_projects_backend_neutral_properties() { - let audit = complete_audit_evidence(); - audit.validate().unwrap(); - let properties = audit.properties(); - assert!(properties.filesystem_confinement.enforced); - assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6"); - assert!(properties.egress_interception.enforced); - assert!(properties.request_attribution.enforced); - assert!(properties.privilege_floor.enforced); - } - #[test] fn native_linux_audit_evidence_rejects_mechanism_failure() { let mut audit = complete_audit_evidence(); audit.seccomp.addfd_send = false; assert!(audit.validate().is_err()); - assert!(!audit.properties().egress_interception.enforced); } #[test] @@ -1520,7 +1448,6 @@ mod tests { let mut audit = complete_audit_evidence(); audit.socket_loopback_confinement = false; assert!(audit.validate().is_err()); - assert!(!audit.properties().egress_interception.enforced); } #[test] @@ -1593,7 +1520,7 @@ mod tests { let request = RequestEnvelope { request_id: "4e94636d-54f8-4d85-8e4e-58954fb5af0a".to_string(), payload_digest: String::new(), - exec_expires_at_unix_ms: None, + exec_admission_deadline_unix_ms: None, request: Request::StartAgent { provider_files: std::collections::HashMap::new(), sandbox_id: "sandbox-1".to_string(), @@ -1604,13 +1531,6 @@ mod tests { timeout_secs: 5, interactive: false, }, - policy: Box::new(SandboxPolicyWire::from(SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy::default(), - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process: ProcessPolicy::default(), - })), ca_cert: Some("test certificate".to_string()), ca_bundle: Some("test bundle".to_string()), provider_env_revision: 7, @@ -1629,7 +1549,6 @@ mod tests { let decoded: RequestEnvelope = decode_frame(&frame).expect("decode request"); assert_eq!(decoded, request); let debug = format!("{request:?}"); - assert!(debug.contains("")); assert!(!debug.contains("test credential")); assert!(!debug.contains("test certificate")); assert!(!debug.contains("test bundle")); @@ -1654,30 +1573,27 @@ mod tests { .unwrap(); let after = unix_time_millis().unwrap(); let window_ms = u64::try_from(EXEC_REQUEST_RETRY_WINDOW.as_millis()).unwrap(); - let deadline = request.exec_expires_at_unix_ms.unwrap(); + let deadline = request.exec_admission_deadline_unix_ms.unwrap(); assert!((before + window_ms..=after + window_ms).contains(&deadline)); let frame = encode_frame(&request).unwrap(); let mut retry: RequestEnvelope = decode_frame(&frame).unwrap(); assert_eq!(retry, request); retry.validate_payload_digest().unwrap(); - retry.exec_expires_at_unix_ms = Some(deadline + 1); - assert!(matches!( - retry.validate_payload_digest(), - Err(FrameError::PayloadDigestMismatch) - )); - retry.exec_expires_at_unix_ms = None; + retry.exec_admission_deadline_unix_ms = Some(deadline + 1); assert!(matches!( retry.validate_payload_digest(), Err(FrameError::PayloadDigestMismatch) )); + retry.exec_admission_deadline_unix_ms = None; + assert!(retry.validate_payload_digest().is_err()); } #[test] fn non_exec_requests_have_no_deadline() { let envelope = RequestEnvelope::new(Request::Confirm).unwrap(); - assert!(envelope.exec_expires_at_unix_ms.is_none()); + assert!(envelope.exec_admission_deadline_unix_ms.is_none()); let encoded = serde_json::to_value(&envelope).unwrap(); - assert!(encoded.get("exec_expires_at_unix_ms").is_none()); + assert!(encoded.get("exec_admission_deadline_unix_ms").is_none()); let decoded: RequestEnvelope = serde_json::from_value(encoded).unwrap(); decoded.validate_payload_digest().unwrap(); } @@ -1696,15 +1612,9 @@ mod tests { revision: 2, provider_env, }; - // Pin the canonical bytes, including the nested environment object. - // Two randomized HashMaps can otherwise happen to iterate identically - // and conceal a serializer that preserves insertion order. - let expected = format!( - "{:x}", - Sha256::digest( - br#"{"generation":1,"operation":"update_provider_environment","provider_env":{"A":"1","B":"2"},"provider_files":{},"revision":2}"# - ) - ); + // The fingerprint is local: independently ordered protobuf maps must + // compare equally, while changed decoded fields must not replay. + let expected = request_envelope_digest(&build(first.clone()), None).unwrap(); for provider_env in [first, second] { let request = build(provider_env); assert_eq!( @@ -1778,13 +1688,6 @@ mod tests { timeout_secs: 5, interactive: false, }, - policy: Box::new(SandboxPolicyWire::from(SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy::default(), - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process: ProcessPolicy::default(), - })), ca_cert: Some("A".repeat(16 * 1024)), ca_bundle: Some("B".repeat(400 * 1024)), provider_env_revision: 0, diff --git a/crates/openshell-sandbox-backend/src/delegated.rs b/crates/openshell-sandbox-backend/src/delegated.rs new file mode 100644 index 0000000000..349141e406 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/delegated.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Trusted launch metadata for an endpoint-registered Sandbox Protocol backend. + +use std::collections::BTreeMap; +use std::net::IpAddr; + +use openshell_core::SandboxSessionId; +use openshell_isolation_interface::contract::{ + BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, +}; +use serde::{Deserialize, Serialize}; + +use crate::boundary_protocol::{ + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTransport, +}; + +/// Delivered by the trusted driver separately from its opaque backend payload. +/// Endpoint selection belongs to operator configuration, not launch data. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DelegatedLaunch { + pub backend_name: String, + pub sandbox_id: String, + pub generation: String, + pub session_id: SandboxSessionId, + pub workload_identity: ResolvedWorkloadIdentity, + pub tls: SandboxTlsClientConfig, + #[serde(default)] + pub host_gateway_ip: Option, + pub resource_claims: BTreeMap, + pub provisioning_evidence: ProvisioningEvidence, +} + +impl DelegatedLaunch { + /// Combine driver-owned identity and TLS with the operator-owned endpoint. + pub fn runtime_descriptor( + self, + transport: SandboxTransport, + ) -> Result { + validate_backend_name(&self.backend_name)?; + let descriptor = SandboxRuntimeDescriptor { + boundary_id: self.sandbox_id, + generation: self.generation, + session_id: self.session_id, + workload_identity: self.workload_identity, + transport, + tls: self.tls, + host_gateway_ip: self.host_gateway_ip, + resource_claims: self.resource_claims, + provisioning_evidence: self.provisioning_evidence, + }; + crate::runtime::validate_runtime_coordinates(&descriptor)?; + Ok(descriptor) + } +} + +/// Validate a non-reserved name for an endpoint-registered backend. +pub fn validate_backend_name(name: &str) -> Result<(), BackendError> { + openshell_core::isolation_registration::validate_backend_name(name) + .map_err(|error| BackendError::Descriptor(error.into())) +} diff --git a/crates/openshell-sandbox-backend/src/isolation_protocol.rs b/crates/openshell-sandbox-backend/src/isolation_protocol.rs new file mode 100644 index 0000000000..9a740e4191 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/isolation_protocol.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Negotiated control contract and boundary routing for the Sandbox Protocol. + +use openshell_core::extension_protocol::negotiate_metadata; +use openshell_core::proto::extension::v1::{PeerMetadata, ProtocolVersion}; + +use crate::backend_proto::OpenBoundaryRequest; +use crate::boundary_protocol::{BoundaryConfig, SandboxRuntimeDescriptor}; +use crate::proto::{ExchangeFrame, exchange_frame::Payload}; + +pub const SANDBOX_PROTOCOL_CAPABILITY: &str = "openshell.sandbox.protocol"; +const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 1, minor: 0 }; + +/// The built-in runtime supports fresh launches; suspend/resume is optional. +#[must_use] +pub fn metadata(implementation: &str) -> PeerMetadata { + let capabilities = vec![SANDBOX_PROTOCOL_CAPABILITY.to_string()]; + PeerMetadata { + protocol_version: Some(PROTOCOL_VERSION), + implementation_name: implementation.to_string(), + implementation_version: openshell_core::VERSION.to_string(), + supported_capabilities: capabilities.clone(), + required_capabilities: capabilities, + } +} + +/// Use the shared extension rules: equal majors and both peers' requirements. +pub fn negotiate(local: &PeerMetadata, remote: Option) -> Result<(), tonic::Status> { + negotiate_metadata("isolation", "sandbox-protocol", local, remote) + .map(drop) + .map_err(|error| tonic::Status::failed_precondition(error.to_string())) +} + +/// Check native driver coordinates against the protected runtime bootstrap. +/// +/// The caller must first authenticate the bearer against the provisioned +/// sandbox and generation using `SandboxProtocolAuthenticator`. +/// This only opens a route; attach, confirm, and start retain their authority. +pub fn validate_open( + request: &OpenBoundaryRequest, + config: &BoundaryConfig, +) -> Result<(), tonic::Status> { + if request.backend_name != crate::BACKEND_NAME + || request.session_id != config.session_id.to_string() + { + return Err(tonic::Status::permission_denied( + "boundary launch identity does not match runtime", + )); + } + let descriptor: SandboxRuntimeDescriptor = + serde_json::from_slice(&request.driver_descriptor) + .map_err(|_| tonic::Status::invalid_argument("invalid OpenShell runtime descriptor"))?; + if descriptor.boundary_id != config.boundary_id + || descriptor.generation != config.generation + || descriptor.session_id != config.session_id + || descriptor.workload_identity != config.workload_identity + || descriptor.resource_claims != config.resource_claims + || descriptor.provisioning_evidence != config.provisioning_evidence + { + return Err(tonic::Status::permission_denied( + "driver descriptor does not match provisioned boundary", + )); + } + Ok(()) +} + +#[must_use] +pub fn data_chunk(data: Vec) -> ExchangeFrame { + ExchangeFrame { + payload: Some(Payload::RelayData(data)), + } +} + +#[must_use] +pub fn exchange_frame(boundary_id: &str) -> ExchangeFrame { + ExchangeFrame { + payload: Some(Payload::BoundaryId(boundary_id.to_string())), + } +} + +/// Adapt a single typed frame for callers inspecting a local response. +pub fn chunk_data(chunk: ExchangeFrame) -> Result, tonic::Status> { + let mut mode = match chunk.payload.as_ref() { + Some(Payload::Request(_)) => crate::stream_adapter::Mode::Request, + Some(Payload::Response(_)) => crate::stream_adapter::Mode::Response, + Some(Payload::Stdin(_) | Payload::StdinClosed(_)) => crate::stream_adapter::Mode::Input, + Some(Payload::Stdout(_) | Payload::Stderr(_) | Payload::Exit(_)) => { + crate::stream_adapter::Mode::Output + } + Some(Payload::TcpOpenDecision(_)) => crate::stream_adapter::Mode::Decision, + _ => crate::stream_adapter::Mode::Relay, + }; + crate::stream_adapter::receive(&mut mode, chunk) + .map_err(|error| tonic::Status::invalid_argument(error.to_string())) +} + +/// A typed control request after the stream's boundary-binding frame. +pub fn request_chunk( + request: &crate::boundary_protocol::RequestEnvelope, +) -> Result { + use crate::protobuf::WireMessage; + Ok(ExchangeFrame { + payload: Some(Payload::Request(request.to_proto()?)), + }) +} + +/// A typed control response, preserving the request's correlation ID. +pub fn response_chunk( + response: &crate::boundary_protocol::ResponseEnvelope, +) -> Result { + use crate::protobuf::WireMessage; + Ok(ExchangeFrame { + payload: Some(Payload::Response(response.to_proto()?)), + }) +} diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 5ffb83ac8e..dd25b36f4b 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -3,23 +3,32 @@ #![forbid(unsafe_code)] -//! `OpenShell` Sandbox Protocol and its concrete RFC 0012 backend. +//! Concrete supervisor-side client for the Isolation Backend and Sandbox Protocol RPCs. //! -//! [`OpenShellRuntimeBackend`] is the supervisor-side implementation of the -//! generic `IsolationBackend` interface. The workload-side `openshell-sandbox` -//! runtime serves the same protocol using the generated server and shared wire -//! types in this crate. +//! [`OpenShellRuntimeBackend`] connects to the in-tree runtime or a registered +//! external backend. Its concrete lifecycle and process handles issue RPCs; +//! they are not backend implementation traits. The workload-side +//! `openshell-sandbox` runtime implements the generated server services. -pub mod audit; +mod audit; pub mod boundary_protocol; +pub mod delegated; +pub mod isolation_protocol; pub mod mediation; +pub mod protobuf; mod runtime; pub mod sandbox_auth; +pub mod stream_adapter; -pub use runtime::{BoundaryTransportConnector, OpenShellRuntimeBackend}; +pub use openshell_core::proto::sandbox::backend::v1 as backend_proto; +pub use runtime::{ + BoundBoundary, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, + ConfirmedBoundary, ExecSession, NetworkMediationSource, OpenShellRuntimeBackend, + ProcessAttachment, ReadyBoundary, RunningBoundary, +}; /// Stable isolation backend name implemented by `openshell-sandbox`. -pub const BACKEND_NAME: &str = "openshell-sandbox"; +pub const BACKEND_NAME: &str = openshell_core::isolation_registration::BUILTIN_BACKEND_NAME; /// Resource claim set by compute drivers when the workload requests GPU access. pub const GPU_RESOURCE_CLAIM: &str = "openshell.gpu"; @@ -39,16 +48,10 @@ pub const SUPERVISOR_CA_RUNTIME_ROOT: &str = "/run/openshell-supervisor-ca"; /// tmpfs ownership create this child as the unprivileged sandbox identity. pub const SUPERVISOR_CA_RUNTIME_DIR: &str = "/run/openshell-supervisor-ca/material"; -/// Generated gRPC transport envelope for the OpenShell Sandbox Protocol. -#[allow( - clippy::all, - clippy::pedantic, - clippy::nursery, - dead_code, - unused_imports, - unused_qualifications, - rust_2018_idioms -)] +/// Generated gRPC transport envelope for the `OpenShell` Sandbox Protocol. pub mod proto { - tonic::include_proto!("openshell.sandbox.protocol.v1"); + pub use openshell_core::proto::sandbox::protocol::v1::*; } + +#[cfg(feature = "test-support")] +pub use runtime::test_support; diff --git a/crates/openshell-sandbox-backend/src/mediation.rs b/crates/openshell-sandbox-backend/src/mediation.rs index 9a728369f1..4f09c6f94e 100644 --- a/crates/openshell-sandbox-backend/src/mediation.rs +++ b/crates/openshell-sandbox-backend/src/mediation.rs @@ -8,7 +8,6 @@ use std::io; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; @@ -56,8 +55,11 @@ pub struct DnsQueryWire { pub timing: MediationTimingWire, } -pub fn encode_json(value: &T) -> io::Result> { - let payload = serde_json::to_vec(value) +pub fn encode_message(value: &T) -> io::Result> +where + T::Proto: prost::Message + Default, +{ + let payload = crate::protobuf::encode(value) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; if payload.len() > MAX_METADATA_BYTES { return Err(io::Error::new( @@ -68,8 +70,11 @@ pub fn encode_json(value: &T) -> io::Result> { Ok(payload) } -pub fn decode_json(payload: &[u8]) -> io::Result { - serde_json::from_slice(payload) +pub fn decode_message(payload: &[u8]) -> io::Result +where + T::Proto: prost::Message + Default, +{ + crate::protobuf::decode(payload) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs new file mode 100644 index 0000000000..21c52788f1 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -0,0 +1,872 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checked conversion between the public protobuf contract and local Rust handles. +//! +//! All required message presence, enums, numeric ranges, paths, and identities +//! are validated here before entering the existing admission/lifecycle code. + +use crate::{ + boundary_protocol::{self as w, FrameError}, + proto as p, +}; +use openshell_isolation_interface::contract as c; +use prost::Message; +use std::path::{Path, PathBuf}; + +pub trait WireMessage: Sized { + type Proto; + fn to_proto(&self) -> Result; + fn from_proto(value: Self::Proto) -> Result; +} +fn invalid(message: impl ToString) -> FrameError { + FrameError::Protobuf(message.to_string()) +} +fn required(value: Option) -> Result { + value.ok_or_else(|| invalid("required message or variant missing")) +} +fn path(value: &Path) -> Result { + value + .to_str() + .map(str::to_owned) + .ok_or_else(|| invalid("path is not UTF-8")) +} +fn parse(value: &str) -> Result +where + T::Err: std::fmt::Display, +{ + value.parse().map_err(invalid) +} +fn number>(value: u64) -> Result { + value + .try_into() + .map_err(|_| invalid("integer out of range")) +} + +macro_rules! enumeration { + ($rust:ty, $proto:ty, {$($variant:ident),+ $(,)?}) => { + impl WireMessage for $rust { + type Proto = $proto; + fn to_proto(&self) -> Result { + Ok(match self { $(Self::$variant => <$proto>::$variant),+ }) + } + fn from_proto(value: Self::Proto) -> Result { + match value { $(<$proto>::$variant => Ok(Self::$variant)),+, _ => Err(invalid("unspecified enum")) } + } + } + }; +} +enumeration!(w::SignalWire, p::BoundarySignal, {Term, Kill, Int, Hup}); +enumeration!(w::NetworkModeWire, p::NetworkMode, {Block, Proxy, Allow}); +enumeration!(w::LandlockCompatibilityWire, p::LandlockCompatibility, {BestEffort, HardRequirement}); +enumeration!(w::RuntimeHelperWire, p::RuntimeHelper, { Sftp }); +enumeration!(w::ProcessKindWire, p::ProcessKind, {Main, Exec}); +enumeration!(w::BoundaryErrorKind, p::BoundaryErrorKind, {Invalid, Denied, Unavailable, Terminated, Process, Unsupported}); +enumeration!(c::TcpOpenDenial, p::TcpOpenDenial, {PolicyDenied, IdentityUnavailable, InvalidDestination, ResourceExhausted, MediationUnavailable}); +enumeration!(c::DnsTransport, p::DnsTransport, {Udp, Tcp}); +fn enum_value>(value: i32) -> Result { + value.try_into().map_err(|_| invalid("unknown enum")) +} + +impl WireMessage for w::ExitStatusWire { + type Proto = p::ExitStatus; + fn to_proto(&self) -> Result { + use p::exit_status::Status; + Ok(p::ExitStatus { + status: Some(match self { + Self::Exited(v) => Status::Exited(*v), + Self::Signaled(v) => Status::Signaled(*v), + }), + }) + } + fn from_proto(value: Self::Proto) -> Result { + use p::exit_status::Status; + Ok(match required(value.status)? { + Status::Exited(v) => Self::Exited(v), + Status::Signaled(v) => Self::Signaled(v), + }) + } +} +impl WireMessage for w::SandboxPolicyWire { + type Proto = p::SandboxPolicy; + fn to_proto(&self) -> Result { + Ok(p::SandboxPolicy { + version: self.version, + read_only: self + .read_only + .iter() + .map(|v| path(v)) + .collect::>()?, + read_write: self + .read_write + .iter() + .map(|v| path(v)) + .collect::>()?, + include_workdir: self.include_workdir, + network: self.network.to_proto()? as i32, + proxy_addr: self.proxy_addr.map(|v| v.to_string()), + landlock: self.landlock.to_proto()? as i32, + run_as_user: self.run_as_user.clone(), + run_as_group: self.run_as_group.clone(), + }) + } + fn from_proto(v: Self::Proto) -> Result { + Ok(Self { + version: v.version, + read_only: v.read_only.into_iter().map(PathBuf::from).collect(), + read_write: v.read_write.into_iter().map(PathBuf::from).collect(), + include_workdir: v.include_workdir, + network: w::NetworkModeWire::from_proto(enum_value(v.network)?)?, + proxy_addr: v.proxy_addr.map(|v| parse(&v)).transpose()?, + landlock: w::LandlockCompatibilityWire::from_proto(enum_value(v.landlock)?)?, + run_as_user: v.run_as_user, + run_as_group: v.run_as_group, + }) + } +} +fn duration(micros: u64) -> Result { + let seconds = number(micros / 1_000_000)?; + if seconds > 315_576_000_000 { + return Err(invalid("duration outside protobuf range")); + } + Ok(prost_types::Duration { + seconds, + nanos: number((micros % 1_000_000) * 1000)?, + }) +} +fn micros(v: Option) -> Result { + let v = v.unwrap_or_default(); + if !(0..=315_576_000_000).contains(&v.seconds) + || !(0..1_000_000_000).contains(&v.nanos) + || v.nanos % 1000 != 0 + { + return Err(invalid("invalid microsecond duration")); + } + u64::try_from(v.seconds) + .map_err(invalid)? + .checked_mul(1_000_000) + .and_then(|s| s.checked_add(u64::try_from(v.nanos).ok()? / 1000)) + .ok_or_else(|| invalid("duration overflow")) +} +impl WireMessage for w::AgentSpecWire { + type Proto = p::AgentSpec; + fn to_proto(&self) -> Result { + Ok(p::AgentSpec { + program: self.program.clone(), + args: self.args.clone(), + workdir: self.workdir.clone(), + timeout: Some(prost_types::Duration { + seconds: if self.timeout_secs <= 315_576_000_000 { + number(self.timeout_secs)? + } else { + return Err(invalid("timeout outside protobuf duration range")); + }, + nanos: 0, + }), + interactive: self.interactive, + }) + } + fn from_proto(v: Self::Proto) -> Result { + let timeout = v.timeout.unwrap_or_default(); + if !(0..=315_576_000_000).contains(&timeout.seconds) || timeout.nanos != 0 { + return Err(invalid("timeout must be whole nonnegative seconds")); + } + Ok(Self { + program: v.program, + args: v.args, + workdir: v.workdir, + timeout_secs: u64::try_from(timeout.seconds).map_err(invalid)?, + interactive: v.interactive, + }) + } +} +impl WireMessage for w::ExecSpecWire { + type Proto = p::ExecSpec; + fn to_proto(&self) -> Result { + Ok(p::ExecSpec { + program: self.program.clone(), + args: self.args.clone(), + shell: self.shell.as_ref().map(|s| p::ShellSpec { + command: s.command.clone(), + login: s.login, + }), + runtime_helper: self + .runtime_helper + .as_ref() + .map(WireMessage::to_proto) + .transpose()? + .map(|v| v as i32), + env: self + .env + .iter() + .map(|(name, value)| p::EnvironmentEntry { + name: name.clone(), + value: value.clone(), + }) + .collect(), + workdir: self.workdir.clone(), + pty: self.pty, + }) + } + fn from_proto(v: Self::Proto) -> Result { + Ok(Self { + program: v.program, + args: v.args, + shell: v.shell.map(|s| w::ShellSpecWire { + command: s.command, + login: s.login, + }), + runtime_helper: v + .runtime_helper + .map(|v| w::RuntimeHelperWire::from_proto(enum_value(v)?)) + .transpose()?, + env: v.env.into_iter().map(|v| (v.name, v.value)).collect(), + workdir: v.workdir, + pty: v.pty, + }) + } +} +impl WireMessage for w::ExecutableIdentityWire { + type Proto = p::ExecutableIdentity; + fn to_proto(&self) -> Result { + Ok(p::ExecutableIdentity { + path: path(&self.path)?, + digest: self.digest.as_ref().map(ToString::to_string), + }) + } + fn from_proto(v: Self::Proto) -> Result { + Ok(Self { + path: v.path.into(), + digest: v.digest.map(|v| parse(&v)).transpose()?, + }) + } +} +impl WireMessage for w::BinaryIdentityWire { + type Proto = p::BinaryIdentity; + fn to_proto(&self) -> Result { + use p::binary_identity::Result as R; + Ok(p::BinaryIdentity { + result: Some(match self { + Self::Resolved { + executable, + ancestors, + cmdline_paths, + } => R::Resolved(p::ResolvedBinaryIdentity { + executable: Some(executable.to_proto()?), + ancestors: ancestors + .iter() + .map(WireMessage::to_proto) + .collect::>()?, + cmdline_paths: cmdline_paths + .iter() + .map(|v| path(v)) + .collect::>()?, + }), + Self::Failed { message } => R::Failed(message.clone()), + }), + }) + } + fn from_proto(v: Self::Proto) -> Result { + use p::binary_identity::Result as R; + Ok(match required(v.result)? { + R::Resolved(v) => Self::Resolved { + executable: w::ExecutableIdentityWire::from_proto(required(v.executable)?)?, + ancestors: v + .ancestors + .into_iter() + .map(w::ExecutableIdentityWire::from_proto) + .collect::>()?, + cmdline_paths: v.cmdline_paths.into_iter().map(PathBuf::from).collect(), + }, + R::Failed(message) => Self::Failed { message }, + }) + } +} +impl WireMessage for w::MediationTimingWire { + type Proto = p::MediationTiming; + fn to_proto(&self) -> Result { + Ok(p::MediationTiming { + notification_to_queue: Some(duration(self.notification_to_queue_us)?), + queue_wait: Some(duration(self.queue_wait_us)?), + }) + } + fn from_proto(v: Self::Proto) -> Result { + Ok(Self { + notification_to_queue_us: micros(v.notification_to_queue)?, + queue_wait_us: micros(v.queue_wait)?, + }) + } +} +impl WireMessage for c::TcpOpenDecision { + type Proto = p::TcpOpenDecision; + fn to_proto(&self) -> Result { + use p::tcp_open_decision::Decision; + Ok(p::TcpOpenDecision { + decision: Some(match self { + Self::Denied(v) => Decision::Denied(v.to_proto()? as i32), + Self::RelayReady => Decision::RelayReady(p::RelayReady {}), + }), + }) + } + fn from_proto(v: Self::Proto) -> Result { + use p::tcp_open_decision::Decision; + Ok(match required(v.decision)? { + Decision::Denied(v) => Self::Denied(c::TcpOpenDenial::from_proto(enum_value(v)?)?), + Decision::RelayReady(_) => Self::RelayReady, + }) + } +} +impl WireMessage for c::BoundaryConfirmation { + type Proto = p::BoundaryConfirmation; + fn to_proto(&self) -> Result { + Ok(p::BoundaryConfirmation { + generation: self.generation.clone(), + identity: Some(p::ResolvedWorkloadIdentity { + uid: self.identity.uid, + gid: self.identity.gid, + supplementary_gids: self.identity.supplementary_gids.clone(), + source: self.identity.source.clone(), + resource_digest: self.identity.resource_digest.clone(), + }), + session_id: self.session_id.to_string(), + resource_claims: self.resource_claims.clone(), + provisioning_evidence_digest: self.provisioning_evidence_digest.to_string(), + backend_audit: if self.backend_audit.is_null() { + Vec::new() + } else { + serde_json::to_vec(&self.backend_audit).map_err(invalid)? + }, + }) + } + fn from_proto(v: Self::Proto) -> Result { + let identity = required(v.identity)?; + Ok(Self { + generation: v.generation, + identity: c::ResolvedWorkloadIdentity::new( + identity.uid, + identity.gid, + identity.supplementary_gids, + identity.source, + identity.resource_digest, + ) + .map_err(invalid)?, + session_id: parse(&v.session_id)?, + resource_claims: v.resource_claims, + provisioning_evidence_digest: parse(&v.provisioning_evidence_digest)?, + backend_audit: if v.backend_audit.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&v.backend_audit).map_err(|error| { + invalid(format!("backend_audit must be UTF-8 JSON: {error}")) + })? + }, + }) + } +} +impl WireMessage for w::SessionSnapshotWire { + type Proto = p::SessionSnapshot; + fn to_proto(&self) -> Result { + Ok(p::SessionSnapshot { + generation: self.generation.clone(), + processes: self + .processes + .iter() + .map(|v| { + Ok(p::ProcessSnapshot { + process_id: v.process_id.clone(), + kind: v.kind.to_proto()? as i32, + terminal: v.terminal, + status: v.status.as_ref().map(WireMessage::to_proto).transpose()?, + retained_output: Some(p::OutputWindow { + first_sequence: v.retained_output.first_sequence, + next_sequence: v.retained_output.next_sequence, + truncated: v.retained_output.truncated, + }), + }) + }) + .collect::>()?, + }) + } + fn from_proto(v: Self::Proto) -> Result { + Ok(Self { + generation: v.generation, + processes: v + .processes + .into_iter() + .map(|v| { + let output = required(v.retained_output)?; + Ok(w::ProcessSnapshotWire { + process_id: v.process_id, + kind: w::ProcessKindWire::from_proto(enum_value(v.kind)?)?, + terminal: v.terminal, + status: v.status.map(w::ExitStatusWire::from_proto).transpose()?, + retained_output: w::OutputWindowWire { + first_sequence: output.first_sequence, + next_sequence: output.next_sequence, + truncated: output.truncated, + }, + }) + }) + .collect::>()?, + }) + } +} +fn timestamp(v: u64) -> Result { + let seconds = number(v / 1000)?; + if seconds > 253_402_300_799 { + return Err(invalid("timestamp outside protobuf range")); + } + Ok(prost_types::Timestamp { + seconds, + nanos: number((v % 1000) * 1_000_000)?, + }) +} +fn millis(v: prost_types::Timestamp) -> Result { + if !(0..=253_402_300_799).contains(&v.seconds) + || !(0..1_000_000_000).contains(&v.nanos) + || v.nanos % 1_000_000 != 0 + { + return Err(invalid("invalid millisecond timestamp")); + } + Ok(u64::try_from(v.seconds).map_err(invalid)? * 1000 + + u64::try_from(v.nanos).map_err(invalid)? / 1_000_000) +} +impl WireMessage for w::RequestEnvelope { + type Proto = p::RequestEnvelope; + fn to_proto(&self) -> Result { + use p::request_envelope::Request as P; + use w::Request as W; + let request = match &self.request { + W::DiscoverPolicy => P::DiscoverPolicy(p::DiscoverPolicy {}), + W::Attach { + supervisor_instance_id, + policy, + } => P::Attach(p::Attach { + supervisor_instance_id: supervisor_instance_id.to_string(), + policy: Some(policy.to_proto()?), + }), + W::Confirm => P::Confirm(p::Confirm {}), + W::StartAgent { + sandbox_id, + spec, + ca_cert, + ca_bundle, + provider_env_revision, + provider_env, + provider_files, + } => P::StartAgent(p::StartAgent { + sandbox_id: sandbox_id.clone(), + spec: Some(spec.to_proto()?), + ca_cert: ca_cert.clone(), + ca_bundle: ca_bundle.clone(), + provider_env_revision: *provider_env_revision, + provider_env: provider_env.clone().into_iter().collect(), + provider_files: provider_files.clone().into_iter().collect(), + }), + W::UpdateProviderEnvironment { + generation, + revision, + provider_env, + provider_files, + } => P::UpdateProviderEnvironment(p::UpdateProviderEnvironment { + generation: *generation, + revision: *revision, + provider_env: provider_env.clone().into_iter().collect(), + provider_files: provider_files.clone().into_iter().collect(), + }), + W::AttachProcess { process_id } => P::AttachProcess(p::AttachProcess { + process_id: process_id.clone(), + }), + W::Wait { process_id } => P::Wait(p::Wait { + process_id: process_id.clone(), + }), + W::Signal { process_id, signal } => P::Signal(p::Signal { + process_id: process_id.clone(), + signal: signal.to_proto()? as i32, + }), + W::Terminate { process_id } => P::Terminate(p::Terminate { + process_id: process_id.clone(), + }), + W::TerminateBoundary => P::TerminateBoundary(p::TerminateBoundary {}), + W::Exec { spec } => P::Exec(p::Exec { + spec: Some(spec.to_proto()?), + exec_admission_deadline: Some(timestamp( + self.exec_admission_deadline_unix_ms + .ok_or_else(|| invalid("exec admission deadline missing"))?, + )?), + }), + W::Resize { + process_id, + cols, + rows, + } => P::Resize(p::Resize { + process_id: process_id.clone(), + cols: u32::from(*cols), + rows: u32::from(*rows), + }), + W::LoopbackConnect { host, port } => P::LoopbackConnect(p::LoopbackConnect { + host: host.to_string(), + port: u32::from(*port), + }), + W::OpenMediation => P::OpenMediation(p::OpenMediation {}), + W::AcceptNetwork => P::AcceptNetwork(p::AcceptNetwork {}), + }; + Ok(p::RequestEnvelope { + request_id: self.request_id.clone(), + request: Some(request), + }) + } + fn from_proto(v: Self::Proto) -> Result { + use p::request_envelope::Request as P; + use w::Request as W; + let mut expires = None; + let request = match required(v.request)? { + P::DiscoverPolicy(_) => W::DiscoverPolicy, + P::Attach(v) => W::Attach { + supervisor_instance_id: parse(&v.supervisor_instance_id)?, + policy: Box::new(w::SandboxPolicyWire::from_proto(required(v.policy)?)?), + }, + P::Confirm(_) => W::Confirm, + P::StartAgent(v) => W::StartAgent { + sandbox_id: v.sandbox_id, + spec: w::AgentSpecWire::from_proto(required(v.spec)?)?, + ca_cert: v.ca_cert, + ca_bundle: v.ca_bundle, + provider_env_revision: v.provider_env_revision, + provider_env: v.provider_env.into_iter().collect(), + provider_files: v.provider_files.into_iter().collect(), + }, + P::UpdateProviderEnvironment(v) => W::UpdateProviderEnvironment { + generation: v.generation, + revision: v.revision, + provider_env: v.provider_env.into_iter().collect(), + provider_files: v.provider_files.into_iter().collect(), + }, + P::AttachProcess(v) => W::AttachProcess { + process_id: v.process_id, + }, + P::Wait(v) => W::Wait { + process_id: v.process_id, + }, + P::Signal(v) => W::Signal { + process_id: v.process_id, + signal: w::SignalWire::from_proto(enum_value(v.signal)?)?, + }, + P::Terminate(v) => W::Terminate { + process_id: v.process_id, + }, + P::TerminateBoundary(_) => W::TerminateBoundary, + P::Exec(v) => { + expires = Some(millis(required(v.exec_admission_deadline)?)?); + W::Exec { + spec: w::ExecSpecWire::from_proto(required(v.spec)?)?, + } + } + P::Resize(v) => W::Resize { + process_id: v.process_id, + cols: number(v.cols.into())?, + rows: number(v.rows.into())?, + }, + P::LoopbackConnect(v) => W::LoopbackConnect { + host: parse(&v.host)?, + port: number(v.port.into())?, + }, + P::OpenMediation(_) => W::OpenMediation, + P::AcceptNetwork(_) => W::AcceptNetwork, + }; + // The server derives a fingerprint from decoded typed fields. Clients + // supply only the random ID; protobuf map order never changes a replay. + parse::(&v.request_id)?; + let mut envelope = Self { + request_id: v.request_id, + payload_digest: String::new(), + exec_admission_deadline_unix_ms: expires, + request, + }; + envelope.payload_digest = w::request_envelope_digest(&envelope.request, expires)?; + Ok(envelope) + } +} +impl WireMessage for w::ResponseEnvelope { + type Proto = p::ResponseEnvelope; + fn to_proto(&self) -> Result { + use p::response_envelope::Response as P; + use w::Response as W; + let response = match &self.response { + W::ImagePolicy { yaml, invalid } => P::ImagePolicy(p::ImagePolicy { + yaml: yaml.clone(), + invalid: *invalid, + }), + W::Attached { snapshot } => P::Attached(p::Attached { + snapshot: Some(snapshot.to_proto()?), + }), + W::Confirmed { confirmation } => P::Confirmed(p::Confirmed { + confirmation: Some(confirmation.to_proto()?), + }), + W::Started { + process_id, + provider_env_revision, + provider_env_generation, + } => P::Started(p::Started { + process_id: process_id.clone(), + provider_env_revision: *provider_env_revision, + provider_env_generation: *provider_env_generation, + }), + W::ProviderEnvironmentUpdated { + revision, + generation, + applied, + } => P::ProviderEnvironmentUpdated(p::ProviderEnvironmentUpdated { + revision: *revision, + generation: *generation, + applied: *applied, + }), + W::ProcessAttached { terminal } => P::ProcessAttached(p::ProcessAttached { + terminal: *terminal, + }), + W::Exited { status } => P::Exited(p::Exited { + status: Some(status.to_proto()?), + }), + W::Signaled => P::Signaled(p::Signaled {}), + W::Terminated => P::Terminated(p::Terminated {}), + W::BoundaryTerminated => P::BoundaryTerminated(p::BoundaryTerminated {}), + W::ExecStarted { process_id, pty } => P::ExecStarted(p::ExecStarted { + process_id: process_id.clone(), + pty: *pty, + }), + W::Resized => P::Resized(p::Resized {}), + W::LoopbackConnected => P::LoopbackConnected(p::LoopbackConnected {}), + W::MediationReady => P::MediationReady(p::MediationReady {}), + W::NetworkConnected { + identity, + destination, + socket, + policy_generation, + timing, + } => P::NetworkConnected(p::NetworkConnected { + identity: Some(identity.to_proto()?), + destination: destination.to_string(), + socket: socket.as_ref().map(|v| p::NetworkSocketMetadata { + socket_cookie: v.socket_cookie, + nonblocking: v.nonblocking, + process_generation: v.process_generation, + }), + policy_generation: *policy_generation, + timing: Some(timing.to_proto()?), + }), + W::Error { kind, message } => P::Error(p::Error { + kind: kind.to_proto()? as i32, + message: message.clone(), + }), + }; + Ok(p::ResponseEnvelope { + request_id: self.request_id.clone(), + response: Some(response), + }) + } + fn from_proto(v: Self::Proto) -> Result { + use p::response_envelope::Response as P; + use w::Response as W; + let response = match required(v.response)? { + P::ImagePolicy(v) => W::ImagePolicy { + yaml: v.yaml, + invalid: v.invalid, + }, + P::Attached(v) => W::Attached { + snapshot: w::SessionSnapshotWire::from_proto(required(v.snapshot)?)?, + }, + P::Confirmed(v) => W::Confirmed { + confirmation: Box::new(c::BoundaryConfirmation::from_proto(required( + v.confirmation, + )?)?), + }, + P::Started(v) => W::Started { + process_id: v.process_id, + provider_env_revision: v.provider_env_revision, + provider_env_generation: v.provider_env_generation, + }, + P::ProviderEnvironmentUpdated(v) => W::ProviderEnvironmentUpdated { + revision: v.revision, + generation: v.generation, + applied: v.applied, + }, + P::ProcessAttached(v) => W::ProcessAttached { + terminal: v.terminal, + }, + P::Exited(v) => W::Exited { + status: w::ExitStatusWire::from_proto(required(v.status)?)?, + }, + P::Signaled(_) => W::Signaled, + P::Terminated(_) => W::Terminated, + P::BoundaryTerminated(_) => W::BoundaryTerminated, + P::ExecStarted(v) => W::ExecStarted { + process_id: v.process_id, + pty: v.pty, + }, + P::Resized(_) => W::Resized, + P::LoopbackConnected(_) => W::LoopbackConnected, + P::MediationReady(_) => W::MediationReady, + P::NetworkConnected(v) => W::NetworkConnected { + identity: w::BinaryIdentityWire::from_proto(required(v.identity)?)?, + destination: parse(&v.destination)?, + socket: v.socket.map(|v| c::NetworkSocketMetadata { + socket_cookie: v.socket_cookie, + nonblocking: v.nonblocking, + process_generation: v.process_generation, + }), + policy_generation: v.policy_generation, + timing: w::MediationTimingWire::from_proto(v.timing.unwrap_or_default())?, + }, + P::Error(v) => W::Error { + kind: w::BoundaryErrorKind::from_proto(enum_value(v.kind)?)?, + message: v.message, + }, + }; + Ok(Self { + request_id: v.request_id, + response, + }) + } +} +impl WireMessage for crate::mediation::DnsQueryWire { + type Proto = p::DnsQuery; + fn to_proto(&self) -> Result { + Ok(p::DnsQuery { + stream_id: 0, + request: self.request.clone(), + transport: self.transport.to_proto()? as i32, + identity: Some(self.identity.to_proto()?), + timing: Some(self.timing.to_proto()?), + }) + } + fn from_proto(v: Self::Proto) -> Result { + Ok(Self { + request: v.request, + transport: c::DnsTransport::from_proto(enum_value(v.transport)?)?, + identity: w::BinaryIdentityWire::from_proto(required(v.identity)?)?, + timing: w::MediationTimingWire::from_proto(v.timing.unwrap_or_default())?, + }) + } +} +impl WireMessage for w::DnsQueryResultWire { + type Proto = p::DnsResponse; + fn to_proto(&self) -> Result { + use p::dns_response::Result as R; + Ok(p::DnsResponse { + stream_id: 0, + result: Some(match self { + Self::Response(v) => R::Response(v.clone()), + Self::Error(v) => R::Error(v.clone()), + }), + }) + } + fn from_proto(v: Self::Proto) -> Result { + use p::dns_response::Result as R; + Ok(match required(v.result)? { + R::Response(v) => Self::Response(v), + R::Error(v) => Self::Error(v), + }) + } +} + +pub fn encode(value: &T) -> Result, FrameError> +where + T::Proto: Message, +{ + Ok(value.to_proto()?.encode_to_vec()) +} +pub fn decode(bytes: &[u8]) -> Result +where + T::Proto: Message + Default, +{ + T::from_proto(T::Proto::decode(bytes).map_err(invalid)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_validation_rejects_missing_context_and_unknown_signal() { + let id = uuid::Uuid::new_v4().to_string(); + let attach = p::RequestEnvelope { + request_id: id.clone(), + request: Some(p::request_envelope::Request::Attach(p::Attach { + supervisor_instance_id: uuid::Uuid::new_v4().to_string(), + policy: None, + })), + }; + assert!(w::RequestEnvelope::from_proto(attach).is_err()); + let signal = p::RequestEnvelope { + request_id: id, + request: Some(p::request_envelope::Request::Signal(p::Signal { + process_id: "main".into(), + signal: 99, + })), + }; + assert!(w::RequestEnvelope::from_proto(signal).is_err()); + } + + #[test] + fn exec_requires_the_original_valid_deadline() { + let request = w::RequestEnvelope::new(w::Request::Exec { + spec: w::ExecSpecWire { + program: "/bin/true".into(), + args: vec![], + shell: None, + runtime_helper: None, + env: vec![], + workdir: None, + pty: false, + }, + }) + .unwrap(); + let mut value = request.to_proto().unwrap(); + let Some(p::request_envelope::Request::Exec(exec)) = value.request.as_mut() else { + unreachable!() + }; + exec.exec_admission_deadline = None; + assert!(w::RequestEnvelope::from_proto(value).is_err()); + let mut value = request.to_proto().unwrap(); + let Some(p::request_envelope::Request::Exec(exec)) = value.request.as_mut() else { + unreachable!() + }; + exec.exec_admission_deadline.as_mut().unwrap().nanos = -1; + assert!(w::RequestEnvelope::from_proto(value).is_err()); + let mut value = request.to_proto().unwrap(); + let Some(p::request_envelope::Request::Exec(exec)) = value.request.as_mut() else { + unreachable!() + }; + exec.exec_admission_deadline.as_mut().unwrap().seconds += 1; + let changed = w::RequestEnvelope::from_proto(value).unwrap(); + assert_eq!(changed.request_id, request.request_id); + assert_ne!(changed.payload_digest, request.payload_digest); + } + + #[test] + fn resize_rejects_values_outside_the_rust_contract() { + let value = p::RequestEnvelope { + request_id: uuid::Uuid::new_v4().to_string(), + request: Some(p::request_envelope::Request::Resize(p::Resize { + process_id: "main".into(), + cols: 65_536, + rows: 24, + })), + }; + assert!(w::RequestEnvelope::from_proto(value).is_err()); + } + + #[test] + fn publication_retains_large_counters_without_a_client_digest() { + let request = w::RequestEnvelope::new(w::Request::UpdateProviderEnvironment { + generation: 9_007_199_254_740_993, + revision: u64::MAX, + provider_env: std::collections::HashMap::from([("KEY".into(), "value".into())]), + provider_files: std::collections::HashMap::default(), + }) + .unwrap(); + let bytes = encode(&request).unwrap(); + let decoded: w::RequestEnvelope = decode(&bytes).unwrap(); + assert_eq!(decoded, request); + } +} diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index b20ca44f3b..bcda27830c 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -10,17 +10,22 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use crate::proto::{BoundaryChunk, isolation_boundary_client::IsolationBoundaryClient}; +use crate::backend_proto::{ + DescribeIsolationBackendRequest, OpenBoundaryRequest, + isolation_backend_client::IsolationBackendClient, open_boundary_response::LaunchOutcome, +}; +#[cfg(test)] +use crate::isolation_protocol::{chunk_data, data_chunk}; +use crate::proto::{ExchangeFrame, MediateFrame, sandbox_protocol_client::SandboxProtocolClient}; +use crate::stream_adapter::ProtocolFrame; use async_trait::async_trait; use hyper_util::rt::TokioIo; use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::{ - BackendError, BoundBoundary, BoundaryDuplexStream, BoundaryExec, BoundaryExitStatus, - BoundaryInput, BoundaryLoopbackConnector, BoundaryOutput, BoundaryProcess, BoundarySignal, - BoundaryTerminal, ConfirmedBoundary, ExecSession, ExecSpec, IsolationBackend, LoopbackTarget, - MediationTiming, NetworkMediationSource, PendingDnsQuery, PendingTcpOpen, ProcessAttachment, - ProviderEnvironmentInstallation, ReadyBoundary, RunningBoundary, SandboxContext, - TcpOpenDecision, TcpOpenDenial, VerifiedBackendDescriptor, + BackendDescriptor, BackendError, BoundaryConfirmation, BoundaryDuplexStream, + BoundaryExitStatus, BoundaryInput, BoundaryOutput, BoundarySignal, ExecSpec, LoopbackTarget, + MediationTiming, PendingDnsQuery, PendingTcpOpen, ProviderEnvironmentInstallation, + ResolvedWorkloadIdentity, SandboxContext, TcpOpenDecision, TcpOpenDenial, }; use sha2::{Digest as _, Sha256}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -64,24 +69,40 @@ fn begin_recovery_window( *deadline.get_or_insert(failure_time + CONNECT_RETRY_TIMEOUT) } -/// Backend-owned raw transport establishment; shared code still authenticates +/// Internal raw transport establishment; shared code still authenticates /// the resulting stream with generation-pinned TLS and Sandbox Protocol JWTs. #[async_trait] -pub trait BoundaryTransportConnector: std::fmt::Debug + Send + Sync { +trait BoundaryTransportConnector: std::fmt::Debug + Send + Sync { async fn connect( &self, descriptor: &SandboxRuntimeDescriptor, ) -> Result; } -/// Host-side `OpenShell` Sandbox Protocol implementation registered with the supervisor. +/// Concrete RPC client used for both in-tree and endpoint-registered backends. #[derive(Debug)] pub struct OpenShellRuntimeBackend { connector: Option>, - audit_validator: Arc, ca_file_paths: Arc>>, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + delegated: Option, +} + +#[derive(Clone)] +struct DelegatedRuntime { + backend_name: String, + descriptor: SandboxRuntimeDescriptor, + driver_descriptor: Vec, +} + +impl std::fmt::Debug for DelegatedRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DelegatedRuntime") + .field("backend_name", &self.backend_name) + .field("descriptor", &self.descriptor) + .finish_non_exhaustive() + } } impl OpenShellRuntimeBackend { @@ -90,22 +111,9 @@ impl OpenShellRuntimeBackend { descriptor: SandboxRuntimeDescriptor, bearer: openshell_core::jwt::SessionBearerTokenSlot, ) -> Result<(Option, bool), BackendError> { - Self::discover_policy_with_connector(descriptor, bearer, None).await - } - - pub async fn discover_policy_with_connector( - descriptor: SandboxRuntimeDescriptor, - bearer: openshell_core::jwt::SessionBearerTokenSlot, - connector: Option>, - ) -> Result<(Option, bool), BackendError> { - let mut client = BoundaryClient::new(descriptor, bearer); - client.connector = connector; - match client.call_idempotent(Request::DiscoverPolicy).await? { - Response::ImagePolicy { yaml, invalid } => Ok((yaml, invalid)), - _ => Err(BackendError::Descriptor( - "expected image policy discovery response".to_string(), - )), - } + BoundaryClient::new(descriptor, bearer) + .discover_policy() + .await } pub fn new( @@ -115,62 +123,93 @@ impl OpenShellRuntimeBackend { ) -> Self { Self { connector: None, - audit_validator: Arc::new(crate::audit::LinuxBoundaryAuditValidator), ca_file_paths, provider_credentials, sandbox_bearer, + delegated: None, } } - #[must_use] - pub fn with_transport_connector( - mut self, - connector: Arc, - ) -> Self { - self.connector = Some(connector); - self + /// Construct the same RPC client for an operator-registered backend. + pub fn registered( + backend_name: String, + descriptor: SandboxRuntimeDescriptor, + driver_descriptor: Vec, + ca_file_paths: Arc>>, + provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, + sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + ) -> Result { + crate::delegated::validate_backend_name(&backend_name)?; + validate_runtime_coordinates(&descriptor)?; + let mut backend = Self::new(ca_file_paths, provider_credentials, sandbox_bearer); + backend.delegated = Some(DelegatedRuntime { + backend_name, + descriptor, + driver_descriptor, + }); + Ok(backend) } - /// Select the backend implementation that validates opaque audit evidence. - #[must_use] - pub fn with_audit_validator( - mut self, - validator: Arc, - ) -> Self { - self.audit_validator = validator; - self + /// Discover policy using the registered endpoint and opaque driver payload. + pub async fn discover_registered_policy( + backend_name: String, + descriptor: SandboxRuntimeDescriptor, + driver_descriptor: Vec, + bearer: openshell_core::jwt::SessionBearerTokenSlot, + ) -> Result<(Option, bool), BackendError> { + crate::delegated::validate_backend_name(&backend_name)?; + validate_runtime_coordinates(&descriptor)?; + let client = + BoundaryClient::with_backend(descriptor, bearer, backend_name, driver_descriptor); + client.discover_policy().await } -} -#[async_trait] -impl IsolationBackend for OpenShellRuntimeBackend { - fn backend_name(&self) -> &str { - crate::BACKEND_NAME + pub fn backend_name(&self) -> &str { + self.delegated + .as_ref() + .map_or(crate::BACKEND_NAME, |backend| backend.backend_name.as_str()) } - async fn attach( + pub async fn attach( &self, - descriptor: VerifiedBackendDescriptor, + descriptor: BackendDescriptor, sandbox: SandboxContext, - ) -> Result, BackendError> { - let runtime_descriptor: SandboxRuntimeDescriptor = - serde_json::from_slice(descriptor.payload()).map_err(|error| { - BackendError::Descriptor(format!("decode runtime descriptor: {error}")) - })?; + ) -> Result, BackendError> { + if descriptor.backend_name != self.backend_name() { + return Err(BackendError::Descriptor( + "backend descriptor name does not match selected backend".to_string(), + )); + } + let runtime_descriptor = if let Some(delegated) = &self.delegated { + if descriptor.payload != delegated.driver_descriptor { + return Err(BackendError::Descriptor( + "backend payload changed after trusted selection".to_string(), + )); + } + delegated.descriptor.clone() + } else { + serde_json::from_slice::(&descriptor.payload).map_err( + |error| BackendError::Descriptor(format!("decode runtime descriptor: {error}")), + )? + }; validate_runtime_descriptor(&runtime_descriptor, &sandbox)?; let host_gateway_ip = runtime_descriptor.host_gateway_ip; let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; - let outer_fence = runtime_descriptor.outer_fence.clone(); - let mut client = BoundaryClient::new(runtime_descriptor, self.sandbox_bearer.clone()); + let provisioning_evidence = runtime_descriptor.provisioning_evidence.clone(); + let mut client = BoundaryClient::with_backend( + runtime_descriptor, + self.sandbox_bearer.clone(), + self.backend_name().to_string(), + descriptor.payload.clone(), + ); client.connector = self.connector.clone(); let client = Arc::new(client); let response = client .call_idempotent(Request::Attach { supervisor_instance_id: client.supervisor_instance_id, policy: Box::new(SandboxPolicyWire::from(sandbox.policy.clone())), - resource_claims: resource_claims.clone(), }) .await?; let Response::Attached { snapshot } = response else { @@ -181,13 +220,11 @@ impl IsolationBackend for OpenShellRuntimeBackend { "sandbox session snapshot generation does not match runtime descriptor".to_string(), )); } - Ok(Box::new(RemoteBound { - audit_validator: self.audit_validator.clone(), + Ok(Box::new(BoundBoundary { client: client.clone(), agent: sandbox.agent, - policy: sandbox.policy, sandbox_id: sandbox.sandbox_id, - mediation: Arc::new(RemoteNetworkMediation { client }), + mediation: Arc::new(NetworkMediationSource { client }), host_gateway_ip, ca_file_paths: self.ca_file_paths.clone(), provider_credentials: self.provider_credentials.clone(), @@ -195,7 +232,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { generation, session_id, resource_claims, - outer_fence, + provisioning_evidence, })) } } @@ -210,11 +247,6 @@ fn validate_runtime_descriptor( runtime_descriptor.boundary_id, sandbox.sandbox_id ))); } - if runtime_descriptor.generation.is_empty() { - return Err(BackendError::Descriptor( - "boundary generation must not be empty".to_string(), - )); - } if runtime_descriptor.session_id != sandbox.session_id { return Err(BackendError::Descriptor( "runtime descriptor session ID does not match admitted sandbox session".to_string(), @@ -226,51 +258,42 @@ fn validate_runtime_descriptor( .to_string(), )); } + validate_runtime_coordinates(runtime_descriptor) +} + +pub fn validate_runtime_coordinates( + runtime_descriptor: &SandboxRuntimeDescriptor, +) -> Result<(), BackendError> { + if runtime_descriptor.boundary_id.is_empty() || runtime_descriptor.generation.is_empty() { + return Err(BackendError::Descriptor( + "sandbox identity and generation are required".to_string(), + )); + } + let identity = &runtime_descriptor.workload_identity; + let normalized = ResolvedWorkloadIdentity::new( + identity.uid, + identity.gid, + identity.supplementary_gids.clone(), + identity.source.clone(), + identity.resource_digest.clone(), + )?; + if normalized != *identity { + return Err(BackendError::Descriptor( + "workload identity is not canonical".into(), + )); + } validate_resource_claims(&runtime_descriptor.resource_claims)?; runtime_descriptor - .outer_fence + .provisioning_evidence .validate(&runtime_descriptor.generation)?; - match &runtime_descriptor.transport { - SandboxTransport::Unix { socket_path } => { - validate_socket_path(socket_path)?; - } - SandboxTransport::Tcp { - authority, - addresses, - } => { - if authority.is_empty() || addresses.is_empty() { - return Err(BackendError::Descriptor( - "boundary TCP transport requires an authority and at least one address" - .to_string(), - )); - } - for address in addresses { - validate_tcp_address(*address)?; - } - } - SandboxTransport::Vsock { guest_cid, port } => { - if *guest_cid < 3 { - return Err(BackendError::Descriptor( - "boundary CID must be at least 3".to_string(), - )); - } - validate_control_port(*port)?; - } - } + runtime_descriptor + .transport + .validate() + .map_err(|error| BackendError::Descriptor(error.into()))?; validate_client_tls(&runtime_descriptor.tls)?; Ok(()) } -fn validate_tcp_address(address: std::net::SocketAddr) -> Result<(), BackendError> { - if address.port() == 0 || address.ip().is_unspecified() { - Err(BackendError::Descriptor( - "boundary TCP address must have a concrete IP and nonzero port".to_string(), - )) - } else { - Ok(()) - } -} - fn validate_client_tls(tls: &SandboxTlsClientConfig) -> Result<(), BackendError> { rustls::pki_types::ServerName::try_from(tls.server_name.clone()).map_err(|error| { BackendError::Descriptor(format!( @@ -307,54 +330,31 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result Result<(), BackendError> { - if path.is_absolute() { - Ok(()) - } else { - Err(BackendError::Descriptor( - "boundary control Unix socket path must be absolute".to_string(), - )) - } -} - -fn validate_control_port(port: u32) -> Result<(), BackendError> { - if port == 0 { - Err(BackendError::Descriptor( - "boundary control port must be nonzero".to_string(), - )) - } else { - Ok(()) - } -} - -struct RemoteBound { - audit_validator: Arc, +pub struct BoundBoundary { client: Arc, agent: AgentSpec, - policy: openshell_core::policy::SandboxPolicy, sandbox_id: String, - mediation: Arc, + mediation: Arc, host_gateway_ip: Option, ca_file_paths: Arc>>, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, - identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, + identity: ResolvedWorkloadIdentity, generation: String, session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, - outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, + provisioning_evidence: openshell_isolation_interface::contract::ProvisioningEvidence, } -#[async_trait] -impl BoundBoundary for RemoteBound { - fn network_mediation_source(&self) -> Arc { +impl BoundBoundary { + pub fn network_mediation_source(&self) -> Arc { self.mediation.clone() } - fn host_gateway_ip(&self) -> Option { + pub fn host_gateway_ip(&self) -> Option { self.host_gateway_ip } - async fn confirm(self: Box) -> Result { + pub async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; let Response::Confirmed { confirmation } = response else { return Err(unexpected_response("confirmed", &response)); @@ -362,25 +362,22 @@ impl BoundBoundary for RemoteBound { if confirmation.generation != self.generation || confirmation.session_id != self.session_id || confirmation.resource_claims != self.resource_claims - || confirmation.outer_fence != self.outer_fence + || confirmation.provisioning_evidence_digest + != self.provisioning_evidence.evidence_digest { return Err(BackendError::Confirm( - "sandbox confirmation generation, session, resource claims, or outer fence do not match runtime descriptor" + "sandbox confirmation generation, session, resource claims, or provisioning evidence do not match runtime descriptor" .to_string(), )); } - let properties = self.audit_validator.validate(&confirmation.backend_audit)?; - if confirmation.properties != properties { - return Err(BackendError::Confirm( - "sandbox confirmation properties do not match validated audit evidence".to_string(), - )); + if self.client.backend_name == crate::BACKEND_NAME { + crate::audit::validate_native_linux_audit(&confirmation.backend_audit)?; } let client = self.client.clone(); let confirmed = ConfirmedBoundary::try_new( - Box::new(RemoteReady { + Box::new(ReadyBoundary { client: self.client, agent: self.agent, - policy: self.policy, sandbox_id: self.sandbox_id, ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, @@ -393,18 +390,16 @@ impl BoundBoundary for RemoteBound { } } -struct RemoteReady { +pub struct ReadyBoundary { client: Arc, agent: AgentSpec, - policy: openshell_core::policy::SandboxPolicy, sandbox_id: String, ca_file_paths: Arc>>, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, } -#[async_trait] -impl ReadyBoundary for RemoteReady { - async fn start_agent(self: Box) -> Result, BackendError> { +impl ReadyBoundary { + pub async fn start_agent(self: Box) -> Result, BackendError> { let ca_paths = self .ca_file_paths .lock() @@ -432,26 +427,11 @@ impl ReadyBoundary for RemoteReady { .map_err(|error| { BackendError::Process(format!("snapshot provider environment: {error}")) })?; - if !provider_snapshot.files.is_empty() { - let response = self - .client - .call_idempotent(Request::ProbeProviderFiles) - .await - .map_err(|error| { - BackendError::Process(format!("provider file capability probe failed: {error}")) - })?; - if !matches!(response, Response::ProviderFilesSupported) { - return Err(BackendError::Process( - "sandbox boundary does not support provider files".to_string(), - )); - } - } let response = self .client .call_idempotent(Request::StartAgent { sandbox_id: self.sandbox_id, spec: AgentSpecWire::from(self.agent), - policy: Box::new(SandboxPolicyWire::from(self.policy)), ca_cert, ca_bundle, provider_env_revision: provider_snapshot.revision, @@ -467,45 +447,45 @@ impl ReadyBoundary for RemoteReady { else { return Err(unexpected_response("started", &response)); }; - let process = Arc::new(RemoteProcess { + let process = Arc::new(BoundaryProcess { + exec: false, client: self.client.clone(), process_id, }); - Ok(Box::new(RemoteRunning { + Ok(Box::new(RunningBoundary { process, - exec: Arc::new(RemoteExec { + exec: Arc::new(BoundaryExec { client: self.client.clone(), provider_credentials: self.provider_credentials, publication_generation: tokio::sync::Mutex::new(provider_env_generation), }), - loopback_connector: Arc::new(RemoteLoopbackConnector { + loopback_connector: Arc::new(BoundaryLoopbackConnector { client: self.client, }), })) } } -struct RemoteRunning { - process: Arc, - exec: Arc, - loopback_connector: Arc, +pub struct RunningBoundary { + process: Arc, + exec: Arc, + loopback_connector: Arc, } -#[async_trait] -impl RunningBoundary for RemoteRunning { - fn agent(&self) -> Arc { +impl RunningBoundary { + pub fn agent(&self) -> Arc { self.process.clone() } - fn exec(&self) -> Arc { + pub fn exec(&self) -> Arc { self.exec.clone() } - fn loopback_connector(&self) -> Arc { + pub fn loopback_connector(&self) -> Arc { self.loopback_connector.clone() } - async fn terminate(&self) -> Result<(), BackendError> { + pub async fn terminate(&self) -> Result<(), BackendError> { let response = self .process .client @@ -515,18 +495,18 @@ impl RunningBoundary for RemoteRunning { } } -struct RemoteProcess { +pub struct BoundaryProcess { + exec: bool, client: Arc, process_id: String, } -#[async_trait] -impl BoundaryProcess for RemoteProcess { - async fn attach(&self) -> Result { +impl BoundaryProcess { + pub async fn attach(&self) -> Result { open_process_attachment(self.client.clone(), self.process_id.clone()).await } - async fn wait(&self) -> Result { + pub async fn wait(&self) -> Result { let response = self .client .call_wait(Request::Wait { @@ -548,7 +528,7 @@ impl BoundaryProcess for RemoteProcess { Ok(status.into()) } - async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { + pub async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { let response = self .client .call_idempotent(Request::Signal { @@ -559,7 +539,10 @@ impl BoundaryProcess for RemoteProcess { expect_response(response, "signaled") } - async fn terminate(&self) -> Result<(), BackendError> { + pub async fn terminate(&self) -> Result<(), BackendError> { + if self.exec { + return self.signal(BoundarySignal::Kill).await; + } let response = self .client .call_idempotent(Request::Terminate { @@ -596,8 +579,8 @@ async fn open_process_attachment( stderr_pump, None, )); - let terminal: Option> = if has_terminal { - let terminal: Arc = Arc::new(RemoteTerminal { client, process_id }); + let terminal: Option> = if has_terminal { + let terminal: Arc = Arc::new(BoundaryTerminal { client, process_id }); Some(terminal) } else { None @@ -635,7 +618,7 @@ async fn pump_process_responses( } } Ok(Some((STREAM_EXIT, payload))) => { - break serde_json::from_slice::(&payload) + break crate::protobuf::decode::(&payload) .map_or(BoundaryExitStatus::Exited(74), Into::into); } Ok(None | Some(_)) | Err(_) => break BoundaryExitStatus::Exited(74), @@ -646,7 +629,7 @@ async fn pump_process_responses( } } -struct RemoteExec { +pub struct BoundaryExec { client: Arc, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, // Shared by proactive synchronization and exec. Reserve generations before @@ -654,7 +637,7 @@ struct RemoteExec { publication_generation: tokio::sync::Mutex, } -impl RemoteExec { +impl BoundaryExec { async fn synchronize( &self, generation: &mut u64, @@ -666,22 +649,6 @@ impl RemoteExec { .map_err(|error| { BackendError::Process(format!("snapshot provider environment: {error}")) })?; - if !snapshot.files.is_empty() { - let response = self - .client - .call_idempotent(Request::ProbeProviderFiles) - .await - .map_err(|error| { - BackendError::Process(format!( - "provider file capability probe failed: {error}" - )) - })?; - if !matches!(response, Response::ProviderFilesSupported) { - return Err(BackendError::Process( - "sandbox boundary does not support provider files".to_string(), - )); - } - } *generation = generation.checked_add(1).ok_or_else(|| { BackendError::Process("provider environment publication exhausted".to_string()) })?; @@ -726,15 +693,14 @@ impl RemoteExec { } } -#[async_trait] -impl BoundaryExec for RemoteExec { - async fn exec(&self, spec: ExecSpec) -> Result { +impl BoundaryExec { + pub async fn exec(&self, spec: ExecSpec) -> Result { let mut generation = self.publication_generation.lock().await; self.synchronize(&mut generation).await?; open_exec_session(self.client.clone(), spec).await } - async fn synchronize_provider_environment( + pub async fn synchronize_provider_environment( &self, ) -> Result { let mut generation = self.publication_generation.lock().await; @@ -742,13 +708,15 @@ impl BoundaryExec for RemoteExec { } } -struct RemoteLoopbackConnector { +pub struct BoundaryLoopbackConnector { client: Arc, } -#[async_trait] -impl BoundaryLoopbackConnector for RemoteLoopbackConnector { - async fn connect(&self, target: LoopbackTarget) -> Result { +impl BoundaryLoopbackConnector { + pub async fn connect( + &self, + target: LoopbackTarget, + ) -> Result { let (stream, response) = self .client .call_stream(Request::LoopbackConnect { @@ -757,57 +725,19 @@ impl BoundaryLoopbackConnector for RemoteLoopbackConnector { }) .await?; match response { - Response::PortConnected => Ok(stream), - response => Err(unexpected_response("port_connected", &response)), + Response::LoopbackConnected => Ok(stream), + response => Err(unexpected_response("loopback_connected", &response)), } } } -struct RemoteExecProcess { +pub struct BoundaryTerminal { client: Arc, process_id: String, } -#[async_trait] -impl BoundaryProcess for RemoteExecProcess { - async fn attach(&self) -> Result { - open_process_attachment(self.client.clone(), self.process_id.clone()).await - } - - async fn wait(&self) -> Result { - RemoteProcess { - client: self.client.clone(), - process_id: self.process_id.clone(), - } - .wait() - .await - } - - async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { - expect_response( - self.client - .call_idempotent(Request::ExecSignal { - process_id: self.process_id.clone(), - signal: SignalWire::from(signal), - }) - .await?, - "signaled", - ) - } - - async fn terminate(&self) -> Result<(), BackendError> { - self.signal(BoundarySignal::Kill).await - } -} - -struct RemoteTerminal { - client: Arc, - process_id: String, -} - -#[async_trait] -impl BoundaryTerminal for RemoteTerminal { - async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { +impl BoundaryTerminal { + pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { let response = self .client .call_idempotent(Request::Resize { @@ -849,12 +779,13 @@ async fn open_exec_session( Some(output_status_tx), )); - let process: Arc = Arc::new(RemoteExecProcess { + let process: Arc = Arc::new(BoundaryProcess { + exec: true, client: client.clone(), process_id: process_id.clone(), }); - let terminal: Option> = if pty { - Some(Arc::new(RemoteTerminal { client, process_id })) + let terminal: Option> = if pty { + Some(Arc::new(BoundaryTerminal { client, process_id })) } else { None }; @@ -901,13 +832,12 @@ async fn pump_exec_input( /// session, but TCP byte streams use HTTP/2's native multiplexing. Nesting all /// TCP connections inside one application-level writer creates avoidable /// head-of-line blocking during concurrent TLS handshakes. -struct RemoteNetworkMediation { +pub struct NetworkMediationSource { client: Arc, } -#[async_trait] -impl NetworkMediationSource for RemoteNetworkMediation { - async fn accept_tcp(&self) -> Result { +impl NetworkMediationSource { + pub async fn accept_tcp(&self) -> Result { // An idle accept has no deadline. Recover transport loss before exposing // a source error, which permanently stops the proxy. The boundary drops // the interrupted pending open; a retry never replays its decision. @@ -947,7 +877,7 @@ impl NetworkMediationSource for RemoteNetworkMediation { }) } - async fn accept_dns(&self) -> Result { + pub async fn accept_dns(&self) -> Result { loop { let session = self.client.mediation_session().await?; match session.accept_dns().await { @@ -967,7 +897,7 @@ async fn complete_network_open( let decision = completion .await .unwrap_or(TcpOpenDecision::Denied(TcpOpenDenial::MediationUnavailable)); - let Ok(payload) = serde_json::to_vec(&decision) else { + let Ok(payload) = crate::protobuf::encode(&decision) else { return; }; if write_stream_frame( @@ -982,6 +912,15 @@ async fn complete_network_open( } if matches!(decision, TcpOpenDecision::RelayReady) { let _ = tokio::io::copy_bidirectional(&mut boundary, &mut transport).await; + } else { + // The write only queues the decision for the request pump. Dropping the + // response stream now cancels the RPC before that pump can deliver it. + // Close the request direction and wait for the backend to finish. + let _ = tokio::time::timeout(REQUEST_TIMEOUT, async { + boundary.shutdown().await?; + boundary.read_u8().await + }) + .await; } } @@ -1061,7 +1000,7 @@ async fn dispatch_client_mediation_frame( ) -> std::io::Result<()> { match frame.kind { MediationFrameKind::DnsQuery => { - let query: DnsQueryWire = mediation::decode_json(&frame.payload)?; + let query: DnsQueryWire = mediation::decode_message(&frame.payload)?; let (response, completion) = tokio::sync::oneshot::channel::, BackendError>>(); let outbound = outbound.clone(); @@ -1073,7 +1012,7 @@ async fn dispatch_client_mediation_frame( "supervisor dropped the mediated DNS query".to_string(), ), }; - if let Ok(payload) = mediation::encode_json(&response) { + if let Ok(payload) = mediation::encode_message(&response) { let _ = outbound .send(OutboundMediationFrame { kind: MediationFrameKind::DnsResponse, @@ -1121,6 +1060,9 @@ async fn dispatch_client_mediation_frame( struct BoundaryClient { connector: Option>, runtime_descriptor: SandboxRuntimeDescriptor, + backend_name: String, + driver_descriptor: Vec, + boundary_handle: std::sync::OnceLock, supervisor_instance_id: crate::boundary_protocol::SupervisorInstanceId, sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, grpc_channel: tokio::sync::Mutex>, @@ -1278,13 +1220,41 @@ impl BoundaryCredential { } impl BoundaryClient { + async fn discover_policy(&self) -> Result<(Option, bool), BackendError> { + match self.call_idempotent(Request::DiscoverPolicy).await? { + Response::ImagePolicy { yaml, invalid } => Ok((yaml, invalid)), + _ => Err(BackendError::Descriptor( + "expected image policy discovery response".to_string(), + )), + } + } + fn new( runtime_descriptor: SandboxRuntimeDescriptor, sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + ) -> Self { + let payload = + serde_json::to_vec(&runtime_descriptor).expect("serializable runtime descriptor"); + Self::with_backend( + runtime_descriptor, + sandbox_bearer, + crate::BACKEND_NAME.to_string(), + payload, + ) + } + + fn with_backend( + runtime_descriptor: SandboxRuntimeDescriptor, + sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + backend_name: String, + driver_descriptor: Vec, ) -> Self { Self { connector: None, runtime_descriptor, + backend_name, + driver_descriptor, + boundary_handle: std::sync::OnceLock::new(), supervisor_instance_id: crate::boundary_protocol::SupervisorInstanceId::new(), sandbox_bearer, grpc_channel: tokio::sync::Mutex::new(None), @@ -1500,9 +1470,10 @@ impl BoundaryClient { BackendError::Unavailable(format!("flush boundary control request: {error}")) })?; let mut header = [0_u8; 4]; - stream.read_exact(&mut header).await.map_err(|error| { - BackendError::Unavailable(format!("read boundary control response header: {error}")) - })?; + stream + .read_exact(&mut header) + .await + .map_err(control_read_error)?; let declared = u32::from_be_bytes(header) as usize; if declared > MAX_CONTROL_FRAME_BYTES { return Err(BackendError::Process(format!( @@ -1512,9 +1483,10 @@ impl BoundaryClient { let mut frame = Vec::with_capacity(4 + declared); frame.extend_from_slice(&header); frame.resize(4 + declared, 0); - stream.read_exact(&mut frame[4..]).await.map_err(|error| { - BackendError::Unavailable(format!("read boundary control response: {error}")) - })?; + stream + .read_exact(&mut frame[4..]) + .await + .map_err(control_read_error)?; let response: ResponseEnvelope = decode_frame(&frame) .map_err(|error| BackendError::Process(format!("decode control response: {error}")))?; if response.request_id != request_id { @@ -1536,7 +1508,7 @@ impl BoundaryClient { ) -> Result { self.ensure_current_credential_connection().await?; let channel = self.grpc_channel().await?; - open_grpc_client_stream(channel, kind, &self.sandbox_bearer).await + open_grpc_client_stream(channel, kind, &self.sandbox_bearer, self.boundary_handle()?).await } async fn ensure_current_credential_connection(&self) -> Result<(), BackendError> { @@ -1730,7 +1702,7 @@ impl BoundaryClient { confirm: Option<&RequestEnvelope>, credential: &BoundaryCredential, ) -> Result<(tonic::transport::Channel, Arc), BackendError> { - let (channel, transport) = self.build_grpc_channel().await?; + let (channel, transport) = self.build_grpc_channel(credential).await?; let result = async { self.exchange_on_channel(channel.clone(), attach, credential) .await?; @@ -1777,6 +1749,7 @@ impl BoundaryClient { channel, GrpcStreamKind::Exchange, credential.authorization.clone(), + self.boundary_handle()?, ) .await?; let frame = encode_frame(envelope) @@ -1790,8 +1763,9 @@ impl BoundaryClient { let response = crate::boundary_protocol::read_frame_async::<_, ResponseEnvelope>(&mut stream) .await - .map_err(|error| { - BackendError::Unavailable(format!("read boundary control response: {error}")) + .map_err(|error| match error { + crate::boundary_protocol::FrameError::Io(error) => control_read_error(error), + error => BackendError::Descriptor(format!("decode control response: {error}")), })?; if response.request_id != request_id { return Err(BackendError::Process( @@ -1888,7 +1862,7 @@ impl BoundaryClient { return Ok(cached.channel.clone()); } let credential = BoundaryCredential::capture(&self.sandbox_bearer)?; - let (channel, transport) = self.build_grpc_channel().await?; + let (channel, transport) = self.build_grpc_channel(&credential).await?; *state = Some(CachedGrpcChannel { credential_epoch: credential.epoch, bearer_fingerprint: credential.fingerprint, @@ -1903,19 +1877,28 @@ impl BoundaryClient { async fn build_grpc_channel( &self, + credential: &BoundaryCredential, ) -> Result<(tonic::transport::Channel, Arc), BackendError> { let runtime_descriptor = self.runtime_descriptor.clone(); let connector = self.connector.clone(); let transport = Arc::new(TransportAbort::default()); let connector_transport = transport.clone(); - let endpoint = - tonic::transport::Endpoint::from_static("http://boundary.openshell.internal") - .initial_stream_window_size(crate::boundary_protocol::BOUNDARY_STREAM_WINDOW_BYTES) - .initial_connection_window_size( - crate::boundary_protocol::BOUNDARY_CONNECTION_WINDOW_BYTES, - ) - .http2_keep_alive_interval(Duration::from_secs(10)) - .keep_alive_while_idle(true); + let authority = match &runtime_descriptor.transport { + SandboxTransport::Tcp { authority, .. } => authority.as_str(), + SandboxTransport::Unix { .. } | SandboxTransport::Vsock { .. } => { + "boundary.openshell.internal" + } + }; + let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{authority}")) + .map_err(|error| { + BackendError::Descriptor(format!("invalid boundary authority: {error}")) + })? + .initial_stream_window_size(crate::boundary_protocol::BOUNDARY_STREAM_WINDOW_BYTES) + .initial_connection_window_size( + crate::boundary_protocol::BOUNDARY_CONNECTION_WINDOW_BYTES, + ) + .http2_keep_alive_interval(Duration::from_secs(10)) + .keep_alive_while_idle(true); let channel = endpoint .connect_with_connector(tower::service_fn(move |_: tonic::transport::Uri| { let runtime_descriptor = runtime_descriptor.clone(); @@ -1937,9 +1920,84 @@ impl BoundaryClient { .map_err(|error| { BackendError::Unavailable(format!("start boundary gRPC channel: {error}")) })?; + let opening = tokio::time::timeout( + REQUEST_TIMEOUT, + self.open_boundary(channel.clone(), credential), + ) + .await + .unwrap_or_else(|_| { + Err(BackendError::Unavailable( + "boundary opening timed out".to_string(), + )) + }); + if let Err(error) = opening { + transport.abort(); + return Err(error); + } Ok((channel, transport)) } + fn boundary_handle(&self) -> Result<&str, BackendError> { + self.boundary_handle + .get() + .map(String::as_str) + .ok_or_else(|| { + BackendError::Descriptor( + "Sandbox Protocol boundary has not been opened".to_string(), + ) + }) + } + + async fn open_boundary( + &self, + channel: tonic::transport::Channel, + credential: &BoundaryCredential, + ) -> Result<(), BackendError> { + let mut client = IsolationBackendClient::new(channel) + .max_decoding_message_size(64 * 1024) + .max_encoding_message_size(64 * 1024); + let supervisor = crate::isolation_protocol::metadata("openshell/supervisor"); + let response = client + .describe(DescribeIsolationBackendRequest { + supervisor: Some(supervisor.clone()), + }) + .await + .map_err(control_rpc_error)? + .into_inner(); + crate::isolation_protocol::negotiate(&supervisor, response.backend) + .map_err(control_rpc_error)?; + let mut request = tonic::Request::new(OpenBoundaryRequest { + session_id: self.runtime_descriptor.session_id.to_string(), + backend_name: self.backend_name.clone(), + driver_descriptor: self.driver_descriptor.clone(), + }); + request + .metadata_mut() + .insert("authorization", credential.authorization.clone()); + let response = client + .open_boundary(request) + .await + .map_err(control_rpc_error)? + .into_inner(); + if response.outcome != LaunchOutcome::Fresh as i32 + || response.boundary_id.is_empty() + || response.boundary_id.len() > 128 + { + return Err(BackendError::Descriptor( + "invalid fresh boundary opening response".to_string(), + )); + } + let handle = self + .boundary_handle + .get_or_init(|| response.boundary_id.clone()); + if *handle != response.boundary_id { + return Err(BackendError::Denied( + "boundary handle changed during launch recovery".to_string(), + )); + } + Ok(()) + } + #[cfg(test)] async fn connect_boundary_once(&self) -> Result { connect_boundary_once(&self.runtime_descriptor).await @@ -2032,103 +2090,143 @@ enum GrpcStreamKind { Mediate, } +fn control_rpc_error(error: tonic::Status) -> BackendError { + match error.code() { + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { + BackendError::Denied(format!("isolation control authentication: {error}")) + } + tonic::Code::Unimplemented => BackendError::Unsupported(error.to_string()), + tonic::Code::InvalidArgument | tonic::Code::FailedPrecondition => { + BackendError::Descriptor(format!("isolation control contract: {error}")) + } + _ => BackendError::Unavailable(format!("isolation control RPC: {error}")), + } +} + async fn open_grpc_client_stream( channel: tonic::transport::Channel, kind: GrpcStreamKind, sandbox_bearer: &openshell_core::jwt::SessionBearerTokenSlot, + boundary_id: &str, ) -> Result { let authorization = sandbox_bearer.authorization_metadata().map_err(|error| { BackendError::Unavailable(format!("Sandbox Protocol credential unavailable: {error}")) })?; - open_grpc_client_stream_with_authorization(channel, kind, authorization).await + open_grpc_client_stream_with_authorization(channel, kind, authorization, boundary_id).await } async fn open_grpc_client_stream_with_authorization( channel: tonic::transport::Channel, kind: GrpcStreamKind, authorization: tonic::metadata::AsciiMetadataValue, + boundary_id: &str, ) -> Result { - let (application, bridge) = tokio::io::duplex(256 * 1024); - let (reader, writer) = tokio::io::split(bridge); - let (outbound, outbound_rx) = tokio::sync::mpsc::channel::(64); - tokio::spawn(pump_to_grpc(reader, outbound)); - let mut client = IsolationBoundaryClient::new(channel) - .max_decoding_message_size(64 * 1024) - .max_encoding_message_size(64 * 1024); - let mut request = tonic::Request::new(ReceiverStream::new(outbound_rx)); - request - .metadata_mut() - .insert("authorization", authorization); - let response = match kind { - GrpcStreamKind::Exchange => client.exchange(request).await, - GrpcStreamKind::Mediate => client.mediate(request).await, - } - .map_err(|error| match error.code() { + let mut client = SandboxProtocolClient::new(channel) + .max_decoding_message_size(crate::stream_adapter::MAX_MESSAGE_BYTES) + .max_encoding_message_size(crate::stream_adapter::MAX_MESSAGE_BYTES); + let error = |error: tonic::Status| match error.code() { tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { BackendError::Denied(format!("authenticate Sandbox Protocol stream: {error}")) } + tonic::Code::Unimplemented => BackendError::Unsupported(error.to_string()), _ => BackendError::Unavailable(format!("open boundary gRPC stream: {error}")), - })?; - tokio::spawn(pump_from_grpc(response.into_inner(), writer)); - Ok(Box::new(application)) -} - -async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) -where - R: tokio::io::AsyncRead + Unpin, -{ - let mut buffer = vec![0_u8; 16 * 1024]; - loop { - let read = match reader.read(&mut buffer).await { - Ok(read) => read, - Err(error) => { - tracing::debug!(%error, "boundary gRPC request reader ended"); - return; - } - }; - if read == 0 { - return; + }; + match kind { + GrpcStreamKind::Exchange => { + let (application, request) = + grpc_request::(boundary_id, authorization).await?; + let response = client.exchange(request).await.map_err(error)?; + Ok(Box::new(tokio::io::join( + grpc_reader(response.into_inner(), crate::stream_adapter::Mode::Response), + application, + ))) } - if sender - .send(BoundaryChunk { - data: buffer[..read].to_vec(), - }) - .await - .is_err() - { - return; + GrpcStreamKind::Mediate => { + let (application, request) = + grpc_request::(boundary_id, authorization).await?; + let response = client.mediate(request).await.map_err(error)?; + Ok(Box::new(tokio::io::join( + grpc_reader(response.into_inner(), crate::stream_adapter::Mode::Response), + application, + ))) } } } -async fn pump_from_grpc(mut stream: tonic::Streaming, mut writer: W) +async fn grpc_request( + boundary_id: &str, + authorization: tonic::metadata::AsciiMetadataValue, +) -> Result<(tokio::io::DuplexStream, tonic::Request>), BackendError> { + let (application, bridge) = tokio::io::duplex(256 * 1024); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel(64); + outbound + .send(F::bind(boundary_id)) + .await + .map_err(|_| BackendError::Unavailable("queue boundary binding frame".into()))?; + tokio::spawn(pump_to_grpc(bridge, outbound)); + let mut request = tonic::Request::new(ReceiverStream::new(outbound_rx)); + request + .metadata_mut() + .insert("authorization", authorization); + Ok((application, request)) +} + +async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) where - W: tokio::io::AsyncWrite + Unpin, + R: tokio::io::AsyncRead + Unpin + Send, { + let mut mode = crate::stream_adapter::Mode::Request; loop { - match stream.message().await { + match F::send(&mut reader, &mut mode).await { Ok(Some(chunk)) => { - if let Err(error) = writer.write_all(&chunk.data).await { - tracing::debug!(%error, "boundary gRPC response writer ended"); + if sender.send(chunk).await.is_err() { return; } } - Ok(None) => { - let _ = writer.shutdown().await; - return; - } + Ok(None) => return, Err(error) => { - tracing::debug!(%error, "boundary gRPC response stream ended"); - // The request pump still owns the other duplex half. Explicitly - // close this direction so a waiting exchange observes EOF and - // can recover instead of waiting for both halves to drop. - let _ = writer.shutdown().await; + tracing::debug!(%error,"boundary request adapter ended"); return; } } } } +/// Read typed responses directly, preserving conversion failures as `InvalidData`. +/// The caller classifies these as terminal protocol errors rather than EOF. +fn grpc_reader( + stream: tonic::Streaming, + mut mode: crate::stream_adapter::Mode, +) -> impl tokio::io::AsyncRead + Unpin + Send { + use tokio_stream::StreamExt as _; + tokio_util::io::StreamReader::new(stream.map(move |frame| { + let frame = frame.map_err(|status| { + let kind = match status.code() { + tonic::Code::Unimplemented => std::io::ErrorKind::Unsupported, + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { + std::io::ErrorKind::PermissionDenied + } + tonic::Code::InvalidArgument + | tonic::Code::FailedPrecondition + | tonic::Code::OutOfRange => std::io::ErrorKind::InvalidData, + _ => std::io::ErrorKind::Other, + }; + std::io::Error::new(kind, status) + })?; + frame.receive(&mut mode).map(bytes::Bytes::from) + })) +} + +fn control_read_error(error: std::io::Error) -> BackendError { + let message = format!("read boundary control response: {error}"); + match error.kind() { + std::io::ErrorKind::InvalidData => BackendError::Descriptor(message), + std::io::ErrorKind::Unsupported => BackendError::Unsupported(message), + std::io::ErrorKind::PermissionDenied => BackendError::Denied(message), + _ => BackendError::Unavailable(message), + } +} + fn enable_boundary_tcp_keepalive(stream: &tokio::net::TcpStream) { let keepalive = socket2::TcpKeepalive::new() .with_time(Duration::from_secs(30)) @@ -2196,6 +2294,7 @@ fn guest_error(kind: crate::boundary_protocol::BoundaryErrorKind, message: Strin use crate::boundary_protocol::BoundaryErrorKind; let message = format!("boundary process leaf: {message}"); match kind { + BoundaryErrorKind::Unsupported => BackendError::Unsupported(message), BoundaryErrorKind::Invalid => BackendError::Descriptor(message), BoundaryErrorKind::Denied => BackendError::Denied(message), BoundaryErrorKind::Unavailable => BackendError::Unavailable(message), @@ -2208,38 +2307,283 @@ fn is_transport_unavailable(message: &str) -> bool { !message.starts_with("boundary process leaf:") } +pub struct ConfirmedBoundary { + boundary: Box, + confirmation: BoundaryConfirmation, +} +impl ConfirmedBoundary { + /// Construct confirmation after checking backend-neutral properties and + /// immutable identity binding. + /// + /// Validate common evidence before exposing the launch-capable RPC handle. + /// + /// # Errors + /// + /// Returns an error if confirmation is incomplete or the identity does not match. + fn try_new( + boundary: Box, + confirmation: BoundaryConfirmation, + expected: &ResolvedWorkloadIdentity, + ) -> Result { + confirmation.validate(expected)?; + Ok(Self { + boundary, + confirmation, + }) + } + + /// Return the record carried by this confirmed state. + pub fn confirmation(&self) -> &BoundaryConfirmation { + &self.confirmation + } + + /// Consume confirmation and advance to the sole launch-capable state. + pub fn into_boundary(self) -> Box { + self.boundary + } +} +pub struct ProcessAttachment { + /// Stdin writer. + pub stdin: BoundaryInput, + /// Stdout reader, or the PTY-merged output stream. + pub stdout: BoundaryOutput, + /// Stderr reader, distinct from stdout for non-PTY processes. + pub stderr: Option, + /// PTY control, present when the admitted process owns a terminal. + pub terminal: Option>, +} + +pub struct ExecSession { + /// The spawned process. + pub process: Arc, + /// Stdin writer, if not a PTY-merged stream. + pub stdin: Option, + /// Stdout reader. + pub stdout: BoundaryOutput, + /// Stderr reader, distinct from stdout for non-PTY exec. + pub stderr: Option, + /// PTY control, present when a terminal was requested. + pub terminal: Option>, + /// Final status of the attached output stream, including delivery failure. + /// The process handle's stable wait status remains independent of attachment. + pub output_status: Option>, +} + +#[cfg(feature = "test-support")] +pub mod test_support; + #[cfg(test)] mod tests { mod credential_renewal; + mod delegated; mod flow_control; mod network_recovery; + mod protocol_errors; + use crate::isolation_protocol::exchange_frame; use std::path::PathBuf; use std::pin::Pin; use std::task::{Context, Poll}; use super::*; + use crate::backend_proto::{ + DescribeIsolationBackendResponse, OpenBoundaryResponse, ResumeBoundaryRequest, + SuspendBoundaryRequest, SuspendBoundaryResponse, + isolation_backend_server::{IsolationBackend as ControlBackend, IsolationBackendServer}, + }; use crate::boundary_protocol::{ExitStatusWire, generate_sandbox_tls_material}; use crate::proto::{ - BoundaryChunk, - isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, + ExchangeFrame, + sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use openshell_core::jwt::{SecretJwt, SessionBearerTokenSlot}; use openshell_core::policy::{ FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; - fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { - use openshell_isolation_interface::contract::OuterFenceGuarantee; + const TEST_BOUNDARY_HANDLE: &str = "opaque-test-boundary"; + + #[derive(Clone)] + struct TestIsolationBackend { + metadata: Option, + outcome: i32, + opens: Arc, + backend_name: String, + driver_descriptor: Option>, + } + + impl Default for TestIsolationBackend { + fn default() -> Self { + Self { + metadata: Some(crate::isolation_protocol::metadata("test/backend")), + outcome: LaunchOutcome::Fresh as i32, + opens: Arc::default(), + backend_name: crate::BACKEND_NAME.to_string(), + driver_descriptor: None, + } + } + } + + #[tonic::async_trait] + impl ControlBackend for TestIsolationBackend { + async fn describe( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + assert!( + request.metadata().get("authorization").is_none(), + "capability request must not convey sandbox credentials" + ); + assert!(request.into_inner().supervisor.is_some()); + Ok(tonic::Response::new(DescribeIsolationBackendResponse { + backend: self.metadata.clone(), + })) + } + + async fn open_boundary( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + assert!(request.metadata().get("authorization").is_some()); + let request = request.into_inner(); + assert_eq!(request.backend_name, self.backend_name); + if let Some(expected) = &self.driver_descriptor { + assert_eq!( + &request.driver_descriptor, expected, + "opaque payload must be forwarded unchanged" + ); + } + assert!(!request.driver_descriptor.is_empty()); + assert!(!request.session_id.is_empty()); + self.opens.fetch_add(1, Ordering::AcqRel); + Ok(tonic::Response::new(OpenBoundaryResponse { + boundary_id: TEST_BOUNDARY_HANDLE.to_string(), + outcome: self.outcome, + })) + } + + async fn suspend_boundary( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("test backend")) + } + + async fn resume_boundary( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("test backend")) + } + } - openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + async fn test_bind(inbound: &mut tonic::Streaming) { + let frame = inbound.message().await.unwrap().unwrap(); + assert_eq!(frame.boundary_id(), Some(TEST_BOUNDARY_HANDLE)); + } + + #[tokio::test] + async fn incompatible_isolation_contract_never_opens_a_boundary() { + let mut wrong_major = crate::isolation_protocol::metadata("test/backend"); + wrong_major.protocol_version.as_mut().unwrap().major += 1; + let mut missing_contract = crate::isolation_protocol::metadata("test/backend"); + missing_contract + .supported_capabilities + .retain(|capability| { + capability != crate::isolation_protocol::SANDBOX_PROTOCOL_CAPABILITY + }); + let mut unsupported_requirement = crate::isolation_protocol::metadata("test/backend"); + unsupported_requirement + .required_capabilities + .push("openshell.sandbox.future-required".to_string()); + for metadata in [ + None, + Some(wrong_major), + Some(missing_contract), + Some(unsupported_requirement), + ] { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let service = TestIsolationBackend { + metadata, + ..TestIsolationBackend::default() + }; + let opens = service.opens.clone(); + let client_tls = certificate.client_tls.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let stream = tokio_rustls::TlsAcceptor::from(certificate.server_config) + .accept(stream) + .await + .unwrap(); + tonic::transport::Server::builder() + .add_service(IsolationBackendServer::new(service)) + .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( + Box::new(stream), + ))])) + .await + .unwrap(); + }); + let result = OpenShellRuntimeBackend::discover_policy( + tls_runtime_descriptor(address, client_tls), + test_bearer(&"a".repeat(32)), + ) + .await; + assert!( + matches!(result, Err(BackendError::Descriptor(_))), + "{result:?}" + ); + assert_eq!(opens.load(Ordering::Acquire), 0); + server.abort(); + } + } + + #[tokio::test] + async fn fresh_launch_rejects_unspecified_restored_and_fallback_outcomes() { + for outcome in [ + LaunchOutcome::Unspecified, + LaunchOutcome::Restored, + LaunchOutcome::ColdStartFallback, + ] { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client_tls = certificate.client_tls.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let stream = tokio_rustls::TlsAcceptor::from(certificate.server_config) + .accept(stream) + .await + .unwrap(); + tonic::transport::Server::builder() + .add_service(IsolationBackendServer::new(TestIsolationBackend { + outcome: outcome as i32, + ..TestIsolationBackend::default() + })) + .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( + Box::new(stream), + ))])) + .await + .unwrap(); + }); + let result = OpenShellRuntimeBackend::discover_policy( + tls_runtime_descriptor(address, client_tls), + test_bearer(&"a".repeat(32)), + ) + .await; + assert!( + matches!(result, Err(BackendError::Descriptor(_))), + "{result:?}" + ); + server.abort(); + } + } + + fn test_provisioning_evidence() -> openshell_isolation_interface::contract::ProvisioningEvidence + { + openshell_isolation_interface::contract::ProvisioningEvidence::from_enforcement_evidence( "test-generation", - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], b"test-vm-fence", ) .unwrap() @@ -2266,22 +2610,17 @@ mod tests { mediation_failures: Arc, mediation_ready: bool, provider_environment_generation: u64, - confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + confirmation: BoundaryConfirmation, } - type TestGrpcStream = Pin< - Box> + Send + 'static>, - >; - - #[tonic::async_trait] - impl IsolationBoundary for TestGrpcBoundary { - type ExchangeStream = TestGrpcStream; - type MediateStream = TestGrpcStream; + type TestGrpcStream = + Pin> + Send + 'static>>; - async fn exchange( + impl TestGrpcBoundary { + async fn serve( &self, - request: tonic::Request>, - ) -> Result, tonic::Status> { + request: tonic::Request>, + ) -> Result>, tonic::Status> { let authorization = request .metadata() .get("authorization") @@ -2292,6 +2631,7 @@ mod tests { )); } let mut inbound = request.into_inner(); + test_bind(&mut inbound).await; let wait_for_half_close = self.wait_for_half_close; let requests = self.requests.clone(); let mediation_ready = self.mediation_ready; @@ -2303,7 +2643,11 @@ mod tests { loop { match inbound.message().await { Ok(Some(chunk)) => { - frame.extend_from_slice(&chunk.data); + frame.extend_from_slice( + &chunk + .receive(&mut crate::stream_adapter::Mode::Request) + .expect("request frame"), + ); if !wait_for_half_close && complete_control_frame(&frame) { break; } @@ -2342,7 +2686,6 @@ mod tests { Request::Confirm => Response::Confirmed { confirmation: Box::new(confirmation), }, - Request::ProbeProviderFiles => Response::ProviderFilesSupported, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { kind: crate::boundary_protocol::BoundaryErrorKind::Denied, @@ -2358,9 +2701,7 @@ mod tests { Request::AttachProcess { .. } => { Response::ProcessAttached { terminal: false } } - Request::Signal { .. } | Request::ExecSignal { .. } => { - Response::Signaled - } + Request::Signal { .. } => Response::Signaled, Request::Terminate { .. } => Response::Terminated, Request::TerminateBoundary => Response::BoundaryTerminated, Request::UpdateProviderEnvironment { @@ -2373,7 +2714,7 @@ mod tests { applied: generation > provider_environment_generation, }, Request::Resize { .. } => Response::Resized, - Request::LoopbackConnect { .. } => Response::PortConnected, + Request::LoopbackConnect { .. } => Response::LoopbackConnected, Request::StartAgent { provider_env_revision, .. @@ -2399,16 +2740,36 @@ mod tests { } else { b"complete response".to_vec() }; - let _ = outbound.send(Ok(BoundaryChunk { data: response })).await; + let frame = F::send( + &mut response.as_slice(), + &mut crate::stream_adapter::Mode::Response, + ) + .await + .unwrap() + .unwrap(); + let _ = outbound.send(Ok(frame)).await; }); Ok(tonic::Response::new(Box::pin(ReceiverStream::new( outbound_rx, )))) } + } + + #[tonic::async_trait] + impl SandboxProtocol for TestGrpcBoundary { + type ExchangeStream = TestGrpcStream; + type MediateStream = TestGrpcStream; + + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + self.serve(request).await + } async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if self .mediation_failures @@ -2421,10 +2782,15 @@ mod tests { "injected mediation transport failure", )); } - self.exchange(request).await + self.serve(request).await } } + fn test_response_chunk(bytes: Vec) -> ExchangeFrame { + let response: ResponseEnvelope = decode_frame(&bytes).unwrap(); + crate::isolation_protocol::response_chunk(&response).unwrap() + } + fn complete_control_frame(frame: &[u8]) -> bool { frame.len() >= 4 && frame.len() @@ -2451,7 +2817,8 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await .unwrap(); @@ -2465,6 +2832,13 @@ mod tests { tls_runtime_descriptor(address, test_certificate().client_tls), test_bearer(&"a".repeat(32)), ); + client + .open_boundary( + channel.clone(), + &BoundaryCredential::capture(&client.sandbox_bearer).unwrap(), + ) + .await + .unwrap(); *client.grpc_channel.lock().await = Some(CachedGrpcChannel { credential_epoch: openshell_core::jwt::CredentialEpoch::new(1).expect("test epoch"), bearer_fingerprint: BoundaryCredential::capture(&client.sandbox_bearer) @@ -2516,7 +2890,8 @@ mod tests { }; tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -2532,7 +2907,6 @@ mod tests { let attach = Request::Attach { supervisor_instance_id: client.supervisor_instance_id, policy: Box::new(SandboxPolicyWire::from(sandbox().policy)), - resource_claims: std::collections::BTreeMap::new(), }; assert!(matches!( client.call_idempotent(attach).await.unwrap(), @@ -2627,7 +3001,8 @@ mod tests { }; tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -2643,7 +3018,6 @@ mod tests { let attach = Request::Attach { supervisor_instance_id: client.supervisor_instance_id, policy: Box::new(SandboxPolicyWire::from(sandbox().policy)), - resource_claims: std::collections::BTreeMap::new(), }; assert!(matches!( client.call_idempotent(attach).await.unwrap(), @@ -2683,7 +3057,8 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await .unwrap(); @@ -2697,14 +3072,23 @@ mod tests { channel, GrpcStreamKind::Exchange, &test_bearer(&"a".repeat(32)), + TEST_BOUNDARY_HANDLE, ) .await .unwrap(); - stream.write_all(b"finite request").await.unwrap(); + let request = RequestEnvelope::new(Request::DiscoverPolicy).unwrap(); + stream + .write_all(&encode_frame(&request).unwrap()) + .await + .unwrap(); stream.shutdown().await.unwrap(); - let mut response = [0_u8; 17]; - stream.read_exact(&mut response).await.unwrap(); - assert_eq!(&response, b"complete response"); + let response: ResponseEnvelope = crate::boundary_protocol::read_frame_async(&mut stream) + .await + .unwrap(); + assert!(matches!( + response.response, + Response::ImagePolicy { invalid: false, .. } + )); drop(stream); server.abort(); } @@ -2731,7 +3115,7 @@ mod tests { &mut server_stream, MediationFrameKind::DnsQuery, 42, - &mediation::encode_json(&query).unwrap(), + &mediation::encode_message(&query).unwrap(), ) .await .unwrap(); @@ -2742,7 +3126,7 @@ mod tests { assert_eq!(reply.kind, MediationFrameKind::DnsResponse); assert_eq!(reply.stream_id, 42); assert_eq!( - mediation::decode_json::(&reply.payload).unwrap(), + mediation::decode_message::(&reply.payload).unwrap(), DnsQueryResultWire::Response(vec![4, 5, 6]) ); }); @@ -2808,7 +3192,7 @@ mod tests { tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), } } @@ -2858,7 +3242,7 @@ mod tests { async fn serve_test_grpc_with_confirmation( stream: BoundaryDuplexStream, expected_token: String, - confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + confirmation: BoundaryConfirmation, ) { let service = TestGrpcBoundary { wait_for_half_close: false, @@ -2870,7 +3254,8 @@ mod tests { confirmation, }; tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( stream, ))])) @@ -2896,7 +3281,7 @@ mod tests { timeout_secs: 5, interactive: false, }, - identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + identity: ResolvedWorkloadIdentity::new( 10_001, 10_001, Vec::new(), @@ -2907,7 +3292,7 @@ mod tests { } } - fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { + fn test_confirmation() -> BoundaryConfirmation { let audit = crate::boundary_protocol::NativeLinuxSandboxAuditEvidence { capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, @@ -2939,22 +3324,17 @@ mod tests { tcp_deny_round_trip: true, socket_loopback_confinement: true, }; - openshell_isolation_interface::contract::BoundaryConfirmation { + BoundaryConfirmation { generation: "test-generation".to_string(), identity: sandbox().identity, - properties: audit.properties(), - authenticated_supervisor: true, session_id: test_session_id(), - outer_fence: test_outer_fence(), - runtime_exit_terminates_workload: true, + provisioning_evidence_digest: test_provisioning_evidence().evidence_digest, resource_claims: std::collections::BTreeMap::new(), backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), } } - async fn assert_remote_confirmation_rejected( - confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, - ) { + async fn assert_remote_confirmation_rejected(confirmation: BoundaryConfirmation) { let certificate = test_certificate(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -2973,19 +3353,17 @@ mod tests { }); let descriptor = tls_runtime_descriptor(address, certificate.client_tls); - let outer_fence = descriptor.outer_fence.clone(); + let provisioning_evidence = descriptor.provisioning_evidence.clone(); let context = sandbox(); let client = Arc::new(BoundaryClient::new( descriptor, test_bearer(&expected_token), )); - let bound = RemoteBound { - audit_validator: Arc::new(crate::audit::LinuxBoundaryAuditValidator), + let bound = BoundBoundary { client: client.clone(), agent: context.agent, - policy: context.policy, sandbox_id: context.sandbox_id, - mediation: Arc::new(RemoteNetworkMediation { + mediation: Arc::new(NetworkMediationSource { client: client.clone(), }), host_gateway_ip: None, @@ -3001,7 +3379,7 @@ mod tests { generation: "test-generation".to_string(), session_id: test_session_id(), resource_claims: std::collections::BTreeMap::new(), - outer_fence, + provisioning_evidence, }; assert!(matches!( @@ -3027,32 +3405,15 @@ mod tests { } #[tokio::test] - async fn remote_confirm_rejects_property_projection_mismatch_before_monitoring() { - let mut confirmation = test_confirmation(); - confirmation.properties.egress_interception.mechanism = "untrusted projection".to_string(); - - assert_remote_confirmation_rejected(confirmation).await; - } - - #[tokio::test] - async fn remote_confirm_rejects_outer_fence_generation_mismatch_before_monitoring() { - let mut confirmation = test_confirmation(); - confirmation.outer_fence.generation = "other-generation".to_string(); - - assert_remote_confirmation_rejected(confirmation).await; - } - - #[tokio::test] - async fn remote_confirm_rejects_outer_fence_digest_mismatch_before_monitoring() { + async fn remote_confirm_rejects_provisioning_evidence_mismatch_before_monitoring() { let mut confirmation = test_confirmation(); let different_fence = - openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + openshell_isolation_interface::contract::ProvisioningEvidence::from_enforcement_evidence( "test-generation", - confirmation.outer_fence.established.iter().copied(), b"different evidence", ) .expect("construct different test evidence"); - confirmation.outer_fence.evidence_digest = different_fence.evidence_digest; + confirmation.provisioning_evidence_digest = different_fence.evidence_digest; assert_remote_confirmation_rejected(confirmation).await; } @@ -3071,7 +3432,7 @@ mod tests { tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), }; let debug = format!("{runtime_descriptor:?}"); assert!(debug.contains("")); @@ -3091,7 +3452,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -3113,7 +3474,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -3135,7 +3496,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), }; validate_runtime_descriptor(&runtime_descriptor, &sandbox()) .expect("TCP runtime descriptor should be valid"); @@ -3336,7 +3697,7 @@ mod tests { write_stream_frame(&mut peer, STREAM_STDOUT, b"hello") .await .unwrap(); - let status = serde_json::to_vec(&ExitStatusWire::Exited(74)).unwrap(); + let status = crate::protobuf::encode(&ExitStatusWire::Exited(74)).unwrap(); write_stream_frame(&mut peer, STREAM_EXIT, &status) .await .unwrap(); @@ -3421,7 +3782,8 @@ mod tests { let service = service.clone(); tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -3443,7 +3805,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), }, test_bearer(&"a".repeat(32)), )); @@ -3489,7 +3851,8 @@ mod tests { .await .unwrap(); tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( Box::new(stream), ))])) @@ -3502,13 +3865,12 @@ mod tests { HashMap::new(), ); let context = sandbox(); - let ready = Box::new(RemoteReady { + let ready = Box::new(ReadyBoundary { client: Arc::new(BoundaryClient::new( tls_runtime_descriptor(address, certificate.client_tls), test_bearer(&"a".repeat(32)), )), agent: context.agent, - policy: context.policy, sandbox_id: context.sandbox_id, ca_file_paths: Arc::new(std::sync::Mutex::new(None)), provider_credentials: credentials.clone(), @@ -3547,7 +3909,7 @@ mod tests { tls_runtime_descriptor(address, certificate.client_tls), test_bearer(&"a".repeat(32)), )); - let exec = RemoteExec { + let exec = BoundaryExec { client, provider_credentials: credentials.clone(), publication_generation: tokio::sync::Mutex::new(0), @@ -3588,7 +3950,6 @@ mod tests { provider_files: HashMap::new(), sandbox_id: context.sandbox_id, spec: AgentSpecWire::from(context.agent), - policy: Box::new(SandboxPolicyWire::from(context.policy)), ca_cert: Some("c".repeat(16 * 1024)), ca_bundle: Some("b".repeat(256 * 1024)), provider_env_revision: 0, @@ -3636,7 +3997,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), }, test_bearer(&"a".repeat(32)), ); @@ -3648,7 +4009,6 @@ mod tests { provider_files: HashMap::new(), sandbox_id: context.sandbox_id, spec: AgentSpecWire::from(context.agent), - policy: Box::new(SandboxPolicyWire::from(context.policy)), ca_cert: Some("c".repeat(16 * 1024)), ca_bundle: Some("b".repeat(256 * 1024)), provider_env_revision: 0, diff --git a/crates/openshell-sandbox-backend/src/runtime/test_support.rs b/crates/openshell-sandbox-backend/src/runtime/test_support.rs new file mode 100644 index 0000000000..464bcc83bf --- /dev/null +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -0,0 +1,909 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Controlled peers for tests of the real RPC client. Not a backend extension API. + +use crate::boundary_protocol::{self as w, Request, RequestEnvelope, Response, ResponseEnvelope}; +use crate::proto::{ + self as p, + exchange_frame::Payload, + sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, +}; +use crate::protobuf::WireMessage as _; +use crate::runtime::BoundaryClient; +use crate::stream_adapter::{Mode, ProtocolFrame}; +use async_trait::async_trait; +use openshell_core::jwt::{CredentialEpoch, SecretJwt, SessionBearerTokenSlot}; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_isolation_interface::contract::{ + BackendError, BackendErrorKind, BoundaryDuplexStream, BoundaryExitStatus, BoundaryInput, + BoundaryOutput, BoundarySignal, ExecSpec, LoopbackTarget, PendingDnsQuery, PendingTcpOpen, + ProviderEnvironmentInstallation, ProvisioningEvidence, ResolvedWorkloadIdentity, + SandboxContext, TcpOpenDecision, +}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; +use tokio_stream::{StreamExt as _, wrappers::ReceiverStream}; + +#[async_trait] +pub trait BoundaryProcess: Send + Sync { + /// Attach to the admitted process's retained standard I/O. The boundary + /// remains the process owner and may permit only one control attachment. + async fn attach(&self) -> Result { + Err(BackendError::Unsupported( + "process attachment is not supported".to_string(), + )) + } + /// Await terminal status (stable across repeated calls). + async fn wait(&self) -> Result; + /// Deliver a signal to the process or its group. + async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError>; + /// Terminate the process and its backend-owned process group. + async fn terminate(&self) -> Result<(), BackendError>; +} + +pub use crate::ProcessAttachment; + +#[async_trait] +pub trait BoundaryTerminal: Send + Sync { + /// Resize the terminal. + async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError>; +} + +pub struct ExecSession { + pub process: Arc, + pub stdin: Option, + pub stdout: BoundaryOutput, + pub stderr: Option, + pub terminal: Option>, + pub output_status: Option>, +} + +#[async_trait] +pub trait BoundaryExec: Send + Sync { + /// Spawn `spec` inside the boundary, returning an owned session. + async fn exec(&self, spec: ExecSpec) -> Result; + + /// Install the current provider environment for future process launches. + /// + /// Success requires an authenticated acknowledgment from the running + /// boundary. Implementations serialize this operation with exec so an + /// older publication cannot replace the acknowledged environment. + async fn synchronize_provider_environment( + &self, + ) -> Result { + Err(BackendError::Unsupported( + "provider environment installation acknowledgment is unavailable".to_string(), + )) + } +} + +#[async_trait] +pub trait BoundaryLoopbackConnector: Send + Sync { + /// Connect to `target` inside the boundary. + async fn connect(&self, target: LoopbackTarget) -> Result; +} + +#[async_trait] +pub trait NetworkMediationSource: Send + Sync { + /// Await the next staged workload TCP open. + async fn accept_tcp(&self) -> Result; + + /// Await the next workload DNS query. + async fn accept_dns(&self) -> Result; +} + +type Handler = Arc Result + Send + Sync>; + +#[derive(Default)] +struct Peer { + handler: Mutex>, + process: Option>, + exec: Option>, + loopback: Option>, + network: Option>, + terminal: Option>, + processes: Mutex>>, + terminals: Mutex>>, +} + +type Stream = + Pin> + Send>>; +type Sender = mpsc::Sender>; + +fn chunk(payload: Payload) -> p::ExchangeFrame { + p::ExchangeFrame { + payload: Some(payload), + } +} +async fn send(out: &Sender, payload: Payload) { + let _ = out.send(Ok(chunk(payload))).await; +} +fn unsupported() -> BackendError { + BackendError::Unsupported("test peer operation".into()) +} +impl Peer { + fn process(&self, id: &str) -> Result, BackendError> { + self.processes + .lock() + .unwrap() + .get(id) + .cloned() + .or_else(|| self.process.clone()) + .ok_or_else(unsupported) + } + async fn exchange( + self: Arc, + mut input: tonic::Streaming, + out: Sender, + request: RequestEnvelope, + ) -> Result<(), BackendError> { + let id = request.request_id; + let mut io = None; + let mut network = None; + let mut relay = None; + let handler = self.handler.lock().unwrap().clone(); + if let Some(handler) = handler { + if matches!(request.request, Request::AcceptNetwork) { + drop(handler); + return std::future::pending().await; + } + let response = handler(request.request).unwrap_or_else(|e| Response::Error { + kind: match e.kind() { + BackendErrorKind::Unsupported => w::BoundaryErrorKind::Unsupported, + BackendErrorKind::Denied => w::BoundaryErrorKind::Denied, + _ => w::BoundaryErrorKind::Process, + }, + message: e.to_string(), + }); + send( + &out, + Payload::Response( + ResponseEnvelope { + request_id: id, + response, + } + .to_proto() + .unwrap(), + ), + ) + .await; + return Ok(()); + } + let response = match request.request { + Request::Wait { process_id } => Response::Exited { + status: self.process(&process_id)?.wait().await?.into(), + }, + Request::Signal { + process_id, + signal: w::SignalWire::Kill, + } => { + self.process(&process_id)?.terminate().await?; + Response::Signaled + } + Request::Signal { process_id, signal } => { + self.process(&process_id)?.signal(signal.into()).await?; + Response::Signaled + } + Request::Terminate { process_id } => { + self.process(&process_id)?.terminate().await?; + Response::Terminated + } + Request::Resize { + process_id, + cols, + rows, + } => { + let terminal = self + .terminals + .lock() + .unwrap() + .get(&process_id) + .cloned() + .or_else(|| self.terminal.clone()) + .ok_or_else(unsupported)?; + terminal.resize(cols, rows).await?; + Response::Resized + } + Request::AttachProcess { process_id } => { + let process = self.process(&process_id)?; + let a = process.attach().await?; + let terminal = a.terminal.is_some(); + if let Some(t) = a.terminal { + self.terminals.lock().unwrap().insert(process_id, t); + } + io = Some((Some(a.stdin), a.stdout, a.stderr, process, None)); + Response::ProcessAttached { terminal } + } + Request::UpdateProviderEnvironment { + revision, + generation, + .. + } => { + // Exercise the peer's acknowledgment hook when a test controls it. + if let Some(exec) = &self.exec { + match exec.synchronize_provider_environment().await { + Ok(_) | Err(BackendError::Unsupported(_)) => (), + Err(e) => return Err(e), + } + } + Response::ProviderEnvironmentUpdated { + revision, + generation, + applied: true, + } + } + Request::Exec { spec } => { + let a = self + .exec + .as_ref() + .ok_or_else(unsupported)? + .exec(spec.into()) + .await?; + let process_id = uuid::Uuid::new_v4().to_string(); + let pty = a.terminal.is_some(); + self.processes + .lock() + .unwrap() + .insert(process_id.clone(), a.process.clone()); + if let Some(t) = a.terminal { + self.terminals.lock().unwrap().insert(process_id.clone(), t); + } + io = Some((a.stdin, a.stdout, a.stderr, a.process, a.output_status)); + Response::ExecStarted { process_id, pty } + } + Request::LoopbackConnect { host, port } => { + relay = Some( + self.loopback + .as_ref() + .ok_or_else(unsupported)? + .connect(LoopbackTarget::new(host, port)?) + .await?, + ); + Response::LoopbackConnected + } + Request::AcceptNetwork => { + let open = self + .network + .as_ref() + .ok_or_else(unsupported)? + .accept_tcp() + .await?; + let response = Response::NetworkConnected { + identity: open.binary_identity.clone().into(), + destination: open.destination, + socket: open.socket, + policy_generation: open.policy_generation, + timing: w::MediationTimingWire::default(), + }; + network = Some(open); + response + } + _ => return Err(unsupported()), + }; + send( + &out, + Payload::Response( + ResponseEnvelope { + request_id: id, + response, + } + .to_proto() + .unwrap(), + ), + ) + .await; + if let Some((stdin, stdout, stderr, process, completion)) = io { + process_io(input, out, stdin, stdout, stderr, process, completion).await; + } else if let Some(open) = network { + if let Some(Payload::TcpOpenDecision(decision)) = + input.message().await.ok().flatten().and_then(|c| c.payload) + { + let decision = TcpOpenDecision::from_proto(decision).unwrap(); + let _ = open.decision.send(decision); + if decision == TcpOpenDecision::RelayReady { + relay_io(input, out, open.stream).await; + } + } + } else if let Some(stream) = relay { + relay_io(input, out, stream).await; + } + Ok(()) + } +} +#[derive(Clone)] +struct Service(Arc); +#[tonic::async_trait] +impl SandboxProtocol for Service { + type ExchangeStream = Stream; + type MediateStream = Stream; + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let (out, rx) = mpsc::channel(8); + let peer = self.0.clone(); + tokio::spawn(async move { + let mut input = request.into_inner(); + let request = match read_request(&mut input).await { + Ok(r) => r, + Err(e) => { + let _ = out.send(Err(e)).await; + return; + } + }; + let id = request.request_id.clone(); + tokio::select! { + ()=out.closed()=>(), + result=peer.exchange(input,out.clone(),request)=>{ + if let Err(e)=result {send_response(&out,id,error_response(e)).await;} + } + } + }); + Ok(tonic::Response::new(Box::pin(ReceiverStream::new(rx)))) + } + async fn mediate( + &self, + request: tonic::Request>, + ) -> Result>, tonic::Status> { + let (out, rx) = mpsc::channel(8); + let source = self.0.network.clone(); + tokio::spawn(async move { + let mut input = request.into_inner(); + let request = match read_request(&mut input).await { + Ok(r) => r, + Err(e) => { + let _ = out.send(Err(e)).await; + return; + } + }; + let id = request.request_id; + let Some(source) = source else { + send_response(&out, id, error_response(unsupported())).await; + return; + }; + let first = match source.accept_dns().await { + Ok(q) => q, + Err(e) => { + send_response(&out, id, error_response(e)).await; + return; + } + }; + send_response(&out, id, Response::MediationReady).await; + let mut pending = HashMap::new(); + let mut stream_id = 1; + let mut next = Some(first); + loop { + if let Some(query) = next.take() { + let message = crate::mediation::DnsQueryWire { + request: query.message, + transport: query.transport, + identity: query.binary_identity.into(), + timing: w::MediationTimingWire::default(), + }; + let mut wire = message.to_proto().unwrap(); + wire.stream_id = stream_id; + pending.insert(stream_id, query.response); + if out + .send(Ok(p::MediateFrame { + payload: Some(p::mediate_frame::Payload::DnsQuery(wire)), + })) + .await + .is_err() + { + break; + } + stream_id += 1; + } + tokio::select! { + ()=out.closed()=>break, + query=source.accept_dns(), if pending.len()<32=>{ + match query {Ok(q)=>next=Some(q),Err(_)=>break} + }, + response=input.message()=>{ + if let Ok(Some(p::MediateFrame{payload:Some(p::mediate_frame::Payload::DnsResponse(r))}))=response { + if let Some(sender)=pending.remove(&r.stream_id){ + let result=match r.result {Some(p::dns_response::Result::Response(v))=>Ok(v),_=>Err(unsupported())}; + let _=sender.send(result); + } + }else{break;} + } + } + } + }); + Ok(tonic::Response::new(Box::pin(ReceiverStream::new(rx)))) + } +} +async fn read_request( + input: &mut tonic::Streaming, +) -> Result { + let first = input + .message() + .await? + .ok_or_else(|| tonic::Status::invalid_argument("missing boundary binding"))?; + assert!(first.boundary_id().is_some()); + let request = input + .message() + .await? + .ok_or_else(|| tonic::Status::invalid_argument("missing request"))?; + let bytes = request + .receive(&mut Mode::Request) + .map_err(|e| tonic::Status::invalid_argument(e.to_string()))?; + w::decode_frame(&bytes).map_err(|e| tonic::Status::invalid_argument(e.to_string())) +} +fn error_response(e: BackendError) -> Response { + Response::Error { + kind: match e.kind() { + BackendErrorKind::Unsupported => w::BoundaryErrorKind::Unsupported, + BackendErrorKind::Unavailable => w::BoundaryErrorKind::Unavailable, + BackendErrorKind::Terminated => w::BoundaryErrorKind::Terminated, + BackendErrorKind::Denied => w::BoundaryErrorKind::Denied, + _ => w::BoundaryErrorKind::Process, + }, + message: e.to_string(), + } +} +async fn send_response(out: &Sender, id: String, response: Response) { + let bytes = w::encode_frame(&ResponseEnvelope { + request_id: id, + response, + }) + .unwrap(); + let frame = F::send(&mut bytes.as_slice(), &mut Mode::Response) + .await + .unwrap() + .unwrap(); + let _ = out.send(Ok(frame)).await; +} + +async fn output(mut reader: BoundaryOutput, out: Sender, stderr: bool) { + let mut b = vec![0; 16384]; + while let Ok(n) = reader.read(&mut b).await { + if n == 0 { + break; + } + if out + .send(Ok(chunk(if stderr { + Payload::Stderr(b[..n].to_vec()) + } else { + Payload::Stdout(b[..n].to_vec()) + }))) + .await + .is_err() + { + break; + } + } +} +async fn process_io( + mut input: tonic::Streaming, + out: Sender, + stdin: Option, + stdout: BoundaryOutput, + stderr: Option, + process: Arc, + completion: Option>, +) { + let (input_error, input_failed) = oneshot::channel(); + let read = tokio::spawn(async move { + if let Some(mut stdin) = stdin { + while let Ok(Some(c)) = input.message().await { + match c.payload { + Some(Payload::Stdin(v)) => { + if stdin.write_all(&v).await.is_err() { + let _ = input_error.send(()); + return; + } + } + _ => break, + } + } + let _ = stdin.shutdown().await; + } + }); + + let a = output(stdout, out.clone(), false); + let b = async { + if let Some(stderr) = stderr { + output(stderr, out.clone(), true).await; + } + }; + let output = async { + tokio::join!(a, b); + }; + tokio::pin!(output); + let wait = process.wait(); + tokio::pin!(wait); + let failed = async { + if input_failed.await.is_err() { + std::future::pending::<()>().await; + } + }; + let waited = tokio::select! { + () = failed => { + read.abort(); + + let _ = process.terminate().await; + + send(&out, Payload::Exit(w::ExitStatusWire::Exited(74).to_proto().unwrap())).await; + return; + }, + r = &mut wait => {read.abort(); output.await; Some(r)}, + () = &mut output => None, + }; + let exit = if let Some(completion) = completion { + completion.await.ok() + } else { + match waited { + Some(r) => r.ok(), + None => wait.await.ok(), + } + }; + if let Some(exit) = exit { + let wire: w::ExitStatusWire = exit.into(); + send(&out, Payload::Exit(wire.to_proto().unwrap())).await; + } + read.abort(); +} +async fn relay_io( + mut input: tonic::Streaming, + out: Sender, + stream: BoundaryDuplexStream, +) { + let (mut reader, mut writer) = tokio::io::split(stream); + let write = async { + while let Ok(Some(c)) = input.message().await { + if let Some(Payload::RelayData(v)) = c.payload { + writer.write_all(&v).await?; + } + } + writer.shutdown().await + }; + let read = async { + let mut b = vec![0; 16384]; + loop { + let n = reader.read(&mut b).await?; + if n == 0 { + break; + } + if out + .send(Ok(chunk(Payload::RelayData(b[..n].to_vec())))) + .await + .is_err() + { + break; + } + } + Ok::<_, std::io::Error>(()) + }; + let _ = tokio::join!(write, read); +} + +struct TestTlsIo(BoundaryDuplexStream); +impl tokio::io::AsyncRead for TestTlsIo { + fn poll_read( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(context, buffer) + } +} +impl tokio::io::AsyncWrite for TestTlsIo { + fn poll_write( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(context, buffer) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_flush(context) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(context) + } +} +impl tonic::transport::server::Connected for TestTlsIo { + type ConnectInfo = (); + + fn connect_info(&self) -> Self::ConnectInfo {} +} + +struct Connector { + peer: Arc, + address: std::net::SocketAddr, + task: tokio::task::JoinHandle<()>, +} +impl std::fmt::Debug for Connector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TestConnector") + .field("address", &self.address) + .finish_non_exhaustive() + } +} +impl Drop for Connector { + fn drop(&mut self) { + self.peer.handler.lock().unwrap().take(); + self.task.abort(); + } +} +#[async_trait] +impl super::BoundaryTransportConnector for Connector { + async fn connect( + &self, + _: &w::SandboxRuntimeDescriptor, + ) -> Result { + Ok(Box::new( + openshell_core::net::connect_tcp_nodelay_best_effort(&[self.address]) + .await + .map_err(|e| BackendError::Unavailable(e.to_string()))?, + )) + } +} +fn client(peer: Peer) -> Arc { + let session_id = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(); + let material = w::generate_sandbox_tls_material(session_id).unwrap(); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certs = rustls_pemfile::certs(&mut material.certificate_chain_pem.as_bytes()) + .collect::, _>>() + .unwrap(); + let key = rustls_pemfile::private_key(&mut material.private_key_pem.as_bytes()) + .unwrap() + .unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .unwrap(); + config.alpn_protocols = vec![b"h2".to_vec()]; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + let accept = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener).then(move |stream| { + let accept = accept.clone(); + async move { + let stream = stream?; + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + accept.accept(stream).await.map(|s| TestTlsIo(Box::new(s))) + } + }); + let peer = Arc::new(peer); + let server_peer = peer.clone(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service( + crate::backend_proto::isolation_backend_server::IsolationBackendServer::new( + Service(Arc::new(Peer::default())), + ), + ) + .add_service(SandboxProtocolServer::new(Service(server_peer))) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + let identity = + ResolvedWorkloadIdentity::new(1000, 1000, vec![], "test".into(), "test".into()).unwrap(); + let descriptor = w::SandboxRuntimeDescriptor { + boundary_id: "test".into(), + generation: "test".into(), + session_id, + workload_identity: identity, + transport: w::SandboxTransport::Tcp { + authority: "test".into(), + addresses: vec![address], + }, + tls: w::SandboxTlsClientConfig { + server_name: material.server_name, + trust_anchor_pem: material.trust_anchor_pem, + }, + host_gateway_ip: None, + resource_claims: std::collections::BTreeMap::default(), + provisioning_evidence: ProvisioningEvidence::from_enforcement_evidence("test", b"test") + .unwrap(), + }; + let bearer = SessionBearerTokenSlot::new( + SecretJwt::parse("a".repeat(32)).unwrap(), + i64::MAX, + CredentialEpoch::new(1).unwrap(), + ) + .unwrap(); + let mut client = + BoundaryClient::with_backend(descriptor, bearer, crate::BACKEND_NAME.into(), vec![]); + client.connector = Some(Arc::new(Connector { + peer, + address, + task, + })); + client.boundary_handle.set("test".into()).unwrap(); + Arc::new(client) +} +pub fn process(process: Arc) -> Arc { + Arc::new(crate::BoundaryProcess { + client: client(Peer { + process: Some(process), + ..Peer::default() + }), + process_id: "main".into(), + exec: false, + }) +} +pub fn terminal(terminal: Arc) -> Arc { + Arc::new(crate::BoundaryTerminal { + client: client(Peer { + terminal: Some(terminal), + ..Peer::default() + }), + process_id: "main".into(), + }) +} +pub fn exec(exec: Arc) -> Arc { + exec_with_credentials( + exec, + ProviderCredentialState::from_child_env_snapshot(0, HashMap::new()), + ) +} +pub fn exec_with_credentials( + exec: Arc, + credentials: ProviderCredentialState, +) -> Arc { + Arc::new(crate::BoundaryExec { + client: client(Peer { + exec: Some(exec), + ..Peer::default() + }), + provider_credentials: credentials, + publication_generation: tokio::sync::Mutex::new(0), + }) +} +pub fn loopback( + loopback: Arc, +) -> Arc { + Arc::new(crate::BoundaryLoopbackConnector { + client: client(Peer { + loopback: Some(loopback), + ..Peer::default() + }), + }) +} +pub fn network(network: Arc) -> Arc { + Arc::new(crate::NetworkMediationSource { + client: client(Peer { + network: Some(network), + ..Peer::default() + }), + }) +} + +#[async_trait] +impl BoundaryTerminal for crate::BoundaryTerminal { + async fn resize(&self, c: u16, r: u16) -> Result<(), BackendError> { + self.resize(c, r).await + } +} + +#[tonic::async_trait] +impl crate::backend_proto::isolation_backend_server::IsolationBackend for Service { + async fn describe( + &self, + _: tonic::Request, + ) -> Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + crate::backend_proto::DescribeIsolationBackendResponse { + backend: Some(crate::isolation_protocol::metadata("test-peer")), + }, + )) + } + async fn open_boundary( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new( + crate::backend_proto::OpenBoundaryResponse { + boundary_id: "test".into(), + outcome: crate::backend_proto::open_boundary_response::LaunchOutcome::Fresh as i32, + }, + )) + } + async fn suspend_boundary( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("test peer")) + } + async fn resume_boundary( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("test peer")) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn backend( + name: String, + sandbox: SandboxContext, + generation: String, + payload: Vec, + ca: Arc>>, + credentials: ProviderCredentialState, + bearer: SessionBearerTokenSlot, + handler: Handler, +) -> crate::OpenShellRuntimeBackend { + let peer = client(Peer { + handler: Mutex::new(Some(handler)), + ..Peer::default() + }); + let mut descriptor = peer.runtime_descriptor.clone(); + descriptor.boundary_id = sandbox.sandbox_id; + descriptor.session_id = sandbox.session_id; + descriptor.generation.clone_from(&generation); + descriptor.workload_identity = sandbox.identity; + descriptor.provisioning_evidence = ProvisioningEvidence::from_enforcement_evidence( + generation, + b"in-process-enforcement-evidence", + ) + .unwrap(); + let mut backend = crate::OpenShellRuntimeBackend::new(ca, credentials, bearer); + backend.connector.clone_from(&peer.connector); + backend.delegated = Some(crate::runtime::DelegatedRuntime { + backend_name: name, + descriptor, + driver_descriptor: payload, + }); + backend +} + +#[cfg(test)] +mod tests { + use super::*; + + struct UnsupportedExec; + + #[async_trait] + impl BoundaryExec for UnsupportedExec { + async fn exec(&self, _: ExecSpec) -> Result { + Err(BackendError::Unsupported( + "exec is optional for this peer".into(), + )) + } + } + + #[tokio::test] + async fn optional_operation_error_survives_the_rpc() { + let client = exec(Arc::new(UnsupportedExec)); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.exec(ExecSpec { + program: "/bin/true".into(), + args: Vec::new(), + shell: None, + runtime_helper: None, + env: Vec::new(), + workdir: None, + pty: false, + }), + ) + .await + .expect("optional operation must return an error without transport retries"); + assert!( + matches!(result, Err(BackendError::Unsupported(message)) if message.contains("exec is optional")) + ); + } +} diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs index df40832d4e..0c5320a46b 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs @@ -13,13 +13,13 @@ struct RenewingGrpcBoundary { } #[tonic::async_trait] -impl IsolationBoundary for RenewingGrpcBoundary { +impl SandboxProtocol for RenewingGrpcBoundary { type ExchangeStream = TestGrpcStream; - type MediateStream = TestGrpcStream; + type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if self .failures @@ -40,9 +40,11 @@ impl IsolationBoundary for RenewingGrpcBoundary { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { - self.exchange(request).await + let mut inner = self.inner.clone(); + inner.expected_token = self.expected_token.read().unwrap().clone(); + inner.mediate(request).await } } @@ -87,7 +89,8 @@ async fn renewing_boundary_client( let service = server_service.clone(); connections.spawn(async move { tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming( tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo(Box::new(stream)))]) .chain(tokio_stream::pending()), @@ -105,7 +108,6 @@ async fn renewing_boundary_client( .call_idempotent(Request::Attach { supervisor_instance_id: client.supervisor_instance_id, policy: Box::new(SandboxPolicyWire::from(sandbox().policy)), - resource_claims: std::collections::BTreeMap::new(), }) .await .unwrap(); diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs new file mode 100644 index 0000000000..86af9ab75e --- /dev/null +++ b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use openshell_isolation_interface::contract::BackendDescriptor; + +const EXTERNAL_NAME: &str = "external-test"; +const OPAQUE_PAYLOAD: &[u8] = b"external-driver-v1\0\xffresource"; + +async fn external_backend( + confirmation: BoundaryConfirmation, +) -> (OpenShellRuntimeBackend, tokio::task::JoinHandle<()>) { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client_tls = certificate.client_tls.clone(); + let server = tokio::spawn(async move { + // Discovery and attachment deliberately use separate authenticated connections. + let mut connections = tokio::task::JoinSet::new(); + loop { + let (stream, _) = listener.accept().await.unwrap(); + let tls = certificate.server_config.clone(); + let confirmation = confirmation.clone(); + connections.spawn(async move { + let stream = tokio_rustls::TlsAcceptor::from(tls) + .accept(stream) + .await + .unwrap(); + let service = TestGrpcBoundary { + wait_for_half_close: false, + expected_token: "a".repeat(32), + requests: Arc::default(), + mediation_failures: Arc::default(), + mediation_ready: false, + provider_environment_generation: 0, + confirmation, + }; + tonic::transport::Server::builder() + .add_service(IsolationBackendServer::new(TestIsolationBackend { + backend_name: EXTERNAL_NAME.into(), + driver_descriptor: Some(OPAQUE_PAYLOAD.to_vec()), + ..TestIsolationBackend::default() + })) + .add_service(SandboxProtocolServer::new(service)) + .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( + Box::new(stream), + ))])) + .await + .unwrap(); + }); + } + }); + let descriptor = tls_runtime_descriptor(address, client_tls); + let bearer = test_bearer(&"a".repeat(32)); + assert_eq!( + OpenShellRuntimeBackend::discover_registered_policy( + EXTERNAL_NAME.into(), + descriptor.clone(), + OPAQUE_PAYLOAD.to_vec(), + bearer.clone() + ) + .await + .unwrap(), + (None, false) + ); + let backend = OpenShellRuntimeBackend::registered( + EXTERNAL_NAME.into(), + descriptor, + OPAQUE_PAYLOAD.to_vec(), + Arc::new(std::sync::Mutex::new(None)), + openshell_core::provider_credentials::ProviderCredentialState::from_child_env_snapshot( + 0, + HashMap::new(), + ), + bearer, + ) + .unwrap(); + (backend, server) +} + +fn external_confirmation() -> BoundaryConfirmation { + let mut confirmation = test_confirmation(); + confirmation.backend_audit = serde_json::Value::Null; + confirmation +} + +async fn attach_external(backend: OpenShellRuntimeBackend) -> Box { + let descriptor = BackendDescriptor { + backend_name: EXTERNAL_NAME.into(), + payload: OPAQUE_PAYLOAD.to_vec(), + }; + backend.attach(descriptor, sandbox()).await.unwrap() +} + +#[tokio::test] +async fn external_protocol_backend_runs_without_native_linux_audit() { + let (backend, server) = external_backend(external_confirmation()).await; + let confirmed = attach_external(backend).await.confirm().await.unwrap(); + assert!(confirmed.confirmation().backend_audit.is_null()); + let running = confirmed.into_boundary().start_agent().await.unwrap(); + assert_eq!( + running.agent().wait().await.unwrap(), + BoundaryExitStatus::Exited(23) + ); + running.agent().signal(BoundarySignal::Term).await.unwrap(); + running.terminate().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn external_confirmation_requires_admitted_launch_binding() { + for field in [ + "generation", + "session", + "identity", + "provisioning_evidence_digest", + "claims", + ] { + let mut confirmation = external_confirmation(); + match field { + "generation" => confirmation.generation = "other-generation".into(), + "session" => confirmation.session_id = openshell_core::SandboxSessionId::new(), + "identity" => confirmation.identity.uid += 1, + "provisioning_evidence_digest" => { + confirmation.provisioning_evidence_digest = "f".repeat(64).parse().unwrap(); + } + "claims" => { + confirmation + .resource_claims + .insert("unexpected".into(), "claim".into()); + } + _ => unreachable!(), + } + let (backend, server) = external_backend(confirmation).await; + assert!( + matches!( + attach_external(backend).await.confirm().await, + Err(BackendError::Confirm(_)) + ), + "{field}" + ); + server.abort(); + } +} + +#[tokio::test] +async fn delegated_backend_rejects_reserved_name_and_changed_payload_before_io() { + let certificate = test_certificate(); + let descriptor = tls_runtime_descriptor("127.0.0.1:1".parse().unwrap(), certificate.client_tls); + assert!(matches!( + OpenShellRuntimeBackend::registered( + crate::BACKEND_NAME.into(), + descriptor.clone(), + OPAQUE_PAYLOAD.to_vec(), + Arc::default(), + openshell_core::provider_credentials::ProviderCredentialState::from_child_env_snapshot( + 0, + HashMap::new() + ), + test_bearer(&"a".repeat(32)) + ), + Err(BackendError::Descriptor(_)) + )); + assert!(matches!( + OpenShellRuntimeBackend::discover_registered_policy( + crate::BACKEND_NAME.into(), + descriptor, + OPAQUE_PAYLOAD.to_vec(), + test_bearer(&"a".repeat(32)) + ) + .await, + Err(BackendError::Descriptor(_)) + )); + let (backend, server) = external_backend(external_confirmation()).await; + let descriptor = BackendDescriptor { + backend_name: EXTERNAL_NAME.into(), + payload: b"changed".to_vec(), + }; + assert!(matches!(backend.attach(descriptor, sandbox()).await, + Err(BackendError::Descriptor(message)) if message.contains("payload changed"))); + server.abort(); +} diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs index ad625aa91c..e42374c4c6 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs @@ -8,27 +8,40 @@ use tokio_stream::StreamExt as _; /// workload stops reading a relayed download. Other streams echo one chunk. #[derive(Clone)] struct StallingPeer { - stalled: Arc>>>, + stalled: Arc>>>, } #[tonic::async_trait] -impl IsolationBoundary for StallingPeer { +impl SandboxProtocol for StallingPeer { type ExchangeStream = TestGrpcStream; - type MediateStream = TestGrpcStream; + type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let stall = request.metadata().get("x-stall").is_some(); let mut inbound = request.into_inner(); + test_bind(&mut inbound).await; let (outbound, receiver) = tokio::sync::mpsc::channel(1); if stall { self.stalled.lock().unwrap().push(inbound); } else { tokio::spawn(async move { if let Ok(Some(chunk)) = inbound.message().await { - let _ = outbound.send(Ok(chunk)).await; + let request = chunk_data(chunk).unwrap(); + let request: RequestEnvelope = decode_frame(&request).unwrap(); + let response = ResponseEnvelope { + request_id: request.request_id, + response: Response::Exited { + status: ExitStatusWire::Exited(23), + }, + }; + let _ = outbound + .send(Ok( + crate::isolation_protocol::response_chunk(&response).unwrap() + )) + .await; } }); } @@ -39,7 +52,7 @@ impl IsolationBoundary for StallingPeer { async fn mediate( &self, - _request: tonic::Request>, + _request: tonic::Request>, ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("flow-control fixture")) } @@ -65,7 +78,8 @@ async fn start_boundary(peer: StallingPeer) -> tonic::transport::Channel { .initial_connection_window_size( crate::boundary_protocol::BOUNDARY_CONNECTION_WINDOW_BYTES, ) - .add_service(IsolationBoundaryServer::new(peer)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(peer)) .serve_with_incoming(incoming) .await .unwrap(); @@ -74,17 +88,25 @@ async fn start_boundary(peer: StallingPeer) -> tonic::transport::Channel { tls_runtime_descriptor(address, certificate.client_tls), test_bearer(&"a".repeat(32)), ); - client.build_grpc_channel().await.unwrap().0 + client + .build_grpc_channel(&BoundaryCredential::capture(&client.sandbox_bearer).unwrap()) + .await + .unwrap() + .0 } /// Push into one stalled stream until the peer stops granting credit. -async fn saturate(channel: tonic::transport::Channel) -> tokio::sync::mpsc::Sender { +async fn saturate(channel: tonic::transport::Channel) -> tokio::sync::mpsc::Sender { let (sender, receiver) = tokio::sync::mpsc::channel(1); + sender + .send(exchange_frame(TEST_BOUNDARY_HANDLE)) + .await + .unwrap(); let mut request = tonic::Request::new(ReceiverStream::new(receiver)); request .metadata_mut() .insert("x-stall", "1".parse().unwrap()); - let mut client = IsolationBoundaryClient::new(channel); + let mut client = SandboxProtocolClient::new(channel); tokio::spawn(async move { let _response = client.exchange(request).await; std::future::pending::<()>().await; @@ -92,9 +114,7 @@ async fn saturate(channel: tonic::transport::Channel) -> tokio::sync::mpsc::Send let chunk = vec![0_u8; 32 * 1024]; while tokio::time::timeout( Duration::from_millis(300), - sender.send(BoundaryChunk { - data: chunk.clone(), - }), + sender.send(data_chunk(chunk.clone())), ) .await .is_ok() @@ -119,18 +139,35 @@ async fn stalled_relays_cannot_starve_other_streams() { let auth: tonic::metadata::AsciiMetadataValue = "Bearer test".parse().unwrap(); let exchange = async { - let mut stream = - open_grpc_client_stream_with_authorization(channel, GrpcStreamKind::Exchange, auth) - .await - .unwrap(); - stream.write_all(b"ping").await.unwrap(); - let mut pong = [0_u8; 4]; - stream.read_exact(&mut pong).await.unwrap(); - pong + let mut stream = open_grpc_client_stream_with_authorization( + channel, + GrpcStreamKind::Exchange, + auth, + TEST_BOUNDARY_HANDLE, + ) + .await + .unwrap(); + let request = RequestEnvelope::new(Request::Wait { + process_id: "main".into(), + }) + .unwrap(); + stream + .write_all(&encode_frame(&request).unwrap()) + .await + .unwrap(); + crate::boundary_protocol::read_frame_async::<_, ResponseEnvelope>(&mut stream) + .await + .unwrap() + .response }; let pong = tokio::time::timeout(Duration::from_secs(3), exchange) .await .expect("stalled relay streams must not exhaust connection-level credit"); - assert_eq!(&pong, b"ping"); + assert!(matches!( + pong, + Response::Exited { + status: ExitStatusWire::Exited(23) + } + )); drop(stalled); } diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs index aa4a1b1409..0e550b3032 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -26,6 +26,7 @@ enum Reply { /// Stop answering on the first connection once it is confirmed. Blackhole, Leaf(BoundaryErrorKind), + Deny, Idle, } @@ -51,13 +52,13 @@ struct NetworkPeer { } #[tonic::async_trait] -impl IsolationBoundary for NetworkPeer { +impl SandboxProtocol for NetworkPeer { type ExchangeStream = TestGrpcStream; - type MediateStream = TestGrpcStream; + type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if request.metadata().get("authorization").unwrap() != format!("Bearer {}", "a".repeat(32)).as_str() @@ -80,7 +81,7 @@ impl IsolationBoundary for NetworkPeer { async fn mediate( &self, - _request: tonic::Request>, + _request: tonic::Request>, ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("TCP fixture")) } @@ -89,12 +90,15 @@ impl IsolationBoundary for NetworkPeer { impl NetworkPeer { async fn respond( &self, - mut inbound: tonic::Streaming, - outbound: tokio::sync::mpsc::Sender>, + mut inbound: tonic::Streaming, + outbound: tokio::sync::mpsc::Sender>, ) -> Result<(), tonic::Status> { + test_bind(&mut inbound).await; let mut frame = Vec::new(); while !complete_control_frame(&frame) { - frame.extend_from_slice(&inbound.message().await?.expect("control request").data); + frame.extend_from_slice(&chunk_data( + inbound.message().await?.expect("control request"), + )?); } let envelope: RequestEnvelope = decode_frame(&frame).unwrap(); if matches!(self.state.reply, Reply::Blackhole) @@ -180,7 +184,8 @@ impl NetworkPeer { | Reply::DisconnectLate | Reply::DisconnectGated | Reply::DropFirstAccept - | Reply::Blackhole => network_response(), + | Reply::Blackhole + | Reply::Deny => network_response(), } } _ => unreachable!(), @@ -191,20 +196,24 @@ impl NetworkPeer { response, }) .unwrap(); - outbound - .send(Ok(BoundaryChunk { data: bytes })) - .await - .unwrap(); + outbound.send(Ok(test_response_chunk(bytes))).await.unwrap(); if connected { let state = self.state.clone(); tokio::spawn(async move { - let (mut reader, writer) = tokio::io::duplex(4096); - let pump = tokio::spawn(pump_from_grpc(inbound, writer)); + let mut reader = grpc_reader(inbound, crate::stream_adapter::Mode::Decision); let result = tokio::time::timeout(Duration::from_secs(3), async { let (channel, payload) = read_stream_frame(&mut reader).await.unwrap().unwrap(); assert_eq!(channel, STREAM_NETWORK_DECISION); + if matches!(state.reply, Reply::Deny) { + assert_eq!( + crate::protobuf::decode::(&payload).unwrap(), + TcpOpenDecision::Denied(TcpOpenDenial::PolicyDenied) + ); + state.decisions.fetch_add(1, Ordering::AcqRel); + return; + } assert_eq!( - serde_json::from_slice::(&payload).unwrap(), + crate::protobuf::decode::(&payload).unwrap(), TcpOpenDecision::RelayReady ); state.decisions.fetch_add(1, Ordering::AcqRel); @@ -212,14 +221,11 @@ impl NetworkPeer { reader.read_exact(&mut ping).await.unwrap(); assert_eq!(&ping, b"ping"); outbound - .send(Ok(BoundaryChunk { - data: b"pong".to_vec(), - })) + .send(Ok(data_chunk(b"pong".to_vec()))) .await .unwrap(); }) .await; - pump.abort(); result.expect("accepted network stream must receive its decision and payload"); }); } @@ -238,11 +244,11 @@ fn network_response() -> Response { cmdline_paths: Vec::new(), }, destination: "203.0.113.1:443".parse().unwrap(), - socket: NetworkSocketMetadata { + socket: Some(NetworkSocketMetadata { socket_cookie: 42, nonblocking: true, process_generation: 7, - }, + }), policy_generation: 9, timing: MediationTimingWire { notification_to_queue_us: 11, @@ -252,7 +258,7 @@ fn network_response() -> Response { } struct Fixture { - source: RemoteNetworkMediation, + source: NetworkMediationSource, state: Arc, server: tokio::task::JoinHandle<()>, } @@ -302,7 +308,8 @@ impl Fixture { ))]) .chain(tokio_stream::pending()); let serving = tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(peer)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(peer)) .serve_with_incoming(incoming); tokio::select! { result = serving => result.unwrap(), @@ -338,7 +345,6 @@ impl Fixture { .call_idempotent(Request::Attach { supervisor_instance_id: client.supervisor_instance_id, policy: Box::new(SandboxPolicyWire::from(sandbox().policy)), - resource_claims: std::collections::BTreeMap::new(), }) .await .unwrap(); @@ -347,7 +353,7 @@ impl Fixture { .await .expect("fixture must attach and confirm"); Self { - source: RemoteNetworkMediation { client }, + source: NetworkMediationSource { client }, state, server, } @@ -367,7 +373,7 @@ async fn verify_connection(mut pending: PendingTcpOpen) { PathBuf::from("/usr/bin/curl") ); assert_eq!( - pending.socket, + pending.socket.unwrap(), NetworkSocketMetadata { socket_cookie: 42, nonblocking: true, @@ -387,6 +393,25 @@ async fn verify_connection(mut pending: PendingTcpOpen) { assert_eq!(&pong, b"pong"); } +#[tokio::test] +async fn tcp_policy_denial_reaches_backend_before_stream_is_closed() { + let fixture = Fixture::new(Reply::Deny).await; + let pending = fixture.source.accept_tcp().await.unwrap(); + pending + .decision + .send(TcpOpenDecision::Denied(TcpOpenDenial::PolicyDenied)) + .unwrap(); + drop(pending.stream); + tokio::time::timeout(Duration::from_secs(3), async { + while fixture.state.decisions.load(Ordering::Acquire) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("policy denial must reach the backend before the RPC is dropped"); + assert_eq!(fixture.state.connections.load(Ordering::Acquire), 1); +} + #[tokio::test] async fn tcp_accept_recovers_a_lost_tls_connection() { let fixture = Fixture::new(Reply::Disconnect { pending_accepts: 1 }).await; @@ -424,7 +449,7 @@ async fn concurrent_tcp_accepts_recover_a_lost_tls_connection() { .await; let mut accepts = tokio::task::JoinSet::new(); for _ in 0..ACCEPTS { - let source = RemoteNetworkMediation { + let source = NetworkMediationSource { client: fixture.source.client.clone(), }; accepts.spawn(async move { verify_connection(source.accept_tcp().await.unwrap()).await }); @@ -474,7 +499,7 @@ async fn tcp_accept_preserves_boundary_leaf_errors() { #[tokio::test] async fn tcp_accept_has_no_idle_operation_timeout() { let fixture = Fixture::new(Reply::Idle).await; - let source = RemoteNetworkMediation { + let source = NetworkMediationSource { client: fixture.source.client.clone(), }; let pending = tokio::spawn(async move { source.accept_tcp().await }); @@ -561,7 +586,7 @@ async fn recovery_closes_an_unresponsive_connection_before_reattaching() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn requests_during_delayed_retirement_wait_for_the_confirmed_replacement() { let fixture = Fixture::new(Reply::DisconnectGated).await; - let first = RemoteNetworkMediation { + let first = NetworkMediationSource { client: fixture.source.client.clone(), }; let first = tokio::spawn(async move { first.accept_tcp().await }); @@ -574,7 +599,7 @@ async fn requests_during_delayed_retirement_wait_for_the_confirmed_replacement() .expect("recovery must reach a same-epoch attach rejection"); // Start an ordinary request while recovery waits for retirement. - let second = RemoteNetworkMediation { + let second = NetworkMediationSource { client: fixture.source.client.clone(), }; let second = tokio::spawn(async move { second.accept_tcp().await }); diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/protocol_errors.rs b/crates/openshell-sandbox-backend/src/runtime/tests/protocol_errors.rs new file mode 100644 index 0000000000..df8a1a3290 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/runtime/tests/protocol_errors.rs @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::proto::{exchange_frame, response_envelope}; +use crate::protobuf::WireMessage as _; + +#[derive(Clone)] +struct RejectingPeer { + malformed_audit: bool, + calls: Arc, +} + +#[tonic::async_trait] +impl SandboxProtocol for RejectingPeer { + type ExchangeStream = TestGrpcStream; + type MediateStream = TestGrpcStream; + + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let mut inbound = request.into_inner(); + test_bind(&mut inbound).await; + let peer = self.clone(); + let (outbound, receiver) = tokio::sync::mpsc::channel(1); + tokio::spawn(async move { + let frame = inbound.message().await.unwrap().unwrap(); + let Some(exchange_frame::Payload::Request(request)) = frame.payload else { + panic!("expected a typed request"); + }; + peer.calls.fetch_add(1, Ordering::AcqRel); + if peer.malformed_audit { + let mut confirmation = test_confirmation().to_proto().unwrap(); + confirmation.backend_audit = b"not-json".to_vec(); + outbound + .send(Ok(ExchangeFrame { + payload: Some(exchange_frame::Payload::Response( + crate::proto::ResponseEnvelope { + request_id: request.request_id, + response: Some(response_envelope::Response::Confirmed( + crate::proto::Confirmed { + confirmation: Some(confirmation), + }, + )), + }, + )), + })) + .await + .unwrap(); + } else { + outbound + .send(Err(tonic::Status::unimplemented( + "provider files unsupported", + ))) + .await + .unwrap(); + } + }); + Ok(tonic::Response::new(Box::pin(ReceiverStream::new( + receiver, + )))) + } + + async fn mediate( + &self, + _: tonic::Request>, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("unused")) + } +} + +#[tokio::test] +async fn protocol_failures_are_terminal_and_tcp_uses_configured_http_authority() { + for malformed_audit in [true, false] { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let mut descriptor = tls_runtime_descriptor(address, certificate.client_tls.clone()); + let SandboxTransport::Tcp { authority, .. } = &mut descriptor.transport else { + unreachable!(); + }; + *authority = "backend.route.test:7443".into(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_authorities = Arc::new(std::sync::Mutex::new(Vec::new())); + let observed = seen_authorities.clone(); + let peer = RejectingPeer { + malformed_audit, + calls: calls.clone(), + }; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let stream = tokio_rustls::TlsAcceptor::from(certificate.server_config) + .accept(stream) + .await + .unwrap(); + tonic::transport::Server::builder() + .layer(tower::util::MapRequestLayer::new( + move |request: tonic::codegen::http::Request<_>| { + observed + .lock() + .unwrap() + .push(request.uri().authority().unwrap().to_string()); + request + }, + )) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(SandboxProtocolServer::new(peer)) + .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( + Box::new(stream), + ))])) + .await + .unwrap(); + }); + let client = BoundaryClient::new(descriptor, test_bearer(&"a".repeat(32))); + let request = if malformed_audit { + Request::Confirm + } else { + Request::UpdateProviderEnvironment { + generation: 1, + revision: 1, + provider_env: HashMap::new(), + provider_files: HashMap::from([( + "/run/openshell/providers/key".into(), + "value".into(), + )]), + } + }; + let error = tokio::time::timeout(Duration::from_secs(5), client.call_idempotent(request)) + .await + .expect("protocol errors must not enter transport recovery") + .unwrap_err(); + if malformed_audit { + assert!(matches!(error, BackendError::Descriptor(_)), "{error:?}"); + assert!( + error + .to_string() + .contains("backend_audit must be UTF-8 JSON"), + "{error}" + ); + } else { + assert!(matches!(error, BackendError::Unsupported(_)), "{error:?}"); + assert!( + error.to_string().contains("provider files unsupported"), + "{error}" + ); + } + assert_eq!( + calls.load(Ordering::Acquire), + 1, + "terminal failures must not replay" + ); + let seen = seen_authorities.lock().unwrap(); + assert!(!seen.is_empty()); + assert!( + seen.iter().all(|value| value == "backend.route.test:7443"), + "{seen:?}" + ); + server.abort(); + } +} diff --git a/crates/openshell-sandbox-backend/src/sandbox_auth.rs b/crates/openshell-sandbox-backend/src/sandbox_auth.rs index d270082af2..69a6c8db56 100644 --- a/crates/openshell-sandbox-backend/src/sandbox_auth.rs +++ b/crates/openshell-sandbox-backend/src/sandbox_auth.rs @@ -438,6 +438,24 @@ mod tests { metadata } + #[test] + fn bearer_identity_must_match_the_provisioned_runtime() { + let (mut authenticator, token) = fixture(1); + let metadata = metadata(token.token.expose_secret()); + authenticator.expected_sandbox_id = SandboxId::parse("sandbox-b").unwrap(); + assert_eq!( + authenticator.authenticate(SandboxConnectionId::new(), &metadata), + Err(SandboxAuthError::WrongSandbox) + ); + authenticator.expected_sandbox_id = SandboxId::parse("sandbox-a").unwrap(); + authenticator.expected_runtime_generation = + SandboxGenerationId::parse("generation-2").unwrap(); + assert_eq!( + authenticator.authenticate(SandboxConnectionId::new(), &metadata), + Err(SandboxAuthError::WrongRuntimeGeneration) + ); + } + #[test] fn bearer_metadata_must_occur_exactly_once() { let (authenticator, token) = fixture(1); diff --git a/crates/openshell-sandbox-backend/src/stream_adapter.rs b/crates/openshell-sandbox-backend/src/stream_adapter.rs new file mode 100644 index 0000000000..e23e6c6314 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/stream_adapter.rs @@ -0,0 +1,463 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Private adapter for Rust `AsyncRead`/`AsyncWrite` handles. +//! +//! Length/channel markers exist only on these in-process pipes; gRPC carries +//! typed `ExchangeFrame` or `MediateFrame` values. +//! Both directions check the operation's expected frame kind before forwarding. +use crate::{ + boundary_protocol as w, mediation as m, + proto::{self as p, exchange_frame::Payload}, + protobuf::{self, WireMessage}, +}; +use prost::Message as _; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt}; + +pub const MAX_MESSAGE_BYTES: usize = w::MAX_CONTROL_FRAME_BYTES + 4096; +#[derive(Clone, Copy, Debug)] +pub enum Mode { + Request, + Response, + Input, + Output, + Decision, + Relay, + DnsQueries, + DnsResponses, + Done, +} +fn bad(message: impl ToString) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.to_string()) +} +fn chunk(payload: Payload) -> p::ExchangeFrame { + p::ExchangeFrame { + payload: Some(payload), + } +} +fn request_mode(v: &w::Request) -> Mode { + match v { + w::Request::Exec { .. } | w::Request::AttachProcess { .. } => Mode::Input, + w::Request::AcceptNetwork => Mode::Decision, + w::Request::LoopbackConnect { .. } => Mode::Relay, + w::Request::OpenMediation => Mode::DnsResponses, + _ => Mode::Done, + } +} +fn response_mode(v: &w::Response) -> Mode { + match v { + w::Response::ProcessAttached { .. } | w::Response::ExecStarted { .. } => Mode::Output, + w::Response::NetworkConnected { .. } | w::Response::LoopbackConnected => Mode::Relay, + w::Response::MediationReady => Mode::DnsQueries, + _ => Mode::Done, + } +} +fn stream_bytes(channel: u8, payload: &[u8]) -> io::Result> { + if payload.len() > w::MAX_STREAM_FRAME_BYTES { + return Err(bad("stream payload exceeds limit")); + } + let mut bytes = vec![channel]; + bytes.extend_from_slice(&u32::try_from(payload.len()).map_err(bad)?.to_be_bytes()); + bytes.extend_from_slice(payload); + Ok(bytes) +} +fn dns_bytes(kind: m::MediationFrameKind, id: u64, payload: &[u8]) -> io::Result> { + if payload.len() > 256 * 1024 { + return Err(bad("DNS metadata exceeds limit")); + } + let mut bytes = vec![kind as u8]; + bytes.extend_from_slice(&id.to_be_bytes()); + bytes.extend_from_slice(&u32::try_from(payload.len()).map_err(bad)?.to_be_bytes()); + bytes.extend_from_slice(payload); + Ok(bytes) +} +/// Convert a checked typed frame to the local stream interface. +pub fn receive(mode: &mut Mode, value: p::ExchangeFrame) -> io::Result> { + use prost::Message; + if value.encoded_len() > MAX_MESSAGE_BYTES { + return Err(bad("protocol message exceeds limit")); + } + let payload = value.payload.ok_or_else(|| bad("frame payload missing"))?; + match (*mode, payload) { + (Mode::Request, Payload::Request(v)) => { + let v = w::RequestEnvelope::from_proto(v).map_err(bad)?; + *mode = request_mode(&v.request); + w::encode_frame(&v).map_err(bad) + } + (Mode::Response, Payload::Response(v)) => { + let v = w::ResponseEnvelope::from_proto(v).map_err(bad)?; + *mode = response_mode(&v.response); + w::encode_frame(&v).map_err(bad) + } + (Mode::Input, Payload::Stdin(v)) => stream_bytes(w::STREAM_STDIN, &v), + (Mode::Input, Payload::StdinClosed(_)) => { + *mode = Mode::Done; + stream_bytes(w::STREAM_STDIN_CLOSED, &[]) + } + (Mode::Output, Payload::Stdout(v)) => stream_bytes(w::STREAM_STDOUT, &v), + (Mode::Output, Payload::Stderr(v)) => stream_bytes(w::STREAM_STDERR, &v), + (Mode::Output, Payload::Exit(v)) => { + *mode = Mode::Done; + let v = w::ExitStatusWire::from_proto(v).map_err(bad)?; + stream_bytes(w::STREAM_EXIT, &protobuf::encode(&v).map_err(bad)?) + } + (Mode::Decision, Payload::TcpOpenDecision(v)) => { + let v = openshell_isolation_interface::contract::TcpOpenDecision::from_proto(v) + .map_err(bad)?; + *mode = if matches!( + v, + openshell_isolation_interface::contract::TcpOpenDecision::RelayReady + ) { + Mode::Relay + } else { + Mode::Done + }; + stream_bytes( + w::STREAM_NETWORK_DECISION, + &protobuf::encode(&v).map_err(bad)?, + ) + } + (Mode::Relay, Payload::RelayData(v)) if v.len() <= w::MAX_STREAM_FRAME_BYTES => Ok(v), + _ => Err(bad( + "frame kind does not match stream operation or direction", + )), + } +} +async fn control(reader: &mut R) -> io::Result>> { + let first = match reader.read_u8().await { + Ok(v) => v, + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + }; + let mut header = [first, 0, 0, 0]; + reader.read_exact(&mut header[1..]).await?; + let len = u32::from_be_bytes(header) as usize; + if len > w::MAX_CONTROL_FRAME_BYTES { + return Err(bad("control message exceeds limit")); + } + let mut bytes = header.to_vec(); + bytes.resize(4 + len, 0); + reader.read_exact(&mut bytes[4..]).await?; + Ok(Some(bytes)) +} +/// Read a local handle and emit its next typed public protocol frame. +pub async fn send( + reader: &mut R, + mode: &mut Mode, +) -> io::Result> { + let payload = match *mode { + Mode::Request => { + let Some(bytes) = control(reader).await? else { + return Ok(None); + }; + let v = w::decode_frame::(&bytes).map_err(bad)?; + *mode = request_mode(&v.request); + Payload::Request(v.to_proto().map_err(bad)?) + } + Mode::Response => { + let Some(bytes) = control(reader).await? else { + return Ok(None); + }; + let v = w::decode_frame::(&bytes).map_err(bad)?; + *mode = response_mode(&v.response); + Payload::Response(v.to_proto().map_err(bad)?) + } + Mode::Input | Mode::Output | Mode::Decision => { + let Some((channel, bytes)) = w::read_stream_frame(reader).await? else { + return Ok(None); + }; + match (*mode, channel) { + (Mode::Input, w::STREAM_STDIN) => Payload::Stdin(bytes), + (Mode::Input, w::STREAM_STDIN_CLOSED) if bytes.is_empty() => { + *mode = Mode::Done; + Payload::StdinClosed(p::StdinClosed {}) + } + (Mode::Output, w::STREAM_STDOUT) => Payload::Stdout(bytes), + (Mode::Output, w::STREAM_STDERR) => Payload::Stderr(bytes), + (Mode::Output, w::STREAM_EXIT) => { + *mode = Mode::Done; + Payload::Exit( + protobuf::decode::(&bytes) + .map_err(bad)? + .to_proto() + .map_err(bad)?, + ) + } + (Mode::Decision, w::STREAM_NETWORK_DECISION) => { + let v = protobuf::decode::< + openshell_isolation_interface::contract::TcpOpenDecision, + >(&bytes) + .map_err(bad)?; + *mode = if matches!( + v, + openshell_isolation_interface::contract::TcpOpenDecision::RelayReady + ) { + Mode::Relay + } else { + Mode::Done + }; + Payload::TcpOpenDecision(v.to_proto().map_err(bad)?) + } + _ => return Err(bad("invalid local stream channel")), + } + } + Mode::Relay => { + let mut bytes = vec![0; 16 * 1024]; + let len = reader.read(&mut bytes).await?; + if len == 0 { + return Ok(None); + } + bytes.truncate(len); + Payload::RelayData(bytes) + } + Mode::DnsQueries | Mode::DnsResponses => return Err(bad("DNS requires Mediate")), + Mode::Done => { + let mut byte = [0]; + if reader.read(&mut byte).await? != 0 { + return Err(bad("unexpected bytes after completed operation")); + } + return Ok(None); + } + }; + Ok(Some(chunk(payload))) +} + +/// Shared local byte adapter for the two distinct RPC stream types. +/// The public schema limits payload kinds; this adapter also checks ordering. +pub trait ProtocolFrame: prost::Message + Default + Send + 'static { + fn bind(boundary_id: &str) -> Self; + fn boundary_id(&self) -> Option<&str>; + fn receive(self, mode: &mut Mode) -> io::Result>; + fn send( + reader: &mut R, + mode: &mut Mode, + ) -> impl Future>> + Send; +} +impl ProtocolFrame for p::ExchangeFrame { + fn bind(id: &str) -> Self { + chunk(Payload::BoundaryId(id.into())) + } + fn boundary_id(&self) -> Option<&str> { + match self.payload.as_ref() { + Some(Payload::BoundaryId(id)) => Some(id), + _ => None, + } + } + fn receive(self, mode: &mut Mode) -> io::Result> { + receive(mode, self) + } + async fn send( + reader: &mut R, + mode: &mut Mode, + ) -> io::Result> { + send(reader, mode).await + } +} +impl ProtocolFrame for p::MediateFrame { + fn bind(id: &str) -> Self { + Self { + payload: Some(p::mediate_frame::Payload::BoundaryId(id.into())), + } + } + fn boundary_id(&self) -> Option<&str> { + match self.payload.as_ref() { + Some(p::mediate_frame::Payload::BoundaryId(id)) => Some(id), + _ => None, + } + } + fn receive(self, mode: &mut Mode) -> io::Result> { + use p::mediate_frame::Payload as M; + if self.encoded_len() > MAX_MESSAGE_BYTES { + return Err(bad("protocol message exceeds limit")); + } + match ( + *mode, + self.payload.ok_or_else(|| bad("frame payload missing"))?, + ) { + (Mode::Request, M::Request(v)) + if matches!( + v.request, + Some(p::request_envelope::Request::OpenMediation(_)) + ) => + { + receive(mode, chunk(Payload::Request(v))) + } + (Mode::Response, M::Response(v)) + if matches!( + v.response, + Some( + p::response_envelope::Response::MediationReady(_) + | p::response_envelope::Response::Error(_) + ) + ) => + { + receive(mode, chunk(Payload::Response(v))) + } + (Mode::DnsQueries, M::DnsQuery(v)) => { + let id = v.stream_id; + let v = m::DnsQueryWire::from_proto(v).map_err(bad)?; + dns_bytes( + m::MediationFrameKind::DnsQuery, + id, + &protobuf::encode(&v).map_err(bad)?, + ) + } + (Mode::DnsResponses, M::DnsResponse(v)) => { + let id = v.stream_id; + let v = w::DnsQueryResultWire::from_proto(v).map_err(bad)?; + dns_bytes( + m::MediationFrameKind::DnsResponse, + id, + &protobuf::encode(&v).map_err(bad)?, + ) + } + + _ => Err(bad( + "frame kind does not match mediation operation or direction", + )), + } + } + async fn send( + reader: &mut R, + mode: &mut Mode, + ) -> io::Result> { + use p::mediate_frame::Payload as M; + let payload = match *mode { + Mode::DnsQueries | Mode::DnsResponses => { + let Some(frame) = m::read_frame(reader).await? else { + return Ok(None); + }; + match (*mode, frame.kind) { + (Mode::DnsQueries, m::MediationFrameKind::DnsQuery) => { + let mut v = protobuf::decode::(&frame.payload) + .map_err(bad)? + .to_proto() + .map_err(bad)?; + v.stream_id = frame.stream_id; + M::DnsQuery(v) + } + (Mode::DnsResponses, m::MediationFrameKind::DnsResponse) => { + let mut v = protobuf::decode::(&frame.payload) + .map_err(bad)? + .to_proto() + .map_err(bad)?; + v.stream_id = frame.stream_id; + M::DnsResponse(v) + } + _ => return Err(bad("wrong DNS stream direction")), + } + } + + Mode::Request | Mode::Response | Mode::Done => { + let Some(frame) = send(reader, mode).await? else { + return Ok(None); + }; + match frame.payload { + Some(Payload::Request(v)) + if matches!( + v.request, + Some(p::request_envelope::Request::OpenMediation(_)) + ) => + { + M::Request(v) + } + Some(Payload::Response(v)) + if matches!( + v.response, + Some( + p::response_envelope::Response::MediationReady(_) + | p::response_envelope::Response::Error(_) + ) + ) => + { + M::Response(v) + } + _ => return Err(bad("only OpenMediation is allowed on Mediate")), + } + } + _ => return Err(bad("process and relay I/O require Exchange")), + }; + Ok(Some(Self { + payload: Some(payload), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stream_direction_and_limits_are_enforced_before_forwarding() { + assert!(receive(&mut Mode::Request, chunk(Payload::RelayData(vec![1]))).is_err()); + assert!(receive(&mut Mode::Input, chunk(Payload::Stdout(vec![1]))).is_err()); + assert!( + receive( + &mut Mode::Relay, + chunk(Payload::RelayData(vec![0; w::MAX_STREAM_FRAME_BYTES + 1])) + ) + .is_err() + ); + assert!(receive(&mut Mode::Done, chunk(Payload::Stdin(vec![1]))).is_err()); + } + + #[tokio::test] + async fn mediation_requires_its_control_handshake_before_dns() { + let request = w::RequestEnvelope::new(w::Request::OpenMediation).unwrap(); + let bytes = w::encode_frame(&request).unwrap(); + let mut outgoing = Mode::Request; + let frame = p::MediateFrame::send(&mut bytes.as_slice(), &mut outgoing) + .await + .unwrap() + .unwrap(); + let mut incoming = Mode::Request; + let decoded = frame.receive(&mut incoming).unwrap(); + assert_eq!( + w::decode_frame::(&decoded).unwrap(), + request + ); + assert!(matches!(outgoing, Mode::DnsResponses)); + assert!(matches!(incoming, Mode::DnsResponses)); + + let other = w::RequestEnvelope::new(w::Request::DiscoverPolicy).unwrap(); + let wrong = p::MediateFrame { + payload: Some(p::mediate_frame::Payload::Request( + other.to_proto().unwrap(), + )), + }; + assert!(wrong.receive(&mut Mode::Request).is_err()); + let dns = p::MediateFrame { + payload: Some(p::mediate_frame::Payload::DnsResponse(p::DnsResponse { + stream_id: 1, + result: Some(p::dns_response::Result::Response(vec![])), + })), + }; + assert!(dns.receive(&mut Mode::Request).is_err()); + assert!( + p::ExchangeFrame::send(&mut &[][..], &mut Mode::DnsResponses) + .await + .is_err() + ); + } + + #[tokio::test] + async fn typed_dns_preserves_correlation_and_failure() { + let frame = p::MediateFrame { + payload: Some(p::mediate_frame::Payload::DnsResponse(p::DnsResponse { + stream_id: 9_007_199_254_740_993, + result: Some(p::dns_response::Result::Error( + "resolver unavailable".into(), + )), + })), + }; + let bytes = frame.clone().receive(&mut Mode::DnsResponses).unwrap(); + let mut reader = bytes.as_slice(); + assert_eq!( + p::MediateFrame::send(&mut reader, &mut Mode::DnsResponses) + .await + .unwrap(), + Some(frame) + ); + } +} diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index c0a4930a35..32d56c50f3 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -3,6 +3,8 @@ //! Workload-side implementation of RFC 0012 sandbox exec. +use crate::native_api::{BoundaryExec, BoundaryProcess, BoundaryTerminal, ExecSession}; + use std::collections::HashMap; #[cfg(target_os = "linux")] use std::io::Write as _; @@ -21,8 +23,7 @@ use nix::unistd::Pid; use openshell_core::policy::SandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BackendError, BoundaryExec, BoundaryExitStatus, BoundaryInput, BoundaryOutput, BoundaryProcess, - BoundarySignal, BoundaryTerminal, ExecSession, ExecSpec, + BackendError, BoundaryExitStatus, BoundaryInput, BoundaryOutput, BoundarySignal, ExecSpec, }; /// The sandbox executor. Every spawn reuses the same admitted policy and @@ -116,7 +117,6 @@ impl LocalBoundaryExec { stdout: Box::new(stdout), stderr: Some(Box::new(stderr)), terminal: None, - output_status: None, }) } @@ -289,7 +289,6 @@ impl LocalBoundaryExec { stdout, stderr, terminal: None, - output_status: None, }), process, armed: true, @@ -385,7 +384,6 @@ impl LocalBoundaryExec { stdout: Box::new(tokio::fs::File::from_std(output)), stderr: None, terminal: Some(terminal), - output_status: None, }), process, armed: true, diff --git a/crates/openshell-sandbox/src/boundary_io.rs b/crates/openshell-sandbox/src/boundary_io.rs index 1889052421..7d0e65f7b0 100644 --- a/crates/openshell-sandbox/src/boundary_io.rs +++ b/crates/openshell-sandbox/src/boundary_io.rs @@ -3,10 +3,10 @@ //! Sandbox-local [`BoundaryLoopbackConnector`] implementation. +use crate::native_api::BoundaryLoopbackConnector; + use async_trait::async_trait; -use openshell_isolation_interface::contract::{ - BackendError, BoundaryDuplexStream, BoundaryLoopbackConnector, LoopbackTarget, -}; +use openshell_isolation_interface::contract::{BackendError, BoundaryDuplexStream, LoopbackTarget}; use std::collections::HashMap; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 16299730c9..a0c3bf5bc5 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -13,6 +13,10 @@ use std::path::Path; #[cfg(target_os = "linux")] mod linux { + use crate::native_api::{ + BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, ExecSession, + }; + use std::fs::File; use std::io::{self, Read, Write}; use std::mem::size_of; @@ -35,17 +39,25 @@ mod linux { }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, - BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, + BoundaryConfirmation, LoopbackTarget, ResolvedWorkloadIdentity, + }; + use openshell_sandbox_backend::backend_proto::{ + DescribeIsolationBackendRequest, DescribeIsolationBackendResponse, OpenBoundaryRequest, + OpenBoundaryResponse, ResumeBoundaryRequest, SuspendBoundaryRequest, + SuspendBoundaryResponse, + isolation_backend_server::{IsolationBackend, IsolationBackendServer}, + open_boundary_response::LaunchOutcome, }; + #[cfg(test)] + use openshell_sandbox_backend::isolation_protocol::{chunk_data, data_chunk}; use openshell_sandbox_backend::mediation::{ self, DnsQueryWire, MediationFrame, MediationFrameKind, }; #[cfg(test)] - use openshell_sandbox_backend::proto::isolation_boundary_client::IsolationBoundaryClient; + use openshell_sandbox_backend::proto::sandbox_protocol_client::SandboxProtocolClient; use openshell_sandbox_backend::proto::{ - BoundaryChunk, - isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, + ExchangeFrame, MediateFrame, + sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use openshell_sandbox_backend::sandbox_auth::{ SandboxAuthError, SandboxConnectionId, SandboxConnectionRegistry, @@ -297,7 +309,7 @@ mod linux { } validate_resource_claims(&config.resource_claims).map_err(|error| error.to_string())?; config - .outer_fence + .provisioning_evidence .validate(&config.generation) .map_err(|error| error.to_string())?; for (claim, path) in &config.resource_claim_files { @@ -589,6 +601,14 @@ mod linux { tokio_stream::pending(), ); let mut shutdown = connection_closed.clone(); + let service = GrpcBoundaryService { + runtime: runtime.clone(), + connection_id, + connection_expiry, + connection_closed, + negotiated: Arc::new(AtomicBool::new(false)), + opened: Arc::new(AtomicBool::new(false)), + }; let result = tonic::transport::Server::builder() .http2_keepalive_interval(Some(CONTROL_KEEPALIVE_INTERVAL)) .http2_keepalive_timeout(Some(CONTROL_KEEPALIVE_TIMEOUT)) @@ -596,14 +616,18 @@ mod linux { .initial_stream_window_size(BOUNDARY_STREAM_WINDOW_BYTES) .initial_connection_window_size(BOUNDARY_CONNECTION_WINDOW_BYTES) .add_service( - IsolationBoundaryServer::new(GrpcBoundaryService { - runtime: runtime.clone(), - connection_id, - connection_expiry, - connection_closed, - }) - .max_decoding_message_size(64 * 1024) - .max_encoding_message_size(64 * 1024), + IsolationBackendServer::new(service.clone()) + .max_decoding_message_size(64 * 1024) + .max_encoding_message_size(64 * 1024), + ) + .add_service( + SandboxProtocolServer::new(service) + .max_decoding_message_size( + openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES, + ) + .max_encoding_message_size( + openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES, + ), ) .serve_with_incoming_shutdown(incoming, async move { let _ = shutdown.changed().await; @@ -677,6 +701,8 @@ mod linux { connection_id: SandboxConnectionId, connection_expiry: Arc, connection_closed: tokio::sync::watch::Receiver<()>, + negotiated: Arc, + opened: Arc, } struct ConnectionExpiry { @@ -754,24 +780,125 @@ mod linux { } } - type GrpcResponseStream = ReceiverStream>; - #[tonic::async_trait] - impl IsolationBoundary for GrpcBoundaryService { - type ExchangeStream = GrpcResponseStream; - type MediateStream = GrpcResponseStream; + impl IsolationBackend for GrpcBoundaryService { + async fn describe( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let backend = + openshell_sandbox_backend::isolation_protocol::metadata("openshell/sandbox"); + openshell_sandbox_backend::isolation_protocol::negotiate( + &backend, + request.into_inner().supervisor, + )?; + self.negotiated.store(true, Ordering::Release); + Ok(tonic::Response::new(DescribeIsolationBackendResponse { + backend: Some(backend), + })) + } - async fn exchange( + async fn open_boundary( &self, - request: tonic::Request>, - ) -> Result, tonic::Status> { + request: tonic::Request, + ) -> Result, tonic::Status> { let principal = self .runtime .authenticate_request(self.connection_id, request.metadata())?; + if !self.negotiated.load(Ordering::Acquire) { + return Err(tonic::Status::failed_precondition( + "negotiate isolation capabilities before opening a boundary", + )); + } + let request = request.into_inner(); + openshell_sandbox_backend::isolation_protocol::validate_open( + &request, + &self.runtime.config, + )?; self.connection_expiry .update(principal.session().expires_at); + self.opened.store(true, Ordering::Release); + Ok(tonic::Response::new(OpenBoundaryResponse { + boundary_id: self.runtime.routing_handle.clone(), + outcome: LaunchOutcome::Fresh as i32, + })) + } + + async fn suspend_boundary( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + self.runtime + .authenticate_request(self.connection_id, request.metadata())?; + Err(tonic::Status::unimplemented( + "OpenShell sandbox does not advertise suspend/resume", + )) + } + + async fn resume_boundary( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + self.runtime + .authenticate_request(self.connection_id, request.metadata())?; + Err(tonic::Status::unimplemented( + "OpenShell sandbox does not advertise suspend/resume", + )) + } + } + + impl GrpcBoundaryService { + async fn bind_stream( + &self, + request: tonic::Request>, + ) -> Result<(tonic::Streaming, SandboxProtocolPrincipal), tonic::Status> { + let principal = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + if !self.opened.load(Ordering::Acquire) { + return Err(tonic::Status::failed_precondition( + "open a boundary before opening Sandbox Protocol streams", + )); + } + self.connection_expiry + .update(principal.session().expires_at); + let mut inbound = request.into_inner(); + let frame = tokio::time::timeout(CONTROL_HANDSHAKE_TIMEOUT, inbound.message()) + .await + .map_err(|_| { + tonic::Status::deadline_exceeded("boundary binding frame timed out") + })??; + match frame.as_ref().and_then(F::boundary_id) { + Some(id) if id == self.runtime.routing_handle => {} + Some(_) => { + return Err(tonic::Status::permission_denied( + "boundary handle does not match authenticated runtime", + )); + } + _ => { + return Err(tonic::Status::invalid_argument( + "first Sandbox Protocol frame must bind a boundary", + )); + } + } + Ok((inbound, principal)) + } + } + + type GrpcResponseStream = ReceiverStream>; + + #[tonic::async_trait] + impl SandboxProtocol for GrpcBoundaryService { + type ExchangeStream = GrpcResponseStream; + type MediateStream = GrpcResponseStream; + + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let (inbound, principal) = self.bind_stream(request).await?; let (stream, response) = - bridge_grpc_server_stream(request.into_inner(), self.connection_closed.clone()); + bridge_grpc_server_stream(inbound, self.connection_closed.clone()); let runtime = self.runtime.clone(); tokio::task::spawn_blocking(move || { let stream = ControlStream::Grpc { @@ -787,15 +914,11 @@ mod linux { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { - let principal = self - .runtime - .authenticate_request(self.connection_id, request.metadata())?; - self.connection_expiry - .update(principal.session().expires_at); + let (inbound, principal) = self.bind_stream(request).await?; let (stream, response) = - bridge_grpc_server_stream(request.into_inner(), self.connection_closed.clone()); + bridge_grpc_server_stream(inbound, self.connection_closed.clone()); let runtime = self.runtime.clone(); tokio::spawn(async move { if let Err(error) = serve_persistent_mediation(stream, runtime, principal).await { @@ -806,23 +929,36 @@ mod linux { } } - fn bridge_grpc_server_stream( - mut inbound: tonic::Streaming, + fn bridge_grpc_server_stream( + mut inbound: tonic::Streaming, connection_closed: tokio::sync::watch::Receiver<()>, - ) -> (tokio::io::DuplexStream, GrpcResponseStream) { + ) -> (tokio::io::DuplexStream, GrpcResponseStream) { let (application, bridge) = tokio::io::duplex(256 * 1024); let (mut reader, mut writer) = tokio::io::split(bridge); - let (outbound, outbound_rx) = - tokio::sync::mpsc::channel::>(64); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel::>(64); + // Do not retain the response sender while the client request direction + // stays open: finite exchanges must still observe the server's EOF. + let invalid_frames = outbound.downgrade(); let mut inbound_closed = connection_closed.clone(); tokio::spawn(async move { tokio::select! { _ = inbound_closed.changed() => {}, () = async { + let mut mode = openshell_sandbox_backend::stream_adapter::Mode::Request; loop { match inbound.message().await { Ok(Some(chunk)) => { - if writer.write_all(&chunk.data).await.is_err() { + let data = match chunk.receive(&mut mode).map_err(|error|tonic::Status::invalid_argument(error.to_string())) { + Ok(data) => data, + Err(error) => { + if let Some(outbound) = invalid_frames.upgrade() { + let _ = outbound.send(Err(error)).await; + } + let _ = writer.shutdown().await; + return; + } + }; + if writer.write_all(&data).await.is_err() { return; } } @@ -844,26 +980,12 @@ mod linux { tokio::select! { _ = outbound_closed.changed() => {}, () = async { - let mut buffer = vec![0_u8; 16 * 1024]; + let mut mode = openshell_sandbox_backend::stream_adapter::Mode::Response; loop { - let read = match reader.read(&mut buffer).await { - Ok(read) => read, - Err(error) => { - tracing::debug!(%error, "Boundary gRPC response reader ended"); - return; - } - }; - if read == 0 { - return; - } - if outbound - .send(Ok(BoundaryChunk { - data: buffer[..read].to_vec(), - })) - .await - .is_err() - { - return; + match F::send(&mut reader,&mut mode).await { + Ok(Some(chunk)) => if outbound.send(Ok(chunk)).await.is_err() { return; }, + Ok(None) => return, + Err(error) => { let _=outbound.send(Err(tonic::Status::invalid_argument(error.to_string()))).await;return; } } } } => {}, @@ -1031,7 +1153,7 @@ mod linux { queue_wait_us: duration_micros(pending.queued_at.elapsed()), }, }; - let Ok(payload) = mediation::encode_json(&query) else { + let Ok(payload) = mediation::encode_message(&query) else { routes.lock().await.remove(&stream_id); return; }; @@ -1052,7 +1174,7 @@ mod linux { kind: MediationFrameKind::DnsResponse, payload, .. - }) => mediation::decode_json::(&payload) + }) => mediation::decode_message::(&payload) .map_err(io::Error::other) .and_then(|response| match response { DnsQueryResultWire::Response(response) => Ok(response), @@ -1107,7 +1229,7 @@ mod linux { let started = match runtime.start_exec( &request.request_id, &request.payload_digest, - request.exec_expires_at_unix_ms, + request.exec_admission_deadline_unix_ms, spec, ) { Ok(started) => started, @@ -1186,7 +1308,7 @@ mod linux { &mut stream, &ResponseEnvelope { request_id: request.request_id, - response: Response::PortConnected, + response: Response::LoopbackConnected, }, ) .map_err(|error| format!("write port-forward response: {error}"))?; @@ -1221,7 +1343,7 @@ mod linux { response: Response::NetworkConnected { identity: BinaryIdentityWire::from(pending.identity.clone()), destination: pending.destination, - socket: pending.socket, + socket: Some(pending.socket), policy_generation: 0, timing: MediationTimingWire { notification_to_queue_us: duration_micros( @@ -1247,7 +1369,7 @@ mod linux { "unexpected network-open decision channel {channel}" )); } - let decision = serde_json::from_slice(&payload) + let decision = openshell_sandbox_backend::protobuf::decode(&payload) .map_err(|error| format!("decode network-open decision: {error}"))?; let Some(target) = pending .complete(decision) @@ -1306,6 +1428,7 @@ mod linux { struct BoundaryRuntime { config: BoundaryConfig, + routing_handle: String, authenticator: SandboxProtocolAuthenticator, connections: SandboxConnectionRegistry, connection_shutdowns: @@ -1466,7 +1589,6 @@ mod linux { struct StartedAgent { sandbox_id: String, spec: AgentSpecWire, - policy: SandboxPolicyWire, ca_cert: Option, ca_bundle: Option, provider_env_revision: u64, @@ -1481,7 +1603,6 @@ mod linux { fn matches_replay(&self, other: &Self) -> bool { self.sandbox_id == other.sandbox_id && self.spec == other.spec - && self.policy == other.policy && self.ca_cert == other.ca_cert && self.ca_bundle == other.ca_bundle } @@ -1612,6 +1733,7 @@ mod linux { config.session_rotation, ), connection_shutdowns: Mutex::new(std::collections::HashMap::new()), + routing_handle: uuid::Uuid::new_v4().to_string(), config, process_runtime, state: Mutex::new(RuntimeState::AwaitingAttach), @@ -1984,29 +2106,11 @@ mod linux { let payload_digest = envelope.payload_digest; let response = match envelope.request { Request::DiscoverPolicy => Self::discover_image_policy(), - Request::Attach { - supervisor_instance_id: _, - policy, - resource_claims, - } => { - if resource_claims == self.config.resource_claims { - self.attach(*policy) - } else { - guest_error( - BoundaryErrorKind::Denied, - "backend resource claims do not match the boundary configuration", - ) - } - } + Request::Attach { policy, .. } => self.attach(*policy), Request::Confirm => self.confirm(), - Request::ProbeProviderFiles => self.network_broker.confirm_healthy().map_or_else( - |error| guest_error(BoundaryErrorKind::Unavailable, error.to_string()), - |()| Response::ProviderFilesSupported, - ), Request::StartAgent { sandbox_id, spec, - policy, ca_cert, ca_bundle, provider_env_revision, @@ -2015,7 +2119,6 @@ mod linux { } => self.start_agent( sandbox_id, spec, - *policy, ca_cert, ca_bundle, provider_env_revision, @@ -2036,7 +2139,6 @@ mod linux { Request::Wait { process_id } => self.wait(&process_id), Request::Signal { process_id, signal } => self.signal(&process_id, signal), Request::Terminate { process_id } => self.terminate(&process_id), - Request::ExecSignal { process_id, signal } => self.signal_exec(&process_id, signal), Request::Resize { process_id, cols, @@ -2156,7 +2258,6 @@ mod linux { stdout, stderr, terminal, - output_status: _, } = session; let Some(stdin) = stdin else { return Err(guest_error( @@ -2167,7 +2268,7 @@ mod linux { let retained = { let _runtime = self.process_runtime.enter(); MainSession::from_boundary( - openshell_isolation_interface::contract::ProcessAttachment { + crate::native_api::ProcessAttachment { stdin, stdout, stderr, @@ -2202,19 +2303,6 @@ mod linux { }) } - fn signal_exec(&self, process_id: &str, signal: SignalWire) -> Response { - let process = lock(&self.exec_handles) - .get(process_id) - .map(|handle| handle.process.clone()); - let Some(process) = process else { - return guest_error(BoundaryErrorKind::Invalid, "unknown exec process ID"); - }; - match self.process_runtime.block_on(process.signal(signal.into())) { - Ok(()) => Response::Signaled, - Err(error) => guest_error(BoundaryErrorKind::Process, error.to_string()), - } - } - fn resize_process(&self, process_id: &str, cols: u16, rows: u16) -> Response { if let Ok(process) = self.running_process(process_id) { let session = process.main_session(); @@ -2464,17 +2552,13 @@ mod linux { // backend validates it before constructing a ConfirmedBoundary. // Keeping that decision at the verifier also lets lifecycle tests // exercise the protocol without claiming host-kernel enforcement. - let properties = audit.properties(); let backend_audit = serde_json::to_value(audit) .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; Ok(BoundaryConfirmation { generation: self.config.generation.clone(), identity: self.config.workload_identity.clone(), - properties, - authenticated_supervisor: true, session_id: self.config.session_id, - outer_fence: self.config.outer_fence.clone(), - runtime_exit_terminates_workload: true, + provisioning_evidence_digest: self.config.provisioning_evidence.evidence_digest, resource_claims: self.config.resource_claims.clone(), backend_audit, }) @@ -2485,18 +2569,18 @@ mod linux { &self, sandbox_id: String, spec: AgentSpecWire, - policy: SandboxPolicyWire, ca_cert: Option, ca_bundle: Option, provider_env_revision: u64, provider_env: std::collections::HashMap, provider_files: std::collections::HashMap, ) -> Response { - // Check the supplied launch policy before installing materials or - // replacing its selectors with the measured driver's numeric pair. - if let Err(error) = validate_vm_policy_identity(&self.config, &policy) { - return guest_error(BoundaryErrorKind::Denied, error); - } + let Some(policy) = lock(&self.attached_policy).clone() else { + return guest_error( + BoundaryErrorKind::Invalid, + "boundary must be attached before start_agent", + ); + }; let spec = match resolve_agent_spec(spec) { Ok(spec) => spec, Err(error) => return guest_error(BoundaryErrorKind::Process, error), @@ -2505,7 +2589,6 @@ mod linux { let requested = StartedAgent { sandbox_id, spec: spec.clone(), - policy: policy.clone(), ca_cert: ca_cert.clone(), ca_bundle: ca_bundle.clone(), provider_env_revision, @@ -2535,6 +2618,15 @@ mod linux { "boundary must be confirmed before start_agent", ); }; + if let Err(error) = self.network_broker.confirm_healthy() { + return guest_error(BoundaryErrorKind::Unavailable, error.to_string()); + } + if let Err(error) = crate::provider_files::ProviderFiles::validate(&provider_files) { + return guest_error( + BoundaryErrorKind::Invalid, + format!("invalid provider files: {error}"), + ); + } let ca_file_paths = match install_ca_material(ca_cert, ca_bundle) { Ok(paths) => paths, Err(error) => return guest_error(BoundaryErrorKind::Process, error), @@ -2632,6 +2724,9 @@ mod linux { applied: false, }; } + if let Err(error) = self.network_broker.confirm_healthy() { + return guest_error(BoundaryErrorKind::Unavailable, error.to_string()); + } if let Err(error) = crate::provider_files::ProviderFiles::validate(&provider_files) { return guest_error( BoundaryErrorKind::Invalid, @@ -2701,6 +2796,15 @@ mod linux { } fn signal(&self, process_id: &str, signal: SignalWire) -> Response { + let exec = lock(&self.exec_handles) + .get(process_id) + .map(|handle| handle.process.clone()); + if let Some(process) = exec { + return match self.process_runtime.block_on(process.signal(signal.into())) { + Ok(()) => Response::Signaled, + Err(error) => guest_error(BoundaryErrorKind::Process, error.to_string()), + }; + } let process = match self.running_process(process_id) { Ok(process) => process, Err(response) => return response, @@ -3144,8 +3248,9 @@ mod linux { Ok(MainOutput::Stdout(payload)) => (STREAM_STDOUT, payload.to_vec()), Ok(MainOutput::Stderr(payload)) => (STREAM_STDERR, payload.to_vec()), Ok(MainOutput::Exit(code)) => { - let status = serde_json::to_vec(&attachment.exit_status(code)) - .map_err(|error| format!("encode main process exit: {error}"))?; + let status = + openshell_sandbox_backend::protobuf::encode(&attachment.exit_status(code)) + .map_err(|error| format!("encode main process exit: {error}"))?; break write_stream_frame(&mut *writer.lock().await, STREAM_EXIT, &status) .await .map_err(|error| format!("write main process exit: {error}")); @@ -3160,8 +3265,10 @@ mod linux { let _ = write_stream_frame(&mut *writer.lock().await, STREAM_STDERR, message) .await; - let status = serde_json::to_vec(&ExitStatusWire::Exited(74)) - .map_err(|error| format!("encode exec output failure: {error}"))?; + let status = openshell_sandbox_backend::protobuf::encode( + &ExitStatusWire::Exited(74), + ) + .map_err(|error| format!("encode exec output failure: {error}"))?; break write_stream_frame(&mut *writer.lock().await, STREAM_EXIT, &status) .await .map_err(|error| format!("write exec output failure: {error}")); @@ -3743,6 +3850,8 @@ mod linux { GatewayVerificationKey, SandboxTlsClientConfig, SandboxTlsServerConfig, generate_sandbox_tls_material, }; + use openshell_sandbox_backend::proto::{exchange_frame::Payload, mediate_frame}; + use openshell_sandbox_backend::protobuf::WireMessage as _; use rcgen::{KeyPair, PKCS_ED25519}; struct FailedWaitProcess; @@ -3933,6 +4042,61 @@ mod linux { request } + fn test_open_request(config: &BoundaryConfig) -> OpenBoundaryRequest { + let descriptor = + openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor { + boundary_id: config.boundary_id.clone(), + generation: config.generation.clone(), + session_id: config.session_id, + workload_identity: config.workload_identity.clone(), + transport: + openshell_sandbox_backend::boundary_protocol::SandboxTransport::Unix { + socket_path: Path::new("/test/boundary.sock").to_path_buf(), + }, + tls: SandboxTlsClientConfig { + server_name: "test-boundary".to_string(), + trust_anchor_pem: "unused-test-anchor".to_string(), + }, + host_gateway_ip: None, + resource_claims: config.resource_claims.clone(), + provisioning_evidence: config.provisioning_evidence.clone(), + }; + OpenBoundaryRequest { + session_id: config.session_id.to_string(), + backend_name: openshell_sandbox_backend::BACKEND_NAME.to_string(), + driver_descriptor: serde_json::to_vec(&descriptor).unwrap(), + } + } + + async fn test_open_boundary( + channel: tonic::transport::Channel, + request: OpenBoundaryRequest, + token: &str, + ) -> String { + let mut control = openshell_sandbox_backend::backend_proto::isolation_backend_client::IsolationBackendClient::new(channel); + control + .describe(DescribeIsolationBackendRequest { + supervisor: Some(openshell_sandbox_backend::isolation_protocol::metadata( + "test/supervisor", + )), + }) + .await + .unwrap(); + let first = control + .open_boundary(bearer_request(request.clone(), token)) + .await + .unwrap() + .into_inner(); + let retry = control + .open_boundary(bearer_request(request, token)) + .await + .unwrap() + .into_inner(); + assert_eq!(first, retry, "opening a launch is idempotent"); + assert_eq!(first.outcome, LaunchOutcome::Fresh as i32); + first.boundary_id + } + fn placeholder_server_tls() -> SandboxTlsServerConfig { SandboxTlsServerConfig { certificate_chain_path: Path::new("/tmp/openshell-sandbox.crt").to_path_buf(), @@ -3996,7 +4160,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }; let debug = format!("{config:?}"); @@ -4110,7 +4274,7 @@ mod linux { )]), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }; assert!(allows_runtime_supplementary_groups(&config)); @@ -4181,7 +4345,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4460,14 +4624,23 @@ mod linux { .await .unwrap(); let (sender, receiver) = tokio::sync::mpsc::channel(4); + let handle = + test_open_boundary(channel.clone(), test_open_request(&runtime.config), &token) + .await; + sender + .send(MediateFrame { + payload: Some(mediate_frame::Payload::BoundaryId(handle.clone())), + }) + .await + .unwrap(); let request = RequestEnvelope::new(Request::OpenMediation).unwrap(); sender - .send(BoundaryChunk { - data: encode_frame(&request).unwrap(), + .send(MediateFrame { + payload: Some(mediate_frame::Payload::Request(request.to_proto().unwrap())), }) .await .unwrap(); - let mut response = IsolationBoundaryClient::new(channel) + let mut response = SandboxProtocolClient::new(channel) .mediate(bearer_request(ReceiverStream::new(receiver), &token)) .await .unwrap() @@ -4616,17 +4789,10 @@ mod linux { .unwrap() } - fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { - use openshell_isolation_interface::contract::OuterFenceGuarantee; - - openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + fn test_provisioning_evidence() + -> openshell_isolation_interface::contract::ProvisioningEvidence { + openshell_isolation_interface::contract::ProvisioningEvidence::from_enforcement_evidence( "generation-1", - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], b"test-vm-fence", ) .unwrap() @@ -4775,7 +4941,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }; @@ -4932,51 +5098,6 @@ mod linux { )); } - #[test] - fn vm_start_rejects_conflicting_identity_without_launch() { - let (_runtime, boundary) = vm_identity_test_runtime(); - let identity = &boundary.config.workload_identity; - let uid = identity.uid.to_string(); - let gid = identity.gid.to_string(); - let wrong_user = if uid == "10000" { "10001" } else { "10000" }; - let wrong_group = if gid == "10000" { "10001" } else { "10000" }; - *lock(&boundary.state) = RuntimeState::Ready(PreparedBoundary { - network_broker: boundary.network_broker.clone(), - }); - for (user, group, field, requested) in [ - (Some(wrong_user), None, "run_as_user", wrong_user), - (None, Some(wrong_group), "run_as_group", wrong_group), - ] { - let response = boundary.start_agent( - boundary.config.boundary_id.clone(), - AgentSpecWire { - program: "/bin/true".to_string(), - args: Vec::new(), - workdir: None, - timeout_secs: 5, - interactive: false, - }, - vm_identity_test_policy(user, group), - None, - None, - 0, - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ); - let Response::Error { kind, message } = response else { - panic!("conflicting identity reached process start: {response:?}"); - }; - assert_eq!(kind, BoundaryErrorKind::Denied); - assert!( - message.contains(&format!("{field} '{requested}'")), - "{message}" - ); - assert!(message.contains(&format!("{uid}:{gid}")), "{message}"); - assert!(matches!(*lock(&boundary.state), RuntimeState::Ready(_))); - assert!(lock(&boundary.started_agent).is_none()); - } - } - #[test] fn tcp_control_listener_rejects_loopback_addresses() { let directory = tempfile::tempdir().expect("temporary directory"); @@ -4997,7 +5118,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }; for address in [ @@ -5044,7 +5165,7 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }; @@ -5058,6 +5179,264 @@ mod linux { assert!(validate_runtime_resource_claims(&config).is_err()); } + #[tokio::test(flavor = "multi_thread")] + async fn delegated_control_and_streams_reject_unbound_or_mismatched_launches() { + let (runtime, token) = availability_test_runtime(); + let opening = test_open_request(&runtime.config); + let server_runtime = runtime.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + let (stream, _) = listener.accept().await.unwrap(); + let runtime = server_runtime.clone(); + connections.spawn(async move { + serve_grpc(Box::new(stream), runtime, SandboxConnectionId::new()) + .await + .unwrap(); + }); + } + }); + let channel = tonic::transport::Endpoint::from_shared(format!("http://{address}")) + .unwrap() + .connect() + .await + .unwrap(); + let mut control = openshell_sandbox_backend::backend_proto::isolation_backend_client::IsolationBackendClient::new(channel.clone()); + let mut streams = SandboxProtocolClient::new(channel.clone()); + let before_open = streams + .exchange(bearer_request( + tokio_stream::iter([data_chunk(Vec::new())]), + &token, + )) + .await; + assert_eq!( + before_open.err().unwrap().code(), + tonic::Code::FailedPrecondition + ); + assert_eq!( + control + .open_boundary(bearer_request(opening.clone(), &token)) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + assert_eq!( + control + .describe(DescribeIsolationBackendRequest { supervisor: None }) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let mut incompatible = + openshell_sandbox_backend::isolation_protocol::metadata("test/supervisor"); + incompatible.protocol_version.as_mut().unwrap().major += 1; + assert_eq!( + control + .describe(DescribeIsolationBackendRequest { + supervisor: Some(incompatible) + }) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let mut compatible = + openshell_sandbox_backend::isolation_protocol::metadata("test/supervisor"); + compatible.protocol_version.as_mut().unwrap().minor += 1; + compatible + .supported_capabilities + .push("openshell.isolation.future".to_string()); + let mut unmet_requirement = compatible.clone(); + unmet_requirement + .required_capabilities + .push("openshell.isolation.future".to_string()); + assert_eq!( + control + .describe(DescribeIsolationBackendRequest { + supervisor: Some(unmet_requirement), + }) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + control + .describe(DescribeIsolationBackendRequest { + supervisor: Some(compatible), + }) + .await + .unwrap(); + assert_eq!( + control + .open_boundary(opening.clone()) + .await + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + for field in ["session", "backend", "descriptor"] { + let mut changed = opening.clone(); + match field { + "session" => changed.session_id = "other-session".to_string(), + "backend" => changed.backend_name = "other-backend".to_string(), + "descriptor" => { + let mut descriptor: openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor = serde_json::from_slice(&changed.driver_descriptor).unwrap(); + descriptor + .resource_claims + .insert("unprovisioned".to_string(), "resource".to_string()); + changed.driver_descriptor = serde_json::to_vec(&descriptor).unwrap(); + } + _ => unreachable!(), + } + assert_eq!( + control + .open_boundary(bearer_request(changed, &token)) + .await + .unwrap_err() + .code(), + tonic::Code::PermissionDenied + ); + } + let handle = test_open_boundary(channel.clone(), opening.clone(), &token).await; + // JSON formatting does not change the provisioned resource binding. + let mut retry = opening.clone(); + let descriptor: openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor = + serde_json::from_slice(&retry.driver_descriptor).unwrap(); + retry.driver_descriptor = serde_json::to_vec_pretty(&descriptor).unwrap(); + assert_eq!( + control + .open_boundary(bearer_request(retry, &token)) + .await + .unwrap() + .into_inner() + .boundary_id, + handle + ); + // Knowing a previous handle does not open a new physical connection. + let replacement = tonic::transport::Endpoint::from_shared(format!("http://{address}")) + .unwrap() + .connect() + .await + .unwrap(); + let binding = openshell_sandbox_backend::isolation_protocol::exchange_frame(&handle); + let mut replacement_streams = SandboxProtocolClient::new(replacement.clone()); + assert_eq!( + replacement_streams + .exchange(bearer_request( + tokio_stream::iter([binding.clone()]), + &token + )) + .await + .err() + .unwrap() + .code(), + tonic::Code::FailedPrecondition + ); + assert_eq!( + replacement_streams + .mediate(bearer_request( + tokio_stream::iter([MediateFrame { + payload: Some(mediate_frame::Payload::BoundaryId(handle.clone())) + }]), + &token + )) + .await + .err() + .unwrap() + .code(), + tonic::Code::FailedPrecondition + ); + assert_eq!( + test_open_boundary(replacement, opening, &token).await, + handle + ); + assert_eq!( + control + .suspend_boundary(bearer_request(SuspendBoundaryRequest::default(), &token)) + .await + .unwrap_err() + .code(), + tonic::Code::Unimplemented + ); + assert_eq!( + control + .resume_boundary(bearer_request(ResumeBoundaryRequest::default(), &token)) + .await + .unwrap_err() + .code(), + tonic::Code::Unimplemented + ); + for frames in [ + vec![], + vec![data_chunk(Vec::new())], + vec![ExchangeFrame { payload: None }], + vec![openshell_sandbox_backend::isolation_protocol::exchange_frame("")], + vec![ + openshell_sandbox_backend::isolation_protocol::exchange_frame("other-boundary"), + ], + ] { + let expected = if frames + .first() + .is_some_and(|frame| matches!(frame.payload, Some(Payload::BoundaryId(_)))) + { + tonic::Code::PermissionDenied + } else { + tonic::Code::InvalidArgument + }; + assert_eq!( + streams + .exchange(bearer_request(tokio_stream::iter(frames.clone()), &token)) + .await + .err() + .unwrap() + .code(), + expected + ); + assert_eq!( + streams + .mediate(bearer_request( + tokio_stream::iter(frames.into_iter().map(|frame| MediateFrame { + payload: match frame.payload { + Some(Payload::BoundaryId(id)) => + Some(mediate_frame::Payload::BoundaryId(id)), + _ => None, + } + })), + &token + )) + .await + .err() + .unwrap() + .code(), + expected + ); + } + let binding = openshell_sandbox_backend::isolation_protocol::exchange_frame(&handle); + let mut response = streams + .exchange(bearer_request( + tokio_stream::iter([binding.clone(), binding]), + &token, + )) + .await + .unwrap() + .into_inner(); + let error = tokio::time::timeout(Duration::from_secs(2), response.message()) + .await + .unwrap() + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(matches!( + *lock(&runtime.state), + RuntimeState::AwaitingAttach + )); + assert!(lock(&runtime.started_agent).is_none()); + server.abort(); + } + #[tokio::test(flavor = "multi_thread")] async fn grpc_server_dispatches_authenticated_logical_streams() { let (workload_launcher, listener) = @@ -5083,7 +5462,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -5097,6 +5476,7 @@ mod linux { .await .expect("bind gRPC test listener"); let address = listener.local_addr().expect("gRPC test address"); + let opening = test_open_request(&boundary.config); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("accept gRPC client"); serve_grpc(Box::new(stream), boundary, SandboxConnectionId::new()).await @@ -5116,20 +5496,22 @@ mod linux { let request = RequestEnvelope::new(Request::Attach { supervisor_instance_id: test_supervisor_instance_id(), policy: Box::new(policy), - resource_claims: std::collections::BTreeMap::new(), }) .expect("encode attach request"); - let request_stream = tokio_stream::iter([BoundaryChunk { - data: encode_frame(&request).expect("encode logical request"), - }]); - let mut body = IsolationBoundaryClient::new(channel) + let handle = test_open_boundary(channel.clone(), opening, &token).await; + let request_stream = tokio_stream::iter([ + openshell_sandbox_backend::isolation_protocol::exchange_frame(&handle), + openshell_sandbox_backend::isolation_protocol::request_chunk(&request) + .expect("typed logical request"), + ]); + let mut body = SandboxProtocolClient::new(channel) .exchange(bearer_request(request_stream, &token)) .await .expect("exchange logical request") .into_inner(); let mut frame = Vec::new(); while let Some(chunk) = body.message().await.expect("read gRPC response") { - frame.extend_from_slice(&chunk.data); + frame.extend_from_slice(&chunk_data(chunk).unwrap()); } let response: ResponseEnvelope = openshell_sandbox_backend::boundary_protocol::decode_frame(&frame) @@ -5386,7 +5768,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -5420,7 +5802,6 @@ mod linux { boundary.start_agent( "sandbox-reconnect".to_string(), spec.clone(), - policy.clone(), None, None, 0, @@ -5512,14 +5893,13 @@ mod linux { let mut changed_policy = policy.clone(); changed_policy.version += 1; assert!(matches!( - boundary.attach(changed_policy.clone()), + boundary.attach(changed_policy), Response::Error { kind, .. } if kind == BoundaryErrorKind::Denied )); assert!(matches!( boundary.start_agent( "sandbox-reconnect".to_string(), - spec, - changed_policy, + AgentSpecWire { program: "/bin/false".to_string(), ..spec }, None, None, 0, @@ -5532,6 +5912,35 @@ mod linux { // Failed refresh and recovery can retain the same provider // fingerprint. Distinct publications must still replace the map, // while a delayed older clear must never undo the repair. + let process = boundary.running_process(&process_id).unwrap(); + let before = process + .provider_credentials + .child_environment_snapshot() + .unwrap(); + assert!(matches!( + boundary.update_provider_environment( + 2, + 8, + std::collections::HashMap::from([( + "ROTATED_TOKEN".to_string(), + "must-not-install".to_string() + )]), + std::collections::HashMap::from([( + "/outside/provider".to_string(), + "invalid path".to_string() + )]), + ), + Response::Error { + kind: BoundaryErrorKind::Invalid, + .. + } + )); + let after = process + .provider_credentials + .child_environment_snapshot() + .unwrap(); + assert_eq!(before.revision, after.revision); + assert_eq!(before.environment, after.environment); assert_eq!( boundary.update_provider_environment( 2, @@ -5592,7 +6001,7 @@ mod linux { .start_exec( &exec_request.request_id, &exec_request.payload_digest, - exec_request.exec_expires_at_unix_ms, + exec_request.exec_admission_deadline_unix_ms, exec_spec, ) .expect("exec after reconnect"); @@ -5624,7 +6033,7 @@ mod linux { "exec status must remain available after its output attachment closes" ); } - let Response::Attached { snapshot } = boundary.attach(policy.clone()) else { + let Response::Attached { snapshot } = boundary.attach(policy) else { panic!("reconnect attach did not return a session snapshot"); }; assert_eq!(snapshot.generation, "generation-reconnect"); @@ -5721,7 +6130,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -5737,7 +6146,6 @@ mod linux { provider_files: std::collections::HashMap::new(), sandbox_id: "sandbox-retained".to_string(), spec: agent_spec.clone(), - policy: wire_policy.clone(), ca_cert: None, ca_bundle: None, provider_env_revision: 0, @@ -5755,7 +6163,6 @@ mod linux { boundary.start_agent( "sandbox-retained".to_string(), agent_spec.clone(), - wire_policy.clone(), None, None, 0, @@ -5848,7 +6255,7 @@ mod linux { ); assert!(matches!( - boundary.attach(wire_policy.clone()), + boundary.attach(wire_policy), Response::Attached { .. } )); assert!(matches!(boundary.confirm(), Response::Confirmed { .. })); @@ -5856,7 +6263,6 @@ mod linux { boundary.start_agent( "sandbox-retained".to_string(), agent_spec, - wire_policy, None, None, 99, @@ -5909,7 +6315,7 @@ mod linux { requests .reserve( &format!("completed-request-{index}"), - sleep_request.exec_expires_at_unix_ms.unwrap(), + sleep_request.exec_admission_deadline_unix_ms.unwrap(), ) .unwrap(); } @@ -5918,7 +6324,7 @@ mod linux { .start_exec( &sleep_request.request_id, &sleep_request.payload_digest, - sleep_request.exec_expires_at_unix_ms, + sleep_request.exec_admission_deadline_unix_ms, sleep_spec.clone(), ) .expect("start exec whose response is disconnected"); @@ -5928,7 +6334,7 @@ mod linux { .start_exec( &sleep_request.request_id, &sleep_request.payload_digest, - sleep_request.exec_expires_at_unix_ms, + sleep_request.exec_admission_deadline_unix_ms, sleep_spec.clone(), ) .expect("reattach exec after response loss"); @@ -5936,7 +6342,7 @@ mod linux { assert_eq!(lock(&boundary.exec_handles).len(), 1); drop(replayed); assert_eq!( - boundary.signal_exec(&retained_id, SignalWire::Kill), + boundary.signal(&retained_id, SignalWire::Kill), Response::Signaled ); for _ in 0..2 { @@ -5954,7 +6360,7 @@ mod linux { .start_exec( &sleep_request.request_id, &sleep_request.payload_digest, - sleep_request.exec_expires_at_unix_ms, + sleep_request.exec_admission_deadline_unix_ms, sleep_spec, ) .is_err(), @@ -5988,7 +6394,7 @@ mod linux { .start_exec( &request.request_id, &request.payload_digest, - request.exec_expires_at_unix_ms, + request.exec_admission_deadline_unix_ms, spec, ) .expect("start exec after canonical exit"); diff --git a/crates/openshell-sandbox/src/delegated.rs b/crates/openshell-sandbox/src/delegated.rs index b0282e1016..d7ceb67024 100644 --- a/crates/openshell-sandbox/src/delegated.rs +++ b/crates/openshell-sandbox/src/delegated.rs @@ -3,6 +3,8 @@ //! Process and access-plane assembly for the capability-free sandbox boundary. +use crate::native_api::{BoundaryExec, BoundaryLoopbackConnector}; + use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::time::Duration; @@ -12,7 +14,6 @@ use miette::WrapErr as _; use miette::{IntoDiagnostic as _, Result}; use openshell_core::policy::SandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; -use openshell_isolation_interface::contract::{BoundaryExec, BoundaryLoopbackConnector}; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, LaunchTypeId, Process as OcsfProcess, ProcessActivityBuilder, SeverityId, StatusId, ocsf_emit, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index da99518833..8ca1738dd7 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -75,3 +75,6 @@ pub fn run( boundary_server::run_boundary(config_path, qualification) .map_err(|error| miette::miette!(error)) } + +#[cfg(target_os = "linux")] +mod native_api; diff --git a/crates/openshell-sandbox/src/main_session.rs b/crates/openshell-sandbox/src/main_session.rs index 7dd5d76442..08872c5aad 100644 --- a/crates/openshell-sandbox/src/main_session.rs +++ b/crates/openshell-sandbox/src/main_session.rs @@ -3,6 +3,8 @@ //! Retained I/O multiplexer for the canonical sandbox process. +use crate::native_api::{BoundaryProcess, BoundaryTerminal, ProcessAttachment}; + use std::collections::VecDeque; use std::io::{Read, Write}; use std::os::fd::AsRawFd; @@ -17,9 +19,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Notify; use tokio::sync::watch; -use openshell_isolation_interface::contract::{ - BoundaryProcess, BoundarySignal, BoundaryTerminal, ProcessAttachment, -}; +use openshell_isolation_interface::contract::BoundarySignal; use crate::container_log::{AgentOutputSink, AgentStream}; use crate::process::ProcessIo; diff --git a/crates/openshell-sandbox/src/native_api.rs b/crates/openshell-sandbox/src/native_api.rs new file mode 100644 index 0000000000..3d9adff950 --- /dev/null +++ b/crates/openshell-sandbox/src/native_api.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Private native process plumbing behind the Sandbox Protocol server. + +use async_trait::async_trait; +use openshell_isolation_interface::contract::{ + BackendError, BoundaryDuplexStream, BoundaryExitStatus, BoundaryInput, BoundaryOutput, + BoundarySignal, ExecSpec, LoopbackTarget, +}; +use std::sync::Arc; + +#[async_trait] +pub trait BoundaryProcess: Send + Sync { + /// Attach to the admitted process's retained standard I/O. The boundary + /// remains the process owner and may permit only one control attachment. + async fn attach(&self) -> Result { + Err(BackendError::Unsupported( + "process attachment is not supported".to_string(), + )) + } + /// Await terminal status (stable across repeated calls). + async fn wait(&self) -> Result; + /// Deliver a signal to the process or its group. + async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError>; + /// Terminate the process and its backend-owned process group. + async fn terminate(&self) -> Result<(), BackendError>; +} + +pub struct ProcessAttachment { + /// Stdin writer. + pub stdin: BoundaryInput, + /// Stdout reader, or the PTY-merged output stream. + pub stdout: BoundaryOutput, + /// Stderr reader, distinct from stdout for non-PTY processes. + pub stderr: Option, + /// PTY control, present when the admitted process owns a terminal. + pub terminal: Option>, +} + +#[async_trait] +pub trait BoundaryTerminal: Send + Sync { + /// Resize the terminal. + async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError>; +} + +pub struct ExecSession { + /// The spawned process. + pub process: Arc, + /// Stdin writer, if not a PTY-merged stream. + pub stdin: Option, + /// Stdout reader. + pub stdout: BoundaryOutput, + /// Stderr reader, distinct from stdout for non-PTY exec. + pub stderr: Option, + /// PTY control, present when a terminal was requested. + pub terminal: Option>, +} + +#[async_trait] +pub trait BoundaryExec: Send + Sync { + /// Spawn `spec` inside the boundary, returning an owned session. + async fn exec(&self, spec: ExecSpec) -> Result; +} + +#[async_trait] +pub trait BoundaryLoopbackConnector: Send + Sync { + /// Connect to `target` inside the boundary. + async fn connect(&self, target: LoopbackTarget) -> Result; +} diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index ec15bb17ca..58f735c35c 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -9,6 +9,15 @@ The server owns control-plane RPCs, authentication, provider credential delivery and sandbox lifecycle orchestration. The `openshell-gateway` crate composes the server with selected compute drivers. +## Sandbox launch identity + +A new start, including an automatic restart, commits a fresh runtime generation +and an incremented authorization epoch before invoking the compute driver. +The launch session ID is derived from the durable sandbox ID and generation. +Retries reuse that generation and session, including after gateway restart. +Credential renewal retains both; credentials for an older launch cannot +authorize the new one. + ## Operator credential export `GetProviderCredentials` exports selected profile-declared runtime environment diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index f286820e51..fcf5eaf223 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -21,6 +21,7 @@ pub use openshell_extension_core::{ MAX_EXTENSION_TOKEN_TTL, }; use serde::Serialize; +use sha2::{Digest as _, Sha256}; use std::{ io::Cursor, sync::Arc, @@ -196,6 +197,20 @@ impl SandboxSessionJwtAuthority { gateway_token_id: uuid::Uuid, token_metadata: Option<(uuid::Uuid, i64)>, ) -> Result { + // Derive the session from durable launch identity, not token IDs or + // minting time. Retries and credential renewal retain this session; + // a fresh generation gets a different one without new stored metadata. + let mut digest = Sha256::new(); + digest.update(b"openshell-launch-session-v1\0"); + digest.update(sandbox_id.as_bytes()); + digest.update(b"\0"); + digest.update(runtime_generation.as_str().as_bytes()); + let mut session_bytes = [0_u8; 16]; + session_bytes.copy_from_slice(&digest.finalize()[..16]); + let session_id = uuid::Uuid::from_bytes(session_bytes) + .to_string() + .parse::() + .map_err(|_| Status::internal("invalid derived sandbox session identity"))?; let identity = SandboxRuntimeIdentity { sandbox_id: SandboxId::parse(sandbox_id) .map_err(|_| Status::invalid_argument("sandbox ID is invalid"))?, @@ -223,7 +238,7 @@ impl SandboxSessionJwtAuthority { })?; Ok(SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { - session_id: SandboxSessionId::new(), + session_id, runtime_generation, session_rotation: openshell_core::jwt::SessionRotation::new(1) .map_err(|error| Status::internal(error.to_string()))?, @@ -499,6 +514,54 @@ mod tests { ExtensionAudience::new(value).expect("valid extension audience") } + #[test] + fn launch_session_survives_identity_reload_and_token_renewal() { + let material = generate_jwt_key().unwrap(); + let authority = SandboxSessionJwtAuthority::from_pem( + material.signing_key_pem.as_bytes(), + material.public_key_pem.as_bytes(), + material.kid, + "test-gateway", + Some(Duration::from_mins(15)), + ) + .unwrap(); + let identity = crate::auth::sandbox_session::PersistedSandboxIdentity::new().unwrap(); + let first = authority + .mint_persisted_launch("sandbox-a", &identity) + .unwrap(); + let mut annotations = std::collections::HashMap::new(); + identity.write(&mut annotations); + let mut reloaded = + crate::auth::sandbox_session::PersistedSandboxIdentity::read(&annotations).unwrap(); + reloaded.gateway_token_id = uuid::Uuid::new_v4(); + let renewed = authority + .mint_persisted_launch("sandbox-a", &reloaded) + .unwrap(); + assert_eq!(first.supervisor.session_id, renewed.supervisor.session_id); + assert_eq!( + first.supervisor.runtime_generation, + renewed.supervisor.runtime_generation + ); + assert_ne!( + first.supervisor.gateway_token.expose_secret(), + renewed.supervisor.gateway_token.expose_secret() + ); + + let other_sandbox = authority + .mint_persisted_launch("sandbox-b", &identity) + .unwrap(); + assert_ne!( + first.supervisor.session_id, + other_sandbox.supervisor.session_id + ); + let next = crate::auth::sandbox_session::PersistedSandboxIdentity::new().unwrap(); + let next_launch = authority.mint_persisted_launch("sandbox-a", &next).unwrap(); + assert_ne!( + first.supervisor.session_id, + next_launch.supervisor.session_id + ); + } + #[test] fn zero_ttl_is_rejected() { let mat = generate_jwt_key().expect("jwt key"); diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 19a22e5d16..f7e0b31efb 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -912,6 +912,7 @@ fn run_effective_config_preflight( }; let mut selected_driver = None; if let Some(selection) = selection.as_ref() { + validate_preflight_isolation_backend(semantic_file, selection.name())?; selected_driver = Some(crate::validate_compute_driver_config( compute_drivers, selection.name(), @@ -931,6 +932,7 @@ fn run_effective_config_preflight( if !semantic_file.openshell.drivers.contains_key(driver_name) { continue; } + validate_preflight_isolation_backend(semantic_file, driver_name)?; crate::validate_compute_driver_config( compute_drivers, driver_name, @@ -1005,6 +1007,22 @@ async fn preflight_host_tools( } } +fn validate_preflight_isolation_backend(file: &ConfigFile, driver_name: &str) -> Result<()> { + let gateway = &file.openshell.gateway; + if file + .isolation_backend(driver_name) + .map_err(|error| miette::miette!(error))? + .is_some() + && gateway.gateway_jwt.is_none() + && defaults::complete_local_jwt_config()?.is_none() + { + return Err(miette::miette!( + "external isolation backends require gateway launch signing; configure openshell.gateway.gateway_jwt or the package-managed local signing keys" + )); + } + Ok(()) +} + fn validate_preflight_semantics( args: &RunArgs, matches: &ArgMatches, @@ -2020,6 +2038,83 @@ mod tests { }); } + #[test] + fn config_preflight_isolation_backend_follows_effective_driver_selection() { + let config_home = crate::test_environment::fixture_dir().unwrap(); + for local_override in [false, true] { + let environment = Environment::new() + .set("XDG_CONFIG_HOME", config_home.path()) + .set("OPENSHELL_LOCAL_TLS_DIR", config_home.path()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DRIVERS"); + let environment = if local_override { + environment.set("OPENSHELL_COMPUTE_DRIVER", "local") + } else { + environment.remove("OPENSHELL_COMPUTE_DRIVER") + }; + environment.run(|| { + let config = config_home.path().join("gateway.toml"); + std::fs::write( + &config, + r#" + [openshell] + version = 2 + [openshell.gateway] + compute_driver = "external" + disable_tls = true + [openshell.drivers.external] + socket_path = "/run/external.sock" + isolation_backend = "agent-substrate" + [[openshell.supervisor.isolation_backends]] + name = "agent-substrate" + endpoint = { kind = "unix", socket_path = "/run/isolation.sock" } + "#, + ) + .unwrap(); + let registry = test_registry("local", true); + let preflight = |argv: &[&str]| { + let (run, matches) = parse_with_args(argv); + super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs { + path: Some(config.clone()), + ..Default::default() + }, + run, + &matches, + ®istry, + ) + }; + // The file-selected external driver needs signing, even without probing its socket. + if local_override { + assert!(matches!( + preflight(&["openshell-gateway"]).unwrap(), + Some(crate::ConfiguredComputeDriver::Registered(_)) + )); + } else { + assert!(preflight(&["openshell-gateway"]).is_err()); + } + // CLI and environment overrides select the other driver's built-in backend; + // the unused external driver's signing requirement must not affect it. + assert!(matches!( + preflight(&["openshell-gateway", "--compute-driver", "local"]).unwrap(), + Some(crate::ConfiguredComputeDriver::Registered(_)) + )); + assert!(preflight(&["openshell-gateway", "--compute-driver", "external"]).is_err()); + // Supplying signing configuration makes the same external selection pass. + // Preflight checks configuration only; it must not read keys or connect sockets. + let signed = format!( + "{}\n[openshell.gateway.gateway_jwt]\nsigning_key_path = '/configured/signing.pem'\npublic_key_path = '/configured/public.pem'\nkid_path = '/configured/kid'\n", + std::fs::read_to_string(&config).unwrap() + ); + std::fs::write(&config, signed).unwrap(); + assert!(matches!( + preflight(&["openshell-gateway", "--compute-driver", "external"]).unwrap(), + Some(crate::ConfiguredComputeDriver::Remote { name }) if name == "external" + )); + }); + } + } + #[test] fn config_preflight_matches_driver_selector_and_registry_semantics() { let config_home = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index 20b52d9e34..12395f4a1b 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -222,6 +222,26 @@ mod tests { } } + #[test] + fn driver_specific_decoder_does_not_receive_the_isolation_selector() { + #[derive(Default, Deserialize)] + #[serde(deny_unknown_fields)] + struct StrictDriverConfig { + socket_path: PathBuf, + } + let file: config_file::ConfigFile = toml::from_str( + "[openshell.drivers.external]\nsocket_path = '/run/compute.sock'\nisolation_backend = 'agent-substrate'", + ) + .unwrap(); + let decoded: StrictDriverConfig = + driver_config_from_context(test_context(Some(&file)), "external").unwrap(); + assert_eq!(decoded.socket_path, PathBuf::from("/run/compute.sock")); + assert_eq!( + file.openshell.drivers["external"]["isolation_backend"].as_str(), + Some("agent-substrate") + ); + } + #[test] fn common_admission_defaults_and_replacement_apply_to_every_driver() { for name in ["kubernetes", "docker", "podman", "vm", "mxc", "external"] { diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 754605d174..3b9e541c65 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -68,6 +68,7 @@ use tonic::transport::Endpoint; use tonic::{Code, Request, Status}; pub const COMPUTE_DRIVER_ANNOTATION: &str = "internal.openshell.ai/compute-driver"; +const ISOLATION_BACKEND_ANNOTATION: &str = "internal.openshell.ai/isolation-backend"; pub const COMPUTE_RUNTIME_IDENTITY_ANNOTATION: &str = "internal.openshell.ai/compute-runtime-identity"; #[cfg(unix)] @@ -79,6 +80,12 @@ pub type DriverWatchStream = pub type SharedComputeDriver = Arc + Send + Sync>; +fn supports_isolation_registration(capabilities: &[String]) -> bool { + capabilities.iter().any(|capability| { + capability == openshell_core::extension_protocol::COMPUTE_ISOLATION_BACKEND + }) +} + use provisioning_operation::ProvisioningOperationError; /// Driver-specific values that must be delivered atomically with sandbox /// creation without expanding the public compute-driver protobuf contract. @@ -555,7 +562,7 @@ impl RemoteComputeDriver { self.client.clone() } - async fn verify_admission_policy(&self) -> Result<(), Status> { + async fn verify_admission_policy(&self, external_isolation: bool) -> Result<(), Status> { let expected = self .admission_acknowledgement .lock() @@ -568,9 +575,17 @@ impl RemoteComputeDriver { gateway: Some(gateway_metadata(ExtensionFamily::Compute)), }) .await? - .into_inner() - .resource_admission_policy; - if current != expected { + .into_inner(); + if external_isolation + && !current.extension.as_ref().is_some_and(|metadata| { + supports_isolation_registration(&metadata.supported_capabilities) + }) + { + return Err(Status::failed_precondition( + "remote compute driver no longer supports isolation backend registration; restart the gateway after upgrading the driver", + )); + } + if current.resource_admission_policy != expected { return Err(Status::failed_precondition( "remote driver admission policy changed; restart gateway after configuring matching policy", )); @@ -616,7 +631,15 @@ impl ComputeDriver for RemoteComputeDriver { Status, > { let mut client = self.client(); - self.verify_admission_policy().await?; + self.verify_admission_policy( + request + .get_ref() + .sandbox + .as_ref() + .and_then(|sandbox| sandbox.spec.as_ref()) + .is_some_and(|spec| spec.isolation_backend.is_some()), + ) + .await?; client.validate_sandbox_create(request).await } @@ -644,7 +667,15 @@ impl ComputeDriver for RemoteComputeDriver { ) -> Result, Status> { let mut client = self.client(); - self.verify_admission_policy().await?; + self.verify_admission_policy( + request + .get_ref() + .sandbox + .as_ref() + .and_then(|sandbox| sandbox.spec.as_ref()) + .is_some_and(|spec| spec.isolation_backend.is_some()), + ) + .await?; client.create_sandbox(request).await } @@ -663,7 +694,8 @@ impl ComputeDriver for RemoteComputeDriver { ) -> Result, Status> { let mut client = self.client(); - self.verify_admission_policy().await?; + self.verify_admission_policy(request.get_ref().isolation_backend.is_some()) + .await?; client.start_sandbox(request).await } @@ -705,6 +737,7 @@ impl ComputeDriver for RemoteComputeDriver { #[derive(Clone)] pub struct ComputeRuntime { + isolation_backend: Option, admission: openshell_core::resource_admission::DriverAdmissionConfig, admission_acknowledgement: String, driver: TracedDriver, @@ -802,6 +835,7 @@ impl ComputeRuntime { )); rootfs_tar_staging.sweep_orphans(); Ok(Self { + isolation_backend: None, driver: TracedDriver::new(driver, driver_name), admission: openshell_core::resource_admission::DriverAdmissionConfig::default(), admission_acknowledgement: capabilities.resource_admission_policy, @@ -933,6 +967,83 @@ impl ComputeRuntime { &self.default_image } + pub(crate) fn with_isolation_backend( + mut self, + registration: Option<&openshell_core::isolation_registration::BackendRegistration>, + ) -> Result { + if let Some(registration) = registration + && !supports_isolation_registration( + &self.driver_info.negotiated_extension.supported_capabilities, + ) + { + return Err(format!( + "compute driver '{}' does not support operator-selected isolation backend '{}'", + self.driver_info.name, registration.name + )); + } + self.isolation_backend = registration + .map(TryInto::try_into) + .transpose() + .map_err(str::to_string)?; + Ok(self) + } + + fn isolation_backend_name(&self) -> &str { + self.isolation_backend.as_ref().map_or( + openshell_core::isolation_registration::BUILTIN_BACKEND_NAME, + |registration| registration.name.as_str(), + ) + } + + fn validate_isolation_backend_binding(&self, sandbox: &Sandbox) -> Result<(), Status> { + let admitted = sandbox + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(ISOLATION_BACKEND_ANNOTATION)) + .map_or( + openshell_core::isolation_registration::BUILTIN_BACKEND_NAME, + String::as_str, + ); + if admitted != self.isolation_backend_name() { + return Err(Status::failed_precondition(format!( + "sandbox isolation backend '{admitted}' does not match gateway selection '{}'; restore the gateway selection or create a new sandbox", + self.isolation_backend_name() + ))); + } + Ok(()) + } + + fn driver_sandbox_for_launch(&self, sandbox: &Sandbox) -> Result { + let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) + .map_err(|status| *status)?; + if let Some(spec) = driver_sandbox.spec.as_mut() { + spec.isolation_backend.clone_from(&self.isolation_backend); + } + Ok(driver_sandbox) + } + + fn driver_start_request( + &self, + sandbox: &Sandbox, + launch_authentication: Vec, + expected_runtime_identity: String, + ) -> Result { + self.validate_isolation_backend_binding(sandbox)?; + if self.isolation_backend.is_some() { + self.validate_launch_signer_configured(!launch_authentication.is_empty())?; + } + Ok(StartSandboxRequest { + sandbox_id: sandbox.object_id().to_string(), + name: sandbox.object_name().to_string(), + generation_id: sandbox_runtime_generation(sandbox) + .map_err(Status::failed_precondition)? + .into_string(), + launch_authentication, + expected_runtime_identity, + isolation_backend: self.isolation_backend.clone(), + }) + } + /// Validate the budget once at startup. Persisted attempts keep their /// original deadline even if the operator changes this value on restart. pub(crate) fn with_image_preparation_timeout(mut self, seconds: u32) -> Result { @@ -1093,8 +1204,7 @@ impl ComputeRuntime { .as_ref() .and_then(|spec| spec.template.as_ref()), )?; - let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) - .map_err(|status| *status)?; + let mut driver_sandbox = self.driver_sandbox_for_launch(sandbox)?; if let Some(spec) = driver_sandbox.spec.as_mut() { spec.await_main_process_attachment = await_main_process_attachment; } @@ -1152,9 +1262,9 @@ impl ComputeRuntime { .any(|capability| { capability == openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION }); - if required && !configured { + if (required || self.isolation_backend.is_some()) && !configured { return Err(Status::failed_precondition( - "the selected compute driver requires sandbox launch signing; configure \ + "the selected runtime requires sandbox launch signing; configure \ [openshell.gateway.gateway_jwt] or provide jwt/signing.pem, jwt/public.pem, \ and jwt/kid under OPENSHELL_LOCAL_TLS_DIR (default: the OpenShell state \ directory's tls/). Listener TLS does not configure sandbox launch signing", @@ -1261,6 +1371,14 @@ impl ComputeRuntime { )?; let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; + let metadata = sandbox + .metadata + .as_mut() + .ok_or_else(|| Status::invalid_argument("sandbox metadata is missing"))?; + metadata.annotations.insert( + ISOLATION_BACKEND_ANNOTATION.into(), + self.isolation_backend_name().into(), + ); // Strip the staging token from the public sandbox before anything // persists it: the object store copy is readable by every member of the @@ -1271,8 +1389,7 @@ impl ComputeRuntime { .map(|token| self.rootfs_tar_staging.consume(&token)) .transpose()?; - let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) - .map_err(|status| *status)?; + let mut driver_sandbox = self.driver_sandbox_for_launch(&sandbox)?; if let Some(effective_policy) = runtime_inputs.effective_policy && let Some(spec) = driver_sandbox.spec.as_mut() { @@ -1847,6 +1964,10 @@ impl ComputeRuntime { .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + self.validate_isolation_backend_binding(&candidate)?; + if self.isolation_backend.is_some() { + self.validate_launch_signer_configured(authority.is_some())?; + } provisioning_operation::ensure_operation_settled(&candidate)?; if provisioning_deadline::timed_out(&candidate) && candidate @@ -1940,24 +2061,16 @@ impl ComputeRuntime { } let previous = current.clone(); - let next_identity = if authority.is_some() - && matches!(phase, SandboxPhase::Stopped | SandboxPhase::Completed) - { - Some(next_runtime_identity(¤t)?) + let next_identity = next_runtime_identity(¤t)?; + let launch_authentication = if let Some(authority) = authority { + self.encode_launch_authentication( + ¤t, + authority.mint_persisted_launch(current.object_id(), &next_identity)?, + ) + .await? } else { - None + Vec::new() }; - let launch_authentication = - if let (Some(authority), Some(identity)) = (authority, next_identity.as_ref()) { - self.encode_launch_authentication( - ¤t, - authority.mint_persisted_launch(current.object_id(), identity)?, - ) - .await? - } else { - self.encode_persisted_launch_authentication(authority, ¤t) - .await? - }; let expected_resource_version = sandbox_resource_version(¤t); let next_identity_for_update = next_identity.clone(); match self @@ -1966,10 +2079,8 @@ impl ComputeRuntime { &sandbox_id, expected_resource_version, move |sandbox| { - if let Some(identity) = &next_identity_for_update - && let Some(metadata) = sandbox.metadata.as_mut() - { - identity.write(&mut metadata.annotations); + if let Some(metadata) = sandbox.metadata.as_mut() { + next_identity_for_update.write(&mut metadata.annotations); } apply_lifecycle_phase( sandbox, @@ -2023,7 +2134,6 @@ impl ComputeRuntime { async move { Box::pin(runtime.complete_sandbox_start( sandbox_id, - sandbox_name, previous, starting, lifecycle_guard, @@ -2044,7 +2154,6 @@ impl ComputeRuntime { async fn complete_sandbox_start( &self, sandbox_id: String, - sandbox_name: String, previous: Sandbox, starting: Sandbox, lifecycle_guard: SandboxLifecycleGuard, @@ -2056,15 +2165,11 @@ impl ComputeRuntime { .as_ref() .and_then(|spec| spec.template.as_ref()), )?; - let generation_id = sandbox_runtime_generation(&starting) - .map_err(Status::failed_precondition)? - .into_string(); let expected_runtime_identity = sandbox_compute_runtime_identity(&previous); // One owned operation covers both calls. A NotFound response alone // does not end ownership while the recovery create can still run. let recreate = if provisioning_deadline::timed_out(&previous) { - let mut driver_sandbox = driver_sandbox_from_public(&starting, &self.driver_info.name) - .map_err(|status| *status)?; + let mut driver_sandbox = self.driver_sandbox_for_launch(&starting)?; if let Some(spec) = driver_sandbox.spec.as_mut() { spec.launch_authentication .clone_from(&launch_authentication); @@ -2073,25 +2178,16 @@ impl ComputeRuntime { } else { None }; + let request = + self.driver_start_request(&starting, launch_authentication, expected_runtime_identity)?; let driver = self.driver.clone(); let operation_id = sandbox_id.clone(); let result = Box::pin(self.await_provisioning_operation(&starting, async move { - let request_id = operation_id.clone(); let response = driver .call_owned( openshell_otel::rpc::START_SANDBOX, Some(&operation_id), - move |driver| async move { - driver - .start_sandbox(Request::new(StartSandboxRequest { - sandbox_id: request_id, - name: sandbox_name, - launch_authentication, - generation_id, - expected_runtime_identity, - })) - .await - }, + move |driver| async move { driver.start_sandbox(Request::new(request)).await }, ) .await; match (response, recreate) { @@ -3567,6 +3663,11 @@ impl ComputeRuntime { if !sandbox_phase_should_be_running(phase) && !recoverable_error { continue; } + if let Err(error) = self.validate_isolation_backend_binding(&sandbox) { + warn!(sandbox_id, %error, "Rejected recovered isolation backend selection"); + failed += 1; + continue; + } if let Err(error) = self.validate_caller_driver_config( sandbox @@ -3587,16 +3688,12 @@ impl ComputeRuntime { continue; } - let sandbox_name = sandbox.object_name().to_string(); - let generation_id = match sandbox_runtime_generation(&sandbox) { - Ok(generation) => generation.into_string(), - Err(error) => { - warn!(sandbox_id, %error, "Persisted sandbox runtime identity is invalid"); - authentication_failed(sandbox.object_id()); - failed += 1; - continue; - } - }; + if let Err(error) = sandbox_runtime_generation(&sandbox) { + warn!(sandbox_id, %error, "Persisted sandbox runtime identity is invalid"); + authentication_failed(sandbox.object_id()); + failed += 1; + continue; + } let launch_authentication = match launch_authentication_for(&sandbox).await { Ok(authentication) => authentication, Err(err) => { @@ -3620,31 +3717,24 @@ impl ComputeRuntime { continue; } }; - let expected_runtime_identity = sandbox_compute_runtime_identity(&sandbox); - let request_id = sandbox_id.clone(); - let request_name = sandbox_name.clone(); + let request = match self.driver_start_request( + &sandbox, + launch_authentication, + sandbox_compute_runtime_identity(&sandbox), + ) { + Ok(request) => request, + Err(error) => { + warn!(sandbox_id, %error, "Rejected recovered sandbox launch"); + failed += 1; + continue; + } + }; match Box::pin(self.await_provisioning_operation( &sandbox, self.driver.call_owned( openshell_otel::rpc::START_SANDBOX, Some(&sandbox_id), - move |driver| { - let sandbox_id = request_id; - let sandbox_name = request_name; - let launch_authentication = launch_authentication.clone(); - let expected_runtime_identity = expected_runtime_identity.clone(); - async move { - driver - .start_sandbox(Request::new(StartSandboxRequest { - sandbox_id, - name: sandbox_name, - launch_authentication, - generation_id, - expected_runtime_identity, - })) - .await - } - }, + move |driver| async move { driver.start_sandbox(Request::new(request)).await }, ), )) .await @@ -3867,6 +3957,10 @@ impl ComputeRuntime { } } SandboxPhase::Starting => { + if let Err(error) = self.validate_isolation_backend_binding(&sandbox) { + warn!(%error, "Rejected pending isolation backend selection"); + continue; + } if let Err(error) = self.validate_caller_driver_config( sandbox .spec @@ -3882,16 +3976,33 @@ impl ComputeRuntime { continue; } let sandbox_id = sandbox.object_id().to_string(); - let sandbox_name = sandbox.object_name().to_string(); - let driver_sandbox_id = sandbox_id.clone(); - let generation_id = match sandbox_runtime_generation(&sandbox) { - Ok(generation) => generation.into_string(), + let expected_runtime_identity = sandbox_compute_runtime_identity(&sandbox); + let launch_authentication = if self.isolation_backend.is_some() { + let authority = self.restart_authority.get().and_then(Option::as_deref); + match self + .encode_persisted_launch_authentication(authority, &sandbox) + .await + { + Ok(authentication) => authentication, + Err(error) => { + warn!(sandbox_id, %error, "Failed to prepare recovered sandbox credentials"); + continue; + } + } + } else { + Vec::new() + }; + let request = match self.driver_start_request( + &sandbox, + launch_authentication, + expected_runtime_identity, + ) { + Ok(request) => request, Err(error) => { - warn!(sandbox_id, %error, "Persisted sandbox runtime identity is invalid"); + warn!(sandbox_id, %error, "Rejected pending sandbox launch"); continue; } }; - let expected_runtime_identity = sandbox_compute_runtime_identity(&sandbox); // Recovery retains the original attempt and deadline. A // stalled driver must release this lifecycle gate after // expiry so the deadline worker can reclaim its compute. @@ -3902,15 +4013,7 @@ impl ComputeRuntime { openshell_otel::rpc::START_SANDBOX, Some(&sandbox_id), |driver| async move { - driver - .start_sandbox(Request::new(StartSandboxRequest { - sandbox_id: driver_sandbox_id, - name: sandbox_name, - launch_authentication: Vec::new(), - generation_id, - expected_runtime_identity, - })) - .await + driver.start_sandbox(Request::new(request)).await }, ), ) @@ -4291,7 +4394,8 @@ impl ComputeRuntime { } let sandbox_name = current.object_name().to_string(); - let authority = self.restart_authority.get().and_then(Option::as_deref); + self.validate_isolation_backend_binding(¤t) + .map_err(|error| error.to_string())?; let already_claimed = current .status .as_ref() @@ -4299,10 +4403,7 @@ impl ComputeRuntime { let next_identity = if already_claimed { None } else { - authority - .map(|_| next_runtime_identity(¤t)) - .transpose() - .map_err(|status| status.to_string())? + Some(next_runtime_identity(¤t).map_err(|status| status.to_string())?) }; let claimed = if already_claimed && sandbox_provisioning_attempt_id(¤t).is_none() { current.clone() @@ -4393,28 +4494,22 @@ impl ComputeRuntime { let launch_authentication = runtime .encode_persisted_launch_authentication(authority, &armed) .await?; - let generation_id = sandbox_runtime_generation(&armed) - .map_err(Status::failed_precondition)? - .into_string(); - let request_id = operation_id.clone(); - let start = runtime - .driver - .call_owned( - openshell_otel::rpc::START_SANDBOX, - Some(&operation_id), - move |driver| async move { - driver - .start_sandbox(Request::new(StartSandboxRequest { - sandbox_id: request_id, - name: operation_name, - launch_authentication, - generation_id, - expected_runtime_identity, - })) - .await - }, - ) - .await; + let request = runtime.driver_start_request( + &armed, + launch_authentication, + expected_runtime_identity, + )?; + let start = + runtime + .driver + .call_owned( + openshell_otel::rpc::START_SANDBOX, + Some(&operation_id), + move |driver| async move { + driver.start_sandbox(Request::new(request)).await + }, + ) + .await; Ok(match start { Ok(response) => RestartOutcome::Started(response.into_inner()), Err(status) => RestartOutcome::DriverError("start", status), @@ -6305,6 +6400,7 @@ fn driver_sandbox_spec_from_public( .map_or_else(String::new, |process| process.run_as_group.clone()), }), launch_authentication: Vec::new(), + isolation_backend: None, }) } @@ -7288,12 +7384,10 @@ fn next_runtime_identity( .checked_add(1) .and_then(|epoch| openshell_core::jwt::CredentialEpoch::new(epoch).ok()) .ok_or_else(|| Status::internal("sandbox authorization epoch overflow"))?; - Ok(crate::auth::sandbox_session::PersistedSandboxIdentity { - runtime_generation: current.runtime_generation, - auth_epoch: next_epoch, - gateway_token_id: uuid::Uuid::new_v4(), - refresh_replay: None, - }) + let mut next = crate::auth::sandbox_session::PersistedSandboxIdentity::new() + .map_err(|error| Status::internal(error.to_string()))?; + next.auth_epoch = next_epoch; + Ok(next) } fn serialize_launch_authentication( @@ -7584,6 +7678,7 @@ pub fn new_test_runtime_with_driver( ) -> ComputeRuntime { let supports_sandbox_authentication = driver.sandbox_authentication.is_some(); ComputeRuntime { + isolation_backend: None, driver: TracedDriver::new(driver, "test".to_string()), admission: openshell_core::resource_admission::DriverAdmissionConfig { allow_driver_config: true, @@ -8233,6 +8328,7 @@ mod tests { start_calls: AtomicUsize, start_requests: TestMutex>, start_authentications: TestMutex>>, + start_generations: TestMutex>, start_expected_runtime_identities: TestMutex>, start_outcome: TestMutex, runtime_identity: TestMutex, @@ -8277,6 +8373,7 @@ mod tests { start_calls: AtomicUsize::new(0), start_requests: TestMutex::new(Vec::new()), start_authentications: TestMutex::new(Vec::new()), + start_generations: TestMutex::new(Vec::new()), start_expected_runtime_identities: TestMutex::new(Vec::new()), start_outcome: TestMutex::new(ControlledLifecycleOutcome::Ok), runtime_identity: TestMutex::new(String::new()), @@ -8588,6 +8685,10 @@ mod tests { .lock() .expect("start requests lock poisoned") .push((request.sandbox_id, request.name)); + self.start_generations + .lock() + .expect("start generations lock poisoned") + .push(request.generation_id); self.start_authentications .lock() .expect("start authentications lock poisoned") @@ -8696,6 +8797,7 @@ mod tests { ) -> ComputeRuntime { let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); ComputeRuntime { + isolation_backend: None, driver: TracedDriver::new(driver, "test-driver".to_string()), admission: openshell_core::resource_admission::DriverAdmissionConfig { allow_driver_config: true, @@ -12331,79 +12433,198 @@ mod tests { #[tokio::test] async fn authenticated_start_commits_identity_with_starting_phase() { - let driver = ControlledDriver::new(); - let runtime = test_runtime(driver.clone()).await; - let sandbox = sandbox_record("sb-auth-start", "sandbox-auth-start", SandboxPhase::Stopped); - runtime.store.put_message(&sandbox).await.unwrap(); - let before = runtime - .store - .get_message::(sandbox.object_id()) - .await - .unwrap() - .unwrap(); - let authority = test_session_authority(); - - runtime - .start_sandbox_authenticated( - sandbox.object_workspace(), - sandbox.object_name(), - Some(&authority), + for phase in [ + SandboxPhase::Stopped, + SandboxPhase::Completed, + SandboxPhase::Error, + ] { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-auth-start", "sandbox-auth-start", phase); + if phase == SandboxPhase::Error { + sandbox.set_phase(SandboxPhase::Ready.into()); + apply_main_process_exit(&mut sandbox, "previous-instance", 9); + } + runtime.store.put_message(&sandbox).await.unwrap(); + let before = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let authority = test_session_authority(); + let before_identity = crate::auth::sandbox_session::PersistedSandboxIdentity::read( + &before.metadata.as_ref().unwrap().annotations, ) - .await .unwrap(); + let previous_launch = authority + .mint_persisted_launch(sandbox.object_id(), &before_identity) + .unwrap(); + let previous_principal = authority + .verify_gateway_token(previous_launch.supervisor.gateway_token.expose_secret()) + .unwrap(); - let stored = runtime - .store - .get_message::(sandbox.object_id()) - .await - .unwrap() - .unwrap(); - let stored_identity = crate::auth::sandbox_session::PersistedSandboxIdentity::read( - &stored.metadata.as_ref().unwrap().annotations, - ) - .unwrap(); - assert_eq!(stored.phase(), SandboxPhase::Starting as i32); - assert_eq!(stored_identity.auth_epoch.get(), 2); - assert_eq!( - sandbox_resource_version(&stored), - sandbox_resource_version(&before) + 3, - "identity/Starting transition, operation claim, and settlement are separate CAS writes" - ); + runtime + .start_sandbox_authenticated( + sandbox.object_workspace(), + sandbox.object_name(), + Some(&authority), + ) + .await + .unwrap(); - runtime - .start_sandbox_authenticated( - sandbox.object_workspace(), - sandbox.object_name(), - Some(&authority), + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let stored_identity = crate::auth::sandbox_session::PersistedSandboxIdentity::read( + &stored.metadata.as_ref().unwrap().annotations, ) - .await .unwrap(); - - let authentications = driver.start_authentications(); - assert_eq!(authentications.len(), 2); - for encoded in authentications { - let authentication: openshell_core::jwt::SandboxLaunchAuthentication = - serde_json::from_slice(&encoded).unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Starting as i32); + assert_eq!(stored_identity.auth_epoch.get(), 2); + assert_ne!( + stored_identity.runtime_generation, + before_identity.runtime_generation + ); + assert_ne!( + stored_identity.gateway_token_id, + before_identity.gateway_token_id + ); assert_eq!( - authentication.supervisor.auth_epoch, - stored_identity.auth_epoch + crate::auth::sandbox_session::authorize_persisted( + &runtime.store, + &previous_principal + ) + .await + .unwrap_err() + .code(), + Code::Unauthenticated, ); assert_eq!( - authentication.supervisor.runtime_generation, - stored_identity.runtime_generation + sandbox_resource_version(&stored), + sandbox_resource_version(&before) + 3, + "identity/Starting transition, operation claim, and settlement are separate CAS writes" + ); + + runtime + .start_sandbox_authenticated( + sandbox.object_workspace(), + sandbox.object_name(), + Some(&authority), + ) + .await + .unwrap(); + + let authentications = driver.start_authentications(); + assert_eq!(authentications.len(), 2); + let mut launch_session = None; + for encoded in authentications { + let authentication: openshell_core::jwt::SandboxLaunchAuthentication = + serde_json::from_slice(&encoded).unwrap(); + assert_ne!( + authentication.supervisor.session_id, + previous_launch.supervisor.session_id + ); + assert_eq!( + *launch_session.get_or_insert(authentication.supervisor.session_id), + authentication.supervisor.session_id + ); + assert_eq!( + authentication.supervisor.auth_epoch, + stored_identity.auth_epoch + ); + assert_eq!( + authentication.supervisor.runtime_generation, + stored_identity.runtime_generation + ); + } + assert!( + driver + .start_generations + .lock() + .unwrap() + .iter() + .all(|generation| generation == stored_identity.runtime_generation.as_str()) + ); + let after_retry = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + sandbox_resource_version(&after_retry), + sandbox_resource_version(&stored) + 2, + "Starting retry claims and settles another operation without rotating identity" ); } - let after_retry = runtime + } + + #[tokio::test] + async fn concurrent_authenticated_start_keeps_the_claimed_launch_identity() { + let driver = ControlledDriver::new(); + driver.block_start(); + let runtime = test_runtime(driver.clone()).await; + let mut other = runtime.clone(); + other.mutation_locks = Arc::new(LocalMutationLocks::new()); + other.lifecycle_gates = Arc::new(LifecycleGateRegistry::default()); + let sandbox = sandbox_record("sb-start-identity", "start-identity", SandboxPhase::Stopped); + let old_generation = sandbox_runtime_generation(&sandbox).unwrap(); + runtime.store.put_message(&sandbox).await.unwrap(); + let authority = Arc::new(test_session_authority()); + let starting = runtime.clone(); + let signer = authority.clone(); + let start = tokio::spawn(async move { + starting + .start_sandbox_authenticated("default", "start-identity", Some(&signer)) + .await + }); + driver.start_started.notified().await; + let claimed = wait_driver_pending(&other, sandbox.object_id(), true).await; + let generation = sandbox_runtime_generation(&claimed).unwrap(); + assert_ne!(old_generation, generation); + let error = other + .start_sandbox_authenticated("default", "start-identity", Some(&authority)) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + assert_eq!(driver.start_calls(), 1); + let retained = runtime .store .get_message::(sandbox.object_id()) .await .unwrap() .unwrap(); - assert_eq!( - sandbox_resource_version(&after_retry), - sandbox_resource_version(&stored) + 2, - "Starting retry claims and settles another operation without rotating identity" + assert_eq!(sandbox_runtime_generation(&retained).unwrap(), generation); + driver.release_start(); + start.await.unwrap().unwrap(); + driver.start_blocked.store(false, Ordering::SeqCst); + other + .start_sandbox_authenticated("default", "start-identity", Some(&authority)) + .await + .unwrap(); + assert!( + driver + .start_generations + .lock() + .unwrap() + .iter() + .all(|value| value == generation.as_str()) ); + let authentications = driver.start_authentications(); + assert_eq!(authentications.len(), 2); + let sessions: Vec<_> = authentications + .iter() + .map(|encoded| { + let auth: openshell_core::jwt::SandboxLaunchAuthentication = + serde_json::from_slice(encoded).unwrap(); + auth.supervisor.session_id + }) + .collect(); + assert_eq!(sessions[0], sessions[1]); } #[tokio::test] @@ -16541,6 +16762,331 @@ mod tests { ); } + #[tokio::test] + #[cfg(unix)] + async fn isolation_backend_operator_selection_reaches_remote_driver_launches() { + use crate::test_support::{FakeComputeDriver, FakeComputeDriverCall}; + let directory = tempfile::tempdir().unwrap(); + let socket_path = directory.path().join("compute.sock"); + let driver = FakeComputeDriver::new() + .with_isolation_backend_registration() + .with_gateway_manages_lifecycle(); + let _server = driver.serve_uds(&socket_path).unwrap(); + let endpoint = connect_remote_compute_driver("external", &socket_path) + .await + .unwrap(); + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let file: crate::config_file::ConfigFile = toml::from_str( + r#" +[openshell] +version = 2 +[openshell.gateway] +compute_driver = "external" +[openshell.drivers.external] +isolation_backend = "agent-substrate" +[[openshell.supervisor.isolation_backends]] +name = "agent-substrate" +endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" } +"#, + ) + .unwrap(); + let registration = file.isolation_backend("external").unwrap().unwrap(); + let expected = openshell_core::proto::compute::v1::IsolationBackendRegistration::try_from( + registration, + ) + .unwrap(); + let runtime = ComputeRuntime::new_remote_driver( + endpoint, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + ) + .await + .unwrap() + .with_isolation_backend(Some(registration)) + .unwrap(); + assert!(runtime.validate_launch_signer_configured(false).is_err()); + let _ = runtime + .restart_authority + .set(Some(Arc::new(test_session_authority()))); + + let mut sandbox = sandbox_record( + "external-create", + "external-create", + SandboxPhase::Provisioning, + ); + sandbox.metadata.as_mut().unwrap().annotations.insert( + ISOLATION_BACKEND_ANNOTATION.into(), + "workload-forgery".into(), + ); + sandbox + .spec + .get_or_insert_with(Default::default) + .environment + .insert( + "OPENSHELL_ADMITTED_ISOLATION_BACKEND".into(), + "workload-forgery".into(), + ); + runtime.validate_sandbox_create(&sandbox).await.unwrap(); + runtime + .create_sandbox_authenticated( + sandbox.clone(), + None, + Some(test_launch_authentication(&sandbox)), + false, + ) + .await + .unwrap(); + let mut launch_calls = 0; + for call in driver.calls() { + match call { + FakeComputeDriverCall::ValidateSandboxCreate { + sandbox: Some(sandbox), + } + | FakeComputeDriverCall::CreateSandbox { + sandbox: Some(sandbox), + } => { + launch_calls += 1; + assert_eq!( + sandbox.spec.unwrap().isolation_backend, + Some(expected.clone()) + ); + } + _ => {} + } + } + assert_eq!( + launch_calls, 2, + "validation and create must both reach the driver" + ); + let stored = runtime + .store + .get_message::("external-create") + .await + .unwrap() + .unwrap(); + assert!( + !format!("{stored:?}").contains("/run/agent-substrate"), + "operator endpoint must not persist in the public sandbox" + ); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get(ISOLATION_BACKEND_ANNOTATION), + Some(®istration.name) + ); + + let legacy = sandbox_record("legacy", "legacy", SandboxPhase::Stopped); + runtime.store.put_message(&legacy).await.unwrap(); + driver.clear_calls(); + assert_eq!( + runtime + .start_sandbox("default", "legacy") + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + assert!( + driver.calls().is_empty(), + "legacy built-in resources must not launch through an external backend" + ); + + let mut stopped = sandbox_record("external-start", "external-start", SandboxPhase::Stopped); + stopped.metadata.as_mut().unwrap().annotations.insert( + ISOLATION_BACKEND_ANNOTATION.into(), + registration.name.clone(), + ); + runtime.store.put_message(&stopped).await.unwrap(); + driver.clear_calls(); + assert_eq!( + runtime + .start_sandbox("default", "external-start") + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + assert!(driver.calls().is_empty()); + assert_eq!( + runtime + .store + .get_message::("external-start") + .await + .unwrap() + .unwrap() + .phase(), + SandboxPhase::Stopped as i32 + ); + runtime + .start_sandbox_authenticated( + "default", + "external-start", + Some(&test_session_authority()), + ) + .await + .unwrap(); + assert!(driver.calls().iter().any(|call| matches!(call, + FakeComputeDriverCall::StartSandbox { sandbox_id, isolation_backend, .. } + if sandbox_id == "external-start" && isolation_backend.as_ref() == Some(&expected)))); + assert_eq!( + runtime + .store + .get_message::("external-start") + .await + .unwrap() + .unwrap() + .phase(), + SandboxPhase::Starting as i32 + ); + + let mut bad_pending = sandbox_record("bad-pending", "bad-pending", SandboxPhase::Starting); + let annotations = &mut bad_pending.metadata.as_mut().unwrap().annotations; + annotations.clear(); + annotations.insert( + ISOLATION_BACKEND_ANNOTATION.into(), + registration.name.clone(), + ); + runtime.store.put_message(&bad_pending).await.unwrap(); + + driver.clear_calls(); + runtime + .start_persisted_sandboxes_with_authentication( + |sandbox| { + let auth = test_launch_authentication(sandbox); + async move { Ok(auth) } + }, + |_| async { Ok(()) }, + |_| {}, + ) + .await + .unwrap(); + let starts: Vec<_> = driver + .calls() + .into_iter() + .filter_map(|call| match call { + FakeComputeDriverCall::StartSandbox { + sandbox_id, + isolation_backend, + launch_authentication, + .. + } => { + assert_ne!( + sandbox_id, "bad-pending", + "invalid credentials must not reach the driver" + ); + assert!( + !launch_authentication.is_empty(), + "recovery must supply signed launch credentials" + ); + Some(isolation_backend) + } + _ => None, + }) + .collect(); + assert!(!starts.is_empty()); + assert!( + starts + .iter() + .all(|registration| registration.as_ref() == Some(&expected)) + ); + + let mut other = registration.clone(); + other.name = "other-substrate".into(); + for selected in [None, Some(&other)] { + let other_replica = runtime.clone().with_isolation_backend(selected).unwrap(); + driver.clear_calls(); + assert_eq!( + other_replica + .start_sandbox("default", "external-start") + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + other_replica + .start_persisted_sandboxes_with_authentication( + |sandbox| { + let name = sandbox.object_name().to_string(); + async move { panic!("mismatched backend must be rejected before minting credentials: {name}") } + }, + |_| async { Ok(()) }, + |_| {}, + ) + .await + .unwrap(); + assert!( + driver.calls().is_empty(), + "another replica must not launch a differently bound resource" + ); + } + let mut capabilities = driver + .get_capabilities(Request::new(GetCapabilitiesRequest::default())) + .await + .unwrap() + .into_inner(); + capabilities + .extension + .as_mut() + .unwrap() + .supported_capabilities + .retain(|capability| { + capability != openshell_core::extension_protocol::COMPUTE_ISOLATION_BACKEND + }); + let _ = driver.clone().with_capabilities(capabilities); + driver.clear_calls(); + assert_eq!( + runtime + .validate_sandbox_create(&sandbox) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + assert_eq!(driver.calls(), vec![FakeComputeDriverCall::GetCapabilities]); + } + + #[tokio::test] + #[cfg(unix)] + async fn isolation_backend_selection_rejects_unsupported_remote_driver_before_launch() { + use crate::test_support::{FakeComputeDriver, FakeComputeDriverCall}; + let directory = tempfile::tempdir().unwrap(); + let socket_path = directory.path().join("compute.sock"); + let driver = FakeComputeDriver::new(); + let _server = driver.serve_uds(&socket_path).unwrap(); + let endpoint = connect_remote_compute_driver("external", &socket_path) + .await + .unwrap(); + let runtime = ComputeRuntime::new_remote_driver( + endpoint, + Arc::new(Store::connect("sqlite::memory:").await.unwrap()), + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + ) + .await + .unwrap(); + let registration = openshell_core::isolation_registration::BackendRegistration { + name: "agent-substrate".into(), + endpoint: openshell_core::isolation_registration::SandboxTransport::Unix { + socket_path: "/run/backend.sock".into(), + }, + }; + assert!( + runtime + .with_isolation_backend(Some(®istration)) + .unwrap_err() + .contains("does not support") + ); + assert_eq!(driver.calls(), vec![FakeComputeDriverCall::GetCapabilities]); + } + #[tokio::test] #[cfg(unix)] async fn remote_compute_driver_forwards_lifecycle_calls_over_uds() { @@ -16647,7 +17193,7 @@ mod tests { runtime.start_persisted_sandboxes().await.unwrap(); assert!(matches!( driver.calls().as_slice(), - [FakeComputeDriverCall::GetCapabilities, FakeComputeDriverCall::StartSandbox { sandbox_id, sandbox_name }] + [FakeComputeDriverCall::GetCapabilities, FakeComputeDriverCall::StartSandbox { sandbox_id, sandbox_name, .. }] if sandbox_id == "sb-uds" && sandbox_name == "uds-sandbox" )); driver.clear_calls(); @@ -17378,6 +17924,10 @@ mod tests { let driver = ControlledDriver::new(); driver.block_start(); let runtime = test_runtime(driver.clone()).await; + runtime + .restart_authority + .set(Some(Arc::new(test_session_authority()))) + .unwrap(); let mut other = runtime.clone(); other.mutation_locks = Arc::new(LocalMutationLocks::new()); other.lifecycle_gates = Arc::new(LifecycleGateRegistry::default()); @@ -17391,6 +17941,7 @@ mod tests { status.provisioning = Some(provisioning_deadline::new_record( openshell_core::time::now_ms(), )); + let old_generation = sandbox_runtime_generation(&sandbox).unwrap(); runtime.store.put_message(&sandbox).await.unwrap(); runtime .main_process_exited(sandbox.object_id(), "instance-1", 9) @@ -17405,6 +17956,32 @@ mod tests { tokio::spawn(async move { restarting.restart_sandbox_runtime("sb-auto-owned").await }); driver.start_started.notified().await; let owned = wait_driver_pending(&other, sandbox.object_id(), true).await; + let identity = crate::auth::sandbox_session::PersistedSandboxIdentity::read( + &owned.metadata.as_ref().unwrap().annotations, + ) + .unwrap(); + assert_ne!(identity.runtime_generation, old_generation); + assert_eq!(identity.auth_epoch.get(), 2); + let encoded = driver.start_authentications(); + let authentication: openshell_core::jwt::SandboxLaunchAuthentication = + serde_json::from_slice(&encoded[0]).unwrap(); + assert_eq!( + authentication.supervisor.runtime_generation, + identity.runtime_generation + ); + let retry = runtime + .encode_persisted_launch_authentication( + runtime.restart_authority.get().unwrap().as_deref(), + &owned, + ) + .await + .unwrap(); + let retry: openshell_core::jwt::SandboxLaunchAuthentication = + serde_json::from_slice(&retry).unwrap(); + assert_eq!( + authentication.supervisor.session_id, + retry.supervisor.session_id + ); restart.abort(); assert!(restart.await.unwrap_err().is_cancelled()); let error = other @@ -17421,6 +17998,10 @@ mod tests { driver.release_start(); let settled = wait_driver_pending(&other, sandbox.object_id(), false).await; assert!(provisioning_operation::same_operation(&owned, &settled)); + assert_eq!( + sandbox_runtime_generation(&settled).unwrap(), + identity.runtime_generation + ); } #[tokio::test] diff --git a/crates/openshell-server/src/compute/tests/error_recovery.rs b/crates/openshell-server/src/compute/tests/error_recovery.rs index e612fa3f9e..c6c2d45aee 100644 --- a/crates/openshell-server/src/compute/tests/error_recovery.rs +++ b/crates/openshell-server/src/compute/tests/error_recovery.rs @@ -101,7 +101,7 @@ async fn failed_gateway_start_can_be_stopped_and_retried() { PersistedSandboxIdentity::read(&failed.metadata.as_ref().unwrap().annotations).unwrap(); let after = PersistedSandboxIdentity::read(&starting.metadata.as_ref().unwrap().annotations).unwrap(); - assert_eq!(after.runtime_generation, before.runtime_generation); + assert_ne!(after.runtime_generation, before.runtime_generation); assert_eq!(after.auth_epoch.get(), before.auth_epoch.get() + 1); assert_ne!(after.gateway_token_id, before.gateway_token_id); assert_eq!( diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 915acdad39..bce0500a67 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -63,10 +63,9 @@ pub struct OpenShellRoot { #[serde(default)] pub supervisor: SupervisorFileSection, - /// `[openshell.drivers.]` tables — passed verbatim to each driver - /// crate's `Deserialize` impl. Stored as raw [`toml::Value`] so each - /// driver can evolve its schema - /// independently of this crate. + /// `[openshell.drivers.]` tables — passed to each driver crate's + /// `Deserialize` impl after removing common gateway controls. Stored as + /// raw [`toml::Value`] so each driver can evolve its schema independently. #[serde(default)] pub drivers: BTreeMap, @@ -429,12 +428,50 @@ impl TryFrom for OtlpConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SupervisorFileSection { + /// Operator-owned external isolation routes, reachable by supervisors. + #[serde(default)] + pub isolation_backends: Vec, /// Statically registered supervisor middleware services. Registration is /// operator-owned and changes require a gateway restart. #[serde(default)] pub middleware: Vec, } +impl ConfigFile { + /// Resolve the selected driver's exact backend name without sandbox input. + pub(crate) fn isolation_backend( + &self, + driver_name: &str, + ) -> Result, &'static str> + { + use openshell_core::isolation_registration::{ + BUILTIN_BACKEND_NAME, validate_backend_name, validate_registrations, + }; + let registrations = &self.openshell.supervisor.isolation_backends; + validate_registrations(registrations)?; + let Some(value) = self + .openshell + .drivers + .get(driver_name) + .and_then(|driver| driver.get("isolation_backend")) + else { + return Ok(None); + }; + let name = value + .as_str() + .ok_or("driver isolation_backend must be a string")?; + if name == BUILTIN_BACKEND_NAME { + return Ok(None); + } + validate_backend_name(name)?; + registrations + .iter() + .find(|registration| registration.name == name) + .map(Some) + .ok_or("selected isolation backend is not registered") + } +} + /// One `[[openshell.supervisor.middleware]]` supervisor middleware registration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -588,6 +625,8 @@ pub enum ConfigFileError { }, #[error("invalid gateway config field `openshell.drivers.{name}`: expected a TOML table")] InvalidDriverTable { name: String }, + #[error("invalid gateway config field `openshell.drivers.{name}.isolation_backend`: {message}")] + InvalidIsolationBackend { name: String, message: &'static str }, #[error( "failed to read TLS CA certificate for supervisor middleware '{name}' from '{}': {source}", path.display() @@ -758,6 +797,21 @@ fn parse_and_validate(path: &Path, contents: &str) -> Result "malformed", }; @@ -853,15 +908,22 @@ pub fn load(path: &Path) -> Result { } /// Return a driver's table without gateway-level inheritance. +/// /// Driver-specific configuration belongs exclusively to -/// `[openshell.drivers.]` in schema version 2. +/// `[openshell.drivers.]` in schema version 2. The gateway resolves and +/// removes the common `isolation_backend` selector before driver decoding. pub fn driver_table( _driver_name: &str, _gateway: &GatewayFileSection, raw: Option<&toml::Value>, ) -> toml::Value { match raw { - Some(toml::Value::Table(table)) => toml::Value::Table(table.clone()), + Some(toml::Value::Table(table)) => { + let mut table = table.clone(); + // Resolved by the gateway, not part of the driver's own schema. + table.remove("isolation_backend"); + toml::Value::Table(table) + } _ => toml::Value::Table(toml::Table::new()), } } @@ -871,6 +933,62 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn isolation_backend_selection_follows_the_driver_and_matches_exactly() { + let tmp = write_tmp( + r#" +[openshell.gateway] +compute_driver = "external" +[openshell.drivers.external] +isolation_backend = "agent-substrate" +[openshell.drivers.alternative] +isolation_backend = "other-backend" +[openshell.drivers.builtin] +isolation_backend = "openshell-sandbox" +[[openshell.supervisor.isolation_backends]] +name = "agent-substrate" +endpoint = { kind = "tcp", authority = "backend.example:443", addresses = ["127.0.0.1:47000"] } +[[openshell.supervisor.isolation_backends]] +name = "other-backend" +endpoint = { kind = "unix", socket_path = "/run/other.sock" } +"#, + ); + let file = load(tmp.path()).unwrap(); + for (driver, backend) in [ + ("external", "agent-substrate"), + ("alternative", "other-backend"), + ] { + assert_eq!( + file.isolation_backend(driver).unwrap().unwrap().name, + backend + ); + } + for driver in ["builtin", "absent"] { + assert!(file.isolation_backend(driver).unwrap().is_none()); + } + let mut file = file; + file.openshell.drivers.get_mut("external").unwrap()["isolation_backend"] = + toml::Value::String("Agent-substrate".into()); + assert!(file.isolation_backend("external").is_err()); + } + + #[test] + fn isolation_backend_invalid_registration_fails_load_and_preflight() { + for contents in [ + "[openshell.gateway]\nisolation_backend = 'agent-substrate'", + "[openshell.drivers.external]\nisolation_backend = 'missing'", + "[openshell.drivers.unused]\nisolation_backend = ''", + "[openshell.drivers.external]\nisolation_backend = 42", + "[[openshell.supervisor.isolation_backends]]\nname = 'openshell-sandbox'\nendpoint = { kind = 'unix', socket_path = '/run/backend.sock' }", + "[[openshell.supervisor.isolation_backends]]\nname = 'unused'\nendpoint = { kind = 'unix', socket_path = 'relative.sock' }", + "[[openshell.supervisor.isolation_backends]]\nname = 'duplicate'\nendpoint = { kind = 'unix', socket_path = '/run/a.sock' }\n[[openshell.supervisor.isolation_backends]]\nname = 'duplicate'\nendpoint = { kind = 'unix', socket_path = '/run/b.sock' }", + ] { + let tmp = write_tmp(contents); + assert!(load(tmp.path()).is_err(), "{contents}"); + assert!(preflight(tmp.path()).is_err(), "{contents}"); + } + } + fn write_raw_tmp(contents: &str) -> tempfile::NamedTempFile { let mut tmp = tempfile::Builder::new() .suffix(".toml") diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 8ca38f17b8..b469042947 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1659,6 +1659,17 @@ async fn build_compute_runtime( supervisor_sessions: Arc, shutdown_rx: watch::Receiver, ) -> Result { + let isolation_backend = driver_startup + .file + .map(|file| file.isolation_backend(selection.name())) + .transpose() + .map_err(Error::config)? + .flatten(); + if isolation_backend.is_some() && config.gateway_jwt.is_none() { + return Err(Error::config( + "external isolation backends require gateway launch signing; configure openshell.gateway.gateway_jwt or the package-managed local signing keys", + )); + } let driver = validate_compute_driver_config( registry, selection.name(), @@ -1754,7 +1765,8 @@ async fn build_compute_runtime( }; let runtime = runtime - .with_admission_policy(admission) + .with_isolation_backend(isolation_backend) + .and_then(|runtime| runtime.with_admission_policy(admission)) .and_then(|runtime| { runtime.with_image_preparation_timeout(config.image_preparation_timeout_seconds) }) diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 3ffff8945e..7490513692 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -528,11 +528,31 @@ mod tests { let mut methods = Vec::new(); let mut public_roots = BTreeSet::new(); let mut compiled_method_count = 0; + let mut isolation_methods = BTreeSet::new(); + let mut sandbox_protocol_methods = BTreeSet::new(); for file in &public.file { let package = file.package.as_deref().unwrap_or_default(); for service in &file.service { let service_name = service.name.as_deref().expect("service name"); compiled_method_count += service.method.len(); + if package == "openshell.sandbox.backend.v1" { + assert_eq!(service_name, "IsolationBackend"); + isolation_methods.extend( + service + .method + .iter() + .map(|method| method.name.as_deref().expect("isolation method name")), + ); + } + if package == "openshell.sandbox.protocol.v1" { + assert_eq!(service_name, "SandboxProtocol"); + sandbox_protocol_methods.extend(service.method.iter().map(|method| { + method + .name + .as_deref() + .expect("sandbox protocol method name") + })); + } if !matches!( (package, service_name), ("openshell.v1", "OpenShell") | ("openshell.inference.v1", "Inference") @@ -556,6 +576,19 @@ mod tests { } } methods.sort(); + assert_eq!( + isolation_methods, + BTreeSet::from([ + "Describe", + "OpenBoundary", + "ResumeBoundary", + "SuspendBoundary" + ]) + ); + assert_eq!( + sandbox_protocol_methods, + BTreeSet::from(["Exchange", "Mediate"]) + ); for signature in PROVIDER_READINESS_RPC_SIGNATURES { assert!( methods.iter().any(|method| method == signature), @@ -570,7 +603,10 @@ mod tests { } assert_eq!( compiled_method_count, - 103 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len(), + 103 + PROVIDER_READINESS_RPC_SIGNATURES.len() + + PEER_OWNER_RPC_SIGNATURES.len() + + isolation_methods.len() + + sandbox_protocol_methods.len(), "classify every compiled RPC" ); assert_eq!( diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 0cd4abf2d5..5aa4a70f5c 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -112,6 +112,8 @@ pub enum FakeComputeDriverCall { StartSandbox { sandbox_id: String, sandbox_name: String, + isolation_backend: Option, + launch_authentication: Vec, }, DeleteSandbox { sandbox_id: String, @@ -202,6 +204,20 @@ impl FakeComputeDriver { self } + #[must_use] + pub fn with_isolation_backend_registration(self) -> Self { + self.with_state(|state| { + state + .capabilities + .extension + .as_mut() + .unwrap() + .supported_capabilities + .push(openshell_core::extension_protocol::COMPUTE_ISOLATION_BACKEND.into()); + }); + self + } + #[must_use] pub fn calls(&self) -> Vec { self.with_state(|state| state.calls.clone()) @@ -410,6 +426,8 @@ impl ComputeDriver for FakeComputeDriver { state.calls.push(FakeComputeDriverCall::StartSandbox { sandbox_id: request.sandbox_id, sandbox_name: request.name, + isolation_backend: request.isolation_backend, + launch_authentication: request.launch_authentication, }); }); Ok(Response::new(StartSandboxResponse::default())) diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 2291fbf2ff..ff0e3e7eb3 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } openshell-binary-identity = { path = "../openshell-binary-identity" } openshell-core = { path = "../openshell-core", features = ["oauth"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } @@ -63,6 +64,8 @@ default = ["bundled-ca-roots"] bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] +openshell-sandbox-backend = { path = "../openshell-sandbox-backend", features = ["test-support"] } + openshell-driver-vm = { path = "../openshell-driver-vm", default-features = false, features = ["allocation-tracking"] } openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } openshell-otel = { path = "../openshell-otel" } diff --git a/crates/openshell-supervisor-network/src/policy_dns/runtime.rs b/crates/openshell-supervisor-network/src/policy_dns/runtime.rs index 6463792a1e..cf1f61cfca 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/runtime.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/runtime.rs @@ -3,6 +3,9 @@ //! Runtime-owned policy DNS listeners for combined Linux supervisors. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + use crate::opa::OpaEngine; use crate::policy_dns::resolver::MAX_DNS_MESSAGE_BYTES; use crate::policy_dns::store::{ResolvedEndpointStore, StoreConfig, SyntheticPools}; @@ -10,10 +13,9 @@ use crate::policy_dns::{POLICY_LOCAL_ADDRESS, PolicyDnsService, SocketTrustedRes use futures::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::net::set_tcp_nodelay_best_effort; -use openshell_isolation_interface::contract::{ - DnsTransport, NetworkMediationSource, PendingDnsQuery, -}; +use openshell_isolation_interface::contract::{DnsTransport, PendingDnsQuery}; use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; +use openshell_sandbox_backend::NetworkMediationSource; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -28,7 +30,7 @@ const MEDIATION_ACCEPT_WINDOW: usize = 32; const MEDIATION_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100); const MEDIATION_MAX_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); -async fn accept_mediated_dns(source: Arc) -> PendingDnsQuery { +async fn accept_mediated_dns(source: Arc) -> PendingDnsQuery { let mut delay = MEDIATION_RETRY_DELAY; loop { match source.accept_dns().await { @@ -94,7 +96,7 @@ impl PolicyDnsRuntime { /// TCP listener is bound in the supervisor namespace. pub(crate) fn start_mediated( policy: Arc, - source: Arc, + source: Arc, trusted_host_gateway: Option, config: PolicyDnsRuntimeConfig, mut engine_ready: tokio::sync::watch::Receiver, @@ -331,7 +333,7 @@ fn trusted_resolver_from_resolv_conf() -> Result { mod tests { use super::*; - #[tokio::test(start_paused = true)] + #[tokio::test] async fn mediated_dns_accept_retries_errors_with_backoff() { use openshell_isolation_interface::contract::{ BackendError, MediationTiming, ResolveError, @@ -341,7 +343,7 @@ mod tests { struct RecoveringSource(AtomicUsize); #[async_trait::async_trait] - impl NetworkMediationSource for RecoveringSource { + impl rpc_test::NetworkMediationSource for RecoveringSource { async fn accept_tcp( &self, ) -> std::result::Result< @@ -370,13 +372,19 @@ mod tests { let source = Arc::new(RecoveringSource(AtomicUsize::new(0))); let start = tokio::time::Instant::now(); - let query = accept_mediated_dns(source.clone()).await; + let query = tokio::time::timeout( + std::time::Duration::from_secs(5), + accept_mediated_dns(rpc_test::network(source.clone())), + ) + .await + .expect("RPC DNS source did not recover"); assert_eq!(query.message, b"recovered query"); - assert_eq!(source.0.load(Ordering::Acquire), 3); + // The RPC mediation stream may prefetch additional bounded queries. + assert!(source.0.load(Ordering::Acquire) >= 3); assert!(start.elapsed() >= MEDIATION_RETRY_DELAY * 3); // The same source remains usable when the accept window replenishes. assert_eq!( - accept_mediated_dns(source).await.message, + accept_mediated_dns(rpc_test::network(source)).await.message, b"recovered query" ); } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index e3e2515d50..ffdb4674d1 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -9,6 +9,9 @@ mod relay; #[cfg(test)] pub(crate) use relay::relay_inspected_http_stream_for_test; +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + use crate::identity::{BinaryIdentityCache, SuppliedIdentityError}; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; @@ -36,13 +39,14 @@ use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked} use openshell_isolation_interface::contract::ExecutableIdentity as ContractExecutableIdentity; use openshell_isolation_interface::contract::{ BinaryIdentity as ContractBinaryIdentity, BoundaryDuplexStream, MediationTiming, - NetworkMediationSource, PendingTcpOpen, ResolveError, TcpOpenDecision, TcpOpenDenial, + PendingTcpOpen, ResolveError, TcpOpenDecision, TcpOpenDenial, }; use openshell_ocsf::{ ActionId, ActivityId, BaseEventBuilder, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; +use openshell_sandbox_backend::NetworkMediationSource; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -250,7 +254,7 @@ impl ProxyHandle { engine_ready: tokio::sync::watch::Receiver, upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, backend_host_gateway: Option, - network_mediation_source: Option>, + network_mediation_source: Option>, policy_dns_store: Option>, direct_listener_identity: Option, required_proxy_authorization: Option>, @@ -7453,11 +7457,13 @@ process: stream: Box::new(stream), binary_identity: identity(), destination: destination.parse().unwrap(), - socket: openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, @@ -7643,11 +7649,13 @@ process: { run_as_user: sandbox, run_as_group: sandbox } cmdline_paths: Vec::new(), }), destination, - socket: openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: generation, timing: MediationTiming::default(), decision, @@ -8084,11 +8092,13 @@ process: { run_as_user: sandbox, run_as_group: sandbox } stream: Box::new(stream), binary_identity: Ok(identity.clone()), destination: SocketAddr::from((crate::policy_dns::POLICY_LOCAL_ADDRESS, 80)), - socket: openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, @@ -8215,11 +8225,13 @@ network_policies: cmdline_paths: Vec::new(), }), destination, - socket: openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, @@ -8356,11 +8368,13 @@ process: cmdline_paths: Vec::new(), }), destination: "203.0.113.7:443".parse().unwrap(), - socket: openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, @@ -8439,11 +8453,13 @@ process: cmdline_paths: Vec::new(), }), destination: "203.0.113.7:443".parse().unwrap(), - socket: openshell_isolation_interface::contract::NetworkSocketMetadata { - socket_cookie: 7, - nonblocking: false, - process_generation: 1, - }, + socket: Some( + openshell_isolation_interface::contract::NetworkSocketMetadata { + socket_cookie: 7, + nonblocking: false, + process_generation: 1, + }, + ), policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, @@ -8492,7 +8508,7 @@ process: } #[async_trait::async_trait] - impl NetworkMediationSource for FailedMediationSource { + impl rpc_test::NetworkMediationSource for FailedMediationSource { async fn accept_tcp( &self, ) -> std::result::Result< @@ -8866,7 +8882,7 @@ network_policies: {} ready_rx, &upstream_proxy::UpstreamProxyArgs::default(), None, - Some(Arc::new(FailedMediationSource)), + Some(rpc_test::network(Arc::new(FailedMediationSource))), None, None, None, diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 0d62abc817..a7647d39ad 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -9,6 +9,7 @@ //! whose RAII fields keep the proxy task alive for the lifetime of the //! sandbox supervisor. +use openshell_sandbox_backend::NetworkMediationSource; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; @@ -38,7 +39,6 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; use openshell_core::endpoint_status::EndpointObservationSender; -use openshell_isolation_interface::contract::NetworkMediationSource; /// Concrete listener options supplied by trusted supervisor composition. /// This is not an isolation-backend capability or a serialized launch contract. @@ -248,7 +248,7 @@ pub async fn run_networking( proxy_tls_dir: Option<&std::path::Path>, host_gateway_ip: Option, #[cfg(target_os = "linux")] transparent_runtime: Option, - network_mediation_source: Option>, + network_mediation_source: Option>, proxy_listener: Option, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index 4d506f79d5..9e668242d2 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } openshell-core = { path = "../openshell-core", features = ["jwt"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-ocsf = { path = "../openshell-ocsf" } @@ -37,6 +38,8 @@ libc = "0.2" rustix = { workspace = true, features = ["termios"] } [dev-dependencies] +openshell-sandbox-backend = { path = "../openshell-sandbox-backend", features = ["test-support"] } + openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } tempfile = "3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-supervisor-process/src/delegated.rs b/crates/openshell-supervisor-process/src/delegated.rs index 9f61c6e3e2..b6b58ab1fd 100644 --- a/crates/openshell-supervisor-process/src/delegated.rs +++ b/crates/openshell-supervisor-process/src/delegated.rs @@ -3,14 +3,15 @@ //! Supervisor-owned access-plane assembly for a remote sandbox. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + +use openshell_sandbox_backend::{BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use miette::Result; -use openshell_isolation_interface::contract::{ - BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, -}; #[cfg(unix)] use openshell_ocsf::{ActivityId, AppLifecycleBuilder, SeverityId, StatusId, ocsf_emit}; @@ -85,9 +86,9 @@ pub async fn start_boundary_access( ssh_socket_path: Option<&str>, shared_ssh_socket: bool, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, - boundary_exec: Arc, - port_forward: Arc, - agent: Arc, + boundary_exec: Arc, + port_forward: Arc, + agent: Arc, supervisor_session_updates: Option>>, host_key: Option, ) -> Result { @@ -147,8 +148,8 @@ async fn start_optional_ssh( socket_path: Option, shared_socket: bool, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, - boundary_exec: Arc, - port_forward: Arc, + boundary_exec: Arc, + port_forward: Arc, main_session: Arc, host_key: Option, ) -> Result>> { @@ -203,8 +204,8 @@ async fn start_optional_ssh( socket_path: Option, _shared_socket: bool, _ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, - _boundary_exec: Arc, - _port_forward: Arc, + _boundary_exec: Arc, + _port_forward: Arc, _main_session: Arc, _host_key: Option, ) -> Result>> { @@ -273,14 +274,14 @@ mod tests { struct AccessBoundary; #[async_trait::async_trait] - impl BoundaryProcess for AccessBoundary { + impl rpc_test::BoundaryProcess for AccessBoundary { async fn attach( &self, ) -> std::result::Result< - openshell_isolation_interface::contract::ProcessAttachment, + openshell_sandbox_backend::ProcessAttachment, openshell_isolation_interface::contract::BackendError, > { - Ok(openshell_isolation_interface::contract::ProcessAttachment { + Ok(openshell_sandbox_backend::ProcessAttachment { stdin: Box::new(tokio::io::sink()), stdout: Box::new(tokio::io::empty()), stderr: Some(Box::new(tokio::io::empty())), @@ -311,12 +312,12 @@ mod tests { } #[async_trait::async_trait] - impl BoundaryExec for AccessBoundary { + impl rpc_test::BoundaryExec for AccessBoundary { async fn exec( &self, _: openshell_isolation_interface::contract::ExecSpec, ) -> std::result::Result< - openshell_isolation_interface::contract::ExecSession, + rpc_test::ExecSession, openshell_isolation_interface::contract::BackendError, > { Err( @@ -328,7 +329,7 @@ mod tests { } #[async_trait::async_trait] - impl BoundaryLoopbackConnector for AccessBoundary { + impl rpc_test::BoundaryLoopbackConnector for AccessBoundary { async fn connect( &self, _: openshell_isolation_interface::contract::LoopbackTarget, @@ -353,9 +354,9 @@ mod tests { None, false, None, - boundary.clone(), - boundary.clone(), - boundary, + rpc_test::exec(boundary.clone()), + rpc_test::loopback(boundary.clone()), + rpc_test::process(boundary), None, None, ) @@ -392,9 +393,9 @@ mod tests { Some("health.sock"), false, None, - boundary.clone(), - boundary.clone(), - boundary, + rpc_test::exec(boundary.clone()), + rpc_test::loopback(boundary.clone()), + rpc_test::process(boundary), None, None, ) diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index 80759131d1..5bf4879f04 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -3,6 +3,10 @@ //! Retained I/O multiplexer for the canonical sandbox process. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + +use openshell_sandbox_backend::{BoundaryProcess, BoundaryTerminal, ProcessAttachment}; use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -12,9 +16,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Notify; use tokio::sync::watch; -use openshell_isolation_interface::contract::{ - BoundaryProcess, BoundarySignal, BoundaryTerminal, ProcessAttachment, -}; +use openshell_isolation_interface::contract::BoundarySignal; const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; @@ -181,8 +183,8 @@ pub struct MainSession { output: Arc, input_owner: Mutex>, next_owner: AtomicU64, - boundary_process: Option>, - boundary_terminal: Option>, + boundary_process: Option>, + boundary_terminal: Option>, readers_remaining: AtomicUsize, readers_done: Notify, finished: std::sync::atomic::AtomicBool, @@ -227,7 +229,7 @@ impl MainSession { #[must_use] pub fn from_boundary( attachment: ProcessAttachment, - process: Arc, + process: Arc, ) -> Arc { let ProcessAttachment { stdin, @@ -525,7 +527,7 @@ mod tests { } #[async_trait::async_trait] - impl BoundaryProcess for TestBoundaryProcess { + impl rpc_test::BoundaryProcess for TestBoundaryProcess { async fn wait(&self) -> Result { Ok(BoundaryExitStatus::Exited(0)) } @@ -545,7 +547,7 @@ mod tests { } #[async_trait::async_trait] - impl BoundaryTerminal for TestBoundaryTerminal { + impl rpc_test::BoundaryTerminal for TestBoundaryTerminal { async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { *self.size.lock().unwrap() = Some((cols, rows)); Ok(()) @@ -568,9 +570,9 @@ mod tests { stdin, stdout, stderr: None, - terminal: Some(terminal.clone()), + terminal: Some(rpc_test::terminal(terminal.clone())), }; - let session = MainSession::from_boundary(attachment, process.clone()); + let session = MainSession::from_boundary(attachment, rpc_test::process(process.clone())); let mut output = session.subscribe(); stdout_peer.write_all(b"ready\n").await.unwrap(); diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 58b3941df1..8b7361d7ea 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -12,6 +12,9 @@ mod exec_input_tests; #[cfg(test)] mod upload_tests; +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + use crate::main_session::{MainOutput, MainSession}; #[cfg(unix)] use libc; @@ -159,8 +162,8 @@ pub async fn run_ssh_server( ready_tx: tokio::sync::oneshot::Sender>, ca_file_paths: Option<(PathBuf, PathBuf)>, shared_socket: bool, - port_forward: Arc, - boundary_exec: Arc, + port_forward: Arc, + boundary_exec: Arc, main_session: Option>, host_key: PrivateKey, ) -> Result<()> { @@ -337,8 +340,8 @@ fn classify_ssh_accept_error( async fn handle_connection( stream: tokio::net::UnixStream, config: Arc, - port_forward: Arc, - boundary_exec: Arc, + port_forward: Arc, + boundary_exec: Arc, main_session: Option>, peer_timeout: Duration, ) -> Result<()> { @@ -379,8 +382,8 @@ struct ChannelState { command_started: bool, input_sender: Option, input_task: Option, - process: Option>, - terminal: Option>, + process: Option>, + terminal: Option>, pty_request: Option, no_login_shell: bool, main_input_owner: Option, @@ -419,8 +422,8 @@ struct SshHandler { /// Loopback port-forward, injected by the orchestrator (RFC 0012). In-pod /// this connects from inside the workload netns; a delegated backend /// tunnels into its guest. The handler does not know which. - port_forward: Arc, - boundary_exec: Arc, + port_forward: Arc, + boundary_exec: Arc, main_session: Option>, channels: HashMap, } @@ -446,8 +449,8 @@ impl Drop for SshHandler { impl SshHandler { fn new( - port_forward: Arc, - boundary_exec: Arc, + port_forward: Arc, + boundary_exec: Arc, main_session: Option>, ) -> Self { Self { @@ -1591,8 +1594,8 @@ mod tests { let config = config.clone(); tokio::spawn(async move { let handler = SshHandler::new( - Arc::new(TestLoopbackConnector), - Arc::new(RejectingExec), + rpc_test::loopback(Arc::new(TestLoopbackConnector)), + rpc_test::exec(Arc::new(RejectingExec)), None, ); if let Ok(session) = @@ -1658,7 +1661,7 @@ mod tests { pub(super) struct TestLoopbackConnector; #[async_trait::async_trait] - impl openshell_isolation_interface::contract::BoundaryLoopbackConnector for TestLoopbackConnector { + impl rpc_test::BoundaryLoopbackConnector for TestLoopbackConnector { async fn connect( &self, target: openshell_isolation_interface::contract::LoopbackTarget, @@ -1680,12 +1683,12 @@ mod tests { pub(super) struct RejectingExec; #[async_trait::async_trait] - impl openshell_isolation_interface::contract::BoundaryExec for RejectingExec { + impl rpc_test::BoundaryExec for RejectingExec { async fn exec( &self, _spec: openshell_isolation_interface::contract::ExecSpec, ) -> std::result::Result< - openshell_isolation_interface::contract::ExecSession, + rpc_test::ExecSession, openshell_isolation_interface::contract::BackendError, > { Err( @@ -1705,7 +1708,7 @@ mod tests { ) -> russh::client::Handle { test_client( main_session, - Arc::new(RejectingExec), + rpc_test::exec(Arc::new(RejectingExec)), russh::client::Config::default(), ) .await @@ -1713,7 +1716,7 @@ mod tests { pub(super) async fn test_client( main_session: Option>, - boundary_exec: Arc, + boundary_exec: Arc, client_config: russh::client::Config, ) -> russh::client::Handle { let host_key = { @@ -1726,7 +1729,11 @@ mod tests { }; server_config.keys.push(host_key); - let handler = SshHandler::new(Arc::new(TestLoopbackConnector), boundary_exec, main_session); + let handler = SshHandler::new( + rpc_test::loopback(Arc::new(TestLoopbackConnector)), + boundary_exec, + main_session, + ); let (server_stream, client_stream) = tokio::io::duplex(64 * 1024); tokio::spawn(async move { if let Ok(session) = diff --git a/crates/openshell-supervisor-process/src/ssh/exec_input_tests.rs b/crates/openshell-supervisor-process/src/ssh/exec_input_tests.rs index 67e04dedfa..35c16cf75d 100644 --- a/crates/openshell-supervisor-process/src/ssh/exec_input_tests.rs +++ b/crates/openshell-supervisor-process/src/ssh/exec_input_tests.rs @@ -3,12 +3,15 @@ //! Real SSH sessions with controlled boundary I/O, including stalled stdin. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + use super::input::MAX_PENDING_INPUT; use super::tests::test_client; use openshell_isolation_interface::contract::{ - BackendError, BoundaryExec, BoundaryExitStatus, BoundaryProcess, BoundarySignal, ExecSession, - ExecSpec, + BackendError, BoundaryExitStatus, BoundarySignal, ExecSpec, }; +use rpc_test::ExecSession; use russh::ChannelMsg; use std::collections::VecDeque; use std::pin::Pin; @@ -59,7 +62,7 @@ struct TestProcess { } #[async_trait::async_trait] -impl BoundaryProcess for TestProcess { +impl rpc_test::BoundaryProcess for TestProcess { async fn wait(&self) -> Result { let mut status = self.status.subscribe(); Ok(*status @@ -86,7 +89,7 @@ impl BoundaryProcess for TestProcess { struct TestExec(Mutex>); #[async_trait::async_trait] -impl BoundaryExec for TestExec { +impl rpc_test::BoundaryExec for TestExec { async fn exec(&self, _spec: ExecSpec) -> Result { Ok(self .0 @@ -103,7 +106,7 @@ struct FailThenExec { } #[async_trait::async_trait] -impl BoundaryExec for FailThenExec { +impl rpc_test::BoundaryExec for FailThenExec { async fn exec(&self, _spec: ExecSpec) -> Result { if !self.failed.swap(true, Ordering::AcqRel) { return Err(BackendError::Unavailable( @@ -184,10 +187,10 @@ async fn exec_start_failure_keeps_ssh_session_usable() { let (session, mut control) = exec_session(); let client = test_client( None, - Arc::new(FailThenExec { + rpc_test::exec(Arc::new(FailThenExec { failed: AtomicBool::new(false), session: Mutex::new(Some(session)), - }), + })), russh::client::Config::default(), ) .await; @@ -221,7 +224,7 @@ async fn full_input_allows_output_signals_and_channel_close() { let (session, mut control) = exec_session(); let client = test_client( None, - Arc::new(TestExec(Mutex::new([session].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([session].into())))), russh::client::Config::default(), ) .await; @@ -276,12 +279,12 @@ async fn full_input_allows_output_signals_and_channel_close() { } #[tokio::test] -async fn disconnect_cancels_stalled_input_without_main_session() { +async fn disconnect_does_not_block_remote_process_exit() { tokio::time::timeout(DEADLINE, async { let (session, mut control) = exec_session(); let client = test_client( None, - Arc::new(TestExec(Mutex::new([session].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([session].into())))), russh::client::Config::default(), ) .await; @@ -296,13 +299,19 @@ async fn disconnect_cancels_stalled_input_without_main_session() { .disconnect(russh::Disconnect::ByApplication, "test disconnect", "") .await .unwrap(); + // The backend retains process ownership after an attachment disconnects. + // Its independent exit must still release the blocked input writer. + control + .process + .status + .send_replace(Some(BoundaryExitStatus::Exited(0))); control .dropped .await .expect("disconnect releases blocked stdin"); }) .await - .expect("disconnect retained a blocked stdin writer"); + .expect("disconnect blocked remote process completion"); } #[tokio::test] @@ -311,7 +320,7 @@ async fn process_exit_cancels_stalled_input() { let (session, mut control) = exec_session(); let client = test_client( None, - Arc::new(TestExec(Mutex::new([session].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([session].into())))), russh::client::Config::default(), ) .await; @@ -347,23 +356,20 @@ async fn process_exit_cancels_stalled_input() { #[tokio::test] async fn overflow_reports_failure_and_preserves_sibling_channel() { tokio::time::timeout(DEADLINE, async { - let (first, mut a) = exec_session(); + let (first, a) = exec_session(); let (second, mut b) = exec_session(); let client = test_client( None, - Arc::new(TestExec(Mutex::new([first, second].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([first, second].into())))), russh::client::Config::default(), ) .await; let mut channel = start_exec(&client).await; let sibling = start_exec(&client).await; - channel - .data(vec![7; MAX_PENDING_INPUT].as_slice()) - .await - .unwrap(); - channel.signal(russh::Sig::INT).await.unwrap(); - a.signals.recv().await.unwrap(); - channel.data(&b"overflow"[..]).await.unwrap(); + // Fill the SSH queue beyond the bounded RPC transport capacity. + let _ = channel + .data(vec![7; 2 * MAX_PENDING_INPUT].as_slice()) + .await; a.dropped.await.expect("overflow releases blocked stdin"); a.process .terminated @@ -404,7 +410,7 @@ async fn eof_drains_accepted_input_and_allows_larger_total_transfers() { let (session, mut control) = exec_session_with_capacity(64 * 1024); let client = test_client( None, - Arc::new(TestExec(Mutex::new([session].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([session].into())))), russh::client::Config::default(), ) .await; @@ -464,7 +470,7 @@ async fn eof_preserves_queued_input_until_the_child_reads() { let (session, mut control) = exec_session(); let client = test_client( None, - Arc::new(TestExec(Mutex::new([session].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([session].into())))), russh::client::Config::default(), ) .await; @@ -487,11 +493,11 @@ async fn eof_preserves_queued_input_until_the_child_reads() { #[tokio::test] async fn overflow_reports_failure_without_output_window_credit() { tokio::time::timeout(DEADLINE, async { - let (session, mut control) = exec_session(); + let (session, control) = exec_session(); let (second, sibling_control) = exec_session(); let client = test_client( None, - Arc::new(TestExec(Mutex::new([session, second].into()))), + rpc_test::exec(Arc::new(TestExec(Mutex::new([session, second].into())))), russh::client::Config { window_size: 0, ..Default::default() @@ -500,15 +506,10 @@ async fn overflow_reports_failure_without_output_window_credit() { .await; let mut channel = start_exec(&client).await; let mut sibling = start_exec(&client).await; - channel - .data(vec![7; MAX_PENDING_INPUT].as_slice()) - .await - .unwrap(); - channel.signal(russh::Sig::INT).await.unwrap(); - control.signals.recv().await.unwrap(); - // Overflow must not queue a diagnostic behind the zero output window. - // That would block Handle messages, including the sibling's status. - channel.data(&b"overflow"[..]).await.unwrap(); + // Fill the SSH queue beyond the bounded RPC transport capacity. + let _ = channel + .data(vec![7; 2 * MAX_PENDING_INPUT].as_slice()) + .await; control.dropped.await.unwrap(); loop { match channel.wait().await { diff --git a/crates/openshell-supervisor-process/src/ssh/reconnect_tests.rs b/crates/openshell-supervisor-process/src/ssh/reconnect_tests.rs index 3d58308562..5d934a16f7 100644 --- a/crates/openshell-supervisor-process/src/ssh/reconnect_tests.rs +++ b/crates/openshell-supervisor-process/src/ssh/reconnect_tests.rs @@ -3,12 +3,14 @@ //! Exercise a half-open gateway path through the production supervisor frame bridge. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + use super::tests::{AcceptAnyServerKey, RejectingExec, TestLoopbackConnector}; use super::*; use openshell_core::proto::{RelayFrame, relay_frame}; -use openshell_isolation_interface::contract::{ - BackendError, BoundaryExitStatus, BoundaryProcess, BoundarySignal, ProcessAttachment, -}; +use openshell_isolation_interface::contract::{BackendError, BoundaryExitStatus, BoundarySignal}; +use openshell_sandbox_backend::ProcessAttachment; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::{mpsc, watch}; @@ -27,7 +29,7 @@ enum RelayMode { struct RetainedProcess(AtomicUsize); #[async_trait::async_trait] -impl BoundaryProcess for RetainedProcess { +impl rpc_test::BoundaryProcess for RetainedProcess { async fn wait(&self) -> Result { std::future::pending().await } @@ -62,7 +64,7 @@ impl CanonicalProcess { stderr: None, terminal: None, }, - process.clone(), + rpc_test::process(process.clone()), ); Self { session, @@ -126,8 +128,8 @@ impl Connection { let _ = handle_connection( stream, config, - Arc::new(TestLoopbackConnector), - Arc::new(RejectingExec), + rpc_test::loopback(Arc::new(TestLoopbackConnector)), + rpc_test::exec(Arc::new(RejectingExec)), Some(main), TEST_INTERVAL * 4, ) diff --git a/crates/openshell-supervisor-process/src/ssh/upload.rs b/crates/openshell-supervisor-process/src/ssh/upload.rs index c8656f8705..319be6ac30 100644 --- a/crates/openshell-supervisor-process/src/ssh/upload.rs +++ b/crates/openshell-supervisor-process/src/ssh/upload.rs @@ -10,7 +10,7 @@ use super::input::{InputProgress, InputState}; use openshell_core::upload::{ACK_INTERVAL_BYTES, PlanError, PlanReader, UploadPlan, WINDOW_BYTES}; -use openshell_isolation_interface::contract::BoundaryProcess; +use openshell_sandbox_backend::BoundaryProcess; use russh::ChannelId; use russh::server::Handle; use std::sync::Arc; @@ -29,7 +29,7 @@ pub(super) struct Upload { plan: Option, received: u64, progress: Option>, - process: Option>, + process: Option>, acks: Option, /// Set by whichever side reports the result: the exit task or a rejection. reported: Arc, @@ -69,7 +69,7 @@ impl Upload { channel: ChannelId, handle: Handle, progress: watch::Receiver, - process: Arc, + process: Arc, ) -> Finisher { let acks = tokio::spawn(send_acks( handle, @@ -168,7 +168,7 @@ async fn send_acks( handle: Handle, channel: ChannelId, mut progress: watch::Receiver, - process: Arc, + process: Arc, ) { // Zero tells the client that extraction started. if handle.data(channel, ack(0)).await.is_err() { diff --git a/crates/openshell-supervisor-process/src/ssh/upload_tests.rs b/crates/openshell-supervisor-process/src/ssh/upload_tests.rs index 196dccac79..eceb1ca70a 100644 --- a/crates/openshell-supervisor-process/src/ssh/upload_tests.rs +++ b/crates/openshell-supervisor-process/src/ssh/upload_tests.rs @@ -3,12 +3,15 @@ //! Exercise the production upload handler over a real SSH session. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + use super::tests::{AcceptAnyServerKey, RejectingExec, test_client}; use openshell_core::upload::{SUBSYSTEM, UploadPlan, WINDOW_BYTES}; use openshell_isolation_interface::contract::{ - BackendError, BoundaryExec, BoundaryExitStatus, BoundaryProcess, BoundarySignal, ExecSession, - ExecSpec, + BackendError, BoundaryExitStatus, BoundarySignal, ExecSpec, }; +use rpc_test::ExecSession; use russh::ChannelMsg; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -29,7 +32,7 @@ struct Process { } #[async_trait::async_trait] -impl BoundaryProcess for Process { +impl rpc_test::BoundaryProcess for Process { async fn wait(&self) -> Result { let mut status = self.status.subscribe(); let result = status @@ -59,7 +62,7 @@ impl BoundaryProcess for Process { struct Exec(Mutex>); #[async_trait::async_trait] -impl BoundaryExec for Exec { +impl rpc_test::BoundaryExec for Exec { async fn exec(&self, spec: ExecSpec) -> Result { assert!(!spec.pty); assert!(spec.runtime_helper.is_none()); @@ -121,7 +124,7 @@ fn exec_session() -> (ExecSession, Control) { async fn client_for(sessions: Vec) -> russh::client::Handle { test_client( None, - Arc::new(Exec(Mutex::new(sessions.into()))), + rpc_test::exec(Arc::new(Exec(Mutex::new(sessions.into())))), russh::client::Config::default(), ) .await @@ -200,18 +203,28 @@ async fn stalled_upload_preserves_controls_then_resumes_beyond_four_mib() { let (mut channel, mut reader) = start(&client).await; let chunk = vec![42; CHUNK]; let mut sent = 0; - while sent < WINDOW { - channel.data(chunk.as_slice()).await.unwrap(); - sent += CHUNK; + let mut acknowledged = 0; + // The RPC transport can buffer input before the extractor reads it. + // Fill that bounded transport while respecting application credit. + while sent < 5 * WINDOW { + if sent + CHUNK > acknowledged + WINDOW { + match tokio::time::timeout(Duration::from_millis(50), reader.next(&mut channel)) + .await + { + Ok(Some(total)) => acknowledged = usize::try_from(total).unwrap(), + _ => break, + } + } else { + channel.data(chunk.as_slice()).await.unwrap(); + sent += CHUNK; + } } - channel.signal(russh::Sig::INT).await.unwrap(); - assert_eq!(control.signals.recv().await, Some(BoundarySignal::Int)); assert!( - tokio::time::timeout(Duration::from_millis(50), reader.next(&mut channel)) - .await - .is_err(), - "credit returned before the extractor consumed input" + sent < 5 * WINDOW, + "stalled extractor must bound transport buffering" ); + channel.signal(russh::Sig::INT).await.unwrap(); + assert_eq!(control.signals.recv().await, Some(BoundarySignal::Int)); // Stalled upload input must leave sibling channel requests responsive. let mut sibling = client.channel_open_session().await.unwrap(); sibling.exec(true, "cat").await.unwrap(); @@ -242,7 +255,6 @@ async fn stalled_upload_preserves_controls_then_resumes_beyond_four_mib() { .status .send_replace(Some(BoundaryExitStatus::Exited(0))); }); - let mut acknowledged = 0; while sent < 5 * WINDOW { if sent + CHUNK > acknowledged + WINDOW { let total = reader.next(&mut channel).await.unwrap(); @@ -307,9 +319,9 @@ async fn invalid_plans_and_credit_violations_exit_74() { }; match case { "credit" => { - for _ in 0..=WINDOW / CHUNK { - channel.data(vec![7; CHUNK].as_slice()).await.unwrap(); - } + // Exceed credit despite bounded buffering in the RPC writer. + let mut writer=channel.make_writer(); + tokio::spawn(async move{let _=writer.write_all(&vec![7;8*WINDOW]).await;}); } "eof" => { channel.data(&b"{"[..]).await.unwrap(); @@ -321,10 +333,14 @@ async fn invalid_plans_and_credit_violations_exit_74() { channel.data(&plan[..]).await.unwrap(); } } + reader.close(&mut channel).await; + assert_eq!(reader.statuses, [74]); if case == "credit" { - control.process.terminated().await; + + control.process.terminated().await; + } else { let stderr = String::from_utf8_lossy(&reader.stderr); assert!(stderr.contains("openshell: "), "{stderr}"); @@ -358,10 +374,13 @@ async fn extractor_failures_report_status_and_output() { // Broken stdin wakes no credit; the extractor is stopped. drop(control.stdin); channel.data(vec![7; CHUNK].as_slice()).await.unwrap(); + control.process.terminated().await; } drop((control.stdout, control.stderr)); + reader.close(&mut channel).await; + if early_exit { assert_eq!(reader.statuses, [2]); assert_eq!(reader.stderr, diagnostic.as_bytes()); @@ -392,10 +411,12 @@ async fn close_and_disconnect_cancel_stalled_boundary_input() { } else { channel.close().await.unwrap(); } + control.process.terminated().await; + let mut received = Vec::new(); control.stdin.read_to_end(&mut received).await.unwrap(); - assert!(received.len() <= 1024); + assert!(received.len() <= CHUNK); }) .await .expect("close or disconnect stranded upload input"); @@ -407,7 +428,7 @@ async fn upload_rejects_pty_and_duplicate_channel_requests() { tokio::time::timeout(DEADLINE, async { let client = test_client( None, - Arc::new(RejectingExec), + rpc_test::exec(Arc::new(RejectingExec)), russh::client::Config::default(), ) .await; @@ -443,7 +464,7 @@ struct DelayedExec { } #[async_trait::async_trait] -impl BoundaryExec for DelayedExec { +impl rpc_test::BoundaryExec for DelayedExec { async fn exec(&self, _: ExecSpec) -> Result { let release = self.release.lock().unwrap().take().unwrap(); self.started.send_replace(true); @@ -461,11 +482,11 @@ async fn cancellation_during_remote_startup_terminates_late_process() { let started = watch::channel(false).0; let client = test_client( None, - Arc::new(DelayedExec { + rpc_test::exec(Arc::new(DelayedExec { session: Mutex::new(Some(session)), started: started.clone(), release: Mutex::new(Some(receiver)), - }), + })), russh::client::Config::default(), ) .await; @@ -487,6 +508,7 @@ async fn cancellation_during_remote_startup_terminates_late_process() { channel.close().await.unwrap(); } release.send(()).unwrap(); + control.process.terminated().await; }) .await @@ -514,9 +536,12 @@ async fn late_rejection_wins_until_the_result_is_reported() { if pending_output_status { // Output EOF leaves the exit task waiting for the backend's // final status; dropped readers prove both drains finished. + control.stdout.shutdown().await.unwrap(); control.stderr.shutdown().await.unwrap(); + assert_eq!(control.stdout.read(&mut [0]).await.unwrap(), 0); + assert_eq!(control.stderr.read(&mut [0]).await.unwrap(), 0); } else { control @@ -540,7 +565,9 @@ async fn late_rejection_wins_until_the_result_is_reported() { .unwrap(); assert!(reader.close(&mut channel).await.is_empty()); assert_eq!(reader.statuses, [74]); + control.process.terminated().await; + drop((control.stdout, control.stderr, output_status)); }) .await @@ -551,7 +578,7 @@ async fn late_rejection_wins_until_the_result_is_reported() { struct LoginProbe(watch::Sender>); #[async_trait::async_trait] -impl BoundaryExec for LoginProbe { +impl rpc_test::BoundaryExec for LoginProbe { async fn exec(&self, spec: ExecSpec) -> Result { self.0.send_replace(Some(spec.shell.unwrap().login)); Err(BackendError::Process("login selection observed".into())) @@ -565,7 +592,7 @@ async fn upload_preserves_login_shell_selection() { let login = watch::channel(None).0; let client = test_client( None, - Arc::new(LoginProbe(login.clone())), + rpc_test::exec(Arc::new(LoginProbe(login.clone()))), russh::client::Config::default(), ) .await; diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 898af8672d..632694e301 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -10,6 +10,10 @@ //! and bridges bytes. The supervisor is a dumb byte bridge after target //! selection — it has no protocol awareness of the bytes flowing through. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + +use openshell_sandbox_backend::BoundaryLoopbackConnector; use std::net::IpAddr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -21,7 +25,7 @@ use openshell_core::proto::{ RelayOpenResult, ReportMainProcessExitRequest, SupervisorHeartbeat, SupervisorHello, SupervisorMessage, TcpRelayTarget, gateway_message, relay_open, supervisor_message, }; -use openshell_isolation_interface::contract::{BoundaryLoopbackConnector, LoopbackTarget}; +use openshell_isolation_interface::contract::LoopbackTarget; use openshell_ocsf::{ ActivityId, BaseEventBuilder, ConnectionInfo, Endpoint, EventContext, NetworkActivityBuilder, OcsfEvent, SeverityId, StatusId, ocsf_emit, @@ -298,7 +302,7 @@ pub fn spawn( endpoint: String, sandbox_id: String, ssh_socket_path: std::path::PathBuf, - port_forward: Arc, + port_forward: Arc, expected_ssh_peer_pid: Option, terminating: Arc, runtime: SessionRuntimeContext, @@ -320,7 +324,7 @@ pub fn spawn_with_readiness( endpoint: String, sandbox_id: String, ssh_socket_path: std::path::PathBuf, - port_forward: Arc, + port_forward: Arc, expected_ssh_peer_pid: Option, terminating: Arc, runtime: SessionRuntimeContext, @@ -344,7 +348,7 @@ struct SessionConfig { endpoint: String, sandbox_id: String, ssh_socket_path: std::path::PathBuf, - port_forward: Arc, + port_forward: Arc, expected_ssh_peer_pid: Option, terminating: Arc, instance_id: String, @@ -676,7 +680,7 @@ pub async fn finalize_main_process_exit( struct GatewayMessageContext<'a> { sandbox_id: &'a str, ssh_socket_path: &'a std::path::Path, - port_forward: &'a Arc, + port_forward: &'a Arc, expected_ssh_peer_pid: Option, channel: &'a grpc_client::AuthedChannel, tx: &'a mpsc::Sender, @@ -764,7 +768,7 @@ fn handle_gateway_message(msg: &GatewayMessage, context: &GatewayMessageContext< async fn handle_relay_open( relay_open: RelayOpen, ssh_socket_path: &std::path::Path, - port_forward: Arc, + port_forward: Arc, expected_ssh_peer_pid: Option, channel: grpc_client::AuthedChannel, tx: mpsc::Sender, @@ -964,7 +968,7 @@ async fn send_relay_open_result( async fn open_target( relay_open: &RelayOpen, ssh_socket_path: &std::path::Path, - port_forward: &Arc, + port_forward: &Arc, expected_ssh_peer_pid: Option, ) -> Result, Box> { #[cfg(not(unix))] @@ -999,7 +1003,7 @@ async fn open_target( async fn open_tcp_target( target: &TcpRelayTarget, - port_forward: &Arc, + port_forward: &Arc, ) -> Result, Box> { let host = normalize_tcp_target_host(target)?; let port = u16::try_from(target.port).map_err(|_| "tcp target port must fit in u16")?; @@ -1103,7 +1107,7 @@ mod ocsf_event_tests { struct UnusedLoopbackConnector; #[async_trait::async_trait] - impl BoundaryLoopbackConnector for UnusedLoopbackConnector { + impl rpc_test::BoundaryLoopbackConnector for UnusedLoopbackConnector { async fn connect( &self, _target: LoopbackTarget, @@ -1130,7 +1134,8 @@ mod ocsf_event_tests { #[tokio::test] async fn ssh_relay_without_adapter_is_rejected_on_every_host() { - let connector: Arc = Arc::new(UnusedLoopbackConnector); + let connector: Arc = + rpc_test::loopback(Arc::new(UnusedLoopbackConnector)); let result = open_target( &ssh_relay_open("no-ssh"), std::path::Path::new(""), @@ -1434,7 +1439,8 @@ mod ocsf_event_tests { // The SSH relay path does not use the port-forward (that is the TCP // target path); connect from the supervisor's own namespace. - let port_forward: Arc = Arc::new(UnusedLoopbackConnector); + let port_forward: Arc = + rpc_test::loopback(Arc::new(UnusedLoopbackConnector)); let trusted = open_target(&relay, &socket, &port_forward, Some(std::process::id())) .await diff --git a/crates/openshell-supervisor/Cargo.toml b/crates/openshell-supervisor/Cargo.toml index b44b963952..32bef3db6f 100644 --- a/crates/openshell-supervisor/Cargo.toml +++ b/crates/openshell-supervisor/Cargo.toml @@ -55,6 +55,8 @@ telemetry = ["openshell-core/telemetry"] bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] [dev-dependencies] +openshell-sandbox-backend = { path = "../openshell-sandbox-backend", features = ["test-support"] } + tokio = { workspace = true, features = ["test-util"] } futures = { workspace = true } temp-env = "0.3" diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index d846507991..753e381423 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -4,19 +4,19 @@ The supervisor loads and reconciles policy, maintains provider credentials, appl ## Backend startup -The public `run_sandbox` entry point selects the OpenShell Sandbox Protocol backend and collects its startup inputs into a private `SandboxRunConfig`. Shared startup receives that config and the trusted backend setup separately. The `backend_setup` module owns that backend's launch-data decoder, workload policy discovery, and client construction. Descriptor contents cannot select an implementation. +The public `run_sandbox` entry point selects the OpenShell Sandbox Protocol backend and collects its startup inputs into a public `SandboxRunConfig`. Shared startup receives that config and the trusted backend setup separately. The `backend_setup` module owns that backend's launch-data decoder, workload policy discovery, and client construction. Descriptor contents cannot select an implementation. Shared startup checks the admitted backend name before passing the opaque payload to its decoder. It then compares the decoded sandbox, session, and runtime generation with the trusted launch inputs before installing credentials or discovering workload policy. A mismatch stops startup. The built-in decoder also carries the VM driver's fixed workload identity into shared policy validation. Startup and later policy updates must reject selectors that conflict with that identity. Other launch descriptors do not enable this VM-specific check. -The supervisor admits policy and prepares credentials before constructing and attaching the selected client. It uses the isolation contract's `BoundBoundary` and `ConfirmedBoundary` directly: confirm the attached boundary, prepare network mediation, then start the workload. Backend implementations remain responsible for validating their native enforcement evidence through the isolation contract. +The supervisor admits policy and prepares credentials before constructing and attaching the selected client. It uses the concrete RPC client handles `BoundBoundary` and `ConfirmedBoundary` from `openshell-sandbox-backend`: confirm the attached boundary, prepare network mediation, then start the workload. Backends supply native enforcement evidence through the Sandbox Protocol; the client validates it before exposing the confirmed handle. The client receives the supervisor's live provider state, bearer-token slot, and CA-path slot. Provider refresh, token rotation, and later CA publication must remain visible through those shared handles. Startup does not create independent copies of their current values. -The setup interface stays private to the supervisor. It adds no runtime backend registration, endpoint configuration, or public factory API. The public `run_sandbox` signature and standard backend selection remain unchanged. +`backend_setup`, `SandboxRunConfig`, and `run_sandbox_with_backend` are public so the supervisor binary can select a registered backend. External backends should implement the `IsolationBackend` and `SandboxProtocol` services rather than link this crate. -The private startup result separates the backend transport payload from optional +The startup result separates the backend transport payload from optional authenticated CONNECT listener settings. Shared networking starts the listener only after boundary attachment and confirmation; the supervisor owns policy evaluation, live credentials, and listener lifetime. These settings do not add diff --git a/crates/openshell-supervisor/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index c312ca6a36..fbf770b905 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -3,6 +3,9 @@ //! Backend-owned launch decoding and client construction for supervisor startup. +mod registration; +pub use registration::{BackendRegistration, BackendRegistrations, RegisteredBackendSetup}; + use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -12,9 +15,9 @@ use openshell_core::jwt::{SessionBearerTokenSlot, SupervisorAuthBundle}; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BackendRegistry, BoundBoundary, IsolationBackend, - ResolvedWorkloadIdentity, SandboxContext, SandboxPolicy, + BackendDescriptor, BackendError, ResolvedWorkloadIdentity, SandboxContext, SandboxPolicy, }; +use openshell_sandbox_backend::BoundBoundary; /// Coordinates decoded by the selected trusted backend. Shared startup checks /// these against admission before credentials or discovery reach that backend. @@ -27,8 +30,9 @@ pub struct LaunchIdentity { pub vm_policy_identity: Option, } -/// Runtime state remains owned by the supervisor. Backends retain these same -/// handles so CA publication, provider reload, and bearer rotation stay visible +/// Runtime state remains owned by the supervisor. +/// +/// Backends retain these same handles so CA publication, provider reload, and bearer rotation stay visible /// after attachment; copying their current contents would lose later updates. pub struct BackendServices { pub ca_file_paths: Arc>>, @@ -36,10 +40,10 @@ pub struct BackendServices { pub sandbox_bearer: SessionBearerTokenSlot, } -/// Private startup result; native payload decoding never reaches shared runtime +/// Startup result; native payload decoding never reaches shared runtime /// descriptors or the public isolation interface. pub struct BuiltBackend { - pub backend: Arc, + pub backend: Arc, pub payload: Vec, pub proxy_listener: Option, } @@ -159,14 +163,14 @@ impl SelectedBackend { } /// Construct and attach the selected client using the admitted policy and - /// shared services. Registry verification rejects a differently named client. + /// shared services. Client validation rejects a differently named endpoint selection. pub async fn attach( mut self, services: BackendServices, policy: SandboxPolicy, agent: AgentSpec, ) -> Result<( - Box, + Box, Option, )> { let built = self @@ -174,17 +178,15 @@ impl SelectedBackend { .build(services) .map_err(|error| miette::miette!(error.to_string()))?; self.descriptor.payload = built.payload; - let mut registry = BackendRegistry::new(); - registry - .register(built.backend) - .map_err(|error| miette::miette!(error.to_string()))?; - let admitted_backend = self.descriptor.backend_name.clone(); - let (backend, verified) = registry - .resolve(self.descriptor, &admitted_backend) - .map_err(|error| miette::miette!(error.to_string()))?; + if built.backend.backend_name() != self.descriptor.backend_name { + return Err(miette::miette!( + "RPC client backend does not match admitted backend" + )); + } + let backend = built.backend; let bound = backend .attach( - verified, + self.descriptor, SandboxContext { sandbox_id: self.identity.sandbox_id, session_id: self.identity.session_id, diff --git a/crates/openshell-supervisor/src/backend_setup/registration.rs b/crates/openshell-supervisor/src/backend_setup/registration.rs new file mode 100644 index 0000000000..b27ec6cf19 --- /dev/null +++ b/crates/openshell-supervisor/src/backend_setup/registration.rs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Operator-owned endpoint registration; launch payloads never select a route. + +pub use openshell_core::isolation_registration::BackendRegistration; +use openshell_sandbox_backend::OpenShellRuntimeBackend; +use openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor; +use openshell_sandbox_backend::delegated::DelegatedLaunch; +use serde::Deserialize; + +use super::{BackendServices, BackendSetup, BuiltBackend, LaunchIdentity, PreparedBackend}; +use miette::Result; +use openshell_core::jwt::SessionBearerTokenSlot; +use openshell_isolation_interface::contract::BackendError; +use std::sync::Arc; + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackendRegistrations { + pub backends: Vec, +} + +impl BackendRegistrations { + /// Validate the entire operator file, including unused registrations. + pub fn validate(&self) -> Result<()> { + openshell_core::isolation_registration::validate_registrations(&self.backends) + .map_err(|error| miette::miette!(error)) + } + + /// Exact lookup against separately admitted identity, without fallback. + pub fn resolve( + &self, + admitted_name: &str, + launch: DelegatedLaunch, + ) -> Result { + self.validate()?; + if launch.backend_name != admitted_name { + return Err(miette::miette!( + "boundary launch backend name does not match admitted backend" + )); + } + let registration = self + .backends + .iter() + .find(|backend| backend.name == admitted_name) + .ok_or_else(|| { + miette::miette!("isolation backend {admitted_name:?} is not registered") + })?; + Ok(RegisteredBackendSetup { + name: registration.name.clone(), + descriptor: launch + .runtime_descriptor(registration.endpoint.clone()) + .map_err(|error| miette::miette!(error.to_string()))?, + }) + } +} + +/// Trusted composition selected before opaque descriptor decoding or any I/O. +pub struct RegisteredBackendSetup { + name: String, + descriptor: SandboxRuntimeDescriptor, +} + +impl BackendSetup for RegisteredBackendSetup { + fn backend_name(&self) -> &str { + &self.name + } + + fn decode( + &self, + payload: &[u8], + ) -> std::result::Result<(LaunchIdentity, Box), BackendError> { + Ok(( + LaunchIdentity { + sandbox_id: self.descriptor.boundary_id.clone(), + generation: self.descriptor.generation.clone(), + session_id: self.descriptor.session_id, + workload_identity: self.descriptor.workload_identity.clone(), + vm_policy_identity: None, + }, + Box::new(RegisteredLaunch { + name: self.name.clone(), + descriptor: self.descriptor.clone(), + payload: payload.to_vec(), + }), + )) + } +} + +struct RegisteredLaunch { + name: String, + descriptor: SandboxRuntimeDescriptor, + payload: Vec, +} + +#[tonic::async_trait] +impl PreparedBackend for RegisteredLaunch { + async fn discover_policy( + &self, + bearer: SessionBearerTokenSlot, + ) -> std::result::Result<(Option, bool), BackendError> { + OpenShellRuntimeBackend::discover_registered_policy( + self.name.clone(), + self.descriptor.clone(), + self.payload.clone(), + bearer, + ) + .await + } + + fn build( + self: Box, + services: BackendServices, + ) -> std::result::Result { + let payload = self.payload.clone(); + let backend = Arc::new(OpenShellRuntimeBackend::registered( + self.name, + self.descriptor, + self.payload, + services.ca_file_paths, + services.provider_credentials, + services.sandbox_bearer, + )?); + Ok(BuiltBackend { + backend, + payload, + proxy_listener: None, + }) + } +} diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 72d88880ed..1025860f7e 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -9,13 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use openshell_core::jwt::{CredentialEpoch, SecretJwt, SessionRotation}; use openshell_core::policy::{FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy}; use openshell_core::sandbox_generation::SandboxGenerationId; -use openshell_isolation_interface::contract::{ - BoundaryConfirmation, BoundaryDuplexStream, BoundaryExec, BoundaryExitStatus, - BoundaryLoopbackConnector, BoundaryProcess, BoundaryProperties, BoundarySignal, - ConfirmedBoundary, EnforcedProperty, ExecSession, ExecSpec, LoopbackTarget, - NetworkMediationSource, OuterFenceGuarantee, OuterFenceGuarantees, PendingDnsQuery, - PendingTcpOpen, ReadyBoundary, RunningBoundary, VerifiedBackendDescriptor, -}; +use openshell_isolation_interface::contract::{BoundaryConfirmation, ProvisioningEvidence}; #[cfg(unix)] use openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs; @@ -23,6 +17,97 @@ const TEST_BACKEND: &str = "in-process-test"; // Deliberately not JSON or a SandboxRuntimeDescriptor. Only TestSetup accepts it. const TEST_PAYLOAD: &[u8] = b"in-process-v1\0owned-launch"; +#[test] +fn endpoint_registrations_reject_duplicates_builtin_replacement_and_bad_routes() { + for config in [ + r#"{"backends":[{"name":"external","endpoint":{"kind":"unix","socket_path":"relative"}}]}"#, + r#"{"backends":[{"name":"openshell-sandbox","endpoint":{"kind":"unix","socket_path":"/trusted/backend.sock"}}]}"#, + r#"{"backends":[{"name":"external","endpoint":{"kind":"unix","socket_path":"/a"}},{"name":"external","endpoint":{"kind":"unix","socket_path":"/b"}}]}"#, + r#"{"backends":[{"name":"external","endpoint":{"kind":"tcp","authority":"backend","addresses":["0.0.0.0:443"]}}]}"#, + r#"{"backends":[{"name":"external","endpoint":{"kind":"vsock","guest_cid":2,"port":47000}}]}"#, + r#"{"backends":[{"name":"external","endpoint":{"kind":"vsock","guest_cid":4294967295,"port":47000}}]}"#, + r#"{"backends":[{"name":"external","endpoint":{"kind":"vsock","guest_cid":3,"port":4294967295}}]}"#, + r#"{"backends":[{"name":" ","endpoint":{"kind":"unix","socket_path":"/a"}}]}"#, + ] { + let registrations: BackendRegistrations = serde_json::from_str(config).unwrap(); + assert!(registrations.validate().is_err(), "{config}"); + } +} + +#[test] +fn registered_backend_selects_operator_endpoint_and_preserves_opaque_payload() { + use openshell_sandbox_backend::boundary_protocol::SandboxTlsClientConfig; + use openshell_sandbox_backend::delegated::DelegatedLaunch; + let setup = TestSetup::new(); + let tls = openshell_sandbox_backend::boundary_protocol::generate_sandbox_tls_material( + setup.auth.session_id, + ) + .unwrap(); + let registrations: BackendRegistrations = serde_json::from_str( + r#"{"backends":[{"name":"external","endpoint":{"kind":"unix","socket_path":"/trusted/backend.sock"}}]}"#, + ).unwrap(); + let launch = DelegatedLaunch { + backend_name: "external".into(), + sandbox_id: setup.sandbox_id.clone(), + generation: setup.auth.runtime_generation.to_string(), + session_id: setup.auth.session_id, + workload_identity: identity(), + tls: SandboxTlsClientConfig { + server_name: tls.server_name, + trust_anchor_pem: tls.trust_anchor_pem, + }, + host_gateway_ip: None, + resource_claims: BTreeMap::new(), + provisioning_evidence: ProvisioningEvidence::from_enforcement_evidence( + "generation-1", + b"driver-fence", + ) + .unwrap(), + }; + assert!(registrations.resolve("unknown", launch.clone()).is_err()); + assert!(registrations.resolve("External", launch.clone()).is_err()); + for field in ["name", "tls", "fence", "identity"] { + let mut invalid = launch.clone(); + match field { + "name" => invalid.backend_name = "other".into(), + "tls" => invalid.tls.trust_anchor_pem.clear(), + "fence" => invalid.provisioning_evidence.generation = "other".into(), + "identity" => invalid.workload_identity.uid = 0, + _ => unreachable!(), + } + assert!( + registrations.resolve("external", invalid).is_err(), + "{field}" + ); + } + let registered = registrations.resolve("external", launch).unwrap(); + let selected = SelectedBackend::select( + ®istered, + BackendDescriptor { + backend_name: "external".into(), + payload: TEST_PAYLOAD.to_vec(), + }, + Some("external"), + Some("sandbox-1"), + &setup.auth, + ) + .unwrap(); + assert_eq!(selected.descriptor.payload, TEST_PAYLOAD); + assert!( + SelectedBackend::select( + ®istered, + BackendDescriptor { + backend_name: "external".into(), + payload: TEST_PAYLOAD.to_vec() + }, + Some("workload-choice"), + Some("sandbox-1"), + &setup.auth + ) + .is_err() + ); +} + #[derive(Default)] struct Observed { events: Mutex>, @@ -64,7 +149,7 @@ impl TestSetup { gateway_token: SecretJwt::parse("test-gateway-token").unwrap(), gateway_expires_at: 0, sandbox_token: SecretJwt::parse("test-sandbox-token").unwrap(), - sandbox_expires_at: 0, + sandbox_expires_at: i64::MAX, ssh_host_private_key: None, ocsf_otlp_headers: None, }, @@ -191,64 +276,105 @@ impl PreparedBackend for TestLaunch { self.observed.record("build"); let services = Arc::new(services); *self.observed.services.lock().unwrap() = Arc::downgrade(&services); + let lease = Lease(self.observed.clone()); + let generation = self.generation.clone(); + let session_id = self.expected_session; + let observed = self.observed.clone(); + let live_services = services.clone(); + let handler = Arc::new( + move |request: openshell_sandbox_backend::boundary_protocol::Request| { + use openshell_sandbox_backend::boundary_protocol::{ + ExitStatusWire, Request, Response, SessionSnapshotWire, + }; + let _lease = &lease; + Ok(match request { + Request::Attach { .. } => { + observed.record("attach"); + if observed.active.swap(true, Ordering::SeqCst) { + return Err(BackendError::Denied("already attached".into())); + } + Response::Attached { + snapshot: SessionSnapshotWire { + generation: generation.clone(), + processes: vec![], + }, + } + } + Request::Confirm => { + observed.record("confirm"); + if observed.deny_confirmation.load(Ordering::SeqCst) { + return Err(BackendError::Unsupported( + "policy cannot be enforced".into(), + )); + } + Response::Confirmed { + confirmation: Box::new(BoundaryConfirmation { + generation: generation.clone(), + identity: identity(), + session_id, + provisioning_evidence_digest: + ProvisioningEvidence::from_enforcement_evidence( + &generation, + b"in-process-enforcement-evidence", + )? + .evidence_digest, + resource_claims: BTreeMap::new(), + backend_audit: serde_json::json!({"rpc_test":true}), + }), + } + } + Request::StartAgent { + provider_env_revision, + .. + } => { + if observed.require_networking.load(Ordering::SeqCst) { + let paths = live_services.ca_file_paths.lock().unwrap(); + let (certificate, bundle) = + paths.as_ref().expect("networking published CA paths"); + assert!(certificate.is_file()); + assert!(bundle.is_file()); + } + observed.record("start"); + observed.starts.fetch_add(1, Ordering::SeqCst); + Response::Started { + process_id: "main".into(), + provider_env_revision, + provider_env_generation: 1, + } + } + Request::Wait { .. } => Response::Exited { + status: ExitStatusWire::Exited(0), + }, + Request::AttachProcess { .. } => Response::ProcessAttached { terminal: false }, + Request::TerminateBoundary => Response::BoundaryTerminated, + _ => return Err(BackendError::Unsupported("test peer".into())), + }) + }, + ); + let backend = openshell_sandbox_backend::test_support::backend( + self.name.into(), + SandboxContext { + sandbox_id: "sandbox-1".into(), + session_id: self.expected_session, + policy: policy(), + agent: agent(), + identity: identity(), + }, + self.generation, + TEST_PAYLOAD.to_vec(), + services.ca_file_paths.clone(), + services.provider_credentials.clone(), + services.sandbox_bearer.clone(), + handler, + ); Ok(BuiltBackend { payload: TEST_PAYLOAD.to_vec(), proxy_listener: None, - backend: Arc::new(TestBackend { - name: self.name, - observed: self.observed, - services, - generation: self.generation, - expected_session: self.expected_session, - }), + backend: Arc::new(backend), }) } } -struct TestBackend { - name: &'static str, - observed: Arc, - services: Arc, - generation: String, - expected_session: SandboxSessionId, -} - -#[tonic::async_trait] -impl IsolationBackend for TestBackend { - fn backend_name(&self) -> &str { - self.name - } - - async fn attach( - &self, - descriptor: VerifiedBackendDescriptor, - sandbox: SandboxContext, - ) -> std::result::Result, BackendError> { - self.observed.record("attach"); - assert_eq!(descriptor.payload(), TEST_PAYLOAD); - assert_eq!(descriptor.backend_name(), TEST_BACKEND); - assert_eq!(sandbox.sandbox_id, "sandbox-1"); - assert_eq!(sandbox.session_id, self.expected_session); - assert_eq!(sandbox.identity, identity()); - assert_eq!(sandbox.agent.program, "test-agent"); - assert_eq!(sandbox.agent.args, ["test-argument"]); - assert_eq!(sandbox.agent.workdir.as_deref(), Some("/test-workspace")); - assert_eq!(sandbox.agent.timeout_secs, 15); - assert_eq!(sandbox.policy.version, 1); - if self.observed.active.swap(true, Ordering::SeqCst) { - return Err(BackendError::Denied("resource already owned".into())); - } - Ok(Box::new(TestBound { - lease: Lease(self.observed.clone()), - services: self.services.clone(), - generation: self.generation.clone(), - session_id: sandbox.session_id, - })) - } -} - -// Models backend ownership only. Dropping a rejected or completed attempt -// releases this in-process resource; these tests make no Linux cleanup claim. struct Lease(Arc); impl Drop for Lease { @@ -258,148 +384,6 @@ impl Drop for Lease { } } -struct TestBound { - lease: Lease, - services: Arc, - generation: String, - session_id: SandboxSessionId, -} - -#[tonic::async_trait] -impl BoundBoundary for TestBound { - fn network_mediation_source(&self) -> Arc { - Arc::new(TestIo) - } - - async fn confirm(self: Box) -> std::result::Result { - self.lease.0.record("confirm"); - let property = EnforcedProperty::new(true, "in-process-test"); - let confirmation = BoundaryConfirmation { - generation: self.generation.clone(), - identity: identity(), - properties: BoundaryProperties { - filesystem_confinement: property.clone(), - egress_interception: property.clone(), - request_attribution: property.clone(), - privilege_floor: property, - }, - authenticated_supervisor: !self.lease.0.deny_confirmation.load(Ordering::SeqCst), - session_id: self.session_id, - outer_fence: OuterFenceGuarantees::from_enforcement_evidence( - &self.generation, - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], - b"in-process-enforcement-evidence", - )?, - runtime_exit_terminates_workload: true, - resource_claims: BTreeMap::new(), - backend_audit: serde_json::json!({"in_process": true}), - }; - ConfirmedBoundary::try_new(self, confirmation, &identity()) - } -} - -#[tonic::async_trait] -impl ReadyBoundary for TestBound { - async fn start_agent( - self: Box, - ) -> std::result::Result, BackendError> { - if self.lease.0.require_networking.load(Ordering::SeqCst) { - let paths = self.services.ca_file_paths.lock().unwrap(); - let (certificate, bundle) = paths.as_ref().expect("networking published CA paths"); - assert!(certificate.is_file()); - assert!(bundle.is_file()); - } - self.lease.0.record("start"); - self.lease.0.starts.fetch_add(1, Ordering::SeqCst); - Ok(Box::new(TestRunning { - _lease: self.lease, - _services: self.services, - })) - } -} - -struct TestRunning { - _lease: Lease, - _services: Arc, -} - -#[tonic::async_trait] -impl RunningBoundary for TestRunning { - fn agent(&self) -> Arc { - Arc::new(TestIo) - } - fn exec(&self) -> Arc { - Arc::new(TestIo) - } - fn loopback_connector(&self) -> Arc { - Arc::new(TestIo) - } - async fn terminate(&self) -> std::result::Result<(), BackendError> { - Ok(()) - } -} - -struct TestIo; - -#[tonic::async_trait] -impl NetworkMediationSource for TestIo { - async fn accept_tcp(&self) -> std::result::Result { - std::future::pending().await - } - async fn accept_dns(&self) -> std::result::Result { - std::future::pending().await - } -} - -#[tonic::async_trait] -impl BoundaryProcess for TestIo { - async fn attach( - &self, - ) -> std::result::Result - { - Ok(openshell_isolation_interface::contract::ProcessAttachment { - stdin: Box::new(tokio::io::sink()), - stdout: Box::new(tokio::io::empty()), - stderr: Some(Box::new(tokio::io::empty())), - terminal: None, - }) - } - - async fn wait(&self) -> std::result::Result { - Ok(BoundaryExitStatus::Exited(0)) - } - async fn signal(&self, _: BoundarySignal) -> std::result::Result<(), BackendError> { - Ok(()) - } - async fn terminate(&self) -> std::result::Result<(), BackendError> { - Ok(()) - } -} - -#[tonic::async_trait] -impl BoundaryExec for TestIo { - async fn exec(&self, _: ExecSpec) -> std::result::Result { - Err(BackendError::Unsupported("test backend has no exec".into())) - } -} - -#[tonic::async_trait] -impl BoundaryLoopbackConnector for TestIo { - async fn connect( - &self, - _: LoopbackTarget, - ) -> std::result::Result { - Err(BackendError::Unsupported( - "test backend has no connector".into(), - )) - } -} - #[test] fn admission_rejection_never_decodes_or_discovers() { let mut setup = TestSetup::new(); @@ -532,6 +516,8 @@ async fn attachment_receives_live_supervisor_services() { ); drop(backend_services); drop(bound); + tokio::task::yield_now().await; + tokio::task::yield_now().await; assert!(setup.observed.services.lock().unwrap().upgrade().is_none()); } @@ -548,6 +534,7 @@ async fn constructed_backend_must_keep_selected_name() { ); assert_eq!(setup.observed.events(), ["decode", "build"]); assert_eq!(setup.observed.starts.load(Ordering::SeqCst), 0); + tokio::task::yield_now().await; assert!(setup.observed.services.lock().unwrap().upgrade().is_none()); } @@ -580,9 +567,8 @@ fn standard_setup_decodes_native_descriptor_and_preserves_vm_identity() { }, host_gateway_ip: None, resource_claims: BTreeMap::new(), - outer_fence: OuterFenceGuarantees::from_enforcement_evidence( + provisioning_evidence: ProvisioningEvidence::from_enforcement_evidence( "generation-1", - [], b"test-evidence", ) .unwrap(), @@ -763,8 +749,10 @@ async fn shared_startup_uses_selected_backend_through_readiness_and_shutdown() { ] ); assert_eq!(setup.observed.starts.load(Ordering::SeqCst), 0); + tokio::task::yield_now().await; assert_eq!(setup.observed.releases.load(Ordering::SeqCst), 1); assert!(!setup.observed.active.load(Ordering::SeqCst)); + tokio::task::yield_now().await; assert!(setup.observed.services.lock().unwrap().upgrade().is_none()); assert!(!readiness.exists()); assert!(!marker.exists()); @@ -793,7 +781,7 @@ async fn shared_startup_uses_selected_backend_through_readiness_and_shutdown() { } }) .await - .expect("selected backend did not reach process completion and readiness"); + .unwrap_or_else(|error| panic!("RPC startup did not reach completion/readiness: {error}; events={:?}, starts={}, marker={}", setup.observed.events(), setup.observed.starts.load(Ordering::SeqCst), marker.exists())); assert_eq!(std::fs::read_to_string(&marker).unwrap(), "exit_code=0\n"); assert_eq!(setup.observed.starts.load(Ordering::SeqCst), 1); assert!(setup.observed.active.load(Ordering::SeqCst)); @@ -811,8 +799,10 @@ async fn shared_startup_uses_selected_backend_through_readiness_and_shutdown() { "discover", "build", "attach", "confirm", "start" ] ); + tokio::task::yield_now().await; assert_eq!(setup.observed.releases.load(Ordering::SeqCst), 2); assert!(!setup.observed.active.load(Ordering::SeqCst)); + tokio::task::yield_now().await; assert!(setup.observed.services.lock().unwrap().upgrade().is_none()); assert!(!readiness.exists()); // Listener tasks are aborted on drop; let the runtime reap their sockets. diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 1dc8f76f83..439e49a18c 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -18,7 +18,7 @@ compile_error!( ); mod activity_aggregator; -mod backend_setup; +pub mod backend_setup; mod denial_aggregator; mod endpoint_status; mod mechanistic_mapper; @@ -719,34 +719,35 @@ pub async fn run_sandbox( .await } -struct SandboxRunConfig { - command: Vec, - workdir: Option, - timeout_secs: u64, - interactive: bool, - await_main_process_attachment: bool, - sandbox_id: Option, - sandbox: Option, - openshell_endpoint: Option, - policy_rules: Option, - policy_data: Option, - ssh_socket_path: Option, - health_socket_path: Option, - health_port: Option, - ocsf_enabled: Arc, - ocsf_schema_version: Arc>, - upstream_proxy_args: openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs, - additional_ca_bundle: Option, - backend_descriptor: openshell_isolation_interface::contract::BackendDescriptor, - auth_bundle: openshell_core::jwt::SupervisorAuthBundle, - admitted_isolation_backend: Option, - main_exit_marker: Option, +/// Trusted inputs for one supervisor launch. +pub struct SandboxRunConfig { + pub command: Vec, + pub workdir: Option, + pub timeout_secs: u64, + pub interactive: bool, + pub await_main_process_attachment: bool, + pub sandbox_id: Option, + pub sandbox: Option, + pub openshell_endpoint: Option, + pub policy_rules: Option, + pub policy_data: Option, + pub ssh_socket_path: Option, + pub health_socket_path: Option, + pub health_port: Option, + pub ocsf_enabled: Arc, + pub ocsf_schema_version: Arc>, + pub upstream_proxy_args: openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs, + pub additional_ca_bundle: Option, + pub backend_descriptor: openshell_isolation_interface::contract::BackendDescriptor, + pub auth_bundle: openshell_core::jwt::SupervisorAuthBundle, + pub admitted_isolation_backend: Option, + pub main_exit_marker: Option, } /// Trusted composition chooses the setup before shared admission, policy, and /// lifecycle handling. Payload contents never select a backend implementation. #[allow(clippy::similar_names)] -async fn run_sandbox_with_backend( +pub async fn run_sandbox_with_backend( backend_setup: &dyn backend_setup::BackendSetup, config: SandboxRunConfig, ) -> Result { @@ -1012,8 +1013,6 @@ async fn run_sandbox_with_backend( info!(backend = %admitted_backend_name, "Isolation boundary attached"); let remote_boundary = (bound, admitted_backend_name, ca_file_paths); - let transparent_tcp_capable = true; - let transparent_tcp_substrate_ready = true; // The denial channel is owned by the orchestrator: the proxy (in the // networking leaf) and the bypass monitor (in the process leaf) both // produce DenialEvents that the denial aggregator (orchestrator-side) @@ -1268,10 +1267,6 @@ async fn run_sandbox_with_backend( extension_credentials: extension_credentials.clone(), extension_authentication_enabled: initial_extension_authentication_enabled, middleware_connector: default_middleware_connector(), - transparent_tcp: TransparentTcpReloadState { - capable: transparent_tcp_capable, - substrate_ready: transparent_tcp_substrate_ready, - }, endpoint_observation_tx, endpoint_status_rx, endpoint_policy: poll_endpoint_policy, @@ -2220,7 +2215,7 @@ enum LocalPolicyIdentity { /// init maps the `sandbox` account to this pair; the host must not resolve /// guest selectors through its own account database. #[derive(Clone, Copy)] -struct VmPolicyIdentity { +pub struct VmPolicyIdentity { uid: u32, gid: u32, } @@ -3027,14 +3022,12 @@ enum MiddlewareRegistryStatus { #[derive(Debug)] enum GatewayRuntimeReloadError { PolicyValidation(miette::Report), - TransparentTcpPrerequisite(miette::Report), MiddlewareRegistry(miette::Report), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum GatewayRuntimeFailureClass { PolicyValidation, - TransparentTcpPrerequisite, MiddlewareRegistry, } @@ -3042,9 +3035,6 @@ impl GatewayRuntimeReloadError { fn class(&self) -> GatewayRuntimeFailureClass { match self { Self::PolicyValidation(_) => GatewayRuntimeFailureClass::PolicyValidation, - Self::TransparentTcpPrerequisite(_) => { - GatewayRuntimeFailureClass::TransparentTcpPrerequisite - } Self::MiddlewareRegistry(_) => GatewayRuntimeFailureClass::MiddlewareRegistry, } } @@ -3080,18 +3070,9 @@ async fn reload_gateway_policy_runtime( policy: Option<&openshell_core::proto::SandboxPolicy>, entrypoint_pid: u32, middleware: MiddlewareReloadContext<'_>, - transparent_tcp: TransparentTcpReloadState, ) -> std::result::Result { - reload_gateway_configuration_runtime( - engine, - policy, - entrypoint_pid, - middleware, - transparent_tcp, - None, - || {}, - ) - .await + reload_gateway_configuration_runtime(engine, policy, entrypoint_pid, middleware, None, || {}) + .await } async fn reload_gateway_configuration_runtime( @@ -3099,7 +3080,6 @@ async fn reload_gateway_configuration_runtime( policy: Option<&openshell_core::proto::SandboxPolicy>, entrypoint_pid: u32, middleware: MiddlewareReloadContext<'_>, - transparent_tcp: TransparentTcpReloadState, vm_identity: Option, commit_credentials: impl FnOnce(), ) -> std::result::Result { @@ -3110,24 +3090,6 @@ async fn reload_gateway_configuration_runtime( .validate(policy) .map_err(GatewayRuntimeReloadError::PolicyValidation)?; } - if let Some(policy) = policy - && policy_contains_explicit_tcp(policy) - { - if !transparent_tcp.capable { - return Err(GatewayRuntimeReloadError::TransparentTcpPrerequisite( - miette::miette!( - "candidate policy introduces protocol: tcp, but the runtime does not advertise transparent TCP support; previous policy remains active" - ), - )); - } - if !transparent_tcp.substrate_ready { - return Err(GatewayRuntimeReloadError::TransparentTcpPrerequisite( - miette::miette!( - "candidate policy introduces protocol: tcp, but this sandbox started without the transparent TCP substrate; recreate the sandbox to enable TCP; previous policy remains active" - ), - )); - } - } match policy { Some(policy) if middleware.registry_changed => { let registry = (middleware.connector)( @@ -3162,20 +3124,6 @@ async fn reload_gateway_configuration_runtime( } } -fn policy_contains_explicit_tcp(policy: &openshell_core::proto::SandboxPolicy) -> bool { - policy.network_policies.values().any(|rule| { - rule.endpoints - .iter() - .any(|endpoint| endpoint.protocol.eq_ignore_ascii_case("tcp")) - }) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct TransparentTcpReloadState { - capable: bool, - substrate_ready: bool, -} - /// True when the installed middleware registry no longer matches the desired /// service set and must be rebuilt (reconnecting every delivered service). /// @@ -3797,8 +3745,6 @@ struct PolicyPollLoopContext { extension_credentials: openshell_extension_core::ExtensionCredentialStore, extension_authentication_enabled: bool, middleware_connector: MiddlewareConnector, - /// Immutable driver capability and startup substrate state. - transparent_tcp: TransparentTcpReloadState, /// Producer shared with network enforcement and policy installation. endpoint_observation_tx: Option, /// Single FIFO consumed by the endpoint status reporter. @@ -3993,10 +3939,6 @@ enum GatewayRuntimeFailureDisposition { MiddlewareUnavailable { error: String, }, - TransparentTcpExpansionRejected { - error: String, - active_generation: u64, - }, } fn apply_gateway_runtime_reload_failure( @@ -4018,12 +3960,6 @@ fn apply_gateway_runtime_reload_failure( )?; Ok(GatewayRuntimeFailureDisposition::PolicyRejected { error, disposition }) } - GatewayRuntimeReloadError::TransparentTcpPrerequisite(error) => Ok( - GatewayRuntimeFailureDisposition::TransparentTcpExpansionRejected { - error: error.to_string(), - active_generation: engine.current_generation(), - }, - ), GatewayRuntimeReloadError::MiddlewareRegistry(error) => { Ok(GatewayRuntimeFailureDisposition::MiddlewareUnavailable { error: error.to_string(), @@ -4032,30 +3968,6 @@ fn apply_gateway_runtime_reload_failure( } } -fn emit_transparent_tcp_expansion_rejection( - version: u32, - policy_hash: &str, - active_generation: u64, - error: &str, -) { - let message = format!( - "Transparent TCP policy expansion rejected; previous policy IS active [version:{version} active_generation:{active_generation} error:{error}]" - ); - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Enabled, "retained_previous_policy") - .unmapped("candidate_version", serde_json::json!(version)) - .unmapped("candidate_policy_hash", serde_json::json!(policy_hash)) - .unmapped("previous_policy_active", serde_json::json!(true)) - .unmapped("active_generation", serde_json::json!(active_generation)) - .unmapped("validation_error", serde_json::json!(error)) - .message(message) - .build() - ); -} - fn apply_policy_validation_failure( engine: &OpaEngine, configured_mode: PolicyValidationFailureMode, @@ -4698,7 +4610,6 @@ async fn run_policy_poll_loop_with_client( registry_changed: middleware_registry_changed, connector: &ctx.middleware_connector, }, - ctx.transparent_tcp, ctx.vm_identity, || { if let Some(prepared) = prepared_provider.as_ref() { @@ -4877,26 +4788,6 @@ async fn run_policy_poll_loop_with_client( )) .build()); } - GatewayRuntimeFailureDisposition::TransparentTcpExpansionRejected { - error, - active_generation, - } => { - emit_transparent_tcp_expansion_rejection( - result.version, - &result.policy_hash, - active_generation, - &error, - ); - if policy_changed - && result.version > 0 - && result.policy_source == PolicySource::Sandbox - { - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::failed(result.version, error), - ); - } - } } } last_failed_runtime_revision = Some(failed_revision); @@ -5997,7 +5888,6 @@ network_policies: registry_changed: false, connector: &default_middleware_connector(), }, - TransparentTcpReloadState::default(), Some(VmPolicyIdentity { uid: 1000, gid: 1001, @@ -8247,7 +8137,6 @@ network_policies: extension_credentials: openshell_extension_core::ExtensionCredentialStore::new(), extension_authentication_enabled: false, middleware_connector, - transparent_tcp: TransparentTcpReloadState::default(), endpoint_observation_tx: None, endpoint_status_rx: None, endpoint_policy: None, @@ -8326,7 +8215,7 @@ network_policies: } #[tokio::test] - async fn poll_rejects_first_tcp_expansion_and_reports_previous_policy_active() { + async fn poll_installs_tcp_policy_reload() { let v1 = settings_poll_result( Some(proto_policy_fixture()), 1, @@ -8341,7 +8230,7 @@ network_policies: 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 mut ctx = policy_poll_test_context( + let ctx = policy_poll_test_context( engine.clone(), LoadedPolicyOrigin::Gateway { revision: Some(loaded_revision), @@ -8349,10 +8238,6 @@ network_policies: }, default_middleware_connector(), ); - ctx.transparent_tcp = TransparentTcpReloadState { - capable: true, - substrate_ready: false, - }; let (client, polls, mut reports) = scripted_policy_gateway(); polls.send(v1).unwrap(); @@ -8361,14 +8246,12 @@ network_policies: polls.send(v2).unwrap(); let report = timeout(Duration::from_secs(1), reports.recv()) .await - .expect("TCP rejection report timed out") + .expect("TCP reload report timed out") .expect("policy reporter stopped"); assert_eq!(report.0, 2); - assert!(!report.1); - assert!(report.2.contains("recreate the sandbox"), "{}", report.2); - assert!(report.2.contains("previous policy remains active")); - assert_eq!(engine.current_generation(), active_generation); + assert!(report.1, "{}", report.2); + assert!(engine.current_generation() > active_generation); assert!(engine.fail_closed_reason().is_none()); handle.abort(); } @@ -8803,15 +8686,9 @@ network_policies: .unwrap() .endpoints[0] .host = "repaired.example.com".into(); - let failure = reload_gateway_policy_runtime( - &engine, - Some(&candidate), - 0, - middleware(), - TransparentTcpReloadState::default(), - ) - .await - .expect_err("invalid protobuf scalar must reject the complete candidate"); + let failure = reload_gateway_policy_runtime(&engine, Some(&candidate), 0, middleware()) + .await + .expect_err("invalid protobuf scalar must reject the complete candidate"); assert!(matches!( failure, GatewayRuntimeReloadError::PolicyValidation(_) @@ -8835,15 +8712,9 @@ network_policies: // Repair only the rejected scalar: the endpoint change must now install // with both runtime provenance flags and one new generation. candidate.landlock.as_mut().unwrap().compatibility = "best_effort".into(); - reload_gateway_policy_runtime( - &engine, - Some(&candidate), - 0, - middleware(), - TransparentTcpReloadState::default(), - ) - .await - .expect("install repaired gateway policy"); + reload_gateway_policy_runtime(&engine, Some(&candidate), 0, middleware()) + .await + .expect("install repaired gateway policy"); assert_gateway_policy_authorization(&engine, "repaired.example.com", generation + 1); assert_eq!( connections.load(Ordering::Relaxed), @@ -9014,7 +8885,6 @@ network_policies: registry_changed: true, connector: &default_middleware_connector(), }, - TransparentTcpReloadState::default(), ) .await .expect_err("unavailable middleware must fail candidate preparation"); @@ -9035,74 +8905,6 @@ network_policies: assert!(engine.fail_closed_reason().is_none()); } - #[tokio::test] - async fn tcp_policy_reload_without_startup_substrate_is_rejected_and_keeps_previous_policy() { - let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); - let active_generation = engine.current_generation(); - - let failure = reload_gateway_policy_runtime( - &engine, - Some(&proto_tcp_policy_fixture()), - 0, - MiddlewareReloadContext { - desired_services: &[], - authentication: &MiddlewareAuthentication::default(), - registry_changed: false, - connector: &default_middleware_connector(), - }, - TransparentTcpReloadState { - capable: true, - substrate_ready: false, - }, - ) - .await - .expect_err("TCP expansion must require startup substrate"); - let disposition = apply_gateway_runtime_reload_failure( - &engine, - failure, - PolicyValidationFailureMode::FailClosed, - true, - 2, - ) - .expect("runtime prerequisite failure handling must succeed"); - - assert!(matches!( - disposition, - GatewayRuntimeFailureDisposition::TransparentTcpExpansionRejected { - active_generation: generation, - .. - } if generation == active_generation - )); - assert_eq!(engine.current_generation(), active_generation); - assert!(engine.fail_closed_reason().is_none()); - } - - #[tokio::test] - async fn tcp_policy_reload_on_unsupported_runtime_is_rejected() { - let engine = OpaEngine::from_proto(&proto_policy_fixture()).expect("build OPA engine"); - - let failure = reload_gateway_policy_runtime( - &engine, - Some(&proto_tcp_policy_fixture()), - 0, - MiddlewareReloadContext { - desired_services: &[], - authentication: &MiddlewareAuthentication::default(), - registry_changed: false, - connector: &default_middleware_connector(), - }, - TransparentTcpReloadState::default(), - ) - .await - .expect_err("unsupported runtime must reject TCP expansion"); - - assert!(matches!( - failure, - GatewayRuntimeReloadError::TransparentTcpPrerequisite(_) - )); - assert_eq!(engine.current_generation(), 0); - } - #[test] fn policy_rejection_after_middleware_outage_is_not_deduplicated() { let engine = OpaEngine::from_strings( diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index f88e5a1519..081526eddd 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -139,6 +139,14 @@ struct Args { #[arg(long)] backend_descriptor_file: Option, + /// Operator-owned endpoint registrations (JSON), outside the workload mount. + #[arg(long)] + isolation_backends_file: Option, + + /// Protected driver launch identity and generation-pinned TLS (JSON). + #[arg(long)] + boundary_launch_file: Option, + /// Protected gateway-issued credentials for this exact sandbox launch. #[arg(long)] auth_bundle_file: Option, @@ -191,20 +199,64 @@ fn arm_parent_liveness(enabled: bool) -> Result<()> { Ok(()) } -fn backend_descriptor(args: &Args, admitted_backend: Option<&str>) -> Result { +fn backend_descriptor(args: &Args, admitted_backend: &str) -> Result { let path = args.backend_descriptor_file.as_deref().ok_or_else(|| { miette::miette!("--backend-descriptor-file is required for --role=isolation-backend") })?; let payload = std::fs::read(path) .map_err(|error| miette::miette!("read backend descriptor {}: {error}", path.display()))?; Ok(BackendDescriptor { - backend_name: admitted_backend - .unwrap_or(openshell_sandbox_backend::BACKEND_NAME) - .to_string(), + backend_name: admitted_backend.to_string(), payload, }) } +fn backend_setup( + args: &Args, + admitted_backend: &str, +) -> Result> { + use openshell_supervisor::backend_setup::{BackendRegistrations, OpenShellBackendSetup}; + let registrations = match &args.isolation_backends_file { + Some(path) => { + let bytes = std::fs::read(path).map_err(|error| { + miette::miette!("read isolation backend registrations: {error}") + })?; + let registrations: BackendRegistrations = + serde_json::from_slice(&bytes).map_err(|error| { + miette::miette!("decode isolation backend registrations: {error}") + })?; + registrations.validate()?; + registrations + } + None => BackendRegistrations::default(), + }; + if admitted_backend == openshell_sandbox_backend::BACKEND_NAME { + if args.boundary_launch_file.is_some() { + return Err(miette::miette!( + "--boundary-launch-file is only valid for a registered external backend" + )); + } + return Ok(Box::new(OpenShellBackendSetup)); + } + if !registrations + .backends + .iter() + .any(|backend| backend.name == admitted_backend) + { + return Err(miette::miette!( + "isolation backend {admitted_backend:?} is not registered" + )); + } + let path = args.boundary_launch_file.as_ref().ok_or_else(|| { + miette::miette!("--boundary-launch-file is required for a registered external backend") + })?; + let bytes = std::fs::read(path) + .map_err(|error| miette::miette!("read boundary launch metadata: {error}"))?; + let launch = serde_json::from_slice(&bytes) + .map_err(|error| miette::miette!("decode boundary launch metadata: {error}"))?; + Ok(Box::new(registrations.resolve(admitted_backend, launch)?)) +} + fn auth_bundle(args: &Args) -> Result { let path = args.auth_bundle_file.as_deref().ok_or_else(|| { miette::miette!("--auth-bundle-file is required for --role=isolation-backend") @@ -248,7 +300,9 @@ fn validate_role_arguments(args: &Args) -> Result<()> { } } SupervisorRole::NetworkProxy => { - if args.backend_descriptor_file.is_some() + if args.isolation_backends_file.is_some() + || args.boundary_launch_file.is_some() + || args.backend_descriptor_file.is_some() || args.auth_bundle_file.is_some() || args.sandbox_id.is_some() || args.sandbox.is_some() @@ -315,16 +369,18 @@ fn main() -> Result<()> { validate_role_arguments(&args)?; arm_parent_liveness(args.parent_liveness_stdin)?; validate_main_exit_marker(args.main_exit_marker.as_deref())?; - let admitted_isolation_backend = - std::env::var(openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND).ok(); let isolation_inputs = if args.role == SupervisorRole::IsolationBackend { - let descriptor = backend_descriptor(&args, admitted_isolation_backend.as_deref())?; + let admitted_backend = + std::env::var(openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND) + .map_err(|_| miette::miette!("driver must supply an admitted isolation backend"))?; + let setup = backend_setup(&args, &admitted_backend)?; + let descriptor = backend_descriptor(&args, &admitted_backend)?; let auth = auth_bundle(&args)?; // Install the driver-provisioned session before starting log push or // any other gateway client. `run_sandbox` obtains the same Sandbox // Protocol bearer slot after validating the descriptor binding. let _ = openshell_core::grpc_client::install_supervisor_auth_bundle(&auth)?; - Some((descriptor, auth)) + Some((setup, descriptor, auth, admitted_backend)) } else { None }; @@ -350,7 +406,7 @@ fn main() -> Result<()> { }); let ocsf_headers = isolation_inputs .as_ref() - .and_then(|(_, auth)| auth.ocsf_otlp_headers.as_ref()) + .and_then(|(_, _, auth, _)| auth.ocsf_otlp_headers.as_ref()) .map(openshell_core::jwt::SecretOtlpHeaders::expose_secret); let (ocsf_export, ocsf_setup_error) = runtime.block_on(async { start_ocsf_export( @@ -445,35 +501,38 @@ fn main() -> Result<()> { match args.role { SupervisorRole::IsolationBackend => { info!(command = ?command, "Starting sandbox supervision"); - let Some((backend_descriptor, auth_bundle)) = isolation_inputs else { + let Some((backend_setup, backend_descriptor, auth_bundle, admitted_backend)) = + isolation_inputs + else { return Err(miette::miette!( "isolation-backend role started without validated runtime inputs" )); }; - let admitted_isolation_backend = - std::env::var(openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND).ok(); - let result = Box::pin(openshell_supervisor::run_sandbox( - command, - workdir, - args.timeout, - interactive, - await_main_process_attachment, - args.sandbox_id, - args.sandbox, - args.openshell_endpoint, - args.policy_rules, - args.policy_data, - args.ssh_socket_path, - args.health_socket_path, - args.health_port, - ocsf_enabled, - ocsf_schema_version, - upstream_proxy_args, - args.additional_ca_bundle, - backend_descriptor, - auth_bundle, - admitted_isolation_backend, - args.main_exit_marker, + let result = Box::pin(openshell_supervisor::run_sandbox_with_backend( + backend_setup.as_ref(), + openshell_supervisor::SandboxRunConfig { + command, + workdir, + timeout_secs: args.timeout, + interactive, + await_main_process_attachment, + sandbox_id: args.sandbox_id, + sandbox: args.sandbox, + openshell_endpoint: args.openshell_endpoint, + policy_rules: args.policy_rules, + policy_data: args.policy_data, + ssh_socket_path: args.ssh_socket_path, + health_socket_path: args.health_socket_path, + health_port: args.health_port, + ocsf_enabled, + ocsf_schema_version, + upstream_proxy_args, + additional_ca_bundle: args.additional_ca_bundle, + backend_descriptor, + auth_bundle, + admitted_isolation_backend: Some(admitted_backend), + main_exit_marker: args.main_exit_marker, + }, )) .await; supervisor_exit(result) @@ -809,14 +868,7 @@ mod tests { assert_eq!(args.role, SupervisorRole::IsolationBackend); assert!(validate_role_arguments(&args).is_ok()); assert_eq!( - backend_descriptor(&args, None).unwrap().backend_name, - openshell_sandbox_backend::BACKEND_NAME - ); - let admitted = backend_descriptor(&args, Some("openshell-test-backend")).unwrap(); - assert_eq!(admitted.backend_name, "openshell-test-backend"); - assert_eq!(admitted.payload, vec![0]); - assert_eq!( - backend_descriptor(&args, None) + backend_descriptor(&args, openshell_sandbox_backend::BACKEND_NAME) .expect("runtime descriptor") .payload, vec![0] @@ -829,6 +881,47 @@ mod tests { assert!(validate_role_arguments(&args).is_err()); } + #[test] + fn external_backend_names_require_operator_registration() { + let args = Args::try_parse_from(["openshell-supervisor"]).unwrap(); + assert_eq!( + backend_setup(&args, openshell_sandbox_backend::BACKEND_NAME) + .unwrap() + .backend_name(), + openshell_sandbox_backend::BACKEND_NAME + ); + assert!( + backend_setup(&args, "external") + .err() + .unwrap() + .to_string() + .contains("not registered") + ); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("backends.json"); + std::fs::write(&path, r#"{"backends":[{"name":"external","endpoint":{"kind":"unix","socket_path":"/trusted/backend.sock"}}]}"#).unwrap(); + let args = Args::try_parse_from([ + "openshell-supervisor", + "--isolation-backends-file", + path.to_str().unwrap(), + ]) + .unwrap(); + assert!( + backend_setup(&args, "external") + .err() + .unwrap() + .to_string() + .contains("--boundary-launch-file is required") + ); + assert!( + backend_setup(&args, "External") + .err() + .unwrap() + .to_string() + .contains("not registered") + ); + } + #[test] fn network_proxy_accepts_local_policy_files() { let args = Args::try_parse_from([ diff --git a/crates/openshell-supervisor/src/provider_readiness.rs b/crates/openshell-supervisor/src/provider_readiness.rs index fae5e71455..60aab2a46d 100644 --- a/crates/openshell-supervisor/src/provider_readiness.rs +++ b/crates/openshell-supervisor/src/provider_readiness.rs @@ -6,6 +6,10 @@ //! Desired-state cursors never establish success. Credentials, policy, and //! the authenticated workload boundary must acknowledge the same authority. +#[cfg(test)] +use openshell_sandbox_backend::test_support as rpc_test; + +use openshell_sandbox_backend::BoundaryExec; use std::sync::Arc; use std::time::Duration; @@ -14,7 +18,7 @@ use openshell_core::proto::{ ReportProviderReadinessResponse, }; use openshell_core::provider_credentials::ProviderCredentialState; -use openshell_isolation_interface::contract::{BoundaryExec, ProviderEnvironmentInstallation}; +use openshell_isolation_interface::contract::ProviderEnvironmentInstallation; use openshell_supervisor_network::opa::PolicyGenerationGuard; use tokio::sync::watch; @@ -283,7 +287,7 @@ impl Tracker { sandbox_id: String, credentials: ProviderCredentialState, sessions: watch::Receiver>, - boundary: Arc, + boundary: Arc, ) -> Reporter { let tracker = self.clone(); Reporter(tokio::spawn(async move { @@ -305,7 +309,7 @@ impl Tracker { &self, credentials: ProviderCredentialState, mut sessions: watch::Receiver>, - boundary: Arc, + boundary: Arc, client: C, ) { let tracker = self; @@ -510,14 +514,12 @@ mod tests { ); #[tonic::async_trait] - impl BoundaryExec for DelayedBoundary { + impl rpc_test::BoundaryExec for DelayedBoundary { async fn exec( &self, _spec: openshell_isolation_interface::contract::ExecSpec, - ) -> Result< - openshell_isolation_interface::contract::ExecSession, - openshell_isolation_interface::contract::BackendError, - > { + ) -> Result + { unreachable!("reporting must never launch a workload process") } @@ -581,9 +583,12 @@ mod tests { let task = tokio::spawn(async move { reporting_tracker .run_reporter( - reporting_credentials, + reporting_credentials.clone(), receiver, - Arc::new(DelayedBoundary(boundary_tx)), + rpc_test::exec_with_credentials( + Arc::new(DelayedBoundary(boundary_tx)), + reporting_credentials, + ), DelayedReporter(report_tx), ) .await; @@ -698,9 +703,12 @@ mod tests { let task = tokio::spawn(async move { tracker .run_reporter( - credentials, + credentials.clone(), receiver, - Arc::new(DelayedBoundary(boundary_tx)), + rpc_test::exec_with_credentials( + Arc::new(DelayedBoundary(boundary_tx)), + credentials, + ), CapturingReporter(report_tx), ) .await; @@ -714,9 +722,13 @@ mod tests { .await .unwrap() .unwrap(); + // A cancelled RPC may still finish remotely. Its late acknowledgment + // must not publish readiness for the replacement gateway session. + let _ = old_ack.send(installed.clone()); assert!( - old_ack.send(installed.clone()).is_err(), - "replaced session must cancel its pending installation" + tokio::time::timeout(Duration::from_millis(30), report_rx.recv()) + .await + .is_err() ); current_ack.send(installed.clone()).unwrap(); let report = tokio::time::timeout(Duration::from_secs(1), report_rx.recv()) diff --git a/docs/extensibility/isolation-backends.mdx b/docs/extensibility/isolation-backends.mdx index 6b89be1b4d..8dd5903176 100644 --- a/docs/extensibility/isolation-backends.mdx +++ b/docs/extensibility/isolation-backends.mdx @@ -6,34 +6,38 @@ description: "Understand the runtime boundary used to launch and supervise sandb keywords: "OpenShell Extensions, Isolation Backends, Sandbox Runtime, Extensibility" --- -The supervisor uses one Rust interface, `IsolationBackend`, to launch and -control the agent workload. `OpenShellRuntimeBackend` implements that interface -by calling `openshell-sandbox` over the OpenShell Sandbox Protocol. Because the -supervisor depends only on the interface, another backend can provide the same -operations with different isolation mechanisms without changing the gateway API -or policy model. - -![The supervisor calls the IsolationBackend interface. OpenShellRuntimeBackend implements it as a supervisor-side client and talks to openshell-sandbox, the workload-side server, over the Sandbox Protocol.](../images/openshell-isolation-backend.svg) - -## The Interface - -Each step returns a new type, so the supervisor can't start an agent on a -boundary that hasn't been confirmed. The traits are defined in -[`contract.rs`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-isolation-interface/src/contract.rs) -in the -[`openshell-isolation-interface`](https://github.com/NVIDIA/OpenShell/tree/main/crates/openshell-isolation-interface) -crate. `OpenShellRuntimeBackend` is implemented in +The supervisor uses the `IsolationBackend` RPC service to discover and bind an +isolation backend, then uses the Sandbox Protocol to launch and control the +agent workload. The built-in `openshell-sandbox` runtime and external backends +implement the same services, allowing different isolation mechanisms without +changing the gateway API or policy model. + +![The supervisor uses its OpenShellRuntimeBackend client to communicate with the workload-side openshell-sandbox runtime over the Sandbox Protocol.](../images/openshell-isolation-backend.svg) + +## The protocols + +The services are defined in +[`isolation_backend.proto`](https://github.com/NVIDIA/OpenShell/blob/main/proto/isolation_backend.proto) +and +[`sandbox_protocol.proto`](https://github.com/NVIDIA/OpenShell/blob/main/proto/sandbox_protocol.proto). +The supervisor's concrete RPC client, `OpenShellRuntimeBackend`, lives in [`openshell-sandbox-backend`](https://github.com/NVIDIA/OpenShell/tree/main/crates/openshell-sandbox-backend). -| Rust surface | What it does | Returns | -|---|---|---| -| `IsolationBackend::attach()` | Binds a verified runtime descriptor to one admitted sandbox. | `BoundBoundary` | -| `BoundBoundary::confirm()` | Checks the workload's controls before any agent code runs. | `ConfirmedBoundary` | -| `ReadyBoundary::start_agent()` | Rechecks launch controls and starts the agent. | `RunningBoundary` | -| `RunningBoundary::agent()` | Waits for, signals, attaches to, or stops the agent process. | `BoundaryProcess` | -| `RunningBoundary::exec()` | Starts exec and terminal sessions inside the same boundary. | `BoundaryExec` | -| `RunningBoundary::loopback_connector()` | Reaches a loopback service inside the workload for forwarding. | `BoundaryLoopbackConnector` | -| `BoundBoundary::network_mediation_source()` | Receives TCP opens and DNS queries tagged with the calling program. | `NetworkMediationSource` | +| RPC | What it does | +|---|---| +| `IsolationBackend.Describe` | Negotiates the protocol and capabilities. | +| `IsolationBackend.OpenBoundary` | Binds a provisioned resource to an authenticated launch. | +| `IsolationBackend.SuspendBoundary` / `ResumeBoundary` | Optionally retains and restores workload state. | +| `SandboxProtocol.Exchange` | Carries admission, process control and I/O, TCP decisions, and relay data. | +| `SandboxProtocol.Mediate` | Carries the DNS mediation handshake, queries, and responses. | + +The admission sequence is Attach, Confirm, then StartAgent. No workload code +runs before StartAgent. + +External compute drivers advertise `openshell.compute.isolation-backend` to +accept an operator-selected `IsolationBackendRegistration`, including its Unix, +TCP, or vsock endpoint. The driver provisions the resource and delivers the +backend's trusted launch metadata to the supervisor. ## Who Owns What @@ -41,7 +45,7 @@ crate. `OpenShellRuntimeBackend` is implemented in |---|---| | Supervisor | Policy decisions. Policy, credentials, DNS resolution, and approved external connections stay outside the agent workload. | | Compute driver | Placement and fencing. The driver creates runtime resources, installs the outer egress fence, and supplies a verified descriptor. | -| Isolation backend | Common behavior. The backend turns the shared Rust calls into a protected session with the sandbox runtime. | +| Isolation backend | Enforcement. The backend implements the RPC contracts and enforces the admitted policy inside its provisioned resource. | For how `openshell-sandbox` enforces the boundary, refer to [Inside the Sandbox Boundary](/about/architecture#inside-the-sandbox-boundary). For driver-specific diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock new file mode 100644 index 0000000000..988cb2a1b5 --- /dev/null +++ b/examples/isolation-backend-v8/Cargo.lock @@ -0,0 +1,2956 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "diplomat" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b37a6cea72eb6df5143d3e091d4c34cb0cf4046dd1ddcc082988886fe1f809" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "diplomat-runtime" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae517853d9f82b2d144393b1c32c34c46c2e361de2b6c44a8fdfce23beb211cd" + +[[package]] +name = "diplomat_core" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b18e806f9ecb92df828d080efe47984ae1193958295d4a84fb8b32c9a1aa1b" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 3.0.9", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gzip-header" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86848f4fd157d91041a62c78046fb7b248bcc2dce78376d436a1756e9a038577" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_calendar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ixdtf" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sbx" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-stream", + "clap", + "openshell-core", + "openshell-isolation-interface", + "openshell-sandbox-backend", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix 1.1.5", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-rustls", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "uuid", + "v8", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "glob", + "ipnet", + "jsonwebtoken", + "miette", + "nix", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix 1.1.5", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-isolation-interface" +version = "0.0.0" +dependencies = [ + "openshell-core", + "serde", + "serde_json", + "sha2", + "tokio", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror", +] + +[[package]] +name = "openshell-sandbox-backend" +version = "0.0.0" +dependencies = [ + "async-trait", + "bytes", + "hyper-util", + "libc", + "openshell-core", + "openshell-isolation-interface", + "prost", + "prost-types", + "rcgen", + "rustix 1.1.5", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "socket2", + "thiserror", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-prost", + "tower", + "tracing", + "uuid", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" +dependencies = [ + "potential_utf", + "serde_core", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "temporal_capi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48eb9ca4ca22caaa48d1e7a534ad3fd598a23d1a71945e8ad5ac0be501fa5f2" +dependencies = [ + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale_core", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", +] + +[[package]] +name = "temporal_rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a978fef907a27736827d336c0bfaab14fb8a7509dd6dc2b43c44e51d59aca5b" +dependencies = [ + "calendrical_calculations", + "core_maths", + "icu_calendar", + "icu_locale_core", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "icu_segmenter", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "timezone_provider" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec4f4eebb4817fde02a411aedc7ac5f66c89c68c49e5d4b17a8139f077af52" +dependencies = [ + "tinystr", + "zerotrie", + "zerovec", + "zoneinfo64", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v8" +version = "152.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a10fe1a92da5c32c7c7f838ce36c0ccfcfd5edf0865b58bdde820aa64cea9888" +dependencies = [ + "bindgen", + "bitflags", + "fslock", + "gzip-header", + "home", + "miniz_oxide", + "paste", + "temporal_capi", + "which", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zoneinfo64" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6eb2607e906160c457fd573e9297e65029669906b9ac8fb1b5cd5e055f0705" +dependencies = [ + "calendrical_calculations", + "icu_locale_core", + "potential_utf", + "resb", + "serde", +] diff --git a/examples/isolation-backend-v8/Cargo.toml b/examples/isolation-backend-v8/Cargo.toml new file mode 100644 index 0000000000..d67d63b2d5 --- /dev/null +++ b/examples/isolation-backend-v8/Cargo.toml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "js-sbx" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +publish = false + +[dependencies] +anyhow = "1" +async-stream = "0.3" +openshell-core = { path = "../../crates/openshell-core", default-features = false, features = ["jwt"] } +openshell-isolation-interface = { path = "../../crates/openshell-isolation-interface" } +openshell-sandbox-backend = { path = "../../crates/openshell-sandbox-backend" } +rustix = { version = "1", features = ["process"] } +tokio-rustls = "0.26" +rustls = "0.23" +rustls-pemfile = "2" +clap = { version = "4.5", features = ["derive", "env"] } +prost = "0.14" +prost-types = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "process", "fs", "io-util"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic = { version = "0.14", features = ["tls-aws-lc"] } +tonic-prost = "0.14" +uuid = { version = "1", features = ["v4"] } + +v8 = "152.2.0" + +[build-dependencies] +protoc-bin-vendored = "3" +tonic-prost-build = "0.14" + +[[bin]] +name = "js-sbx" +path = "src/main.rs" diff --git a/examples/isolation-backend-v8/README.md b/examples/isolation-backend-v8/README.md new file mode 100644 index 0000000000..cf85980733 --- /dev/null +++ b/examples/isolation-backend-v8/README.md @@ -0,0 +1,98 @@ +# js-sbx + +A small JavaScript runtime controlled by the OpenShell supervisor through the +Sandbox Protocol. `js-sbx` runs natively on the host and executes the guest in +a V8 isolate. It exposes `console.log`, `console.error`, and a `fetch` stub that +throws `Network access is disabled`. The guest has no filesystem, subprocess, +socket, or module-loading bindings. + +```text +gateway -> example driver -> supervisor -> Unix socket -> js-sbx -> V8 isolate +``` + +## Start + +From this example directory, as a non-root user: + +```shell +./start.sh +``` + +The script builds the binaries and starts the runtime, example driver, and +gateway. It generates the local configuration and launch credentials; no manual +certificate setup is needed. The first build downloads V8. To use another script, +run `./start.sh path/to/script.js`. + +The gateway listens without user authentication on `127.0.0.1:18770`. +Supervisor-to-runtime RPCs use an authenticated Unix socket. The script prints +the temporary directory containing configuration and logs. + +## Gateway configuration + +These are the driver and backend sections of the generated `gateway.toml`. +`` is the temporary directory printed by the script; listener, +authentication, and signing-key settings are omitted here. + +```toml +[openshell.gateway] +compute_driver = "js-sbx-example" + +[openshell.drivers.js-sbx-example] +socket_path = "/compute.sock" +isolation_backend = "js-sbx" + +[[openshell.supervisor.isolation_backends]] +name = "js-sbx" +endpoint = { kind = "unix", socket_path = "/backend.sock" } +``` + +`start.sh` runs the same binary in two processes: `js-sbx driver` provisions the +supervisor and launch metadata; `js-sbx serve` implements `IsolationBackend` and +`SandboxProtocol`. The runtime starts JavaScript only after Attach, Confirm, +and StartAgent. The existing example directory name refers to the V8 engine. + +## Create a sandbox + +In another terminal, from this example directory: + +```shell +export OPENSHELL_GATEWAY_ENDPOINT=http://127.0.0.1:18770 +openshell sandbox create --name js-demo \ + --keep --no-tty --no-auto-providers -- /work/guest.js +``` + +The bundled script prints a greeting, attempts a request, and catches the +network-disabled error. Egress is disabled by the runtime's lack of network +bindings; this example does not relay requests to the supervisor for policy +evaluation. No policy file is needed. + +The create command prints the script's output and returns its exit code. The +sandbox becomes `Completed` on exit code 0 or `Error` on a nonzero exit code. +Uncaught exceptions and Promise rejections left unhandled after microtasks finish +print an error and return exit code 1. +`--keep` retains the sandbox so you can inspect that result after the command. + +Add `--detach` to run without attaching; detached creation does not stream guest +output. Use `openshell sandbox connect js-demo` to attach afterward. + +## Inspect and stop + +```shell +openshell sandbox list +openshell logs js-demo +openshell sandbox delete js-demo +``` + +Wait for deletion, then press Ctrl-C in the script's terminal to stop all three +processes. Each run uses a fresh state directory and keeps its logs. + +## Limits + +One script runs at a time, with a 15-second deadline, a 32 MiB JavaScript heap +limit, and 64 KiB per output stream. Termination interrupts the isolate. These +limits do not bound all native V8 resources. The V8 isolate confines JavaScript; +the host process does not run in a Linux namespace or container. + +The example has no HTTP relay or guest network implementation. `fetch` is only +a synchronous denial stub. Exec, PTY, providers, suspend/resume, and supervisor +recovery are unsupported. diff --git a/examples/isolation-backend-v8/build.rs b/examples/isolation-backend-v8/build.rs new file mode 100644 index 0000000000..c0c93f7fcd --- /dev/null +++ b/examples/isolation-backend-v8/build.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +fn main() -> Result<(), Box> { + let mut config = tonic_prost_build::Config::new(); + config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); + tonic_prost_build::configure() + .btree_map(".openshell.sandbox.protocol.v1") + .compile_with_config( + config, + &[ + "../../proto/isolation_backend.proto", + "../../proto/sandbox_protocol.proto", + ], + &["../../proto"], + )?; + println!("cargo:rerun-if-changed=../../proto"); + Ok(()) +} diff --git a/examples/isolation-backend-v8/guest.js b/examples/isolation-backend-v8/guest.js new file mode 100644 index 0000000000..ff872b3833 --- /dev/null +++ b/examples/isolation-backend-v8/guest.js @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +console.log("Hello from js-sbx"); +try { + fetch("https://example.com"); + throw new Error("Expected network access to be disabled"); +} catch (error) { + if (error.message !== "Network access is disabled") throw error; + console.log(error.message); +} diff --git a/examples/isolation-backend-v8/src/driver.rs b/examples/isolation-backend-v8/src/driver.rs new file mode 100644 index 0000000000..9d432d36bd --- /dev/null +++ b/examples/isolation-backend-v8/src/driver.rs @@ -0,0 +1,556 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Single-sandbox local compute placement. The backend is a separate process. +use crate::{ + guest::read_script, + launch::{Launch, write_private}, + protocol::BACKEND, +}; +use anyhow::{Result, ensure}; +use openshell_core::{ + extension_protocol::{self, ExtensionFamily}, + jwt::SandboxLaunchAuthentication, + proto::compute::v1::*, +}; +use openshell_isolation_interface::contract::ProvisioningEvidence; +use openshell_sandbox_backend::{ + boundary_protocol::{SandboxTlsClientConfig, generate_sandbox_tls_material}, + delegated::DelegatedLaunch, +}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration}; +use tokio::sync::Mutex; +use tonic::{Request, Response, Status}; + +pub struct Driver { + pub state_dir: PathBuf, + pub supervisor: PathBuf, + pub script: PathBuf, + pub gateway: String, + pub policy_rules: PathBuf, + entry: Mutex>, + deleted: tokio::sync::watch::Sender>, +} +struct Entry { + sandbox: DriverSandbox, + supervisor: tokio::process::Child, + boundary: DelegatedLaunch, +} +impl Driver { + pub fn new( + state_dir: PathBuf, + supervisor: PathBuf, + script: PathBuf, + gateway: String, + policy_rules: PathBuf, + ) -> Result { + ensure!( + !rustix::process::geteuid().is_root(), + "run this local example as a non-root user" + ); + ensure!( + !state_dir.join("launch.json").exists(), + "previous launch is still present; stop the old demo and use a fresh state directory" + ); + std::fs::create_dir_all(&state_dir)?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&state_dir, std::fs::Permissions::from_mode(0o700))?; + Ok(Self { + state_dir: state_dir.canonicalize()?, + supervisor: supervisor.canonicalize()?, + script: script.canonicalize()?, + gateway, + policy_rules: policy_rules.canonicalize()?, + entry: Mutex::new(None), + deleted: tokio::sync::watch::channel(None).0, + }) + } + fn validate(&self, sandbox: &DriverSandbox) -> Result<(), Status> { + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| Status::invalid_argument("sandbox spec is required"))?; + let registration = spec + .isolation_backend + .as_ref() + .ok_or_else(|| Status::failed_precondition("configure the driver isolation_backend"))?; + if registration.name != BACKEND { + return Err(Status::failed_precondition("this driver requires js-sbx")); + } + if spec.command != ["/work/guest.js"] || spec.tty { + return Err(Status::invalid_argument( + "use /work/guest.js without args or TTY", + )); + } + if spec.resource_requirements.is_some() || !spec.environment.is_empty() { + return Err(Status::invalid_argument( + "resource requests and workload environment are not supported", + )); + } + if let Some(template) = &spec.template + && (!template.environment.is_empty() + || template.driver_config.is_some() + || template.platform_config.is_some() + || template.resources.is_some() + || (!template.image.is_empty() && template.image != "js-sbx:example")) + { + return Err(Status::invalid_argument( + "template overrides are unsupported", + )); + } + Ok(()) + } + fn observation(entry: &mut Entry) -> DriverSandbox { + let exited = entry.supervisor.try_wait().ok().flatten(); + let mut sandbox = entry.sandbox.clone(); + let identity = &entry.boundary.workload_identity; + sandbox.spec = None; + sandbox.status = Some(DriverSandboxStatus { + name: sandbox.name.clone(), + instance_id: sandbox.id.clone(), + conditions: vec![DriverCondition { + r#type: if exited.is_some() { "Stopped" } else { "Ready" }.into(), + status: "True".into(), + ..Default::default() + }], + resolved_identity: Some(ResolvedWorkloadIdentity { + uid: identity.uid, + gid: identity.gid, + supplementary_gids: identity.supplementary_gids.clone(), + source: identity.source.clone(), + resource_digest: identity.resource_digest.clone(), + }), + fence_evidence: Some(DriverFenceEvidence { + generation: entry.boundary.generation.clone(), + evidence_digest: entry + .boundary + .provisioning_evidence + .evidence_digest + .to_string(), + resource_claims: Default::default(), + }), + ..Default::default() + }); + sandbox + } + async fn stop_entry(&self, entry: &mut Entry) -> Result<(), Status> { + let _ = entry.supervisor.start_kill(); + let _ = entry.supervisor.wait().await; + self.retire_boundary().await + } + async fn retire_boundary(&self) -> Result<(), Status> { + match std::fs::remove_file(self.state_dir.join("launch.json")) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Status::internal("remove backend launch")), + } + // Backend closes TLS, interrupts its guest, then acknowledges file deletion. + for _ in 0..100 { + if !self.state_dir.join("active").exists() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(Status::deadline_exceeded( + "backend did not acknowledge termination", + )) + } + async fn launch(&self, sandbox: DriverSandbox) -> Result { + self.validate(&sandbox)?; + let spec = sandbox.spec.as_ref().unwrap(); + let auth: SandboxLaunchAuthentication = serde_json::from_slice(&spec.launch_authentication) + .map_err(|_| Status::failed_precondition("gateway launch credentials required"))?; + auth.validate() + .map_err(|_| Status::failed_precondition("invalid gateway launch credentials"))?; + let bytes = read_script(&self.script).map_err(internal)?; + let digest = format!("{:x}", Sha256::digest(&bytes)); + let descriptor = + serde_json::to_vec(&serde_json::json!({"script_sha256":digest})).map_err(internal)?; + let identity = crate::launch::host_identity(digest).map_err(internal)?; + let evidence = crate::launch::capability_evidence(&identity.resource_digest); + let fence = ProvisioningEvidence::from_enforcement_evidence( + auth.supervisor.runtime_generation.to_string(), + &evidence, + ) + .map_err(internal)?; + let tls = generate_sandbox_tls_material(auth.supervisor.session_id).map_err(internal)?; + let boundary = DelegatedLaunch { + backend_name: BACKEND.into(), + sandbox_id: sandbox.id.clone(), + generation: auth.supervisor.runtime_generation.to_string(), + session_id: auth.supervisor.session_id, + workload_identity: identity, + tls: SandboxTlsClientConfig { + server_name: tls.server_name, + trust_anchor_pem: tls.trust_anchor_pem, + }, + host_gateway_ip: None, + resource_claims: BTreeMap::new(), + provisioning_evidence: fence, + }; + let launch = Launch { + boundary: boundary.clone(), + script: self.script.clone(), + descriptor: descriptor.clone(), + gateway_id: auth.gateway_id, + keys: auth.verification_keys, + epoch: auth.supervisor.auth_epoch, + rotation: auth.supervisor.session_rotation, + certificate: tls.certificate_chain_pem, + private_key: tls.private_key_pem, + }; + let selected = spec.isolation_backend.as_ref().unwrap(); + let Some(isolation_backend_registration::Endpoint::UnixSocketPath(path)) = + &selected.endpoint + else { + return Err(Status::invalid_argument( + "the local example requires a Unix backend socket", + )); + }; + if std::path::Path::new(path) != self.state_dir.join("backend.sock") { + return Err(Status::invalid_argument( + "register backend.sock in the shared demo state directory", + )); + } + let registration = openshell_core::isolation_registration::BackendRegistration { + name: selected.name.clone(), + endpoint: openshell_core::isolation_registration::SandboxTransport::Unix { + socket_path: path.into(), + }, + }; + let result = async { + for (name, bytes) in [ + ( + "auth.json", + serde_json::to_vec(&auth.supervisor).map_err(internal)?, + ), + ( + "boundary.json", + serde_json::to_vec(&boundary).map_err(internal)?, + ), + ("descriptor.json", descriptor), + ( + "registrations.json", + serde_json::to_vec(&serde_json::json!({"backends":[registration]})) + .map_err(internal)?, + ), + ( + "launch.json", + serde_json::to_vec(&launch).map_err(internal)?, + ), + ] { + write_private(&self.state_dir.join(name), &bytes).map_err(internal)?; + } + for attempt in 0..100 { + if self.state_dir.join("active").exists() { + break; + } + if attempt == 99 { + return Err(Status::deadline_exceeded( + "start js-sbx serve before creating the sandbox", + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let output = + std::fs::File::create(self.state_dir.join("supervisor.log")).map_err(internal)?; + let errors = std::fs::File::create(self.state_dir.join("supervisor.err.log")) + .map_err(internal)?; + let mut command = tokio::process::Command::new(&self.supervisor); + command + .kill_on_drop(true) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", &self.state_dir) + .env( + openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(Some(spec)) + .map_err(internal)?, + ) + .env( + openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND, + BACKEND, + ) + .env( + openshell_core::sandbox_env::PROXY_TLS_DIR, + self.state_dir.join("proxy-tls"), + ) + .args([ + "--sandbox-id", + &sandbox.id, + "--sandbox", + &sandbox.name, + "--openshell-endpoint", + &self.gateway, + "--workdir", + "/work", + "--log-level", + "info", + ]) + .arg("--policy-rules") + .arg(&self.policy_rules) + .arg("--ssh-socket-path") + .arg(self.state_dir.join("ssh.sock")) + .arg("--health-socket-path") + .arg(self.state_dir.join("ready.sock")) + .arg("--auth-bundle-file") + .arg(self.state_dir.join("auth.json")) + .arg("--backend-descriptor-file") + .arg(self.state_dir.join("descriptor.json")) + .arg("--boundary-launch-file") + .arg(self.state_dir.join("boundary.json")) + .arg("--isolation-backends-file") + .arg(self.state_dir.join("registrations.json")) + .arg("--parent-liveness-stdin") + .stdin(std::process::Stdio::piped()) + .stdout(output) + .stderr(errors); + let supervisor = command.spawn().map_err(internal)?; + Ok(supervisor) + } + .await; + let supervisor = match result { + Ok(supervisor) => supervisor, + Err(error) => { + self.retire_boundary().await?; + return Err(error); + } + }; + Ok(Entry { + sandbox, + supervisor, + boundary, + }) + } +} +fn internal(error: impl std::fmt::Display) -> Status { + Status::internal(error.to_string()) +} + +pub struct DriverService(Arc); +impl std::ops::Deref for DriverService { + type Target = Driver; + fn deref(&self) -> &Driver { + &self.0 + } +} +#[tonic::async_trait] +impl compute_driver_server::ComputeDriver for DriverService { + async fn get_capabilities( + &self, + _: Request, + ) -> Result, Status> { + let mut extension = extension_protocol::extension_metadata( + ExtensionFamily::Compute, + "js-sbx", + "0.0.0", + [ + extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.into(), + extension_protocol::COMPUTE_ISOLATION_BACKEND.into(), + ], + ); + extension + .required_capabilities + .push(extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.into()); + Ok(Response::new(GetCapabilitiesResponse { + driver_name: "js-sbx-example".into(), + driver_version: "0.0.0".into(), + default_image: "js-sbx:example".into(), + gateway_manages_lifecycle: true, + driver_reports_runtime_readiness: false, + extension: Some(extension), + resource_admission_policy: + openshell_core::resource_admission::DriverAdmissionConfig::default() + .acknowledgement(), + ..Default::default() + })) + } + async fn authenticate_sandbox( + &self, + _: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "gateway-minted credentials are provisioned directly", + )) + } + async fn validate_sandbox_create( + &self, + q: Request, + ) -> Result, Status> { + self.validate( + q.get_ref() + .sandbox + .as_ref() + .ok_or_else(|| Status::invalid_argument("sandbox required"))?, + )?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + } + async fn create_sandbox( + &self, + q: Request, + ) -> Result, Status> { + let sandbox = q + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox required"))?; + let mut entry = self.entry.lock().await; + if let Some(current) = entry.as_ref() { + if current.sandbox.id == sandbox.id { + return Ok(Response::new(CreateSandboxResponse { + runtime_identity: sandbox.id, + })); + } + return Err(Status::resource_exhausted( + "this example supports one sandbox; delete it first", + )); + } + let created = self.launch(sandbox).await?; + let identity = created.sandbox.id.clone(); + *entry = Some(created); + Ok(Response::new(CreateSandboxResponse { + runtime_identity: identity, + })) + } + async fn get_sandbox( + &self, + q: Request, + ) -> Result, Status> { + let mut entry = self.entry.lock().await; + let entry = entry + .as_mut() + .filter(|e| e.sandbox.id == q.get_ref().sandbox_id) + .ok_or_else(|| Status::not_found("sandbox not found"))?; + Ok(Response::new(GetSandboxResponse { + sandbox: Some(Driver::observation(entry)), + })) + } + async fn list_sandboxes( + &self, + _: Request, + ) -> Result, Status> { + let mut entry = self.entry.lock().await; + Ok(Response::new(ListSandboxesResponse { + sandboxes: entry + .as_mut() + .map(|e| vec![Driver::observation(e)]) + .unwrap_or_default(), + })) + } + async fn stop_sandbox( + &self, + q: Request, + ) -> Result, Status> { + let mut entry = self.entry.lock().await; + if let Some(entry) = entry + .as_mut() + .filter(|e| e.sandbox.id == q.get_ref().sandbox_id) + { + self.stop_entry(entry).await?; + } + Ok(Response::new(StopSandboxResponse {})) + } + async fn delete_sandbox( + &self, + q: Request, + ) -> Result, Status> { + let mut entry = self.entry.lock().await; + if entry + .as_ref() + .is_some_and(|e| e.sandbox.id == q.get_ref().sandbox_id) + { + self.stop_entry(entry.as_mut().unwrap()).await?; + *entry = None; + self.deleted + .send_replace(Some(q.get_ref().sandbox_id.clone())); + } + Ok(Response::new(DeleteSandboxResponse { deleted: true })) + } + async fn start_sandbox( + &self, + q: Request, + ) -> Result, Status> { + let q = q.into_inner(); + let mut entry = self.entry.lock().await; + let current = entry + .as_mut() + .filter(|e| e.sandbox.id == q.sandbox_id) + .ok_or_else(|| Status::not_found("sandbox not found"))?; + if current.boundary.generation == q.generation_id + && current.supervisor.try_wait().map_err(internal)?.is_none() + { + return Ok(Response::new(StartSandboxResponse { + runtime_identity: q.sandbox_id, + })); + } + self.stop_entry(current).await?; + let mut sandbox = current.sandbox.clone(); + let spec = sandbox.spec.as_mut().unwrap(); + spec.launch_authentication = q.launch_authentication; + spec.isolation_backend = q.isolation_backend; + let launched = self.launch(sandbox).await?; + *entry = Some(launched); + Ok(Response::new(StartSandboxResponse { + runtime_identity: q.sandbox_id, + })) + } + type WatchSandboxesStream = std::pin::Pin< + Box> + Send>, + >; + async fn watch_sandboxes( + &self, + _: Request, + ) -> Result, Status> { + let driver = self.0.clone(); + let mut deleted = self.deleted.subscribe(); + let stream = async_stream::stream! { + let mut last_deleted = None; + loop { + let removed = deleted.borrow_and_update().clone(); + if removed != last_deleted { + if let Some(id) = &removed { yield Ok(WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Deleted(WatchSandboxesDeletedEvent { sandbox_id: id.clone() })) }); } + last_deleted = removed; + } + let snapshot = { + let mut entry = driver.entry.lock().await; + entry.as_mut().map(Driver::observation) + }; + if let Some(sandbox) = snapshot { yield Ok(WatchSandboxesEvent { payload: Some(watch_sandboxes_event::Payload::Sandbox(WatchSandboxesSandboxEvent { sandbox: Some(sandbox) })) }); } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }; + Ok(Response::new(Box::pin(stream))) + } + async fn ensure_workspace( + &self, + _: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + async fn delete_workspace( + &self, + _: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } +} + +pub async fn serve(driver: Arc, socket: PathBuf) -> Result<()> { + ensure!(!socket.exists(), "driver socket already exists"); + let listener = tokio::net::UnixListener::bind(&socket)?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600))?; + tonic::transport::Server::builder() + .add_service(compute_driver_server::ComputeDriverServer::new( + DriverService(driver), + )) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::UnixListenerStream::new(listener), + async { + let _ = tokio::signal::ctrl_c().await; + }, + ) + .await?; + std::fs::remove_file(socket)?; + Ok(()) +} diff --git a/examples/isolation-backend-v8/src/guest.rs b/examples/isolation-backend-v8/src/guest.rs new file mode 100644 index 0000000000..e2dc8205de --- /dev/null +++ b/examples/isolation-backend-v8/src/guest.rs @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! One V8 isolate with bounded console output and no guest I/O. +use std::{ + ffi::c_void, + fs::File, + io::Read, + path::Path, + sync::{ + Arc, Mutex, Once, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use anyhow::{Result, ensure}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const MAX_SCRIPT: usize = 256 * 1024; +const MAX_OUTPUT: usize = 64 * 1024; +const MAX_HEAP: usize = 32 * 1024 * 1024; +const DEADLINE: Duration = Duration::from_secs(15); + +pub fn read_script(path: &Path) -> Result> { + let mut bytes = Vec::new(); + File::open(path)? + .take((MAX_SCRIPT + 1) as u64) + .read_to_end(&mut bytes)?; + ensure!(bytes.len() <= MAX_SCRIPT, "script exceeds 256 KiB"); + std::str::from_utf8(&bytes)?; + Ok(bytes) +} + +#[derive(Clone, Default)] +pub struct Output(Arc>>); + +impl Output { + pub fn contents(&self) -> Vec { + self.0.lock().unwrap().clone() + } + + fn append(&self, text: &str) -> bool { + let mut bytes = self.0.lock().unwrap(); + if text.len() + bytes.len() > MAX_OUTPUT { + return false; + } + bytes.extend_from_slice(text.as_bytes()); + true + } +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GuestStatus { + Exited, + Terminated, + Failed, +} + +#[derive(Debug, Serialize)] +pub struct GuestResult { + pub status: GuestStatus, + pub exit_code: Option, + #[serde(skip)] + pub stdout: Vec, + #[serde(skip)] + pub stderr: Vec, + pub script_sha256: String, +} + +struct State { + stdout: Output, + stderr: Output, + rejections: Vec>, +} + +pub struct Guest { + source: String, + digest: String, + cancelled: AtomicBool, + isolate: Mutex>, +} + +impl Guest { + pub fn new(bytes: &[u8]) -> Result { + ensure!(bytes.len() <= MAX_SCRIPT, "script exceeds 256 KiB"); + Ok(Self { + source: std::str::from_utf8(bytes)?.to_owned(), + digest: format!("{:x}", Sha256::digest(bytes)), + cancelled: AtomicBool::new(false), + isolate: Mutex::new(None), + }) + } + + pub fn terminate(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(handle) = self.isolate.lock().unwrap().as_ref() { + handle.terminate_execution(); + } + } + + pub fn run(&self, ready: impl FnOnce(Output, Output)) -> Result { + static INITIALIZE: Once = Once::new(); + INITIALIZE.call_once(|| { + v8::V8::initialize_platform(v8::new_default_platform(0, false).make_shared()); + v8::V8::initialize(); + }); + let stdout = Output::default(); + let stderr = Output::default(); + ready(stdout.clone(), stderr.clone()); + // Dropping `done` also releases the watchdog if execution returns early. + let (done, finished) = std::sync::mpsc::channel::<()>(); + let outcome = std::thread::scope(|threads| { + threads.spawn(move || { + if matches!( + finished.recv_timeout(DEADLINE), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ) { + self.terminate(); + } + }); + let outcome = self.execute(stdout.clone(), &stderr); + drop(done); + outcome + })?; + *self.isolate.lock().unwrap() = None; + Ok(GuestResult { + exit_code: match outcome { + GuestStatus::Exited => Some(0), + GuestStatus::Failed => Some(1), + GuestStatus::Terminated => None, + }, + status: outcome, + stdout: stdout.contents(), + stderr: stderr.contents(), + script_sha256: self.digest.clone(), + }) + } + + fn execute(&self, stdout: Output, stderr: &Output) -> Result { + let mut isolate = v8::Isolate::new(v8::CreateParams::default().heap_limits(0, MAX_HEAP)); + *self.isolate.lock().unwrap() = Some(isolate.thread_safe_handle()); + if self.cancelled.load(Ordering::SeqCst) { + return Ok(GuestStatus::Terminated); + } + isolate.set_slot(State { + stdout, + stderr: stderr.clone(), + rejections: Vec::new(), + }); + isolate.set_promise_reject_callback(promise_rejected); + // The callback borrows this Guest only while execute() owns the isolate. + isolate + .add_near_heap_limit_callback(heap_limit, std::ptr::from_ref(self).cast_mut().cast()); + let result = (|| { + v8::scope!(let scope, &mut isolate); + let global = v8::ObjectTemplate::new(scope); + global.set( + v8::String::new(scope, "fetch").unwrap().into(), + v8::FunctionTemplate::new(scope, deny_network).into(), + ); + let context = v8::Context::new( + scope, + v8::ContextOptions { + global_template: Some(global), + ..Default::default() + }, + ); + let scope = &mut v8::ContextScope::new(scope, context); + // V8 installs a console while creating the context; replace it only + // after that step so every log goes through our bounded capture. + let console = v8::Object::new(scope); + let logger = v8::Function::new(scope, log).unwrap(); + let log_key = v8::String::new(scope, "log").unwrap(); + console.set(scope, log_key.into(), logger.into()); + let error_logger = v8::Function::new(scope, log_error).unwrap(); + let error_key = v8::String::new(scope, "error").unwrap(); + console.set(scope, error_key.into(), error_logger.into()); + let console_key = v8::String::new(scope, "console").unwrap(); + context + .global(scope) + .set(scope, console_key.into(), console.into()); + v8::tc_scope!(let scope, scope); + let source = v8::String::new(scope, &self.source) + .ok_or_else(|| anyhow::anyhow!("source allocation failed"))?; + let result = + v8::Script::compile(scope, source, None).and_then(|script| script.run(scope)); + // Drain ordinary JS microtasks, but install no timers, loaders, or I/O. + if result.is_some() { + scope.perform_microtask_checkpoint(); + } + let rejections = std::mem::take(&mut scope.get_slot_mut::().unwrap().rejections); + let rejection = rejections + .iter() + .map(|promise| v8::Local::new(scope, promise)) + .find(|promise| !promise.has_handler()) + .map(|promise| promise.result(scope)); + if scope.has_terminated() { + Ok(GuestStatus::Terminated) + } else if result.is_some() && rejection.is_none() { + Ok(GuestStatus::Exited) + } else { + let message = scope + .exception() + .or(rejection) + .and_then(|value| value.to_string(scope)) + .map(|value| { + if value.length() > MAX_OUTPUT { + "JavaScript exception exceeds output limit".into() + } else { + value.to_rust_string_lossy(scope) + } + }) + .unwrap_or_else(|| "JavaScript execution failed".into()); + stderr.append(&message); + stderr.append("\n"); + Ok(GuestStatus::Failed) + } + })(); + isolate.remove_near_heap_limit_callback(heap_limit, 0); + isolate.remove_slot::(); + result + } +} + +extern "C" fn promise_rejected(message: v8::PromiseRejectMessage) { + if message.get_event() != v8::PromiseRejectEvent::PromiseRejectWithNoHandler { + return; + } + // SAFETY: V8 invokes this callback with a live promise on the current isolate. + v8::callback_scope!(unsafe scope, &message); + let promise = v8::Global::new(scope, message.get_promise()); + scope + .get_slot_mut::() + .unwrap() + .rejections + .push(promise); +} + +unsafe extern "C" fn heap_limit(data: *mut c_void, current: usize, _initial: usize) -> usize { + // SAFETY: execute() registers its borrowed Guest and removes the callback + // before disposing the isolate; the Guest remains alive for that whole call. + let guest = unsafe { &*data.cast::() }; + guest.terminate(); + // Give V8 room to unwind after termination instead of aborting on heap OOM. + current.saturating_add(8 * 1024 * 1024) +} + +fn log(scope: &mut v8::PinScope, args: v8::FunctionCallbackArguments, _result: v8::ReturnValue) { + write_output(scope, args, false); +} + +fn log_error( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + _result: v8::ReturnValue, +) { + write_output(scope, args, true); +} + +fn write_output(scope: &mut v8::PinScope, args: v8::FunctionCallbackArguments, error: bool) { + let Some(text) = args.get(0).to_string(scope) else { + return; + }; + if text.length() > MAX_OUTPUT { + scope.terminate_execution(); + return; + } + let message = text.to_rust_string_lossy(scope); + let state = scope.get_slot::().unwrap(); + let output = if error { &state.stderr } else { &state.stdout }; + if !output.append(&message) || !output.append("\n") { + scope.terminate_execution(); + } +} + +fn deny_network( + scope: &mut v8::PinScope, + _args: v8::FunctionCallbackArguments, + _result: v8::ReturnValue, +) { + let message = v8::String::new(scope, "Network access is disabled").unwrap(); + let error = v8::Exception::error(scope, message); + scope.throw_exception(error); +} diff --git a/examples/isolation-backend-v8/src/launch.rs b/examples/isolation-backend-v8/src/launch.rs new file mode 100644 index 0000000000..193055e87e --- /dev/null +++ b/examples/isolation-backend-v8/src/launch.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Protected provisioning files shared by the example driver and backend. +use anyhow::Result; +use openshell_core::jwt::{CredentialEpoch, SessionRotation, SessionVerificationKey}; +use openshell_sandbox_backend::delegated::DelegatedLaunch; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Serialize, Deserialize)] +pub struct Launch { + pub boundary: DelegatedLaunch, + pub script: PathBuf, + pub descriptor: Vec, + pub gateway_id: String, + pub keys: Vec, + pub epoch: CredentialEpoch, + pub rotation: SessionRotation, + pub certificate: String, + pub private_key: String, +} + +pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let temporary = path.with_extension("tmp"); + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(temporary, path)?; + Ok(()) +} + +/// Evidence for capabilities the V8 runner actually grants. +pub fn capability_evidence(digest: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({"script_sha256":digest,"network":"disabled","filesystem":"no-host-access"})).expect("string-valued capability manifest") +} + +/// Admit the actual identity of the host process containing the V8 guest. +pub fn host_identity( + digest: String, +) -> Result { + Ok( + openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + rustix::process::geteuid().as_raw(), + rustix::process::getegid().as_raw(), + rustix::process::getgroups()? + .into_iter() + .map(|gid| gid.as_raw()) + .collect(), + "v8-script".into(), + digest, + )?, + ) +} diff --git a/examples/isolation-backend-v8/src/lib.rs b/examples/isolation-backend-v8/src/lib.rs new file mode 100644 index 0000000000..5540a9100b --- /dev/null +++ b/examples/isolation-backend-v8/src/lib.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub mod driver; +pub mod guest; +pub mod launch; +pub mod lifecycle; +pub mod protocol; +pub mod transport; diff --git a/examples/isolation-backend-v8/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs new file mode 100644 index 0000000000..9391e237fd --- /dev/null +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -0,0 +1,707 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Supervisor-facing lifecycle for the network-disabled JavaScript runtime. +use crate::{ + guest::{Guest, GuestResult, GuestStatus, read_script}, + launch::Launch, + protocol::{self, BACKEND, boundary::*, control::*}, +}; +use anyhow::{Result, ensure}; +use openshell_core::jwt::{SandboxId, SessionJwtVerifier, SessionTokenProfile, SystemJwtClock}; +use openshell_isolation_interface::contract::BoundaryConfirmation; +use openshell_sandbox_backend::{boundary_protocol as wire, sandbox_auth::*}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::Arc, + time::Duration, +}; +use tokio::sync::{Mutex, watch}; +use tonic::{Request, Response, Status, Streaming}; + +type Chunks = + std::pin::Pin> + Send>>; +struct State { + policy: Option, + ledger: HashMap, + publications: (u64, u64), + running: bool, + terminal: bool, +} + +pub struct Runtime { + launch: Launch, + guest: Arc, + authenticator: SandboxProtocolAuthenticator, + pub registry: Arc, + opened: Mutex>, + state: Mutex, + mutation: Mutex<()>, + output: Arc>>, + result: watch::Sender>>, + ended: watch::Sender, +} + +impl Runtime { + pub fn new(launch: Launch) -> Result> { + let bytes = read_script(&launch.script)?; + let digest = format!("{:x}", Sha256::digest(&bytes)); + ensure!( + serde_json::from_slice::(&launch.descriptor)?["script_sha256"] + == digest, + "script binding changed" + ); + ensure!( + launch.boundary.workload_identity.resource_digest == digest, + "script identity changed" + ); + ensure!( + launch.boundary.workload_identity == crate::launch::host_identity(digest.clone())?, + "backend identity differs from the admitted host identity" + ); + use openshell_isolation_interface::contract::ProvisioningEvidence; + let fence = ProvisioningEvidence::from_enforcement_evidence( + launch.boundary.generation.clone(), + &crate::launch::capability_evidence(&digest), + )?; + ensure!( + fence == launch.boundary.provisioning_evidence, + "native V8 capability evidence differs from driver fence" + ); + let verifier = SessionJwtVerifier::new( + &launch.gateway_id, + SessionTokenProfile::Sandbox, + launch.keys.clone(), + Arc::new(SystemJwtClock), + )?; + let authenticator = SandboxProtocolAuthenticator::new( + verifier, + SandboxId::parse(launch.boundary.sandbox_id.clone())?, + openshell_core::sandbox_generation::SandboxGenerationId::parse( + launch.boundary.generation.clone(), + )?, + launch.epoch, + ); + let registry = Arc::new(SandboxConnectionRegistry::new( + launch.boundary.session_id, + launch.rotation, + )); + Ok(Arc::new(Self { + launch, + guest: Arc::new(Guest::new(&bytes)?), + authenticator, + registry, + opened: Mutex::new(HashSet::new()), + mutation: Mutex::new(()), + state: Mutex::new(State { + policy: None, + ledger: HashMap::new(), + publications: (0, 0), + running: false, + terminal: false, + }), + output: Arc::new(std::sync::Mutex::new(None)), + result: watch::channel(None).0, + ended: watch::channel(false).0, + })) + } + fn authenticate(&self, request: &Request) -> Result { + let id = *request + .extensions() + .get::() + .ok_or_else(|| Status::unauthenticated("TLS connection identity missing"))?; + self.authenticator + .authenticate(id, request.metadata()) + .map_err(|_| Status::unauthenticated("invalid sandbox launch credential")) + } + async fn route( + &self, + mut request: Request>, + binding: fn(F) -> Option, + ) -> Result<(SandboxProtocolPrincipal, Streaming), Status> { + let principal = self.authenticate(&request)?; + if !self + .opened + .lock() + .await + .contains(&principal.connection_id()) + { + return Err(Status::failed_precondition( + "OpenBoundary required on this connection", + )); + } + let first = request.get_mut().message().await?; + if first.and_then(binding) != Some(self.launch.boundary.generation.clone()) { + return Err(Status::permission_denied("wrong boundary handle")); + } + Ok((principal, request.into_inner())) + } + async fn result(&self) -> Result, Status> { + let mut receiver = self.result.subscribe(); + loop { + if let Some(result) = receiver.borrow_and_update().clone() { + return Ok(result); + } + receiver + .changed() + .await + .map_err(|_| Status::unavailable("guest completion lost"))?; + } + } + fn exit(result: &GuestResult) -> wire::ExitStatusWire { + match result.status { + GuestStatus::Terminated => wire::ExitStatusWire::Signaled(15), + _ => wire::ExitStatusWire::Exited(result.exit_code.unwrap_or(1)), + } + } + pub async fn stop(&self) { + self.guest.terminate(); + if self.state.lock().await.running { + let _ = self.result().await; + } + self.state.lock().await.terminal = true; + self.ended.send_replace(true); + self.registry.mark_terminal(); + } + fn validate_policy(&self, policy: &wire::SandboxPolicyWire) -> Result<(), Status> { + for (selector, expected) in [ + ( + &policy.run_as_user, + self.launch.boundary.workload_identity.uid, + ), + ( + &policy.run_as_group, + self.launch.boundary.workload_identity.gid, + ), + ] { + if selector + .as_ref() + .is_some_and(|value| value != &expected.to_string()) + { + return Err(Status::permission_denied( + "process identity differs from admitted host identity", + )); + } + } + Ok(()) + } + async fn control( + self: &Arc, + principal: &SandboxProtocolPrincipal, + envelope: &wire::RequestEnvelope, + ) -> Result { + use wire::{Request as Q, Response as R}; + envelope + .validate_payload_digest() + .map_err(|_| Status::invalid_argument("request digest mismatch"))?; + // Serialize mutations through validation, effects, and ledger commit. + let _mutation = if envelope.request.is_replayable_mutation() { + Some(self.mutation.lock().await) + } else { + None + }; + let cached = { + let state = self.state.lock().await; + if let Some((digest, response)) = state.ledger.get(&envelope.request_id) { + if digest != &envelope.payload_digest { + return Err(Status::invalid_argument( + "request ID reused with different payload", + )); + } + Some(response.clone()) + } else { + if state.terminal + && !matches!(envelope.request, Q::Wait { .. } | Q::AttachProcess { .. }) + { + return Err(Status::failed_precondition("boundary is terminal")); + } + if envelope.request.is_replayable_mutation() && state.ledger.len() >= 1024 { + return Err(Status::resource_exhausted("replay ledger full")); + } + None + } + }; + match &envelope.request { + Q::DiscoverPolicy => { + return Ok(R::ImagePolicy { + yaml: None, + invalid: false, + }); + } + Q::Attach { + supervisor_instance_id, + .. + } => { + self.registry + .attach(principal, *supervisor_instance_id) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + } + Q::Confirm => self + .registry + .require_attached(principal) + .map_err(|e| Status::failed_precondition(e.to_string()))?, + _ => self + .registry + .require_active(principal) + .map_err(|e| Status::failed_precondition(e.to_string()))?, + } + if let Some(response) = cached { + if matches!(envelope.request, Q::Confirm) { + self.registry + .confirm(principal) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + } + return Ok(response); + } + match &envelope.request { + Q::Wait { process_id } | Q::Signal { process_id, .. } | Q::Terminate { process_id } => { + self.check_process(process_id)? + } + _ => {} + } + if matches!(envelope.request, Q::Wait { .. }) { + return Ok(R::Exited { + status: Self::exit(self.result().await?.as_ref()), + }); + } + if let Q::Signal { signal, .. } = &envelope.request + && !matches!(signal, wire::SignalWire::Term) + { + return Err(Status::unimplemented("js-sbx supports TERM only")); + } + if matches!( + envelope.request, + Q::TerminateBoundary | Q::Terminate { .. } | Q::Signal { .. } + ) { + self.guest.terminate(); + if self.state.lock().await.running { + let _ = self.result().await?; + } + } + let mut state = self.state.lock().await; + let response = match &envelope.request { + Q::Attach { policy, .. } => { + self.validate_policy(policy)?; + if state + .policy + .as_ref() + .is_some_and(|old| old != policy.as_ref()) + { + return Err(Status::failed_precondition( + "policy changed during attachment", + )); + } + state.policy = Some(*policy.clone()); + R::Attached { + snapshot: wire::SessionSnapshotWire { + generation: self.launch.boundary.generation.clone(), + processes: vec![], + }, + } + } + Q::Confirm => { + if state.policy.is_none() { + return Err(Status::failed_precondition("attach required")); + } + self.registry + .confirm(principal) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + R::Confirmed { + confirmation: Box::new(BoundaryConfirmation { + generation: self.launch.boundary.generation.clone(), + session_id: self.launch.boundary.session_id, + identity: self.launch.boundary.workload_identity.clone(), + resource_claims: self.launch.boundary.resource_claims.clone(), + provisioning_evidence_digest: self + .launch + .boundary + .provisioning_evidence + .evidence_digest, + backend_audit: serde_json::json!({"script_sha256": self.launch.boundary.workload_identity.resource_digest, "network": "disabled", "filesystem": "no-host-access"}), + }), + } + } + Q::StartAgent { + sandbox_id, + spec, + provider_env_revision, + provider_env, + provider_files, + .. + } => { + if sandbox_id != &self.launch.boundary.sandbox_id || state.policy.is_none() { + return Err(Status::permission_denied("admitted launch changed")); + } + if spec.timeout_secs != 0 + || spec.workdir.as_deref().is_some_and(|path| path != "/work") + || spec.program != "/work/guest.js" + || !spec.args.is_empty() + || spec.interactive + || !provider_env.is_empty() + || !provider_files.is_empty() + { + return Err(Status::unimplemented( + "this example accepts only the provisioned script, without args, providers or PTY", + )); + } + if state.running { + return Err(Status::failed_precondition("main process already started")); + } + state.running = true; + state.publications = (*provider_env_revision, 1); + let runtime = self.clone(); + let guest = self.guest.clone(); + let output = self.output.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + guest.run(|stdout, stderr| { + *output.lock().unwrap() = Some((stdout, stderr)); + }) + }) + .await; + let result = match result { + Ok(Ok(result)) => result, + error => GuestResult { + status: GuestStatus::Failed, + exit_code: None, + stdout: vec![], + stderr: format!("guest failed: {error:?}").into_bytes(), + script_sha256: String::new(), + }, + }; + println!( + "Guest completed: {:?}, exit code {:?}", + result.status, result.exit_code + ); + for line in String::from_utf8_lossy(&result.stdout).lines() { + println!("guest: {line}"); + } + for line in String::from_utf8_lossy(&result.stderr).lines() { + eprintln!("guest: {line}"); + } + runtime.result.send_replace(Some(Arc::new(result))); + }); + R::Started { + process_id: self.process_id(), + provider_env_revision: *provider_env_revision, + provider_env_generation: 1, + } + } + Q::AttachProcess { process_id } => { + self.check_process(process_id)?; + R::ProcessAttached { terminal: false } + } + Q::UpdateProviderEnvironment { + revision, + generation, + provider_env, + provider_files, + } => { + if !provider_env.is_empty() || !provider_files.is_empty() { + return Err(Status::unimplemented( + "provider injection is not supported in this example", + )); + } + if *generation <= state.publications.1 || *revision < state.publications.0 { + return Err(Status::failed_precondition("stale publication")); + } + state.publications = (*revision, *generation); + R::ProviderEnvironmentUpdated { + revision: *revision, + generation: *generation, + applied: true, + } + } + Q::Signal { process_id, .. } => { + self.check_process(process_id)?; + R::Signaled + } + Q::Terminate { process_id } => { + self.check_process(process_id)?; + R::Terminated + } + Q::TerminateBoundary => { + state.terminal = true; + self.ended.send_replace(true); + R::BoundaryTerminated + } + Q::OpenMediation => R::MediationReady, + _ => { + return Err(Status::unimplemented( + "operation is not supported by js-sbx", + )); + } + }; + if envelope.request.is_replayable_mutation() { + state.ledger.insert( + envelope.request_id.clone(), + (envelope.payload_digest.clone(), response.clone()), + ); + } + Ok(response) + } + fn process_output(self: Arc) -> Chunks { + Box::pin(async_stream::try_stream! { + let mut offsets = [0usize, 0usize]; + loop { + let complete = self.result.borrow().clone(); + let buffers = if let Some(result) = &complete { + [result.stdout.clone(), result.stderr.clone()] + } else { + self.output.lock().unwrap().as_ref() + .map(|(out, err)| [out.contents(), err.contents()]) + .unwrap_or_default() + }; + for (index, data) in buffers.into_iter().enumerate() { + let channel = if index == 0 { + wire::STREAM_STDOUT + } else { + wire::STREAM_STDERR + }; + let pending = data.get(offsets[index]..) + .ok_or_else(|| Status::internal("guest output was truncated"))?; + for piece in pending.chunks(wire::MAX_STREAM_FRAME_BYTES) { + yield protocol::stream_frame(channel, piece)?; + } + offsets[index] = data.len(); + } + if let Some(result) = complete { + let bytes = openshell_sandbox_backend::protobuf::encode(&Runtime::exit(&result)) + .map_err(|_| Status::internal("encode exit status"))?; + yield protocol::stream_frame(wire::STREAM_EXIT, &bytes)?; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + } + fn process_id(&self) -> String { + format!("{}:main", self.launch.boundary.generation) + } + fn check_process(&self, id: &str) -> Result<(), Status> { + if id == self.process_id() { + Ok(()) + } else { + Err(Status::not_found("unknown guest process")) + } + } +} + +#[tonic::async_trait] +impl isolation_backend_server::IsolationBackend for Arc { + async fn describe( + &self, + request: Request, + ) -> Result, Status> { + let peer = request + .into_inner() + .supervisor + .ok_or_else(|| Status::failed_precondition("peer metadata required"))?; + let capability = protocol::CAPABILITY.to_string(); + if peer.protocol_version.map(|v| v.major) != Some(1) + || !peer.supported_capabilities.contains(&capability) + || peer.required_capabilities.iter().any(|c| c != &capability) + { + return Err(Status::failed_precondition( + "incompatible Sandbox Protocol peer", + )); + } + Ok(Response::new(DescribeIsolationBackendResponse { + backend: Some(protocol::metadata()), + })) + } + async fn open_boundary( + &self, + request: Request, + ) -> Result, Status> { + let principal = self.authenticate(&request)?; + let q = request.into_inner(); + let b = &self.launch.boundary; + if q.session_id != b.session_id.to_string() + || q.backend_name != BACKEND + || q.driver_descriptor != self.launch.descriptor + { + return Err(Status::permission_denied( + "immutable launch binding changed", + )); + } + self.opened.lock().await.insert(principal.connection_id()); + Ok(Response::new(OpenBoundaryResponse { + boundary_id: b.generation.clone(), + outcome: 1, + })) + } + async fn suspend_boundary( + &self, + request: Request, + ) -> Result, Status> { + self.authenticate(&request)?; + Err(Status::unimplemented("suspend is unsupported")) + } + async fn resume_boundary( + &self, + request: Request, + ) -> Result, Status> { + self.authenticate(&request)?; + Err(Status::unimplemented("resume is unsupported")) + } +} + +#[tonic::async_trait] +impl sandbox_protocol_server::SandboxProtocol for Arc { + type ExchangeStream = Chunks; + type MediateStream = + std::pin::Pin> + Send>>; + async fn mediate( + &self, + request: Request>, + ) -> Result, Status> { + let (principal, mut input) = self + .route(request, |f| match f.payload { + Some(mediate_frame::Payload::BoundaryId(id)) => Some(id), + _ => None, + }) + .await?; + let runtime = self.clone(); + let stream = async_stream::try_stream! { + let envelope = protocol::read_mediation_request(&mut input).await?; + let response = runtime.control(&principal, &envelope).await?; + yield protocol::mediation_response(envelope.request_id, response)?; + let mut ended = runtime.ended.subscribe(); + while !*ended.borrow_and_update() { + ended.changed().await.map_err(|_| Status::unavailable("boundary closed"))?; + } + }; + Ok(Response::new(Box::pin(stream))) + } + async fn exchange( + &self, + request: Request>, + ) -> Result, Status> { + let (principal, mut input) = self + .route(request, |f| match f.payload { + Some(exchange_frame::Payload::BoundaryId(id)) => Some(id), + _ => None, + }) + .await?; + let runtime = self.clone(); + let stream = async_stream::try_stream! { + let envelope = protocol::read_request(&mut input).await?; + if matches!(envelope.request, wire::Request::AcceptNetwork) { + envelope.validate_payload_digest() + .map_err(|_| Status::invalid_argument("request digest mismatch"))?; + runtime.registry.require_active(&principal) + .map_err(|e| Status::failed_precondition(e.to_string()))?; + let mut ended = runtime.ended.subscribe(); + // The guest has no socket bindings. Keep the network accept + // pending until shutdown or the supervisor disconnects. + tokio::select! { + _ = ended.wait_for(|value| *value) => {}, + _ = input.message() => {}, + } + return; + } + let response = match runtime.control(&principal, &envelope).await { + Ok(response) => response, + Err(error) => wire::Response::Error { + kind: match error.code() { + tonic::Code::InvalidArgument => wire::BoundaryErrorKind::Invalid, + tonic::Code::Unavailable => wire::BoundaryErrorKind::Unavailable, + tonic::Code::Unimplemented => wire::BoundaryErrorKind::Unsupported, + _ => wire::BoundaryErrorKind::Denied, + }, + message: error.message().to_string(), + }, + }; + let attached = matches!(response, wire::Response::ProcessAttached { .. }); + let mediation = matches!(response, wire::Response::MediationReady); + yield protocol::response(envelope.request_id, response)?; + if attached { + use tokio_stream::StreamExt; + let mut output = runtime.clone().process_output(); + while let Some(chunk) = output.next().await { + yield chunk?; + } + } + if mediation { + let mut ended = runtime.ended.subscribe(); + while !*ended.borrow_and_update() { + ended.changed().await + .map_err(|_| Status::unavailable("boundary closed"))?; + } + } + }; + Ok(Response::new(Box::pin(stream))) + } +} + +pub async fn serve(state_dir: PathBuf) -> Result<()> { + let state_dir = state_dir.canonicalize()?; + let socket_path = state_dir.join("backend.sock"); + println!( + "js-sbx waiting for driver provisioning in {} on {}", + state_dir.display(), + socket_path.display() + ); + loop { + let path = state_dir.join("launch.json"); + while !path.exists() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + let launch: Launch = serde_json::from_slice(&std::fs::read(&path)?)?; + let certs = rustls_pemfile::certs(&mut launch.certificate.as_bytes()) + .collect::, _>>()?; + let key = rustls_pemfile::private_key(&mut launch.private_key.as_bytes())? + .ok_or_else(|| anyhow::anyhow!("missing TLS key"))?; + let mut tls = + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(certs, key)?; + tls.alpn_protocols = vec![b"h2".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls)); + let runtime = Runtime::new(launch)?; + println!( + "JavaScript runtime provisioned: {}", + runtime.launch.boundary.sandbox_id + ); + let registry = runtime.registry.clone(); + let listener = tokio::net::UnixListener::bind(&socket_path)?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?; + crate::launch::write_private(&state_dir.join("active"), b"ready")?; + let incoming = async_stream::stream! { loop { + let (socket,_)=match listener.accept().await { Ok(v)=>v,Err(e)=>{yield Err(e);continue} }; + match tokio::time::timeout(Duration::from_secs(5),acceptor.accept(socket)).await { + Ok(Ok(stream))=>yield Ok(crate::transport::Connection {stream,id:SandboxConnectionId::new(),registry:registry.clone()}), + _=>continue, + } + }}; + let shutdown = async { + while path.exists() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + runtime.stop().await; + }; + tonic::transport::Server::builder() + .add_service( + isolation_backend_server::IsolationBackendServer::new(runtime.clone()) + .max_decoding_message_size(64 * 1024) + .max_encoding_message_size(64 * 1024), + ) + .add_service( + sandbox_protocol_server::SandboxProtocolServer::new(runtime.clone()) + .max_decoding_message_size( + openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES, + ) + .max_encoding_message_size( + openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES, + ), + ) + .serve_with_incoming_shutdown(incoming, shutdown) + .await?; + std::fs::remove_file(&socket_path)?; + std::fs::remove_file(state_dir.join("active"))?; + } +} diff --git a/examples/isolation-backend-v8/src/main.rs b/examples/isolation-backend-v8/src/main.rs new file mode 100644 index 0000000000..2391cca510 --- /dev/null +++ b/examples/isolation-backend-v8/src/main.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::{path::PathBuf, sync::Arc}; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(about = "Network-disabled JavaScript sandbox and local compute driver")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Serve the JavaScript runtime on its authenticated Unix socket. + Serve { + #[arg(long, env = "JS_SBX_STATE")] + state_dir: PathBuf, + }, + /// Run the local-development compute driver on a private Unix socket. + Driver { + #[arg(long, env = "JS_SBX_STATE")] + state_dir: PathBuf, + #[arg(long)] + socket: Option, + #[arg(long, default_value = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/debug/openshell-supervisor"))] + supervisor: PathBuf, + #[arg(long, default_value = "guest.js")] + script: PathBuf, + #[arg( + long, + env = "OPENSHELL_GATEWAY_ENDPOINT", + default_value = "http://127.0.0.1:18770" + )] + gateway_endpoint: String, + #[arg(long, default_value = concat!(env!("CARGO_MANIFEST_DIR"), "/../../crates/openshell-supervisor-network/data/sandbox-policy.rego"))] + policy_rules: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + match Cli::parse().command { + Command::Serve { state_dir } => { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + js_sbx::lifecycle::serve(state_dir).await?; + } + Command::Driver { + state_dir, + socket, + supervisor, + script, + gateway_endpoint, + policy_rules, + } => { + let socket = socket.unwrap_or_else(|| state_dir.join("compute.sock")); + let driver = js_sbx::driver::Driver::new( + state_dir, + supervisor, + script, + gateway_endpoint, + policy_rules, + )?; + js_sbx::driver::serve(Arc::new(driver), socket).await?; + } + } + Ok(()) +} diff --git a/examples/isolation-backend-v8/src/protocol.rs b/examples/isolation-backend-v8/src/protocol.rs new file mode 100644 index 0000000000..30e793f696 --- /dev/null +++ b/examples/isolation-backend-v8/src/protocol.rs @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Backend RPC bindings are generated from the public wire contracts. +mod openshell { + pub mod extension { + pub mod v1 { + tonic::include_proto!("openshell.extension.v1"); + } + } + pub mod sandbox { + pub mod backend { + pub mod v1 { + tonic::include_proto!("openshell.sandbox.backend.v1"); + } + } + pub mod protocol { + pub mod v1 { + tonic::include_proto!("openshell.sandbox.protocol.v1"); + } + } + } +} +pub use openshell::extension::v1::{PeerMetadata, ProtocolVersion}; +pub use openshell::sandbox::{backend::v1 as control, protocol::v1 as boundary}; + +pub const BACKEND: &str = "js-sbx"; +pub const CAPABILITY: &str = "openshell.sandbox.protocol"; + +pub fn metadata() -> PeerMetadata { + PeerMetadata { + protocol_version: Some(ProtocolVersion { major: 1, minor: 0 }), + implementation_name: BACKEND.into(), + implementation_version: env!("CARGO_PKG_VERSION").into(), + supported_capabilities: vec![CAPABILITY.into()], + required_capabilities: vec![CAPABILITY.into()], + } +} + +// This example generates its own bindings. Bridge local Rust convenience types +// through protobuf encoding so it also exercises the independently generated API. +fn convert(value: T) -> Result { + U::decode(value.encode_to_vec().as_slice()) + .map_err(|_| tonic::Status::invalid_argument("invalid protocol message")) +} + +pub async fn read_request( + input: &mut tonic::Streaming, +) -> Result { + use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; + use prost::Message; + let chunk = input + .message() + .await? + .ok_or_else(|| tonic::Status::invalid_argument("request required"))?; + if chunk.encoded_len() > openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES { + return Err(tonic::Status::resource_exhausted("request too large")); + } + let Some(boundary::exchange_frame::Payload::Request(request)) = chunk.payload else { + return Err(tonic::Status::invalid_argument("typed request required")); + }; + wire::RequestEnvelope::from_proto(convert(request)?) + .map_err(|error| tonic::Status::invalid_argument(error.to_string())) +} + +pub async fn read_mediation_request( + input: &mut tonic::Streaming, +) -> Result { + use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; + use prost::Message; + let frame = input + .message() + .await? + .ok_or_else(|| tonic::Status::invalid_argument("request required"))?; + if frame.encoded_len() > openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES { + return Err(tonic::Status::resource_exhausted("request too large")); + } + let Some(boundary::mediate_frame::Payload::Request(request)) = frame.payload else { + return Err(tonic::Status::invalid_argument("OpenMediation required")); + }; + let request = wire::RequestEnvelope::from_proto(convert(request)?) + .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; + if !matches!(request.request, wire::Request::OpenMediation) { + return Err(tonic::Status::invalid_argument( + "only OpenMediation is allowed on Mediate", + )); + } + Ok(request) +} + +pub fn mediation_response( + request_id: String, + value: openshell_sandbox_backend::boundary_protocol::Response, +) -> Result { + let frame = response(request_id, value)?; + let Some(boundary::exchange_frame::Payload::Response(value)) = frame.payload else { + unreachable!(); + }; + Ok(boundary::MediateFrame { + payload: Some(boundary::mediate_frame::Payload::Response(value)), + }) +} + +pub fn response( + request_id: String, + response: openshell_sandbox_backend::boundary_protocol::Response, +) -> Result { + use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; + let value = wire::ResponseEnvelope { + request_id, + response, + } + .to_proto() + .map_err(|error| tonic::Status::internal(error.to_string()))?; + Ok(boundary::ExchangeFrame { + payload: Some(boundary::exchange_frame::Payload::Response(convert(value)?)), + }) +} + +pub fn stream_frame(channel: u8, payload: &[u8]) -> Result { + use boundary::exchange_frame::Payload; + use openshell_sandbox_backend::{ + boundary_protocol as wire, + protobuf::{self, WireMessage}, + }; + if payload.len() > wire::MAX_STREAM_FRAME_BYTES { + return Err(tonic::Status::resource_exhausted("output frame too large")); + } + let payload = match channel { + wire::STREAM_STDOUT => Payload::Stdout(payload.to_vec()), + wire::STREAM_STDERR => Payload::Stderr(payload.to_vec()), + wire::STREAM_EXIT => { + let value = protobuf::decode::(payload) + .and_then(|v| v.to_proto()) + .map_err(|error| tonic::Status::internal(error.to_string()))?; + Payload::Exit(convert(value)?) + } + _ => { + return Err(tonic::Status::invalid_argument( + "unsupported output channel", + )); + } + }; + Ok(boundary::ExchangeFrame { + payload: Some(payload), + }) +} diff --git a/examples/isolation-backend-v8/src/transport.rs b/examples/isolation-backend-v8/src/transport.rs new file mode 100644 index 0000000000..79eb4da421 --- /dev/null +++ b/examples/isolation-backend-v8/src/transport.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! TLS connections carry backend-owned IDs, never request-supplied IDs. +use openshell_sandbox_backend::sandbox_auth::{SandboxConnectionId, SandboxConnectionRegistry}; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_rustls::server::TlsStream; + +pub struct Connection { + pub stream: TlsStream, + pub id: SandboxConnectionId, + pub registry: Arc, +} +impl tonic::transport::server::Connected for Connection { + type ConnectInfo = SandboxConnectionId; + fn connect_info(&self) -> Self::ConnectInfo { + self.id + } +} +impl Drop for Connection { + fn drop(&mut self) { + self.registry.disconnect(self.id); + } +} +impl AsyncRead for Connection { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(cx, buf) + } +} +impl AsyncWrite for Connection { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, bytes) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} diff --git a/examples/isolation-backend-v8/start.sh b/examples/isolation-backend-v8/start.sh new file mode 100755 index 0000000000..a197e3db6f --- /dev/null +++ b/examples/isolation-backend-v8/start.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +set -euo pipefail +umask 077 + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" +if [[ "$(id -u)" == 0 ]]; then + echo "Run this example as a non-root user." >&2 + exit 1 +fi +cargo build +cargo build --manifest-path ../../Cargo.toml \ + -p openshell-gateway -p openshell-supervisor -p openshell-cli +export PATH="$PWD/target/debug:$PWD/../../target/debug:$PATH" +js_sbx_state_dir="$(mktemp -d /tmp/js-sbx.XXXXXX)" +js_sbx_gateway_port="${JS_SBX_PORT:-18770}" +export OPENSHELL_GATEWAY_ENDPOINT="http://127.0.0.1:$js_sbx_gateway_port" +openshell-gateway generate-certs --output-dir "$js_sbx_state_dir/tls" +cat > "$js_sbx_state_dir/gateway.toml" <=0; i--)); do + kill "${js_sbx_children[i]}" 2>/dev/null || true + done + for js_sbx_pid in "${js_sbx_children[@]}"; do + wait "$js_sbx_pid" 2>/dev/null || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +js-sbx serve --state-dir "$js_sbx_state_dir" > "$js_sbx_state_dir/backend.log" 2>&1 & +js_sbx_children+=("$!") +js-sbx driver --state-dir "$js_sbx_state_dir" --script "${1:-guest.js}" > "$js_sbx_state_dir/driver.log" 2>&1 & +js_sbx_children+=("$!") +for ((i=0; i<100; i++)); do + if [[ -S "$js_sbx_state_dir/compute.sock" ]]; then break; fi + if ! kill -0 "${js_sbx_children[1]}" 2>/dev/null; then + cat "$js_sbx_state_dir/driver.log" >&2 + exit 1 + fi + sleep 0.1 +done +if [[ ! -S "$js_sbx_state_dir/compute.sock" ]]; then + echo "Timed out waiting for the compute driver; see $js_sbx_state_dir/driver.log" >&2 + exit 1 +fi +printf '\nDemo state and logs: %s\n' "$js_sbx_state_dir" +printf 'Gateway: %s\n' "$OPENSHELL_GATEWAY_ENDPOINT" +printf 'Guest output: tail -f %q/backend.log\n' "$js_sbx_state_dir" +printf 'In another terminal, follow the README to create a sandbox. Ctrl-C stops the demo.\n\n' +openshell-gateway --config "$js_sbx_state_dir/gateway.toml" \ + --db-url "sqlite://$js_sbx_state_dir/gateway.db?mode=rwc" & +js_sbx_children+=("$!") +wait "${js_sbx_children[2]}" diff --git a/proto/README.md b/proto/README.md index 725fb21129..61fc8d6456 100644 --- a/proto/README.md +++ b/proto/README.md @@ -85,3 +85,13 @@ identifies the service being exposed. - Regenerate Rust, Python, Go, and TypeScript bindings after contract changes. Run `mise run pre-commit`, the affected SDK checks, and relevant server tests before submitting the change. + +## Isolation backend protocol + +[`isolation_backend.proto`](isolation_backend.proto) defines backend discovery, +boundary binding, and optional suspend/resume RPCs. +[`sandbox_protocol.proto`](sandbox_protocol.proto) defines typed workload +control, process I/O, and network mediation over the bound boundary. Both are +supervisor-to-backend contracts, not gateway RPCs. Backend authors can generate bindings independently +from these definitions. The negotiated Sandbox Protocol version covers the gRPC +services, typed messages, streaming, and lifecycle semantics. diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 60bda56914..2d3f67b5d7 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -210,6 +210,37 @@ message DriverSandboxSpec { // material between the host supervisor and workload-side sandbox runtime; // it must never expose the supervisor bearer tokens to the workload. bytes launch_authentication = 16 [(openshell.options.v1.secret) = true]; + // Operator-selected external isolation backend. Never derived from the + // public sandbox spec or workload environment. Omitted for the built-in + // runtime. Requires openshell.compute.isolation-backend. + IsolationBackendRegistration isolation_backend = 17; +} + +// Trusted operator registration delivered to the compute driver on every +// launch. The driver stages this route outside workload mounts, admits this +// exact name, and supplies its opaque descriptor and generation-specific launch +// coordinates to the stock supervisor. It must reject unsupported selections +// before provisioning; it must never fall back to another isolation backend. +message IsolationBackendRegistration { + string name = 1; + oneof endpoint { + // Absolute path in the supervisor's filesystem namespace. + string unix_socket_path = 2; + IsolationBackendTcpEndpoint tcp = 3; + IsolationBackendVsockEndpoint vsock = 4; + } +} + +message IsolationBackendTcpEndpoint { + // Logical HTTP authority; generation-specific TLS identity is driver-owned. + string authority = 1; + // Operator-pinned socket addresses reachable from the supervisor. + repeated string addresses = 2; +} + +message IsolationBackendVsockEndpoint { + uint32 guest_cid = 1; + uint32 port = 2; } // Identity inputs admitted by the gateway before workload provisioning. @@ -426,6 +457,11 @@ message StartSandboxRequest { // stable resource represented by this identity while replacing only the // generation-specific runtime component. string expected_runtime_identity = 5; + // Same trusted selection as create; applies to this new generation. + // The gateway pins the backend name at creation and rejects start/recovery + // under a different selection. Operator endpoints may change without + // changing that identity; the driver still validates the retained resource. + IsolationBackendRegistration isolation_backend = 6; } message StartSandboxResponse { diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto new file mode 100644 index 0000000000..fc9a2023c1 --- /dev/null +++ b/proto/isolation_backend.proto @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.sandbox.backend.v1; + +import "extension.proto"; +import "options.proto"; + +// EXPERIMENTAL: This protocol is under active development. Its API and wire +// format may change incompatibly before stabilization. +// +// Isolation Backend control contract. The supervisor uses this service on a +// trusted, authenticated endpoint selected by operator configuration or compute +// driver provisioning. Workload control, network mediation, and byte streams +// remain on the Sandbox Protocol. +// Before serving a launch, the trusted compute driver provisions the backend +// with gateway_id and verification_keys from the gateway-created +// launch_authentication in compute_driver.proto. These public keys verify the +// supervisor's sandbox bearer; the driver delivers supervisor bearer credentials +// only to the supervisor, never to workload code. The gateway identity and key +// set remain fixed for that runtime generation. Bearers may be renewed within +// the generation using the provisioned keys; changing the gateway identity or +// key set requires a fresh launch and generation. These RPCs do not provision +// or update verification keys. +// Describe exchanges PeerMetadata to negotiate the base contract and optional +// suspend/resume support. +// Version 1.0 requires openshell.sandbox.protocol on both peers and includes +// boundary routing. Optional state retention support advertises +// openshell.sandbox.suspend-resume. Add optional capabilities +// or fields with compatible minor versions; incompatible semantics require a +// major bump. Peers ignore unknown protobuf fields and must satisfy both peers' +// required capabilities before use. +service IsolationBackend { + // Describe the backend and negotiate before opening a boundary. This call + // conveys no sandbox credentials or driver descriptor. + rpc Describe(DescribeIsolationBackendRequest) returns (DescribeIsolationBackendResponse); + + // Bind a driver-provisioned resource to the authenticated sandbox and generation + // using the launch in OpenBoundaryRequest. Follow SandboxProtocol admission. + // A resource that already is the boundary may only validate and reserve its + // binding, then return a handle. + // Reopening the same session and generation returns the same result. The + // descriptor must bind to the same provisioned resource; a different resource + // binding fails. + // Driver deletion invalidates all handles and retained-state references, + // including abandoned launches and suspended boundaries. + rpc OpenBoundary(OpenBoundaryRequest) returns (OpenBoundaryResponse); + + // Freeze the workload, retain its state, and fence the old generation before + // returning. The caller credential must match the active boundary. The + // compute driver owns storage and handoff of the reference bytes; the backend + // owns placement and retention of the state itself. Fenced means old-generation + // calls and streams are rejected and the old workload cannot make progress. + rpc SuspendBoundary(SuspendBoundaryRequest) returns (SuspendBoundaryResponse); + + // Restore a suspended boundary using the fresh launch in ResumeBoundaryRequest. + // Follow SandboxProtocol admission while keeping the restored workload frozen + // until StartAgent. A permitted cold start is reported. Unsupported optional + // suspend/resume RPCs return UNIMPLEMENTED without advancing lifecycle state. + rpc ResumeBoundary(ResumeBoundaryRequest) returns (OpenBoundaryResponse); +} + +message DescribeIsolationBackendRequest { + openshell.extension.v1.PeerMetadata supervisor = 1; +} + +message DescribeIsolationBackendResponse { + openshell.extension.v1.PeerMetadata backend = 1; +} + +// Launch identity is issued by the gateway: session and generation are fresh +// for each launch and unchanged on retries. Sandbox and generation identity +// come from the verified sandbox bearer; backends must bind that identity to +// their own provisioned state. +message OpenBoundaryRequest { + string session_id = 2; + // Must match trusted admission and the configured endpoint registration. + string backend_name = 4; + // Opaque descriptor from the trusted compute driver. The backend validates + // its resource binding; workload-controlled input must not select it. + bytes driver_descriptor = 5 [(openshell.options.v1.secret) = true]; +} + +message OpenBoundaryResponse { + // Opaque handle scoped to this sandbox and runtime generation. The supervisor + // presents it when opening Sandbox Protocol streams. + string boundary_id = 1; + + enum LaunchOutcome { + LAUNCH_OUTCOME_UNSPECIFIED = 0; + LAUNCH_OUTCOME_FRESH = 1; + LAUNCH_OUTCOME_RESTORED = 2; + LAUNCH_OUTCOME_COLD_START_FALLBACK = 3; + } + + // Required. Distinguishes restoration from cold-start fallback; admission + // follows SandboxProtocol for either outcome. + LaunchOutcome outcome = 2; +} + +message SuspendBoundaryRequest { + // Retries for this session and generation return the same reference or a + // terminal error. + string boundary_id = 1; + string session_id = 2; + string generation_id = 3; +} + +message SuspendBoundaryResponse { + // Opaque protected state reference. It may be used only with an authorized + // ResumeBoundary request for the same sandbox and a fresh generation. + // A backend may invalidate superseded references; resuming one then fails + // terminally with FAILED_PRECONDITION rather than restoring a different state. + bytes restore_reference = 1 [(openshell.options.v1.secret) = true]; +} + +message ResumeBoundaryRequest { + // New launch identity and trusted driver descriptor, as defined by + // OpenBoundaryRequest; replays reuse this launch request. + OpenBoundaryRequest launch = 1; + // Reference returned by SuspendBoundary for this sandbox. It carries no + // authority without the new authenticated launch credential. + bytes restore_reference = 2 [(openshell.options.v1.secret) = true]; + // If false, fail when the memory image cannot be restored. If true, a clean + // boot from the image and persistent volumes is permitted and reported as + // LAUNCH_OUTCOME_COLD_START_FALLBACK. This never permits unauthorized or + // superseded references to select a new launch. + bool allow_cold_start_fallback = 3; +} diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto new file mode 100644 index 0000000000..40a35cf096 --- /dev/null +++ b/proto/sandbox_protocol.proto @@ -0,0 +1,413 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.sandbox.protocol.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "options.proto"; + +// EXPERIMENTAL: This protocol is under active development. Its API and wire +// format may change incompatibly before stabilization. +// +// Authenticated Sandbox Protocol for per-boundary and shared backends. Each +// client stream starts with the OpenBoundary handle, checked against the +// authenticated sandbox and generation. Subsequent frames are typed protocol +// messages, with no nested JSON envelopes or application framing. +// Exchange handles one request/response and its associated process or relay I/O. +// Mediate handles OpenMediation followed by correlated DNS queries/responses. +// DiscoverPolicy is allowed before Attach on its own authenticated stream. +// Admission is Attach -> Confirm -> StartAgent; no untrusted instruction may +// execute before StartAgent. Exec and forwarding require Running. +service SandboxProtocol { + rpc Exchange(stream ExchangeFrame) returns (stream ExchangeFrame); + rpc Mediate(stream MediateFrame) returns (stream MediateFrame); +} + +// The first client frame contains boundary_id, never repeated on that stream. +// Control messages are bounded to 1 MiB; each I/O payload is at most 64 KiB. +// Process stdin closes explicitly; ending a gRPC request stream does not end +// the independent response stream. Exit follows all retained process output. +message ExchangeFrame { + oneof payload { + string boundary_id = 1; + RequestEnvelope request = 3; + ResponseEnvelope response = 4; + bytes stdin = 5; + bytes stdout = 6; + bytes stderr = 7; + ExitStatus exit = 8; + StdinClosed stdin_closed = 9; + TcpOpenDecision tcp_open_decision = 10; + bytes relay_data = 11; + } +} +// DNS mediation has its own stream, with no process I/O or TCP relay frames. +// The first frame binds the boundary; OpenMediation request/response follows, +// then correlated DNS messages. Other control operations are rejected. +message MediateFrame { + oneof payload { + string boundary_id = 1; + RequestEnvelope request = 3; + ResponseEnvelope response = 4; + DnsQuery dns_query = 12; + DnsResponse dns_response = 13; + } +} +message StdinClosed {} + +// A random UUID scoped to the boundary generation. Retried mutations reuse the +// same ID and typed request, including the exec deadline. Replays return the +// original result; conflicting reuse is rejected. Compare decoded known fields +// (map order is immaterial), not serialized protobuf bytes. Unknown fields do +// not authorize behavior: send behavior-affecting additions only after +// negotiating the receiver's supporting capability. No client digest required. +// Replayable mutations: Attach, Confirm, StartAgent, UpdateProviderEnvironment, +// Exec, Signal, Terminate, TerminateBoundary, and Resize. Exec outcomes remain +// retained through the deadline even after process exit. +message RequestEnvelope { + string request_id = 1; + oneof request { + DiscoverPolicy discover_policy = 10; + Attach attach = 11; + Confirm confirm = 12; + StartAgent start_agent = 14; + UpdateProviderEnvironment update_provider_environment = 15; + AttachProcess attach_process = 16; + Wait wait = 17; + Signal signal = 18; + Terminate terminate = 19; + TerminateBoundary terminate_boundary = 20; + Exec exec = 21; + Resize resize = 23; + LoopbackConnect loopback_connect = 24; + OpenMediation open_mediation = 25; + AcceptNetwork accept_network = 26; + } +} +message ResponseEnvelope { + string request_id = 1; + oneof response { + ImagePolicy image_policy = 10; + Attached attached = 11; + Confirmed confirmed = 12; + Started started = 14; + ProviderEnvironmentUpdated provider_environment_updated = 15; + ProcessAttached process_attached = 16; + Exited exited = 17; + Signaled signaled = 18; + Terminated terminated = 19; + BoundaryTerminated boundary_terminated = 20; + ExecStarted exec_started = 21; + Resized resized = 22; + LoopbackConnected loopback_connected = 23; + MediationReady mediation_ready = 24; + NetworkConnected network_connected = 25; + Error error = 26; + } +} +// Read-only image-policy discovery, permitted before attachment. +message DiscoverPolicy {} +message Attach { + string supervisor_instance_id = 1; + SandboxPolicy policy = 2; +} +// Success asserts that the backend can enforce the attached policy and trusted +// workload identity. Unsupported requirements fail before StartAgent, which +// installs all launch-time controls as required by the admission sequence. +// Supervisor or transport loss must never relax enforcement or open an +// unmediated egress path; supervisor-dependent operations fail closed. +// A backend may terminate, pause, or keep a disconnected workload contained. +message Confirm {} +// Start with the policy retained by Attach. Unsupported provider files must be +// rejected before workload execution. +message StartAgent { + string sandbox_id = 1; + AgentSpec spec = 2; + optional string ca_cert = 4; + optional string ca_bundle = 5; + uint64 provider_env_revision = 6; + map provider_env = 7 [(openshell.options.v1.secret) = true]; + map provider_files = 8 [(openshell.options.v1.secret) = true]; +} +// Validate support and the complete snapshot before applying an update. +message UpdateProviderEnvironment { + uint64 generation = 1; + uint64 revision = 2; + map provider_env = 3 [(openshell.options.v1.secret) = true]; + map provider_files = 4 [(openshell.options.v1.secret) = true]; +} +message AttachProcess { + string process_id = 1; +} +message Wait { + string process_id = 1; +} +// Signal either a main or exec process using its generation-scoped ID. +message Signal { + string process_id = 1; + BoundarySignal signal = 2; +} +message Terminate { + string process_id = 1; +} +message TerminateBoundary {} +message Exec { + ExecSpec spec = 1; + // Required absolute admission/retry deadline, not a process runtime limit. + // Remains unchanged on retries and participates in replay comparison. + google.protobuf.Timestamp exec_admission_deadline = 2; +} +message Resize { + string process_id = 1; + uint32 cols = 2; + uint32 rows = 3; +} +message LoopbackConnect { + // Numeric IPv4/IPv6 address inside the boundary. Loopback only. + string host = 1; + uint32 port = 2; +} +message OpenMediation {} +message AcceptNetwork {} + +// No image policy is yaml absent with invalid=false. invalid=true reports an +// unreadable or invalid image policy; it must not be treated as missing. +message ImagePolicy { + optional string yaml = 1; + bool invalid = 2; +} +message Attached { + SessionSnapshot snapshot = 1; +} +message Confirmed { + BoundaryConfirmation confirmation = 1; +} +message Started { + string process_id = 1; + uint64 provider_env_revision = 2; + uint64 provider_env_generation = 3; +} +message ProviderEnvironmentUpdated { + uint64 revision = 1; + uint64 generation = 2; + bool applied = 3; +} +message ProcessAttached { + bool terminal = 1; +} +message Exited { + ExitStatus status = 1; +} +message Signaled {} +message Terminated {} +message BoundaryTerminated {} +message ExecStarted { + string process_id = 1; + bool pty = 2; +} +message Resized {} +message LoopbackConnected {} +message MediationReady {} +message NetworkConnected { + BinaryIdentity identity = 1; + // Numeric destination captured before mediation; never a workload-provided + // claim of executable identity. No upstream bytes flow before RelayReady. + string destination = 2; + NetworkSocketMetadata socket = 3; + uint64 policy_generation = 4; + // Omitted timing means unmeasured (zero). + MediationTiming timing = 5; +} +message Error { + BoundaryErrorKind kind = 1; + string message = 2; +} + +enum BoundaryErrorKind { + BOUNDARY_ERROR_KIND_UNSPECIFIED = 0; + BOUNDARY_ERROR_KIND_INVALID = 1; + BOUNDARY_ERROR_KIND_DENIED = 2; + BOUNDARY_ERROR_KIND_UNAVAILABLE = 3; + BOUNDARY_ERROR_KIND_TERMINATED = 4; + BOUNDARY_ERROR_KIND_PROCESS = 5; + BOUNDARY_ERROR_KIND_UNSUPPORTED = 6; +} +enum BoundarySignal { + BOUNDARY_SIGNAL_UNSPECIFIED = 0; + BOUNDARY_SIGNAL_TERM = 1; + BOUNDARY_SIGNAL_KILL = 2; + BOUNDARY_SIGNAL_INT = 3; + BOUNDARY_SIGNAL_HUP = 4; +} +message ExitStatus { + oneof status { + int32 exited = 1; + int32 signaled = 2; + } +} +message AgentSpec { + string program = 1; + repeated string args = 2; + optional string workdir = 3; + // Omitted or zero means no timeout; otherwise whole nonnegative seconds. + google.protobuf.Duration timeout = 4; + bool interactive = 5; +} +message ExecSpec { + string program = 1; + repeated string args = 2; + ShellSpec shell = 3; + optional RuntimeHelper runtime_helper = 4; + // Ordered entries preserve duplicate-variable semantics of the existing API. + repeated EnvironmentEntry env = 5 [(openshell.options.v1.secret) = true]; + optional string workdir = 6; + bool pty = 7; +} +message EnvironmentEntry { + string name = 1; + string value = 2; +} +message ShellSpec { + optional string command = 1; + bool login = 2; +} +enum RuntimeHelper { + RUNTIME_HELPER_UNSPECIFIED = 0; + RUNTIME_HELPER_SFTP = 1; +} + +// Boundary launch controls, distinct from the authored gateway SandboxPolicy. +message SandboxPolicy { + uint32 version = 1; + repeated string read_only = 2; + repeated string read_write = 3; + bool include_workdir = 4; + NetworkMode network = 5; + optional string proxy_addr = 6; + LandlockCompatibility landlock = 7; + optional string run_as_user = 8; + optional string run_as_group = 9; +} +enum NetworkMode { + NETWORK_MODE_UNSPECIFIED = 0; + NETWORK_MODE_BLOCK = 1; + NETWORK_MODE_PROXY = 2; + NETWORK_MODE_ALLOW = 3; +} +enum LandlockCompatibility { + LANDLOCK_COMPATIBILITY_UNSPECIFIED = 0; + LANDLOCK_COMPATIBILITY_BEST_EFFORT = 1; + LANDLOCK_COMPATIBILITY_HARD_REQUIREMENT = 2; +} +message ResolvedWorkloadIdentity { + uint32 uid = 1; + uint32 gid = 2; + repeated uint32 supplementary_gids = 3; + string source = 4; + string resource_digest = 5; +} +// Successful confirmation binds policy enforcement to the admitted launch. +// Enforcement mechanisms are backend-specific, not a portable flag checklist. +message BoundaryConfirmation { + string generation = 1; + ResolvedWorkloadIdentity identity = 2; + string session_id = 5; + // Derived from backend-owned provisioned state, not supervisor-supplied claims. + map resource_claims = 8; + // Optional backend-owned audit document encoded as a UTF-8 JSON value. Empty + // bytes mean absent. Native clients may validate its schema; external backends + // need not provide Linux mechanism evidence. + bytes backend_audit = 9; + // Required SHA-256 commitment to the driver's validated provisioning evidence + // and this generation. Must match trusted launch metadata. A matching digest + // binds provisioning; it is not independent proof of policy enforcement. + string provisioning_evidence_digest = 10; +} +message SessionSnapshot { + string generation = 1; + repeated ProcessSnapshot processes = 2; +} +message ProcessSnapshot { + string process_id = 1; + ProcessKind kind = 2; + bool terminal = 3; + ExitStatus status = 4; + OutputWindow retained_output = 5; +} +enum ProcessKind { + PROCESS_KIND_UNSPECIFIED = 0; + PROCESS_KIND_MAIN = 1; + PROCESS_KIND_EXEC = 2; +} +message OutputWindow { + uint64 first_sequence = 1; + uint64 next_sequence = 2; + bool truncated = 3; +} +message ExecutableIdentity { + string path = 1; + optional string digest = 2; +} +message ResolvedBinaryIdentity { + ExecutableIdentity executable = 1; + repeated ExecutableIdentity ancestors = 2; + repeated string cmdline_paths = 3; +} +message BinaryIdentity { + oneof result { + ResolvedBinaryIdentity resolved = 1; + string failed = 2; + } +} +// Optional native socket evidence. Guests without kernel sockets omit it and +// supply their authoritative executable identity; never fabricate host PIDs. +message NetworkSocketMetadata { + uint64 socket_cookie = 1; + bool nonblocking = 2; + uint64 process_generation = 3; +} +message MediationTiming { + // Diagnostic backend-monotonic measurements; zero if unmeasured. + google.protobuf.Duration notification_to_queue = 1; + google.protobuf.Duration queue_wait = 2; +} +message TcpOpenDecision { + oneof decision { + TcpOpenDenial denied = 1; + RelayReady relay_ready = 2; + } +} +message RelayReady {} +enum TcpOpenDenial { + TCP_OPEN_DENIAL_UNSPECIFIED = 0; + TCP_OPEN_DENIAL_POLICY_DENIED = 1; + TCP_OPEN_DENIAL_IDENTITY_UNAVAILABLE = 2; + TCP_OPEN_DENIAL_INVALID_DESTINATION = 3; + TCP_OPEN_DENIAL_RESOURCE_EXHAUSTED = 4; + TCP_OPEN_DENIAL_MEDIATION_UNAVAILABLE = 5; +} +message DnsQuery { + uint64 stream_id = 1; + // One DNS wire message, without DNS-over-TCP framing. + bytes request = 2; + DnsTransport transport = 3; + BinaryIdentity identity = 4; + // Omitted timing means unmeasured (zero). + MediationTiming timing = 5; +} +enum DnsTransport { + DNS_TRANSPORT_UNSPECIFIED = 0; + DNS_TRANSPORT_UDP = 1; + DNS_TRANSPORT_TCP = 2; +} +// Reply on the same authenticated mediation stream with the query stream_id. +message DnsResponse { + uint64 stream_id = 1; + oneof result { + bytes response = 2; + string error = 3; + } +} diff --git a/rfc/0012-isolation-backend/README.md b/rfc/0012-isolation-backend/README.md index b384c13e5e..9febf8d763 100644 --- a/rfc/0012-isolation-backend/README.md +++ b/rfc/0012-isolation-backend/README.md @@ -21,6 +21,13 @@ Today the supervisor both builds the workload's isolation boundary and applies i The compute driver orchestrates the workload, supervisor, sandbox runtime, protected channel, and outer network fence. The supervisor is the trusted bridge between the gateway and the workload: it maintains the gateway connection, handles authorized requests, and drives the backend. The backend establishes the isolation controls, manages workload processes, and routes egress to network mediation. The same lifecycle supports different compute drivers and backend implementations without driver-specific supervisor paths. +The implementation expresses this contract through +[`isolation_backend.proto`](../../proto/isolation_backend.proto) and +[`sandbox_protocol.proto`](../../proto/sandbox_protocol.proto). The Rust +signatures below illustrate the lifecycle model; they are not backend extension +traits. The supervisor uses concrete RPC clients, and backends implement the +generated services. Wire negotiation and message definitions live in the protos. + ## Motivation Boundary construction is embedded in the supervisor, so moving it anywhere else means changing the supervisor. That placement creates three problems: @@ -80,7 +87,7 @@ flowchart TB Mediator -->|"allowed egress"| Egress["Egress"] ``` -The initial implementation is `OpenShellRuntimeBackend`: a supervisor-side client that delegates operations over the authenticated OpenShell Sandbox Protocol to `openshell-sandbox` beside the workload. A future runtime-native backend could implement the same interface without using that protocol or binary. +The initial implementation is `OpenShellRuntimeBackend`: a supervisor-side client that delegates operations over the authenticated OpenShell Sandbox Protocol to `openshell-sandbox` beside the workload. Other backends implement the same RPC services using their own isolation mechanisms; they do not need the `openshell-sandbox` binary. A boundary is active from successful `attach` until normal backend cleanup releases the binding or the compute driver's trusted cleanup path invalidates it. A backend may coordinate multiple trusted helper or interception processes for that boundary. The backend owns the active-boundary binding; the compute driver owns the sandbox instance and infrastructure lifecycle. @@ -93,9 +100,9 @@ Six invariants hold for every boundary: 3. An operation is authorized only when the complete effective policy permits it; network operations are decided through network mediation. There is no silent weakening. 4. Agent startup, `exec`, and forwarding occur only through the active backend, and every workload process remains in the compute driver's provisioned execution environment. 5. Shared infrastructure preserves strict per-boundary lifecycle, policy, identity, enforcement, and cleanup isolation. -6. If the logical supervisor is lost, the boundary remains under its last confirmed enforcement state while supervisor-dependent operations fail closed. Loss of required enforcement ends `Running` and terminates all workload processes within a documented bound; detection and termination may be performed by a trusted node or control-plane actor. Network-mediation unavailability denies outbound connections and never enables direct egress. Exit of the Sandbox Runtime that owns the boundary terminates its workload through the compute driver's containment primitive; it never leaves an unmanaged agent process tree. +6. If the logical supervisor or its transport is lost, enforcement remains in force and supervisor-dependent operations fail closed. A backend may terminate, pause, or keep the workload contained. Network-mediation unavailability denies outbound connections and never enables direct egress. If required enforcement cannot be maintained, the backend or a trusted node or control-plane actor stops workload execution within a documented bound. Runtime exit must not leave a workload running without its required enforcement; terminating the workload is one backend-specific way to meet this requirement. -Each backend states its termination bound in its implementation documentation. Loss of the logical supervisor means loss of the components holding the backend lifecycle, not loss of the gateway connection; gateway disconnection follows RFC 0001's reconnection semantics. +Each backend documents its behavior on supervisor or runtime loss and its bound for stopping execution when enforcement cannot be maintained. Loss of the logical supervisor means loss of the components holding the backend lifecycle, not loss of the gateway connection; gateway disconnection follows RFC 0001's reconnection semantics. Transport recovery is scoped to the same logical supervisor process. A backend may reconnect that process and re-confirm its binding, but a replacement @@ -210,6 +217,23 @@ The states have normative meanings: `attach` rejects a resource already bound to an active boundary. A boundary that cannot enforce the complete admitted policy does not reach `Ready`: the backend fails `attach` or `confirm`, or the supervisor fails network-mediation initialization. +Successful `confirm` asserts these requirements; it does not return a universal +checklist of mechanisms or guarantee flags. Authentication, launch binding, +trusted identity for policy decisions, and prevention of unmediated egress are +mandatory. Filesystem and privilege controls depend on the admitted policy and +the backend's execution model. An isolate with no filesystem API need not +implement Linux filesystem controls. Unsupported requirements are rejected +before workload execution; they are never silently ignored. + +The protobuf representation in +[`sandbox_protocol.proto`](../../proto/sandbox_protocol.proto) returns the +generation, session, admitted workload identity, resource claims, and a digest +of the driver's validated provisioning evidence bound to that generation. +The client compares these with trusted launch metadata. The digest preserves +the provisioning binding; it is not independent proof of enforcement. +Backend-specific audit data is optional on the wire. The native Linux client +continues to require and validate its measured audit evidence. + **Standing enforcement** is established independently of a workload process. **Launch-time controls** must be in force before a process executes its first untrusted instruction. Both `start_agent` and `BoundaryExec::exec` enforce this ordering and preserve the provisioned execution environment. `start_agent` is the sole operation that may make the admitted agent runnable. The backend may create or release the process, but workload-controlled code cannot run before `start_agent` applies the required controls. @@ -386,9 +410,12 @@ This is natural for drivers such as MXC, and the same component may implement bo ### Start with a remote backend service -The contract could be expressed as a gRPC service or plugin ABI rather than an in-process Rust contract. [RFC 0001](../0001-core-architecture/README.md) chose gRPC for its gateway-facing drivers, so the question applies here. - -The callers differ. A gateway driver is a control-plane peer with its own release cycle, while the Isolation Backend is driven by the supervisor that operates the boundary, and a co-located implementation needs no transport at all. Starting in-process serves that case directly and lets delegated implementations carry their own transport behind the same interface. A transport-bearing surface is not precluded: it is versioned contract surface, added when a concrete delegated backend requires it. +The original proposal started with an in-process Rust contract and allowed a +versioned transport when a delegated backend required it. The implementation +now uses gRPC for both the in-tree Linux runtime and external backends, retaining +the lifecycle semantics described here. As in +[RFC 0001](../0001-core-architecture/README.md), protobuf services let external +implementations generate their own bindings without linking supervisor code. ### Standardize placement and capabilities diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index cd1cbed4ef..bb1379efdf 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -333,8 +333,9 @@ Common findings: published [Docker configuration](https://docs.nvidia.com/openshell/latest/how-it-works/gateways/configuration.md) for the current networking options. - Sandbox runtime image exits before printing `openshell-sandbox --version`: verify the configured image contains a static executable at `/openshell-sandbox`. -- A sandbox with explicit `protocol: tcp` endpoints fails before workload readiness: confirm the selected isolation backend advertises TCP mediation, then inspect the sandbox and supervisor logs for protected-channel setup or listener failures. A driver that cannot supply the required outer egress fence and authenticated runtime channel must reject the policy before starting the agent. +- A sandbox with explicit `protocol: tcp` endpoints fails before workload readiness: inspect supervisor and backend logs for policy rejection, network-mediation initialization, protected-channel setup, or listener failures. A backend that cannot enforce the admitted policy must reject it before starting workload code. - Supervisor runtime validation fails: verify `supervisor_image` contains an `/openshell-supervisor` executable from the same release as the sandbox runtime, and that the dynamic loader and shared libraries it links against are available inside that image. `docker run --rm --network none --entrypoint /openshell-supervisor --version` should print that release; a `no such file or directory` error for a binary that exists means the loader or a library is missing. The supervisor runs from its own image and does not need to be static; only `/openshell-sandbox` must be. +- Sandbox startup reports an `isolation control contract` failure: inspect the supervisor and sandbox-runtime image versions and update them together. An older runtime or incompatible isolation capability set fails before workload startup. See the published Sandbox Runtimes documentation for trusted image override precedence. - The sandbox fails its enforcement probe: inspect the sandbox log for the exact nested seccomp user-notification, task-memory, Landlock, loopback DNS, or socket-injection check that failed. A runtime may return `ENOSYS` for `process_vm_readv` and `process_vm_writev` while satisfying the production parent-to-workload-child task-memory probe through `/proc//mem`; only failure of both backends is fatal. Do not add capabilities or switch to an unconfined seccomp profile; use a runtime whose default profile permits the unprivileged probe. - A GPU sandbox fails because Docker reports no discovered NVIDIA CDI devices: verify `.DiscoveredDevices` contains entries such as `nvidia.com/gpu=all`, verify `/etc/cdi` or `/var/run/cdi` contains a generated NVIDIA spec, and check that `nvidia-cdi-refresh.service` and `nvidia-cdi-refresh.path` from NVIDIA Container Toolkit are enabled and healthy. The service is a one-shot unit, so `inactive (dead)` can be normal after a successful run; use `systemctl status` and `journalctl` to distinguish success from a skipped or failed refresh. Restart `nvidia-cdi-refresh.service` to regenerate missing or stale CDI specs, then restart or reload Docker and re-check `docker info`. - Docker or Podman rejects an explicit `cdi_devices` selector: use a CDI qualified name (`/=`), not a host-device path. Syntax validation accepts other vendors; it does not establish that the runtime has the device or that its GPU workload is supported. Consult the published GPU selection documentation for request requirements. diff --git a/tasks/rust.toml b/tasks/rust.toml index 32928b8f69..89f2061bc6 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -32,6 +32,7 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -43,6 +44,7 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", + "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all", ] hide = true @@ -53,6 +55,7 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", + "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check", ] hide = true