From b9635a6f728643cf0f30143980f0914857ddf429 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 9 Oct 2026 08:36:31 -0700 Subject: [PATCH 01/24] feat(isolation): publish backend protocol contracts independently of runtime Signed-off-by: Drew Newberry --- crates/openshell-core/README.md | 7 + .../openshell-core/src/extension_protocol.rs | 4 + .../src/isolation_registration.rs | 209 ++++++++++ crates/openshell-core/src/lib.rs | 1 + crates/openshell-core/src/proto/mod.rs | 9 + crates/openshell-driver-docker/src/tests.rs | 1 + .../src/boundary_protocol.rs | 50 ++- crates/openshell-sandbox-backend/src/lib.rs | 2 +- crates/openshell-server/src/compute/mod.rs | 5 + crates/openshell-server/src/storage_proto.rs | 38 +- proto/README.md | 8 + proto/compute_driver.proto | 36 ++ proto/isolation_backend.proto | 126 ++++++ proto/sandbox_protocol.proto | 26 ++ proto/sandbox_protocol/v1/README.md | 375 ++++++++++++++++++ proto/sandbox_protocol/v1/vectors.json | 282 +++++++++++++ 16 files changed, 1158 insertions(+), 21 deletions(-) create mode 100644 crates/openshell-core/src/isolation_registration.rs create mode 100644 proto/isolation_backend.proto create mode 100644 proto/sandbox_protocol.proto create mode 100644 proto/sandbox_protocol/v1/README.md create mode 100644 proto/sandbox_protocol/v1/vectors.json 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/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index aef96b46d2..b56498b53e 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -19,6 +19,10 @@ 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_REGISTRATION: &str = + "openshell.compute.isolation-backend-registration"; + const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; const MAX_CAPABILITY_BYTES: usize = 128; 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 d4bfb4579a..3164618ef1 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -32,6 +32,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..f17126e8e2 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -34,6 +34,9 @@ pub mod datamodel { pub mod sandbox { pub use super::generated::openshell::sandbox::v1; + pub mod protocol { + pub use super::super::generated::openshell::sandbox::protocol::v1; + } } pub mod compute { @@ -44,6 +47,12 @@ pub mod extension { pub use super::generated::openshell::extension::v1; } +pub mod isolation { + pub mod backend { + pub use super::super::generated::openshell::isolation::backend::v1; + } +} + #[allow( clippy::all, clippy::pedantic, diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index f690aae921..829154ea33 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -101,6 +101,7 @@ fn test_sandbox() -> DriverSandbox { await_main_process_attachment: false, workload_identity: None, launch_authentication: test_launch_authentication(), + isolation_backend: None, }), status: None, workspace: String::new(), diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 70d491d318..22cab052e0 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -276,25 +276,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)] @@ -1461,6 +1443,36 @@ pub enum FrameError { mod tests { use super::*; + #[test] + fn published_protocol_vectors_match_typed_messages_digest_and_framing() { + use sha2::Digest as _; + let vectors: serde_json::Value = serde_json::from_str(include_str!( + "../../../proto/sandbox_protocol/v1/vectors.json" + )) + .unwrap(); + assert_eq!(vectors["version"], "1.0"); + for vector in vectors["requests"].as_array().unwrap() { + let envelope: RequestEnvelope = + serde_json::from_value(vector["envelope"].clone()).unwrap(); + envelope.validate_payload_digest().unwrap(); + assert_eq!(serde_json::to_value(&envelope).unwrap(), vector["envelope"]); + let canonical = vector["canonical_request"].as_str().unwrap(); + assert_eq!( + format!("{:x}", Sha256::digest(canonical.as_bytes())), + envelope.payload_digest + ); + let hex = vector["frame_hex"].as_str().unwrap(); + let frame: Vec = (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).unwrap()) + .collect(); + let decoded: RequestEnvelope = decode_frame(&frame).unwrap(); + assert_eq!(decoded, envelope); + let decoded: RequestEnvelope = decode_frame(&encode_frame(&decoded).unwrap()).unwrap(); + assert_eq!(decoded, envelope); + } + } + fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { NativeLinuxSandboxAuditEvidence { capabilities: CapabilityEvidence { diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 5ffb83ac8e..9ef2b05964 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -19,7 +19,7 @@ pub mod sandbox_auth; pub use runtime::{BoundaryTransportConnector, OpenShellRuntimeBackend}; /// 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"; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 4fc082eb30..9605f45317 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -2084,6 +2084,7 @@ impl ComputeRuntime { move |driver| async move { driver .start_sandbox(Request::new(StartSandboxRequest { + isolation_backend: None, sandbox_id: request_id, name: sandbox_name, launch_authentication, @@ -3636,6 +3637,7 @@ impl ComputeRuntime { async move { driver .start_sandbox(Request::new(StartSandboxRequest { + isolation_backend: None, sandbox_id, name: sandbox_name, launch_authentication, @@ -3904,6 +3906,7 @@ impl ComputeRuntime { |driver| async move { driver .start_sandbox(Request::new(StartSandboxRequest { + isolation_backend: None, sandbox_id: driver_sandbox_id, name: sandbox_name, launch_authentication: Vec::new(), @@ -4405,6 +4408,7 @@ impl ComputeRuntime { move |driver| async move { driver .start_sandbox(Request::new(StartSandboxRequest { + isolation_backend: None, sandbox_id: request_id, name: operation_name, launch_authentication, @@ -6272,6 +6276,7 @@ fn driver_sandbox_spec_from_public( driver_name: &str, ) -> Result> { Ok(DriverSandboxSpec { + isolation_backend: None, log_level: spec.log_level.clone(), environment: spec.environment.clone(), template: spec diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 3e0bad0afe..9bbb42a205 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -522,11 +522,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.isolation.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, "DelegatedIsolationBoundary"); + 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") @@ -550,6 +570,19 @@ mod tests { } } methods.sort(); + assert_eq!( + isolation_methods, + BTreeSet::from([ + "GetCapabilities", + "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), @@ -564,7 +597,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/proto/README.md b/proto/README.md index 725fb21129..d08bc6518d 100644 --- a/proto/README.md +++ b/proto/README.md @@ -85,3 +85,11 @@ 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) and +[`sandbox_protocol.proto`](sandbox_protocol.proto) are supervisor-to-backend +contracts, not gateway RPCs. Backend authors can generate bindings independently +and implement [Sandbox Protocol 1.0](sandbox_protocol/v1/README.md). Its negotiated +version covers the gRPC services, JSON messages, framing, and lifecycle semantics. diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 60bda56914..d27d7246e5 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-registration. + 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..a8fd91007e --- /dev/null +++ b/proto/isolation_backend.proto @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.isolation.backend.v1; + +import "extension.proto"; +import "options.proto"; + +// 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. +// PeerMetadata negotiates the base contract and optional suspend/resume support. +// Version 1.0 requires openshell.isolation.contract on both peers and includes +// boundary routing. Optional state retention support advertises +// openshell.isolation.suspend-resume. Add optional capabilities +// with compatible minor versions; incompatible semantics require a major bump. +service IsolationBackend { + // Negotiate the isolation contract before opening a boundary. This call + // conveys no sandbox credentials or driver descriptor. + rpc GetCapabilities(GetIsolationCapabilitiesRequest) returns (GetIsolationCapabilitiesResponse); + + // Bind a driver-provisioned resource to one fresh sandbox launch. The + // caller credential must match sandbox_id and generation_id. This call must + // not run untrusted workload code. The subsequent Sandbox Protocol attach, + // confirm, and start sequence remains mandatory. 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. A route opened before attach never authorizes execution. + // 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 into a fresh launch session and generation. + // The restored workload remains frozen until attach, confirm, and start + // complete under the new supervisor: no untrusted workload code executes + // before start. A permitted cold start is reported. Unsupported optional + // suspend/resume RPCs return UNIMPLEMENTED without advancing lifecycle state. + rpc ResumeBoundary(ResumeBoundaryRequest) returns (OpenBoundaryResponse); +} + +message GetIsolationCapabilitiesRequest { + openshell.extension.v1.PeerMetadata supervisor = 1; +} + +message GetIsolationCapabilitiesResponse { + openshell.extension.v1.PeerMetadata backend = 1; +} + +message OpenBoundaryRequest { + reserved 6; + reserved "request_id"; + + // Stable gateway-assigned sandbox identity, not a public resource name. + string sandbox_id = 1; + // Fresh for each launch and unchanged for retries of that launch. + string session_id = 2; + // Fresh runtime generation bound to the supervisor's launch credential. + string generation_id = 3; + // 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. A restoration result remains frozen until the new supervisor + // authenticates, confirms enforcement, installs current policy and secrets, + // and explicitly starts the boundary. Fallback must be visible to callers. + LaunchOutcome outcome = 2; +} + +message SuspendBoundaryRequest { + reserved 4; + reserved "request_id"; + + // 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 { + // Fresh launch identity and trusted driver descriptor. Replays reuse the + // same launch request; the old session and generation are never reused. + 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..95c8aa502e --- /dev/null +++ b/proto/sandbox_protocol.proto @@ -0,0 +1,26 @@ +// 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; + +// Stream entrypoint for per-boundary or shared out-of-process Isolation Backends. +// Each client stream starts with a boundary_id frame. The backend checks that +// handle against the authenticated sandbox and generation before accepting any +// data. Subsequent data fragments carry the existing Sandbox Protocol bytes. +// OpenBoundary returns the handle before policy discovery and lifecycle traffic. +service DelegatedIsolationBoundary { + rpc Exchange(stream DelegatedBoundaryChunk) returns (stream DelegatedBoundaryChunk); + rpc Mediate(stream DelegatedBoundaryChunk) returns (stream DelegatedBoundaryChunk); +} + +message DelegatedBoundaryChunk { + oneof payload { + // Nonempty handle required as the first client frame; forbidden on later frames. + string boundary_id = 1; + // Ordered fragment of the existing Sandbox Protocol. Response frames only + // use data. Empty data is allowed and does not bind a stream. + bytes data = 2; + } +} diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md new file mode 100644 index 0000000000..9be7e1cedd --- /dev/null +++ b/proto/sandbox_protocol/v1/README.md @@ -0,0 +1,375 @@ + + +# OpenShell Sandbox Protocol 1.0 + +This is the wire contract for an isolation backend implemented outside the +OpenShell tree. An implementation may use any language and owns its native +isolation mechanisms. Generate gRPC bindings from +[`isolation_backend.proto`](../../isolation_backend.proto), +[`sandbox_protocol.proto`](../../sandbox_protocol.proto), and their imports. +No OpenShell guest executable or linked backend implementation is required. + +The negotiated isolation version covers **both** the control RPCs and the +framing and JSON messages below. Version 1.0 requires +`openshell.isolation.contract` on both peers. An incompatible change to a +message, framing, authentication, or lifecycle guarantee requires a major bump. +A minor revision may add optional capabilities; it must preserve all 1.0 +messages and behavior. A new operation requires an advertised capability +before a sender uses it. Adding fields to an existing 1.0 request requires a +major bump: additional request fields are not part of 1.0 and must not be sent. +Unknown required capabilities fail startup. This +contract is independently versioned from OpenShell releases. + +## Trusted registration and launch + +The gateway reads `isolation_backend` from the selected compute driver's +`[openshell.drivers.]` table and resolves it through +`openshell.supervisor.isolation_backends` in operator-owned `gateway.toml`. +Selecting `openshell.gateway.compute_driver` selects both the driver and its +backend; their names can differ. +It sends the resolved `IsolationBackendRegistration` through the compute +protocol in validation/create specs and every start request, including recovery. +This requires the driver's `openshell.compute.isolation-backend-registration` +capability; unsupported drivers fail gateway startup. Missing selection or +`openshell-sandbox` retains the built-in runtime. The public sandbox spec has +no selector or endpoint. + +The driver must reject any registered backend it cannot place, before creating +workload resources. It converts the typed endpoint to the JSON registration +below and supplies the exact admitted name. Rust drivers can use +`openshell_core::isolation_registration::BackendRegistration::try_from` and +serialize the result in a `{"backends":[...]}` wrapper. This compute launch +handoff does not change the supervisor-to-backend Protocol 1.0 contract. +The gateway pins the admitted backend name in internal sandbox metadata and +rejects start/recovery under a different selection. Legacy records without +that binding retain `openshell-sandbox`. Endpoint addresses are not persisted. + +These files and flags are supervisor CLI inputs versioned with the supervisor +release, independently of the negotiated wire protocol. Both file formats +reject unknown fields. + +The operator supplies `--isolation-backends-file` to `openshell-supervisor`: + +```json +{ + "backends": [ + { + "name": "agent-substrate", + "endpoint": { + "kind": "unix", + "socket_path": "/run/agent-substrate/isolation.sock" + } + } + ] +} +``` + +TCP endpoints use `{"kind":"tcp","authority":"backend.example:443", +"addresses":["192.0.2.10:443"]}`. Addresses are operator-pinned concrete socket +addresses; the supervisor does not resolve workload-provided hostnames. +Vsock endpoints use `{"kind":"vsock","guest_cid":3,"port":47000}`. +Vsock connects to a guest CID of at least 3; wildcard CIDs and ports are invalid. +Registration names contain 1–128 ASCII letters, digits, `-`, `_`, or `.`, and +match exactly. Duplicate names, malformed endpoints, unknown +names, and replacement of `openshell-sandbox` fail startup. + +The trusted compute driver or orchestrator supplies: + +- `OPENSHELL_ADMITTED_ISOLATION_BACKEND`: the independently admitted backend + name, never copied from sandbox environment or request input. +- `--backend-descriptor-file`: the driver's opaque native bytes. The supervisor + passes them unchanged as `OpenBoundaryRequest.driver_descriptor`. +- `--boundary-launch-file`: JSON with `backend_name`, `sandbox_id`, `generation`, `session_id`, + `workload_identity`, `tls`, `resource_claims`, `outer_fence`, and optional + `host_gateway_ip`. These are trusted launch coordinates, separate from the + native descriptor. Its backend name must match the admitted name. No endpoint + is accepted in this file. +- `--auth-bundle-file`: the existing gateway-issued `SupervisorAuthBundle`. + +`tls` contains `server_name` and `trust_anchor_pem`. The driver pins the serving +identity for this generation, including for Unix sockets and vsock. Both TLS +fields must be nonempty, and the trust anchor must parse as certificates. TCP +`authority` controls HTTP routing; `tls.server_name` controls SNI and certificate +verification. The backend derives launch identity, claims and fence from its +native descriptor or trusted driver state; it must independently verify them +before returning confirmation. + +Both JSON files and native descriptor bytes are protected from workload writes. +The +registration file belongs to the operator and must be delivered outside +workload mounts. In-tree drivers continue to select `openshell-sandbox`; an +external compute driver stages the files and launches the stock supervisor. +The public sandbox API has no endpoint or isolation-backend selector. + +## Authentication and ordering + +TLS 1.3 verifies the generation-pinned serving certificate and negotiates HTTP/2 +with ALPN `h2`. Every control RPC except `GetCapabilities`, and every stream, +uses `authorization: Bearer `. The backend validates the token's +signature with the trusted gateway verification keys and its expiry, audience, +gateway ID, sandbox ID, runtime generation, and authorization epoch. +TLS authenticates the backend; the bearer authenticates the supervisor. + +The JWT header uses `alg: "EdDSA"`, an operator-provisioned `kid`, and +`typ: "openshell-sandbox-session+jwt"`. Required claims are: +`iss: "openshell-gateway:"`, +`sub: "spiffe://openshell/sandbox/"`, `aud: "openshell-sandbox"`, +`iat: i64`, `exp: i64`, `jti: UUID`, `sandbox_id: string`, +`runtime_generation: string`, `auth_epoch: nonzero u64`, and +`component: "openshell-supervisor"`. Verify Ed25519 signatures against trusted +keys; never select a key or issuer supplied by the workload. Reject unknown +claims, wrong component/type/audience/issuer/subject, or a different sandbox, +generation, or admitted authorization epoch. Allow at most 30 seconds of clock +skew. For exp != 0, require iat < exp, a lifetime at most one hour, and an +unexpired token (with that skew). exp == 0 is the gateway's configured +nonexpiring profile; accept it only when the operator enables that profile on +the backend. Launch session binding is checked by OpenBoundary and +confirmation; it is not a JWT claim. + +The logical supervisor instance UUID is separate from the launch session. +Attach pins it for the generation. Reject another instance, and never replace +an active connection with another of the same epoch until the old connection +has closed. Confirmation promotes a staged authenticated connection. Retain +these ownership rules across transport recovery and credential renewal. + +On **each physical connection**, negotiate `PeerMetadata`, call authenticated +`OpenBoundary`, then use its nonempty opaque handle as the first client frame +on every `Exchange` or `Mediate` stream. Handles are at most 128 UTF-8 bytes. +Each control RPC message fits within 64 KiB of encoded protobuf, including the +opaque driver descriptor and launch coordinates in `OpenBoundary`. +After validating the binding, the server sends initial gRPC metadata before +waiting for the JSON request. The client waits for that metadata to complete +stream opening; delaying it until a response payload causes a deadlock. +All subsequent client frames and all response frames contain `data`. Empty data +is allowed; it never binds a stream. Protobuf message boundaries have no meaning +inside the concatenated data bytes. A handle without authentication or without +opening on the current connection has no authority. + +Opening only validates/reserves a route. Image policy discovery is read-only. +`attach` binds the admitted context; `confirm` establishes enforcement; +`start_agent` is the only operation that makes the admitted agent runnable. +No untrusted instruction may execute before those checks complete. Exec and +forwarding require `Running`. Reject out-of-order operations without advancing +state. Only the same logical supervisor may recover its transport in the same +generation. A replacement supervisor needs a newly authorized launch. + +A backend must validate native enforcement evidence before issuing confirmation. +The shared client verifies common properties and their immutable launch binding; +it does not interpret an external backend's `backend_audit`. The built-in backend +continues to validate Linux-specific evidence. An external backend remains +responsible for the RFC 0012 lifecycle and failure invariants, including bounded +termination when required enforcement disappears. Backend selection is a trust +boundary, not a mechanism for accepting untrusted attestations. + +## Control framing and replay + +Each logical stream begins with one request and its matching response: + +```text +u32 big-endian UTF-8 JSON length | JSON bytes +``` + +The maximum JSON payload is 1,048,576 bytes. Integers use their complete wire +range; avoid conversion through floating-point numbers. Byte arrays in JSON +are arrays of integers in 0..255, not base64. Paths, IP addresses, socket +addresses, UUIDs, and SHA-256 digests are strings. Socket addresses use +`IPv4:port` or `[IPv6]:port`. Digests are 64 lowercase hex characters. +Optional values are JSON `null` unless explicitly omitted below. Map keys are +strings. Fields are required unless described as optional or defaulted. + +Request envelope: + +```json +{ + "request_id": "00000000-0000-4000-8000-000000000001", + "payload_digest": "", + "request": { "operation": "discover_policy" } +} +``` + +`request_id` is a cryptographically random UUID scoped to the runtime generation. +`exec_expires_at_unix_ms` is omitted for non-exec requests; exec requires an +absolute admission deadline, normally 30 seconds after request creation. +Host and backend wall clocks must be synchronized; exec deadlines have no skew +allowance. The deadline limits exec +admission/recovery, not the lifetime of an admitted process. + +The sender emits all request fields, including nulls and defaults. To compute +`payload_digest`, canonicalize the `request` object as transmitted, without +dropping or inventing fields. For exec, add `exec_expires_at_unix_ms` to a copy +of that object for hashing only. Recursively sort object keys by UTF-8 byte +order (equivalently, Unicode code point order). Serialize compact UTF-8 JSON +without whitespace. Escape quote, backslash, backspace, form feed, newline, +carriage return and tab as `\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`; +escape other control characters below U+0020 as lowercase `\u00xx`. Do not +escape non-ASCII characters or `/`. Integers use base-10 digits without leading +zeros; floating point values are not used. Hash these bytes with SHA-256. +Object order in the transmitted message is immaterial. See +[`vectors.json`](vectors.json) for independently generated request byte/digest examples +checked by Rust and the independent Python fixture. Frame hex provides one +valid serialization; its object key order is not normative. + +The backend validates the digest and records replayable mutations by generation +and request ID. The same ID and digest returns the original result without +repeating effects; the same ID with a different digest fails. Replayable requests +are attach, confirm, start_agent, update_provider_environment, exec, signal, +terminate, terminate_boundary, exec_signal, and resize. Non-exec mutation results +remain replayable for the generation. Exec IDs remain admitted until their +absolute deadline, independently of process/output retention. Expired execs fail +even if their ID was discarded. An unknown outcome after transport loss does +not authorize launching another exec under a new ID. + +Responses use `{"request_id":"","response":{"result":"..."}}`. +The result may be followed by stream data as specified below. Structured errors +use `{"result":"error","kind":"invalid|denied|unavailable|terminated|process", +"message":"..."}` and must not include secrets. + +## Request and response messages + +`operation` selects the request; `result` selects the response. Fields listed +below belong to that object alongside its tag. Empty field lists mean the tag +alone. IDs are opaque generation-scoped strings, never local process IDs. + +| Operation | Request fields | Success result and fields | +|---|---|---| +| `discover_policy` | — | `image_policy`: `yaml: string\|null`, `invalid: bool` | +| `attach` | `supervisor_instance_id: UUID`, `policy: Policy`, `resource_claims: map` | `attached`: `snapshot: SessionSnapshot` | +| `confirm` | — | `confirmed`: `confirmation: Confirmation` | +| `probe_provider_files` | — | `provider_files_supported` | +| `start_agent` | `sandbox_id: string`, `spec: AgentSpec`, `policy: Policy`, `ca_cert: string\|null`, `ca_bundle: string\|null`, `provider_env_revision: u64`, `provider_env: map`, `provider_files: map` (default `{}`) | `started`: `process_id: string`, `provider_env_revision: u64`, `provider_env_generation: u64` | +| `update_provider_environment` | `generation: u64`, `revision: u64`, `provider_env: map`, `provider_files: map` (default `{}`) | `provider_environment_updated`: `revision: u64`, `generation: u64`, `applied: bool` | +| `attach_process` | `process_id: string` | `process_attached`: `terminal: bool`; then process I/O | +| `wait` | `process_id: string` | `exited`: `status: ExitStatus` | +| `signal` | `process_id: string`, `signal: Signal` | `signaled` | +| `terminate` | `process_id: string` | `terminated` | +| `terminate_boundary` | — | `boundary_terminated` | +| `exec` | `spec: ExecSpec` | `exec_started`: `process_id: string`, `pty: bool`; then process I/O | +| `exec_signal` | `process_id: string`, `signal: Signal` | `signaled` | +| `resize` | `process_id: string`, `cols: u16`, `rows: u16` | `resized` | +| `loopback_connect` | `host: IP string`, `port: u16` | `port_connected`; then raw bidirectional bytes | +| `accept_network` | — | `network_connected`: `identity: BinaryIdentity`, `destination: socket address`, `socket: SocketMetadata`, `policy_generation: u64`, `timing: Timing`; then TCP decision/relay | +| `open_mediation` | —; sent on `Mediate` | `mediation_ready`; then persistent DNS framing | + +Image policy parse/read failure returns `invalid: true` or an error; it must +never be represented as missing policy. `attach` includes policy with the +supervisor's ready mediation listener. Replayed attach returns the owned session +snapshot without transferring ownership to another supervisor instance. +Termination acknowledgments require all targeted owned processes to be terminal. +`wait` returns a stable status, including on repeat calls. + +Before sending provider files, the supervisor probes support. Provider snapshots +replace the previous environment/files atomically. `generation` orders +publications within a session; stale publications must not replace newer ones. +`applied` is true for an installed publication or its exact replay. Existing +process environments remain unchanged; subsequent execs observe the installed +snapshot. Workload launch applies all controls before its first instruction. + +### Compound JSON types + +- **AgentSpec**: `program: string`, `args: string[]`, `workdir: string|null`, + `timeout_secs: u64`, `interactive: bool`. An empty program asks the backend to + resolve the image's default login shell. +- **ExecSpec**: `program: string`, `args: string[]`, `env: [string,string][]`, + `workdir: string|null`, `pty: bool`, `shell: ShellSpec|null` (default `null`), + `runtime_helper: "sftp"|null` (default `null`). +- **ShellSpec**: `command: string|null`, `login: bool`. The backend resolves the shell + against the workload image. Shell and runtime helper are mutually exclusive. +- **Policy**: `version: u32`, `read_only: string[]`, `read_write: string[]`, + `include_workdir: bool`, `network: "block"|"proxy"|"allow"`, + `proxy_addr: socket address|null`, `landlock: "best_effort"|"hard_requirement"`, + `run_as_user: string|null`, `run_as_group: string|null`. These wire names remain + historical; the external backend translates admitted constraints into its own + mechanisms and fails when it cannot enforce them. +- **Identity**: `uid: u32`, `gid: u32`, `supplementary_gids: u32[]`, + `source: string`, `resource_digest: string`. UID, GID, and groups are nonzero; + groups are sorted, unique, and exclude the primary GID. Source and resource + digest are nonempty driver-owned values. +- **Property**: `enforced: bool`, `mechanism: string`. Confirmation requires + `enforced: true` and a nonempty mechanism for each property. +- **Properties**: `filesystem_confinement: Property`, `egress_interception: + Property`, `request_attribution: Property`, `privilege_floor: Property`. +- **OuterFence**: `generation: string`, `established: string[]`, + `evidence_digest: SHA-256 string`. Established guarantees are exactly + `default_deny_egress`, `no_unmanaged_egress_path`, `revocation_verified`, and + `controller_loss_fails_closed`. The digest commits to + `u64be(UTF-8 generation length) || generation || validated native evidence`. + Serialize the guarantee array in the listed order. The enforcement owner + validates native evidence before projecting these guarantees. +- **Confirmation**: `generation: string`, `identity: Identity`, `properties: + Properties`, `authenticated_supervisor: bool`, `session_id: UUID`, + `outer_fence: OuterFence`, `runtime_exit_terminates_workload: bool`, + `resource_claims: map`, `backend_audit: any JSON`. Generation, + session, identity, claims, and fence must match trusted launch metadata; + both booleans must be true. The backend owns validation of its audit payload. +- **SessionSnapshot**: `generation: string`, `processes: ProcessSnapshot[]`. +- **ProcessSnapshot**: `process_id: string`, `kind: "main"|"exec"`, `terminal: + bool`, `status: ExitStatus|null`, `retained_output: OutputWindow`. +- **OutputWindow**: `first_sequence: u64`, `next_sequence: u64`, `truncated: bool`. +- **ExitStatus**: `{"kind":"exited","value":i32}` or + `{"kind":"signaled","value":i32}`. +- **Signal**: `"term"`, `"kill"`, `"int"`, or `"hup"`. +- **ExecutableIdentity**: `path: string`, `digest: SHA-256 string|null`. +- **BinaryIdentity**: `{"result":"resolved","executable":ExecutableIdentity, + "ancestors":ExecutableIdentity[],"cmdline_paths":string[]}` or + `{"result":"failed","message":string}`. Identity must come from trusted + backend observation; never substitute workload claims on resolution failure. +- **SocketMetadata**: `socket_cookie: u64`, `nonblocking: bool`, + `process_generation: u64`. +- **Timing**: `notification_to_queue_us: u64`, `queue_wait_us: u64`. + +## Process I/O, TCP relay, and DNS + +Process I/O uses `u8 channel | u32be payload length | payload`, with a maximum +payload of 65,536 bytes: + +| Channel | Direction | Meaning | +|---|---|---| +| 0 | supervisor → backend | stdin bytes | +| 1 | backend → supervisor | stdout bytes | +| 2 | backend → supervisor | stderr bytes (non-PTY only) | +| 3 | backend → supervisor | UTF-8 JSON ExitStatus | +| 4 | supervisor → backend | stdin closed; empty payload | +| 5 | supervisor → backend | UTF-8 JSON TCP decision | + +PTY sessions merge stderr into stdout. Send exit only after output drains; +closing stdin must not close the response direction. Relay and exec streams +must not starve control or DNS traffic. The reference transport supports 128 +concurrent streams, 256 KiB per-stream windows, and a 48 MiB connection window. + +After `network_connected`, the backend waits for channel 5. The JSON decision is +`"RelayReady"` or `{"Denied":"PolicyDenied|IdentityUnavailable| +InvalidDestination|ResourceExhausted|MediationUnavailable"}` (one exact enum +value, without `|`). Denial leaves the socket uncommitted; mediation loss denies +by default. `RelayReady` switches to raw bidirectional bytes, and does not bypass +subsequent L7 policy. Each TCP open owns an independent `Exchange` stream. + +After `mediation_ready`, DNS uses `u8 kind | u64be stream ID | u32be JSON length | +JSON`, with a maximum JSON payload of 262,144 bytes. Kind 5 carries a backend +query: `request: byte[]`, `transport: "Udp"|"Tcp"`, `identity: BinaryIdentity`, +`timing: Timing`. Kind 6 carries the matching supervisor result: +`{"result":"response","value":byte[]}` or +`{"result":"error","value":string}`. Stream IDs correlate independent queries +on the persistent channel. Malformed framing, unknown kinds, lost mediation, or +queue exhaustion fail closed; they never authorize direct DNS/egress. + +## Conformance and optional retention + +An external backend must exercise descriptor rejection, lifecycle ordering, +confirmation mismatch, exec replay/deadlines, provider publication, forwarding, +TCP/DNS decisions, connection recovery, credential rotation, and enforcement +loss in its actual driver placement. OpenShell's delegated adapter tests verify +opaque payload forwarding and generic evidence without a native Linux audit. +Its existing transport suites cover recovery, credential renewal, and flow +control; Docker lifecycle E2E verifies the built-in placement. Run +`mise run e2e:isolation-protocol` for the stock supervisor against an independent +Python implementation generated from the public protos. That fixture tests +interoperability and launch ordering, and simulates enforcement evidence with a +fixed test bearer. It does not exercise JWT validation or provide isolation. + +Suspend/resume is a separate optional capability described in +`isolation_backend.proto`. Protocol 1.0 implementations may return +`UNIMPLEMENTED` without advertising it. This registration path supports fresh +launches; it does not yet orchestrate state-preserving restoration. diff --git a/proto/sandbox_protocol/v1/vectors.json b/proto/sandbox_protocol/v1/vectors.json new file mode 100644 index 0000000000..591fa478a9 --- /dev/null +++ b/proto/sandbox_protocol/v1/vectors.json @@ -0,0 +1,282 @@ +{ + "version": "1.0", + "requests": [ + { + "canonical_request": "{\"operation\":\"discover_policy\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000001", + "payload_digest": "f316ae675411d6d0fc8d903e8ec9fbd7c4cfcff90ba4b9a0ea42ee12e6ecb393", + "request": { + "operation": "discover_policy" + } + }, + "frame_hex": "000000b37b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303031222c227061796c6f61645f646967657374223a2266333136616536373534313164366430666338643930336538656339666264376334636663666639306261346239613065613432656531326536656362333933222c2272657175657374223a7b226f7065726174696f6e223a22646973636f7665725f706f6c696379227d7d" + }, + { + "canonical_request": "{\"operation\":\"attach\",\"policy\":{\"include_workdir\":true,\"landlock\":\"hard_requirement\",\"network\":\"block\",\"proxy_addr\":null,\"read_only\":[\"/usr\"],\"read_write\":[\"/sandbox\"],\"run_as_group\":null,\"run_as_user\":null,\"version\":1},\"resource_claims\":{\"actor\":\"external-1\",\"quote\\\"\\\\\\n\":\"line\\n\\b\\f\\r\\t\\u0000\\u001f / 雪 😀\",\"\":\"bmp-last\",\"𐀀\":\"astral-first\"},\"supervisor_instance_id\":\"00000000-0000-4000-8000-000000000002\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000002", + "payload_digest": "6a398dbe0829e4833d9135b8d11e3913aee5a02eb90480289b5250ed939bfe13", + "request": { + "operation": "attach", + "supervisor_instance_id": "00000000-0000-4000-8000-000000000002", + "policy": { + "version": 1, + "read_only": [ + "/usr" + ], + "read_write": [ + "/sandbox" + ], + "include_workdir": true, + "network": "block", + "proxy_addr": null, + "landlock": "hard_requirement", + "run_as_user": null, + "run_as_group": null + }, + "resource_claims": { + "actor": "external-1", + "": "bmp-last", + "𐀀": "astral-first", + "quote\"\\\n": "line\n\b\f\r\t\u0000\u001f / 雪 😀" + } + } + }, + "frame_hex": "000002367b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303032222c227061796c6f61645f646967657374223a2236613339386462653038323965343833336439313335623864313165333931336165653561303265623930343830323839623532353065643933396266653133222c2272657175657374223a7b226f7065726174696f6e223a22617474616368222c2273757065727669736f725f696e7374616e63655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303032222c22706f6c696379223a7b2276657273696f6e223a312c22726561645f6f6e6c79223a5b222f757372225d2c22726561645f7772697465223a5b222f73616e64626f78225d2c22696e636c7564655f776f726b646972223a747275652c226e6574776f726b223a22626c6f636b222c2270726f78795f61646472223a6e756c6c2c226c616e646c6f636b223a22686172645f726571756972656d656e74222c2272756e5f61735f75736572223a6e756c6c2c2272756e5f61735f67726f7570223a6e756c6c7d2c227265736f757263655f636c61696d73223a7b226163746f72223a2265787465726e616c2d31222c22ee8080223a22626d702d6c617374222c22f0908080223a2261737472616c2d6669727374222c2271756f74655c225c5c5c6e223a226c696e655c6e5c625c665c725c745c75303030305c7530303166202f20e99baa20f09f9880227d7d7d" + }, + { + "canonical_request": "{\"operation\":\"confirm\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000003", + "payload_digest": "c47c155e09b02a1684c146535ab246541a4775d433a91576130bf1a33462b67e", + "request": { + "operation": "confirm" + } + }, + "frame_hex": "000000ab7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303033222c227061796c6f61645f646967657374223a2263343763313535653039623032613136383463313436353335616232343635343161343737356434333361393135373631333062663161333334363262363765222c2272657175657374223a7b226f7065726174696f6e223a22636f6e6669726d227d7d" + }, + { + "canonical_request": "{\"operation\":\"probe_provider_files\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000004", + "payload_digest": "49d3b528d28661e09c4ac1534e4e6f2f2b9e7f8ee5bdd2fd2b6c0b7619392709", + "request": { + "operation": "probe_provider_files" + } + }, + "frame_hex": "000000b87b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303034222c227061796c6f61645f646967657374223a2234396433623532386432383636316530396334616331353334653465366632663262396537663865653562646432666432623663306237363139333932373039222c2272657175657374223a7b226f7065726174696f6e223a2270726f62655f70726f76696465725f66696c6573227d7d" + }, + { + "canonical_request": "{\"ca_bundle\":null,\"ca_cert\":null,\"operation\":\"start_agent\",\"policy\":{\"include_workdir\":true,\"landlock\":\"hard_requirement\",\"network\":\"block\",\"proxy_addr\":null,\"read_only\":[\"/usr\"],\"read_write\":[\"/sandbox\"],\"run_as_group\":null,\"run_as_user\":null,\"version\":1},\"provider_env\":{\"A\":\"first\",\"Z\":\"last\"},\"provider_env_revision\":1,\"provider_files\":{},\"sandbox_id\":\"sandbox-1\",\"spec\":{\"args\":[],\"interactive\":false,\"program\":\"/bin/true\",\"timeout_secs\":5,\"workdir\":\"/sandbox\"}}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000005", + "payload_digest": "d330c975b9c419ed32b1c98976a041caa165293ecd3078dcbb3c4b3ff5014c0b", + "request": { + "operation": "start_agent", + "sandbox_id": "sandbox-1", + "spec": { + "program": "/bin/true", + "args": [], + "workdir": "/sandbox", + "timeout_secs": 5, + "interactive": false + }, + "policy": { + "version": 1, + "read_only": [ + "/usr" + ], + "read_write": [ + "/sandbox" + ], + "include_workdir": true, + "network": "block", + "proxy_addr": null, + "landlock": "hard_requirement", + "run_as_user": null, + "run_as_group": null + }, + "ca_cert": null, + "ca_bundle": null, + "provider_env_revision": 1, + "provider_env": { + "Z": "last", + "A": "first" + }, + "provider_files": {} + } + }, + "frame_hex": "000002677b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303035222c227061796c6f61645f646967657374223a2264333330633937356239633431396564333262316339383937366130343163616131363532393365636433303738646362623363346233666635303134633062222c2272657175657374223a7b226f7065726174696f6e223a2273746172745f6167656e74222c2273616e64626f785f6964223a2273616e64626f782d31222c2273706563223a7b2270726f6772616d223a222f62696e2f74727565222c2261726773223a5b5d2c22776f726b646972223a222f73616e64626f78222c2274696d656f75745f73656373223a352c22696e746572616374697665223a66616c73657d2c22706f6c696379223a7b2276657273696f6e223a312c22726561645f6f6e6c79223a5b222f757372225d2c22726561645f7772697465223a5b222f73616e64626f78225d2c22696e636c7564655f776f726b646972223a747275652c226e6574776f726b223a22626c6f636b222c2270726f78795f61646472223a6e756c6c2c226c616e646c6f636b223a22686172645f726571756972656d656e74222c2272756e5f61735f75736572223a6e756c6c2c2272756e5f61735f67726f7570223a6e756c6c7d2c2263615f63657274223a6e756c6c2c2263615f62756e646c65223a6e756c6c2c2270726f76696465725f656e765f7265766973696f6e223a312c2270726f76696465725f656e76223a7b225a223a226c617374222c2241223a226669727374227d2c2270726f76696465725f66696c6573223a7b7d7d7d" + }, + { + "canonical_request": "{\"generation\":9007199254740993,\"operation\":\"update_provider_environment\",\"provider_env\":{},\"provider_files\":{},\"revision\":2}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000006", + "payload_digest": "d927db9193ba4efe4c4b2d19242b1a03d3fea01fce9bbba0e9871cea19f3b647", + "request": { + "operation": "update_provider_environment", + "generation": 9007199254740993, + "revision": 2, + "provider_env": {}, + "provider_files": {} + } + }, + "frame_hex": "000001107b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303036222c227061796c6f61645f646967657374223a2264393237646239313933626134656665346334623264313932343262316130336433666561303166636539626262613065393837316365613139663362363437222c2272657175657374223a7b226f7065726174696f6e223a227570646174655f70726f76696465725f656e7669726f6e6d656e74222c2267656e65726174696f6e223a393030373139393235343734303939332c227265766973696f6e223a322c2270726f76696465725f656e76223a7b7d2c2270726f76696465725f66696c6573223a7b7d7d7d" + }, + { + "canonical_request": "{\"operation\":\"attach_process\",\"process_id\":\"generation-1:main\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000007", + "payload_digest": "09735dc472b7fb71d3034b7d0e801f56618027ccdb2ba1d1850a29faa516b11f", + "request": { + "operation": "attach_process", + "process_id": "generation-1:main" + } + }, + "frame_hex": "000000d37b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303037222c227061796c6f61645f646967657374223a2230393733356463343732623766623731643330333462376430653830316635363631383032376363646232626131643138353061323966616135313662313166222c2272657175657374223a7b226f7065726174696f6e223a226174746163685f70726f63657373222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e227d7d" + }, + { + "canonical_request": "{\"operation\":\"wait\",\"process_id\":\"generation-1:main\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000008", + "payload_digest": "e61da144ef7e66274658d99d77a8155d50a935510f5489d54465ca4c689d05db", + "request": { + "operation": "wait", + "process_id": "generation-1:main" + } + }, + "frame_hex": "000000c97b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303038222c227061796c6f61645f646967657374223a2265363164613134346566376536363237343635386439396437376138313535643530613933353531306635343839643534343635636134633638396430356462222c2272657175657374223a7b226f7065726174696f6e223a2277616974222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e227d7d" + }, + { + "canonical_request": "{\"operation\":\"signal\",\"process_id\":\"generation-1:main\",\"signal\":\"term\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000009", + "payload_digest": "d91abab7d4835262bdada10f98d61994a9de5d6edfee33853c48d6952c08fc51", + "request": { + "operation": "signal", + "process_id": "generation-1:main", + "signal": "term" + } + }, + "frame_hex": "000000db7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303039222c227061796c6f61645f646967657374223a2264393161626162376434383335323632626461646131306639386436313939346139646535643665646665653333383533633438643639353263303866633531222c2272657175657374223a7b226f7065726174696f6e223a227369676e616c222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e222c227369676e616c223a227465726d227d7d" + }, + { + "canonical_request": "{\"operation\":\"terminate\",\"process_id\":\"generation-1:main\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000010", + "payload_digest": "d9197927a320eaeecd06e3b83bfbe9b8d1516479b7561d5125b0530e6a1549ad", + "request": { + "operation": "terminate", + "process_id": "generation-1:main" + } + }, + "frame_hex": "000000ce7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303130222c227061796c6f61645f646967657374223a2264393139373932376133323065616565636430366533623833626662653962386431353136343739623735363164353132356230353330653661313534396164222c2272657175657374223a7b226f7065726174696f6e223a227465726d696e617465222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e227d7d" + }, + { + "canonical_request": "{\"operation\":\"terminate_boundary\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000011", + "payload_digest": "fcb65e1ddb606a79abbbad448847b833e4135ca9cc2f9f8e455fbfc9c3837cc2", + "request": { + "operation": "terminate_boundary" + } + }, + "frame_hex": "000000b67b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303131222c227061796c6f61645f646967657374223a2266636236356531646462363036613739616262626164343438383437623833336534313335636139636332663966386534353566626663396333383337636332222c2272657175657374223a7b226f7065726174696f6e223a227465726d696e6174655f626f756e64617279227d7d" + }, + { + "canonical_request": "{\"exec_expires_at_unix_ms\":1800000000000,\"operation\":\"exec\",\"spec\":{\"args\":[\"héllo\"],\"env\":[[\"LANG\",\"C.UTF-8\"],[\"SPECIAL\",\"quote\\\" backslash\\\\ newline\\n NUL\\u0000 emoji😀\"]],\"program\":\"/bin/echo\",\"pty\":false,\"runtime_helper\":null,\"shell\":null,\"workdir\":null}}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000012", + "exec_expires_at_unix_ms": 1800000000000, + "payload_digest": "a269e736f12f736c0c17bd19fc8ff5fb0fd700dd3e04379db81ab961c23c0878", + "request": { + "operation": "exec", + "spec": { + "program": "/bin/echo", + "args": [ + "héllo" + ], + "shell": null, + "runtime_helper": null, + "env": [ + [ + "LANG", + "C.UTF-8" + ], + [ + "SPECIAL", + "quote\" backslash\\ newline\n NUL\u0000 emoji😀" + ] + ], + "workdir": null, + "pty": false + } + } + }, + "frame_hex": "0000019a7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303132222c22657865635f657870697265735f61745f756e69785f6d73223a313830303030303030303030302c227061796c6f61645f646967657374223a2261323639653733366631326637333663306331376264313966633866663566623066643730306464336530343337396462383161623936316332336330383738222c2272657175657374223a7b226f7065726174696f6e223a2265786563222c2273706563223a7b2270726f6772616d223a222f62696e2f6563686f222c2261726773223a5b2268c3a96c6c6f225d2c227368656c6c223a6e756c6c2c2272756e74696d655f68656c706572223a6e756c6c2c22656e76223a5b5b224c414e47222c22432e5554462d38225d2c5b225350454349414c222c2271756f74655c22206261636b736c6173685c5c206e65776c696e655c6e204e554c5c753030303020656d6f6a69f09f9880225d5d2c22776f726b646972223a6e756c6c2c22707479223a66616c73657d7d7d" + }, + { + "canonical_request": "{\"operation\":\"exec_signal\",\"process_id\":\"generation-1:exec\",\"signal\":\"kill\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000013", + "payload_digest": "bc80382030d743852845bc35c200f7c9c51e76c5369acca6024f3d69b6d8bdb7", + "request": { + "operation": "exec_signal", + "process_id": "generation-1:exec", + "signal": "kill" + } + }, + "frame_hex": "000000e07b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303133222c227061796c6f61645f646967657374223a2262633830333832303330643734333835323834356263333563323030663763396335316537366335333639616363613630323466336436396236643862646237222c2272657175657374223a7b226f7065726174696f6e223a22657865635f7369676e616c222c2270726f636573735f6964223a2267656e65726174696f6e2d313a65786563222c227369676e616c223a226b696c6c227d7d" + }, + { + "canonical_request": "{\"cols\":80,\"operation\":\"resize\",\"process_id\":\"generation-1:main\",\"rows\":24}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000014", + "payload_digest": "f51f875abe67b01093312c0536b7686b30a47ce3ea1bcfcd4206052fe44db356", + "request": { + "operation": "resize", + "process_id": "generation-1:main", + "cols": 80, + "rows": 24 + } + }, + "frame_hex": "000000df7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303134222c227061796c6f61645f646967657374223a2266353166383735616265363762303130393333313263303533366237363836623330613437636533656131626366636434323036303532666534346462333536222c2272657175657374223a7b226f7065726174696f6e223a22726573697a65222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e222c22636f6c73223a38302c22726f7773223a32347d7d" + }, + { + "canonical_request": "{\"host\":\"127.0.0.1\",\"operation\":\"loopback_connect\",\"port\":8080}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000015", + "payload_digest": "62c8b23330ea3c04bc820acdc50e77286b7439d541f626ae2c35ad3e3dc023d4", + "request": { + "operation": "loopback_connect", + "host": "127.0.0.1", + "port": 8080 + } + }, + "frame_hex": "000000d37b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303135222c227061796c6f61645f646967657374223a2236326338623233333330656133633034626338323061636463353065373732383662373433396435343166363236616532633335616433653364633032336434222c2272657175657374223a7b226f7065726174696f6e223a226c6f6f706261636b5f636f6e6e656374222c22686f7374223a223132372e302e302e31222c22706f7274223a383038307d7d" + }, + { + "canonical_request": "{\"operation\":\"open_mediation\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000016", + "payload_digest": "ddc0eb31d09a4e3f8528f7c9e075d5f252739b6a0ff364995b42f52181249eb4", + "request": { + "operation": "open_mediation" + } + }, + "frame_hex": "000000b27b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303136222c227061796c6f61645f646967657374223a2264646330656233316430396134653366383532386637633965303735643566323532373339623661306666333634393935623432663532313831323439656234222c2272657175657374223a7b226f7065726174696f6e223a226f70656e5f6d6564696174696f6e227d7d" + }, + { + "canonical_request": "{\"operation\":\"accept_network\"}", + "envelope": { + "request_id": "00000000-0000-4000-8000-000000000017", + "payload_digest": "cc7045e9b9efe3b32d8082d881c8d872aabbc6c910edf20f9d3a9bc9e7d31f2e", + "request": { + "operation": "accept_network" + } + }, + "frame_hex": "000000b27b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303137222c227061796c6f61645f646967657374223a2263633730343565396239656665336233326438303832643838316338643837326161626263366339313065646632306639643361396263396537643331663265222c2272657175657374223a7b226f7065726174696f6e223a226163636570745f6e6574776f726b227d7d" + } + ] +} From 2752ca630a65cbe09825f29553351053bcb5ec62 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 9 Oct 2026 08:43:10 -0700 Subject: [PATCH 02/24] feat(isolation): expose trusted launch and registry data contracts Signed-off-by: Drew Newberry --- .../src/delegated.rs | 30 ++++++++++++ crates/openshell-sandbox-backend/src/lib.rs | 1 + .../openshell-supervisor/src/backend_setup.rs | 8 ++- .../src/backend_setup/registration.rs | 13 +++++ crates/openshell-supervisor/src/lib.rs | 49 ++++++++++--------- 5 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 crates/openshell-sandbox-backend/src/delegated.rs create mode 100644 crates/openshell-supervisor/src/backend_setup/registration.rs diff --git a/crates/openshell-sandbox-backend/src/delegated.rs b/crates/openshell-sandbox-backend/src/delegated.rs new file mode 100644 index 0000000000..8cde5f319d --- /dev/null +++ b/crates/openshell-sandbox-backend/src/delegated.rs @@ -0,0 +1,30 @@ +// 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::{OuterFenceGuarantees, ResolvedWorkloadIdentity}; +use serde::{Deserialize, Serialize}; + +use crate::boundary_protocol::SandboxTlsClientConfig; + +/// 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 outer_fence: OuterFenceGuarantees, +} diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 9ef2b05964..b41653e83e 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -12,6 +12,7 @@ pub mod audit; pub mod boundary_protocol; +pub mod delegated; pub mod mediation; mod runtime; pub mod sandbox_auth; diff --git a/crates/openshell-supervisor/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index c312ca6a36..627100e6be 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}; + use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -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>>, 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..b3dede3e4a --- /dev/null +++ b/crates/openshell-supervisor/src/backend_setup/registration.rs @@ -0,0 +1,13 @@ +// 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 serde::Deserialize; + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackendRegistrations { + pub backends: Vec, +} diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index baa918bd47..73e4baa544 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; @@ -715,33 +715,34 @@ 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, - 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 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 { @@ -2213,7 +2214,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, } From ea5db961e272084277b22aa8701d485c2b626cce Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 12:14:29 -0700 Subject: [PATCH 03/24] refactor(isolation): name the required capability sandbox Signed-off-by: Drew Newberry --- proto/isolation_backend.proto | 2 +- proto/sandbox_protocol/v1/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto index a8fd91007e..74036d2579 100644 --- a/proto/isolation_backend.proto +++ b/proto/isolation_backend.proto @@ -13,7 +13,7 @@ import "options.proto"; // driver provisioning. Workload control, network mediation, and byte streams // remain on the Sandbox Protocol. // PeerMetadata negotiates the base contract and optional suspend/resume support. -// Version 1.0 requires openshell.isolation.contract on both peers and includes +// Version 1.0 requires openshell.isolation.sandbox on both peers and includes // boundary routing. Optional state retention support advertises // openshell.isolation.suspend-resume. Add optional capabilities // with compatible minor versions; incompatible semantics require a major bump. diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md index 9be7e1cedd..cbf9414b78 100644 --- a/proto/sandbox_protocol/v1/README.md +++ b/proto/sandbox_protocol/v1/README.md @@ -14,7 +14,7 @@ No OpenShell guest executable or linked backend implementation is required. The negotiated isolation version covers **both** the control RPCs and the framing and JSON messages below. Version 1.0 requires -`openshell.isolation.contract` on both peers. An incompatible change to a +`openshell.isolation.sandbox` on both peers. An incompatible change to a message, framing, authentication, or lifecycle guarantee requires a major bump. A minor revision may add optional capabilities; it must preserve all 1.0 messages and behavior. A new operation requires an advertised capability From f286a97694e2fe769e4436be9df0f7472cfb2f7e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 12:31:59 -0700 Subject: [PATCH 04/24] refactor(sandbox): align protocol and compute capability names Signed-off-by: Drew Newberry --- crates/openshell-core/src/extension_protocol.rs | 3 +-- proto/compute_driver.proto | 2 +- proto/isolation_backend.proto | 4 ++-- proto/sandbox_protocol/v1/README.md | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index b56498b53e..a46b3016b0 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -20,8 +20,7 @@ pub const PROTOCOL_MINOR: u32 = 0; 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_REGISTRATION: &str = - "openshell.compute.isolation-backend-registration"; +pub const COMPUTE_SANDBOX_BACKEND: &str = "openshell.compute.sandbox-backend"; const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index d27d7246e5..e04ca0832f 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -212,7 +212,7 @@ message DriverSandboxSpec { 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-registration. + // runtime. Requires openshell.compute.sandbox-backend. IsolationBackendRegistration isolation_backend = 17; } diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto index 74036d2579..51b367c2e6 100644 --- a/proto/isolation_backend.proto +++ b/proto/isolation_backend.proto @@ -13,9 +13,9 @@ import "options.proto"; // driver provisioning. Workload control, network mediation, and byte streams // remain on the Sandbox Protocol. // PeerMetadata negotiates the base contract and optional suspend/resume support. -// Version 1.0 requires openshell.isolation.sandbox on both peers and includes +// Version 1.0 requires openshell.sandbox.protocol on both peers and includes // boundary routing. Optional state retention support advertises -// openshell.isolation.suspend-resume. Add optional capabilities +// openshell.sandbox.suspend-resume. Add optional capabilities // with compatible minor versions; incompatible semantics require a major bump. service IsolationBackend { // Negotiate the isolation contract before opening a boundary. This call diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md index cbf9414b78..8ddc594d0a 100644 --- a/proto/sandbox_protocol/v1/README.md +++ b/proto/sandbox_protocol/v1/README.md @@ -14,7 +14,7 @@ No OpenShell guest executable or linked backend implementation is required. The negotiated isolation version covers **both** the control RPCs and the framing and JSON messages below. Version 1.0 requires -`openshell.isolation.sandbox` on both peers. An incompatible change to a +`openshell.sandbox.protocol` on both peers. An incompatible change to a message, framing, authentication, or lifecycle guarantee requires a major bump. A minor revision may add optional capabilities; it must preserve all 1.0 messages and behavior. A new operation requires an advertised capability @@ -32,7 +32,7 @@ Selecting `openshell.gateway.compute_driver` selects both the driver and its backend; their names can differ. It sends the resolved `IsolationBackendRegistration` through the compute protocol in validation/create specs and every start request, including recovery. -This requires the driver's `openshell.compute.isolation-backend-registration` +This requires the driver's `openshell.compute.sandbox-backend` capability; unsupported drivers fail gateway startup. Missing selection or `openshell-sandbox` retains the built-in runtime. The public sandbox spec has no selector or endpoint. From eb8bc7e42205fadce5a8e7a81c79829256ab7d7c Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 12:55:08 -0700 Subject: [PATCH 05/24] refactor(sandbox): finalize public protocol contract names Signed-off-by: Drew Newberry --- .../src/isolation_registration.rs | 20 +++++++++---------- crates/openshell-core/src/proto/mod.rs | 9 +++------ crates/openshell-server/src/storage_proto.rs | 8 ++++---- proto/README.md | 2 +- proto/compute_driver.proto | 14 ++++++------- ...on_backend.proto => sandbox_backend.proto} | 10 +++++----- proto/sandbox_protocol.proto | 8 ++++---- proto/sandbox_protocol/v1/README.md | 8 ++++---- 8 files changed, 38 insertions(+), 41 deletions(-) rename proto/{isolation_backend.proto => sandbox_backend.proto} (95%) diff --git a/crates/openshell-core/src/isolation_registration.rs b/crates/openshell-core/src/isolation_registration.rs index 1633dbb742..4fc8b56c53 100644 --- a/crates/openshell-core/src/isolation_registration.rs +++ b/crates/openshell-core/src/isolation_registration.rs @@ -90,13 +90,13 @@ pub fn validate_registrations(registrations: &[BackendRegistration]) -> Result<( Ok(()) } -impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::IsolationBackendRegistration { +impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::SandboxBackendRegistration { type Error = &'static str; fn try_from(registration: &BackendRegistration) -> Result { use crate::proto::compute::v1::{ - IsolationBackendTcpEndpoint, IsolationBackendVsockEndpoint, - isolation_backend_registration::Endpoint, + SandboxBackendTcpEndpoint, SandboxBackendVsockEndpoint, + sandbox_backend_registration::Endpoint, }; validate_backend_name(®istration.name)?; registration.endpoint.validate()?; @@ -110,12 +110,12 @@ impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::IsolationBacke SandboxTransport::Tcp { authority, addresses, - } => Endpoint::Tcp(IsolationBackendTcpEndpoint { + } => Endpoint::Tcp(SandboxBackendTcpEndpoint { authority: authority.clone(), addresses: addresses.iter().map(ToString::to_string).collect(), }), SandboxTransport::Vsock { guest_cid, port } => { - Endpoint::Vsock(IsolationBackendVsockEndpoint { + Endpoint::Vsock(SandboxBackendVsockEndpoint { guest_cid: *guest_cid, port: *port, }) @@ -128,13 +128,13 @@ impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::IsolationBacke } } -impl TryFrom<&crate::proto::compute::v1::IsolationBackendRegistration> for BackendRegistration { +impl TryFrom<&crate::proto::compute::v1::SandboxBackendRegistration> for BackendRegistration { type Error = &'static str; fn try_from( - registration: &crate::proto::compute::v1::IsolationBackendRegistration, + registration: &crate::proto::compute::v1::SandboxBackendRegistration, ) -> Result { - use crate::proto::compute::v1::isolation_backend_registration::Endpoint; + use crate::proto::compute::v1::sandbox_backend_registration::Endpoint; let endpoint = match registration .endpoint .as_ref() @@ -172,7 +172,7 @@ impl TryFrom<&crate::proto::compute::v1::IsolationBackendRegistration> for Backe #[cfg(test)] mod tests { use super::*; - use crate::proto::compute::v1::IsolationBackendRegistration; + use crate::proto::compute::v1::SandboxBackendRegistration; #[test] fn operator_routes_survive_compute_protocol_and_supervisor_json() { @@ -196,7 +196,7 @@ mod tests { name: "agent-substrate".into(), endpoint, }; - let wire = IsolationBackendRegistration::try_from(&original).unwrap(); + let wire = SandboxBackendRegistration::try_from(&original).unwrap(); let decoded = BackendRegistration::try_from(&wire).unwrap(); assert_eq!(decoded, original); let json = serde_json::to_string(&decoded).unwrap(); diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index f17126e8e2..194a69e44d 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -33,6 +33,9 @@ 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; @@ -47,12 +50,6 @@ pub mod extension { pub use super::generated::openshell::extension::v1; } -pub mod isolation { - pub mod backend { - pub use super::super::generated::openshell::isolation::backend::v1; - } -} - #[allow( clippy::all, clippy::pedantic, diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 9bbb42a205..2db9baa20e 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -529,8 +529,8 @@ mod tests { for service in &file.service { let service_name = service.name.as_deref().expect("service name"); compiled_method_count += service.method.len(); - if package == "openshell.isolation.backend.v1" { - assert_eq!(service_name, "IsolationBackend"); + if package == "openshell.sandbox.backend.v1" { + assert_eq!(service_name, "SandboxBackend"); isolation_methods.extend( service .method @@ -539,7 +539,7 @@ mod tests { ); } if package == "openshell.sandbox.protocol.v1" { - assert_eq!(service_name, "DelegatedIsolationBoundary"); + assert_eq!(service_name, "SandboxBoundary"); sandbox_protocol_methods.extend(service.method.iter().map(|method| { method .name @@ -573,7 +573,7 @@ mod tests { assert_eq!( isolation_methods, BTreeSet::from([ - "GetCapabilities", + "Describe", "OpenBoundary", "ResumeBoundary", "SuspendBoundary" diff --git a/proto/README.md b/proto/README.md index d08bc6518d..f8a8b3bd92 100644 --- a/proto/README.md +++ b/proto/README.md @@ -88,7 +88,7 @@ identifies the service being exposed. ## Isolation backend protocol -[`isolation_backend.proto`](isolation_backend.proto) and +[`sandbox_backend.proto`](sandbox_backend.proto) and [`sandbox_protocol.proto`](sandbox_protocol.proto) are supervisor-to-backend contracts, not gateway RPCs. Backend authors can generate bindings independently and implement [Sandbox Protocol 1.0](sandbox_protocol/v1/README.md). Its negotiated diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index e04ca0832f..1b7bbe95e7 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -213,7 +213,7 @@ message DriverSandboxSpec { // Operator-selected external isolation backend. Never derived from the // public sandbox spec or workload environment. Omitted for the built-in // runtime. Requires openshell.compute.sandbox-backend. - IsolationBackendRegistration isolation_backend = 17; + SandboxBackendRegistration isolation_backend = 17; } // Trusted operator registration delivered to the compute driver on every @@ -221,24 +221,24 @@ message DriverSandboxSpec { // 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 { +message SandboxBackendRegistration { string name = 1; oneof endpoint { // Absolute path in the supervisor's filesystem namespace. string unix_socket_path = 2; - IsolationBackendTcpEndpoint tcp = 3; - IsolationBackendVsockEndpoint vsock = 4; + SandboxBackendTcpEndpoint tcp = 3; + SandboxBackendVsockEndpoint vsock = 4; } } -message IsolationBackendTcpEndpoint { +message SandboxBackendTcpEndpoint { // 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 { +message SandboxBackendVsockEndpoint { uint32 guest_cid = 1; uint32 port = 2; } @@ -461,7 +461,7 @@ message StartSandboxRequest { // 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; + SandboxBackendRegistration isolation_backend = 6; } message StartSandboxResponse { diff --git a/proto/isolation_backend.proto b/proto/sandbox_backend.proto similarity index 95% rename from proto/isolation_backend.proto rename to proto/sandbox_backend.proto index 51b367c2e6..391fe83d1f 100644 --- a/proto/isolation_backend.proto +++ b/proto/sandbox_backend.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package openshell.isolation.backend.v1; +package openshell.sandbox.backend.v1; import "extension.proto"; import "options.proto"; @@ -17,10 +17,10 @@ import "options.proto"; // boundary routing. Optional state retention support advertises // openshell.sandbox.suspend-resume. Add optional capabilities // with compatible minor versions; incompatible semantics require a major bump. -service IsolationBackend { +service SandboxBackend { // Negotiate the isolation contract before opening a boundary. This call // conveys no sandbox credentials or driver descriptor. - rpc GetCapabilities(GetIsolationCapabilitiesRequest) returns (GetIsolationCapabilitiesResponse); + rpc Describe(DescribeSandboxBackendRequest) returns (DescribeSandboxBackendResponse); // Bind a driver-provisioned resource to one fresh sandbox launch. The // caller credential must match sandbox_id and generation_id. This call must @@ -49,11 +49,11 @@ service IsolationBackend { rpc ResumeBoundary(ResumeBoundaryRequest) returns (OpenBoundaryResponse); } -message GetIsolationCapabilitiesRequest { +message DescribeSandboxBackendRequest { openshell.extension.v1.PeerMetadata supervisor = 1; } -message GetIsolationCapabilitiesResponse { +message DescribeSandboxBackendResponse { openshell.extension.v1.PeerMetadata backend = 1; } diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 95c8aa502e..1e83880b55 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -10,12 +10,12 @@ package openshell.sandbox.protocol.v1; // handle against the authenticated sandbox and generation before accepting any // data. Subsequent data fragments carry the existing Sandbox Protocol bytes. // OpenBoundary returns the handle before policy discovery and lifecycle traffic. -service DelegatedIsolationBoundary { - rpc Exchange(stream DelegatedBoundaryChunk) returns (stream DelegatedBoundaryChunk); - rpc Mediate(stream DelegatedBoundaryChunk) returns (stream DelegatedBoundaryChunk); +service SandboxBoundary { + rpc Exchange(stream BoundaryChunk) returns (stream BoundaryChunk); + rpc Mediate(stream BoundaryChunk) returns (stream BoundaryChunk); } -message DelegatedBoundaryChunk { +message BoundaryChunk { oneof payload { // Nonempty handle required as the first client frame; forbidden on later frames. string boundary_id = 1; diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md index 8ddc594d0a..54f8259c19 100644 --- a/proto/sandbox_protocol/v1/README.md +++ b/proto/sandbox_protocol/v1/README.md @@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0 This is the wire contract for an isolation backend implemented outside the OpenShell tree. An implementation may use any language and owns its native isolation mechanisms. Generate gRPC bindings from -[`isolation_backend.proto`](../../isolation_backend.proto), +[`sandbox_backend.proto`](../../sandbox_backend.proto), [`sandbox_protocol.proto`](../../sandbox_protocol.proto), and their imports. No OpenShell guest executable or linked backend implementation is required. @@ -30,7 +30,7 @@ The gateway reads `isolation_backend` from the selected compute driver's `openshell.supervisor.isolation_backends` in operator-owned `gateway.toml`. Selecting `openshell.gateway.compute_driver` selects both the driver and its backend; their names can differ. -It sends the resolved `IsolationBackendRegistration` through the compute +It sends the resolved `SandboxBackendRegistration` through the compute protocol in validation/create specs and every start request, including recovery. This requires the driver's `openshell.compute.sandbox-backend` capability; unsupported drivers fail gateway startup. Missing selection or @@ -107,7 +107,7 @@ The public sandbox API has no endpoint or isolation-backend selector. ## Authentication and ordering TLS 1.3 verifies the generation-pinned serving certificate and negotiates HTTP/2 -with ALPN `h2`. Every control RPC except `GetCapabilities`, and every stream, +with ALPN `h2`. Every control RPC except `Describe`, and every stream, uses `authorization: Bearer `. The backend validates the token's signature with the trusted gateway verification keys and its expiry, audience, gateway ID, sandbox ID, runtime generation, and authorization epoch. @@ -370,6 +370,6 @@ interoperability and launch ordering, and simulates enforcement evidence with a fixed test bearer. It does not exercise JWT validation or provide isolation. Suspend/resume is a separate optional capability described in -`isolation_backend.proto`. Protocol 1.0 implementations may return +`sandbox_backend.proto`. Protocol 1.0 implementations may return `UNIMPLEMENTED` without advertising it. This registration path supports fresh launches; it does not yet orchestrate state-preserving restoration. From b1020b7335807d641474447cb7da64326064250a Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 9 Oct 2026 08:45:07 -0700 Subject: [PATCH 06/24] feat(isolation)!: activate backend contracts in sandbox runtime and driver selection Signed-off-by: Drew Newberry --- .github/workflows/branch-checks.yml | 5 + Cargo.lock | 2 - .../openshell-core/src/extension_protocol.rs | 40 +- crates/openshell-sandbox-backend/Cargo.toml | 4 - crates/openshell-sandbox-backend/README.md | 45 +- crates/openshell-sandbox-backend/build.rs | 50 -- .../proto/openshell_sandbox.proto | 20 - .../src/delegated.rs | 37 +- .../src/isolation_protocol.rs | 90 +++ crates/openshell-sandbox-backend/src/lib.rs | 17 +- .../openshell-sandbox-backend/src/runtime.rs | 613 ++++++++++++++--- .../src/runtime/tests/credential_renewal.rs | 9 +- .../src/runtime/tests/delegated.rs | 222 ++++++ .../src/runtime/tests/flow_control.rs | 44 +- .../src/runtime/tests/network_recovery.rs | 27 +- .../openshell-sandbox/src/boundary_server.rs | 515 ++++++++++++-- crates/openshell-server/src/cli.rs | 91 +++ .../src/compute/driver_config.rs | 20 + crates/openshell-server/src/compute/mod.rs | 643 +++++++++++++++--- crates/openshell-server/src/config_file.rs | 130 +++- crates/openshell-server/src/lib.rs | 14 +- crates/openshell-server/src/test_support.rs | 21 + .../openshell-supervisor/src/backend_setup.rs | 2 +- .../src/backend_setup/registration.rs | 112 +++ .../src/backend_setup/tests.rs | 97 +++ crates/openshell-supervisor/src/main.rs | 175 +++-- .../tests/gateway_config_serializer_test.yaml | 24 + docs/how-it-works/gateways/configuration.mdx | 68 ++ docs/how-it-works/sandboxes/runtimes.mdx | 27 + e2e/isolation/protocol_interop.py | 488 +++++++++++++ skills/debug-openshell-cluster/SKILL.md | 2 + tasks/test.toml | 8 + 32 files changed, 3233 insertions(+), 429 deletions(-) delete mode 100644 crates/openshell-sandbox-backend/build.rs delete mode 100644 crates/openshell-sandbox-backend/proto/openshell_sandbox.proto create mode 100644 crates/openshell-sandbox-backend/src/isolation_protocol.rs create mode 100644 crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs create mode 100644 e2e/isolation/protocol_interop.py diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index af6692a7dc..e34a276039 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -313,6 +313,11 @@ jobs: cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + - name: Sandbox Protocol interoperability + if: matrix.system == 'x86_64-linux' + shell: nix develop -c bash -euo pipefail {0} + run: mise run e2e:isolation-protocol + rust-postgres-test: name: Rust PostgreSQL tests needs: pr_metadata diff --git a/Cargo.lock b/Cargo.lock index a6bfc3cc80..e170ff66f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4843,7 +4843,6 @@ dependencies = [ "openshell-core", "openshell-isolation-interface", "prost", - "protoc-bin-vendored", "rcgen", "rustix 1.1.4", "rustls", @@ -4858,7 +4857,6 @@ dependencies = [ "tokio-stream", "tonic", "tonic-prost", - "tonic-prost-build", "tower", "tracing", "uuid", diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index b56498b53e..f15fbec195 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -125,6 +125,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_REGISTRATION.to_string()); } PeerMetadata { protocol_version: Some(ProtocolVersion { @@ -161,10 +162,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(", ") ) } @@ -175,8 +176,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(), @@ -269,13 +294,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-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index 6d39fc37d5..c8e3c561f3 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -39,9 +39,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..c5033b40d3 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -1,8 +1,51 @@ # OpenShell sandbox backend -This crate implements the supervisor-side isolation backend and the private +This crate implements the supervisor-side isolation backend and the versioned control protocol shared with `openshell-sandbox`. +## 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.isolation.contract` on both peers. The shared extension +negotiator checks major versions and each peer's required capabilities. The +built-in runtime does not advertise `openshell.isolation.suspend-resume` and +returns `UNIMPLEMENTED` for those optional RPCs. + +`DelegatedIsolationBoundary.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 data fragments. +Missing bindings, repeated bindings, and frames without a payload are rejected. +The fragments retain the existing versioned Sandbox Protocol framing. + +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. + +Update the supervisor and sandbox runtime together; an older peer fails during +startup instead of using a fallback path. + +## Endpoint-registered backends + +`DelegatedRuntimeBackend` uses 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 outer-fence evidence. Native evidence is +validated by the registered backend; Linux evidence validation remains in the +built-in adapter. + +The language-neutral contract, generation instructions, and interoperability +vectors are in [Sandbox Protocol 1.0](../../proto/sandbox_protocol/v1/README.md). + ## Exec recovery An exec envelope carries a UUID request ID and an absolute expiration time in 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/delegated.rs b/crates/openshell-sandbox-backend/src/delegated.rs index 8cde5f319d..e5ed88eeec 100644 --- a/crates/openshell-sandbox-backend/src/delegated.rs +++ b/crates/openshell-sandbox-backend/src/delegated.rs @@ -7,10 +7,14 @@ use std::collections::BTreeMap; use std::net::IpAddr; use openshell_core::SandboxSessionId; -use openshell_isolation_interface::contract::{OuterFenceGuarantees, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use serde::{Deserialize, Serialize}; -use crate::boundary_protocol::SandboxTlsClientConfig; +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. @@ -28,3 +32,32 @@ pub struct DelegatedLaunch { pub resource_claims: BTreeMap, pub outer_fence: OuterFenceGuarantees, } + +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, + outer_fence: self.outer_fence, + }; + 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..1204bb2785 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/isolation_protocol.rs @@ -0,0 +1,90 @@ +// 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::boundary_protocol::{BoundaryConfig, SandboxRuntimeDescriptor}; +use crate::isolation_proto::OpenBoundaryRequest; +use crate::proto::{DelegatedBoundaryChunk, delegated_boundary_chunk::Payload}; + +pub const CONTRACT_CAPABILITY: &str = "openshell.isolation.contract"; +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![CONTRACT_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. +/// 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.sandbox_id != config.boundary_id + || request.generation_id != config.generation + || 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.outer_fence != config.outer_fence + { + return Err(tonic::Status::permission_denied( + "driver descriptor does not match provisioned boundary", + )); + } + Ok(()) +} + +#[must_use] +pub fn data_chunk(data: Vec) -> DelegatedBoundaryChunk { + DelegatedBoundaryChunk { + payload: Some(Payload::Data(data)), + } +} + +#[must_use] +pub fn boundary_chunk(boundary_id: &str) -> DelegatedBoundaryChunk { + DelegatedBoundaryChunk { + payload: Some(Payload::BoundaryId(boundary_id.to_string())), + } +} + +/// After binding, only data frames are legal in either stream direction. +pub fn chunk_data(chunk: DelegatedBoundaryChunk) -> Result, tonic::Status> { + match chunk.payload { + Some(Payload::Data(data)) => Ok(data), + _ => Err(tonic::Status::invalid_argument( + "only data frames are allowed after boundary binding", + )), + } +} diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index b41653e83e..0dd421232e 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -13,11 +13,13 @@ pub mod audit; pub mod boundary_protocol; pub mod delegated; +pub mod isolation_protocol; pub mod mediation; mod runtime; pub mod sandbox_auth; -pub use runtime::{BoundaryTransportConnector, OpenShellRuntimeBackend}; +pub use openshell_core::proto::sandbox::backend::v1 as backend_proto; +pub use runtime::{BoundaryTransportConnector, DelegatedRuntimeBackend, OpenShellRuntimeBackend}; /// Stable isolation backend name implemented by `openshell-sandbox`. pub const BACKEND_NAME: &str = openshell_core::isolation_registration::BUILTIN_BACKEND_NAME; @@ -40,16 +42,7 @@ 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::*; } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index b20ca44f3b..6173b1c70a 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -10,7 +10,14 @@ 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::isolation_proto::{ + GetIsolationCapabilitiesRequest, OpenBoundaryRequest, + isolation_backend_client::IsolationBackendClient, open_boundary_response::LaunchOutcome, +}; +use crate::isolation_protocol::{boundary_chunk, chunk_data, data_chunk}; +use crate::proto::{ + DelegatedBoundaryChunk, delegated_isolation_boundary_client::DelegatedIsolationBoundaryClient, +}; use async_trait::async_trait; use hyper_util::rt::TokioIo; use openshell_isolation_interface::AgentSpec; @@ -19,8 +26,8 @@ use openshell_isolation_interface::contract::{ BoundaryInput, BoundaryLoopbackConnector, BoundaryOutput, BoundaryProcess, BoundarySignal, BoundaryTerminal, ConfirmedBoundary, ExecSession, ExecSpec, IsolationBackend, LoopbackTarget, MediationTiming, NetworkMediationSource, PendingDnsQuery, PendingTcpOpen, ProcessAttachment, - ProviderEnvironmentInstallation, ReadyBoundary, RunningBoundary, SandboxContext, - TcpOpenDecision, TcpOpenDenial, VerifiedBackendDescriptor, + ProviderEnvironmentInstallation, ReadyBoundary, ResolvedWorkloadIdentity, RunningBoundary, + SandboxContext, TcpOpenDecision, TcpOpenDenial, VerifiedBackendDescriptor, }; use sha2::{Digest as _, Sha256}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -82,6 +89,23 @@ pub struct OpenShellRuntimeBackend { 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 { @@ -119,6 +143,7 @@ impl OpenShellRuntimeBackend { ca_file_paths, provider_credentials, sandbox_bearer, + delegated: None, } } @@ -142,10 +167,72 @@ impl OpenShellRuntimeBackend { } } +/// Shared Sandbox Protocol adapter for a trusted, endpoint-registered backend. +/// The remote backend validates its native evidence; this adapter validates the +/// common guarantees and immutable launch binding. +pub struct DelegatedRuntimeBackend(OpenShellRuntimeBackend); + +impl DelegatedRuntimeBackend { + pub fn new( + 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 = + OpenShellRuntimeBackend::new(ca_file_paths, provider_credentials, sandbox_bearer); + backend.delegated = Some(DelegatedRuntime { + backend_name, + descriptor, + driver_descriptor, + }); + Ok(Self(backend)) + } + + pub async fn discover_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); + match client.call_idempotent(Request::DiscoverPolicy).await? { + Response::ImagePolicy { yaml, invalid } => Ok((yaml, invalid)), + _ => Err(BackendError::Descriptor( + "expected image policy discovery response".to_string(), + )), + } + } +} + +#[async_trait] +impl IsolationBackend for DelegatedRuntimeBackend { + fn backend_name(&self) -> &str { + self.0.backend_name() + } + + async fn attach( + &self, + descriptor: VerifiedBackendDescriptor, + sandbox: SandboxContext, + ) -> Result, BackendError> { + self.0.attach(descriptor, sandbox).await + } +} + #[async_trait] impl IsolationBackend for OpenShellRuntimeBackend { fn backend_name(&self) -> &str { - crate::BACKEND_NAME + self.delegated + .as_ref() + .map_or(crate::BACKEND_NAME, |backend| backend.backend_name.as_str()) } async fn attach( @@ -153,17 +240,35 @@ impl IsolationBackend for OpenShellRuntimeBackend { descriptor: VerifiedBackendDescriptor, sandbox: SandboxContext, ) -> Result, BackendError> { - let runtime_descriptor: SandboxRuntimeDescriptor = - serde_json::from_slice(descriptor.payload()).map_err(|error| { - BackendError::Descriptor(format!("decode runtime descriptor: {error}")) - })?; + 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 mut client = BoundaryClient::with_backend( + runtime_descriptor, + self.sandbox_bearer.clone(), + self.backend_name().to_string(), + descriptor.payload().to_vec(), + ); client.connector = self.connector.clone(); let client = Arc::new(client); let response = client @@ -196,6 +301,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { session_id, resource_claims, outer_fence, + native_audit: self.delegated.is_none(), })) } } @@ -226,51 +332,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 .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,26 +404,6 @@ 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, client: Arc, @@ -337,11 +414,12 @@ struct RemoteBound { 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, + native_audit: bool, } #[async_trait] @@ -369,11 +447,14 @@ impl BoundBoundary for RemoteBound { .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.native_audit { + 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(), + )); + } } let client = self.client.clone(); let confirmed = ConfirmedBoundary::try_new( @@ -1121,6 +1202,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>, @@ -1281,10 +1365,29 @@ impl BoundaryClient { 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), @@ -1536,7 +1639,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 +1833,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 +1880,7 @@ impl BoundaryClient { channel, GrpcStreamKind::Exchange, credential.authorization.clone(), + self.boundary_handle()?, ) .await?; let frame = encode_frame(envelope) @@ -1888,7 +1992,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,6 +2007,7 @@ 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(); @@ -1937,9 +2042,87 @@ 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 + .get_capabilities(GetIsolationCapabilitiesRequest { + 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 descriptor = &self.runtime_descriptor; + let mut request = tonic::Request::new(OpenBoundaryRequest { + sandbox_id: descriptor.boundary_id.clone(), + session_id: descriptor.session_id.to_string(), + generation_id: descriptor.generation.clone(), + 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,27 +2215,47 @@ 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::InvalidArgument + | tonic::Code::FailedPrecondition + | tonic::Code::Unimplemented => { + 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); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel::(64); + outbound + .send(boundary_chunk(boundary_id)) + .await + .map_err(|_| BackendError::Unavailable("queue boundary binding frame".to_string()))?; tokio::spawn(pump_to_grpc(reader, outbound)); - let mut client = IsolationBoundaryClient::new(channel) + let mut client = DelegatedIsolationBoundaryClient::new(channel) .max_decoding_message_size(64 * 1024) .max_encoding_message_size(64 * 1024); let mut request = tonic::Request::new(ReceiverStream::new(outbound_rx)); @@ -2073,7 +2276,7 @@ async fn open_grpc_client_stream_with_authorization( Ok(Box::new(application)) } -async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) +async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) where R: tokio::io::AsyncRead + Unpin, { @@ -2090,9 +2293,7 @@ where return; } if sender - .send(BoundaryChunk { - data: buffer[..read].to_vec(), - }) + .send(data_chunk(buffer[..read].to_vec())) .await .is_err() { @@ -2101,14 +2302,18 @@ where } } -async fn pump_from_grpc(mut stream: tonic::Streaming, mut writer: W) +async fn pump_from_grpc(mut stream: tonic::Streaming, mut writer: W) where W: tokio::io::AsyncWrite + Unpin, { loop { match stream.message().await { Ok(Some(chunk)) => { - if let Err(error) = writer.write_all(&chunk.data).await { + let Ok(data) = chunk_data(chunk) else { + let _ = writer.shutdown().await; + return; + }; + if let Err(error) = writer.write_all(&data).await { tracing::debug!(%error, "boundary gRPC response writer ended"); return; } @@ -2211,6 +2416,7 @@ fn is_transport_unavailable(message: &str) -> bool { #[cfg(test)] mod tests { mod credential_renewal; + mod delegated; mod flow_control; mod network_recovery; @@ -2220,15 +2426,201 @@ mod tests { use super::*; use crate::boundary_protocol::{ExitStatusWire, generate_sandbox_tls_material}; + use crate::isolation_proto::{ + GetIsolationCapabilitiesResponse, OpenBoundaryResponse, ResumeBoundaryRequest, + SuspendBoundaryRequest, SuspendBoundaryResponse, + isolation_backend_server::{IsolationBackend as ControlBackend, IsolationBackendServer}, + }; use crate::proto::{ - BoundaryChunk, - isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, + DelegatedBoundaryChunk, + delegated_isolation_boundary_server::{ + DelegatedIsolationBoundary, DelegatedIsolationBoundaryServer, + }, }; use openshell_core::jwt::{SecretJwt, SessionBearerTokenSlot}; use openshell_core::policy::{ FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; + 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 get_capabilities( + &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(GetIsolationCapabilitiesResponse { + 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")) + } + } + + async fn test_bind(inbound: &mut tonic::Streaming) { + assert_eq!( + inbound.message().await.unwrap().unwrap(), + boundary_chunk(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::CONTRACT_CAPABILITY); + let mut unsupported_requirement = crate::isolation_protocol::metadata("test/backend"); + unsupported_requirement + .required_capabilities + .push("openshell.isolation.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_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { use openshell_isolation_interface::contract::OuterFenceGuarantee; @@ -2270,17 +2662,21 @@ mod tests { } type TestGrpcStream = Pin< - Box> + Send + 'static>, + Box< + dyn tokio_stream::Stream> + + Send + + 'static, + >, >; #[tonic::async_trait] - impl IsolationBoundary for TestGrpcBoundary { + impl DelegatedIsolationBoundary for TestGrpcBoundary { type ExchangeStream = TestGrpcStream; type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let authorization = request .metadata() @@ -2292,6 +2688,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 +2700,7 @@ mod tests { loop { match inbound.message().await { Ok(Some(chunk)) => { - frame.extend_from_slice(&chunk.data); + frame.extend_from_slice(&chunk_data(chunk).expect("data frame")); if !wait_for_half_close && complete_control_frame(&frame) { break; } @@ -2399,7 +2796,7 @@ mod tests { } else { b"complete response".to_vec() }; - let _ = outbound.send(Ok(BoundaryChunk { data: response })).await; + let _ = outbound.send(Ok(data_chunk(response))).await; }); Ok(tonic::Response::new(Box::pin(ReceiverStream::new( outbound_rx, @@ -2408,7 +2805,7 @@ mod tests { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if self .mediation_failures @@ -2451,7 +2848,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(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await .unwrap(); @@ -2465,6 +2863,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 +2921,8 @@ mod tests { }; tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -2627,7 +3033,8 @@ mod tests { }; tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -2683,7 +3090,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(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await .unwrap(); @@ -2697,6 +3105,7 @@ mod tests { channel, GrpcStreamKind::Exchange, &test_bearer(&"a".repeat(32)), + TEST_BOUNDARY_HANDLE, ) .await .unwrap(); @@ -2870,7 +3279,8 @@ mod tests { confirmation, }; tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( stream, ))])) @@ -2896,7 +3306,7 @@ mod tests { timeout_secs: 5, interactive: false, }, - identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + identity: ResolvedWorkloadIdentity::new( 10_001, 10_001, Vec::new(), @@ -2981,6 +3391,7 @@ mod tests { )); let bound = RemoteBound { audit_validator: Arc::new(crate::audit::LinuxBoundaryAuditValidator), + native_audit: true, client: client.clone(), agent: context.agent, policy: context.policy, @@ -3421,7 +3832,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(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -3489,7 +3901,8 @@ mod tests { .await .unwrap(); tonic::transport::Server::builder() - .add_service(IsolationBoundaryServer::new(service)) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) + .add_service(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( Box::new(stream), ))])) 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..ab60d645c5 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 DelegatedIsolationBoundary for RenewingGrpcBoundary { type ExchangeStream = TestGrpcStream; type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if self .failures @@ -40,7 +40,7 @@ impl IsolationBoundary for RenewingGrpcBoundary { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { self.exchange(request).await } @@ -87,7 +87,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(DelegatedIsolationBoundaryServer::new(service)) .serve_with_incoming( tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo(Box::new(stream)))]) .chain(tokio_stream::pending()), 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..11fc14262b --- /dev/null +++ b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs @@ -0,0 +1,222 @@ +// 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, BackendRegistry, EnforcedProperty, +}; + +const EXTERNAL_NAME: &str = "external-test"; +const OPAQUE_PAYLOAD: &[u8] = b"external-driver-v1\0\xffresource"; + +async fn external_backend( + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, +) -> (DelegatedRuntimeBackend, 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(DelegatedIsolationBoundaryServer::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!( + DelegatedRuntimeBackend::discover_policy( + EXTERNAL_NAME.into(), + descriptor.clone(), + OPAQUE_PAYLOAD.to_vec(), + bearer.clone() + ) + .await + .unwrap(), + (None, false) + ); + let backend = DelegatedRuntimeBackend::new( + 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() -> openshell_isolation_interface::contract::BoundaryConfirmation { + let mut confirmation = test_confirmation(); + confirmation.backend_audit = serde_json::json!({"external_boundary": "verified-actor"}); + let property = EnforcedProperty::new(true, "external-actor-isolation"); + confirmation.properties = openshell_isolation_interface::contract::BoundaryProperties { + filesystem_confinement: property.clone(), + egress_interception: property.clone(), + request_attribution: property.clone(), + privilege_floor: property, + }; + confirmation +} + +async fn attach_external(backend: DelegatedRuntimeBackend) -> Box { + let mut registry = BackendRegistry::new(); + registry.register(Arc::new(backend)).unwrap(); + let (backend, descriptor) = registry + .resolve( + BackendDescriptor { + backend_name: EXTERNAL_NAME.into(), + payload: OPAQUE_PAYLOAD.to_vec(), + }, + EXTERNAL_NAME, + ) + .unwrap(); + 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_eq!( + confirmed.confirmation().backend_audit["external_boundary"], + "verified-actor" + ); + 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_still_requires_common_guarantees_and_launch_binding() { + for field in [ + "generation", + "session", + "identity", + "fence", + "fence_digest", + "claims", + "properties", + "mechanism", + "containment", + "authentication", + ] { + 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, + "fence" => confirmation.outer_fence.generation = "other-generation".into(), + "fence_digest" => { + confirmation.outer_fence.evidence_digest = "f".repeat(64).parse().unwrap(); + } + "claims" => { + confirmation + .resource_claims + .insert("unexpected".into(), "claim".into()); + } + "properties" => confirmation.properties.egress_interception.enforced = false, + "mechanism" => confirmation + .properties + .egress_interception + .mechanism + .clear(), + "containment" => confirmation.runtime_exit_terminates_workload = false, + "authentication" => confirmation.authenticated_supervisor = false, + _ => 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!( + DelegatedRuntimeBackend::new( + 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!( + DelegatedRuntimeBackend::discover_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 mut registry = BackendRegistry::new(); + registry.register(Arc::new(backend)).unwrap(); + let (backend, descriptor) = registry + .resolve( + BackendDescriptor { + backend_name: EXTERNAL_NAME.into(), + payload: b"changed".to_vec(), + }, + EXTERNAL_NAME, + ) + .unwrap(); + 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..80412ec1b0 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs @@ -8,20 +8,21 @@ 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 DelegatedIsolationBoundary for StallingPeer { type ExchangeStream = 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); @@ -39,7 +40,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 +66,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(DelegatedIsolationBoundaryServer::new(peer)) .serve_with_incoming(incoming) .await .unwrap(); @@ -74,17 +76,27 @@ 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(boundary_chunk(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 = DelegatedIsolationBoundaryClient::new(channel); tokio::spawn(async move { let _response = client.exchange(request).await; std::future::pending::<()>().await; @@ -92,9 +104,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,10 +129,14 @@ 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(); + let mut stream = open_grpc_client_stream_with_authorization( + channel, + GrpcStreamKind::Exchange, + auth, + TEST_BOUNDARY_HANDLE, + ) + .await + .unwrap(); stream.write_all(b"ping").await.unwrap(); let mut pong = [0_u8; 4]; stream.read_exact(&mut pong).await.unwrap(); 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..9f91b4368c 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -51,13 +51,13 @@ struct NetworkPeer { } #[tonic::async_trait] -impl IsolationBoundary for NetworkPeer { +impl DelegatedIsolationBoundary for NetworkPeer { type ExchangeStream = 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 +80,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 +89,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) @@ -191,10 +194,7 @@ impl NetworkPeer { response, }) .unwrap(); - outbound - .send(Ok(BoundaryChunk { data: bytes })) - .await - .unwrap(); + outbound.send(Ok(data_chunk(bytes))).await.unwrap(); if connected { let state = self.state.clone(); tokio::spawn(async move { @@ -212,9 +212,7 @@ 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(); }) @@ -302,7 +300,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(DelegatedIsolationBoundaryServer::new(peer)) .serve_with_incoming(incoming); tokio::select! { result = serving => result.unwrap(), diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 16299730c9..8847ce53b1 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -38,14 +38,25 @@ mod linux { BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, }; + use openshell_sandbox_backend::isolation_proto::{ + GetIsolationCapabilitiesRequest, GetIsolationCapabilitiesResponse, OpenBoundaryRequest, + OpenBoundaryResponse, ResumeBoundaryRequest, SuspendBoundaryRequest, + SuspendBoundaryResponse, + isolation_backend_server::{IsolationBackend, IsolationBackendServer}, + open_boundary_response::LaunchOutcome, + }; + 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::delegated_isolation_boundary_client::DelegatedIsolationBoundaryClient; use openshell_sandbox_backend::proto::{ - BoundaryChunk, - isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, + DelegatedBoundaryChunk, + delegated_boundary_chunk::Payload, + delegated_isolation_boundary_server::{ + DelegatedIsolationBoundary, DelegatedIsolationBoundaryServer, + }, }; use openshell_sandbox_backend::sandbox_auth::{ SandboxAuthError, SandboxConnectionId, SandboxConnectionRegistry, @@ -589,6 +600,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 +615,14 @@ 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( + DelegatedIsolationBoundaryServer::new(service) + .max_decoding_message_size(64 * 1024) + .max_encoding_message_size(64 * 1024), ) .serve_with_incoming_shutdown(incoming, async move { let _ = shutdown.changed().await; @@ -677,6 +696,8 @@ mod linux { connection_id: SandboxConnectionId, connection_expiry: Arc, connection_closed: tokio::sync::watch::Receiver<()>, + negotiated: Arc, + opened: Arc, } struct ConnectionExpiry { @@ -754,24 +775,131 @@ mod linux { } } - type GrpcResponseStream = ReceiverStream>; - #[tonic::async_trait] - impl IsolationBoundary for GrpcBoundaryService { - type ExchangeStream = GrpcResponseStream; - type MediateStream = GrpcResponseStream; + impl IsolationBackend for GrpcBoundaryService { + async fn get_capabilities( + &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(GetIsolationCapabilitiesResponse { + 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.and_then(|frame| frame.payload) { + Some(Payload::BoundaryId(id)) if id == self.runtime.routing_handle => {} + Some(Payload::BoundaryId(_)) => { + 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 DelegatedIsolationBoundary 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 +915,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 { @@ -807,13 +931,16 @@ mod linux { } fn bridge_grpc_server_stream( - mut inbound: tonic::Streaming, + mut inbound: tonic::Streaming, connection_closed: tokio::sync::watch::Receiver<()>, ) -> (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); + 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! { @@ -822,7 +949,17 @@ mod linux { loop { match inbound.message().await { Ok(Some(chunk)) => { - if writer.write_all(&chunk.data).await.is_err() { + let data = match chunk_data(chunk) { + 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; } } @@ -857,9 +994,7 @@ mod linux { return; } if outbound - .send(Ok(BoundaryChunk { - data: buffer[..read].to_vec(), - })) + .send(Ok(data_chunk(buffer[..read].to_vec()))) .await .is_err() { @@ -1306,6 +1441,7 @@ mod linux { struct BoundaryRuntime { config: BoundaryConfig, + routing_handle: String, authenticator: SandboxProtocolAuthenticator, connections: SandboxConnectionRegistry, connection_shutdowns: @@ -1612,6 +1748,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), @@ -3933,6 +4070,63 @@ 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(), + outer_fence: config.outer_fence.clone(), + }; + OpenBoundaryRequest { + sandbox_id: config.boundary_id.clone(), + session_id: config.session_id.to_string(), + generation_id: config.generation.clone(), + 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::isolation_proto::isolation_backend_client::IsolationBackendClient::new(channel); + control + .get_capabilities(GetIsolationCapabilitiesRequest { + 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(), @@ -4460,14 +4654,19 @@ 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(openshell_sandbox_backend::isolation_protocol::boundary_chunk(&handle)) + .await + .unwrap(); let request = RequestEnvelope::new(Request::OpenMediation).unwrap(); sender - .send(BoundaryChunk { - data: encode_frame(&request).unwrap(), - }) + .send(data_chunk(encode_frame(&request).unwrap())) .await .unwrap(); - let mut response = IsolationBoundaryClient::new(channel) + let mut response = DelegatedIsolationBoundaryClient::new(channel) .mediate(bearer_request(ReceiverStream::new(receiver), &token)) .await .unwrap() @@ -5058,6 +5257,235 @@ 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::isolation_proto::isolation_backend_client::IsolationBackendClient::new(channel.clone()); + let mut streams = DelegatedIsolationBoundaryClient::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 + .get_capabilities(GetIsolationCapabilitiesRequest { 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 + .get_capabilities(GetIsolationCapabilitiesRequest { + supervisor: Some(incompatible) + }) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + control + .get_capabilities(GetIsolationCapabilitiesRequest { + supervisor: Some(openshell_sandbox_backend::isolation_protocol::metadata( + "test/supervisor", + )), + }) + .await + .unwrap(); + assert_eq!( + control + .open_boundary(opening.clone()) + .await + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + for field in ["sandbox", "session", "generation", "backend", "descriptor"] { + let mut changed = opening.clone(); + match field { + "sandbox" => changed.sandbox_id = "other-sandbox".to_string(), + "session" => changed.session_id = "other-session".to_string(), + "generation" => changed.generation_id = "other-generation".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::boundary_chunk(&handle); + let mut replacement_streams = + DelegatedIsolationBoundaryClient::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([binding]), &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![DelegatedBoundaryChunk { payload: None }], + vec![openshell_sandbox_backend::isolation_protocol::boundary_chunk("")], + vec![ + openshell_sandbox_backend::isolation_protocol::boundary_chunk("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), &token)) + .await + .err() + .unwrap() + .code(), + expected + ); + } + let binding = openshell_sandbox_backend::isolation_protocol::boundary_chunk(&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) = @@ -5097,6 +5525,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 @@ -5119,17 +5548,19 @@ mod linux { 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::boundary_chunk(&handle), + data_chunk(encode_frame(&request).expect("encode logical request")), + ]); + let mut body = DelegatedIsolationBoundaryClient::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) diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 19a22e5d16..ae698e1fb9 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,79 @@ mod tests { }); } + #[test] + fn config_preflight_isolation_backend_follows_effective_driver_selection() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let config_home = tempfile::tempdir().unwrap(); + let config = config_home.path().join("gateway.toml"); + let _config_home = + EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); + let _tls_dir = EnvVarGuard::set( + "OPENSHELL_LOCAL_TLS_DIR", + config_home.path().to_str().unwrap(), + ); + let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); + let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); + let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); + 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. + 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(_)) + )); + let _canonical = EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "local"); + assert!(matches!( + preflight(&["openshell-gateway"]).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 9605f45317..b650a5084b 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_REGISTRATION + }) +} + 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 @@ -2023,7 +2144,6 @@ impl ComputeRuntime { async move { Box::pin(runtime.complete_sandbox_start( sandbox_id, - sandbox_name, previous, starting, lifecycle_guard, @@ -2044,7 +2164,6 @@ impl ComputeRuntime { async fn complete_sandbox_start( &self, sandbox_id: String, - sandbox_name: String, previous: Sandbox, starting: Sandbox, lifecycle_guard: SandboxLifecycleGuard, @@ -2056,15 +2175,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,26 +2188,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 { - isolation_backend: None, - 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) { @@ -3568,6 +3673,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 sandbox backend selection"); + failed += 1; + continue; + } if let Err(error) = self.validate_caller_driver_config( sandbox @@ -3588,16 +3698,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) => { @@ -3621,32 +3727,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 { - isolation_backend: None, - 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 @@ -3869,6 +3967,10 @@ impl ComputeRuntime { } } SandboxPhase::Starting => { + if let Err(error) = self.validate_isolation_backend_binding(&sandbox) { + warn!(%error, "Rejected pending sandbox backend selection"); + continue; + } if let Err(error) = self.validate_caller_driver_config( sandbox .spec @@ -3884,16 +3986,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. @@ -3904,16 +4023,7 @@ impl ComputeRuntime { openshell_otel::rpc::START_SANDBOX, Some(&sandbox_id), |driver| async move { - driver - .start_sandbox(Request::new(StartSandboxRequest { - isolation_backend: None, - 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 }, ), ) @@ -4294,6 +4404,8 @@ impl ComputeRuntime { } let sandbox_name = current.object_name().to_string(); + self.validate_isolation_backend_binding(¤t) + .map_err(|error| error.to_string())?; let authority = self.restart_authority.get().and_then(Option::as_deref); let already_claimed = current .status @@ -4396,29 +4508,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 { - isolation_backend: None, - 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), @@ -6276,7 +6381,6 @@ fn driver_sandbox_spec_from_public( driver_name: &str, ) -> Result> { Ok(DriverSandboxSpec { - isolation_backend: None, log_level: spec.log_level.clone(), environment: spec.environment.clone(), template: spec @@ -6310,6 +6414,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, }) } @@ -7585,6 +7690,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, @@ -8697,6 +8803,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, @@ -16535,6 +16642,332 @@ 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_REGISTRATION + }); + 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() { @@ -16641,7 +17074,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(); 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/test_support.rs b/crates/openshell-server/src/test_support.rs index 0cd4abf2d5..d15f9fe98f 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,23 @@ 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_REGISTRATION + .into(), + ); + }); + self + } + #[must_use] pub fn calls(&self) -> Vec { self.with_state(|state| state.calls.clone()) @@ -410,6 +429,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/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index 627100e6be..c4c414ed69 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -4,7 +4,7 @@ //! Backend-owned launch decoding and client construction for supervisor startup. mod registration; -pub use registration::{BackendRegistration, BackendRegistrations}; +pub use registration::{BackendRegistration, BackendRegistrations, RegisteredBackendSetup}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; diff --git a/crates/openshell-supervisor/src/backend_setup/registration.rs b/crates/openshell-supervisor/src/backend_setup/registration.rs index b3dede3e4a..04264dbb51 100644 --- a/crates/openshell-supervisor/src/backend_setup/registration.rs +++ b/crates/openshell-supervisor/src/backend_setup/registration.rs @@ -4,10 +4,122 @@ //! Operator-owned endpoint registration; launch payloads never select a route. pub use openshell_core::isolation_registration::BackendRegistration; +use openshell_sandbox_backend::DelegatedRuntimeBackend; +use openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor; +use openshell_sandbox_backend::delegated::DelegatedLaunch; use serde::Deserialize; +use super::{BackendServices, BackendSetup, LaunchIdentity, PreparedBackend}; +use miette::Result; +use openshell_core::jwt::SessionBearerTokenSlot; +use openshell_isolation_interface::contract::{BackendError, IsolationBackend}; +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> { + DelegatedRuntimeBackend::discover_policy( + self.name.clone(), + self.descriptor.clone(), + self.payload.clone(), + bearer, + ) + .await + } + + fn build( + self: Box, + services: BackendServices, + ) -> std::result::Result, BackendError> { + Ok(Arc::new(DelegatedRuntimeBackend::new( + self.name, + self.descriptor, + self.payload, + services.ca_file_paths, + services.provider_credentials, + services.sandbox_bearer, + )?)) + } +} diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 3db21568b0..c423af26cb 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -23,6 +23,103 @@ 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(), + outer_fence: OuterFenceGuarantees::from_enforcement_evidence( + "generation-1", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + 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.outer_fence.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>, diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index 1ce6f16e70..8fbaf48158 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -133,6 +133,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, @@ -185,20 +193,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") @@ -242,7 +294,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() @@ -309,16 +363,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 }; @@ -439,34 +495,37 @@ 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, - 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, + backend_descriptor, + auth_bundle, + admitted_isolation_backend: Some(admitted_backend), + main_exit_marker: args.main_exit_marker, + }, )) .await; supervisor_exit(result) @@ -801,14 +860,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] @@ -821,6 +873,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/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml b/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml index 8d0afc30bc..423e6b4b73 100644 --- a/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml @@ -9,6 +9,30 @@ release: namespace: serializer-namespace tests: + - it: renders operator isolation selection and endpoint registrations + set: + gatewayConfig: + openshell.gateway: + compute_driver: agent-substrate + openshell.drivers.agent-substrate: + socket_path: /run/agent-substrate/compute.sock + isolation_backend: agent-substrate + openshell.supervisor.isolation_backends: + - name: agent-substrate + endpoint: + kind: unix + socket_path: /run/agent-substrate/isolation.sock + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\[openshell\.drivers\.agent-substrate\]\n(?:[^\[]*\n)*isolation_backend = "agent-substrate"$' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^\[\[openshell\.supervisor\.isolation_backends\]\]$' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^endpoint = \{ kind = "unix", socket_path = "/run/agent-substrate/isolation\.sock" \}$' + - it: gives gatewayConfig precedence over a deprecated proxy alias set: upstreamProxy.url: http://legacy-proxy.example:8080 diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 0c6a9e2a88..b193ac2f7e 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1405,6 +1405,74 @@ compute_driver = "kyma" socket_path = "/run/openshell/kyma-compute-driver.sock" ``` +### External isolation backend + +After installing a compatible external compute driver and isolation backend, +set its backend name in `[openshell.drivers.]`, then select that compute +driver with `openshell.gateway.compute_driver`: + +```toml +[openshell.gateway] +compute_driver = "agent-substrate" + +[openshell.drivers.agent-substrate] +socket_path = "/run/agent-substrate/compute.sock" +isolation_backend = "agent-substrate" + +[[openshell.supervisor.isolation_backends]] +name = "agent-substrate" +endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" } +``` + +Endpoint addresses must be reachable from the supervisor's placement. TCP uses +`{ kind = "tcp", authority = "backend.example:443", addresses = ["192.0.2.10:443"] }`; +vsock uses `{ kind = "vsock", guest_cid = 3, port = 47000 }`. +Names match exactly. Unknown names, duplicate registrations, invalid endpoints, +and registration of the reserved `openshell-sandbox` name fail configuration +validation, including for unused registrations. + +The selected compute driver must advertise +`openshell.compute.isolation-backend-registration`. The gateway sends the +resolved name and endpoint on validation, create, start, and recovery. The +driver places the backend and stages protected supervisor launch files, +generation-specific TLS, and its opaque descriptor. Gateway launch signing +must be configured through `gateway_jwt` or the package-managed local signing +keys. An incompatible driver fails gateway startup; there is no fallback. + +Once configured, changing the `compute_driver` line and restarting the gateway +selects that driver and its isolation backend for new sandboxes. Driver and +backend names can differ; the gateway never infers one from the other. + +Changing the selected driver's `isolation_backend` and restarting also changes +its backend for new sandboxes. Its existing sandboxes retain their admitted +backend name; start and recovery reject a different backend before calling the +driver. Restore the matching backend to start them, or create new sandboxes. +Legacy records retain `openshell-sandbox`. +Omit the driver's `isolation_backend` field or set it to `openshell-sandbox` +to use the driver's built-in runtime. The first-party drivers currently support +that built-in selection only. Sandbox CLI and API requests do not select +isolation backends or supply endpoints. + +Helm renders these same fields through `gatewayConfig`: + +```yaml +gatewayConfig: + openshell.gateway: + compute_driver: agent-substrate + openshell.drivers.agent-substrate: + socket_path: /run/agent-substrate/compute.sock + isolation_backend: agent-substrate + openshell.supervisor.isolation_backends: + - name: agent-substrate + endpoint: + kind: unix + socket_path: /run/agent-substrate/isolation.sock +``` + +The driver socket must be mounted in the gateway deployment. The isolation +endpoint belongs to the supervisor's placement; mounting it in the gateway +alone does not make it reachable by the supervisor. + ## Preflight package configuration {#gateway-config-preflight} Before starting a package-managed gateway, validate the selected file without diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index cc76cf98d2..827f516d6a 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -49,6 +49,11 @@ driver TOML overrides the compiled release defaults. Helm renders Kubernetes image fields into TOML, so they apply when these environment variables are unset. Sandbox requests cannot override either trusted image. +Use supervisor and sandbox-runtime images from the same release. The supervisor +checks their isolation protocol version and required capabilities before +starting workload code. An incompatible pair fails sandbox startup; update +both trusted images together when overriding the release defaults. + When building your own gateway or supervisor image, the `GATEWAY_BASE_IMAGE` and `SUPERVISOR_BASE_IMAGE` build arguments override their pinned bases. See [Container Image Base Overrides](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#container-image-base-overrides) @@ -230,6 +235,28 @@ VM sandboxes have no network interface. All traffic flows through the OpenShell If the gateway is briefly unavailable while a VM starts, its supervisor retries the session connection. The sandbox stays in provisioning until the gateway accepts the session. +## External isolation backends + +An external compute driver can launch the stock supervisor against an isolation +backend maintained outside OpenShell. Register the operator-owned endpoint under +`openshell.supervisor.isolation_backends`, set its exact name as +`isolation_backend` in `[openshell.drivers.]`, and select that compute +driver with `openshell.gateway.compute_driver`. See the +[gateway configuration reference](/how-it-works/gateways/configuration#external-isolation-backend) +for TOML and Helm examples. + +The gateway passes that registration to a compute driver that advertises support. +The driver stages protected supervisor launch files, generation-pinned TLS, +gateway credentials, and its native descriptor. Sandbox requests and workload +environment cannot select the backend. First-party drivers currently support +only the built-in `openshell-sandbox` selection. + +Backend authors can generate bindings from the public protos and implement +[Sandbox Protocol 1.0](https://github.com/NVIDIA/OpenShell/tree/main/proto/sandbox_protocol/v1). +The contract includes lifecycle, framing, replay, process I/O, and TCP/DNS +mediation. Endpoint registration supports fresh launches; state-preserving +suspend/resume remains an optional contract awaiting lifecycle integration. + ## Kubernetes Driver The Kubernetes driver runs sandboxes as pods in a sandbox namespace. It requires the [Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox) controller. Install the gateway with the Helm chart; see [Kubernetes setup](/kubernetes/setup). diff --git a/e2e/isolation/protocol_interop.py b/e2e/isolation/protocol_interop.py new file mode 100644 index 0000000000..3e9eb84228 --- /dev/null +++ b/e2e/isolation/protocol_interop.py @@ -0,0 +1,488 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# /// script +# requires-python = ">=3.11" +# dependencies = ["grpcio==1.78.0", "grpcio-tools==1.78.0", "cryptography==46.0.7"] +# [tool.uv] +# exclude-newer = "2026-10-08T00:00:00Z" +# /// +"""Protocol interoperability fixture, not a production isolation backend. + +Run with uv run e2e/isolation/protocol_interop.py target/debug/openshell-supervisor. +Uses generated public gRPC bindings and independently implements JSON/framing. +The fixture simulates enforcement evidence and uses a fixed test bearer; +it does not provide isolation or exercise JWT validation. +""" + +import asyncio +import datetime +import hashlib +import json +import os +import signal +import struct +import sys +import tempfile +import traceback +import uuid +from collections import Counter +from pathlib import Path + +import grpc +import grpc_tools +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from grpc_tools import protoc + +ROOT = Path(__file__).resolve().parents[2] +TOKEN = "a" * 32 +PAYLOAD = b"external-actor-v1\x00\xffopaque-resource" + + +def frame(value): + payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode() + return struct.pack(">I", len(payload)) + payload + + +def canonical_request(envelope): + request = dict(envelope["request"]) + if "exec_expires_at_unix_ms" in envelope: + request["exec_expires_at_unix_ms"] = envelope["exec_expires_at_unix_ms"] + return json.dumps( + request, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode() + + +def check_vectors(): + vectors = json.loads((ROOT / "proto/sandbox_protocol/v1/vectors.json").read_text()) + assert vectors["version"] == "1.0" + for vector in vectors["requests"]: + canonical = canonical_request(vector["envelope"]) + assert canonical == vector["canonical_request"].encode() + assert ( + hashlib.sha256(canonical).hexdigest() + == vector["envelope"]["payload_digest"] + ) + data = bytes.fromhex(vector["frame_hex"]) + assert struct.unpack(">I", data[:4])[0] == len(data) - 4 + assert json.loads(data[4:]) == vector["envelope"] + print("PASS: public request digest and framing vectors") + + +async def main(binary): + check_vectors() + with tempfile.TemporaryDirectory(prefix="openshell-interop-") as directory: + directory = Path(directory) + assert ( + protoc.main( + [ + "protoc", + f"-I{ROOT / 'proto'}", + f"-I{Path(grpc_tools.__file__).parent / '_proto'}", + f"--python_out={directory}", + f"--grpc_python_out={directory}", + *[ + str(ROOT / "proto" / name) + for name in [ + "options.proto", + "extension.proto", + "isolation_backend.proto", + "sandbox_protocol.proto", + ] + ], + ] + ) + == 0 + ) + sys.path.insert(0, str(directory)) + import extension_pb2 as extension + import isolation_backend_pb2 as control + import isolation_backend_pb2_grpc as control_rpc + import sandbox_protocol_pb2 as stream + import sandbox_protocol_pb2_grpc as stream_rpc + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "backend.test")]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(hours=1)) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True + ) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("backend.test")]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + ca_pem = cert.public_bytes(serialization.Encoding.PEM) + ca_key = key + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(hours=1)) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True + ) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("backend.test")]), + critical=False, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False + ) + .sign(ca_key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + session = str(uuid.uuid4()) + identity = { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [], + "source": "fixture", + "resource_digest": "fixture-rootfs", + } + fence = { + "generation": "interop-generation", + "established": [ + "default_deny_egress", + "no_unmanaged_egress_path", + "revocation_verified", + "controller_loss_fails_closed", + ], + "evidence_digest": "1" * 64, + } + events = [] + opened_peers = set() + replay = {} + confirmed = False + process = None + finished = asyncio.Event() + started = asyncio.Event() + handle = "external-route" + + class Backend( + control_rpc.IsolationBackendServicer, + stream_rpc.DelegatedIsolationBoundaryServicer, + ): + async def authenticated(self, context): + assert ( + dict(context.invocation_metadata()).get("authorization") + == f"Bearer {TOKEN}" + ) + + async def GetCapabilities(self, request, context): + assert request.supervisor.protocol_version.major == 1 + assert ( + "openshell.isolation.contract" + in request.supervisor.supported_capabilities + ) + assert "authorization" not in dict(context.invocation_metadata()) + events.append("negotiate") + return control.GetIsolationCapabilitiesResponse( + backend=extension.PeerMetadata( + protocol_version=extension.ProtocolVersion(major=1, minor=0), + implementation_name="python-interop", + implementation_version="1.0", + supported_capabilities=["openshell.isolation.contract"], + required_capabilities=["openshell.isolation.contract"], + ) + ) + + async def OpenBoundary(self, request, context): + await self.authenticated(context) + assert request.backend_name == "python-external" + assert request.driver_descriptor == PAYLOAD + assert ( + request.sandbox_id, + request.generation_id, + request.session_id, + ) == ("interop-sandbox", "interop-generation", session) + opened_peers.add(context.peer()) + events.append("open") + return control.OpenBoundaryResponse(boundary_id=handle, outcome=1) + + async def Exchange(self, requests, context): + try: + async for chunk in self.exchange(requests, context): + yield chunk + except Exception: + traceback.print_exc() + raise + + async def exchange(self, requests, context): + nonlocal confirmed, process + await self.authenticated(context) + assert context.peer() in opened_peers + requests = aiter(requests) + binding = await anext(requests) + assert ( + binding.WhichOneof("payload") == "boundary_id" + and binding.boundary_id == handle + ) + await context.send_initial_metadata(()) + data = bytearray() + async for chunk in requests: + assert chunk.WhichOneof("payload") == "data" + data.extend(chunk.data) + if len(data) >= 4: + length = struct.unpack(">I", data[:4])[0] + assert 0 < length <= 1048576 + if len(data) >= 4 + length: + break + length = struct.unpack(">I", data[:4])[0] + assert 0 < length <= 1048576 + envelope = json.loads(data[4 : 4 + length]) + request = envelope["request"] + canonical = canonical_request(envelope) + assert ( + hashlib.sha256(canonical).hexdigest() == envelope["payload_digest"] + ) + operation = request["operation"] + events.append(operation) + if envelope["request_id"] in replay: + digest, response = replay[envelope["request_id"]] + assert digest == envelope["payload_digest"] + elif operation == "discover_policy": + response = { + "result": "image_policy", + "yaml": None, + "invalid": False, + } + elif operation == "attach": + response = { + "result": "attached", + "snapshot": { + "generation": "interop-generation", + "processes": [], + }, + } + elif operation == "confirm": + assert "attach" in events + confirmed = True + prop = {"enforced": True, "mechanism": "interop-fixture"} + response = { + "result": "confirmed", + "confirmation": { + "generation": "interop-generation", + "session_id": session, + "identity": identity, + "outer_fence": fence, + "resource_claims": {}, + "authenticated_supervisor": True, + "runtime_exit_terminates_workload": True, + "properties": dict.fromkeys( + [ + "filesystem_confinement", + "egress_interception", + "request_attribution", + "privilege_floor", + ], + prop, + ), + "backend_audit": {"fixture": "python-no-linux-audit"}, + }, + } + elif operation == "start_agent": + assert confirmed + spec = request["spec"] + assert spec["program"] == "/bin/sleep" and spec["args"] == ["60"] + process = await asyncio.create_subprocess_exec( + spec["program"], *spec["args"] + ) + started.set() + response = { + "result": "started", + "process_id": "external-main", + "provider_env_revision": request["provider_env_revision"], + "provider_env_generation": 1, + } + elif operation == "attach_process": + response = {"result": "process_attached", "terminal": False} + elif operation == "signal": + assert request["signal"] == "term" + process.terminate() + response = {"result": "signaled"} + elif operation == "wait": + code = await process.wait() + response = { + "result": "exited", + "status": { + "kind": "signaled" if code < 0 else "exited", + "value": abs(code), + }, + } + elif operation == "accept_network": + await finished.wait() + response = { + "result": "error", + "kind": "terminated", + "message": "fixture boundary ended", + } + elif operation == "open_mediation": + response = {"result": "mediation_ready"} + elif operation == "terminate_boundary": + assert process.returncode is not None + finished.set() + response = {"result": "boundary_terminated"} + else: + raise AssertionError(f"unexpected operation {operation}") + replay[envelope["request_id"]] = envelope["payload_digest"], response + yield stream.DelegatedBoundaryChunk( + data=frame( + {"request_id": envelope["request_id"], "response": response} + ) + ) + if operation == "open_mediation": + await finished.wait() + if operation == "attach_process": + status = await process.wait() + payload = json.dumps( + { + "kind": "signaled" if status < 0 else "exited", + "value": abs(status), + }, + separators=(",", ":"), + ).encode() + yield stream.DelegatedBoundaryChunk( + data=bytes([3]) + struct.pack(">I", len(payload)) + payload + ) + + async def Mediate(self, requests, context): + async for chunk in self.Exchange(requests, context): + yield chunk + + server = grpc.aio.server() + backend = Backend() + control_rpc.add_IsolationBackendServicer_to_server(backend, server) + stream_rpc.add_DelegatedIsolationBoundaryServicer_to_server(backend, server) + port = server.add_secure_port( + "127.0.0.1:0", grpc.ssl_server_credentials([(key_pem, cert_pem)]) + ) + await server.start() + registration = { + "backends": [ + { + "name": "python-external", + "endpoint": { + "kind": "tcp", + "authority": f"backend.test:{port}", + "addresses": [f"127.0.0.1:{port}"], + }, + } + ] + } + launch = { + "backend_name": "python-external", + "sandbox_id": "interop-sandbox", + "generation": "interop-generation", + "session_id": session, + "workload_identity": identity, + "resource_claims": {}, + "outer_fence": fence, + "tls": {"server_name": "backend.test", "trust_anchor_pem": ca_pem.decode()}, + } + auth = { + "session_id": session, + "runtime_generation": "interop-generation", + "session_rotation": 1, + "auth_epoch": 1, + "gateway_token": TOKEN, + "gateway_expires_at": 0, + "sandbox_token": TOKEN, + "sandbox_expires_at": 0, + } + for name, value in [ + ("registration.json", registration), + ("launch.json", launch), + ("auth.json", auth), + ]: + (directory / name).write_text(json.dumps(value)) + (directory / "descriptor").write_bytes(PAYLOAD) + (directory / "policy.yaml").write_text("network_policies: {}\n") + environment = dict( + { + name: value + for name, value in os.environ.items() + if not name.startswith("OPENSHELL_") + }, + OPENSHELL_ADMITTED_ISOLATION_BACKEND="python-external", + OPENSHELL_TELEMETRY_ENABLED="false", + OPENSHELL_PROXY_TLS_DIR=str(directory / "tls"), + ) + supervisor = await asyncio.create_subprocess_exec( + str(Path(binary).resolve()), + "--health-socket-path", + str(directory / "ready.sock"), + "--sandbox-id", + "interop-sandbox", + "--policy-rules", + str(ROOT / "crates/openshell-supervisor-network/data/sandbox-policy.rego"), + "--policy-data", + str(directory / "policy.yaml"), + "--isolation-backends-file", + str(directory / "registration.json"), + "--boundary-launch-file", + str(directory / "launch.json"), + "--backend-descriptor-file", + str(directory / "descriptor"), + "--auth-bundle-file", + str(directory / "auth.json"), + "--", + "/bin/sleep", + "60", + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output_task = asyncio.create_task(supervisor.stdout.read()) + + async def shutdown_when_ready(): + await started.wait() + while not (directory / "ready.sock").exists(): + assert supervisor.returncode is None, (await output_task).decode() + await asyncio.sleep(0.01) + supervisor.send_signal(signal.SIGTERM) + await supervisor.wait() + + try: + await asyncio.wait_for(shutdown_when_ready(), timeout=30) + output = await output_task + assert supervisor.returncode == 128 + signal.SIGTERM, output.decode() + assert finished.is_set(), output.decode() + assert events.index("confirm") < events.index("start_agent") + print( + "PASS: stock supervisor → registered Python backend → workload → acknowledged cleanup" + ) + finally: + if supervisor.returncode is None: + supervisor.kill() + await supervisor.wait() + print((await output_task).decode(), file=sys.stderr) + print(Counter(events), events[-10:], file=sys.stderr) + if process is not None and process.returncode is None: + process.kill() + await process.wait() + await server.stop(0) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1])) diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 2dd0493b27..82c8eb03e9 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -315,6 +315,8 @@ Common findings: - 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. - 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. +- An external isolation backend fails selection or registration: check `openshell.gateway.compute_driver`, `isolation_backend` in that driver's `[openshell.drivers.]` table, and `openshell.supervisor.isolation_backends` in the operator's gateway configuration. The selected compute driver must advertise `openshell.compute.isolation-backend-registration`; unsupported drivers fail startup. Check that the driver stages the resolved registration through the supervisor's protected `--isolation-backends-file`, independently admitted name, and `--boundary-launch-file`. Endpoint addresses must be reachable from the supervisor placement. Names match exactly; duplicate registrations and built-in replacement are rejected. See the published gateway configuration reference and Sandbox Runtimes documentation. +- 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/test.toml b/tasks/test.toml index c1df20262a..1e5a2750cc 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -99,6 +99,14 @@ depends = ["e2e:rust", "e2e:python", "e2e:mcp"] description = "Build the current checkout and run a named host or Nix test-guest E2E suite" run = "e2e/run.sh" +["e2e:isolation-protocol"] +description = "Run the stock supervisor against an independently implemented Python Sandbox Protocol backend" +run = [ + "cargo build --locked -p openshell-supervisor", + 'uv run e2e/isolation/protocol_interop.py "${CARGO_TARGET_DIR:-target}/debug/openshell-supervisor"', +] +run_windows = "echo Skipping e2e:isolation-protocol: the workload fixture requires /bin/sleep." + ["e2e:gpu"] description = "Run Docker GPU end-to-end tests" depends = ["e2e:docker:gpu"] From fb22a97f4e8d86c3d9d35776c19a8675adce3e0b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 16:17:49 -0700 Subject: [PATCH 07/24] refactor(sandbox): simplify runtime and fix backend example Signed-off-by: Drew Newberry --- crates/openshell-sandbox-backend/README.md | 2 +- .../openshell-sandbox-backend/src/runtime.rs | 44 +- .../openshell-sandbox/src/boundary_server.rs | 24 +- .../tests/gateway_config_serializer_test.yaml | 24 - examples/isolation-backend-wasi/README.md | 121 ++++ examples/isolation-backend-wasi/example.py | 607 ++++++++++++++++++ examples/isolation-backend-wasi/guest.wat | 55 ++ proto/sandbox_backend.proto | 9 +- proto/sandbox_protocol/v1/README.md | 27 +- 9 files changed, 848 insertions(+), 65 deletions(-) create mode 100644 examples/isolation-backend-wasi/README.md create mode 100644 examples/isolation-backend-wasi/example.py create mode 100644 examples/isolation-backend-wasi/guest.wat diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 1c818e26bf..51f217f8f1 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -5,7 +5,7 @@ control protocol shared with `openshell-sandbox`. ## Boundary startup and routing -The supervisor uses the generated `IsolationBackend` control service to +The supervisor uses the generated `SandboxBackend` 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; diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index cb43653ed9..87578915c7 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -122,12 +122,7 @@ impl OpenShellRuntimeBackend { ) -> 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(), - )), - } + client.discover_policy().await } pub fn new( @@ -201,12 +196,7 @@ impl DelegatedRuntimeBackend { validate_runtime_coordinates(&descriptor)?; let client = BoundaryClient::with_backend(descriptor, bearer, backend_name, driver_descriptor); - match client.call_idempotent(Request::DiscoverPolicy).await? { - Response::ImagePolicy { yaml, invalid } => Ok((yaml, invalid)), - _ => Err(BackendError::Descriptor( - "expected image policy discovery response".to_string(), - )), - } + client.discover_policy().await } } @@ -285,7 +275,10 @@ impl IsolationBackend for OpenShellRuntimeBackend { )); } Ok(Box::new(RemoteBound { - audit_validator: self.audit_validator.clone(), + audit_validator: self + .delegated + .is_none() + .then(|| self.audit_validator.clone()), client: client.clone(), agent: sandbox.agent, policy: sandbox.policy, @@ -299,7 +292,6 @@ impl IsolationBackend for OpenShellRuntimeBackend { session_id, resource_claims, outer_fence, - native_audit: self.delegated.is_none(), })) } } @@ -314,11 +306,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(), @@ -403,7 +390,7 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result, + audit_validator: Option>, client: Arc, agent: AgentSpec, policy: openshell_core::policy::SandboxPolicy, @@ -417,7 +404,6 @@ struct RemoteBound { session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, - native_audit: bool, } #[async_trait] @@ -445,8 +431,8 @@ impl BoundBoundary for RemoteBound { .to_string(), )); } - if self.native_audit { - let properties = self.audit_validator.validate(&confirmation.backend_audit)?; + if let Some(validator) = &self.audit_validator { + let properties = validator.validate(&confirmation.backend_audit)?; if confirmation.properties != properties { return Err(BackendError::Confirm( "sandbox confirmation properties do not match validated audit evidence" @@ -1360,6 +1346,15 @@ 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, @@ -3384,8 +3379,7 @@ mod tests { test_bearer(&expected_token), )); let bound = RemoteBound { - audit_validator: Arc::new(crate::audit::LinuxBoundaryAuditValidator), - native_audit: true, + audit_validator: Some(Arc::new(crate::audit::LinuxBoundaryAuditValidator)), client: client.clone(), agent: context.agent, policy: context.policy, diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 03802c300a..63a2fac0fb 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -5315,11 +5315,29 @@ mod linux { .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(DescribeSandboxBackendRequest { + supervisor: Some(unmet_requirement), + }) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); control .describe(DescribeSandboxBackendRequest { - supervisor: Some(openshell_sandbox_backend::isolation_protocol::metadata( - "test/supervisor", - )), + supervisor: Some(compatible), }) .await .unwrap(); diff --git a/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml b/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml index 423e6b4b73..8d0afc30bc 100644 --- a/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_serializer_test.yaml @@ -9,30 +9,6 @@ release: namespace: serializer-namespace tests: - - it: renders operator isolation selection and endpoint registrations - set: - gatewayConfig: - openshell.gateway: - compute_driver: agent-substrate - openshell.drivers.agent-substrate: - socket_path: /run/agent-substrate/compute.sock - isolation_backend: agent-substrate - openshell.supervisor.isolation_backends: - - name: agent-substrate - endpoint: - kind: unix - socket_path: /run/agent-substrate/isolation.sock - asserts: - - matchRegex: - path: data["gateway.toml"] - pattern: '(?m)^\[openshell\.drivers\.agent-substrate\]\n(?:[^\[]*\n)*isolation_backend = "agent-substrate"$' - - matchRegex: - path: data["gateway.toml"] - pattern: '(?m)^\[\[openshell\.supervisor\.isolation_backends\]\]$' - - matchRegex: - path: data["gateway.toml"] - pattern: '(?m)^endpoint = \{ kind = "unix", socket_path = "/run/agent-substrate/isolation\.sock" \}$' - - it: gives gatewayConfig precedence over a deprecated proxy alias set: upstreamProxy.url: http://legacy-proxy.example:8080 diff --git a/examples/isolation-backend-wasi/README.md b/examples/isolation-backend-wasi/README.md new file mode 100644 index 0000000000..2e5bd3fe2d --- /dev/null +++ b/examples/isolation-backend-wasi/README.md @@ -0,0 +1,121 @@ +# Partial Wasmtime isolation backend + +This example explores WebAssembly as a different isolation mechanism for the +OpenShell backend contract. It contains a Python protobuf service and a local +WASIp1 guest runner. It does **not** implement a complete supervisor backend. + +The service uses the repository's actual `SandboxBackend` and +`SandboxBoundary` protobuf definitions. Generated Python bindings live +in a temporary directory, using `proto/sandbox_backend.proto` and +`proto/sandbox_protocol.proto`. Negotiation uses `SandboxBackend.Describe`; streams +carry `BoundaryChunk` messages. No Rust crate, workspace dependency, or runtime +selection code is changed. + +## Run the experiment + +From the repository root: + +```shell +uv run examples/isolation-backend-wasi/example.py smoke +``` + +`uv` installs the script's pinned dependencies. The smoke command launches the +backend in a separate Python process and calls it over loopback gRPC. It then runs +real WebAssembly guests through the separate local runner. It checks: + +- Experimental capability negotiation and rejection of the full supervisor contract. +- Credential checks and binding to the provisioned sandbox, session, generation, + backend name, and SHA-256 digest of the compiled guest bytes. +- Identical boundary handles on an exact retry; rejection of changed inputs. +- Stream routing and explicit `UNIMPLEMENTED` for unsupported operations. +- Reading an allowed file through a read-only `/work` preopen. +- Denial of parent traversal, a symlink pointing at an existing outside file, and writes. +- Fuel exhaustion, a 16 MiB guest memory limit, and rejection of an unprovided socket import. +- Interrupting a running guest using its backend-owned Wasmtime engine. + +The guest is handwritten WebAssembly text in `guest.wat`; no WASI compiler SDK is +needed. File-denial checks trap if an operation unexpectedly succeeds. + +The two execution paths are deliberately distinct: + +```text +smoke client -> external protobuf service -> reserve a module binding; no execution +local runner -> Wasmtime/WASIp1 -> execute a guest with limited capabilities +``` + +An `OpenBoundary` call never instantiates the guest. Instantiation can execute a +WebAssembly start function, so it belongs behind an eventual admitted start +operation, not resource binding or confirmation. + +## Contract coverage + +| Contract area | Prototype behavior | Remaining work | +| --- | --- | --- | +| Backend selection | Own name `example-wasmtime`; descriptor is a provisioned module digest | Generic supervisor setup and trusted driver provisioning | +| Protocol negotiation | Protocol 1.0 with `openshell.example.wasi-prototype` only | Meet the mandatory base contract before advertising `openshell.sandbox.protocol` | +| Authentication | Fixed launch credential loaded from a trusted file; loopback-only service | Production authenticated transport, launch JWT verification, renewal, and supervisor ownership | +| Boundary binding | Exact launch coordinates and descriptor; idempotent reservation | Durable resource ownership, driver deletion, generation replacement, and handle invalidation | +| Attach / confirm / start | `Exchange` validates the route, then returns `UNIMPLEMENTED`; no workload starts | Policy translation, evidence, admission, lifecycle framing, and replay ledger | +| Filesystem confinement | Local runner grants one read-only WASI directory | Translate authored filesystem policy into WASI capabilities | +| Network | Local runner grants no socket-creation capability | Implement guest TCP/DNS host calls and route their decisions and byte streams through the supervisor | +| Workload identity | Runner records SHA-256 of compiled guest bytes | Define guest module path and identity in supervisor policy and audit records | +| Process lifecycle | Local runner captures bounded stdout/stderr, exit codes, traps, and engine interruption | Expose owned guest instances through the supervisor process contract | +| Exec / PTY / signals / forwarding | Unsupported on the wire | Define useful guest semantics or explicit unsupported results | +| Suspend / resume | Authenticated `UNIMPLEMENTED` | Retained-state semantics and old-generation fencing | + +The existing base capability is a bundle of mandatory guarantees. A partial +implementation cannot safely advertise that capability just because it implements +the control RPCs. Consequently, a production supervisor's negotiation fails for +this example. The smoke peer explicitly negotiates the experimental capability. + +This is a contract probe, not a route for bypassing supervisor confirmation. +The local `run` command is unreachable through the service and does not install +OpenShell policy, credentials, or mediation. + +## What this exposes + +WASI guest instances have module identities, not useful host PIDs. The host +Wasmtime executable must not be used as the guest's authorization identity. +The existing executable path/digest shape could represent a module, but the +trusted path mapping and lineage semantics still need a decision. + +The mediation contract includes kernel socket cookies, process generations, +syscall timing, and errno-oriented socket decisions. A guest socket host call +does not naturally produce those measurements. A real backend must define +appropriate trusted guest identifiers or prompt a contract change; it must not +fabricate Linux kernel evidence. + +Lifecycle support also needs capability decisions. There is no shell, ordinary +fork/exec, PTY, or complete POSIX signal model in this runner. Termination can +interrupt guest execution, but it is not automatically equivalent to every +signal operation. + +Finally, `BoundaryErrorKind` currently has no `Unsupported` variant even though +the Rust interface does. This prototype rejects the stream with gRPC +`UNIMPLEMENTED` rather than inventing an inner error value. An admitted partial +backend would need a consistent way to report optional operation support. + +## Implementation limits + +The runner uses WASIp1 imports supplied by Wasmtime, without inheriting host +environment variables, stdin, or network descriptors. Only `/work` is preopened, +read-only. Guest memory is limited to 16 MiB, tables to 1,024 elements, and each +captured output stream to 64 KiB. Fuel bounds execution; epoch interruption +implements termination for the running WebAssembly guest. + +These guest limits do not bound all host resources. Module compilation and host +WASI calls are not governed by instruction fuel, and interruption is not a +general cancellation mechanism for blocking host calls. Production resource +accounting and termination acknowledgement remain open work. + +The service is plaintext loopback gRPC with a prototype bearer credential. It +serves one preprovisioned launch and retains state only for its process lifetime. +It does not accept request-controlled module or filesystem paths. + +The next integration milestone is an admitted, networkless WASI workload using +attach, confirmation, start, output, wait, and termination through the supervisor. +The following milestone would add a mediated guest socket host function with +authoritative module identity. Neither is implemented here. + +Sources: [Wasmtime security model](https://docs.wasmtime.dev/security.html) and +[Wasmtime Python API](https://bytecodealliance.github.io/wasmtime-py/). diff --git a/examples/isolation-backend-wasi/example.py b/examples/isolation-backend-wasi/example.py new file mode 100644 index 0000000000..657e50bf80 --- /dev/null +++ b/examples/isolation-backend-wasi/example.py @@ -0,0 +1,607 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "grpcio==1.78.0", +# "grpcio-tools==1.78.0", +# "protobuf==6.33.5", +# "wasmtime==49.0.0", +# ] +# /// +"""Partial external isolation backend and a separate, local WASI guest runner. + +This is deliberately not a production supervisor backend. Its protobuf endpoint +reserves resources but cannot authorize guest execution. See README.md for gaps. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib +import json +import secrets +import subprocess +import sys +import tempfile +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path + +import grpc +import grpc_tools +import wasmtime +from grpc_tools import protoc + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +BACKEND = "example-wasmtime" +CAPABILITY = "openshell.example.wasi-prototype" +MAX_MODULE = 4 * 1024 * 1024 +MAX_OUTPUT = 64 * 1024 + + +def generate(directory: str): + """Generate from repository protos into temporary storage, never the tree.""" + includes = [ROOT / "proto"] + result = protoc.main( + [ + "protoc", + *(f"-I{path}" for path in includes), + f"-I{Path(grpc_tools.__file__).parent / '_proto'}", + f"--python_out={directory}", + f"--grpc_python_out={directory}", + "options.proto", + "extension.proto", + "sandbox_backend.proto", + "sandbox_protocol.proto", + ] + ) + if result: + raise RuntimeError("protobuf generation failed") + sys.path.insert(0, directory) + # Keep Python from emitting bytecode into the temporary generated directory. + sys.dont_write_bytecode = True + return tuple( + importlib.import_module(name) + for name in ( + "extension_pb2", + "sandbox_backend_pb2", + "sandbox_backend_pb2_grpc", + "sandbox_protocol_pb2", + "sandbox_protocol_pb2_grpc", + ) + ) + + +def read_module(path: Path) -> bytes: + with path.open("rb") as source: + payload = source.read(MAX_MODULE + 1) + if len(payload) > MAX_MODULE: + raise ValueError("module exceeds the 4 MiB prototype limit") + return bytes(wasmtime.wat2wasm(payload)) if path.suffix == ".wat" else payload + + +@dataclass(frozen=True) +class Provisioned: + """Trusted local provisioning. Credentials never come from request claims.""" + + sandbox_id: str + session_id: str + generation_id: str + token: str + module: bytes + + @property + def descriptor(self) -> bytes: + return json.dumps( + {"module_sha256": hashlib.sha256(self.module).hexdigest()}, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +class Endpoint: + def __init__(self, protos, provisioned: Provisioned): + self.extension, self.control, _, _, _ = protos + self.provisioned = provisioned + self.lock = threading.Lock() + self.boundary_id: str | None = None + + def metadata(self): + return self.extension.PeerMetadata( + protocol_version=self.extension.ProtocolVersion(major=1, minor=0), + implementation_name=BACKEND, + implementation_version="0.0.0", + supported_capabilities=[CAPABILITY], + required_capabilities=[CAPABILITY], + ) + + def authenticate(self, context): + values = [ + item.value + for item in context.invocation_metadata() + if item.key == "authorization" + ] + if len(values) != 1 or not secrets.compare_digest( + values[0], f"Bearer {self.provisioned.token}" + ): + context.abort( + grpc.StatusCode.UNAUTHENTICATED, "invalid prototype credential" + ) + + def Describe(self, request, context): + local = self.metadata() + remote = request.supervisor + if ( + not remote.HasField("protocol_version") + or remote.protocol_version.major != 1 + or not set(local.required_capabilities) + <= set(remote.supported_capabilities) + or not set(remote.required_capabilities) + <= set(local.supported_capabilities) + ): + context.abort( + grpc.StatusCode.FAILED_PRECONDITION, + "partial WASI prototype does not implement the base isolation contract", + ) + return self.control.DescribeSandboxBackendResponse(backend=local) + + def OpenBoundary(self, request, context): + self.authenticate(context) + provisioned = self.provisioned + if ( + request.sandbox_id != provisioned.sandbox_id + or request.session_id != provisioned.session_id + or request.generation_id != provisioned.generation_id + or request.backend_name != BACKEND + ): + context.abort(grpc.StatusCode.PERMISSION_DENIED, "launch identity mismatch") + # Exact bytes deliberately form the immutable prototype descriptor. + # No request-controlled module path or host path is accepted. + if request.driver_descriptor != provisioned.descriptor: + context.abort( + grpc.StatusCode.PERMISSION_DENIED, "provisioned module mismatch" + ) + with self.lock: + if self.boundary_id is None: + self.boundary_id = secrets.token_hex(32) + boundary_id = self.boundary_id + # Only reserve the compiled-input identity: do not instantiate here. + # Instantiation can itself execute a WebAssembly start function. + return self.control.OpenBoundaryResponse( + boundary_id=boundary_id, + outcome=self.control.OpenBoundaryResponse.LAUNCH_OUTCOME_FRESH, + ) + + def route(self, requests, context): + self.authenticate(context) + first = next(requests, None) + if ( + first is None + or first.WhichOneof("payload") != "boundary_id" + or not first.boundary_id + ): + context.abort( + grpc.StatusCode.INVALID_ARGUMENT, "first frame must identify a boundary" + ) + with self.lock: + expected = self.boundary_id + if expected is None or not secrets.compare_digest(first.boundary_id, expected): + context.abort(grpc.StatusCode.PERMISSION_DENIED, "unknown boundary handle") + + def Exchange(self, requests, context): + self.route(requests, context) + context.abort( + grpc.StatusCode.UNIMPLEMENTED, + "WASI prototype has no attach/confirm/start implementation; no guest was run", + ) + + def Mediate(self, requests, context): + self.route(requests, context) + context.abort( + grpc.StatusCode.UNIMPLEMENTED, + "no TCP/DNS host capability is granted; mediation is not implemented", + ) + + def SuspendBoundary(self, _request, context): + self.authenticate(context) + context.abort( + grpc.StatusCode.UNIMPLEMENTED, "WASI memory snapshots are not implemented" + ) + + def ResumeBoundary(self, _request, context): + self.authenticate(context) + context.abort( + grpc.StatusCode.UNIMPLEMENTED, "WASI memory restoration is not implemented" + ) + + +def start_server(protos, provisioned: Provisioned): + _, _, control_grpc, _, stream_grpc = protos + endpoint = Endpoint(protos, provisioned) + server = grpc.server( + ThreadPoolExecutor(max_workers=4), + options=[ + ("grpc.max_receive_message_length", 1024 * 1024), + ], + ) + control_grpc.add_SandboxBackendServicer_to_server(endpoint, server) + stream_grpc.add_SandboxBoundaryServicer_to_server(endpoint, server) + port = server.add_insecure_port("127.0.0.1:0") + if not port: + raise RuntimeError("cannot bind loopback server") + server.start() + return server, f"127.0.0.1:{port}" + + +@dataclass(frozen=True) +class GuestResult: + status: str + exit_code: int | None + stdout: bytes + stderr: bytes + module_sha256: str + + +class Guest: + """Local experimental runner, intentionally unreachable through the RPCs.""" + + def __init__(self, module: bytes, workspace: Path, fuel: int = 1_000_000): + config = wasmtime.Config() + config.consume_fuel = True + config.epoch_interruption = True + self.engine = wasmtime.Engine(config) + self.module = wasmtime.Module(self.engine, module) + self.digest = hashlib.sha256(module).hexdigest() + self.workspace = workspace.resolve(strict=True) + self.fuel = fuel + self.started = threading.Event() + self.cancelled = threading.Event() + + def terminate(self): + self.cancelled.set() + self.engine.increment_epoch() + + def run(self) -> GuestResult: + stdout = bytearray() + stderr = bytearray() + + def capture(target): + def write(payload): + if len(target) + len(payload) > MAX_OUTPUT: + return -1 + target.extend(payload) + return len(payload) + + return write + + with wasmtime.Store(self.engine) as store: + store.set_limits( + memory_size=16 * 1024 * 1024, + table_elements=1024, + instances=1, + tables=1, + memories=1, + ) + store.set_fuel(self.fuel) + store.set_epoch_deadline(1) + wasi = wasmtime.WasiConfig() + wasi.argv = ["/work/guest.wasm"] + # No environment or stdin inheritance, and no network descriptors. + wasi.preopen_dir(str(self.workspace), "/work", fs_mutable=False) + wasi.stdout_custom = capture(stdout) + wasi.stderr_custom = capture(stderr) + store.set_wasi(wasi) + linker = wasmtime.Linker(self.engine) + linker.define_wasi() + if self.cancelled.is_set(): + return GuestResult("terminated", None, b"", b"", self.digest) + self.started.set() + try: + # This is the first operation that can execute guest code. + instance = linker.instantiate(store, self.module) + instance.exports(store)["_start"](store) + status, code = "exited", 0 + except wasmtime.ExitTrap as error: + status, code = "exited", error.code + except wasmtime.Trap as error: + if error.trap_code == wasmtime.TrapCode.INTERRUPT: + status = "terminated" + elif error.trap_code == wasmtime.TrapCode.OUT_OF_FUEL: + status = "fuel_exhausted" + else: + status = "trapped" + code = None + except wasmtime.WasmtimeError: + # Includes unsupported imports: no fallback host function. + status, code = "unsupported_module", None + return GuestResult(status, code, bytes(stdout), bytes(stderr), self.digest) + + +def expect_rpc_error(call, status): + try: + call() + except grpc.RpcError as error: + if error.code() != status: + raise AssertionError( + f"expected {status}, got {error.code()}: {error.details()}" + ) from error + else: + raise AssertionError(f"expected {status}") + + +def smoke(protos): + _extension, control, control_grpc, stream, stream_grpc = protos + payload = read_module(HERE / "guest.wat") + provisioned = Provisioned( + "sandbox-a", "session-a", "generation-a", secrets.token_hex(32), payload + ) + # Run the endpoint in a separate interpreter: the client shares only wire + # messages with it, not an in-process backend or generated service instance. + temporary = tempfile.TemporaryDirectory(prefix="openshell-wasi-server-") + token_file = Path(temporary.name) / "credential" + token_file.write_text(provisioned.token) + token_file.chmod(0o600) + process = subprocess.Popen( + [ + sys.executable, + str(HERE / "example.py"), + "serve", + str(HERE / "guest.wat"), + "--sandbox-id", + provisioned.sandbox_id, + "--session-id", + provisioned.session_id, + "--generation-id", + provisioned.generation_id, + "--token-file", + str(token_file), + ], + stdout=subprocess.PIPE, + text=True, + ) + try: + assert process.stdout is not None + announcement = json.loads(process.stdout.readline()) + address = announcement["endpoint"] + assert announcement["backend_name"] == BACKEND + assert announcement["descriptor"] == json.loads(provisioned.descriptor) + with grpc.insecure_channel(address) as channel: + grpc.channel_ready_future(channel).result(timeout=5) + client = control_grpc.SandboxBackendStub(channel) + streams = stream_grpc.SandboxBoundaryStub(channel) + auth = [("authorization", f"Bearer {provisioned.token}")] + peer = Endpoint(protos, provisioned).metadata() + caps = client.Describe( + control.DescribeSandboxBackendRequest(supervisor=peer), timeout=5 + ) + assert list(caps.backend.supported_capabilities) == [CAPABILITY] + peer.required_capabilities[:] = ["openshell.sandbox.protocol"] + expect_rpc_error( + lambda: client.Describe( + control.DescribeSandboxBackendRequest(supervisor=peer), timeout=5 + ), + grpc.StatusCode.FAILED_PRECONDITION, + ) + print( + "PASS negotiation: partial capability accepted; full supervisor contract rejected" + ) + + launch = control.OpenBoundaryRequest( + sandbox_id=provisioned.sandbox_id, + session_id=provisioned.session_id, + generation_id=provisioned.generation_id, + backend_name=BACKEND, + driver_descriptor=provisioned.descriptor, + ) + opened = client.OpenBoundary(launch, metadata=auth, timeout=5) + retry = client.OpenBoundary(launch, metadata=auth, timeout=5) + assert opened == retry and opened.boundary_id + expect_rpc_error( + lambda: client.OpenBoundary(launch, timeout=5), + grpc.StatusCode.UNAUTHENTICATED, + ) + for field, value in ( + ("sandbox_id", "sandbox-b"), + ("session_id", "other-session"), + ("generation_id", "stale"), + ("backend_name", "openshell"), + ("driver_descriptor", b"{}"), + ): + invalid = control.OpenBoundaryRequest() + invalid.CopyFrom(launch) + setattr(invalid, field, value) + expect_rpc_error( + lambda invalid=invalid: client.OpenBoundary( + invalid, metadata=auth, timeout=5 + ), + grpc.StatusCode.PERMISSION_DENIED, + ) + print( + "PASS binding: exact retry, credential, sandbox, session, generation, backend, module" + ) + + def exchange(first): + return list(streams.Exchange(iter([first]), metadata=auth, timeout=5)) + + expect_rpc_error( + lambda: exchange(stream.BoundaryChunk(data=b"bad")), + grpc.StatusCode.INVALID_ARGUMENT, + ) + expect_rpc_error( + lambda: exchange(stream.BoundaryChunk(boundary_id="other")), + grpc.StatusCode.PERMISSION_DENIED, + ) + expect_rpc_error( + lambda: exchange(stream.BoundaryChunk(boundary_id=opened.boundary_id)), + grpc.StatusCode.UNIMPLEMENTED, + ) + expect_rpc_error( + lambda: list( + streams.Mediate( + iter([stream.BoundaryChunk(boundary_id=opened.boundary_id)]), + metadata=auth, + timeout=5, + ) + ), + grpc.StatusCode.UNIMPLEMENTED, + ) + expect_rpc_error( + lambda: client.SuspendBoundary( + control.SuspendBoundaryRequest( + boundary_id=opened.boundary_id, + session_id=provisioned.session_id, + generation_id=provisioned.generation_id, + ), + metadata=auth, + timeout=5, + ), + grpc.StatusCode.UNIMPLEMENTED, + ) + expect_rpc_error( + lambda: client.ResumeBoundary( + control.ResumeBoundaryRequest(launch=launch), + metadata=auth, + timeout=5, + ), + grpc.StatusCode.UNIMPLEMENTED, + ) + print( + "PASS routing: invalid handles rejected; lifecycle, mediation, suspend/resume unsupported" + ) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + if process.stdout is not None: + process.stdout.close() + temporary.cleanup() + + with tempfile.TemporaryDirectory(prefix="openshell-wasi-smoke-") as directory: + outer = Path(directory) + workspace = outer / "workspace" + workspace.mkdir() + (workspace / "allowed.txt").write_text("workspace content\n") + (outer / "secret.txt").write_text("outside secret\n") + (workspace / "escape").symlink_to(outer / "secret.txt") + result = Guest(payload, workspace).run() + assert result.status == "exited" and result.exit_code == 0, result + assert ( + result.stdout + == b"workspace content\nescape denied\nsymlink denied\nwrite denied\n" + ), result + assert not (workspace / "new.txt").exists() + print( + "PASS real WASI: allowed file read; traversal, symlink escape, writes denied" + ) + + loop = bytes( + wasmtime.wat2wasm('(module (func (export "_start") (loop $l br $l)))') + ) + assert Guest(loop, workspace).run().status == "fuel_exhausted" + memory = bytes( + wasmtime.wat2wasm('(module (memory 257) (func (export "_start")))') + ) + assert Guest(memory, workspace).run().status == "unsupported_module" + network = bytes( + wasmtime.wat2wasm( + '(module (import "wasi:sockets/tcp" "create-socket" (func)) (func (export "_start")))' + ) + ) + assert Guest(network, workspace).run().status == "unsupported_module" + print( + "PASS limits: infinite loop fuel exhausted; excess memory and socket import rejected" + ) + + guest = Guest(loop, workspace, fuel=1_000_000_000_000) + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(guest.run) + assert guest.started.wait(timeout=5) + guest.terminate() + assert pending.result(timeout=5).status == "terminated" + print("PASS termination: running guest interrupted by its backend-owned engine") + print("Prototype smoke passed. No production supervisor workload was started.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser( + "smoke", help="real gRPC contract checks and real WASI isolation checks" + ) + runner = commands.add_parser( + "run", help="local guest experiment; bypasses supervisor admission" + ) + runner.add_argument("module", type=Path) + runner.add_argument("--workspace", type=Path, required=True) + server = commands.add_parser( + "serve", help="partial, loopback-only protobuf endpoint" + ) + server.add_argument("module", type=Path) + server.add_argument("--sandbox-id", required=True) + server.add_argument("--session-id", required=True) + server.add_argument("--generation-id", required=True) + server.add_argument("--token-file", type=Path, required=True) + args = parser.parse_args() + if args.command == "run": + result = Guest(read_module(args.module), args.workspace).run() + sys.stdout.buffer.write(result.stdout) + sys.stderr.buffer.write(result.stderr) + print( + json.dumps( + { + "status": result.status, + "exit_code": result.exit_code, + "module_sha256": result.module_sha256, + } + ), + file=sys.stderr, + ) + return 0 if result.status == "exited" and result.exit_code == 0 else 1 + with tempfile.TemporaryDirectory(prefix="openshell-wasi-proto-") as generated: + protos = generate(generated) + if args.command == "smoke": + smoke(protos) + else: + if not all((args.sandbox_id, args.session_id, args.generation_id)): + raise ValueError( + "sandbox, session, and generation identities must be nonempty" + ) + token = args.token_file.read_text().strip() + if len(token) < 32 or any(character.isspace() for character in token): + raise ValueError( + "prototype credential must have at least 32 non-whitespace characters" + ) + provisioned = Provisioned( + args.sandbox_id, + args.session_id, + args.generation_id, + token, + read_module(args.module), + ) + endpoint, address = start_server(protos, provisioned) + print( + json.dumps( + { + "endpoint": address, + "backend_name": BACKEND, + "descriptor": json.loads(provisioned.descriptor), + } + ), + flush=True, + ) + try: + endpoint.wait_for_termination() + except KeyboardInterrupt: + endpoint.stop(0).wait(timeout=5) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/isolation-backend-wasi/guest.wat b/examples/isolation-backend-wasi/guest.wat new file mode 100644 index 0000000000..8574f4a779 --- /dev/null +++ b/examples/isolation-backend-wasi/guest.wat @@ -0,0 +1,55 @@ +;; SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +;; SPDX-License-Identifier: Apache-2.0 + +;; Handwritten WASIp1 guest: no compiler SDK or platform-specific binary needed. +(module + (import "wasi_snapshot_preview1" "path_open" + (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_read" + (func $read (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $write (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_close" (func $close (param i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 128) "allowed.txt") + (data (i32.const 160) "../secret.txt") + (data (i32.const 192) "escape") + (data (i32.const 224) "new.txt") + (data (i32.const 256) "escape denied\n") + (data (i32.const 288) "symlink denied\n") + (data (i32.const 320) "write denied\n") + + (func $assert_zero (param $errno i32) + local.get $errno if unreachable end) + (func $assert_denied (param $errno i32) + local.get $errno i32.eqz if unreachable end) + (func $print (param $pointer i32) (param $length i32) + i32.const 0 local.get $pointer i32.store + i32.const 4 local.get $length i32.store + i32.const 1 i32.const 0 i32.const 1 i32.const 8 call $write call $assert_zero) + + (func (export "_start") (local $fd i32) + ;; Preopen fd 3. Read rights = FD_READ (2); returned fd stored at offset 16. + i32.const 3 i32.const 0 i32.const 128 i32.const 11 i32.const 0 + i64.const 2 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_zero + i32.const 16 i32.load local.set $fd + i32.const 0 i32.const 1024 i32.store + i32.const 4 i32.const 128 i32.store + local.get $fd i32.const 0 i32.const 1 i32.const 8 call $read call $assert_zero + i32.const 1024 i32.const 8 i32.load call $print + local.get $fd call $close call $assert_zero + + ;; Traversal beyond the preopen must fail. + i32.const 3 i32.const 0 i32.const 160 i32.const 13 i32.const 0 + i64.const 2 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_denied + i32.const 256 i32.const 14 call $print + + ;; Follow a symlink that points at a real file outside the granted directory. + i32.const 3 i32.const 1 i32.const 192 i32.const 6 i32.const 0 + i64.const 2 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_denied + i32.const 288 i32.const 15 call $print + + ;; O_CREAT (1), FD_WRITE (64) against a read-only preopen must fail. + i32.const 3 i32.const 0 i32.const 224 i32.const 7 i32.const 1 + i64.const 64 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_denied + i32.const 320 i32.const 13 call $print)) diff --git a/proto/sandbox_backend.proto b/proto/sandbox_backend.proto index 391fe83d1f..c29e6e688b 100644 --- a/proto/sandbox_backend.proto +++ b/proto/sandbox_backend.proto @@ -12,13 +12,16 @@ import "options.proto"; // trusted, authenticated endpoint selected by operator configuration or compute // driver provisioning. Workload control, network mediation, and byte streams // remain on the Sandbox Protocol. -// PeerMetadata negotiates the base contract and optional suspend/resume support. +// 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 -// with compatible minor versions; incompatible semantics require a major bump. +// 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 SandboxBackend { - // Negotiate the isolation contract before opening a boundary. This call + // Describe the backend and negotiate before opening a boundary. This call // conveys no sandbox credentials or driver descriptor. rpc Describe(DescribeSandboxBackendRequest) returns (DescribeSandboxBackendResponse); diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md index 54f8259c19..a85484312f 100644 --- a/proto/sandbox_protocol/v1/README.md +++ b/proto/sandbox_protocol/v1/README.md @@ -13,15 +13,24 @@ isolation mechanisms. Generate gRPC bindings from No OpenShell guest executable or linked backend implementation is required. The negotiated isolation version covers **both** the control RPCs and the -framing and JSON messages below. Version 1.0 requires -`openshell.sandbox.protocol` on both peers. An incompatible change to a -message, framing, authentication, or lifecycle guarantee requires a major bump. -A minor revision may add optional capabilities; it must preserve all 1.0 -messages and behavior. A new operation requires an advertised capability -before a sender uses it. Adding fields to an existing 1.0 request requires a -major bump: additional request fields are not part of 1.0 and must not be sent. -Unknown required capabilities fail startup. This -contract is independently versioned from OpenShell releases. +framing and JSON messages below. `Describe` exchanges the shared +[`PeerMetadata` and `ProtocolVersion`](../../extension.proto). Version 1.0 +requires `openshell.sandbox.protocol` on both peers. + +Follow the shared extension version rules: major versions change when peers +cannot safely interoperate; minor versions add optional fields or capabilities +and remain compatible when both peers' required capabilities are satisfied. +Unknown required capabilities fail negotiation. The protocol version is +independent of OpenShell releases and the diagnostic implementation version. + +Protobuf additions preserve existing field numbers and behavior; receivers +ignore unknown protobuf fields. A new operation or a field whose behavior the +sender relies on requires an advertised capability before use. JSON additions +must also preserve existing behavior: send new fields only after negotiating +receiver support, since existing JSON types may reject unknown fields or bind +their contents to a request digest. Without that support, use the existing +message shape and digest rules. Incompatible changes to messages, framing, +authentication, or lifecycle guarantees require a major bump. ## Trusted registration and launch From c9aa7517c4135200289357e7a3ff885cb1652cf1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 16:24:09 -0700 Subject: [PATCH 08/24] refactor(examples): organize WASI backend in Rust Signed-off-by: Drew Newberry --- .github/workflows/branch-checks.yml | 3 + examples/isolation-backend-wasi/Cargo.lock | 2868 +++++++++++++++++ examples/isolation-backend-wasi/Cargo.toml | 40 + examples/isolation-backend-wasi/README.md | 75 +- examples/isolation-backend-wasi/build.rs | 17 + examples/isolation-backend-wasi/example.py | 607 ---- .../isolation-backend-wasi/src/backend.rs | 215 ++ examples/isolation-backend-wasi/src/guest.rs | 175 + examples/isolation-backend-wasi/src/lib.rs | 6 + examples/isolation-backend-wasi/src/main.rs | 111 + .../isolation-backend-wasi/src/protocol.rs | 38 + .../isolation-backend-wasi/tests/guest.rs | 105 + .../isolation-backend-wasi/tests/protocol.rs | 268 ++ tasks/rust.toml | 3 + tasks/test.toml | 1 + 15 files changed, 3901 insertions(+), 631 deletions(-) create mode 100644 examples/isolation-backend-wasi/Cargo.lock create mode 100644 examples/isolation-backend-wasi/Cargo.toml create mode 100644 examples/isolation-backend-wasi/build.rs delete mode 100644 examples/isolation-backend-wasi/example.py create mode 100644 examples/isolation-backend-wasi/src/backend.rs create mode 100644 examples/isolation-backend-wasi/src/guest.rs create mode 100644 examples/isolation-backend-wasi/src/lib.rs create mode 100644 examples/isolation-backend-wasi/src/main.rs create mode 100644 examples/isolation-backend-wasi/src/protocol.rs create mode 100644 examples/isolation-backend-wasi/tests/guest.rs create mode 100644 examples/isolation-backend-wasi/tests/protocol.rs diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index e34a276039..750a8441a4 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-wasi/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-wasi/Cargo.toml --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) @@ -312,6 +314,7 @@ jobs: run: | cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml - name: Sandbox Protocol interoperability if: matrix.system == 'x86_64-linux' diff --git a/examples/isolation-backend-wasi/Cargo.lock b/examples/isolation-backend-wasi/Cargo.lock new file mode 100644 index 0000000000..e20e4b1ed9 --- /dev/null +++ b/examples/isolation-backend-wasi/Cargo.lock @@ -0,0 +1,2868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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 = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[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 = "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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cap-fs-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f0d0003a760918ed4b1e039a59e11769030416f79c8222551d22785f7f70d" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150941cefd3df4de2fea24604ba4949371576f62e527410298333f7d431a1bc6" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54c289326c70f1c697ebf0a31842a480932e5942b5fac92fcc46e87286b48e2" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix", + "winx", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[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 = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aadfd45214f09461ba0406f7dacecce5cac39bf0bdb4890eeb468f4ef090e07" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57069faede9bd1f926364b4cf69ccc9bebc6f4af5ab897083581b7bc991bf20a" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1221450f2f9efaa996d916bf53a0a4d13346a4a75468afb828390209a4fdd4" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97706f2b466d67bdf0f69d1cf5c5c4a5cd31087cf1fa2e8e5fe0450dddc8661d" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "432246ee44b2fc0140b412dca55801d200d6fb25d2d7d18c44bb29e8cd71268b" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aa51eff0949f06a5c8f3d5adefef94a12ea99f71cd8985047b91d45f367150" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6095e7095930b6169490138b4262fe7ec24290d3f604d7fe62e1e3138e740daa" + +[[package]] +name = "cranelift-control" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e61dfa0766c0eadf42b3febd145a35f81f31086e207f534e5fb406fc3d08fc47" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93867a79a06dd5d9ffeb96f7563dc7b6b142b5d89e42ab77e458a408a8aa9d5" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1991dc32fefa54f14d2a63570d1e963826257605e478558733f83450b7e7661" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784546de9988ff6e1df0fd4d2450760469a7607344156c661e69ec28082c5da4" + +[[package]] +name = "cranelift-native" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7107a4ae4a2ba48b5df8648365e638b08322fa1da11e86206bf5eddb862b21dd" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.133.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f161aa81ee5dd8d7b0eb8828988f57baf2f78d288e62b6b3714b37aa6d6a1" + +[[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 = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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 = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[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-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[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 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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", + "tinystr", + "writeable", + "zerovec", +] + +[[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", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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 = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[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.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 = "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 = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[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.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 = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "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-wasi-backend-example" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "prost", + "protoc-bin-vendored", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "uuid", + "wasmtime", + "wasmtime-wasi", + "wat", +] + +[[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 = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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", + "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", + "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 = "pulley-interpreter" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f647f8aefd39efd5e38b484a8b7a926dd035ae366127df75c1d94d196f0a4ba" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e902032e329008b189b67498b1be7784d5849276f32402eca17441df08a6bcb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[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 = "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 = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[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 = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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" +dependencies = [ + "serde", +] + +[[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 = "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 = "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 = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "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-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", + "socket2", + "sync_wrapper", + "tokio", + "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 = "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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "js-sys", + "wasm-bindgen", +] + +[[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 = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser 0.262.0", +] + +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8798c1a699bd25648b6708eefe94d97c6f9891febb94b42cca1f7a4b086ea64e" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.251.0", +] + +[[package]] +name = "wasmtime" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39226663bd765601279a79b88645a7c4a4d3c2fe73040afdc27bead02ee6659" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser 0.251.0", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2595a168541e8a7faeacf21e3d59da93628ee71468cd69fee9fc2a92c9b1c7d8" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder 0.251.0", + "wasmparser 0.251.0", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf0fb4ce458eb6edbfaf6cbf13fcbf4216eac5a85febfe90224a1f6064c990" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e50df18c9a8e0deec353ad1e6364ef50538231719a12d80fa5dcb300be8d36b" + +[[package]] +name = "wasmtime-internal-core" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b097389e5fad89c8ff8ee0aeafd16447b5a288d52aa1131f17ab4c19fd5be3" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08b3e909115836655b83c8bdef7c2e20a34293e785218d9f71a4e5382a09c6b0" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.21", + "wasmparser 0.251.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a000d1124dd40417ce8433d07de1063ecc9be7cac21745c8de7f9cf7684c3ead" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7439956e78ba68e2cd766f34c3f056d625fad9a055eafb0c4569ac91c09e6b4" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3a381a9dd1ffe3893507733300b420605f465655d78c7a75a965a09d7b43d5" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f951f78a1a09001547838fa557522d56655385db97e47307d0f582ca866974" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e06f9fabbe647e5c2ed186e6920ab1fb48174688d7d3fdadbda537b8499b51" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49ac5121036c2255bdc203b1b75a1c04544744cd5f51c93240b58bddca9f0f1" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wasmtime-wasi" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23668f18d6fd0a932c1fc58600bbb8a2e4f9b217f08bb61841e2c275d4ce3" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-std", + "cap-time-ext", + "cfg-if", + "futures", + "io-extras", + "io-lifetimes", + "rand", + "rustix", + "thiserror 2.0.21", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df5ffcd5aca96285be7745d4677ca5e2851245ca7c066a98c0fbee5bc6375f1" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "262.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.262.0", +] + +[[package]] +name = "wat" +version = "1.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" +dependencies = [ + "wast 262.0.0", +] + +[[package]] +name = "wiggle" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77deefc2b0fe8c258316372ec322da627ce8c5b5ea9eec3b001190e9f8f2f69" +dependencies = [ + "bitflags", + "thiserror 2.0.21", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a52145d29793eb1e0c5be9b6be5a74552b9236f01866c769ec1c3270584085f" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "46.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26242c5c54d0f2837e1fecc933e1581e45d39cde1b1f6b3ad72955e0851ec20" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "wiggle-generate", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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 = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-parser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e960732e824fab95099971a09e638979347c94ca48568d3c854c945729196947" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.251.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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 = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "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" diff --git a/examples/isolation-backend-wasi/Cargo.toml b/examples/isolation-backend-wasi/Cargo.toml new file mode 100644 index 0000000000..08250dfe1a --- /dev/null +++ b/examples/isolation-backend-wasi/Cargo.toml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-wasi-backend-example" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +publish = false + +[dependencies] +anyhow = "1" +clap = { version = "4.5", features = ["derive"] } +prost = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +subtle = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic = "0.14" +tonic-prost = "0.14" +uuid = { version = "1", features = ["v4"] } +wasmtime = { version = "46.0", default-features = false, features = ["anyhow", "cranelift", "runtime", "std", "wat"] } +wasmtime-wasi = { version = "46.0", default-features = false, features = ["p1"] } +wat = "1" + +[build-dependencies] +protoc-bin-vendored = "3" +tonic-prost-build = "0.14" + +[dev-dependencies] +tempfile = "3" + +[[bin]] +name = "wasi-backend" +path = "src/main.rs" diff --git a/examples/isolation-backend-wasi/README.md b/examples/isolation-backend-wasi/README.md index 2e5bd3fe2d..6f85b5dc8a 100644 --- a/examples/isolation-backend-wasi/README.md +++ b/examples/isolation-backend-wasi/README.md @@ -1,37 +1,64 @@ # Partial Wasmtime isolation backend This example explores WebAssembly as a different isolation mechanism for the -OpenShell backend contract. It contains a Python protobuf service and a local +OpenShell backend contract. It contains a Rust protobuf service and a local WASIp1 guest runner. It does **not** implement a complete supervisor backend. -The service uses the repository's actual `SandboxBackend` and -`SandboxBoundary` protobuf definitions. Generated Python bindings live -in a temporary directory, using `proto/sandbox_backend.proto` and -`proto/sandbox_protocol.proto`. Negotiation uses `SandboxBackend.Describe`; streams -carry `BoundaryChunk` messages. No Rust crate, workspace dependency, or runtime -selection code is changed. +The service generates Rust bindings from the repository's public +`SandboxBackend` and `SandboxBoundary` protobuf definitions at build time. It +links no OpenShell implementation crates and lives in its own Cargo workspace. + +## Code layout + +```text +build.rs Generate bindings from the public protos +src/main.rs CLI: serve the backend or run a local guest +src/protocol.rs Generated bindings, backend name, experimental capability +src/backend.rs Provisioning, authentication, boundary binding, RPC handlers +src/guest.rs Wasmtime configuration, WASI capabilities, execution limits +tests/protocol.rs Subprocess gRPC contract checks +tests/guest.rs Real guest confinement, resource limits, and interruption +guest.wat Small WASIp1 filesystem probe +``` ## Run the experiment -From the repository root: +From the repository root, with Rust 1.94 or newer: ```shell -uv run examples/isolation-backend-wasi/example.py smoke +cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml ``` -`uv` installs the script's pinned dependencies. The smoke command launches the -backend in a separate Python process and calls it over loopback gRPC. It then runs -real WebAssembly guests through the separate local runner. It checks: +The protocol test starts the compiled backend in a separate process and calls it +over loopback gRPC. Guest tests separately check read-only filesystem access, +parent traversal and symlink rejection, fuel and memory limits, bounded output, +unsupported socket imports, and engine interruption. The symlink check requires +Unix. `Cargo.lock` pins dependencies; Wasmtime 46 supports the repository's Rust +version. The first build compiles Wasmtime and Cranelift. + +Run a local guest against a directory containing `allowed.txt`: + +```shell +cargo run --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml -- \ + run examples/isolation-backend-wasi/guest.wat --workspace /path/to/workspace +``` + +The bundled filesystem probe also expects `/work/escape` to be a symlink to an +existing file outside the workspace. It asserts that reading that link fails. + +Start the partial service with trusted launch coordinates and a credential file +containing at least 32 non-whitespace characters: + +```shell +cargo run --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml -- \ + serve examples/isolation-backend-wasi/guest.wat \ + --sandbox-id sandbox-a --session-id session-a --generation-id generation-a \ + --token-file /path/to/protected/credential +``` -- Experimental capability negotiation and rejection of the full supervisor contract. -- Credential checks and binding to the provisioned sandbox, session, generation, - backend name, and SHA-256 digest of the compiled guest bytes. -- Identical boundary handles on an exact retry; rejection of changed inputs. -- Stream routing and explicit `UNIMPLEMENTED` for unsupported operations. -- Reading an allowed file through a read-only `/work` preopen. -- Denial of parent traversal, a symlink pointing at an existing outside file, and writes. -- Fuel exhaustion, a 16 MiB guest memory limit, and rejection of an unprovided socket import. -- Interrupting a running guest using its backend-owned Wasmtime engine. +The service prints JSON containing its ephemeral loopback endpoint, backend name, +and module descriptor. The credential stays in the protected file. Send it as +`authorization: Bearer ` on authenticated RPCs. The guest is handwritten WebAssembly text in `guest.wat`; no WASI compiler SDK is needed. File-denial checks trap if an operation unexpectedly succeeds. @@ -39,7 +66,7 @@ needed. File-denial checks trap if an operation unexpectedly succeeds. The two execution paths are deliberately distinct: ```text -smoke client -> external protobuf service -> reserve a module binding; no execution +protocol test -> external protobuf service -> reserve a module binding; no execution local runner -> Wasmtime/WASIp1 -> execute a guest with limited capabilities ``` @@ -66,7 +93,7 @@ operation, not resource binding or confirmation. The existing base capability is a bundle of mandatory guarantees. A partial implementation cannot safely advertise that capability just because it implements the control RPCs. Consequently, a production supervisor's negotiation fails for -this example. The smoke peer explicitly negotiates the experimental capability. +this example. The test peer explicitly negotiates the experimental capability. This is a contract probe, not a route for bypassing supervisor confirmation. The local `run` command is unreachable through the service and does not install @@ -118,4 +145,4 @@ The following milestone would add a mediated guest socket host function with authoritative module identity. Neither is implemented here. Sources: [Wasmtime security model](https://docs.wasmtime.dev/security.html) and -[Wasmtime Python API](https://bytecodealliance.github.io/wasmtime-py/). +[Wasmtime Rust WASIp1 API](https://docs.rs/wasmtime-wasi/46.0.3/wasmtime_wasi/p1/index.html). diff --git a/examples/isolation-backend-wasi/build.rs b/examples/isolation-backend-wasi/build.rs new file mode 100644 index 0000000000..da952ad2f5 --- /dev/null +++ b/examples/isolation-backend-wasi/build.rs @@ -0,0 +1,17 @@ +// 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().compile_with_config( + config, + &[ + "../../proto/sandbox_backend.proto", + "../../proto/sandbox_protocol.proto", + ], + &["../../proto"], + )?; + println!("cargo:rerun-if-changed=../../proto"); + Ok(()) +} diff --git a/examples/isolation-backend-wasi/example.py b/examples/isolation-backend-wasi/example.py deleted file mode 100644 index 657e50bf80..0000000000 --- a/examples/isolation-backend-wasi/example.py +++ /dev/null @@ -1,607 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "grpcio==1.78.0", -# "grpcio-tools==1.78.0", -# "protobuf==6.33.5", -# "wasmtime==49.0.0", -# ] -# /// -"""Partial external isolation backend and a separate, local WASI guest runner. - -This is deliberately not a production supervisor backend. Its protobuf endpoint -reserves resources but cannot authorize guest execution. See README.md for gaps. -""" - -from __future__ import annotations - -import argparse -import hashlib -import importlib -import json -import secrets -import subprocess -import sys -import tempfile -import threading -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from pathlib import Path - -import grpc -import grpc_tools -import wasmtime -from grpc_tools import protoc - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -BACKEND = "example-wasmtime" -CAPABILITY = "openshell.example.wasi-prototype" -MAX_MODULE = 4 * 1024 * 1024 -MAX_OUTPUT = 64 * 1024 - - -def generate(directory: str): - """Generate from repository protos into temporary storage, never the tree.""" - includes = [ROOT / "proto"] - result = protoc.main( - [ - "protoc", - *(f"-I{path}" for path in includes), - f"-I{Path(grpc_tools.__file__).parent / '_proto'}", - f"--python_out={directory}", - f"--grpc_python_out={directory}", - "options.proto", - "extension.proto", - "sandbox_backend.proto", - "sandbox_protocol.proto", - ] - ) - if result: - raise RuntimeError("protobuf generation failed") - sys.path.insert(0, directory) - # Keep Python from emitting bytecode into the temporary generated directory. - sys.dont_write_bytecode = True - return tuple( - importlib.import_module(name) - for name in ( - "extension_pb2", - "sandbox_backend_pb2", - "sandbox_backend_pb2_grpc", - "sandbox_protocol_pb2", - "sandbox_protocol_pb2_grpc", - ) - ) - - -def read_module(path: Path) -> bytes: - with path.open("rb") as source: - payload = source.read(MAX_MODULE + 1) - if len(payload) > MAX_MODULE: - raise ValueError("module exceeds the 4 MiB prototype limit") - return bytes(wasmtime.wat2wasm(payload)) if path.suffix == ".wat" else payload - - -@dataclass(frozen=True) -class Provisioned: - """Trusted local provisioning. Credentials never come from request claims.""" - - sandbox_id: str - session_id: str - generation_id: str - token: str - module: bytes - - @property - def descriptor(self) -> bytes: - return json.dumps( - {"module_sha256": hashlib.sha256(self.module).hexdigest()}, - sort_keys=True, - separators=(",", ":"), - ).encode() - - -class Endpoint: - def __init__(self, protos, provisioned: Provisioned): - self.extension, self.control, _, _, _ = protos - self.provisioned = provisioned - self.lock = threading.Lock() - self.boundary_id: str | None = None - - def metadata(self): - return self.extension.PeerMetadata( - protocol_version=self.extension.ProtocolVersion(major=1, minor=0), - implementation_name=BACKEND, - implementation_version="0.0.0", - supported_capabilities=[CAPABILITY], - required_capabilities=[CAPABILITY], - ) - - def authenticate(self, context): - values = [ - item.value - for item in context.invocation_metadata() - if item.key == "authorization" - ] - if len(values) != 1 or not secrets.compare_digest( - values[0], f"Bearer {self.provisioned.token}" - ): - context.abort( - grpc.StatusCode.UNAUTHENTICATED, "invalid prototype credential" - ) - - def Describe(self, request, context): - local = self.metadata() - remote = request.supervisor - if ( - not remote.HasField("protocol_version") - or remote.protocol_version.major != 1 - or not set(local.required_capabilities) - <= set(remote.supported_capabilities) - or not set(remote.required_capabilities) - <= set(local.supported_capabilities) - ): - context.abort( - grpc.StatusCode.FAILED_PRECONDITION, - "partial WASI prototype does not implement the base isolation contract", - ) - return self.control.DescribeSandboxBackendResponse(backend=local) - - def OpenBoundary(self, request, context): - self.authenticate(context) - provisioned = self.provisioned - if ( - request.sandbox_id != provisioned.sandbox_id - or request.session_id != provisioned.session_id - or request.generation_id != provisioned.generation_id - or request.backend_name != BACKEND - ): - context.abort(grpc.StatusCode.PERMISSION_DENIED, "launch identity mismatch") - # Exact bytes deliberately form the immutable prototype descriptor. - # No request-controlled module path or host path is accepted. - if request.driver_descriptor != provisioned.descriptor: - context.abort( - grpc.StatusCode.PERMISSION_DENIED, "provisioned module mismatch" - ) - with self.lock: - if self.boundary_id is None: - self.boundary_id = secrets.token_hex(32) - boundary_id = self.boundary_id - # Only reserve the compiled-input identity: do not instantiate here. - # Instantiation can itself execute a WebAssembly start function. - return self.control.OpenBoundaryResponse( - boundary_id=boundary_id, - outcome=self.control.OpenBoundaryResponse.LAUNCH_OUTCOME_FRESH, - ) - - def route(self, requests, context): - self.authenticate(context) - first = next(requests, None) - if ( - first is None - or first.WhichOneof("payload") != "boundary_id" - or not first.boundary_id - ): - context.abort( - grpc.StatusCode.INVALID_ARGUMENT, "first frame must identify a boundary" - ) - with self.lock: - expected = self.boundary_id - if expected is None or not secrets.compare_digest(first.boundary_id, expected): - context.abort(grpc.StatusCode.PERMISSION_DENIED, "unknown boundary handle") - - def Exchange(self, requests, context): - self.route(requests, context) - context.abort( - grpc.StatusCode.UNIMPLEMENTED, - "WASI prototype has no attach/confirm/start implementation; no guest was run", - ) - - def Mediate(self, requests, context): - self.route(requests, context) - context.abort( - grpc.StatusCode.UNIMPLEMENTED, - "no TCP/DNS host capability is granted; mediation is not implemented", - ) - - def SuspendBoundary(self, _request, context): - self.authenticate(context) - context.abort( - grpc.StatusCode.UNIMPLEMENTED, "WASI memory snapshots are not implemented" - ) - - def ResumeBoundary(self, _request, context): - self.authenticate(context) - context.abort( - grpc.StatusCode.UNIMPLEMENTED, "WASI memory restoration is not implemented" - ) - - -def start_server(protos, provisioned: Provisioned): - _, _, control_grpc, _, stream_grpc = protos - endpoint = Endpoint(protos, provisioned) - server = grpc.server( - ThreadPoolExecutor(max_workers=4), - options=[ - ("grpc.max_receive_message_length", 1024 * 1024), - ], - ) - control_grpc.add_SandboxBackendServicer_to_server(endpoint, server) - stream_grpc.add_SandboxBoundaryServicer_to_server(endpoint, server) - port = server.add_insecure_port("127.0.0.1:0") - if not port: - raise RuntimeError("cannot bind loopback server") - server.start() - return server, f"127.0.0.1:{port}" - - -@dataclass(frozen=True) -class GuestResult: - status: str - exit_code: int | None - stdout: bytes - stderr: bytes - module_sha256: str - - -class Guest: - """Local experimental runner, intentionally unreachable through the RPCs.""" - - def __init__(self, module: bytes, workspace: Path, fuel: int = 1_000_000): - config = wasmtime.Config() - config.consume_fuel = True - config.epoch_interruption = True - self.engine = wasmtime.Engine(config) - self.module = wasmtime.Module(self.engine, module) - self.digest = hashlib.sha256(module).hexdigest() - self.workspace = workspace.resolve(strict=True) - self.fuel = fuel - self.started = threading.Event() - self.cancelled = threading.Event() - - def terminate(self): - self.cancelled.set() - self.engine.increment_epoch() - - def run(self) -> GuestResult: - stdout = bytearray() - stderr = bytearray() - - def capture(target): - def write(payload): - if len(target) + len(payload) > MAX_OUTPUT: - return -1 - target.extend(payload) - return len(payload) - - return write - - with wasmtime.Store(self.engine) as store: - store.set_limits( - memory_size=16 * 1024 * 1024, - table_elements=1024, - instances=1, - tables=1, - memories=1, - ) - store.set_fuel(self.fuel) - store.set_epoch_deadline(1) - wasi = wasmtime.WasiConfig() - wasi.argv = ["/work/guest.wasm"] - # No environment or stdin inheritance, and no network descriptors. - wasi.preopen_dir(str(self.workspace), "/work", fs_mutable=False) - wasi.stdout_custom = capture(stdout) - wasi.stderr_custom = capture(stderr) - store.set_wasi(wasi) - linker = wasmtime.Linker(self.engine) - linker.define_wasi() - if self.cancelled.is_set(): - return GuestResult("terminated", None, b"", b"", self.digest) - self.started.set() - try: - # This is the first operation that can execute guest code. - instance = linker.instantiate(store, self.module) - instance.exports(store)["_start"](store) - status, code = "exited", 0 - except wasmtime.ExitTrap as error: - status, code = "exited", error.code - except wasmtime.Trap as error: - if error.trap_code == wasmtime.TrapCode.INTERRUPT: - status = "terminated" - elif error.trap_code == wasmtime.TrapCode.OUT_OF_FUEL: - status = "fuel_exhausted" - else: - status = "trapped" - code = None - except wasmtime.WasmtimeError: - # Includes unsupported imports: no fallback host function. - status, code = "unsupported_module", None - return GuestResult(status, code, bytes(stdout), bytes(stderr), self.digest) - - -def expect_rpc_error(call, status): - try: - call() - except grpc.RpcError as error: - if error.code() != status: - raise AssertionError( - f"expected {status}, got {error.code()}: {error.details()}" - ) from error - else: - raise AssertionError(f"expected {status}") - - -def smoke(protos): - _extension, control, control_grpc, stream, stream_grpc = protos - payload = read_module(HERE / "guest.wat") - provisioned = Provisioned( - "sandbox-a", "session-a", "generation-a", secrets.token_hex(32), payload - ) - # Run the endpoint in a separate interpreter: the client shares only wire - # messages with it, not an in-process backend or generated service instance. - temporary = tempfile.TemporaryDirectory(prefix="openshell-wasi-server-") - token_file = Path(temporary.name) / "credential" - token_file.write_text(provisioned.token) - token_file.chmod(0o600) - process = subprocess.Popen( - [ - sys.executable, - str(HERE / "example.py"), - "serve", - str(HERE / "guest.wat"), - "--sandbox-id", - provisioned.sandbox_id, - "--session-id", - provisioned.session_id, - "--generation-id", - provisioned.generation_id, - "--token-file", - str(token_file), - ], - stdout=subprocess.PIPE, - text=True, - ) - try: - assert process.stdout is not None - announcement = json.loads(process.stdout.readline()) - address = announcement["endpoint"] - assert announcement["backend_name"] == BACKEND - assert announcement["descriptor"] == json.loads(provisioned.descriptor) - with grpc.insecure_channel(address) as channel: - grpc.channel_ready_future(channel).result(timeout=5) - client = control_grpc.SandboxBackendStub(channel) - streams = stream_grpc.SandboxBoundaryStub(channel) - auth = [("authorization", f"Bearer {provisioned.token}")] - peer = Endpoint(protos, provisioned).metadata() - caps = client.Describe( - control.DescribeSandboxBackendRequest(supervisor=peer), timeout=5 - ) - assert list(caps.backend.supported_capabilities) == [CAPABILITY] - peer.required_capabilities[:] = ["openshell.sandbox.protocol"] - expect_rpc_error( - lambda: client.Describe( - control.DescribeSandboxBackendRequest(supervisor=peer), timeout=5 - ), - grpc.StatusCode.FAILED_PRECONDITION, - ) - print( - "PASS negotiation: partial capability accepted; full supervisor contract rejected" - ) - - launch = control.OpenBoundaryRequest( - sandbox_id=provisioned.sandbox_id, - session_id=provisioned.session_id, - generation_id=provisioned.generation_id, - backend_name=BACKEND, - driver_descriptor=provisioned.descriptor, - ) - opened = client.OpenBoundary(launch, metadata=auth, timeout=5) - retry = client.OpenBoundary(launch, metadata=auth, timeout=5) - assert opened == retry and opened.boundary_id - expect_rpc_error( - lambda: client.OpenBoundary(launch, timeout=5), - grpc.StatusCode.UNAUTHENTICATED, - ) - for field, value in ( - ("sandbox_id", "sandbox-b"), - ("session_id", "other-session"), - ("generation_id", "stale"), - ("backend_name", "openshell"), - ("driver_descriptor", b"{}"), - ): - invalid = control.OpenBoundaryRequest() - invalid.CopyFrom(launch) - setattr(invalid, field, value) - expect_rpc_error( - lambda invalid=invalid: client.OpenBoundary( - invalid, metadata=auth, timeout=5 - ), - grpc.StatusCode.PERMISSION_DENIED, - ) - print( - "PASS binding: exact retry, credential, sandbox, session, generation, backend, module" - ) - - def exchange(first): - return list(streams.Exchange(iter([first]), metadata=auth, timeout=5)) - - expect_rpc_error( - lambda: exchange(stream.BoundaryChunk(data=b"bad")), - grpc.StatusCode.INVALID_ARGUMENT, - ) - expect_rpc_error( - lambda: exchange(stream.BoundaryChunk(boundary_id="other")), - grpc.StatusCode.PERMISSION_DENIED, - ) - expect_rpc_error( - lambda: exchange(stream.BoundaryChunk(boundary_id=opened.boundary_id)), - grpc.StatusCode.UNIMPLEMENTED, - ) - expect_rpc_error( - lambda: list( - streams.Mediate( - iter([stream.BoundaryChunk(boundary_id=opened.boundary_id)]), - metadata=auth, - timeout=5, - ) - ), - grpc.StatusCode.UNIMPLEMENTED, - ) - expect_rpc_error( - lambda: client.SuspendBoundary( - control.SuspendBoundaryRequest( - boundary_id=opened.boundary_id, - session_id=provisioned.session_id, - generation_id=provisioned.generation_id, - ), - metadata=auth, - timeout=5, - ), - grpc.StatusCode.UNIMPLEMENTED, - ) - expect_rpc_error( - lambda: client.ResumeBoundary( - control.ResumeBoundaryRequest(launch=launch), - metadata=auth, - timeout=5, - ), - grpc.StatusCode.UNIMPLEMENTED, - ) - print( - "PASS routing: invalid handles rejected; lifecycle, mediation, suspend/resume unsupported" - ) - finally: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - if process.stdout is not None: - process.stdout.close() - temporary.cleanup() - - with tempfile.TemporaryDirectory(prefix="openshell-wasi-smoke-") as directory: - outer = Path(directory) - workspace = outer / "workspace" - workspace.mkdir() - (workspace / "allowed.txt").write_text("workspace content\n") - (outer / "secret.txt").write_text("outside secret\n") - (workspace / "escape").symlink_to(outer / "secret.txt") - result = Guest(payload, workspace).run() - assert result.status == "exited" and result.exit_code == 0, result - assert ( - result.stdout - == b"workspace content\nescape denied\nsymlink denied\nwrite denied\n" - ), result - assert not (workspace / "new.txt").exists() - print( - "PASS real WASI: allowed file read; traversal, symlink escape, writes denied" - ) - - loop = bytes( - wasmtime.wat2wasm('(module (func (export "_start") (loop $l br $l)))') - ) - assert Guest(loop, workspace).run().status == "fuel_exhausted" - memory = bytes( - wasmtime.wat2wasm('(module (memory 257) (func (export "_start")))') - ) - assert Guest(memory, workspace).run().status == "unsupported_module" - network = bytes( - wasmtime.wat2wasm( - '(module (import "wasi:sockets/tcp" "create-socket" (func)) (func (export "_start")))' - ) - ) - assert Guest(network, workspace).run().status == "unsupported_module" - print( - "PASS limits: infinite loop fuel exhausted; excess memory and socket import rejected" - ) - - guest = Guest(loop, workspace, fuel=1_000_000_000_000) - with ThreadPoolExecutor(max_workers=1) as executor: - pending = executor.submit(guest.run) - assert guest.started.wait(timeout=5) - guest.terminate() - assert pending.result(timeout=5).status == "terminated" - print("PASS termination: running guest interrupted by its backend-owned engine") - print("Prototype smoke passed. No production supervisor workload was started.") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser( - "smoke", help="real gRPC contract checks and real WASI isolation checks" - ) - runner = commands.add_parser( - "run", help="local guest experiment; bypasses supervisor admission" - ) - runner.add_argument("module", type=Path) - runner.add_argument("--workspace", type=Path, required=True) - server = commands.add_parser( - "serve", help="partial, loopback-only protobuf endpoint" - ) - server.add_argument("module", type=Path) - server.add_argument("--sandbox-id", required=True) - server.add_argument("--session-id", required=True) - server.add_argument("--generation-id", required=True) - server.add_argument("--token-file", type=Path, required=True) - args = parser.parse_args() - if args.command == "run": - result = Guest(read_module(args.module), args.workspace).run() - sys.stdout.buffer.write(result.stdout) - sys.stderr.buffer.write(result.stderr) - print( - json.dumps( - { - "status": result.status, - "exit_code": result.exit_code, - "module_sha256": result.module_sha256, - } - ), - file=sys.stderr, - ) - return 0 if result.status == "exited" and result.exit_code == 0 else 1 - with tempfile.TemporaryDirectory(prefix="openshell-wasi-proto-") as generated: - protos = generate(generated) - if args.command == "smoke": - smoke(protos) - else: - if not all((args.sandbox_id, args.session_id, args.generation_id)): - raise ValueError( - "sandbox, session, and generation identities must be nonempty" - ) - token = args.token_file.read_text().strip() - if len(token) < 32 or any(character.isspace() for character in token): - raise ValueError( - "prototype credential must have at least 32 non-whitespace characters" - ) - provisioned = Provisioned( - args.sandbox_id, - args.session_id, - args.generation_id, - token, - read_module(args.module), - ) - endpoint, address = start_server(protos, provisioned) - print( - json.dumps( - { - "endpoint": address, - "backend_name": BACKEND, - "descriptor": json.loads(provisioned.descriptor), - } - ), - flush=True, - ) - try: - endpoint.wait_for_termination() - except KeyboardInterrupt: - endpoint.stop(0).wait(timeout=5) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/isolation-backend-wasi/src/backend.rs b/examples/isolation-backend-wasi/src/backend.rs new file mode 100644 index 0000000000..c7bf3d97e4 --- /dev/null +++ b/examples/isolation-backend-wasi/src/backend.rs @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Partial loopback service. Reserving a boundary never executes guest code. +use std::{pin::Pin, sync::Mutex}; + +use anyhow::{Result, ensure}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use tokio_stream::Stream; +use tonic::{Request, Response, Status, Streaming}; + +use crate::protocol::{BACKEND, boundary::*, control::*, metadata}; + +/// Trusted provisioning, independent of request claims. Paths never arrive over RPC. +pub struct Provisioned { + pub sandbox_id: String, + pub session_id: String, + pub generation_id: String, + token: String, + pub descriptor: Vec, +} + +impl Provisioned { + pub fn new( + sandbox_id: String, + session_id: String, + generation_id: String, + token: String, + module: &[u8], + ) -> Result { + ensure!( + !sandbox_id.is_empty() && !session_id.is_empty() && !generation_id.is_empty(), + "launch identities must not be empty" + ); + ensure!( + token.len() >= 32 && !token.chars().any(char::is_whitespace), + "credential must contain at least 32 non-whitespace characters" + ); + let digest = format!("{:x}", Sha256::digest(module)); + Ok(Self { + sandbox_id, + session_id, + generation_id, + token, + descriptor: serde_json::to_vec(&serde_json::json!({"module_sha256": digest}))?, + }) + } +} + +pub struct Backend { + provisioned: Provisioned, + boundary_id: Mutex>, +} + +impl Backend { + pub fn new(provisioned: Provisioned) -> Self { + Self { + provisioned, + boundary_id: Mutex::new(None), + } + } + + fn authenticate(&self, request: &Request) -> Result<(), Status> { + let mut values = request.metadata().get_all("authorization").iter(); + let valid = values.next().is_some_and(|value| { + bool::from( + value + .as_bytes() + .ct_eq(format!("Bearer {}", self.provisioned.token).as_bytes()), + ) + }); + if !valid || values.next().is_some() { + return Err(Status::unauthenticated("invalid prototype credential")); + } + Ok(()) + } + + async fn route(&self, mut request: Request>) -> Result<(), Status> { + self.authenticate(&request)?; + let first = request.get_mut().message().await?; + let Some(BoundaryChunk { + payload: Some(boundary_chunk::Payload::BoundaryId(id)), + }) = first + else { + return Err(Status::invalid_argument( + "first frame must identify a boundary", + )); + }; + if id.is_empty() { + return Err(Status::invalid_argument( + "first frame must identify a boundary", + )); + } + let expected = self + .boundary_id + .lock() + .map_err(|_| Status::internal("binding lock poisoned"))?; + if !expected + .as_ref() + .is_some_and(|expected| bool::from(id.as_bytes().ct_eq(expected.as_bytes()))) + { + return Err(Status::permission_denied("unknown boundary handle")); + } + Ok(()) + } +} + +#[tonic::async_trait] +impl sandbox_backend_server::SandboxBackend for Backend { + async fn describe( + &self, + request: Request, + ) -> Result, Status> { + let local = metadata(); + let remote = request + .into_inner() + .supervisor + .ok_or_else(|| Status::failed_precondition("missing peer metadata"))?; + if remote.protocol_version.as_ref().map(|v| v.major) != Some(1) + || !local + .required_capabilities + .iter() + .all(|c| remote.supported_capabilities.contains(c)) + || !remote + .required_capabilities + .iter() + .all(|c| local.supported_capabilities.contains(c)) + { + return Err(Status::failed_precondition( + "partial WASI prototype does not implement the base sandbox contract", + )); + } + Ok(Response::new(DescribeSandboxBackendResponse { + backend: Some(local), + })) + } + + async fn open_boundary( + &self, + request: Request, + ) -> Result, Status> { + self.authenticate(&request)?; + let launch = request.into_inner(); + let p = &self.provisioned; + if launch.sandbox_id != p.sandbox_id + || launch.session_id != p.session_id + || launch.generation_id != p.generation_id + || launch.backend_name != BACKEND + { + return Err(Status::permission_denied("launch identity mismatch")); + } + if launch.driver_descriptor != p.descriptor { + return Err(Status::permission_denied("provisioned module mismatch")); + } + let mut id = self + .boundary_id + .lock() + .map_err(|_| Status::internal("binding lock poisoned"))?; + Ok(Response::new(OpenBoundaryResponse { + boundary_id: id + .get_or_insert_with(|| uuid::Uuid::new_v4().to_string()) + .clone(), + outcome: open_boundary_response::LaunchOutcome::Fresh.into(), + })) + } + + async fn suspend_boundary( + &self, + request: Request, + ) -> Result, Status> { + self.authenticate(&request)?; + Err(Status::unimplemented( + "WASI memory snapshots are not implemented", + )) + } + + async fn resume_boundary( + &self, + request: Request, + ) -> Result, Status> { + self.authenticate(&request)?; + Err(Status::unimplemented( + "WASI memory restoration is not implemented", + )) + } +} + +type Chunks = Pin> + Send>>; + +#[tonic::async_trait] +impl sandbox_boundary_server::SandboxBoundary for Backend { + type ExchangeStream = Chunks; + type MediateStream = Chunks; + + async fn exchange( + &self, + request: Request>, + ) -> Result, Status> { + self.route(request).await?; + Err(Status::unimplemented( + "attach/confirm/start are not implemented; no guest was run", + )) + } + + async fn mediate( + &self, + request: Request>, + ) -> Result, Status> { + self.route(request).await?; + Err(Status::unimplemented( + "TCP/DNS mediation is not implemented", + )) + } +} diff --git a/examples/isolation-backend-wasi/src/guest.rs b/examples/isolation-backend-wasi/src/guest.rs new file mode 100644 index 0000000000..e2ebfaa03e --- /dev/null +++ b/examples/isolation-backend-wasi/src/guest.rs @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Local guest execution. Deliberately unreachable through the backend service. +use std::{ + fs::File, + io::Read, + path::Path, + sync::atomic::{AtomicBool, Ordering}, +}; + +use anyhow::{Result, ensure}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, Trap}; +use wasmtime_wasi::{ + DirPerms, FilePerms, I32Exit, WasiCtxBuilder, + p1::{self, WasiP1Ctx}, + p2::pipe::MemoryOutputPipe, +}; + +pub const DEFAULT_FUEL: u64 = 1_000_000; +const MAX_MODULE: u64 = 4 * 1024 * 1024; +const MAX_OUTPUT: usize = 64 * 1024; + +pub fn read_module(path: &Path) -> Result> { + let mut bytes = Vec::new(); + File::open(path)? + .take(MAX_MODULE + 1) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() as u64 <= MAX_MODULE, + "module exceeds the 4 MiB prototype limit" + ); + // Normalize WAT to the actual module bytes before computing its identity. + let bytes = wat::parse_bytes(&bytes)?.into_owned(); + ensure!( + bytes.len() as u64 <= MAX_MODULE, + "compiled module exceeds the 4 MiB prototype limit" + ); + Ok(bytes) +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GuestStatus { + Exited, + Terminated, + FuelExhausted, + Trapped, + UnsupportedModule, +} + +#[derive(Debug, Serialize)] +pub struct GuestResult { + pub status: GuestStatus, + pub exit_code: Option, + #[serde(skip)] + pub stdout: Vec, + #[serde(skip)] + pub stderr: Vec, + pub module_sha256: String, +} + +struct State { + wasi: WasiP1Ctx, + limits: StoreLimits, +} + +/// One engine per guest: terminating one guest cannot interrupt another. +pub struct Guest { + engine: Engine, + module: Module, + digest: String, + cancelled: AtomicBool, +} + +impl Guest { + pub fn new(module: &[u8]) -> Result { + let mut config = Config::new(); + config.consume_fuel(true).epoch_interruption(true); + let engine = Engine::new(&config)?; + Ok(Self { + module: Module::new(&engine, module)?, + engine, + digest: format!("{:x}", Sha256::digest(module)), + cancelled: AtomicBool::new(false), + }) + } + + pub fn terminate(&self) { + self.cancelled.store(true, Ordering::SeqCst); + self.engine.increment_epoch(); + } + + pub fn run(&self, workspace: &Path, fuel: u64) -> Result { + self.run_when_ready(workspace, fuel, || {}) + } + + /// Calls `ready` once the store's interruption deadline is armed. + pub fn run_when_ready( + &self, + workspace: &Path, + fuel: u64, + ready: impl FnOnce(), + ) -> Result { + let stdout = MemoryOutputPipe::new(MAX_OUTPUT); + let stderr = MemoryOutputPipe::new(MAX_OUTPUT); + let wasi = WasiCtxBuilder::new() + .arg("/work/guest.wasm") + .stdout(stdout.clone()) + .stderr(stderr.clone()) + .preopened_dir( + workspace.canonicalize()?, + "/work", + DirPerms::READ, + FilePerms::READ, + )? + .build_p1(); + let mut store = Store::new( + &self.engine, + State { + wasi, + limits: StoreLimitsBuilder::new() + .memory_size(16 * 1024 * 1024) + .table_elements(1024) + .instances(1) + .tables(1) + .memories(1) + .build(), + }, + ); + store.limiter(|state| &mut state.limits); + store.set_fuel(fuel)?; + store.set_epoch_deadline(1); + let mut linker = Linker::new(&self.engine); + p1::add_to_linker_sync(&mut linker, |state: &mut State| &mut state.wasi)?; + let pre = linker.instantiate_pre(&self.module); + ready(); + let (status, exit_code) = if self.cancelled.load(Ordering::SeqCst) { + (GuestStatus::Terminated, None) + } else if let Ok(pre) = pre { + // Instantiation may execute a start function; it happens only here. + let result = pre.instantiate(&mut store).and_then(|instance| { + instance + .get_typed_func::<(), ()>(&mut store, "_start")? + .call(&mut store, ()) + }); + match result { + Ok(()) => (GuestStatus::Exited, Some(0)), + Err(error) => { + if let Some(exit) = error.downcast_ref::() { + (GuestStatus::Exited, Some(exit.0)) + } else { + let status = match error.downcast_ref::() { + Some(Trap::Interrupt) => GuestStatus::Terminated, + Some(Trap::OutOfFuel) => GuestStatus::FuelExhausted, + _ => GuestStatus::Trapped, + }; + (status, None) + } + } + } + } else { + (GuestStatus::UnsupportedModule, None) + }; + Ok(GuestResult { + status, + exit_code, + stdout: stdout.contents().to_vec(), + stderr: stderr.contents().to_vec(), + module_sha256: self.digest.clone(), + }) + } +} diff --git a/examples/isolation-backend-wasi/src/lib.rs b/examples/isolation-backend-wasi/src/lib.rs new file mode 100644 index 0000000000..dd58d28189 --- /dev/null +++ b/examples/isolation-backend-wasi/src/lib.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub mod backend; +pub mod guest; +pub mod protocol; diff --git a/examples/isolation-backend-wasi/src/main.rs b/examples/isolation-backend-wasi/src/main.rs new file mode 100644 index 0000000000..2ffab3ec4b --- /dev/null +++ b/examples/isolation-backend-wasi/src/main.rs @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + io::{self, Write}, + path::PathBuf, + sync::Arc, +}; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use openshell_wasi_backend_example::{ + backend::{Backend, Provisioned}, + guest::{DEFAULT_FUEL, Guest, GuestStatus, read_module}, + protocol::{ + BACKEND, boundary::sandbox_boundary_server::SandboxBoundaryServer, + control::sandbox_backend_server::SandboxBackendServer, + }, +}; +use tokio_stream::{StreamExt, wrappers::TcpListenerStream}; + +#[derive(Parser)] +#[command(about = "Partial WASI backend service and a separate local guest runner")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a guest locally, without supervisor admission. + Run { + module: PathBuf, + #[arg(long)] + workspace: PathBuf, + }, + /// Serve the partial protobuf contract on an ephemeral loopback port. + Serve { + module: PathBuf, + #[arg(long)] + sandbox_id: String, + #[arg(long)] + session_id: String, + #[arg(long)] + generation_id: String, + #[arg(long)] + token_file: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + match Cli::parse().command { + Command::Run { module, workspace } => { + let bytes = read_module(&module)?; + let result = tokio::task::spawn_blocking(move || { + Guest::new(&bytes)?.run(&workspace, DEFAULT_FUEL) + }) + .await??; + io::stdout().write_all(&result.stdout)?; + io::stderr().write_all(&result.stderr)?; + eprintln!("{}", serde_json::to_string(&result)?); + if result.status != GuestStatus::Exited || result.exit_code != Some(0) { + std::process::exit(1); + } + } + Command::Serve { + module, + sandbox_id, + session_id, + generation_id, + token_file, + } => { + let token = std::fs::read_to_string(token_file)?.trim().to_string(); + let launch = Provisioned::new( + sandbox_id, + session_id, + generation_id, + token, + &read_module(&module)?, + )?; + let descriptor: serde_json::Value = serde_json::from_slice(&launch.descriptor)?; + let backend = Arc::new(Backend::new(launch)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + println!( + "{}", + serde_json::json!({"endpoint": listener.local_addr()?.to_string(), "backend_name": BACKEND, "descriptor": descriptor}) + ); + io::stdout().flush()?; + // Custom incoming streams bypass Tonic's TCP settings. + let incoming = TcpListenerStream::new(listener).map(|accepted| { + accepted.inspect(|socket| { + let _ = socket.set_nodelay(true); + }) + }); + tonic::transport::Server::builder() + .add_service( + SandboxBackendServer::from_arc(backend.clone()) + .max_decoding_message_size(1024 * 1024), + ) + .add_service( + SandboxBoundaryServer::from_arc(backend).max_decoding_message_size(1024 * 1024), + ) + .serve_with_incoming_shutdown(incoming, async { + let _ = tokio::signal::ctrl_c().await; + }) + .await?; + } + } + Ok(()) +} diff --git a/examples/isolation-backend-wasi/src/protocol.rs b/examples/isolation-backend-wasi/src/protocol.rs new file mode 100644 index 0000000000..5573350318 --- /dev/null +++ b/examples/isolation-backend-wasi/src/protocol.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Generate only the public wire contracts; this example links no OpenShell crates. +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 = "example-wasmtime"; +pub const CAPABILITY: &str = "openshell.example.wasi-prototype"; + +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()], + } +} diff --git a/examples/isolation-backend-wasi/tests/guest.rs b/examples/isolation-backend-wasi/tests/guest.rs new file mode 100644 index 0000000000..8cf1408bb3 --- /dev/null +++ b/examples/isolation-backend-wasi/tests/guest.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_wasi_backend_example::guest::{DEFAULT_FUEL, Guest, GuestStatus, read_module}; +use std::{path::Path, sync::Arc, time::Duration}; + +#[test] +#[cfg(unix)] +fn wasi_reads_only_the_workspace() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + std::fs::write(workspace.join("allowed.txt"), "workspace content\n").unwrap(); + std::fs::write(temp.path().join("secret.txt"), "outside secret\n").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(temp.path().join("secret.txt"), workspace.join("escape")).unwrap(); + let bytes = read_module(&Path::new(env!("CARGO_MANIFEST_DIR")).join("guest.wat")).unwrap(); + let result = Guest::new(&bytes) + .unwrap() + .run(&workspace, DEFAULT_FUEL) + .unwrap(); + assert_eq!(result.status, GuestStatus::Exited, "{result:?}"); + assert_eq!(result.exit_code, Some(0)); + assert_eq!( + result.stdout, + b"workspace content\nescape denied\nsymlink denied\nwrite denied\n" + ); + assert!(!workspace.join("new.txt").exists()); +} + +#[test] +fn guest_limits_fuel_memory_and_imports() { + let temp = tempfile::tempdir().unwrap(); + for (module, expected) in [ + ( + "(module (func (export \"_start\") (loop $l br $l)))", + GuestStatus::FuelExhausted, + ), + ( + "(module (memory 257) (func (export \"_start\")))", + GuestStatus::Trapped, + ), + ( + "(module (import \"wasi:sockets/tcp\" \"create-socket\" (func)) (func (export \"_start\")))", + GuestStatus::UnsupportedModule, + ), + ( + "(module (func (export \"_start\") unreachable))", + GuestStatus::Trapped, + ), + ] { + let result = Guest::new(module.as_bytes()) + .unwrap() + .run(temp.path(), DEFAULT_FUEL) + .unwrap(); + assert_eq!(result.status, expected, "{result:?}"); + } +} + +#[test] +fn termination_interrupts_only_its_owned_engine() { + let temp = tempfile::tempdir().unwrap(); + let guest = + Arc::new(Guest::new(b"(module (func (export \"_start\") (loop $l br $l)))").unwrap()); + let worker_guest = guest.clone(); + let workspace = temp.path().to_owned(); + let (ready_send, ready_receive) = std::sync::mpsc::channel(); + let (done_send, done_receive) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = worker_guest.run_when_ready(&workspace, 1_000_000_000_000, || { + ready_send.send(()).unwrap() + }); + done_send.send(result).unwrap(); + }); + ready_receive.recv_timeout(Duration::from_secs(5)).unwrap(); + guest.terminate(); + let result = done_receive + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + assert_eq!(result.status, GuestStatus::Terminated); + worker.join().unwrap(); + let independent = Guest::new(b"(module (func (export \"_start\")))").unwrap(); + assert_eq!( + independent.run(temp.path(), DEFAULT_FUEL).unwrap().status, + GuestStatus::Exited + ); +} + +#[test] +fn output_capture_is_bounded() { + let temp = tempfile::tempdir().unwrap(); + let module = br#"(module + (import "wasi_snapshot_preview1" "fd_write" (func $write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 2) + (data (i32.const 0) "\10\00\00\00\01\00\01\00") + (func (export "_start") + i32.const 1 i32.const 0 i32.const 1 i32.const 8 call $write drop))"#; + let result = Guest::new(module) + .unwrap() + .run(temp.path(), DEFAULT_FUEL) + .unwrap(); + assert!(!result.stdout.is_empty(), "{result:?}"); + assert!(result.stdout.len() <= 64 * 1024); +} diff --git a/examples/isolation-backend-wasi/tests/protocol.rs b/examples/isolation-backend-wasi/tests/protocol.rs new file mode 100644 index 0000000000..16df2d3c19 --- /dev/null +++ b/examples/isolation-backend-wasi/tests/protocol.rs @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Exercise the actual executable over TCP, using only the generated public API. +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + time::Duration, +}; + +use openshell_wasi_backend_example::protocol::{ + BACKEND, + boundary::{sandbox_boundary_client::SandboxBoundaryClient, *}, + control::{sandbox_backend_client::SandboxBackendClient, *}, + metadata, +}; +use tonic::{ + Code, Request, + transport::{Channel, Endpoint}, +}; + +const TOKEN: &str = "test-only-credential-with-at-least-32-characters"; + +struct Server(Child); +impl Drop for Server { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn authorized(message: T) -> Request { + let mut request = Request::new(message); + request + .metadata_mut() + .insert("authorization", format!("Bearer {TOKEN}").parse().unwrap()); + request.set_timeout(Duration::from_secs(5)); + request +} + +async fn stream_error( + client: &mut SandboxBoundaryClient, + payload: boundary_chunk::Payload, +) -> Code { + client + .exchange(authorized(tokio_stream::iter([BoundaryChunk { + payload: Some(payload), + }]))) + .await + .unwrap_err() + .code() +} + +#[tokio::test] +async fn external_service_checks_negotiation_binding_and_routes() { + let temp = tempfile::tempdir().unwrap(); + let token_file = temp.path().join("credential"); + std::fs::write(&token_file, TOKEN).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + let module = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("guest.wat"); + let mut server = Server( + Command::new(env!("CARGO_BIN_EXE_wasi-backend")) + .arg("serve") + .arg(module) + .args([ + "--sandbox-id", + "sandbox-a", + "--session-id", + "session-a", + "--generation-id", + "generation-a", + "--token-file", + ]) + .arg(token_file) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let stdout = server.0.stdout.take().unwrap(); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout).read_line(&mut line).map(|_| line); + let _ = send.send(result); + }); + let announcement: serde_json::Value = serde_json::from_str( + &receive + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(announcement["backend_name"], BACKEND); + let channel = Endpoint::from_shared(format!( + "http://{}", + announcement["endpoint"].as_str().unwrap() + )) + .unwrap() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(5)) + .tcp_nodelay(true) + .connect() + .await + .unwrap(); + let mut client = SandboxBackendClient::new(channel.clone()); + let mut streams = SandboxBoundaryClient::new(channel); + let response = client + .describe(DescribeSandboxBackendRequest { + supervisor: Some(metadata()), + }) + .await + .unwrap() + .into_inner(); + assert_eq!(response.backend, Some(metadata())); + let mut incompatible = metadata(); + incompatible.required_capabilities = vec!["openshell.sandbox.protocol".into()]; + assert_eq!( + client + .describe(DescribeSandboxBackendRequest { + supervisor: Some(incompatible) + }) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + + let launch = OpenBoundaryRequest { + sandbox_id: "sandbox-a".into(), + session_id: "session-a".into(), + generation_id: "generation-a".into(), + backend_name: BACKEND.into(), + driver_descriptor: serde_json::to_vec(&announcement["descriptor"]).unwrap(), + }; + let opened = client + .open_boundary(authorized(launch.clone())) + .await + .unwrap() + .into_inner(); + assert!(!opened.boundary_id.is_empty()); + assert_eq!( + opened.outcome, + open_boundary_response::LaunchOutcome::Fresh as i32 + ); + assert_eq!( + client + .open_boundary(authorized(launch.clone())) + .await + .unwrap() + .into_inner(), + opened + ); + assert_eq!( + client + .open_boundary(launch.clone()) + .await + .unwrap_err() + .code(), + Code::Unauthenticated + ); + let mut duplicate_auth = authorized(launch.clone()); + duplicate_auth + .metadata_mut() + .append("authorization", format!("Bearer {TOKEN}").parse().unwrap()); + assert_eq!( + client + .open_boundary(duplicate_auth) + .await + .unwrap_err() + .code(), + Code::Unauthenticated + ); + let mut wrong_auth = authorized(launch.clone()); + wrong_auth + .metadata_mut() + .insert("authorization", "Bearer wrong".parse().unwrap()); + assert_eq!( + client.open_boundary(wrong_auth).await.unwrap_err().code(), + Code::Unauthenticated + ); + for field in 0..5 { + let mut changed = launch.clone(); + match field { + 0 => changed.sandbox_id = "other".into(), + 1 => changed.session_id = "other".into(), + 2 => changed.generation_id = "other".into(), + 3 => changed.backend_name = "other".into(), + _ => changed.driver_descriptor = b"{}".to_vec(), + } + assert_eq!( + client + .open_boundary(authorized(changed)) + .await + .unwrap_err() + .code(), + Code::PermissionDenied + ); + } + assert_eq!( + stream_error(&mut streams, boundary_chunk::Payload::Data(b"bad".to_vec())).await, + Code::InvalidArgument + ); + assert_eq!( + stream_error( + &mut streams, + boundary_chunk::Payload::BoundaryId(String::new()) + ) + .await, + Code::InvalidArgument + ); + assert_eq!( + stream_error( + &mut streams, + boundary_chunk::Payload::BoundaryId("other".into()) + ) + .await, + Code::PermissionDenied + ); + assert_eq!( + stream_error( + &mut streams, + boundary_chunk::Payload::BoundaryId(opened.boundary_id.clone()) + ) + .await, + Code::Unimplemented + ); + let route = BoundaryChunk { + payload: Some(boundary_chunk::Payload::BoundaryId( + opened.boundary_id.clone(), + )), + }; + assert_eq!( + streams + .mediate(authorized(tokio_stream::iter([route]))) + .await + .unwrap_err() + .code(), + Code::Unimplemented + ); + assert_eq!( + client + .suspend_boundary(authorized(SuspendBoundaryRequest { + boundary_id: opened.boundary_id, + session_id: launch.session_id.clone(), + generation_id: launch.generation_id.clone(), + })) + .await + .unwrap_err() + .code(), + Code::Unimplemented + ); + assert_eq!( + client + .resume_boundary(authorized(ResumeBoundaryRequest { + launch: Some(launch), + restore_reference: vec![], + allow_cold_start_fallback: false, + })) + .await + .unwrap_err() + .code(), + Code::Unimplemented + ); +} diff --git a/tasks/rust.toml b/tasks/rust.toml index 32928b8f69..3b7ba523d5 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-wasi/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-wasi/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-wasi/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index 1e5a2750cc..c6749b6262 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -125,6 +125,7 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", + "cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From 408aa40997a6a780ba85893861def162003ffbb1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 16:38:05 -0700 Subject: [PATCH 09/24] refactor(sandbox): defer protocol conformance fixtures Signed-off-by: Drew Newberry --- .github/workflows/branch-checks.yml | 5 - crates/openshell-driver-docker/src/tests.rs | 2 +- crates/openshell-sandbox-backend/README.md | 3 +- .../src/boundary_protocol.rs | 30 -- docs/how-it-works/gateways/configuration.mdx | 68 --- docs/how-it-works/sandboxes/runtimes.mdx | 27 - e2e/isolation/protocol_interop.py | 488 ------------------ proto/sandbox_protocol/v1/README.md | 11 +- proto/sandbox_protocol/v1/vectors.json | 282 ---------- skills/debug-openshell-cluster/SKILL.md | 1 - tasks/test.toml | 8 - 11 files changed, 4 insertions(+), 921 deletions(-) delete mode 100644 e2e/isolation/protocol_interop.py delete mode 100644 proto/sandbox_protocol/v1/vectors.json diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 750a8441a4..08f8ed9c1d 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -316,11 +316,6 @@ jobs: cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml - - name: Sandbox Protocol interoperability - if: matrix.system == 'x86_64-linux' - shell: nix develop -c bash -euo pipefail {0} - run: mise run e2e:isolation-protocol - rust-postgres-test: name: Rust PostgreSQL tests needs: pr_metadata diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 829154ea33..a56f4c7429 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -101,7 +101,7 @@ fn test_sandbox() -> DriverSandbox { await_main_process_attachment: false, workload_identity: None, launch_authentication: test_launch_authentication(), - isolation_backend: None, + ..Default::default() }), status: None, workspace: String::new(), diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 51f217f8f1..2b03371dee 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -43,8 +43,7 @@ generation, resource claims, and outer-fence evidence. Native evidence is validated by the registered backend; Linux evidence validation remains in the built-in adapter. -The language-neutral contract, generation instructions, and interoperability -vectors are in [Sandbox Protocol 1.0](../../proto/sandbox_protocol/v1/README.md). +The language-neutral contract and generation instructions are in [Sandbox Protocol 1.0](../../proto/sandbox_protocol/v1/README.md). ## Exec recovery diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 22cab052e0..32d67538f9 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -1443,36 +1443,6 @@ pub enum FrameError { mod tests { use super::*; - #[test] - fn published_protocol_vectors_match_typed_messages_digest_and_framing() { - use sha2::Digest as _; - let vectors: serde_json::Value = serde_json::from_str(include_str!( - "../../../proto/sandbox_protocol/v1/vectors.json" - )) - .unwrap(); - assert_eq!(vectors["version"], "1.0"); - for vector in vectors["requests"].as_array().unwrap() { - let envelope: RequestEnvelope = - serde_json::from_value(vector["envelope"].clone()).unwrap(); - envelope.validate_payload_digest().unwrap(); - assert_eq!(serde_json::to_value(&envelope).unwrap(), vector["envelope"]); - let canonical = vector["canonical_request"].as_str().unwrap(); - assert_eq!( - format!("{:x}", Sha256::digest(canonical.as_bytes())), - envelope.payload_digest - ); - let hex = vector["frame_hex"].as_str().unwrap(); - let frame: Vec = (0..hex.len()) - .step_by(2) - .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).unwrap()) - .collect(); - let decoded: RequestEnvelope = decode_frame(&frame).unwrap(); - assert_eq!(decoded, envelope); - let decoded: RequestEnvelope = decode_frame(&encode_frame(&decoded).unwrap()).unwrap(); - assert_eq!(decoded, envelope); - } - } - fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { NativeLinuxSandboxAuditEvidence { capabilities: CapabilityEvidence { diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 6afcd121bf..0c6a9e2a88 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1405,74 +1405,6 @@ compute_driver = "kyma" socket_path = "/run/openshell/kyma-compute-driver.sock" ``` -### External isolation backend - -After installing a compatible external compute driver and isolation backend, -set its backend name in `[openshell.drivers.]`, then select that compute -driver with `openshell.gateway.compute_driver`: - -```toml -[openshell.gateway] -compute_driver = "agent-substrate" - -[openshell.drivers.agent-substrate] -socket_path = "/run/agent-substrate/compute.sock" -isolation_backend = "agent-substrate" - -[[openshell.supervisor.isolation_backends]] -name = "agent-substrate" -endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" } -``` - -Endpoint addresses must be reachable from the supervisor's placement. TCP uses -`{ kind = "tcp", authority = "backend.example:443", addresses = ["192.0.2.10:443"] }`; -vsock uses `{ kind = "vsock", guest_cid = 3, port = 47000 }`. -Names match exactly. Unknown names, duplicate registrations, invalid endpoints, -and registration of the reserved `openshell-sandbox` name fail configuration -validation, including for unused registrations. - -The selected compute driver must advertise -`openshell.compute.sandbox-backend`. The gateway sends the -resolved name and endpoint on validation, create, start, and recovery. The -driver places the backend and stages protected supervisor launch files, -generation-specific TLS, and its opaque descriptor. Gateway launch signing -must be configured through `gateway_jwt` or the package-managed local signing -keys. An incompatible driver fails gateway startup; there is no fallback. - -Once configured, changing the `compute_driver` line and restarting the gateway -selects that driver and its isolation backend for new sandboxes. Driver and -backend names can differ; the gateway never infers one from the other. - -Changing the selected driver's `isolation_backend` and restarting also changes -its backend for new sandboxes. Its existing sandboxes retain their admitted -backend name; start and recovery reject a different backend before calling the -driver. Restore the matching backend to start them, or create new sandboxes. -Legacy records retain `openshell-sandbox`. -Omit the driver's `isolation_backend` field or set it to `openshell-sandbox` -to use the driver's built-in runtime. The first-party drivers currently support -that built-in selection only. Sandbox CLI and API requests do not select -isolation backends or supply endpoints. - -Helm renders these same fields through `gatewayConfig`: - -```yaml -gatewayConfig: - openshell.gateway: - compute_driver: agent-substrate - openshell.drivers.agent-substrate: - socket_path: /run/agent-substrate/compute.sock - isolation_backend: agent-substrate - openshell.supervisor.isolation_backends: - - name: agent-substrate - endpoint: - kind: unix - socket_path: /run/agent-substrate/isolation.sock -``` - -The driver socket must be mounted in the gateway deployment. The isolation -endpoint belongs to the supervisor's placement; mounting it in the gateway -alone does not make it reachable by the supervisor. - ## Preflight package configuration {#gateway-config-preflight} Before starting a package-managed gateway, validate the selected file without diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index 827f516d6a..cc76cf98d2 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -49,11 +49,6 @@ driver TOML overrides the compiled release defaults. Helm renders Kubernetes image fields into TOML, so they apply when these environment variables are unset. Sandbox requests cannot override either trusted image. -Use supervisor and sandbox-runtime images from the same release. The supervisor -checks their isolation protocol version and required capabilities before -starting workload code. An incompatible pair fails sandbox startup; update -both trusted images together when overriding the release defaults. - When building your own gateway or supervisor image, the `GATEWAY_BASE_IMAGE` and `SUPERVISOR_BASE_IMAGE` build arguments override their pinned bases. See [Container Image Base Overrides](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#container-image-base-overrides) @@ -235,28 +230,6 @@ VM sandboxes have no network interface. All traffic flows through the OpenShell If the gateway is briefly unavailable while a VM starts, its supervisor retries the session connection. The sandbox stays in provisioning until the gateway accepts the session. -## External isolation backends - -An external compute driver can launch the stock supervisor against an isolation -backend maintained outside OpenShell. Register the operator-owned endpoint under -`openshell.supervisor.isolation_backends`, set its exact name as -`isolation_backend` in `[openshell.drivers.]`, and select that compute -driver with `openshell.gateway.compute_driver`. See the -[gateway configuration reference](/how-it-works/gateways/configuration#external-isolation-backend) -for TOML and Helm examples. - -The gateway passes that registration to a compute driver that advertises support. -The driver stages protected supervisor launch files, generation-pinned TLS, -gateway credentials, and its native descriptor. Sandbox requests and workload -environment cannot select the backend. First-party drivers currently support -only the built-in `openshell-sandbox` selection. - -Backend authors can generate bindings from the public protos and implement -[Sandbox Protocol 1.0](https://github.com/NVIDIA/OpenShell/tree/main/proto/sandbox_protocol/v1). -The contract includes lifecycle, framing, replay, process I/O, and TCP/DNS -mediation. Endpoint registration supports fresh launches; state-preserving -suspend/resume remains an optional contract awaiting lifecycle integration. - ## Kubernetes Driver The Kubernetes driver runs sandboxes as pods in a sandbox namespace. It requires the [Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox) controller. Install the gateway with the Helm chart; see [Kubernetes setup](/kubernetes/setup). diff --git a/e2e/isolation/protocol_interop.py b/e2e/isolation/protocol_interop.py deleted file mode 100644 index b8e3634b1f..0000000000 --- a/e2e/isolation/protocol_interop.py +++ /dev/null @@ -1,488 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = ["grpcio==1.78.0", "grpcio-tools==1.78.0", "cryptography==46.0.7"] -# [tool.uv] -# exclude-newer = "2026-10-08T00:00:00Z" -# /// -"""Protocol interoperability fixture, not a production isolation backend. - -Run with uv run e2e/isolation/protocol_interop.py target/debug/openshell-supervisor. -Uses generated public gRPC bindings and independently implements JSON/framing. -The fixture simulates enforcement evidence and uses a fixed test bearer; -it does not provide isolation or exercise JWT validation. -""" - -import asyncio -import datetime -import hashlib -import json -import os -import signal -import struct -import sys -import tempfile -import traceback -import uuid -from collections import Counter -from pathlib import Path - -import grpc -import grpc_tools -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID -from grpc_tools import protoc - -ROOT = Path(__file__).resolve().parents[2] -TOKEN = "a" * 32 -PAYLOAD = b"external-actor-v1\x00\xffopaque-resource" - - -def frame(value): - payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode() - return struct.pack(">I", len(payload)) + payload - - -def canonical_request(envelope): - request = dict(envelope["request"]) - if "exec_expires_at_unix_ms" in envelope: - request["exec_expires_at_unix_ms"] = envelope["exec_expires_at_unix_ms"] - return json.dumps( - request, ensure_ascii=False, sort_keys=True, separators=(",", ":") - ).encode() - - -def check_vectors(): - vectors = json.loads((ROOT / "proto/sandbox_protocol/v1/vectors.json").read_text()) - assert vectors["version"] == "1.0" - for vector in vectors["requests"]: - canonical = canonical_request(vector["envelope"]) - assert canonical == vector["canonical_request"].encode() - assert ( - hashlib.sha256(canonical).hexdigest() - == vector["envelope"]["payload_digest"] - ) - data = bytes.fromhex(vector["frame_hex"]) - assert struct.unpack(">I", data[:4])[0] == len(data) - 4 - assert json.loads(data[4:]) == vector["envelope"] - print("PASS: public request digest and framing vectors") - - -async def main(binary): - check_vectors() - with tempfile.TemporaryDirectory(prefix="openshell-interop-") as directory: - directory = Path(directory) - assert ( - protoc.main( - [ - "protoc", - f"-I{ROOT / 'proto'}", - f"-I{Path(grpc_tools.__file__).parent / '_proto'}", - f"--python_out={directory}", - f"--grpc_python_out={directory}", - *[ - str(ROOT / "proto" / name) - for name in [ - "options.proto", - "extension.proto", - "sandbox_backend.proto", - "sandbox_protocol.proto", - ] - ], - ] - ) - == 0 - ) - sys.path.insert(0, str(directory)) - import extension_pb2 as extension - import sandbox_backend_pb2 as control - import sandbox_backend_pb2_grpc as control_rpc - import sandbox_protocol_pb2 as stream - import sandbox_protocol_pb2_grpc as stream_rpc - - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "backend.test")]) - now = datetime.datetime.now(datetime.UTC) - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(subject) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(minutes=1)) - .not_valid_after(now + datetime.timedelta(hours=1)) - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), critical=True - ) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName("backend.test")]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - ca_pem = cert.public_bytes(serialization.Encoding.PEM) - ca_key = key - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(subject) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(minutes=1)) - .not_valid_after(now + datetime.timedelta(hours=1)) - .add_extension( - x509.BasicConstraints(ca=False, path_length=None), critical=True - ) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName("backend.test")]), - critical=False, - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False - ) - .sign(ca_key, hashes.SHA256()) - ) - cert_pem = cert.public_bytes(serialization.Encoding.PEM) - key_pem = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - session = str(uuid.uuid4()) - identity = { - "uid": 1000, - "gid": 1000, - "supplementary_gids": [], - "source": "fixture", - "resource_digest": "fixture-rootfs", - } - fence = { - "generation": "interop-generation", - "established": [ - "default_deny_egress", - "no_unmanaged_egress_path", - "revocation_verified", - "controller_loss_fails_closed", - ], - "evidence_digest": "1" * 64, - } - events = [] - opened_peers = set() - replay = {} - confirmed = False - process = None - finished = asyncio.Event() - started = asyncio.Event() - handle = "external-route" - - class Backend( - control_rpc.SandboxBackendServicer, - stream_rpc.SandboxBoundaryServicer, - ): - async def authenticated(self, context): - assert ( - dict(context.invocation_metadata()).get("authorization") - == f"Bearer {TOKEN}" - ) - - async def Describe(self, request, context): - assert request.supervisor.protocol_version.major == 1 - assert ( - "openshell.sandbox.protocol" - in request.supervisor.supported_capabilities - ) - assert "authorization" not in dict(context.invocation_metadata()) - events.append("negotiate") - return control.DescribeSandboxBackendResponse( - backend=extension.PeerMetadata( - protocol_version=extension.ProtocolVersion(major=1, minor=0), - implementation_name="python-interop", - implementation_version="1.0", - supported_capabilities=["openshell.sandbox.protocol"], - required_capabilities=["openshell.sandbox.protocol"], - ) - ) - - async def OpenBoundary(self, request, context): - await self.authenticated(context) - assert request.backend_name == "python-external" - assert request.driver_descriptor == PAYLOAD - assert ( - request.sandbox_id, - request.generation_id, - request.session_id, - ) == ("interop-sandbox", "interop-generation", session) - opened_peers.add(context.peer()) - events.append("open") - return control.OpenBoundaryResponse(boundary_id=handle, outcome=1) - - async def Exchange(self, requests, context): - try: - async for chunk in self.exchange(requests, context): - yield chunk - except Exception: - traceback.print_exc() - raise - - async def exchange(self, requests, context): - nonlocal confirmed, process - await self.authenticated(context) - assert context.peer() in opened_peers - requests = aiter(requests) - binding = await anext(requests) - assert ( - binding.WhichOneof("payload") == "boundary_id" - and binding.boundary_id == handle - ) - await context.send_initial_metadata(()) - data = bytearray() - async for chunk in requests: - assert chunk.WhichOneof("payload") == "data" - data.extend(chunk.data) - if len(data) >= 4: - length = struct.unpack(">I", data[:4])[0] - assert 0 < length <= 1048576 - if len(data) >= 4 + length: - break - length = struct.unpack(">I", data[:4])[0] - assert 0 < length <= 1048576 - envelope = json.loads(data[4 : 4 + length]) - request = envelope["request"] - canonical = canonical_request(envelope) - assert ( - hashlib.sha256(canonical).hexdigest() == envelope["payload_digest"] - ) - operation = request["operation"] - events.append(operation) - if envelope["request_id"] in replay: - digest, response = replay[envelope["request_id"]] - assert digest == envelope["payload_digest"] - elif operation == "discover_policy": - response = { - "result": "image_policy", - "yaml": None, - "invalid": False, - } - elif operation == "attach": - response = { - "result": "attached", - "snapshot": { - "generation": "interop-generation", - "processes": [], - }, - } - elif operation == "confirm": - assert "attach" in events - confirmed = True - prop = {"enforced": True, "mechanism": "interop-fixture"} - response = { - "result": "confirmed", - "confirmation": { - "generation": "interop-generation", - "session_id": session, - "identity": identity, - "outer_fence": fence, - "resource_claims": {}, - "authenticated_supervisor": True, - "runtime_exit_terminates_workload": True, - "properties": dict.fromkeys( - [ - "filesystem_confinement", - "egress_interception", - "request_attribution", - "privilege_floor", - ], - prop, - ), - "backend_audit": {"fixture": "python-no-linux-audit"}, - }, - } - elif operation == "start_agent": - assert confirmed - spec = request["spec"] - assert spec["program"] == "/bin/sleep" and spec["args"] == ["60"] - process = await asyncio.create_subprocess_exec( - spec["program"], *spec["args"] - ) - started.set() - response = { - "result": "started", - "process_id": "external-main", - "provider_env_revision": request["provider_env_revision"], - "provider_env_generation": 1, - } - elif operation == "attach_process": - response = {"result": "process_attached", "terminal": False} - elif operation == "signal": - assert request["signal"] == "term" - process.terminate() - response = {"result": "signaled"} - elif operation == "wait": - code = await process.wait() - response = { - "result": "exited", - "status": { - "kind": "signaled" if code < 0 else "exited", - "value": abs(code), - }, - } - elif operation == "accept_network": - await finished.wait() - response = { - "result": "error", - "kind": "terminated", - "message": "fixture boundary ended", - } - elif operation == "open_mediation": - response = {"result": "mediation_ready"} - elif operation == "terminate_boundary": - assert process.returncode is not None - finished.set() - response = {"result": "boundary_terminated"} - else: - raise AssertionError(f"unexpected operation {operation}") - replay[envelope["request_id"]] = envelope["payload_digest"], response - yield stream.BoundaryChunk( - data=frame( - {"request_id": envelope["request_id"], "response": response} - ) - ) - if operation == "open_mediation": - await finished.wait() - if operation == "attach_process": - status = await process.wait() - payload = json.dumps( - { - "kind": "signaled" if status < 0 else "exited", - "value": abs(status), - }, - separators=(",", ":"), - ).encode() - yield stream.BoundaryChunk( - data=bytes([3]) + struct.pack(">I", len(payload)) + payload - ) - - async def Mediate(self, requests, context): - async for chunk in self.Exchange(requests, context): - yield chunk - - server = grpc.aio.server() - backend = Backend() - control_rpc.add_SandboxBackendServicer_to_server(backend, server) - stream_rpc.add_SandboxBoundaryServicer_to_server(backend, server) - port = server.add_secure_port( - "127.0.0.1:0", grpc.ssl_server_credentials([(key_pem, cert_pem)]) - ) - await server.start() - registration = { - "backends": [ - { - "name": "python-external", - "endpoint": { - "kind": "tcp", - "authority": f"backend.test:{port}", - "addresses": [f"127.0.0.1:{port}"], - }, - } - ] - } - launch = { - "backend_name": "python-external", - "sandbox_id": "interop-sandbox", - "generation": "interop-generation", - "session_id": session, - "workload_identity": identity, - "resource_claims": {}, - "outer_fence": fence, - "tls": {"server_name": "backend.test", "trust_anchor_pem": ca_pem.decode()}, - } - auth = { - "session_id": session, - "runtime_generation": "interop-generation", - "session_rotation": 1, - "auth_epoch": 1, - "gateway_token": TOKEN, - "gateway_expires_at": 0, - "sandbox_token": TOKEN, - "sandbox_expires_at": 0, - } - for name, value in [ - ("registration.json", registration), - ("launch.json", launch), - ("auth.json", auth), - ]: - (directory / name).write_text(json.dumps(value)) - (directory / "descriptor").write_bytes(PAYLOAD) - (directory / "policy.yaml").write_text("network_policies: {}\n") - environment = dict( - { - name: value - for name, value in os.environ.items() - if not name.startswith("OPENSHELL_") - }, - OPENSHELL_ADMITTED_ISOLATION_BACKEND="python-external", - OPENSHELL_TELEMETRY_ENABLED="false", - OPENSHELL_PROXY_TLS_DIR=str(directory / "tls"), - ) - supervisor = await asyncio.create_subprocess_exec( - str(Path(binary).resolve()), - "--health-socket-path", - str(directory / "ready.sock"), - "--sandbox-id", - "interop-sandbox", - "--policy-rules", - str(ROOT / "crates/openshell-supervisor-network/data/sandbox-policy.rego"), - "--policy-data", - str(directory / "policy.yaml"), - "--isolation-backends-file", - str(directory / "registration.json"), - "--boundary-launch-file", - str(directory / "launch.json"), - "--backend-descriptor-file", - str(directory / "descriptor"), - "--auth-bundle-file", - str(directory / "auth.json"), - "--", - "/bin/sleep", - "60", - env=environment, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - output_task = asyncio.create_task(supervisor.stdout.read()) - - async def shutdown_when_ready(): - await started.wait() - while not (directory / "ready.sock").exists(): - assert supervisor.returncode is None, (await output_task).decode() - await asyncio.sleep(0.01) - supervisor.send_signal(signal.SIGTERM) - await supervisor.wait() - - try: - await asyncio.wait_for(shutdown_when_ready(), timeout=30) - output = await output_task - assert supervisor.returncode == 128 + signal.SIGTERM, output.decode() - assert finished.is_set(), output.decode() - assert events.index("confirm") < events.index("start_agent") - print( - "PASS: stock supervisor → registered Python backend → workload → acknowledged cleanup" - ) - finally: - if supervisor.returncode is None: - supervisor.kill() - await supervisor.wait() - print((await output_task).decode(), file=sys.stderr) - print(Counter(events), events[-10:], file=sys.stderr) - if process is not None and process.returncode is None: - process.kill() - await process.wait() - await server.stop(0) - - -if __name__ == "__main__": - asyncio.run(main(sys.argv[1])) diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md index a85484312f..4ea59f002f 100644 --- a/proto/sandbox_protocol/v1/README.md +++ b/proto/sandbox_protocol/v1/README.md @@ -216,10 +216,7 @@ carriage return and tab as `\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`; escape other control characters below U+0020 as lowercase `\u00xx`. Do not escape non-ASCII characters or `/`. Integers use base-10 digits without leading zeros; floating point values are not used. Hash these bytes with SHA-256. -Object order in the transmitted message is immaterial. See -[`vectors.json`](vectors.json) for independently generated request byte/digest examples -checked by Rust and the independent Python fixture. Frame hex provides one -valid serialization; its object key order is not normative. +Object order in the transmitted message is immaterial. The backend validates the digest and records replayable mutations by generation and request ID. The same ID and digest returns the original result without @@ -372,11 +369,7 @@ TCP/DNS decisions, connection recovery, credential rotation, and enforcement loss in its actual driver placement. OpenShell's delegated adapter tests verify opaque payload forwarding and generic evidence without a native Linux audit. Its existing transport suites cover recovery, credential renewal, and flow -control; Docker lifecycle E2E verifies the built-in placement. Run -`mise run e2e:isolation-protocol` for the stock supervisor against an independent -Python implementation generated from the public protos. That fixture tests -interoperability and launch ordering, and simulates enforcement evidence with a -fixed test bearer. It does not exercise JWT validation or provide isolation. +control; Docker lifecycle E2E verifies the built-in placement. Suspend/resume is a separate optional capability described in `sandbox_backend.proto`. Protocol 1.0 implementations may return diff --git a/proto/sandbox_protocol/v1/vectors.json b/proto/sandbox_protocol/v1/vectors.json deleted file mode 100644 index 591fa478a9..0000000000 --- a/proto/sandbox_protocol/v1/vectors.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "version": "1.0", - "requests": [ - { - "canonical_request": "{\"operation\":\"discover_policy\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000001", - "payload_digest": "f316ae675411d6d0fc8d903e8ec9fbd7c4cfcff90ba4b9a0ea42ee12e6ecb393", - "request": { - "operation": "discover_policy" - } - }, - "frame_hex": "000000b37b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303031222c227061796c6f61645f646967657374223a2266333136616536373534313164366430666338643930336538656339666264376334636663666639306261346239613065613432656531326536656362333933222c2272657175657374223a7b226f7065726174696f6e223a22646973636f7665725f706f6c696379227d7d" - }, - { - "canonical_request": "{\"operation\":\"attach\",\"policy\":{\"include_workdir\":true,\"landlock\":\"hard_requirement\",\"network\":\"block\",\"proxy_addr\":null,\"read_only\":[\"/usr\"],\"read_write\":[\"/sandbox\"],\"run_as_group\":null,\"run_as_user\":null,\"version\":1},\"resource_claims\":{\"actor\":\"external-1\",\"quote\\\"\\\\\\n\":\"line\\n\\b\\f\\r\\t\\u0000\\u001f / 雪 😀\",\"\":\"bmp-last\",\"𐀀\":\"astral-first\"},\"supervisor_instance_id\":\"00000000-0000-4000-8000-000000000002\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000002", - "payload_digest": "6a398dbe0829e4833d9135b8d11e3913aee5a02eb90480289b5250ed939bfe13", - "request": { - "operation": "attach", - "supervisor_instance_id": "00000000-0000-4000-8000-000000000002", - "policy": { - "version": 1, - "read_only": [ - "/usr" - ], - "read_write": [ - "/sandbox" - ], - "include_workdir": true, - "network": "block", - "proxy_addr": null, - "landlock": "hard_requirement", - "run_as_user": null, - "run_as_group": null - }, - "resource_claims": { - "actor": "external-1", - "": "bmp-last", - "𐀀": "astral-first", - "quote\"\\\n": "line\n\b\f\r\t\u0000\u001f / 雪 😀" - } - } - }, - "frame_hex": "000002367b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303032222c227061796c6f61645f646967657374223a2236613339386462653038323965343833336439313335623864313165333931336165653561303265623930343830323839623532353065643933396266653133222c2272657175657374223a7b226f7065726174696f6e223a22617474616368222c2273757065727669736f725f696e7374616e63655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303032222c22706f6c696379223a7b2276657273696f6e223a312c22726561645f6f6e6c79223a5b222f757372225d2c22726561645f7772697465223a5b222f73616e64626f78225d2c22696e636c7564655f776f726b646972223a747275652c226e6574776f726b223a22626c6f636b222c2270726f78795f61646472223a6e756c6c2c226c616e646c6f636b223a22686172645f726571756972656d656e74222c2272756e5f61735f75736572223a6e756c6c2c2272756e5f61735f67726f7570223a6e756c6c7d2c227265736f757263655f636c61696d73223a7b226163746f72223a2265787465726e616c2d31222c22ee8080223a22626d702d6c617374222c22f0908080223a2261737472616c2d6669727374222c2271756f74655c225c5c5c6e223a226c696e655c6e5c625c665c725c745c75303030305c7530303166202f20e99baa20f09f9880227d7d7d" - }, - { - "canonical_request": "{\"operation\":\"confirm\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000003", - "payload_digest": "c47c155e09b02a1684c146535ab246541a4775d433a91576130bf1a33462b67e", - "request": { - "operation": "confirm" - } - }, - "frame_hex": "000000ab7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303033222c227061796c6f61645f646967657374223a2263343763313535653039623032613136383463313436353335616232343635343161343737356434333361393135373631333062663161333334363262363765222c2272657175657374223a7b226f7065726174696f6e223a22636f6e6669726d227d7d" - }, - { - "canonical_request": "{\"operation\":\"probe_provider_files\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000004", - "payload_digest": "49d3b528d28661e09c4ac1534e4e6f2f2b9e7f8ee5bdd2fd2b6c0b7619392709", - "request": { - "operation": "probe_provider_files" - } - }, - "frame_hex": "000000b87b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303034222c227061796c6f61645f646967657374223a2234396433623532386432383636316530396334616331353334653465366632663262396537663865653562646432666432623663306237363139333932373039222c2272657175657374223a7b226f7065726174696f6e223a2270726f62655f70726f76696465725f66696c6573227d7d" - }, - { - "canonical_request": "{\"ca_bundle\":null,\"ca_cert\":null,\"operation\":\"start_agent\",\"policy\":{\"include_workdir\":true,\"landlock\":\"hard_requirement\",\"network\":\"block\",\"proxy_addr\":null,\"read_only\":[\"/usr\"],\"read_write\":[\"/sandbox\"],\"run_as_group\":null,\"run_as_user\":null,\"version\":1},\"provider_env\":{\"A\":\"first\",\"Z\":\"last\"},\"provider_env_revision\":1,\"provider_files\":{},\"sandbox_id\":\"sandbox-1\",\"spec\":{\"args\":[],\"interactive\":false,\"program\":\"/bin/true\",\"timeout_secs\":5,\"workdir\":\"/sandbox\"}}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000005", - "payload_digest": "d330c975b9c419ed32b1c98976a041caa165293ecd3078dcbb3c4b3ff5014c0b", - "request": { - "operation": "start_agent", - "sandbox_id": "sandbox-1", - "spec": { - "program": "/bin/true", - "args": [], - "workdir": "/sandbox", - "timeout_secs": 5, - "interactive": false - }, - "policy": { - "version": 1, - "read_only": [ - "/usr" - ], - "read_write": [ - "/sandbox" - ], - "include_workdir": true, - "network": "block", - "proxy_addr": null, - "landlock": "hard_requirement", - "run_as_user": null, - "run_as_group": null - }, - "ca_cert": null, - "ca_bundle": null, - "provider_env_revision": 1, - "provider_env": { - "Z": "last", - "A": "first" - }, - "provider_files": {} - } - }, - "frame_hex": "000002677b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303035222c227061796c6f61645f646967657374223a2264333330633937356239633431396564333262316339383937366130343163616131363532393365636433303738646362623363346233666635303134633062222c2272657175657374223a7b226f7065726174696f6e223a2273746172745f6167656e74222c2273616e64626f785f6964223a2273616e64626f782d31222c2273706563223a7b2270726f6772616d223a222f62696e2f74727565222c2261726773223a5b5d2c22776f726b646972223a222f73616e64626f78222c2274696d656f75745f73656373223a352c22696e746572616374697665223a66616c73657d2c22706f6c696379223a7b2276657273696f6e223a312c22726561645f6f6e6c79223a5b222f757372225d2c22726561645f7772697465223a5b222f73616e64626f78225d2c22696e636c7564655f776f726b646972223a747275652c226e6574776f726b223a22626c6f636b222c2270726f78795f61646472223a6e756c6c2c226c616e646c6f636b223a22686172645f726571756972656d656e74222c2272756e5f61735f75736572223a6e756c6c2c2272756e5f61735f67726f7570223a6e756c6c7d2c2263615f63657274223a6e756c6c2c2263615f62756e646c65223a6e756c6c2c2270726f76696465725f656e765f7265766973696f6e223a312c2270726f76696465725f656e76223a7b225a223a226c617374222c2241223a226669727374227d2c2270726f76696465725f66696c6573223a7b7d7d7d" - }, - { - "canonical_request": "{\"generation\":9007199254740993,\"operation\":\"update_provider_environment\",\"provider_env\":{},\"provider_files\":{},\"revision\":2}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000006", - "payload_digest": "d927db9193ba4efe4c4b2d19242b1a03d3fea01fce9bbba0e9871cea19f3b647", - "request": { - "operation": "update_provider_environment", - "generation": 9007199254740993, - "revision": 2, - "provider_env": {}, - "provider_files": {} - } - }, - "frame_hex": "000001107b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303036222c227061796c6f61645f646967657374223a2264393237646239313933626134656665346334623264313932343262316130336433666561303166636539626262613065393837316365613139663362363437222c2272657175657374223a7b226f7065726174696f6e223a227570646174655f70726f76696465725f656e7669726f6e6d656e74222c2267656e65726174696f6e223a393030373139393235343734303939332c227265766973696f6e223a322c2270726f76696465725f656e76223a7b7d2c2270726f76696465725f66696c6573223a7b7d7d7d" - }, - { - "canonical_request": "{\"operation\":\"attach_process\",\"process_id\":\"generation-1:main\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000007", - "payload_digest": "09735dc472b7fb71d3034b7d0e801f56618027ccdb2ba1d1850a29faa516b11f", - "request": { - "operation": "attach_process", - "process_id": "generation-1:main" - } - }, - "frame_hex": "000000d37b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303037222c227061796c6f61645f646967657374223a2230393733356463343732623766623731643330333462376430653830316635363631383032376363646232626131643138353061323966616135313662313166222c2272657175657374223a7b226f7065726174696f6e223a226174746163685f70726f63657373222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e227d7d" - }, - { - "canonical_request": "{\"operation\":\"wait\",\"process_id\":\"generation-1:main\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000008", - "payload_digest": "e61da144ef7e66274658d99d77a8155d50a935510f5489d54465ca4c689d05db", - "request": { - "operation": "wait", - "process_id": "generation-1:main" - } - }, - "frame_hex": "000000c97b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303038222c227061796c6f61645f646967657374223a2265363164613134346566376536363237343635386439396437376138313535643530613933353531306635343839643534343635636134633638396430356462222c2272657175657374223a7b226f7065726174696f6e223a2277616974222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e227d7d" - }, - { - "canonical_request": "{\"operation\":\"signal\",\"process_id\":\"generation-1:main\",\"signal\":\"term\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000009", - "payload_digest": "d91abab7d4835262bdada10f98d61994a9de5d6edfee33853c48d6952c08fc51", - "request": { - "operation": "signal", - "process_id": "generation-1:main", - "signal": "term" - } - }, - "frame_hex": "000000db7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303039222c227061796c6f61645f646967657374223a2264393161626162376434383335323632626461646131306639386436313939346139646535643665646665653333383533633438643639353263303866633531222c2272657175657374223a7b226f7065726174696f6e223a227369676e616c222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e222c227369676e616c223a227465726d227d7d" - }, - { - "canonical_request": "{\"operation\":\"terminate\",\"process_id\":\"generation-1:main\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000010", - "payload_digest": "d9197927a320eaeecd06e3b83bfbe9b8d1516479b7561d5125b0530e6a1549ad", - "request": { - "operation": "terminate", - "process_id": "generation-1:main" - } - }, - "frame_hex": "000000ce7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303130222c227061796c6f61645f646967657374223a2264393139373932376133323065616565636430366533623833626662653962386431353136343739623735363164353132356230353330653661313534396164222c2272657175657374223a7b226f7065726174696f6e223a227465726d696e617465222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e227d7d" - }, - { - "canonical_request": "{\"operation\":\"terminate_boundary\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000011", - "payload_digest": "fcb65e1ddb606a79abbbad448847b833e4135ca9cc2f9f8e455fbfc9c3837cc2", - "request": { - "operation": "terminate_boundary" - } - }, - "frame_hex": "000000b67b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303131222c227061796c6f61645f646967657374223a2266636236356531646462363036613739616262626164343438383437623833336534313335636139636332663966386534353566626663396333383337636332222c2272657175657374223a7b226f7065726174696f6e223a227465726d696e6174655f626f756e64617279227d7d" - }, - { - "canonical_request": "{\"exec_expires_at_unix_ms\":1800000000000,\"operation\":\"exec\",\"spec\":{\"args\":[\"héllo\"],\"env\":[[\"LANG\",\"C.UTF-8\"],[\"SPECIAL\",\"quote\\\" backslash\\\\ newline\\n NUL\\u0000 emoji😀\"]],\"program\":\"/bin/echo\",\"pty\":false,\"runtime_helper\":null,\"shell\":null,\"workdir\":null}}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000012", - "exec_expires_at_unix_ms": 1800000000000, - "payload_digest": "a269e736f12f736c0c17bd19fc8ff5fb0fd700dd3e04379db81ab961c23c0878", - "request": { - "operation": "exec", - "spec": { - "program": "/bin/echo", - "args": [ - "héllo" - ], - "shell": null, - "runtime_helper": null, - "env": [ - [ - "LANG", - "C.UTF-8" - ], - [ - "SPECIAL", - "quote\" backslash\\ newline\n NUL\u0000 emoji😀" - ] - ], - "workdir": null, - "pty": false - } - } - }, - "frame_hex": "0000019a7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303132222c22657865635f657870697265735f61745f756e69785f6d73223a313830303030303030303030302c227061796c6f61645f646967657374223a2261323639653733366631326637333663306331376264313966633866663566623066643730306464336530343337396462383161623936316332336330383738222c2272657175657374223a7b226f7065726174696f6e223a2265786563222c2273706563223a7b2270726f6772616d223a222f62696e2f6563686f222c2261726773223a5b2268c3a96c6c6f225d2c227368656c6c223a6e756c6c2c2272756e74696d655f68656c706572223a6e756c6c2c22656e76223a5b5b224c414e47222c22432e5554462d38225d2c5b225350454349414c222c2271756f74655c22206261636b736c6173685c5c206e65776c696e655c6e204e554c5c753030303020656d6f6a69f09f9880225d5d2c22776f726b646972223a6e756c6c2c22707479223a66616c73657d7d7d" - }, - { - "canonical_request": "{\"operation\":\"exec_signal\",\"process_id\":\"generation-1:exec\",\"signal\":\"kill\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000013", - "payload_digest": "bc80382030d743852845bc35c200f7c9c51e76c5369acca6024f3d69b6d8bdb7", - "request": { - "operation": "exec_signal", - "process_id": "generation-1:exec", - "signal": "kill" - } - }, - "frame_hex": "000000e07b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303133222c227061796c6f61645f646967657374223a2262633830333832303330643734333835323834356263333563323030663763396335316537366335333639616363613630323466336436396236643862646237222c2272657175657374223a7b226f7065726174696f6e223a22657865635f7369676e616c222c2270726f636573735f6964223a2267656e65726174696f6e2d313a65786563222c227369676e616c223a226b696c6c227d7d" - }, - { - "canonical_request": "{\"cols\":80,\"operation\":\"resize\",\"process_id\":\"generation-1:main\",\"rows\":24}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000014", - "payload_digest": "f51f875abe67b01093312c0536b7686b30a47ce3ea1bcfcd4206052fe44db356", - "request": { - "operation": "resize", - "process_id": "generation-1:main", - "cols": 80, - "rows": 24 - } - }, - "frame_hex": "000000df7b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303134222c227061796c6f61645f646967657374223a2266353166383735616265363762303130393333313263303533366237363836623330613437636533656131626366636434323036303532666534346462333536222c2272657175657374223a7b226f7065726174696f6e223a22726573697a65222c2270726f636573735f6964223a2267656e65726174696f6e2d313a6d61696e222c22636f6c73223a38302c22726f7773223a32347d7d" - }, - { - "canonical_request": "{\"host\":\"127.0.0.1\",\"operation\":\"loopback_connect\",\"port\":8080}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000015", - "payload_digest": "62c8b23330ea3c04bc820acdc50e77286b7439d541f626ae2c35ad3e3dc023d4", - "request": { - "operation": "loopback_connect", - "host": "127.0.0.1", - "port": 8080 - } - }, - "frame_hex": "000000d37b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303135222c227061796c6f61645f646967657374223a2236326338623233333330656133633034626338323061636463353065373732383662373433396435343166363236616532633335616433653364633032336434222c2272657175657374223a7b226f7065726174696f6e223a226c6f6f706261636b5f636f6e6e656374222c22686f7374223a223132372e302e302e31222c22706f7274223a383038307d7d" - }, - { - "canonical_request": "{\"operation\":\"open_mediation\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000016", - "payload_digest": "ddc0eb31d09a4e3f8528f7c9e075d5f252739b6a0ff364995b42f52181249eb4", - "request": { - "operation": "open_mediation" - } - }, - "frame_hex": "000000b27b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303136222c227061796c6f61645f646967657374223a2264646330656233316430396134653366383532386637633965303735643566323532373339623661306666333634393935623432663532313831323439656234222c2272657175657374223a7b226f7065726174696f6e223a226f70656e5f6d6564696174696f6e227d7d" - }, - { - "canonical_request": "{\"operation\":\"accept_network\"}", - "envelope": { - "request_id": "00000000-0000-4000-8000-000000000017", - "payload_digest": "cc7045e9b9efe3b32d8082d881c8d872aabbc6c910edf20f9d3a9bc9e7d31f2e", - "request": { - "operation": "accept_network" - } - }, - "frame_hex": "000000b27b22726571756573745f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303137222c227061796c6f61645f646967657374223a2263633730343565396239656665336233326438303832643838316338643837326161626263366339313065646632306639643361396263396537643331663265222c2272657175657374223a7b226f7065726174696f6e223a226163636570745f6e6574776f726b227d7d" - } - ] -} diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 3b51273df6..00f70472ac 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -315,7 +315,6 @@ Common findings: - 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. - 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. -- An external isolation backend fails selection or registration: check `openshell.gateway.compute_driver`, `isolation_backend` in that driver's `[openshell.drivers.]` table, and `openshell.supervisor.isolation_backends` in the operator's gateway configuration. The selected compute driver must advertise `openshell.compute.sandbox-backend`; unsupported drivers fail startup. Check that the driver stages the resolved registration through the supervisor's protected `--isolation-backends-file`, independently admitted name, and `--boundary-launch-file`. Endpoint addresses must be reachable from the supervisor placement. Names match exactly; duplicate registrations and built-in replacement are rejected. See the published gateway configuration reference and Sandbox Runtimes documentation. - 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`. diff --git a/tasks/test.toml b/tasks/test.toml index c6749b6262..6ac8731345 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -99,14 +99,6 @@ depends = ["e2e:rust", "e2e:python", "e2e:mcp"] description = "Build the current checkout and run a named host or Nix test-guest E2E suite" run = "e2e/run.sh" -["e2e:isolation-protocol"] -description = "Run the stock supervisor against an independently implemented Python Sandbox Protocol backend" -run = [ - "cargo build --locked -p openshell-supervisor", - 'uv run e2e/isolation/protocol_interop.py "${CARGO_TARGET_DIR:-target}/debug/openshell-supervisor"', -] -run_windows = "echo Skipping e2e:isolation-protocol: the workload fixture requires /bin/sleep." - ["e2e:gpu"] description = "Run Docker GPU end-to-end tests" depends = ["e2e:docker:gpu"] From 90345bb2a2c251c42cd192427317575a573708e4 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 14 Oct 2026 23:52:13 -0700 Subject: [PATCH 10/24] feat(examples): add supervised V8 backend demo Signed-off-by: Drew Newberry --- .github/workflows/branch-checks.yml | 5 +- .../src/contract.rs | 12 +- .../tests/backend_conformance.rs | 10 +- .../src/boundary_protocol.rs | 4 +- .../src/runtime/tests/network_recovery.rs | 6 +- .../openshell-sandbox/src/boundary_server.rs | 2 +- .../openshell-supervisor-network/src/proxy.rs | 72 +- .../Cargo.lock | 2045 +++++++++-------- .../Cargo.toml | 27 +- examples/isolation-backend-v8/README.md | 88 + .../build.rs | 0 examples/isolation-backend-v8/guest.js | 10 + examples/isolation-backend-v8/policy.yaml | 11 + examples/isolation-backend-v8/src/driver.rs | 542 +++++ examples/isolation-backend-v8/src/guest.rs | 276 +++ examples/isolation-backend-v8/src/launch.rs | 60 + .../src/lib.rs | 6 +- .../isolation-backend-v8/src/lifecycle.rs | 743 ++++++ examples/isolation-backend-v8/src/main.rs | 92 + examples/isolation-backend-v8/src/network.rs | 168 ++ .../src/protocol.rs | 6 +- .../isolation-backend-v8/src/transport.rs | 53 + examples/isolation-backend-v8/start.sh | 84 + examples/isolation-backend-wasi/README.md | 148 -- examples/isolation-backend-wasi/guest.wat | 55 - .../isolation-backend-wasi/src/backend.rs | 215 -- examples/isolation-backend-wasi/src/guest.rs | 175 -- examples/isolation-backend-wasi/src/main.rs | 111 - .../isolation-backend-wasi/tests/guest.rs | 105 - .../isolation-backend-wasi/tests/protocol.rs | 268 --- proto/sandbox_protocol/v1/README.md | 10 +- tasks/rust.toml | 6 +- tasks/test.toml | 1 - 33 files changed, 3288 insertions(+), 2128 deletions(-) rename examples/{isolation-backend-wasi => isolation-backend-v8}/Cargo.lock (75%) rename examples/{isolation-backend-wasi => isolation-backend-v8}/Cargo.toml (50%) create mode 100644 examples/isolation-backend-v8/README.md rename examples/{isolation-backend-wasi => isolation-backend-v8}/build.rs (100%) create mode 100644 examples/isolation-backend-v8/guest.js create mode 100644 examples/isolation-backend-v8/policy.yaml create mode 100644 examples/isolation-backend-v8/src/driver.rs create mode 100644 examples/isolation-backend-v8/src/guest.rs create mode 100644 examples/isolation-backend-v8/src/launch.rs rename examples/{isolation-backend-wasi => isolation-backend-v8}/src/lib.rs (66%) create mode 100644 examples/isolation-backend-v8/src/lifecycle.rs create mode 100644 examples/isolation-backend-v8/src/main.rs create mode 100644 examples/isolation-backend-v8/src/network.rs rename examples/{isolation-backend-wasi => isolation-backend-v8}/src/protocol.rs (85%) create mode 100644 examples/isolation-backend-v8/src/transport.rs create mode 100755 examples/isolation-backend-v8/start.sh delete mode 100644 examples/isolation-backend-wasi/README.md delete mode 100644 examples/isolation-backend-wasi/guest.wat delete mode 100644 examples/isolation-backend-wasi/src/backend.rs delete mode 100644 examples/isolation-backend-wasi/src/guest.rs delete mode 100644 examples/isolation-backend-wasi/src/main.rs delete mode 100644 examples/isolation-backend-wasi/tests/guest.rs delete mode 100644 examples/isolation-backend-wasi/tests/protocol.rs diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 08f8ed9c1d..b4c6fd48b0 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -220,7 +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-wasi/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check cargo-lockfiles: name: Cargo lockfiles @@ -276,7 +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-wasi/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 }}) @@ -314,7 +314,6 @@ jobs: run: | cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml - cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml rust-postgres-test: name: Rust PostgreSQL tests diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 246394d28e..85334b3621 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -986,8 +986,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 +1015,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. diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 2fb1f3fdba..404a094015 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -105,11 +105,11 @@ impl NetworkMediationSource for MockSource { cmdline_paths: vec![], }), destination: "203.0.113.10:443".parse().unwrap(), - socket: NetworkSocketMetadata { + socket: Some(NetworkSocketMetadata { socket_cookie: 7, nonblocking: false, process_generation: 1, - }, + }), policy_generation: 1, timing: MediationTiming::default(), decision, @@ -144,11 +144,11 @@ impl NetworkMediationSource for UnattributedSource { stream: Box::new(near), binary_identity: Err(ResolveError::Failed("hash unavailable".to_string())), destination: "203.0.113.10:443".parse().unwrap(), - socket: NetworkSocketMetadata { + socket: Some(NetworkSocketMetadata { socket_cookie: 8, nonblocking: false, process_generation: 1, - }, + }), policy_generation: 1, timing: MediationTiming::default(), decision, @@ -795,7 +795,7 @@ async fn pending_network_open_carries_socket_bound_identity() { "00".repeat(32) ); assert_eq!(conn.destination, "203.0.113.10:443".parse().unwrap()); - assert_eq!(conn.socket.socket_cookie, 7); + assert_eq!(conn.socket.unwrap().socket_cookie, 7); } #[tokio::test] diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 32d67538f9..4ec3ad1a67 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -925,7 +925,9 @@ pub enum Response { 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, }, 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 1fadd4102d..7b0a672fae 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -236,11 +236,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, @@ -366,7 +366,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, diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 63a2fac0fb..f1190414dd 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -1349,7 +1349,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( diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 13cbce0d73..a6b7feab53 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7410,11 +7410,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, @@ -7600,11 +7602,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, @@ -8042,11 +8046,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, @@ -8174,11 +8180,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, @@ -8316,11 +8324,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, @@ -8399,11 +8409,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, diff --git a/examples/isolation-backend-wasi/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock similarity index 75% rename from examples/isolation-backend-wasi/Cargo.lock rename to examples/isolation-backend-v8/Cargo.lock index e20e4b1ed9..3e6b022eb0 100644 --- a/examples/isolation-backend-wasi/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -4,13 +4,19 @@ version = 4 [[package]] name = "addr2line" -version = "0.26.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +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" @@ -20,27 +26,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "ambient-authority" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -98,10 +83,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "arbitrary" -version = "1.5.0" +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 = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "async-trait" @@ -120,6 +121,36 @@ 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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -163,12 +194,56 @@ dependencies = [ "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" @@ -189,9 +264,6 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -dependencies = [ - "allocator-api2", -] [[package]] name = "bytes" @@ -200,81 +272,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "cap-fs-ext" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476f0d0003a760918ed4b1e039a59e11769030416f79c8222551d22785f7f70d" -dependencies = [ - "cap-primitives", - "cap-std", - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "cap-net-ext" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "150941cefd3df4de2fea24604ba4949371576f62e527410298333f7d431a1bc6" -dependencies = [ - "cap-primitives", - "cap-std", - "rustix", - "smallvec", -] - -[[package]] -name = "cap-primitives" -version = "3.4.6" +name = "calendrical_calculations" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" dependencies = [ - "ambient-authority", - "fs-set-times", - "io-extras", - "io-lifetimes", - "ipnet", - "maybe-owned", - "rustix", - "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-std" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix", + "core_maths", + "displaydoc", ] [[package]] -name = "cap-time-ext" -version = "3.4.6" +name = "cc" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54c289326c70f1c697ebf0a31842a480932e5942b5fac92fcc46e87286b48e2" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ - "ambient-authority", - "cap-primitives", - "iana-time-zone", - "once_cell", - "rustix", - "winx", + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] -name = "cc" -version = "1.8.0" +name = "cexpr" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "find-msvc-tools", - "shlex", + "nom", ] [[package]] @@ -284,14 +309,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] -name = "chacha20" -version = "0.10.2" +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 = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core", + "glob", + "libc", + "libloading", ] [[package]] @@ -335,12 +366,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] -name = "cobs" -version = "0.3.0" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ - "thiserror 2.0.21", + "cc", ] [[package]] @@ -350,190 +381,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core_detect" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" - -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ + "core-foundation-sys", "libc", ] [[package]] -name = "cranelift-assembler-x64" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aadfd45214f09461ba0406f7dacecce5cac39bf0bdb4890eeb468f4ef090e07" -dependencies = [ - "cranelift-assembler-x64-meta", -] - -[[package]] -name = "cranelift-assembler-x64-meta" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57069faede9bd1f926364b4cf69ccc9bebc6f4af5ab897083581b7bc991bf20a" -dependencies = [ - "cranelift-srcgen", -] - -[[package]] -name = "cranelift-bforest" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1221450f2f9efaa996d916bf53a0a4d13346a4a75468afb828390209a4fdd4" -dependencies = [ - "cranelift-entity", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-bitset" -version = "0.133.3" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97706f2b466d67bdf0f69d1cf5c5c4a5cd31087cf1fa2e8e5fe0450dddc8661d" -dependencies = [ - "serde", - "serde_derive", - "wasmtime-internal-core", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "cranelift-codegen" -version = "0.133.3" +name = "core_maths" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "432246ee44b2fc0140b412dca55801d200d6fb25d2d7d18c44bb29e8cd71268b" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" dependencies = [ - "bumpalo", - "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.17.1", "libm", - "log", - "postcard", - "pulley-interpreter", - "regalloc2", - "rustc-hash", - "serde", - "serde_derive", - "sha2", - "smallvec", - "target-lexicon", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aa51eff0949f06a5c8f3d5adefef94a12ea99f71cd8985047b91d45f367150" -dependencies = [ - "cranelift-assembler-x64-meta", - "cranelift-codegen-shared", - "cranelift-srcgen", - "heck", - "pulley-interpreter", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6095e7095930b6169490138b4262fe7ec24290d3f604d7fe62e1e3138e740daa" - -[[package]] -name = "cranelift-control" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e61dfa0766c0eadf42b3febd145a35f81f31086e207f534e5fb406fc3d08fc47" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93867a79a06dd5d9ffeb96f7563dc7b6b142b5d89e42ab77e458a408a8aa9d5" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-frontend" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1991dc32fefa54f14d2a63570d1e963826257605e478558733f83450b7e7661" -dependencies = [ - "cranelift-codegen", - "hashbrown 0.17.1", - "log", - "smallvec", - "target-lexicon", ] [[package]] -name = "cranelift-isle" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784546de9988ff6e1df0fd4d2450760469a7607344156c661e69ec28082c5da4" - -[[package]] -name = "cranelift-native" -version = "0.133.3" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7107a4ae4a2ba48b5df8648365e638b08322fa1da11e86206bf5eddb862b21dd" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "cranelift-codegen", "libc", - "target-lexicon", ] -[[package]] -name = "cranelift-srcgen" -version = "0.133.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3f161aa81ee5dd8d7b0eb8828988f57baf2f78d288e62b6b3714b37aa6d6a1" - [[package]] name = "crc32fast" version = "1.5.2" @@ -553,6 +433,12 @@ dependencies = [ "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" @@ -564,47 +450,59 @@ dependencies = [ ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "diplomat" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "f8b37a6cea72eb6df5143d3e091d4c34cb0cf4046dd1ddcc082988886fe1f809" dependencies = [ + "diplomat_core", "proc-macro2", "quote", "syn 3.0.9", ] [[package]] -name = "either" -version = "1.19.0" +name = "diplomat-runtime" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +checksum = "ae517853d9f82b2d144393b1c32c34c46c2e361de2b6c44a8fdfce23beb211cd" [[package]] -name = "embedded-io" -version = "0.4.0" +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 = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] -name = "embedded-io" -version = "0.6.1" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "encoding_rs" -version = "0.8.42" +name = "either" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" -dependencies = [ - "cfg-if", - "core_detect", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", -] +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "equivalent" @@ -653,14 +551,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ @@ -668,28 +560,19 @@ dependencies = [ ] [[package]] -name = "fs-set-times" -version = "0.20.3" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" -dependencies = [ - "io-lifetimes", - "rustix", - "windows-sys 0.59.0", -] +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "futures" -version = "0.3.34" +name = "fslock" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "libc", + "winapi", ] [[package]] @@ -699,7 +582,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -708,12 +590,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - [[package]] name = "futures-sink" version = "0.3.34" @@ -732,12 +608,8 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -752,6 +624,17 @@ dependencies = [ "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" @@ -761,19 +644,27 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", ] [[package]] name = "gimli" -version = "0.33.0" +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 = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +checksum = "86848f4fd157d91041a62c78046fb7b248bcc2dce78376d436a1756e9a038577" dependencies = [ - "fnv", - "hashbrown 0.16.1", - "indexmap", - "stable_deref_trait", + "crc32fast", ] [[package]] @@ -801,25 +692,14 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", - "serde", - "serde_core", -] [[package]] name = "heck" @@ -827,6 +707,15 @@ 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" @@ -929,28 +818,26 @@ dependencies = [ ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "icu_calendar" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "icu_calendar_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" [[package]] name = "icu_collections" @@ -974,11 +861,32 @@ 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" @@ -1028,6 +936,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1036,10 +946,26 @@ dependencies = [ ] [[package]] -name = "id-arena" +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 = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" [[package]] name = "idna" @@ -1074,34 +1000,33 @@ dependencies = [ "serde_core", ] -[[package]] -name = "io-extras" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" -dependencies = [ - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "io-lifetimes" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" - [[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" @@ -1117,6 +1042,22 @@ 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-sys" version = "0.3.106" @@ -1129,16 +1070,22 @@ dependencies = [ ] [[package]] -name = "leb128" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" - -[[package]] -name = "leb128fmt" -version = "0.1.0" +name = "jsonwebtoken" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] [[package]] name = "libc" @@ -1146,12 +1093,28 @@ 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" @@ -1165,16 +1128,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] -name = "log" -version = "0.4.34" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] [[package]] -name = "mach2" -version = "0.6.0" +name = "log" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchit" @@ -1182,12 +1148,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "maybe-owned" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" - [[package]] name = "memchr" version = "2.8.3" @@ -1195,12 +1155,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "memfd" -version = "0.6.6" +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 = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ - "rustix", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1209,6 +1190,21 @@ 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" @@ -1227,20 +1223,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "multiversion_no_op" -version = "1.0.0" +name = "nix" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] [[package]] -name = "object" -version = "0.39.1" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +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 = [ - "crc32fast", "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", ] @@ -1257,39 +1315,194 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "openshell-wasi-backend-example" +name = "openshell-core" version = "0.0.0" dependencies = [ - "anyhow", - "clap", + "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", - "subtle", - "tempfile", + "thiserror", "tokio", "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tonic-types", + "tracing", + "url", "uuid", - "wasmtime", - "wasmtime-wasi", - "wat", + "zeroize", ] [[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" +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 = [ + "async-trait", + "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", + "hyper-util", + "libc", + "openshell-core", + "openshell-isolation-interface", + "prost", + "rcgen", + "rustix 1.1.5", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "socket2", + "thiserror", + "tokio", + "tokio-rustls", + "tokio-stream", + "tonic", + "tonic-prost", + "tower", + "tracing", + "uuid", +] + +[[package]] +name = "openshell-v8-backend-example" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-stream", + "clap", + "openshell-core", + "openshell-isolation-interface", + "openshell-sandbox-backend", + "prost", + "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 = "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", @@ -1324,16 +1537,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "postcard" -version = "1.1.3" +name = "pkg-config" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" @@ -1341,9 +1548,17 @@ 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" @@ -1380,7 +1595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -1401,7 +1616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1500,29 +1715,6 @@ dependencies = [ "pulldown-cmark", ] -[[package]] -name = "pulley-interpreter" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f647f8aefd39efd5e38b484a8b7a926dd035ae366127df75c1d94d196f0a4ba" -dependencies = [ - "cranelift-bitset", - "log", - "pulley-macros", - "wasmtime-internal-core", -] - -[[package]] -name = "pulley-macros" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e902032e329008b189b67498b1be7784d5849276f32402eca17441df08a6bcb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "quote" version = "1.0.47" @@ -1539,35 +1731,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand" -version = "0.10.3" +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "chacha20", - "getrandom", - "rand_core", + "getrandom 0.2.17", ] [[package]] -name = "rand_core" -version = "0.10.1" +name = "rcgen" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "yasna", +] [[package]] -name = "regalloc2" -version = "0.15.2" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.17.1", - "log", - "rustc-hash", - "serde", - "smallvec", + "bitflags", ] [[package]] @@ -1599,6 +1790,30 @@ 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" @@ -1611,6 +1826,19 @@ 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" @@ -1620,18 +1848,65 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] -name = "rustix-linux-procfs" -version = "0.1.1" +name = "rustls" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", + "log", "once_cell", - "rustix", + "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]] @@ -1640,6 +1915,15 @@ 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" @@ -1647,13 +1931,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "semver" -version = "1.0.28" +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "serde", - "serde_core", + "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]] @@ -1699,6 +1996,17 @@ dependencies = [ "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" @@ -1706,10 +2014,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "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" @@ -1727,10 +2041,25 @@ dependencies = [ ] [[package]] -name = "simdutf8" -version = "0.1.5" +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 = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] [[package]] name = "slab" @@ -1743,9 +2072,6 @@ name = "smallvec" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -1763,6 +2089,15 @@ 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" @@ -1775,6 +2110,27 @@ 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" @@ -1814,12 +2170,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tempfile" version = "3.27.0" @@ -1827,48 +2177,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.5", "windows-sys 0.61.2", ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "temporal_capi" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +checksum = "f48eb9ca4ca22caaa48d1e7a534ad3fd598a23d1a71945e8ad5ac0be501fa5f2" dependencies = [ - "winapi-util", + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale_core", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "temporal_rs" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "1a978fef907a27736827d336c0bfaab14fb8a7509dd6dc2b43c44e51d59aca5b" dependencies = [ - "thiserror-impl 1.0.69", + "calendrical_calculations", + "core_maths", + "icu_calendar", + "icu_locale_core", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", ] [[package]] -name = "thiserror" -version = "2.0.21" +name = "terminal_size" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "thiserror-impl 2.0.21", + "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "textwrap" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "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]] @@ -1882,6 +2257,48 @@ dependencies = [ "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" @@ -1889,6 +2306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -1901,6 +2319,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -1919,6 +2338,16 @@ dependencies = [ "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" @@ -1963,9 +2392,11 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", + "rustls-native-certs", "socket2", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-stream", "tower", "tower-layer", @@ -2012,6 +2443,17 @@ dependencies = [ "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" @@ -2098,6 +2540,12 @@ 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" @@ -2105,10 +2553,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unicode-xid" -version = "0.2.6" +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 = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" @@ -2140,11 +2594,28 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom", + "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" @@ -2212,424 +2683,38 @@ dependencies = [ ] [[package]] -name = "wasm-encoder" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" -dependencies = [ - "leb128fmt", - "wasmparser 0.251.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.262.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" -dependencies = [ - "leb128fmt", - "wasmparser 0.262.0", -] - -[[package]] -name = "wasmparser" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" -dependencies = [ - "bitflags", - "hashbrown 0.17.1", - "indexmap", - "semver", - "serde", -] - -[[package]] -name = "wasmparser" -version = "0.262.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" -dependencies = [ - "bitflags", - "indexmap", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8798c1a699bd25648b6708eefe94d97c6f9891febb94b42cca1f7a4b086ea64e" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.251.0", -] - -[[package]] -name = "wasmtime" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39226663bd765601279a79b88645a7c4a4d3c2fe73040afdc27bead02ee6659" -dependencies = [ - "addr2line", - "async-trait", - "bitflags", - "bumpalo", - "cc", - "cfg-if", - "encoding_rs", - "futures", - "libc", - "log", - "mach2", - "memfd", - "object", - "once_cell", - "postcard", - "pulley-interpreter", - "rustix", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon", - "wasmparser 0.251.0", - "wasmtime-environ", - "wasmtime-internal-component-macro", - "wasmtime-internal-component-util", - "wasmtime-internal-core", - "wasmtime-internal-cranelift", - "wasmtime-internal-fiber", - "wasmtime-internal-jit-debug", - "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", - "wat", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-environ" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2595a168541e8a7faeacf21e3d59da93628ee71468cd69fee9fc2a92c9b1c7d8" -dependencies = [ - "anyhow", - "cpp_demangle", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "hashbrown 0.17.1", - "indexmap", - "log", - "object", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "sha2", - "smallvec", - "target-lexicon", - "wasm-encoder 0.251.0", - "wasmparser 0.251.0", - "wasmprinter", - "wasmtime-internal-component-util", - "wasmtime-internal-core", -] - -[[package]] -name = "wasmtime-internal-component-macro" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9abf0fb4ce458eb6edbfaf6cbf13fcbf4216eac5a85febfe90224a1f6064c990" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasmtime-internal-component-util", - "wasmtime-internal-wit-bindgen", - "wit-parser", -] - -[[package]] -name = "wasmtime-internal-component-util" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e50df18c9a8e0deec353ad1e6364ef50538231719a12d80fa5dcb300be8d36b" - -[[package]] -name = "wasmtime-internal-core" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93b097389e5fad89c8ff8ee0aeafd16447b5a288d52aa1131f17ab4c19fd5be3" -dependencies = [ - "anyhow", - "hashbrown 0.17.1", - "libm", - "serde", -] - -[[package]] -name = "wasmtime-internal-cranelift" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08b3e909115836655b83c8bdef7c2e20a34293e785218d9f71a4e5382a09c6b0" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon", - "thiserror 2.0.21", - "wasmparser 0.251.0", - "wasmtime-environ", - "wasmtime-internal-core", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-fiber" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a000d1124dd40417ce8433d07de1063ecc9be7cac21745c8de7f9cf7684c3ead" -dependencies = [ - "cc", - "cfg-if", - "libc", - "rustix", - "wasmtime-environ", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-jit-debug" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7439956e78ba68e2cd766f34c3f056d625fad9a055eafb0c4569ac91c09e6b4" -dependencies = [ - "cc", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-jit-icache-coherence" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3a381a9dd1ffe3893507733300b420605f465655d78c7a75a965a09d7b43d5" -dependencies = [ - "cfg-if", - "libc", - "wasmtime-internal-core", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-unwinder" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f951f78a1a09001547838fa557522d56655385db97e47307d0f582ca866974" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "log", - "object", - "wasmtime-environ", -] - -[[package]] -name = "wasmtime-internal-versioned-export-macros" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e06f9fabbe647e5c2ed186e6920ab1fb48174688d7d3fdadbda537b8499b51" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "wasmtime-internal-wit-bindgen" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49ac5121036c2255bdc203b1b75a1c04544744cd5f51c93240b58bddca9f0f1" -dependencies = [ - "anyhow", - "bitflags", - "heck", - "indexmap", - "wit-parser", -] - -[[package]] -name = "wasmtime-wasi" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23668f18d6fd0a932c1fc58600bbb8a2e4f9b217f08bb61841e2c275d4ce3" -dependencies = [ - "async-trait", - "bitflags", - "bytes", - "cap-fs-ext", - "cap-net-ext", - "cap-std", - "cap-time-ext", - "cfg-if", - "futures", - "io-extras", - "io-lifetimes", - "rand", - "rustix", - "thiserror 2.0.21", - "tokio", - "tracing", - "url", - "wasmtime", - "wasmtime-wasi-io", - "wiggle", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-wasi-io" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0df5ffcd5aca96285be7745d4677ca5e2851245ca7c066a98c0fbee5bc6375f1" -dependencies = [ - "async-trait", - "bytes", - "futures", - "tracing", - "wasmtime", -] - -[[package]] -name = "wast" -version = "35.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" -dependencies = [ - "leb128", -] - -[[package]] -name = "wast" -version = "262.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.262.0", -] - -[[package]] -name = "wat" -version = "1.262.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" -dependencies = [ - "wast 262.0.0", -] - -[[package]] -name = "wiggle" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77deefc2b0fe8c258316372ec322da627ce8c5b5ea9eec3b001190e9f8f2f69" -dependencies = [ - "bitflags", - "thiserror 2.0.21", - "tracing", - "wasmtime", - "wasmtime-environ", - "wiggle-macro", -] - -[[package]] -name = "wiggle-generate" -version = "46.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a52145d29793eb1e0c5be9b6be5a74552b9236f01866c769ec1c3270584085f" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasmtime-environ", - "witx", -] - -[[package]] -name = "wiggle-macro" -version = "46.0.3" +name = "which" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26242c5c54d0f2837e1fecc933e1581e45d39cde1b1f6b3ad72955e0851ec20" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "wiggle-generate", + "either", + "home", + "rustix 0.38.44", + "winsafe", ] [[package]] -name = "winapi-util" -version = "0.1.11" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "windows-sys 0.61.2", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "windows-core" -version = "0.62.2" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-link" @@ -2638,21 +2723,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-link", + "windows-targets", ] [[package]] @@ -2738,52 +2814,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winx" -version = "0.36.4" +name = "winsafe" +version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" -dependencies = [ - "bitflags", - "windows-sys 0.59.0", -] +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] -name = "wit-parser" -version = "0.251.0" +name = "writeable" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e960732e824fab95099971a09e638979347c94ca48568d3c854c945729196947" -dependencies = [ - "anyhow", - "hashbrown 0.17.1", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.251.0", -] +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] -name = "witx" -version = "0.9.1" +name = "yasna" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" dependencies = [ - "anyhow", - "log", - "thiserror 1.0.69", - "wast 35.0.2", + "time", ] -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - [[package]] name = "yoke" version = "0.8.3" @@ -2828,6 +2878,26 @@ dependencies = [ "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" @@ -2837,6 +2907,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -2845,6 +2916,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -2866,3 +2938,16 @@ 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-wasi/Cargo.toml b/examples/isolation-backend-v8/Cargo.toml similarity index 50% rename from examples/isolation-backend-wasi/Cargo.toml rename to examples/isolation-backend-v8/Cargo.toml index 08250dfe1a..72f55854f6 100644 --- a/examples/isolation-backend-wasi/Cargo.toml +++ b/examples/isolation-backend-v8/Cargo.toml @@ -4,7 +4,7 @@ [workspace] [package] -name = "openshell-wasi-backend-example" +name = "openshell-v8-backend-example" version = "0.0.0" edition = "2024" rust-version = "1.94" @@ -13,28 +13,31 @@ publish = false [dependencies] anyhow = "1" -clap = { version = "4.5", features = ["derive"] } +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" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -subtle = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "process", "fs", "io-util"] } tokio-stream = { version = "0.1", features = ["net"] } -tonic = "0.14" +tonic = { version = "0.14", features = ["tls-aws-lc"] } tonic-prost = "0.14" uuid = { version = "1", features = ["v4"] } -wasmtime = { version = "46.0", default-features = false, features = ["anyhow", "cranelift", "runtime", "std", "wat"] } -wasmtime-wasi = { version = "46.0", default-features = false, features = ["p1"] } -wat = "1" + +v8 = "152.2.0" [build-dependencies] protoc-bin-vendored = "3" tonic-prost-build = "0.14" -[dev-dependencies] -tempfile = "3" - [[bin]] -name = "wasi-backend" +name = "v8-backend" 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..daa631c97b --- /dev/null +++ b/examples/isolation-backend-v8/README.md @@ -0,0 +1,88 @@ +# Local V8 sandbox backend + +Run a JavaScript file in a V8 isolate and see the supervisor deny its HTTP +request. The driver and backend run as separate processes. Use a non-root +account with the repository's Rust build prerequisites installed. + +## Start + +From this example directory: + +```shell +./start.sh +``` + +The script builds the binaries, generates local configuration and credentials, +and starts the backend, driver, and gateway. The first build downloads the V8 +library. The script prints the temporary directory containing `gateway.toml` and +logs. The gateway accepts unauthenticated requests on `127.0.0.1:18770`; the +backend uses an authenticated Unix socket. + +## 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 = "v8-example" + +[openshell.drivers.v8-example] +socket_path = "/compute.sock" +isolation_backend = "example-v8" + +[[openshell.supervisor.isolation_backends]] +name = "example-v8" +endpoint = { kind = "unix", socket_path = "/backend.sock" } +``` + +The gateway calls the driver on `compute.sock`. The supervisor resolves the +backend name to `backend.sock`. + +## Create a sandbox + +In another terminal, from this example directory: + +```shell +export PATH="$PWD/../../target/debug:$PATH" +export OPENSHELL_GATEWAY_ENDPOINT=http://127.0.0.1:18770 +openshell sandbox create --name v8-demo --policy policy.yaml \ + --detach --no-tty --no-auto-providers -- /work/guest.js +``` + +The driver starts the supervisor. The backend loads `guest.js` into a fresh V8 +isolate with `console.log` and `httpGet`. No filesystem, process, or direct +network API is exposed. `httpGet` sends the request through the Sandbox Protocol; +the supervisor enforces `policy.yaml`, whose empty `network_policies` denies it. +The guest succeeds only on that policy denial; transport failures are errors. + +```javascript +const url = "http://1.1.1.1/"; +console.log(`Attempting GET ${url} through the supervisor`); +const result = httpGet(url); +if (result !== -1) { + throw new Error(`Expected a supervisor policy denial, received ${result}`); +} +console.log("Request denied by supervisor network policy (expected)"); +``` + +The script prints a `tail -f` command for the backend log, which includes guest +output. `openshell logs v8-demo` shows the supervisor's policy-denial audit. + +## Inspect and stop + +```shell +openshell sandbox list +openshell logs v8-demo +openshell sandbox delete v8-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. + +This example runs one script at a time, with a 15-second deadline, a 32 MiB +JavaScript heap limit, and 64 KiB per output stream. These limits do not bound +all native V8 resources. It supports synchronous HTTP GET status checks to +numeric addresses through `httpGet`, not the browser `fetch` API. TypeScript, +module imports, DNS, HTTPS, exec, PTY, and recovery are unsupported. diff --git a/examples/isolation-backend-wasi/build.rs b/examples/isolation-backend-v8/build.rs similarity index 100% rename from examples/isolation-backend-wasi/build.rs rename to examples/isolation-backend-v8/build.rs diff --git a/examples/isolation-backend-v8/guest.js b/examples/isolation-backend-v8/guest.js new file mode 100644 index 0000000000..af524dfc45 --- /dev/null +++ b/examples/isolation-backend-v8/guest.js @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const url = "http://1.1.1.1/"; +console.log(`Attempting GET ${url} through the supervisor`); +const result = httpGet(url); +if (result !== -1) { + throw new Error(`Expected a supervisor policy denial, received ${result}`); +} +console.log("Request denied by supervisor network policy (expected)"); diff --git a/examples/isolation-backend-v8/policy.yaml b/examples/isolation-backend-v8/policy.yaml new file mode 100644 index 0000000000..a713e27617 --- /dev/null +++ b/examples/isolation-backend-v8/policy.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 +filesystem_policy: + include_workdir: false + read_only: [] + read_write: [] +landlock: + compatibility: best_effort +network_policies: {} diff --git a/examples/isolation-backend-v8/src/driver.rs b/examples/isolation-backend-v8/src/driver.rs new file mode 100644 index 0000000000..2862832bf3 --- /dev/null +++ b/examples/isolation-backend-v8/src/driver.rs @@ -0,0 +1,542 @@ +// 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::{OuterFenceGuarantee, OuterFenceGuarantees}; +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 example-v8", + )); + } + 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 != "v8-local: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.outer_fence.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; + 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 = OuterFenceGuarantees::from_enforcement_evidence( + auth.supervisor.runtime_generation.to_string(), + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + &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(), + outer_fence: 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(sandbox_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(), + }, + }; + 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 v8-backend backend 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::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") + .arg("--") + .arg("/work/guest.js") + .stdin(std::process::Stdio::piped()) + .stdout(output) + .stderr(errors); + let supervisor = command.spawn().map_err(internal)?; + 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, + "example-v8", + "0.0.0", + [ + extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.into(), + extension_protocol::COMPUTE_SANDBOX_BACKEND.into(), + ], + ); + extension + .required_capabilities + .push(extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.into()); + Ok(Response::new(GetCapabilitiesResponse { + driver_name: "v8-example".into(), + driver_version: "0.0.0".into(), + default_image: "v8-local: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..116fec6aa2 --- /dev/null +++ b/examples/isolation-backend-v8/src/guest.rs @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! One V8 isolate with two host functions: bounded logging and mediated HTTP. +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}; +use tokio::sync::watch; + +use crate::network::{self, Network}; + +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 { + network: Option, + stopped: watch::Receiver, + stdout: Output, +} + +pub struct Guest { + source: String, + digest: String, + cancelled: AtomicBool, + stopped: watch::Sender, + 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), + stopped: watch::channel(false).0, + isolate: Mutex::new(None), + }) + } + + pub fn terminate(&self) { + self.cancelled.store(true, Ordering::SeqCst); + self.stopped.send_replace(true); + if let Some(handle) = self.isolate.lock().unwrap().as_ref() { + handle.terminate_execution(); + } + } + + pub fn run( + &self, + network: Option, + 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(network, 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, + network: Option, + 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 { + network, + stopped: self.stopped.subscribe(), + stdout, + }); + // 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, "httpGet").unwrap().into(), + v8::FunctionTemplate::new(scope, http_get).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 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(); + } + if scope.has_terminated() { + Ok(GuestStatus::Terminated) + } else if result.is_some() { + Ok(GuestStatus::Exited) + } else { + let message = scope + .exception() + .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); + Ok(GuestStatus::Failed) + } + })(); + isolate.remove_near_heap_limit_callback(heap_limit, 0); + result + } +} + +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) { + 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 output = &scope.get_slot::().unwrap().stdout; + if !output.append(&message) || !output.append("\n") { + scope.terminate_execution(); + } +} + +fn http_get( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + mut result: v8::ReturnValue, +) { + let Ok(url) = v8::Local::::try_from(args.get(0)) else { + result.set_int32(network::INVALID_REQUEST); + return; + }; + if url.length() > network::MAX_URL { + result.set_int32(network::INVALID_REQUEST); + return; + } + let url = url.to_rust_string_lossy(scope); + let state = scope.get_slot::().unwrap(); + let status = state + .network + .as_ref() + .map_or(network::UNAVAILABLE, |network| { + network.http_get(&url, state.stopped.clone()) + }); + result.set_int32(status); +} diff --git a/examples/isolation-backend-v8/src/launch.rs b/examples/isolation-backend-v8/src/launch.rs new file mode 100644 index 0000000000..84b9807d05 --- /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":"supervisor-http-host-call","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-wasi/src/lib.rs b/examples/isolation-backend-v8/src/lib.rs similarity index 66% rename from examples/isolation-backend-wasi/src/lib.rs rename to examples/isolation-backend-v8/src/lib.rs index dd58d28189..08cf6498d3 100644 --- a/examples/isolation-backend-wasi/src/lib.rs +++ b/examples/isolation-backend-v8/src/lib.rs @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -pub mod backend; +pub mod driver; pub mod guest; +pub mod launch; +pub mod lifecycle; +pub mod network; 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..1e47bd1ce7 --- /dev/null +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -0,0 +1,743 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Supervisor-facing lifecycle and mediated HTTP for the local V8 placement. +use crate::{ + guest::{Guest, GuestResult, GuestStatus, read_script}, + launch::Launch, + protocol::{BACKEND, PeerMetadata, ProtocolVersion, boundary::*, control::*}, +}; +use anyhow::{Result, ensure}; +use openshell_core::jwt::{SandboxId, SessionJwtVerifier, SessionTokenProfile, SystemJwtClock}; +use openshell_isolation_interface::contract::{ + BoundaryConfirmation, BoundaryProperties, EnforcedProperty, +}; +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, + network: crate::network::Network, + network_requests: Mutex>, + 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::{ + OuterFenceGuarantee as G, OuterFenceGuarantees, + }; + let fence = OuterFenceGuarantees::from_enforcement_evidence( + launch.boundary.generation.clone(), + [ + G::DefaultDenyEgress, + G::NoUnmanagedEgressPath, + G::RevocationVerified, + G::ControllerLossFailsClosed, + ], + &crate::launch::capability_evidence(&digest), + )?; + ensure!( + fence == launch.boundary.outer_fence, + "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, + )); + let (network, network_requests) = crate::network::Network::channel(); + Ok(Arc::new(Self { + launch, + network, + network_requests: Mutex::new(network_requests), + 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>, + ) -> 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(|f| f.payload) + != Some(boundary_chunk::Payload::BoundaryId( + 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("this V8 example 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, + resource_claims, + .. + } => { + self.validate_policy(policy)?; + if resource_claims != &self.launch.boundary.resource_claims { + return Err(Status::permission_denied("resource claims mismatch")); + } + 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()))?; + let property = || EnforcedProperty::new(true, "v8-isolate-capabilities"); + 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(), + outer_fence: self.launch.boundary.outer_fence.clone(), + authenticated_supervisor: true, + runtime_exit_terminates_workload: true, + properties: BoundaryProperties { + filesystem_confinement: property(), + egress_interception: property(), + request_attribution: property(), + privilege_floor: property(), + }, + backend_audit: serde_json::json!({"script_sha256": self.launch.boundary.workload_identity.resource_digest, "network": "supervisor-http-host-call", "filesystem": "no-host-access"}), + }), + } + } + Q::ProbeProviderFiles => { + return Err(Status::unimplemented("V8 provider files are unsupported")); + } + Q::StartAgent { + sandbox_id, + spec, + policy, + provider_env_revision, + provider_env, + provider_files, + .. + } => { + if sandbox_id != &self.launch.boundary.sandbox_id + || state.policy.as_ref() != Some(policy) + { + 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(); + let network = self.network.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + guest.run(Some(network), |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 this V8 example", + )); + } + }; + if envelope.request.is_replayable_mutation() { + state.ledger.insert( + envelope.request_id.clone(), + (envelope.payload_digest.clone(), response.clone()), + ); + } + Ok(response) + } + 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 sandbox_backend_server::SandboxBackend 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 = "openshell.sandbox.protocol".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(DescribeSandboxBackendResponse { + backend: Some(PeerMetadata { + protocol_version: Some(ProtocolVersion { major: 1, minor: 0 }), + implementation_name: BACKEND.into(), + implementation_version: "0.0.0".into(), + supported_capabilities: vec![capability.clone()], + required_capabilities: vec![capability], + }), + })) + } + 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.sandbox_id != b.sandbox_id + || q.session_id != b.session_id.to_string() + || q.generation_id != b.generation + || 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_boundary_server::SandboxBoundary for Arc { + type ExchangeStream = Chunks; + type MediateStream = Chunks; + async fn mediate( + &self, + request: Request>, + ) -> Result, Status> { + self.exchange(request).await + } + async fn exchange( + &self, + request: Request>, + ) -> Result, Status> { + let (principal, mut input) = self.route(request).await?; + let runtime = self.clone(); + let stream = async_stream::try_stream! { + let mut bytes = Vec::new(); + let envelope = loop { + if bytes.len() >= 4 { + let length = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + if length == 0 || length > wire::MAX_CONTROL_FRAME_BYTES { Err(Status::invalid_argument("invalid frame length"))?; } + if bytes.len() >= 4+length { break wire::decode_frame::(&bytes[..4+length]).map_err(|_|Status::invalid_argument("invalid request frame"))?; } + } + let chunk = input.message().await?.ok_or_else(||Status::invalid_argument("incomplete request"))?; + let Some(boundary_chunk::Payload::Data(data)) = chunk.payload else { Err(Status::invalid_argument("data frame required"))?; unreachable!() }; + if bytes.len()+data.len()>wire::MAX_CONTROL_FRAME_BYTES+4 { Err(Status::resource_exhausted("request too large"))?; } + bytes.extend_from_slice(&data); + }; + 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(); + let mut pending = loop { + let queued = tokio::select! { + biased; + _ = ended.wait_for(|value| *value) => Err(Status::unavailable("boundary closed")), + _ = input.message() => Err(Status::unavailable("network accept disconnected")), + pending = async { runtime.network_requests.lock().await.recv().await } => pending.ok_or_else(||Status::unavailable("network source closed")), + }?; + if !queued.result.is_closed() { break queued; } + }; + let identity = wire::BinaryIdentityWire::Resolved { + executable: wire::ExecutableIdentityWire { + path: PathBuf::from("/work/guest.js"), + digest: Some(runtime.launch.boundary.workload_identity.resource_digest.parse().map_err(|_|Status::internal("invalid script digest"))?), + }, + ancestors: vec![], + cmdline_paths: vec![], + }; + let response = wire::Response::NetworkConnected { + identity, + destination: pending.destination, + socket: None, + policy_generation: 0, + timing: wire::MediationTimingWire { + notification_to_queue_us: 0, + queue_wait_us: pending.queued_at.elapsed().as_micros().try_into().unwrap_or(u64::MAX), + }, + }; + yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(wire::encode_frame(&wire::ResponseEnvelope { request_id:envelope.request_id,response }).map_err(|_|Status::internal("encode network response"))?))}; + let decision = tokio::select! { + _ = pending.result.closed() => return, + decision = crate::network::decision(&mut input) => decision, + }?; + use openshell_isolation_interface::contract::TcpOpenDecision; + let status = match decision { + TcpOpenDecision::Denied(reason) => crate::network::denied(reason), + TcpOpenDecision::RelayReady => { + yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(pending.request))}; + tokio::select! { + _ = pending.result.closed() => return, + status = crate::network::http_status(&mut input) => status, + }? + } + }; + let _ = pending.result.send(status); + 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, + _ => wire::BoundaryErrorKind::Denied, + }, + message: error.message().to_string(), + }, + }; + let attached = matches!(response,wire::Response::ProcessAttached {..}); + let mediation = matches!(response,wire::Response::MediationReady); + yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(wire::encode_frame(&wire::ResponseEnvelope { request_id:envelope.request_id,response }).map_err(|_|Status::internal("encode response"))?))}; + if attached { + let mut offsets = [0usize, 0usize]; + loop { + let complete = runtime.result.borrow().clone(); + let buffers = runtime.output.lock().unwrap().as_ref().map(|(out,err)| [out.contents(), err.contents()]); + let buffers = complete.as_ref().map(|r| [r.stdout.clone(),r.stderr.clone()]).or(buffers).unwrap_or_default(); + for (index, data) in buffers.into_iter().enumerate() { + for piece in data[offsets[index]..].chunks(wire::MAX_STREAM_FRAME_BYTES) { + let mut frame=vec![if index==0 {wire::STREAM_STDOUT}else{wire::STREAM_STDERR}]; + frame.extend_from_slice(&(piece.len() as u32).to_be_bytes()); frame.extend_from_slice(piece); + yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(frame))}; + } + offsets[index] = data.len(); + } + if let Some(result)=complete { + let bytes=serde_json::to_vec(&Runtime::exit(&result)).unwrap(); + let mut frame=vec![wire::STREAM_EXIT];frame.extend_from_slice(&(bytes.len() as u32).to_be_bytes());frame.extend_from_slice(&bytes); + yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(frame))}; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + 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!( + "V8 backend 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!( + "V8 boundary 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(sandbox_backend_server::SandboxBackendServer::new( + runtime.clone(), + )) + .add_service(sandbox_boundary_server::SandboxBoundaryServer::new( + runtime.clone(), + )) + .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..ec21913a0d --- /dev/null +++ b/examples/isolation-backend-v8/src/main.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + io::{self, Write}, + path::PathBuf, + sync::Arc, +}; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use openshell_v8_backend_example::guest::{Guest, GuestStatus, read_script}; + +#[derive(Parser)] +#[command(about = "Local V8 backend, example compute driver, and guest runner")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Serve driver-provisioned launches over authenticated TLS. + Backend { + #[arg(long, env = "V8_DEMO_STATE")] + state_dir: PathBuf, + }, + /// Run the local-development compute driver on a private Unix socket. + Driver { + #[arg(long, env = "V8_DEMO_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, + }, + /// Run a guest locally, without supervisor admission. + Run { script: PathBuf }, +} + +#[tokio::main] +async fn main() -> Result<()> { + match Cli::parse().command { + Command::Backend { state_dir } => { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + openshell_v8_backend_example::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 = openshell_v8_backend_example::driver::Driver::new( + state_dir, + supervisor, + script, + gateway_endpoint, + policy_rules, + )?; + openshell_v8_backend_example::driver::serve(Arc::new(driver), socket).await?; + } + Command::Run { script } => { + let bytes = read_script(&script)?; + let result = + tokio::task::spawn_blocking(move || Guest::new(&bytes)?.run(None, |_, _| {})) + .await??; + io::stdout().write_all(&result.stdout)?; + io::stderr().write_all(&result.stderr)?; + eprintln!("{}", serde_json::to_string(&result)?); + if result.status != GuestStatus::Exited || result.exit_code != Some(0) { + std::process::exit(1); + } + } + } + Ok(()) +} diff --git a/examples/isolation-backend-v8/src/network.rs b/examples/isolation-backend-v8/src/network.rs new file mode 100644 index 0000000000..c26ba6d014 --- /dev/null +++ b/examples/isolation-backend-v8/src/network.rs @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A deliberately small HTTP host call. Only the supervisor opens upstream sockets. +use std::{ + net::SocketAddr, + time::{Duration, Instant}, +}; + +use openshell_isolation_interface::contract::{TcpOpenDecision, TcpOpenDenial}; +use openshell_sandbox_backend::boundary_protocol as wire; +use tokio::sync::{mpsc, oneshot, watch}; +use tonic::{Status, Streaming}; + +use crate::protocol::boundary::{BoundaryChunk, boundary_chunk}; + +pub const POLICY_DENIED: i32 = -1; +pub const UNAVAILABLE: i32 = -2; +pub const OTHER_DENIAL: i32 = -3; +pub const INVALID_REQUEST: i32 = -4; +pub const MAX_URL: usize = 512; +const TIMEOUT: Duration = Duration::from_secs(10); + +pub struct HttpOpen { + pub destination: SocketAddr, + pub request: Vec, + pub queued_at: Instant, + pub result: oneshot::Sender, +} + +#[derive(Clone)] +pub struct Network { + sender: mpsc::Sender, + runtime: tokio::runtime::Handle, +} + +impl Network { + pub fn channel() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(8); + ( + Self { + sender, + runtime: tokio::runtime::Handle::current(), + }, + receiver, + ) + } + + /// Called only from the guest's blocking execution thread. A lost supervisor, + /// full queue, timeout, or cancellation can never become a policy denial. + pub fn http_get(&self, url: &str, mut stopped: watch::Receiver) -> i32 { + let Some((destination, request)) = parse_request(url) else { + return INVALID_REQUEST; + }; + let (result, response) = oneshot::channel(); + let outcome = self.runtime.block_on(async { + tokio::select! { + biased; + _ = stopped.wait_for(|value| *value) => UNAVAILABLE, + result = tokio::time::timeout(TIMEOUT, async { + self.sender.send(HttpOpen { destination, request, queued_at: Instant::now(), result }).await.ok()?; + response.await.ok() + }) => result.ok().flatten().unwrap_or(UNAVAILABLE), + } + }); + println!( + "HTTP GET {url}: {}", + match outcome { + POLICY_DENIED => "denied by supervisor policy".to_string(), + UNAVAILABLE => "supervisor mediation unavailable".to_string(), + OTHER_DENIAL => "denied by supervisor (not a policy denial)".to_string(), + status => format!("HTTP {status}"), + } + ); + outcome + } +} + +// Numeric destinations keep DNS out of this example. No host resolver or socket +// API is exposed to the guest, and CR/LF cannot inject extra HTTP requests. +fn parse_request(url: &str) -> Option<(SocketAddr, Vec)> { + if url.len() > MAX_URL + || !url.is_ascii() + || url + .bytes() + .any(|b| b.is_ascii_control() || b.is_ascii_whitespace()) + { + return None; + } + let url = url.strip_prefix("http://")?; + let (authority, path) = url + .split_once('/') + .map_or((url, "/".to_string()), |(host, path)| { + (host, format!("/{path}")) + }); + let destination = authority + .parse() + .or_else(|_| authority.parse().map(|ip| SocketAddr::new(ip, 80))) + .ok()?; + Some(( + destination, + format!("GET {path} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n") + .into_bytes(), + )) +} + +async fn data(input: &mut Streaming) -> Result, Status> { + match input.message().await?.and_then(|chunk| chunk.payload) { + Some(boundary_chunk::Payload::Data(bytes)) if !bytes.is_empty() => Ok(bytes), + _ => Err(Status::unavailable("network stream closed or invalid")), + } +} + +pub async fn decision(input: &mut Streaming) -> Result { + let mut bytes = Vec::new(); + loop { + bytes.extend_from_slice(&data(input).await?); + if bytes.len() > wire::MAX_STREAM_FRAME_BYTES + 5 { + return Err(Status::invalid_argument("network decision too large")); + } + if bytes.len() >= 5 { + let length = u32::from_be_bytes(bytes[1..5].try_into().unwrap()) as usize; + if bytes[0] != wire::STREAM_NETWORK_DECISION || length > wire::MAX_STREAM_FRAME_BYTES { + return Err(Status::invalid_argument("invalid network decision frame")); + } + if bytes.len() == length + 5 { + return serde_json::from_slice(&bytes[5..]) + .map_err(|_| Status::invalid_argument("invalid network decision")); + } + if bytes.len() > length + 5 { + return Err(Status::invalid_argument("data before network decision")); + } + } + } +} + +pub async fn http_status(input: &mut Streaming) -> Result { + let mut bytes = Vec::new(); + loop { + let data = data(input).await?; + let remaining = 4096 - bytes.len(); + bytes.extend_from_slice(&data[..data.len().min(remaining)]); + if let Some(end) = bytes.windows(2).position(|pair| pair == b"\r\n") { + let line = std::str::from_utf8(&bytes[..end]) + .map_err(|_| Status::invalid_argument("invalid HTTP status"))?; + let mut fields = line.split(' '); + if !matches!(fields.next(), Some("HTTP/1.0" | "HTTP/1.1")) { + return Err(Status::invalid_argument("invalid HTTP response")); + } + return fields + .next() + .and_then(|s| s.parse::().ok()) + .filter(|s| (100..=599).contains(s)) + .ok_or_else(|| Status::invalid_argument("invalid HTTP status")); + } + if bytes.len() == 4096 { + return Err(Status::resource_exhausted("HTTP status line too long")); + } + } +} + +pub fn denied(reason: TcpOpenDenial) -> i32 { + match reason { + TcpOpenDenial::PolicyDenied => POLICY_DENIED, + TcpOpenDenial::MediationUnavailable => UNAVAILABLE, + _ => OTHER_DENIAL, + } +} diff --git a/examples/isolation-backend-wasi/src/protocol.rs b/examples/isolation-backend-v8/src/protocol.rs similarity index 85% rename from examples/isolation-backend-wasi/src/protocol.rs rename to examples/isolation-backend-v8/src/protocol.rs index 5573350318..2db8bef4bb 100644 --- a/examples/isolation-backend-wasi/src/protocol.rs +++ b/examples/isolation-backend-v8/src/protocol.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Generate only the public wire contracts; this example links no OpenShell crates. +// Backend RPC bindings are generated from the public wire contracts. mod openshell { pub mod extension { pub mod v1 { @@ -24,8 +24,8 @@ mod openshell { pub use openshell::extension::v1::{PeerMetadata, ProtocolVersion}; pub use openshell::sandbox::{backend::v1 as control, protocol::v1 as boundary}; -pub const BACKEND: &str = "example-wasmtime"; -pub const CAPABILITY: &str = "openshell.example.wasi-prototype"; +pub const BACKEND: &str = "example-v8"; +pub const CAPABILITY: &str = "openshell.sandbox.protocol"; pub fn metadata() -> PeerMetadata { PeerMetadata { 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..e4867c0c4d --- /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" +v8_state_dir="$(mktemp -d /tmp/openshell-v8.XXXXXX)" +v8_gateway_port="${V8_DEMO_PORT:-18770}" +export OPENSHELL_GATEWAY_ENDPOINT="http://127.0.0.1:$v8_gateway_port" +openshell-gateway generate-certs --output-dir "$v8_state_dir/tls" +cat > "$v8_state_dir/gateway.toml" <=0; i--)); do + kill "${v8_children[i]}" 2>/dev/null || true + done + for v8_pid in "${v8_children[@]}"; do + wait "$v8_pid" 2>/dev/null || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +v8-backend backend --state-dir "$v8_state_dir" > "$v8_state_dir/backend.log" 2>&1 & +v8_children+=("$!") +v8-backend driver --state-dir "$v8_state_dir" > "$v8_state_dir/driver.log" 2>&1 & +v8_children+=("$!") +for ((i=0; i<100; i++)); do + if [[ -S "$v8_state_dir/compute.sock" ]]; then break; fi + if ! kill -0 "${v8_children[1]}" 2>/dev/null; then + cat "$v8_state_dir/driver.log" >&2 + exit 1 + fi + sleep 0.1 +done +if [[ ! -S "$v8_state_dir/compute.sock" ]]; then + echo "Timed out waiting for the compute driver; see $v8_state_dir/driver.log" >&2 + exit 1 +fi +printf '\nDemo state and logs: %s\n' "$v8_state_dir" +printf 'Gateway: %s\n' "$OPENSHELL_GATEWAY_ENDPOINT" +printf 'Guest request results: tail -f %q/backend.log\n' "$v8_state_dir" +printf 'In another terminal, follow the README to create a sandbox. Ctrl-C stops the demo.\n\n' +openshell-gateway --config "$v8_state_dir/gateway.toml" \ + --db-url "sqlite://$v8_state_dir/gateway.db?mode=rwc" & +v8_children+=("$!") +wait "${v8_children[2]}" diff --git a/examples/isolation-backend-wasi/README.md b/examples/isolation-backend-wasi/README.md deleted file mode 100644 index 6f85b5dc8a..0000000000 --- a/examples/isolation-backend-wasi/README.md +++ /dev/null @@ -1,148 +0,0 @@ -# Partial Wasmtime isolation backend - -This example explores WebAssembly as a different isolation mechanism for the -OpenShell backend contract. It contains a Rust protobuf service and a local -WASIp1 guest runner. It does **not** implement a complete supervisor backend. - -The service generates Rust bindings from the repository's public -`SandboxBackend` and `SandboxBoundary` protobuf definitions at build time. It -links no OpenShell implementation crates and lives in its own Cargo workspace. - -## Code layout - -```text -build.rs Generate bindings from the public protos -src/main.rs CLI: serve the backend or run a local guest -src/protocol.rs Generated bindings, backend name, experimental capability -src/backend.rs Provisioning, authentication, boundary binding, RPC handlers -src/guest.rs Wasmtime configuration, WASI capabilities, execution limits -tests/protocol.rs Subprocess gRPC contract checks -tests/guest.rs Real guest confinement, resource limits, and interruption -guest.wat Small WASIp1 filesystem probe -``` - -## Run the experiment - -From the repository root, with Rust 1.94 or newer: - -```shell -cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml -``` - -The protocol test starts the compiled backend in a separate process and calls it -over loopback gRPC. Guest tests separately check read-only filesystem access, -parent traversal and symlink rejection, fuel and memory limits, bounded output, -unsupported socket imports, and engine interruption. The symlink check requires -Unix. `Cargo.lock` pins dependencies; Wasmtime 46 supports the repository's Rust -version. The first build compiles Wasmtime and Cranelift. - -Run a local guest against a directory containing `allowed.txt`: - -```shell -cargo run --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml -- \ - run examples/isolation-backend-wasi/guest.wat --workspace /path/to/workspace -``` - -The bundled filesystem probe also expects `/work/escape` to be a symlink to an -existing file outside the workspace. It asserts that reading that link fails. - -Start the partial service with trusted launch coordinates and a credential file -containing at least 32 non-whitespace characters: - -```shell -cargo run --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml -- \ - serve examples/isolation-backend-wasi/guest.wat \ - --sandbox-id sandbox-a --session-id session-a --generation-id generation-a \ - --token-file /path/to/protected/credential -``` - -The service prints JSON containing its ephemeral loopback endpoint, backend name, -and module descriptor. The credential stays in the protected file. Send it as -`authorization: Bearer ` on authenticated RPCs. - -The guest is handwritten WebAssembly text in `guest.wat`; no WASI compiler SDK is -needed. File-denial checks trap if an operation unexpectedly succeeds. - -The two execution paths are deliberately distinct: - -```text -protocol test -> external protobuf service -> reserve a module binding; no execution -local runner -> Wasmtime/WASIp1 -> execute a guest with limited capabilities -``` - -An `OpenBoundary` call never instantiates the guest. Instantiation can execute a -WebAssembly start function, so it belongs behind an eventual admitted start -operation, not resource binding or confirmation. - -## Contract coverage - -| Contract area | Prototype behavior | Remaining work | -| --- | --- | --- | -| Backend selection | Own name `example-wasmtime`; descriptor is a provisioned module digest | Generic supervisor setup and trusted driver provisioning | -| Protocol negotiation | Protocol 1.0 with `openshell.example.wasi-prototype` only | Meet the mandatory base contract before advertising `openshell.sandbox.protocol` | -| Authentication | Fixed launch credential loaded from a trusted file; loopback-only service | Production authenticated transport, launch JWT verification, renewal, and supervisor ownership | -| Boundary binding | Exact launch coordinates and descriptor; idempotent reservation | Durable resource ownership, driver deletion, generation replacement, and handle invalidation | -| Attach / confirm / start | `Exchange` validates the route, then returns `UNIMPLEMENTED`; no workload starts | Policy translation, evidence, admission, lifecycle framing, and replay ledger | -| Filesystem confinement | Local runner grants one read-only WASI directory | Translate authored filesystem policy into WASI capabilities | -| Network | Local runner grants no socket-creation capability | Implement guest TCP/DNS host calls and route their decisions and byte streams through the supervisor | -| Workload identity | Runner records SHA-256 of compiled guest bytes | Define guest module path and identity in supervisor policy and audit records | -| Process lifecycle | Local runner captures bounded stdout/stderr, exit codes, traps, and engine interruption | Expose owned guest instances through the supervisor process contract | -| Exec / PTY / signals / forwarding | Unsupported on the wire | Define useful guest semantics or explicit unsupported results | -| Suspend / resume | Authenticated `UNIMPLEMENTED` | Retained-state semantics and old-generation fencing | - -The existing base capability is a bundle of mandatory guarantees. A partial -implementation cannot safely advertise that capability just because it implements -the control RPCs. Consequently, a production supervisor's negotiation fails for -this example. The test peer explicitly negotiates the experimental capability. - -This is a contract probe, not a route for bypassing supervisor confirmation. -The local `run` command is unreachable through the service and does not install -OpenShell policy, credentials, or mediation. - -## What this exposes - -WASI guest instances have module identities, not useful host PIDs. The host -Wasmtime executable must not be used as the guest's authorization identity. -The existing executable path/digest shape could represent a module, but the -trusted path mapping and lineage semantics still need a decision. - -The mediation contract includes kernel socket cookies, process generations, -syscall timing, and errno-oriented socket decisions. A guest socket host call -does not naturally produce those measurements. A real backend must define -appropriate trusted guest identifiers or prompt a contract change; it must not -fabricate Linux kernel evidence. - -Lifecycle support also needs capability decisions. There is no shell, ordinary -fork/exec, PTY, or complete POSIX signal model in this runner. Termination can -interrupt guest execution, but it is not automatically equivalent to every -signal operation. - -Finally, `BoundaryErrorKind` currently has no `Unsupported` variant even though -the Rust interface does. This prototype rejects the stream with gRPC -`UNIMPLEMENTED` rather than inventing an inner error value. An admitted partial -backend would need a consistent way to report optional operation support. - -## Implementation limits - -The runner uses WASIp1 imports supplied by Wasmtime, without inheriting host -environment variables, stdin, or network descriptors. Only `/work` is preopened, -read-only. Guest memory is limited to 16 MiB, tables to 1,024 elements, and each -captured output stream to 64 KiB. Fuel bounds execution; epoch interruption -implements termination for the running WebAssembly guest. - -These guest limits do not bound all host resources. Module compilation and host -WASI calls are not governed by instruction fuel, and interruption is not a -general cancellation mechanism for blocking host calls. Production resource -accounting and termination acknowledgement remain open work. - -The service is plaintext loopback gRPC with a prototype bearer credential. It -serves one preprovisioned launch and retains state only for its process lifetime. -It does not accept request-controlled module or filesystem paths. - -The next integration milestone is an admitted, networkless WASI workload using -attach, confirmation, start, output, wait, and termination through the supervisor. -The following milestone would add a mediated guest socket host function with -authoritative module identity. Neither is implemented here. - -Sources: [Wasmtime security model](https://docs.wasmtime.dev/security.html) and -[Wasmtime Rust WASIp1 API](https://docs.rs/wasmtime-wasi/46.0.3/wasmtime_wasi/p1/index.html). diff --git a/examples/isolation-backend-wasi/guest.wat b/examples/isolation-backend-wasi/guest.wat deleted file mode 100644 index 8574f4a779..0000000000 --- a/examples/isolation-backend-wasi/guest.wat +++ /dev/null @@ -1,55 +0,0 @@ -;; SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -;; SPDX-License-Identifier: Apache-2.0 - -;; Handwritten WASIp1 guest: no compiler SDK or platform-specific binary needed. -(module - (import "wasi_snapshot_preview1" "path_open" - (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32))) - (import "wasi_snapshot_preview1" "fd_read" - (func $read (param i32 i32 i32 i32) (result i32))) - (import "wasi_snapshot_preview1" "fd_write" - (func $write (param i32 i32 i32 i32) (result i32))) - (import "wasi_snapshot_preview1" "fd_close" (func $close (param i32) (result i32))) - (memory (export "memory") 1) - (data (i32.const 128) "allowed.txt") - (data (i32.const 160) "../secret.txt") - (data (i32.const 192) "escape") - (data (i32.const 224) "new.txt") - (data (i32.const 256) "escape denied\n") - (data (i32.const 288) "symlink denied\n") - (data (i32.const 320) "write denied\n") - - (func $assert_zero (param $errno i32) - local.get $errno if unreachable end) - (func $assert_denied (param $errno i32) - local.get $errno i32.eqz if unreachable end) - (func $print (param $pointer i32) (param $length i32) - i32.const 0 local.get $pointer i32.store - i32.const 4 local.get $length i32.store - i32.const 1 i32.const 0 i32.const 1 i32.const 8 call $write call $assert_zero) - - (func (export "_start") (local $fd i32) - ;; Preopen fd 3. Read rights = FD_READ (2); returned fd stored at offset 16. - i32.const 3 i32.const 0 i32.const 128 i32.const 11 i32.const 0 - i64.const 2 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_zero - i32.const 16 i32.load local.set $fd - i32.const 0 i32.const 1024 i32.store - i32.const 4 i32.const 128 i32.store - local.get $fd i32.const 0 i32.const 1 i32.const 8 call $read call $assert_zero - i32.const 1024 i32.const 8 i32.load call $print - local.get $fd call $close call $assert_zero - - ;; Traversal beyond the preopen must fail. - i32.const 3 i32.const 0 i32.const 160 i32.const 13 i32.const 0 - i64.const 2 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_denied - i32.const 256 i32.const 14 call $print - - ;; Follow a symlink that points at a real file outside the granted directory. - i32.const 3 i32.const 1 i32.const 192 i32.const 6 i32.const 0 - i64.const 2 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_denied - i32.const 288 i32.const 15 call $print - - ;; O_CREAT (1), FD_WRITE (64) against a read-only preopen must fail. - i32.const 3 i32.const 0 i32.const 224 i32.const 7 i32.const 1 - i64.const 64 i64.const 0 i32.const 0 i32.const 16 call $open call $assert_denied - i32.const 320 i32.const 13 call $print)) diff --git a/examples/isolation-backend-wasi/src/backend.rs b/examples/isolation-backend-wasi/src/backend.rs deleted file mode 100644 index c7bf3d97e4..0000000000 --- a/examples/isolation-backend-wasi/src/backend.rs +++ /dev/null @@ -1,215 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Partial loopback service. Reserving a boundary never executes guest code. -use std::{pin::Pin, sync::Mutex}; - -use anyhow::{Result, ensure}; -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; -use tokio_stream::Stream; -use tonic::{Request, Response, Status, Streaming}; - -use crate::protocol::{BACKEND, boundary::*, control::*, metadata}; - -/// Trusted provisioning, independent of request claims. Paths never arrive over RPC. -pub struct Provisioned { - pub sandbox_id: String, - pub session_id: String, - pub generation_id: String, - token: String, - pub descriptor: Vec, -} - -impl Provisioned { - pub fn new( - sandbox_id: String, - session_id: String, - generation_id: String, - token: String, - module: &[u8], - ) -> Result { - ensure!( - !sandbox_id.is_empty() && !session_id.is_empty() && !generation_id.is_empty(), - "launch identities must not be empty" - ); - ensure!( - token.len() >= 32 && !token.chars().any(char::is_whitespace), - "credential must contain at least 32 non-whitespace characters" - ); - let digest = format!("{:x}", Sha256::digest(module)); - Ok(Self { - sandbox_id, - session_id, - generation_id, - token, - descriptor: serde_json::to_vec(&serde_json::json!({"module_sha256": digest}))?, - }) - } -} - -pub struct Backend { - provisioned: Provisioned, - boundary_id: Mutex>, -} - -impl Backend { - pub fn new(provisioned: Provisioned) -> Self { - Self { - provisioned, - boundary_id: Mutex::new(None), - } - } - - fn authenticate(&self, request: &Request) -> Result<(), Status> { - let mut values = request.metadata().get_all("authorization").iter(); - let valid = values.next().is_some_and(|value| { - bool::from( - value - .as_bytes() - .ct_eq(format!("Bearer {}", self.provisioned.token).as_bytes()), - ) - }); - if !valid || values.next().is_some() { - return Err(Status::unauthenticated("invalid prototype credential")); - } - Ok(()) - } - - async fn route(&self, mut request: Request>) -> Result<(), Status> { - self.authenticate(&request)?; - let first = request.get_mut().message().await?; - let Some(BoundaryChunk { - payload: Some(boundary_chunk::Payload::BoundaryId(id)), - }) = first - else { - return Err(Status::invalid_argument( - "first frame must identify a boundary", - )); - }; - if id.is_empty() { - return Err(Status::invalid_argument( - "first frame must identify a boundary", - )); - } - let expected = self - .boundary_id - .lock() - .map_err(|_| Status::internal("binding lock poisoned"))?; - if !expected - .as_ref() - .is_some_and(|expected| bool::from(id.as_bytes().ct_eq(expected.as_bytes()))) - { - return Err(Status::permission_denied("unknown boundary handle")); - } - Ok(()) - } -} - -#[tonic::async_trait] -impl sandbox_backend_server::SandboxBackend for Backend { - async fn describe( - &self, - request: Request, - ) -> Result, Status> { - let local = metadata(); - let remote = request - .into_inner() - .supervisor - .ok_or_else(|| Status::failed_precondition("missing peer metadata"))?; - if remote.protocol_version.as_ref().map(|v| v.major) != Some(1) - || !local - .required_capabilities - .iter() - .all(|c| remote.supported_capabilities.contains(c)) - || !remote - .required_capabilities - .iter() - .all(|c| local.supported_capabilities.contains(c)) - { - return Err(Status::failed_precondition( - "partial WASI prototype does not implement the base sandbox contract", - )); - } - Ok(Response::new(DescribeSandboxBackendResponse { - backend: Some(local), - })) - } - - async fn open_boundary( - &self, - request: Request, - ) -> Result, Status> { - self.authenticate(&request)?; - let launch = request.into_inner(); - let p = &self.provisioned; - if launch.sandbox_id != p.sandbox_id - || launch.session_id != p.session_id - || launch.generation_id != p.generation_id - || launch.backend_name != BACKEND - { - return Err(Status::permission_denied("launch identity mismatch")); - } - if launch.driver_descriptor != p.descriptor { - return Err(Status::permission_denied("provisioned module mismatch")); - } - let mut id = self - .boundary_id - .lock() - .map_err(|_| Status::internal("binding lock poisoned"))?; - Ok(Response::new(OpenBoundaryResponse { - boundary_id: id - .get_or_insert_with(|| uuid::Uuid::new_v4().to_string()) - .clone(), - outcome: open_boundary_response::LaunchOutcome::Fresh.into(), - })) - } - - async fn suspend_boundary( - &self, - request: Request, - ) -> Result, Status> { - self.authenticate(&request)?; - Err(Status::unimplemented( - "WASI memory snapshots are not implemented", - )) - } - - async fn resume_boundary( - &self, - request: Request, - ) -> Result, Status> { - self.authenticate(&request)?; - Err(Status::unimplemented( - "WASI memory restoration is not implemented", - )) - } -} - -type Chunks = Pin> + Send>>; - -#[tonic::async_trait] -impl sandbox_boundary_server::SandboxBoundary for Backend { - type ExchangeStream = Chunks; - type MediateStream = Chunks; - - async fn exchange( - &self, - request: Request>, - ) -> Result, Status> { - self.route(request).await?; - Err(Status::unimplemented( - "attach/confirm/start are not implemented; no guest was run", - )) - } - - async fn mediate( - &self, - request: Request>, - ) -> Result, Status> { - self.route(request).await?; - Err(Status::unimplemented( - "TCP/DNS mediation is not implemented", - )) - } -} diff --git a/examples/isolation-backend-wasi/src/guest.rs b/examples/isolation-backend-wasi/src/guest.rs deleted file mode 100644 index e2ebfaa03e..0000000000 --- a/examples/isolation-backend-wasi/src/guest.rs +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Local guest execution. Deliberately unreachable through the backend service. -use std::{ - fs::File, - io::Read, - path::Path, - sync::atomic::{AtomicBool, Ordering}, -}; - -use anyhow::{Result, ensure}; -use serde::Serialize; -use sha2::{Digest, Sha256}; -use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, Trap}; -use wasmtime_wasi::{ - DirPerms, FilePerms, I32Exit, WasiCtxBuilder, - p1::{self, WasiP1Ctx}, - p2::pipe::MemoryOutputPipe, -}; - -pub const DEFAULT_FUEL: u64 = 1_000_000; -const MAX_MODULE: u64 = 4 * 1024 * 1024; -const MAX_OUTPUT: usize = 64 * 1024; - -pub fn read_module(path: &Path) -> Result> { - let mut bytes = Vec::new(); - File::open(path)? - .take(MAX_MODULE + 1) - .read_to_end(&mut bytes)?; - ensure!( - bytes.len() as u64 <= MAX_MODULE, - "module exceeds the 4 MiB prototype limit" - ); - // Normalize WAT to the actual module bytes before computing its identity. - let bytes = wat::parse_bytes(&bytes)?.into_owned(); - ensure!( - bytes.len() as u64 <= MAX_MODULE, - "compiled module exceeds the 4 MiB prototype limit" - ); - Ok(bytes) -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum GuestStatus { - Exited, - Terminated, - FuelExhausted, - Trapped, - UnsupportedModule, -} - -#[derive(Debug, Serialize)] -pub struct GuestResult { - pub status: GuestStatus, - pub exit_code: Option, - #[serde(skip)] - pub stdout: Vec, - #[serde(skip)] - pub stderr: Vec, - pub module_sha256: String, -} - -struct State { - wasi: WasiP1Ctx, - limits: StoreLimits, -} - -/// One engine per guest: terminating one guest cannot interrupt another. -pub struct Guest { - engine: Engine, - module: Module, - digest: String, - cancelled: AtomicBool, -} - -impl Guest { - pub fn new(module: &[u8]) -> Result { - let mut config = Config::new(); - config.consume_fuel(true).epoch_interruption(true); - let engine = Engine::new(&config)?; - Ok(Self { - module: Module::new(&engine, module)?, - engine, - digest: format!("{:x}", Sha256::digest(module)), - cancelled: AtomicBool::new(false), - }) - } - - pub fn terminate(&self) { - self.cancelled.store(true, Ordering::SeqCst); - self.engine.increment_epoch(); - } - - pub fn run(&self, workspace: &Path, fuel: u64) -> Result { - self.run_when_ready(workspace, fuel, || {}) - } - - /// Calls `ready` once the store's interruption deadline is armed. - pub fn run_when_ready( - &self, - workspace: &Path, - fuel: u64, - ready: impl FnOnce(), - ) -> Result { - let stdout = MemoryOutputPipe::new(MAX_OUTPUT); - let stderr = MemoryOutputPipe::new(MAX_OUTPUT); - let wasi = WasiCtxBuilder::new() - .arg("/work/guest.wasm") - .stdout(stdout.clone()) - .stderr(stderr.clone()) - .preopened_dir( - workspace.canonicalize()?, - "/work", - DirPerms::READ, - FilePerms::READ, - )? - .build_p1(); - let mut store = Store::new( - &self.engine, - State { - wasi, - limits: StoreLimitsBuilder::new() - .memory_size(16 * 1024 * 1024) - .table_elements(1024) - .instances(1) - .tables(1) - .memories(1) - .build(), - }, - ); - store.limiter(|state| &mut state.limits); - store.set_fuel(fuel)?; - store.set_epoch_deadline(1); - let mut linker = Linker::new(&self.engine); - p1::add_to_linker_sync(&mut linker, |state: &mut State| &mut state.wasi)?; - let pre = linker.instantiate_pre(&self.module); - ready(); - let (status, exit_code) = if self.cancelled.load(Ordering::SeqCst) { - (GuestStatus::Terminated, None) - } else if let Ok(pre) = pre { - // Instantiation may execute a start function; it happens only here. - let result = pre.instantiate(&mut store).and_then(|instance| { - instance - .get_typed_func::<(), ()>(&mut store, "_start")? - .call(&mut store, ()) - }); - match result { - Ok(()) => (GuestStatus::Exited, Some(0)), - Err(error) => { - if let Some(exit) = error.downcast_ref::() { - (GuestStatus::Exited, Some(exit.0)) - } else { - let status = match error.downcast_ref::() { - Some(Trap::Interrupt) => GuestStatus::Terminated, - Some(Trap::OutOfFuel) => GuestStatus::FuelExhausted, - _ => GuestStatus::Trapped, - }; - (status, None) - } - } - } - } else { - (GuestStatus::UnsupportedModule, None) - }; - Ok(GuestResult { - status, - exit_code, - stdout: stdout.contents().to_vec(), - stderr: stderr.contents().to_vec(), - module_sha256: self.digest.clone(), - }) - } -} diff --git a/examples/isolation-backend-wasi/src/main.rs b/examples/isolation-backend-wasi/src/main.rs deleted file mode 100644 index 2ffab3ec4b..0000000000 --- a/examples/isolation-backend-wasi/src/main.rs +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::{ - io::{self, Write}, - path::PathBuf, - sync::Arc, -}; - -use anyhow::Result; -use clap::{Parser, Subcommand}; -use openshell_wasi_backend_example::{ - backend::{Backend, Provisioned}, - guest::{DEFAULT_FUEL, Guest, GuestStatus, read_module}, - protocol::{ - BACKEND, boundary::sandbox_boundary_server::SandboxBoundaryServer, - control::sandbox_backend_server::SandboxBackendServer, - }, -}; -use tokio_stream::{StreamExt, wrappers::TcpListenerStream}; - -#[derive(Parser)] -#[command(about = "Partial WASI backend service and a separate local guest runner")] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Run a guest locally, without supervisor admission. - Run { - module: PathBuf, - #[arg(long)] - workspace: PathBuf, - }, - /// Serve the partial protobuf contract on an ephemeral loopback port. - Serve { - module: PathBuf, - #[arg(long)] - sandbox_id: String, - #[arg(long)] - session_id: String, - #[arg(long)] - generation_id: String, - #[arg(long)] - token_file: PathBuf, - }, -} - -#[tokio::main] -async fn main() -> Result<()> { - match Cli::parse().command { - Command::Run { module, workspace } => { - let bytes = read_module(&module)?; - let result = tokio::task::spawn_blocking(move || { - Guest::new(&bytes)?.run(&workspace, DEFAULT_FUEL) - }) - .await??; - io::stdout().write_all(&result.stdout)?; - io::stderr().write_all(&result.stderr)?; - eprintln!("{}", serde_json::to_string(&result)?); - if result.status != GuestStatus::Exited || result.exit_code != Some(0) { - std::process::exit(1); - } - } - Command::Serve { - module, - sandbox_id, - session_id, - generation_id, - token_file, - } => { - let token = std::fs::read_to_string(token_file)?.trim().to_string(); - let launch = Provisioned::new( - sandbox_id, - session_id, - generation_id, - token, - &read_module(&module)?, - )?; - let descriptor: serde_json::Value = serde_json::from_slice(&launch.descriptor)?; - let backend = Arc::new(Backend::new(launch)); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - println!( - "{}", - serde_json::json!({"endpoint": listener.local_addr()?.to_string(), "backend_name": BACKEND, "descriptor": descriptor}) - ); - io::stdout().flush()?; - // Custom incoming streams bypass Tonic's TCP settings. - let incoming = TcpListenerStream::new(listener).map(|accepted| { - accepted.inspect(|socket| { - let _ = socket.set_nodelay(true); - }) - }); - tonic::transport::Server::builder() - .add_service( - SandboxBackendServer::from_arc(backend.clone()) - .max_decoding_message_size(1024 * 1024), - ) - .add_service( - SandboxBoundaryServer::from_arc(backend).max_decoding_message_size(1024 * 1024), - ) - .serve_with_incoming_shutdown(incoming, async { - let _ = tokio::signal::ctrl_c().await; - }) - .await?; - } - } - Ok(()) -} diff --git a/examples/isolation-backend-wasi/tests/guest.rs b/examples/isolation-backend-wasi/tests/guest.rs deleted file mode 100644 index 8cf1408bb3..0000000000 --- a/examples/isolation-backend-wasi/tests/guest.rs +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use openshell_wasi_backend_example::guest::{DEFAULT_FUEL, Guest, GuestStatus, read_module}; -use std::{path::Path, sync::Arc, time::Duration}; - -#[test] -#[cfg(unix)] -fn wasi_reads_only_the_workspace() { - let temp = tempfile::tempdir().unwrap(); - let workspace = temp.path().join("workspace"); - std::fs::create_dir(&workspace).unwrap(); - std::fs::write(workspace.join("allowed.txt"), "workspace content\n").unwrap(); - std::fs::write(temp.path().join("secret.txt"), "outside secret\n").unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink(temp.path().join("secret.txt"), workspace.join("escape")).unwrap(); - let bytes = read_module(&Path::new(env!("CARGO_MANIFEST_DIR")).join("guest.wat")).unwrap(); - let result = Guest::new(&bytes) - .unwrap() - .run(&workspace, DEFAULT_FUEL) - .unwrap(); - assert_eq!(result.status, GuestStatus::Exited, "{result:?}"); - assert_eq!(result.exit_code, Some(0)); - assert_eq!( - result.stdout, - b"workspace content\nescape denied\nsymlink denied\nwrite denied\n" - ); - assert!(!workspace.join("new.txt").exists()); -} - -#[test] -fn guest_limits_fuel_memory_and_imports() { - let temp = tempfile::tempdir().unwrap(); - for (module, expected) in [ - ( - "(module (func (export \"_start\") (loop $l br $l)))", - GuestStatus::FuelExhausted, - ), - ( - "(module (memory 257) (func (export \"_start\")))", - GuestStatus::Trapped, - ), - ( - "(module (import \"wasi:sockets/tcp\" \"create-socket\" (func)) (func (export \"_start\")))", - GuestStatus::UnsupportedModule, - ), - ( - "(module (func (export \"_start\") unreachable))", - GuestStatus::Trapped, - ), - ] { - let result = Guest::new(module.as_bytes()) - .unwrap() - .run(temp.path(), DEFAULT_FUEL) - .unwrap(); - assert_eq!(result.status, expected, "{result:?}"); - } -} - -#[test] -fn termination_interrupts_only_its_owned_engine() { - let temp = tempfile::tempdir().unwrap(); - let guest = - Arc::new(Guest::new(b"(module (func (export \"_start\") (loop $l br $l)))").unwrap()); - let worker_guest = guest.clone(); - let workspace = temp.path().to_owned(); - let (ready_send, ready_receive) = std::sync::mpsc::channel(); - let (done_send, done_receive) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let result = worker_guest.run_when_ready(&workspace, 1_000_000_000_000, || { - ready_send.send(()).unwrap() - }); - done_send.send(result).unwrap(); - }); - ready_receive.recv_timeout(Duration::from_secs(5)).unwrap(); - guest.terminate(); - let result = done_receive - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap(); - assert_eq!(result.status, GuestStatus::Terminated); - worker.join().unwrap(); - let independent = Guest::new(b"(module (func (export \"_start\")))").unwrap(); - assert_eq!( - independent.run(temp.path(), DEFAULT_FUEL).unwrap().status, - GuestStatus::Exited - ); -} - -#[test] -fn output_capture_is_bounded() { - let temp = tempfile::tempdir().unwrap(); - let module = br#"(module - (import "wasi_snapshot_preview1" "fd_write" (func $write (param i32 i32 i32 i32) (result i32))) - (memory (export "memory") 2) - (data (i32.const 0) "\10\00\00\00\01\00\01\00") - (func (export "_start") - i32.const 1 i32.const 0 i32.const 1 i32.const 8 call $write drop))"#; - let result = Guest::new(module) - .unwrap() - .run(temp.path(), DEFAULT_FUEL) - .unwrap(); - assert!(!result.stdout.is_empty(), "{result:?}"); - assert!(result.stdout.len() <= 64 * 1024); -} diff --git a/examples/isolation-backend-wasi/tests/protocol.rs b/examples/isolation-backend-wasi/tests/protocol.rs deleted file mode 100644 index 16df2d3c19..0000000000 --- a/examples/isolation-backend-wasi/tests/protocol.rs +++ /dev/null @@ -1,268 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Exercise the actual executable over TCP, using only the generated public API. -use std::{ - io::{BufRead, BufReader}, - process::{Child, Command, Stdio}, - time::Duration, -}; - -use openshell_wasi_backend_example::protocol::{ - BACKEND, - boundary::{sandbox_boundary_client::SandboxBoundaryClient, *}, - control::{sandbox_backend_client::SandboxBackendClient, *}, - metadata, -}; -use tonic::{ - Code, Request, - transport::{Channel, Endpoint}, -}; - -const TOKEN: &str = "test-only-credential-with-at-least-32-characters"; - -struct Server(Child); -impl Drop for Server { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn authorized(message: T) -> Request { - let mut request = Request::new(message); - request - .metadata_mut() - .insert("authorization", format!("Bearer {TOKEN}").parse().unwrap()); - request.set_timeout(Duration::from_secs(5)); - request -} - -async fn stream_error( - client: &mut SandboxBoundaryClient, - payload: boundary_chunk::Payload, -) -> Code { - client - .exchange(authorized(tokio_stream::iter([BoundaryChunk { - payload: Some(payload), - }]))) - .await - .unwrap_err() - .code() -} - -#[tokio::test] -async fn external_service_checks_negotiation_binding_and_routes() { - let temp = tempfile::tempdir().unwrap(); - let token_file = temp.path().join("credential"); - std::fs::write(&token_file, TOKEN).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600)).unwrap(); - } - let module = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("guest.wat"); - let mut server = Server( - Command::new(env!("CARGO_BIN_EXE_wasi-backend")) - .arg("serve") - .arg(module) - .args([ - "--sandbox-id", - "sandbox-a", - "--session-id", - "session-a", - "--generation-id", - "generation-a", - "--token-file", - ]) - .arg(token_file) - .stdout(Stdio::piped()) - .spawn() - .unwrap(), - ); - let stdout = server.0.stdout.take().unwrap(); - let (send, receive) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut line = String::new(); - let result = BufReader::new(stdout).read_line(&mut line).map(|_| line); - let _ = send.send(result); - }); - let announcement: serde_json::Value = serde_json::from_str( - &receive - .recv_timeout(Duration::from_secs(10)) - .unwrap() - .unwrap(), - ) - .unwrap(); - assert_eq!(announcement["backend_name"], BACKEND); - let channel = Endpoint::from_shared(format!( - "http://{}", - announcement["endpoint"].as_str().unwrap() - )) - .unwrap() - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(5)) - .tcp_nodelay(true) - .connect() - .await - .unwrap(); - let mut client = SandboxBackendClient::new(channel.clone()); - let mut streams = SandboxBoundaryClient::new(channel); - let response = client - .describe(DescribeSandboxBackendRequest { - supervisor: Some(metadata()), - }) - .await - .unwrap() - .into_inner(); - assert_eq!(response.backend, Some(metadata())); - let mut incompatible = metadata(); - incompatible.required_capabilities = vec!["openshell.sandbox.protocol".into()]; - assert_eq!( - client - .describe(DescribeSandboxBackendRequest { - supervisor: Some(incompatible) - }) - .await - .unwrap_err() - .code(), - Code::FailedPrecondition - ); - - let launch = OpenBoundaryRequest { - sandbox_id: "sandbox-a".into(), - session_id: "session-a".into(), - generation_id: "generation-a".into(), - backend_name: BACKEND.into(), - driver_descriptor: serde_json::to_vec(&announcement["descriptor"]).unwrap(), - }; - let opened = client - .open_boundary(authorized(launch.clone())) - .await - .unwrap() - .into_inner(); - assert!(!opened.boundary_id.is_empty()); - assert_eq!( - opened.outcome, - open_boundary_response::LaunchOutcome::Fresh as i32 - ); - assert_eq!( - client - .open_boundary(authorized(launch.clone())) - .await - .unwrap() - .into_inner(), - opened - ); - assert_eq!( - client - .open_boundary(launch.clone()) - .await - .unwrap_err() - .code(), - Code::Unauthenticated - ); - let mut duplicate_auth = authorized(launch.clone()); - duplicate_auth - .metadata_mut() - .append("authorization", format!("Bearer {TOKEN}").parse().unwrap()); - assert_eq!( - client - .open_boundary(duplicate_auth) - .await - .unwrap_err() - .code(), - Code::Unauthenticated - ); - let mut wrong_auth = authorized(launch.clone()); - wrong_auth - .metadata_mut() - .insert("authorization", "Bearer wrong".parse().unwrap()); - assert_eq!( - client.open_boundary(wrong_auth).await.unwrap_err().code(), - Code::Unauthenticated - ); - for field in 0..5 { - let mut changed = launch.clone(); - match field { - 0 => changed.sandbox_id = "other".into(), - 1 => changed.session_id = "other".into(), - 2 => changed.generation_id = "other".into(), - 3 => changed.backend_name = "other".into(), - _ => changed.driver_descriptor = b"{}".to_vec(), - } - assert_eq!( - client - .open_boundary(authorized(changed)) - .await - .unwrap_err() - .code(), - Code::PermissionDenied - ); - } - assert_eq!( - stream_error(&mut streams, boundary_chunk::Payload::Data(b"bad".to_vec())).await, - Code::InvalidArgument - ); - assert_eq!( - stream_error( - &mut streams, - boundary_chunk::Payload::BoundaryId(String::new()) - ) - .await, - Code::InvalidArgument - ); - assert_eq!( - stream_error( - &mut streams, - boundary_chunk::Payload::BoundaryId("other".into()) - ) - .await, - Code::PermissionDenied - ); - assert_eq!( - stream_error( - &mut streams, - boundary_chunk::Payload::BoundaryId(opened.boundary_id.clone()) - ) - .await, - Code::Unimplemented - ); - let route = BoundaryChunk { - payload: Some(boundary_chunk::Payload::BoundaryId( - opened.boundary_id.clone(), - )), - }; - assert_eq!( - streams - .mediate(authorized(tokio_stream::iter([route]))) - .await - .unwrap_err() - .code(), - Code::Unimplemented - ); - assert_eq!( - client - .suspend_boundary(authorized(SuspendBoundaryRequest { - boundary_id: opened.boundary_id, - session_id: launch.session_id.clone(), - generation_id: launch.generation_id.clone(), - })) - .await - .unwrap_err() - .code(), - Code::Unimplemented - ); - assert_eq!( - client - .resume_boundary(authorized(ResumeBoundaryRequest { - launch: Some(launch), - restore_reference: vec![], - allow_cold_start_fallback: false, - })) - .await - .unwrap_err() - .code(), - Code::Unimplemented - ); -} diff --git a/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md index 4ea59f002f..219c3f6ca7 100644 --- a/proto/sandbox_protocol/v1/README.md +++ b/proto/sandbox_protocol/v1/README.md @@ -256,7 +256,7 @@ alone. IDs are opaque generation-scoped strings, never local process IDs. | `exec_signal` | `process_id: string`, `signal: Signal` | `signaled` | | `resize` | `process_id: string`, `cols: u16`, `rows: u16` | `resized` | | `loopback_connect` | `host: IP string`, `port: u16` | `port_connected`; then raw bidirectional bytes | -| `accept_network` | — | `network_connected`: `identity: BinaryIdentity`, `destination: socket address`, `socket: SocketMetadata`, `policy_generation: u64`, `timing: Timing`; then TCP decision/relay | +| `accept_network` | — | `network_connected`: `identity: BinaryIdentity`, `destination: socket address`, `socket: SocketMetadata` (optional), `policy_generation: u64`, `timing: Timing`; then TCP decision/relay | | `open_mediation` | —; sent on `Mediate` | `mediation_ready`; then persistent DNS framing | Image policy parse/read failure returns `invalid: true` or an error; it must @@ -323,8 +323,12 @@ snapshot. Workload launch applies all controls before its first instruction. `{"result":"failed","message":string}`. Identity must come from trusted backend observation; never substitute workload claims on resolution failure. - **SocketMetadata**: `socket_cookie: u64`, `nonblocking: bool`, - `process_generation: u64`. -- **Timing**: `notification_to_queue_us: u64`, `queue_wait_us: u64`. + `process_generation: u64`. Omit `socket` for guest host calls without native + sockets. Supply the guest executable path and digest (module or script) as identity; do not + substitute the host runtime or fabricate kernel socket evidence. +- **Timing**: `notification_to_queue_us: u64`, `queue_wait_us: u64`. Measure + backend-monotonic syscall or guest host-call staging; unmeasured durations are + zero. These are diagnostic timings, not authorization evidence. ## Process I/O, TCP relay, and DNS diff --git a/tasks/rust.toml b/tasks/rust.toml index 3b7ba523d5..89f2061bc6 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -32,7 +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-wasi/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 @@ -44,7 +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-wasi/Cargo.toml --all", + "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all", ] hide = true @@ -55,7 +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-wasi/Cargo.toml --all -- --check", + "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index 6ac8731345..c1df20262a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -117,7 +117,6 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", - "cargo test --locked --manifest-path examples/isolation-backend-wasi/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From 710d7a8b7425f50a7622bc523c3b60b9c88b7eca Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 15 Oct 2026 13:25:10 -0700 Subject: [PATCH 11/24] fix(examples): stream V8 guest output with default policy Signed-off-by: Drew Newberry --- examples/isolation-backend-v8/README.md | 21 ++++++++++++++------- examples/isolation-backend-v8/policy.yaml | 11 ----------- examples/isolation-backend-v8/src/driver.rs | 7 +++++-- examples/isolation-backend-v8/src/guest.rs | 1 + examples/isolation-backend-v8/start.sh | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) delete mode 100644 examples/isolation-backend-v8/policy.yaml diff --git a/examples/isolation-backend-v8/README.md b/examples/isolation-backend-v8/README.md index daa631c97b..6e9d9176bd 100644 --- a/examples/isolation-backend-v8/README.md +++ b/examples/isolation-backend-v8/README.md @@ -12,6 +12,8 @@ From this example directory: ./start.sh ``` +Pass a JavaScript file to run another script: `./start.sh path/to/script.js`. + The script builds the binaries, generates local configuration and credentials, and starts the backend, driver, and gateway. The first build downloads the V8 library. The script prints the temporary directory containing `gateway.toml` and @@ -45,17 +47,17 @@ backend name to `backend.sock`. In another terminal, from this example directory: ```shell -export PATH="$PWD/../../target/debug:$PATH" export OPENSHELL_GATEWAY_ENDPOINT=http://127.0.0.1:18770 -openshell sandbox create --name v8-demo --policy policy.yaml \ - --detach --no-tty --no-auto-providers -- /work/guest.js +openshell sandbox create --name v8-demo \ + --keep --no-tty --no-auto-providers -- /work/guest.js ``` The driver starts the supervisor. The backend loads `guest.js` into a fresh V8 isolate with `console.log` and `httpGet`. No filesystem, process, or direct network API is exposed. `httpGet` sends the request through the Sandbox Protocol; -the supervisor enforces `policy.yaml`, whose empty `network_policies` denies it. -The guest succeeds only on that policy denial; transport failures are errors. +the supervisor's default policy has no network grants and denies it. No policy +file is needed. The guest succeeds only on that policy denial; transport failures +are errors. ```javascript const url = "http://1.1.1.1/"; @@ -67,8 +69,13 @@ if (result !== -1) { console.log("Request denied by supervisor network policy (expected)"); ``` -The script prints a `tail -f` command for the backend log, which includes guest -output. `openshell logs v8-demo` shows the supervisor's policy-denial audit. +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. +`--keep` retains the sandbox so you can inspect that result after the command. +`openshell logs v8-demo` shows the supervisor's policy-denial audit. + +Add `--detach` to run without attaching; detached creation does not stream guest +output. Use `openshell sandbox connect v8-demo` to attach afterward. ## Inspect and stop diff --git a/examples/isolation-backend-v8/policy.yaml b/examples/isolation-backend-v8/policy.yaml deleted file mode 100644 index a713e27617..0000000000 --- a/examples/isolation-backend-v8/policy.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -version: 1 -filesystem_policy: - include_workdir: false - read_only: [] - read_write: [] -landlock: - compatibility: best_effort -network_policies: {} diff --git a/examples/isolation-backend-v8/src/driver.rs b/examples/isolation-backend-v8/src/driver.rs index 2862832bf3..aab08139e9 100644 --- a/examples/isolation-backend-v8/src/driver.rs +++ b/examples/isolation-backend-v8/src/driver.rs @@ -262,6 +262,11 @@ impl Driver { .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, @@ -297,8 +302,6 @@ impl Driver { .arg("--isolation-backends-file") .arg(self.state_dir.join("registrations.json")) .arg("--parent-liveness-stdin") - .arg("--") - .arg("/work/guest.js") .stdin(std::process::Stdio::piped()) .stdout(output) .stderr(errors); diff --git a/examples/isolation-backend-v8/src/guest.rs b/examples/isolation-backend-v8/src/guest.rs index 116fec6aa2..d75e35f223 100644 --- a/examples/isolation-backend-v8/src/guest.rs +++ b/examples/isolation-backend-v8/src/guest.rs @@ -219,6 +219,7 @@ impl Guest { }) .unwrap_or_else(|| "JavaScript execution failed".into()); stderr.append(&message); + stderr.append("\n"); Ok(GuestStatus::Failed) } })(); diff --git a/examples/isolation-backend-v8/start.sh b/examples/isolation-backend-v8/start.sh index e4867c0c4d..75dcadeb90 100755 --- a/examples/isolation-backend-v8/start.sh +++ b/examples/isolation-backend-v8/start.sh @@ -60,7 +60,7 @@ trap 'exit 143' TERM v8-backend backend --state-dir "$v8_state_dir" > "$v8_state_dir/backend.log" 2>&1 & v8_children+=("$!") -v8-backend driver --state-dir "$v8_state_dir" > "$v8_state_dir/driver.log" 2>&1 & +v8-backend driver --state-dir "$v8_state_dir" --script "${1:-guest.js}" > "$v8_state_dir/driver.log" 2>&1 & v8_children+=("$!") for ((i=0; i<100; i++)); do if [[ -S "$v8_state_dir/compute.sock" ]]; then break; fi From 0827934ab6048cc11b11eb224528c661b5006b68 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 15 Oct 2026 17:40:04 -0700 Subject: [PATCH 12/24] fix(examples): handle V8 rejections and roll back failed launches Signed-off-by: Drew Newberry --- examples/isolation-backend-v8/README.md | 2 + examples/isolation-backend-v8/src/driver.rs | 182 ++++++++++-------- examples/isolation-backend-v8/src/guest.rs | 27 ++- .../isolation-backend-v8/src/lifecycle.rs | 114 ++++++----- examples/isolation-backend-v8/src/main.rs | 23 +-- examples/isolation-backend-v8/src/protocol.rs | 59 ++++++ 6 files changed, 250 insertions(+), 157 deletions(-) diff --git a/examples/isolation-backend-v8/README.md b/examples/isolation-backend-v8/README.md index 6e9d9176bd..21e6bebe73 100644 --- a/examples/isolation-backend-v8/README.md +++ b/examples/isolation-backend-v8/README.md @@ -71,6 +71,8 @@ console.log("Request denied by supervisor network policy (expected)"); 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. `openshell logs v8-demo` shows the supervisor's policy-denial audit. diff --git a/examples/isolation-backend-v8/src/driver.rs b/examples/isolation-backend-v8/src/driver.rs index aab08139e9..49386b3f85 100644 --- a/examples/isolation-backend-v8/src/driver.rs +++ b/examples/isolation-backend-v8/src/driver.rs @@ -135,6 +135,9 @@ impl Driver { 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 => {} @@ -219,93 +222,104 @@ impl Driver { socket_path: path.into(), }, }; - 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; + 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)?; } - if attempt == 99 { - return Err(Status::deadline_exceeded( - "start v8-backend backend before creating the sandbox", - )); + for attempt in 0..100 { + if self.state_dir.join("active").exists() { + break; + } + if attempt == 99 { + return Err(Status::deadline_exceeded( + "start v8-backend backend before creating the sandbox", + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; } - 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) } - 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)?; + .await; + let supervisor = match result { + Ok(supervisor) => supervisor, + Err(error) => { + self.retire_boundary().await?; + return Err(error); + } + }; Ok(Entry { sandbox, supervisor, diff --git a/examples/isolation-backend-v8/src/guest.rs b/examples/isolation-backend-v8/src/guest.rs index d75e35f223..fa76be32ec 100644 --- a/examples/isolation-backend-v8/src/guest.rs +++ b/examples/isolation-backend-v8/src/guest.rs @@ -77,6 +77,7 @@ struct State { network: Option, stopped: watch::Receiver, stdout: Output, + rejections: Vec>, } pub struct Guest { @@ -164,7 +165,9 @@ impl Guest { network, stopped: self.stopped.subscribe(), stdout, + 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()); @@ -202,13 +205,20 @@ impl Guest { 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() { + } 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 { @@ -224,10 +234,25 @@ impl Guest { } })(); 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. diff --git a/examples/isolation-backend-v8/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs index 1e47bd1ce7..a4da00d158 100644 --- a/examples/isolation-backend-v8/src/lifecycle.rs +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -5,7 +5,7 @@ use crate::{ guest::{Guest, GuestResult, GuestStatus, read_script}, launch::Launch, - protocol::{BACKEND, PeerMetadata, ProtocolVersion, boundary::*, control::*}, + protocol::{self, BACKEND, boundary::*, control::*}, }; use anyhow::{Result, ensure}; use openshell_core::jwt::{SandboxId, SessionJwtVerifier, SessionTokenProfile, SystemJwtClock}; @@ -479,6 +479,41 @@ impl Runtime { } 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 = serde_json::to_vec(&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) } @@ -501,7 +536,7 @@ impl sandbox_backend_server::SandboxBackend for Arc { .into_inner() .supervisor .ok_or_else(|| Status::failed_precondition("peer metadata required"))?; - let capability = "openshell.sandbox.protocol".to_string(); + 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) @@ -511,13 +546,7 @@ impl sandbox_backend_server::SandboxBackend for Arc { )); } Ok(Response::new(DescribeSandboxBackendResponse { - backend: Some(PeerMetadata { - protocol_version: Some(ProtocolVersion { major: 1, minor: 0 }), - implementation_name: BACKEND.into(), - implementation_version: "0.0.0".into(), - supported_capabilities: vec![capability.clone()], - required_capabilities: vec![capability], - }), + backend: Some(protocol::metadata()), })) } async fn open_boundary( @@ -576,21 +605,12 @@ impl sandbox_boundary_server::SandboxBoundary for Arc { let (principal, mut input) = self.route(request).await?; let runtime = self.clone(); let stream = async_stream::try_stream! { - let mut bytes = Vec::new(); - let envelope = loop { - if bytes.len() >= 4 { - let length = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; - if length == 0 || length > wire::MAX_CONTROL_FRAME_BYTES { Err(Status::invalid_argument("invalid frame length"))?; } - if bytes.len() >= 4+length { break wire::decode_frame::(&bytes[..4+length]).map_err(|_|Status::invalid_argument("invalid request frame"))?; } - } - let chunk = input.message().await?.ok_or_else(||Status::invalid_argument("incomplete request"))?; - let Some(boundary_chunk::Payload::Data(data)) = chunk.payload else { Err(Status::invalid_argument("data frame required"))?; unreachable!() }; - if bytes.len()+data.len()>wire::MAX_CONTROL_FRAME_BYTES+4 { Err(Status::resource_exhausted("request too large"))?; } - bytes.extend_from_slice(&data); - }; + 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()))?; + 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(); let mut pending = loop { let queued = tokio::select! { @@ -599,7 +619,9 @@ impl sandbox_boundary_server::SandboxBoundary for Arc { _ = input.message() => Err(Status::unavailable("network accept disconnected")), pending = async { runtime.network_requests.lock().await.recv().await } => pending.ok_or_else(||Status::unavailable("network source closed")), }?; - if !queued.result.is_closed() { break queued; } + if !queued.result.is_closed() { + break queued; + } }; let identity = wire::BinaryIdentityWire::Resolved { executable: wire::ExecutableIdentityWire { @@ -619,7 +641,7 @@ impl sandbox_boundary_server::SandboxBoundary for Arc { queue_wait_us: pending.queued_at.elapsed().as_micros().try_into().unwrap_or(u64::MAX), }, }; - yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(wire::encode_frame(&wire::ResponseEnvelope { request_id:envelope.request_id,response }).map_err(|_|Status::internal("encode network response"))?))}; + yield protocol::response(envelope.request_id, response)?; let decision = tokio::select! { _ = pending.result.closed() => return, decision = crate::network::decision(&mut input) => decision, @@ -628,7 +650,7 @@ impl sandbox_boundary_server::SandboxBoundary for Arc { let status = match decision { TcpOpenDecision::Denied(reason) => crate::network::denied(reason), TcpOpenDecision::RelayReady => { - yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(pending.request))}; + yield protocol::data(pending.request); tokio::select! { _ = pending.result.closed() => return, status = crate::network::http_status(&mut input) => status, @@ -638,7 +660,7 @@ impl sandbox_boundary_server::SandboxBoundary for Arc { let _ = pending.result.send(status); return; } - let response = match runtime.control(&principal,&envelope).await { + let response = match runtime.control(&principal, &envelope).await { Ok(response) => response, Err(error) => wire::Response::Error { kind: match error.code() { @@ -649,33 +671,23 @@ impl sandbox_boundary_server::SandboxBoundary for Arc { message: error.message().to_string(), }, }; - let attached = matches!(response,wire::Response::ProcessAttached {..}); - let mediation = matches!(response,wire::Response::MediationReady); - yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(wire::encode_frame(&wire::ResponseEnvelope { request_id:envelope.request_id,response }).map_err(|_|Status::internal("encode response"))?))}; + let attached = matches!(response, wire::Response::ProcessAttached { .. }); + let mediation = matches!(response, wire::Response::MediationReady); + yield protocol::response(envelope.request_id, response)?; if attached { - let mut offsets = [0usize, 0usize]; - loop { - let complete = runtime.result.borrow().clone(); - let buffers = runtime.output.lock().unwrap().as_ref().map(|(out,err)| [out.contents(), err.contents()]); - let buffers = complete.as_ref().map(|r| [r.stdout.clone(),r.stderr.clone()]).or(buffers).unwrap_or_default(); - for (index, data) in buffers.into_iter().enumerate() { - for piece in data[offsets[index]..].chunks(wire::MAX_STREAM_FRAME_BYTES) { - let mut frame=vec![if index==0 {wire::STREAM_STDOUT}else{wire::STREAM_STDERR}]; - frame.extend_from_slice(&(piece.len() as u32).to_be_bytes()); frame.extend_from_slice(piece); - yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(frame))}; - } - offsets[index] = data.len(); - } - if let Some(result)=complete { - let bytes=serde_json::to_vec(&Runtime::exit(&result)).unwrap(); - let mut frame=vec![wire::STREAM_EXIT];frame.extend_from_slice(&(bytes.len() as u32).to_be_bytes());frame.extend_from_slice(&bytes); - yield BoundaryChunk {payload:Some(boundary_chunk::Payload::Data(frame))}; - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; + 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"))?; } } - 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))) } diff --git a/examples/isolation-backend-v8/src/main.rs b/examples/isolation-backend-v8/src/main.rs index ec21913a0d..5eee81a627 100644 --- a/examples/isolation-backend-v8/src/main.rs +++ b/examples/isolation-backend-v8/src/main.rs @@ -1,18 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::{ - io::{self, Write}, - path::PathBuf, - sync::Arc, -}; +use std::{path::PathBuf, sync::Arc}; use anyhow::Result; use clap::{Parser, Subcommand}; -use openshell_v8_backend_example::guest::{Guest, GuestStatus, read_script}; #[derive(Parser)] -#[command(about = "Local V8 backend, example compute driver, and guest runner")] +#[command(about = "Local V8 backend and example compute driver")] struct Cli { #[command(subcommand)] command: Command, @@ -44,8 +39,6 @@ enum Command { #[arg(long, default_value = concat!(env!("CARGO_MANIFEST_DIR"), "/../../crates/openshell-supervisor-network/data/sandbox-policy.rego"))] policy_rules: PathBuf, }, - /// Run a guest locally, without supervisor admission. - Run { script: PathBuf }, } #[tokio::main] @@ -75,18 +68,6 @@ async fn main() -> Result<()> { )?; openshell_v8_backend_example::driver::serve(Arc::new(driver), socket).await?; } - Command::Run { script } => { - let bytes = read_script(&script)?; - let result = - tokio::task::spawn_blocking(move || Guest::new(&bytes)?.run(None, |_, _| {})) - .await??; - io::stdout().write_all(&result.stdout)?; - io::stderr().write_all(&result.stderr)?; - eprintln!("{}", serde_json::to_string(&result)?); - if result.status != GuestStatus::Exited || result.exit_code != Some(0) { - std::process::exit(1); - } - } } Ok(()) } diff --git a/examples/isolation-backend-v8/src/protocol.rs b/examples/isolation-backend-v8/src/protocol.rs index 2db8bef4bb..5070fa3dd5 100644 --- a/examples/isolation-backend-v8/src/protocol.rs +++ b/examples/isolation-backend-v8/src/protocol.rs @@ -36,3 +36,62 @@ pub fn metadata() -> PeerMetadata { required_capabilities: vec![CAPABILITY.into()], } } + +/// Read one length-prefixed control request across arbitrary gRPC fragments. +pub async fn read_request( + input: &mut tonic::Streaming, +) -> Result { + use boundary::boundary_chunk::Payload; + use openshell_sandbox_backend::boundary_protocol as wire; + use tonic::Status; + let mut bytes = Vec::new(); + loop { + if bytes.len() >= 4 { + let length = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + if length == 0 || length > wire::MAX_CONTROL_FRAME_BYTES { + return Err(Status::invalid_argument("invalid frame length")); + } + if bytes.len() >= 4 + length { + return wire::decode_frame(&bytes[..4 + length]) + .map_err(|_| Status::invalid_argument("invalid request frame")); + } + } + let chunk = input + .message() + .await? + .ok_or_else(|| Status::invalid_argument("incomplete request"))?; + let Some(Payload::Data(data)) = chunk.payload else { + return Err(Status::invalid_argument("data frame required")); + }; + if bytes.len() + data.len() > wire::MAX_CONTROL_FRAME_BYTES + 4 { + return Err(Status::resource_exhausted("request too large")); + } + bytes.extend_from_slice(&data); + } +} + +pub fn data(bytes: Vec) -> boundary::BoundaryChunk { + boundary::BoundaryChunk { + payload: Some(boundary::boundary_chunk::Payload::Data(bytes)), + } +} + +pub fn response( + request_id: String, + response: openshell_sandbox_backend::boundary_protocol::Response, +) -> Result { + use openshell_sandbox_backend::boundary_protocol as wire; + wire::encode_frame(&wire::ResponseEnvelope { + request_id, + response, + }) + .map(data) + .map_err(|_| tonic::Status::internal("encode response")) +} + +pub fn stream_frame(channel: u8, payload: &[u8]) -> boundary::BoundaryChunk { + let mut bytes = vec![channel]; + bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + bytes.extend_from_slice(payload); + data(bytes) +} From b1f17e1c0f617169d324c93362a26bdb477fde4c Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 15 Oct 2026 21:05:12 -0700 Subject: [PATCH 13/24] fix(examples): align V8 crypto dependencies with CI AWS-LC Signed-off-by: Drew Newberry --- examples/isolation-backend-v8/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 3e6b022eb0..8107fa94b6 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -129,9 +129,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -140,9 +140,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.46.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", From 879f591e7cd5221cfce70dbe2b3242261e0ddcda Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 15 Oct 2026 23:09:10 -0700 Subject: [PATCH 14/24] feat(sandbox): represent Sandbox Protocol operations in protobuf Signed-off-by: Drew Newberry --- Cargo.lock | 1 + crates/openshell-core/build.rs | 1 + crates/openshell-sandbox-backend/Cargo.toml | 1 + crates/openshell-sandbox-backend/README.md | 21 +- .../src/boundary_protocol.rs | 77 +- .../src/isolation_protocol.rs | 44 +- crates/openshell-sandbox-backend/src/lib.rs | 2 + .../src/mediation.rs | 15 +- .../openshell-sandbox-backend/src/protobuf.rs | 921 ++++++++++++++++++ .../openshell-sandbox-backend/src/runtime.rs | 83 +- .../src/runtime/tests/flow_control.rs | 37 +- .../src/runtime/tests/network_recovery.rs | 10 +- .../src/stream_adapter.rs | 298 ++++++ .../openshell-sandbox/src/boundary_server.rs | 58 +- examples/isolation-backend-v8/Cargo.lock | 2 + examples/isolation-backend-v8/Cargo.toml | 1 + examples/isolation-backend-v8/build.rs | 18 +- .../isolation-backend-v8/src/lifecycle.rs | 26 +- examples/isolation-backend-v8/src/network.rs | 31 +- examples/isolation-backend-v8/src/protocol.rs | 100 +- proto/README.md | 12 +- ..._backend.proto => isolation_backend.proto} | 4 +- proto/sandbox_protocol.proto | 409 +++++++- proto/sandbox_protocol/v1/README.md | 381 -------- 24 files changed, 1956 insertions(+), 597 deletions(-) create mode 100644 crates/openshell-sandbox-backend/src/protobuf.rs create mode 100644 crates/openshell-sandbox-backend/src/stream_adapter.rs rename proto/{sandbox_backend.proto => isolation_backend.proto} (98%) delete mode 100644 proto/sandbox_protocol/v1/README.md diff --git a/Cargo.lock b/Cargo.lock index 8043ef6010..b4f4c70c1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4846,6 +4846,7 @@ dependencies = [ "openshell-core", "openshell-isolation-interface", "prost", + "prost-types", "rcgen", "rustix 1.1.4", "rustls", 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-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index c8e3c561f3..4573c27ae1 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -16,6 +16,7 @@ openshell-isolation-interface = { path = "../openshell-isolation-interface" } async-trait = "0.1" hyper-util = { workspace = true } prost = { workspace = true } +prost-types = { workspace = true } rcgen = { workspace = true } rustls = { workspace = true } rustls-pemfile = { workspace = true } diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 2b03371dee..0629606043 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -21,9 +21,11 @@ returns `UNIMPLEMENTED` for those optional RPCs. `SandboxBoundary.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 data fragments. -Missing bindings, repeated bindings, and frames without a payload are rejected. -The fragments retain the existing versioned Sandbox Protocol framing. +against its provisioned sandbox and generation, then accepts typed protobuf +requests, responses, process I/O, network decisions, relay data, 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 @@ -43,13 +45,18 @@ generation, resource claims, and outer-fence evidence. Native evidence is validated by the registered backend; Linux evidence validation remains in the built-in adapter. -The language-neutral contract and generation instructions are in [Sandbox Protocol 1.0](../../proto/sandbox_protocol/v1/README.md). +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 diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 4ec3ad1a67..567a825ef9 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; @@ -29,7 +29,6 @@ use openshell_isolation_interface::contract::{ OuterFenceGuarantees, 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}; @@ -608,7 +607,7 @@ 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. @@ -657,19 +656,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, ) -> 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_expires_at_unix_ms, + request: request.clone(), + }; + let payload = crate::protobuf::encode(&envelope)?; let digest = Sha256::digest(payload); Ok(format!("{digest:x}")) } @@ -1350,8 +1349,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())); } @@ -1362,7 +1364,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)? @@ -1379,10 +1384,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; @@ -1399,7 +1407,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 _; @@ -1416,7 +1425,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()?; @@ -1425,16 +1440,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}")] @@ -1680,15 +1693,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!( diff --git a/crates/openshell-sandbox-backend/src/isolation_protocol.rs b/crates/openshell-sandbox-backend/src/isolation_protocol.rs index d8223281fc..d94b14e229 100644 --- a/crates/openshell-sandbox-backend/src/isolation_protocol.rs +++ b/crates/openshell-sandbox-backend/src/isolation_protocol.rs @@ -68,7 +68,7 @@ pub fn validate_open( #[must_use] pub fn data_chunk(data: Vec) -> BoundaryChunk { BoundaryChunk { - payload: Some(Payload::Data(data)), + payload: Some(Payload::RelayData(data)), } } @@ -79,12 +79,40 @@ pub fn boundary_chunk(boundary_id: &str) -> BoundaryChunk { } } -/// After binding, only data frames are legal in either stream direction. +/// Adapt a single typed frame for callers inspecting a local response. pub fn chunk_data(chunk: BoundaryChunk) -> Result, tonic::Status> { - match chunk.payload { - Some(Payload::Data(data)) => Ok(data), - _ => Err(tonic::Status::invalid_argument( - "only data frames are allowed after boundary binding", - )), - } + 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::NetworkDecision(_)) => crate::stream_adapter::Mode::Decision, + Some(Payload::DnsQuery(_)) => crate::stream_adapter::Mode::DnsQueries, + Some(Payload::DnsResponse(_)) => crate::stream_adapter::Mode::DnsResponses, + _ => 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(BoundaryChunk { + 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(BoundaryChunk { + 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 0dd421232e..0333cef99b 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -15,8 +15,10 @@ 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 openshell_core::proto::sandbox::backend::v1 as backend_proto; pub use runtime::{BoundaryTransportConnector, DelegatedRuntimeBackend, OpenShellRuntimeBackend}; 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..fec2a36c77 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -0,0 +1,921 @@ +// 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}); +enumeration!(c::OuterFenceGuarantee, p::OuterFenceGuarantee, {DefaultDenyEgress, NoUnmanagedEgressPath, RevocationVerified, ControllerLossFailsClosed}); +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 { + let prop = |v: &c::EnforcedProperty| { + Some(p::EnforcedProperty { + enforced: v.enforced, + mechanism: v.mechanism.clone(), + }) + }; + 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(), + }), + properties: Some(p::BoundaryProperties { + filesystem_confinement: prop(&self.properties.filesystem_confinement), + egress_interception: prop(&self.properties.egress_interception), + request_attribution: prop(&self.properties.request_attribution), + privilege_floor: prop(&self.properties.privilege_floor), + }), + authenticated_supervisor: self.authenticated_supervisor, + session_id: self.session_id.to_string(), + outer_fence: Some(p::OuterFenceGuarantees { + generation: self.outer_fence.generation.clone(), + established: self + .outer_fence + .established + .iter() + .map(|v| v.to_proto().map(|v| v as i32)) + .collect::>()?, + evidence_digest: self.outer_fence.evidence_digest.to_string(), + }), + runtime_exit_terminates_workload: self.runtime_exit_terminates_workload, + resource_claims: self.resource_claims.clone(), + backend_audit: serde_json::to_vec(&self.backend_audit).map_err(invalid)?, + }) + } + fn from_proto(v: Self::Proto) -> Result { + let identity = required(v.identity)?; + let properties = required(v.properties)?; + let fence = required(v.outer_fence)?; + let prop = |v: Option| -> Result<_, FrameError> { + let v = required(v)?; + Ok(c::EnforcedProperty { + enforced: v.enforced, + mechanism: v.mechanism, + }) + }; + Ok(Self { + generation: v.generation, + identity: c::ResolvedWorkloadIdentity::new( + identity.uid, + identity.gid, + identity.supplementary_gids, + identity.source, + identity.resource_digest, + ) + .map_err(invalid)?, + properties: c::BoundaryProperties { + filesystem_confinement: prop(properties.filesystem_confinement)?, + egress_interception: prop(properties.egress_interception)?, + request_attribution: prop(properties.request_attribution)?, + privilege_floor: prop(properties.privilege_floor)?, + }, + authenticated_supervisor: v.authenticated_supervisor, + session_id: parse(&v.session_id)?, + outer_fence: c::OuterFenceGuarantees { + generation: fence.generation, + established: fence + .established + .into_iter() + .map(|v| c::OuterFenceGuarantee::from_proto(enum_value(v)?)) + .collect::>()?, + evidence_digest: parse(&fence.evidence_digest)?, + }, + runtime_exit_terminates_workload: v.runtime_exit_terminates_workload, + resource_claims: v.resource_claims, + backend_audit: serde_json::from_slice(&v.backend_audit).map_err(invalid)?, + }) + } +} +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, + resource_claims, + } => P::Attach(p::Attach { + supervisor_instance_id: supervisor_instance_id.to_string(), + policy: Some(policy.to_proto()?), + resource_claims: resource_claims.clone(), + }), + W::Confirm => P::Confirm(p::Confirm {}), + W::ProbeProviderFiles => P::ProbeProviderFiles(p::ProbeProviderFiles {}), + W::StartAgent { + sandbox_id, + spec, + policy, + 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()?), + policy: Some(policy.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()?), + }), + W::ExecSignal { process_id, signal } => P::ExecSignal(p::ExecSignal { + process_id: process_id.clone(), + signal: signal.to_proto()? as i32, + }), + 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(), + exec_expires_at: self.exec_expires_at_unix_ms.map(timestamp).transpose()?, + request: Some(request), + }) + } + fn from_proto(v: Self::Proto) -> Result { + use p::request_envelope::Request as P; + use w::Request as W; + 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)?)?), + resource_claims: v.resource_claims, + }, + P::Confirm(_) => W::Confirm, + P::ProbeProviderFiles(_) => W::ProbeProviderFiles, + P::StartAgent(v) => W::StartAgent { + sandbox_id: v.sandbox_id, + spec: w::AgentSpecWire::from_proto(required(v.spec)?)?, + policy: Box::new(w::SandboxPolicyWire::from_proto(required(v.policy)?)?), + 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) => W::Exec { + spec: w::ExecSpecWire::from_proto(required(v.spec)?)?, + }, + P::ExecSignal(v) => W::ExecSignal { + process_id: v.process_id, + signal: w::SignalWire::from_proto(enum_value(v.signal)?)?, + }, + 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 expires = v.exec_expires_at.map(millis).transpose()?; + if matches!(request, W::Exec { .. }) != expires.is_some() { + return Err(invalid("exec deadline presence does not match operation")); + } + let mut envelope = Self { + request_id: v.request_id, + payload_digest: String::new(), + exec_expires_at_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::ProviderFilesSupported => P::ProviderFilesSupported(p::ProviderFilesSupported {}), + 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::PortConnected => P::PortConnected(p::PortConnected {}), + 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::ProviderFilesSupported(_) => W::ProviderFilesSupported, + 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::PortConnected(_) => W::PortConnected, + 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(), + exec_expires_at: None, + request: Some(p::request_envelope::Request::Attach(p::Attach { + supervisor_instance_id: uuid::Uuid::new_v4().to_string(), + policy: None, + resource_claims: std::collections::BTreeMap::default(), + })), + }; + assert!(w::RequestEnvelope::from_proto(attach).is_err()); + let signal = p::RequestEnvelope { + request_id: id, + exec_expires_at: None, + 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(); + value.exec_expires_at = None; + assert!(w::RequestEnvelope::from_proto(value).is_err()); + let mut value = request.to_proto().unwrap(); + value.exec_expires_at.as_mut().unwrap().nanos = -1; + assert!(w::RequestEnvelope::from_proto(value).is_err()); + let mut value = request.to_proto().unwrap(); + value.exec_expires_at.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(), + exec_expires_at: None, + 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 87578915c7..ee362a0698 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -14,7 +14,9 @@ use crate::backend_proto::{ DescribeSandboxBackendRequest, OpenBoundaryRequest, open_boundary_response::LaunchOutcome, sandbox_backend_client::SandboxBackendClient, }; -use crate::isolation_protocol::{boundary_chunk, chunk_data, data_chunk}; +use crate::isolation_protocol::boundary_chunk; +#[cfg(test)] +use crate::isolation_protocol::{chunk_data, data_chunk}; use crate::proto::{BoundaryChunk, sandbox_boundary_client::SandboxBoundaryClient}; use async_trait::async_trait; use hyper_util::rt::TokioIo; @@ -700,7 +702,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), @@ -1032,7 +1034,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( @@ -1126,7 +1128,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(); @@ -1138,7 +1140,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, @@ -2249,8 +2251,8 @@ async fn open_grpc_client_stream_with_authorization( .map_err(|_| BackendError::Unavailable("queue boundary binding frame".to_string()))?; tokio::spawn(pump_to_grpc(reader, outbound)); let mut client = SandboxBoundaryClient::new(channel) - .max_decoding_message_size(64 * 1024) - .max_encoding_message_size(64 * 1024); + .max_decoding_message_size(crate::stream_adapter::MAX_MESSAGE_BYTES) + .max_encoding_message_size(crate::stream_adapter::MAX_MESSAGE_BYTES); let mut request = tonic::Request::new(ReceiverStream::new(outbound_rx)); request .metadata_mut() @@ -2265,7 +2267,11 @@ async fn open_grpc_client_stream_with_authorization( } _ => BackendError::Unavailable(format!("open boundary gRPC stream: {error}")), })?; - tokio::spawn(pump_from_grpc(response.into_inner(), writer)); + tokio::spawn(pump_from_grpc( + response.into_inner(), + writer, + crate::stream_adapter::Mode::Response, + )); Ok(Box::new(application)) } @@ -2273,36 +2279,34 @@ async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender read, + match crate::stream_adapter::send(&mut reader, &mut mode).await { + Ok(Some(chunk)) => { + if sender.send(chunk).await.is_err() { + return; + } + } + Ok(None) => return, Err(error) => { - tracing::debug!(%error, "boundary gRPC request reader ended"); + tracing::debug!(%error,"boundary request adapter ended"); return; } - }; - if read == 0 { - return; - } - if sender - .send(data_chunk(buffer[..read].to_vec())) - .await - .is_err() - { - return; } } } -async fn pump_from_grpc(mut stream: tonic::Streaming, mut writer: W) -where +async fn pump_from_grpc( + mut stream: tonic::Streaming, + mut writer: W, + mut mode: crate::stream_adapter::Mode, +) where W: tokio::io::AsyncWrite + Unpin, { loop { match stream.message().await { Ok(Some(chunk)) => { - let Ok(data) = chunk_data(chunk) else { + let Ok(data) = crate::stream_adapter::receive(&mut mode, chunk) else { let _ = writer.shutdown().await; return; }; @@ -2785,7 +2789,7 @@ mod tests { } else { b"complete response".to_vec() }; - let _ = outbound.send(Ok(data_chunk(response))).await; + let _ = outbound.send(Ok(test_response_chunk(response))).await; }); Ok(tonic::Response::new(Box::pin(ReceiverStream::new( outbound_rx, @@ -2811,6 +2815,11 @@ mod tests { } } + fn test_response_chunk(bytes: Vec) -> BoundaryChunk { + 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() @@ -3098,11 +3107,19 @@ mod tests { ) .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(); } @@ -3129,7 +3146,7 @@ mod tests { &mut server_stream, MediationFrameKind::DnsQuery, 42, - &mediation::encode_json(&query).unwrap(), + &mediation::encode_message(&query).unwrap(), ) .await .unwrap(); @@ -3140,7 +3157,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]) ); }); @@ -3735,7 +3752,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(); 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 a68201c5ea..fba8f2c894 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs @@ -29,7 +29,19 @@ impl SandboxBoundary for StallingPeer { } 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; } }); } @@ -135,14 +147,27 @@ async fn stalled_relays_cannot_starve_other_streams() { ) .await .unwrap(); - stream.write_all(b"ping").await.unwrap(); - let mut pong = [0_u8; 4]; - stream.read_exact(&mut pong).await.unwrap(); - pong + 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 7b0a672fae..efb725cd0e 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -194,17 +194,21 @@ impl NetworkPeer { response, }) .unwrap(); - outbound.send(Ok(data_chunk(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 pump = tokio::spawn(pump_from_grpc( + inbound, + writer, + 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); assert_eq!( - serde_json::from_slice::(&payload).unwrap(), + crate::protobuf::decode::(&payload).unwrap(), TcpOpenDecision::RelayReady ); state.decisions.fetch_add(1, Ordering::AcqRel); 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..74fa87d960 --- /dev/null +++ b/crates/openshell-sandbox-backend/src/stream_adapter.rs @@ -0,0 +1,298 @@ +// 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 `BoundaryChunk` 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, boundary_chunk::Payload}, + protobuf::{self, WireMessage}, +}; +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::BoundaryChunk { + p::BoundaryChunk { + 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::PortConnected => 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::BoundaryChunk) -> 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::NetworkDecision(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), + (Mode::DnsQueries, Payload::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, Payload::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 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::NetworkDecision(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 => { + 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; + Payload::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; + Payload::DnsResponse(v) + } + _ => return Err(bad("wrong DNS stream direction")), + } + } + 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))) +} + +#[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 typed_dns_preserves_correlation_and_failure() { + let frame = chunk(Payload::DnsResponse(p::DnsResponse { + stream_id: 9_007_199_254_740_993, + result: Some(p::dns_response::Result::Error( + "resolver unavailable".into(), + )), + })); + let bytes = receive(&mut Mode::DnsResponses, frame.clone()).unwrap(); + let mut reader = bytes.as_slice(); + assert_eq!( + send(&mut reader, &mut Mode::DnsResponses).await.unwrap(), + Some(frame) + ); + } +} diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index f1190414dd..09644dedbb 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -45,6 +45,7 @@ mod linux { open_boundary_response::LaunchOutcome, sandbox_backend_server::{SandboxBackend, SandboxBackendServer}, }; + #[cfg(test)] use openshell_sandbox_backend::isolation_protocol::{chunk_data, data_chunk}; use openshell_sandbox_backend::mediation::{ self, DnsQueryWire, MediationFrame, MediationFrameKind, @@ -619,8 +620,12 @@ mod linux { ) .add_service( SandboxBoundaryServer::new(service) - .max_decoding_message_size(64 * 1024) - .max_encoding_message_size(64 * 1024), + .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; @@ -939,10 +944,11 @@ mod linux { tokio::select! { _ = inbound_closed.changed() => {}, () = async { + let mut mode = openshell_sandbox_backend::stream_adapter::Mode::Request; loop { match inbound.message().await { Ok(Some(chunk)) => { - let data = match chunk_data(chunk) { + let data = match openshell_sandbox_backend::stream_adapter::receive(&mut mode,chunk).map_err(|error|tonic::Status::invalid_argument(error.to_string())) { Ok(data) => data, Err(error) => { if let Some(outbound) = invalid_frames.upgrade() { @@ -974,24 +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(data_chunk(buffer[..read].to_vec()))) - .await - .is_err() - { - return; + match openshell_sandbox_backend::stream_adapter::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; } } } } => {}, @@ -1159,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; }; @@ -1180,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), @@ -1375,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) @@ -3274,8 +3268,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}")); @@ -3290,8 +3285,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}")); @@ -4656,7 +4653,9 @@ mod linux { .unwrap(); let request = RequestEnvelope::new(Request::OpenMediation).unwrap(); sender - .send(data_chunk(encode_frame(&request).unwrap())) + .send( + openshell_sandbox_backend::isolation_protocol::request_chunk(&request).unwrap(), + ) .await .unwrap(); let mut response = SandboxBoundaryClient::new(channel) @@ -5561,7 +5560,8 @@ mod linux { let handle = test_open_boundary(channel.clone(), opening, &token).await; let request_stream = tokio_stream::iter([ openshell_sandbox_backend::isolation_protocol::boundary_chunk(&handle), - data_chunk(encode_frame(&request).expect("encode logical request")), + openshell_sandbox_backend::isolation_protocol::request_chunk(&request) + .expect("typed logical request"), ]); let mut body = SandboxBoundaryClient::new(channel) .exchange(bearer_request(request_stream, &token)) diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 8107fa94b6..a795433657 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -1395,6 +1395,7 @@ dependencies = [ "openshell-core", "openshell-isolation-interface", "prost", + "prost-types", "rcgen", "rustix 1.1.5", "rustls", @@ -1425,6 +1426,7 @@ dependencies = [ "openshell-isolation-interface", "openshell-sandbox-backend", "prost", + "prost-types", "protoc-bin-vendored", "rustix 1.1.5", "rustls", diff --git a/examples/isolation-backend-v8/Cargo.toml b/examples/isolation-backend-v8/Cargo.toml index 72f55854f6..dd2db56dc7 100644 --- a/examples/isolation-backend-v8/Cargo.toml +++ b/examples/isolation-backend-v8/Cargo.toml @@ -23,6 +23,7 @@ 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" diff --git a/examples/isolation-backend-v8/build.rs b/examples/isolation-backend-v8/build.rs index da952ad2f5..c0c93f7fcd 100644 --- a/examples/isolation-backend-v8/build.rs +++ b/examples/isolation-backend-v8/build.rs @@ -4,14 +4,16 @@ 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().compile_with_config( - config, - &[ - "../../proto/sandbox_backend.proto", - "../../proto/sandbox_protocol.proto", - ], - &["../../proto"], - )?; + 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/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs index a4da00d158..1324e453f5 100644 --- a/examples/isolation-backend-v8/src/lifecycle.rs +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -500,14 +500,14 @@ impl Runtime { 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); + yield protocol::stream_frame(channel, piece)?; } offsets[index] = data.len(); } if let Some(result) = complete { - let bytes = serde_json::to_vec(&Runtime::exit(&result)) + 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); + yield protocol::stream_frame(wire::STREAM_EXIT, &bytes)?; break; } tokio::time::sleep(Duration::from_millis(20)).await; @@ -741,12 +741,20 @@ pub async fn serve(state_dir: PathBuf) -> Result<()> { runtime.stop().await; }; tonic::transport::Server::builder() - .add_service(sandbox_backend_server::SandboxBackendServer::new( - runtime.clone(), - )) - .add_service(sandbox_boundary_server::SandboxBoundaryServer::new( - runtime.clone(), - )) + .add_service( + sandbox_backend_server::SandboxBackendServer::new(runtime.clone()) + .max_decoding_message_size(64 * 1024) + .max_encoding_message_size(64 * 1024), + ) + .add_service( + sandbox_boundary_server::SandboxBoundaryServer::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)?; diff --git a/examples/isolation-backend-v8/src/network.rs b/examples/isolation-backend-v8/src/network.rs index c26ba6d014..b0bfdd510e 100644 --- a/examples/isolation-backend-v8/src/network.rs +++ b/examples/isolation-backend-v8/src/network.rs @@ -8,7 +8,6 @@ use std::{ }; use openshell_isolation_interface::contract::{TcpOpenDecision, TcpOpenDenial}; -use openshell_sandbox_backend::boundary_protocol as wire; use tokio::sync::{mpsc, oneshot, watch}; use tonic::{Status, Streaming}; @@ -106,31 +105,23 @@ fn parse_request(url: &str) -> Option<(SocketAddr, Vec)> { async fn data(input: &mut Streaming) -> Result, Status> { match input.message().await?.and_then(|chunk| chunk.payload) { - Some(boundary_chunk::Payload::Data(bytes)) if !bytes.is_empty() => Ok(bytes), + Some(boundary_chunk::Payload::RelayData(bytes)) + if !bytes.is_empty() + && bytes.len() + <= openshell_sandbox_backend::boundary_protocol::MAX_STREAM_FRAME_BYTES => + { + Ok(bytes) + } _ => Err(Status::unavailable("network stream closed or invalid")), } } pub async fn decision(input: &mut Streaming) -> Result { - let mut bytes = Vec::new(); - loop { - bytes.extend_from_slice(&data(input).await?); - if bytes.len() > wire::MAX_STREAM_FRAME_BYTES + 5 { - return Err(Status::invalid_argument("network decision too large")); - } - if bytes.len() >= 5 { - let length = u32::from_be_bytes(bytes[1..5].try_into().unwrap()) as usize; - if bytes[0] != wire::STREAM_NETWORK_DECISION || length > wire::MAX_STREAM_FRAME_BYTES { - return Err(Status::invalid_argument("invalid network decision frame")); - } - if bytes.len() == length + 5 { - return serde_json::from_slice(&bytes[5..]) - .map_err(|_| Status::invalid_argument("invalid network decision")); - } - if bytes.len() > length + 5 { - return Err(Status::invalid_argument("data before network decision")); - } + match input.message().await?.and_then(|chunk| chunk.payload) { + Some(boundary_chunk::Payload::NetworkDecision(value)) => { + crate::protocol::network_decision(value) } + _ => Err(Status::invalid_argument("network decision required")), } } diff --git a/examples/isolation-backend-v8/src/protocol.rs b/examples/isolation-backend-v8/src/protocol.rs index 5070fa3dd5..d249c72ef1 100644 --- a/examples/isolation-backend-v8/src/protocol.rs +++ b/examples/isolation-backend-v8/src/protocol.rs @@ -37,42 +37,35 @@ pub fn metadata() -> PeerMetadata { } } -/// Read one length-prefixed control request across arbitrary gRPC fragments. +// 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 boundary::boundary_chunk::Payload; - use openshell_sandbox_backend::boundary_protocol as wire; - use tonic::Status; - let mut bytes = Vec::new(); - loop { - if bytes.len() >= 4 { - let length = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; - if length == 0 || length > wire::MAX_CONTROL_FRAME_BYTES { - return Err(Status::invalid_argument("invalid frame length")); - } - if bytes.len() >= 4 + length { - return wire::decode_frame(&bytes[..4 + length]) - .map_err(|_| Status::invalid_argument("invalid request frame")); - } - } - let chunk = input - .message() - .await? - .ok_or_else(|| Status::invalid_argument("incomplete request"))?; - let Some(Payload::Data(data)) = chunk.payload else { - return Err(Status::invalid_argument("data frame required")); - }; - if bytes.len() + data.len() > wire::MAX_CONTROL_FRAME_BYTES + 4 { - return Err(Status::resource_exhausted("request too large")); - } - bytes.extend_from_slice(&data); + 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::boundary_chunk::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 fn data(bytes: Vec) -> boundary::BoundaryChunk { boundary::BoundaryChunk { - payload: Some(boundary::boundary_chunk::Payload::Data(bytes)), + payload: Some(boundary::boundary_chunk::Payload::RelayData(bytes)), } } @@ -80,18 +73,51 @@ pub fn response( request_id: String, response: openshell_sandbox_backend::boundary_protocol::Response, ) -> Result { - use openshell_sandbox_backend::boundary_protocol as wire; - wire::encode_frame(&wire::ResponseEnvelope { + 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::BoundaryChunk { + payload: Some(boundary::boundary_chunk::Payload::Response(convert(value)?)), + }) +} + +pub fn stream_frame(channel: u8, payload: &[u8]) -> Result { + use boundary::boundary_chunk::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::BoundaryChunk { + payload: Some(payload), }) - .map(data) - .map_err(|_| tonic::Status::internal("encode response")) } -pub fn stream_frame(channel: u8, payload: &[u8]) -> boundary::BoundaryChunk { - let mut bytes = vec![channel]; - bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes()); - bytes.extend_from_slice(payload); - data(bytes) +pub fn network_decision( + value: boundary::TcpOpenDecision, +) -> Result { + use openshell_sandbox_backend::protobuf::WireMessage; + openshell_isolation_interface::contract::TcpOpenDecision::from_proto(convert(value)?) + .map_err(|error| tonic::Status::invalid_argument(error.to_string())) } diff --git a/proto/README.md b/proto/README.md index f8a8b3bd92..61fc8d6456 100644 --- a/proto/README.md +++ b/proto/README.md @@ -88,8 +88,10 @@ identifies the service being exposed. ## Isolation backend protocol -[`sandbox_backend.proto`](sandbox_backend.proto) and -[`sandbox_protocol.proto`](sandbox_protocol.proto) are supervisor-to-backend -contracts, not gateway RPCs. Backend authors can generate bindings independently -and implement [Sandbox Protocol 1.0](sandbox_protocol/v1/README.md). Its negotiated -version covers the gRPC services, JSON messages, framing, and lifecycle semantics. +[`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/sandbox_backend.proto b/proto/isolation_backend.proto similarity index 98% rename from proto/sandbox_backend.proto rename to proto/isolation_backend.proto index c29e6e688b..15feaef403 100644 --- a/proto/sandbox_backend.proto +++ b/proto/isolation_backend.proto @@ -27,8 +27,8 @@ service SandboxBackend { // Bind a driver-provisioned resource to one fresh sandbox launch. The // caller credential must match sandbox_id and generation_id. This call must - // not run untrusted workload code. The subsequent Sandbox Protocol attach, - // confirm, and start sequence remains mandatory. A resource that already is + // not run untrusted workload code. The subsequent Sandbox Protocol Attach, + // Confirm, and StartAgent sequence remains mandatory. 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 diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 1e83880b55..d494450fe4 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -5,22 +5,413 @@ syntax = "proto3"; package openshell.sandbox.protocol.v1; -// Stream entrypoint for per-boundary or shared out-of-process Isolation Backends. -// Each client stream starts with a boundary_id frame. The backend checks that -// handle against the authenticated sandbox and generation before accepting any -// data. Subsequent data fragments carry the existing Sandbox Protocol bytes. -// OpenBoundary returns the handle before policy discovery and lifecycle traffic. +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "options.proto"; + +// 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. +// Admission remains Attach -> Confirm -> StartAgent; no untrusted instruction +// may execute before StartAgent. Exec and forwarding require Running. service SandboxBoundary { rpc Exchange(stream BoundaryChunk) returns (stream BoundaryChunk); rpc Mediate(stream BoundaryChunk) returns (stream BoundaryChunk); } +// 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 BoundaryChunk { + reserved 2; + reserved "data"; oneof payload { - // Nonempty handle required as the first client frame; forbidden on later frames. string boundary_id = 1; - // Ordered fragment of the existing Sandbox Protocol. Response frames only - // use data. Empty data is allowed and does not bind a stream. - bytes data = 2; + RequestEnvelope request = 3; + ResponseEnvelope response = 4; + bytes stdin = 5; + bytes stdout = 6; + bytes stderr = 7; + ExitStatus exit = 8; + StdinClosed stdin_closed = 9; + TcpOpenDecision network_decision = 10; + bytes relay_data = 11; + 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, ExecSignal, Terminate, TerminateBoundary, and Resize. Exec +// outcomes remain retained through the deadline even after process exit. +message RequestEnvelope { + string request_id = 1; + // Required only for Exec. An absolute admission/retry deadline, not a process + // runtime limit; it remains unchanged on retries and transport recovery. + google.protobuf.Timestamp exec_expires_at = 2; + oneof request { + DiscoverPolicy discover_policy = 10; + Attach attach = 11; + Confirm confirm = 12; + ProbeProviderFiles probe_provider_files = 13; + 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; + ExecSignal exec_signal = 22; + 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; + ProviderFilesSupported provider_files_supported = 13; + 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; + PortConnected port_connected = 23; + MediationReady mediation_ready = 24; + NetworkConnected network_connected = 25; + Error error = 26; + } +} +message DiscoverPolicy {} +message Attach { + string supervisor_instance_id = 1; + SandboxPolicy policy = 2; + map resource_claims = 3; +} +message Confirm {} +message ProbeProviderFiles {} +message StartAgent { + string sandbox_id = 1; + AgentSpec spec = 2; + SandboxPolicy policy = 3; + 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]; +} +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; +} +message Signal { + string process_id = 1; + BoundarySignal signal = 2; +} +message Terminate { + string process_id = 1; +} +message TerminateBoundary {} +message Exec { + ExecSpec spec = 1; +} +message ExecSignal { + string process_id = 1; + BoundarySignal signal = 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 {} + +message ImagePolicy { + optional string yaml = 1; + bool invalid = 2; +} +message Attached { + SessionSnapshot snapshot = 1; +} +message Confirmed { + BoundaryConfirmation confirmation = 1; +} +message ProviderFilesSupported {} +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 PortConnected {} +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; +} +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; +} +message EnforcedProperty { + bool enforced = 1; + string mechanism = 2; +} +message BoundaryProperties { + // Every property is required and must be enforced before confirmation. + EnforcedProperty filesystem_confinement = 1; + EnforcedProperty egress_interception = 2; + EnforcedProperty request_attribution = 3; + EnforcedProperty privilege_floor = 4; +} +message OuterFenceGuarantees { + string generation = 1; + repeated OuterFenceGuarantee established = 2; + string evidence_digest = 3; +} +enum OuterFenceGuarantee { + OUTER_FENCE_GUARANTEE_UNSPECIFIED = 0; + OUTER_FENCE_GUARANTEE_DEFAULT_DENY_EGRESS = 1; + OUTER_FENCE_GUARANTEE_NO_UNMANAGED_EGRESS_PATH = 2; + OUTER_FENCE_GUARANTEE_REVOCATION_VERIFIED = 3; + OUTER_FENCE_GUARANTEE_CONTROLLER_LOSS_FAILS_CLOSED = 4; +} +message BoundaryConfirmation { + string generation = 1; + ResolvedWorkloadIdentity identity = 2; + BoundaryProperties properties = 3; + bool authenticated_supervisor = 4; + string session_id = 5; + OuterFenceGuarantees outer_fence = 6; + bool runtime_exit_terminates_workload = 7; + map resource_claims = 8; + // Opaque backend-owned audit document. The selected backend validates its + // schema; common properties above are typed and checked by the supervisor. + bytes backend_audit = 9; +} +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/proto/sandbox_protocol/v1/README.md b/proto/sandbox_protocol/v1/README.md deleted file mode 100644 index 219c3f6ca7..0000000000 --- a/proto/sandbox_protocol/v1/README.md +++ /dev/null @@ -1,381 +0,0 @@ - - -# OpenShell Sandbox Protocol 1.0 - -This is the wire contract for an isolation backend implemented outside the -OpenShell tree. An implementation may use any language and owns its native -isolation mechanisms. Generate gRPC bindings from -[`sandbox_backend.proto`](../../sandbox_backend.proto), -[`sandbox_protocol.proto`](../../sandbox_protocol.proto), and their imports. -No OpenShell guest executable or linked backend implementation is required. - -The negotiated isolation version covers **both** the control RPCs and the -framing and JSON messages below. `Describe` exchanges the shared -[`PeerMetadata` and `ProtocolVersion`](../../extension.proto). Version 1.0 -requires `openshell.sandbox.protocol` on both peers. - -Follow the shared extension version rules: major versions change when peers -cannot safely interoperate; minor versions add optional fields or capabilities -and remain compatible when both peers' required capabilities are satisfied. -Unknown required capabilities fail negotiation. The protocol version is -independent of OpenShell releases and the diagnostic implementation version. - -Protobuf additions preserve existing field numbers and behavior; receivers -ignore unknown protobuf fields. A new operation or a field whose behavior the -sender relies on requires an advertised capability before use. JSON additions -must also preserve existing behavior: send new fields only after negotiating -receiver support, since existing JSON types may reject unknown fields or bind -their contents to a request digest. Without that support, use the existing -message shape and digest rules. Incompatible changes to messages, framing, -authentication, or lifecycle guarantees require a major bump. - -## Trusted registration and launch - -The gateway reads `isolation_backend` from the selected compute driver's -`[openshell.drivers.]` table and resolves it through -`openshell.supervisor.isolation_backends` in operator-owned `gateway.toml`. -Selecting `openshell.gateway.compute_driver` selects both the driver and its -backend; their names can differ. -It sends the resolved `SandboxBackendRegistration` through the compute -protocol in validation/create specs and every start request, including recovery. -This requires the driver's `openshell.compute.sandbox-backend` -capability; unsupported drivers fail gateway startup. Missing selection or -`openshell-sandbox` retains the built-in runtime. The public sandbox spec has -no selector or endpoint. - -The driver must reject any registered backend it cannot place, before creating -workload resources. It converts the typed endpoint to the JSON registration -below and supplies the exact admitted name. Rust drivers can use -`openshell_core::isolation_registration::BackendRegistration::try_from` and -serialize the result in a `{"backends":[...]}` wrapper. This compute launch -handoff does not change the supervisor-to-backend Protocol 1.0 contract. -The gateway pins the admitted backend name in internal sandbox metadata and -rejects start/recovery under a different selection. Legacy records without -that binding retain `openshell-sandbox`. Endpoint addresses are not persisted. - -These files and flags are supervisor CLI inputs versioned with the supervisor -release, independently of the negotiated wire protocol. Both file formats -reject unknown fields. - -The operator supplies `--isolation-backends-file` to `openshell-supervisor`: - -```json -{ - "backends": [ - { - "name": "agent-substrate", - "endpoint": { - "kind": "unix", - "socket_path": "/run/agent-substrate/isolation.sock" - } - } - ] -} -``` - -TCP endpoints use `{"kind":"tcp","authority":"backend.example:443", -"addresses":["192.0.2.10:443"]}`. Addresses are operator-pinned concrete socket -addresses; the supervisor does not resolve workload-provided hostnames. -Vsock endpoints use `{"kind":"vsock","guest_cid":3,"port":47000}`. -Vsock connects to a guest CID of at least 3; wildcard CIDs and ports are invalid. -Registration names contain 1–128 ASCII letters, digits, `-`, `_`, or `.`, and -match exactly. Duplicate names, malformed endpoints, unknown -names, and replacement of `openshell-sandbox` fail startup. - -The trusted compute driver or orchestrator supplies: - -- `OPENSHELL_ADMITTED_ISOLATION_BACKEND`: the independently admitted backend - name, never copied from sandbox environment or request input. -- `--backend-descriptor-file`: the driver's opaque native bytes. The supervisor - passes them unchanged as `OpenBoundaryRequest.driver_descriptor`. -- `--boundary-launch-file`: JSON with `backend_name`, `sandbox_id`, `generation`, `session_id`, - `workload_identity`, `tls`, `resource_claims`, `outer_fence`, and optional - `host_gateway_ip`. These are trusted launch coordinates, separate from the - native descriptor. Its backend name must match the admitted name. No endpoint - is accepted in this file. -- `--auth-bundle-file`: the existing gateway-issued `SupervisorAuthBundle`. - -`tls` contains `server_name` and `trust_anchor_pem`. The driver pins the serving -identity for this generation, including for Unix sockets and vsock. Both TLS -fields must be nonempty, and the trust anchor must parse as certificates. TCP -`authority` controls HTTP routing; `tls.server_name` controls SNI and certificate -verification. The backend derives launch identity, claims and fence from its -native descriptor or trusted driver state; it must independently verify them -before returning confirmation. - -Both JSON files and native descriptor bytes are protected from workload writes. -The -registration file belongs to the operator and must be delivered outside -workload mounts. In-tree drivers continue to select `openshell-sandbox`; an -external compute driver stages the files and launches the stock supervisor. -The public sandbox API has no endpoint or isolation-backend selector. - -## Authentication and ordering - -TLS 1.3 verifies the generation-pinned serving certificate and negotiates HTTP/2 -with ALPN `h2`. Every control RPC except `Describe`, and every stream, -uses `authorization: Bearer `. The backend validates the token's -signature with the trusted gateway verification keys and its expiry, audience, -gateway ID, sandbox ID, runtime generation, and authorization epoch. -TLS authenticates the backend; the bearer authenticates the supervisor. - -The JWT header uses `alg: "EdDSA"`, an operator-provisioned `kid`, and -`typ: "openshell-sandbox-session+jwt"`. Required claims are: -`iss: "openshell-gateway:"`, -`sub: "spiffe://openshell/sandbox/"`, `aud: "openshell-sandbox"`, -`iat: i64`, `exp: i64`, `jti: UUID`, `sandbox_id: string`, -`runtime_generation: string`, `auth_epoch: nonzero u64`, and -`component: "openshell-supervisor"`. Verify Ed25519 signatures against trusted -keys; never select a key or issuer supplied by the workload. Reject unknown -claims, wrong component/type/audience/issuer/subject, or a different sandbox, -generation, or admitted authorization epoch. Allow at most 30 seconds of clock -skew. For exp != 0, require iat < exp, a lifetime at most one hour, and an -unexpired token (with that skew). exp == 0 is the gateway's configured -nonexpiring profile; accept it only when the operator enables that profile on -the backend. Launch session binding is checked by OpenBoundary and -confirmation; it is not a JWT claim. - -The logical supervisor instance UUID is separate from the launch session. -Attach pins it for the generation. Reject another instance, and never replace -an active connection with another of the same epoch until the old connection -has closed. Confirmation promotes a staged authenticated connection. Retain -these ownership rules across transport recovery and credential renewal. - -On **each physical connection**, negotiate `PeerMetadata`, call authenticated -`OpenBoundary`, then use its nonempty opaque handle as the first client frame -on every `Exchange` or `Mediate` stream. Handles are at most 128 UTF-8 bytes. -Each control RPC message fits within 64 KiB of encoded protobuf, including the -opaque driver descriptor and launch coordinates in `OpenBoundary`. -After validating the binding, the server sends initial gRPC metadata before -waiting for the JSON request. The client waits for that metadata to complete -stream opening; delaying it until a response payload causes a deadlock. -All subsequent client frames and all response frames contain `data`. Empty data -is allowed; it never binds a stream. Protobuf message boundaries have no meaning -inside the concatenated data bytes. A handle without authentication or without -opening on the current connection has no authority. - -Opening only validates/reserves a route. Image policy discovery is read-only. -`attach` binds the admitted context; `confirm` establishes enforcement; -`start_agent` is the only operation that makes the admitted agent runnable. -No untrusted instruction may execute before those checks complete. Exec and -forwarding require `Running`. Reject out-of-order operations without advancing -state. Only the same logical supervisor may recover its transport in the same -generation. A replacement supervisor needs a newly authorized launch. - -A backend must validate native enforcement evidence before issuing confirmation. -The shared client verifies common properties and their immutable launch binding; -it does not interpret an external backend's `backend_audit`. The built-in backend -continues to validate Linux-specific evidence. An external backend remains -responsible for the RFC 0012 lifecycle and failure invariants, including bounded -termination when required enforcement disappears. Backend selection is a trust -boundary, not a mechanism for accepting untrusted attestations. - -## Control framing and replay - -Each logical stream begins with one request and its matching response: - -```text -u32 big-endian UTF-8 JSON length | JSON bytes -``` - -The maximum JSON payload is 1,048,576 bytes. Integers use their complete wire -range; avoid conversion through floating-point numbers. Byte arrays in JSON -are arrays of integers in 0..255, not base64. Paths, IP addresses, socket -addresses, UUIDs, and SHA-256 digests are strings. Socket addresses use -`IPv4:port` or `[IPv6]:port`. Digests are 64 lowercase hex characters. -Optional values are JSON `null` unless explicitly omitted below. Map keys are -strings. Fields are required unless described as optional or defaulted. - -Request envelope: - -```json -{ - "request_id": "00000000-0000-4000-8000-000000000001", - "payload_digest": "", - "request": { "operation": "discover_policy" } -} -``` - -`request_id` is a cryptographically random UUID scoped to the runtime generation. -`exec_expires_at_unix_ms` is omitted for non-exec requests; exec requires an -absolute admission deadline, normally 30 seconds after request creation. -Host and backend wall clocks must be synchronized; exec deadlines have no skew -allowance. The deadline limits exec -admission/recovery, not the lifetime of an admitted process. - -The sender emits all request fields, including nulls and defaults. To compute -`payload_digest`, canonicalize the `request` object as transmitted, without -dropping or inventing fields. For exec, add `exec_expires_at_unix_ms` to a copy -of that object for hashing only. Recursively sort object keys by UTF-8 byte -order (equivalently, Unicode code point order). Serialize compact UTF-8 JSON -without whitespace. Escape quote, backslash, backspace, form feed, newline, -carriage return and tab as `\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`; -escape other control characters below U+0020 as lowercase `\u00xx`. Do not -escape non-ASCII characters or `/`. Integers use base-10 digits without leading -zeros; floating point values are not used. Hash these bytes with SHA-256. -Object order in the transmitted message is immaterial. - -The backend validates the digest and records replayable mutations by generation -and request ID. The same ID and digest returns the original result without -repeating effects; the same ID with a different digest fails. Replayable requests -are attach, confirm, start_agent, update_provider_environment, exec, signal, -terminate, terminate_boundary, exec_signal, and resize. Non-exec mutation results -remain replayable for the generation. Exec IDs remain admitted until their -absolute deadline, independently of process/output retention. Expired execs fail -even if their ID was discarded. An unknown outcome after transport loss does -not authorize launching another exec under a new ID. - -Responses use `{"request_id":"","response":{"result":"..."}}`. -The result may be followed by stream data as specified below. Structured errors -use `{"result":"error","kind":"invalid|denied|unavailable|terminated|process", -"message":"..."}` and must not include secrets. - -## Request and response messages - -`operation` selects the request; `result` selects the response. Fields listed -below belong to that object alongside its tag. Empty field lists mean the tag -alone. IDs are opaque generation-scoped strings, never local process IDs. - -| Operation | Request fields | Success result and fields | -|---|---|---| -| `discover_policy` | — | `image_policy`: `yaml: string\|null`, `invalid: bool` | -| `attach` | `supervisor_instance_id: UUID`, `policy: Policy`, `resource_claims: map` | `attached`: `snapshot: SessionSnapshot` | -| `confirm` | — | `confirmed`: `confirmation: Confirmation` | -| `probe_provider_files` | — | `provider_files_supported` | -| `start_agent` | `sandbox_id: string`, `spec: AgentSpec`, `policy: Policy`, `ca_cert: string\|null`, `ca_bundle: string\|null`, `provider_env_revision: u64`, `provider_env: map`, `provider_files: map` (default `{}`) | `started`: `process_id: string`, `provider_env_revision: u64`, `provider_env_generation: u64` | -| `update_provider_environment` | `generation: u64`, `revision: u64`, `provider_env: map`, `provider_files: map` (default `{}`) | `provider_environment_updated`: `revision: u64`, `generation: u64`, `applied: bool` | -| `attach_process` | `process_id: string` | `process_attached`: `terminal: bool`; then process I/O | -| `wait` | `process_id: string` | `exited`: `status: ExitStatus` | -| `signal` | `process_id: string`, `signal: Signal` | `signaled` | -| `terminate` | `process_id: string` | `terminated` | -| `terminate_boundary` | — | `boundary_terminated` | -| `exec` | `spec: ExecSpec` | `exec_started`: `process_id: string`, `pty: bool`; then process I/O | -| `exec_signal` | `process_id: string`, `signal: Signal` | `signaled` | -| `resize` | `process_id: string`, `cols: u16`, `rows: u16` | `resized` | -| `loopback_connect` | `host: IP string`, `port: u16` | `port_connected`; then raw bidirectional bytes | -| `accept_network` | — | `network_connected`: `identity: BinaryIdentity`, `destination: socket address`, `socket: SocketMetadata` (optional), `policy_generation: u64`, `timing: Timing`; then TCP decision/relay | -| `open_mediation` | —; sent on `Mediate` | `mediation_ready`; then persistent DNS framing | - -Image policy parse/read failure returns `invalid: true` or an error; it must -never be represented as missing policy. `attach` includes policy with the -supervisor's ready mediation listener. Replayed attach returns the owned session -snapshot without transferring ownership to another supervisor instance. -Termination acknowledgments require all targeted owned processes to be terminal. -`wait` returns a stable status, including on repeat calls. - -Before sending provider files, the supervisor probes support. Provider snapshots -replace the previous environment/files atomically. `generation` orders -publications within a session; stale publications must not replace newer ones. -`applied` is true for an installed publication or its exact replay. Existing -process environments remain unchanged; subsequent execs observe the installed -snapshot. Workload launch applies all controls before its first instruction. - -### Compound JSON types - -- **AgentSpec**: `program: string`, `args: string[]`, `workdir: string|null`, - `timeout_secs: u64`, `interactive: bool`. An empty program asks the backend to - resolve the image's default login shell. -- **ExecSpec**: `program: string`, `args: string[]`, `env: [string,string][]`, - `workdir: string|null`, `pty: bool`, `shell: ShellSpec|null` (default `null`), - `runtime_helper: "sftp"|null` (default `null`). -- **ShellSpec**: `command: string|null`, `login: bool`. The backend resolves the shell - against the workload image. Shell and runtime helper are mutually exclusive. -- **Policy**: `version: u32`, `read_only: string[]`, `read_write: string[]`, - `include_workdir: bool`, `network: "block"|"proxy"|"allow"`, - `proxy_addr: socket address|null`, `landlock: "best_effort"|"hard_requirement"`, - `run_as_user: string|null`, `run_as_group: string|null`. These wire names remain - historical; the external backend translates admitted constraints into its own - mechanisms and fails when it cannot enforce them. -- **Identity**: `uid: u32`, `gid: u32`, `supplementary_gids: u32[]`, - `source: string`, `resource_digest: string`. UID, GID, and groups are nonzero; - groups are sorted, unique, and exclude the primary GID. Source and resource - digest are nonempty driver-owned values. -- **Property**: `enforced: bool`, `mechanism: string`. Confirmation requires - `enforced: true` and a nonempty mechanism for each property. -- **Properties**: `filesystem_confinement: Property`, `egress_interception: - Property`, `request_attribution: Property`, `privilege_floor: Property`. -- **OuterFence**: `generation: string`, `established: string[]`, - `evidence_digest: SHA-256 string`. Established guarantees are exactly - `default_deny_egress`, `no_unmanaged_egress_path`, `revocation_verified`, and - `controller_loss_fails_closed`. The digest commits to - `u64be(UTF-8 generation length) || generation || validated native evidence`. - Serialize the guarantee array in the listed order. The enforcement owner - validates native evidence before projecting these guarantees. -- **Confirmation**: `generation: string`, `identity: Identity`, `properties: - Properties`, `authenticated_supervisor: bool`, `session_id: UUID`, - `outer_fence: OuterFence`, `runtime_exit_terminates_workload: bool`, - `resource_claims: map`, `backend_audit: any JSON`. Generation, - session, identity, claims, and fence must match trusted launch metadata; - both booleans must be true. The backend owns validation of its audit payload. -- **SessionSnapshot**: `generation: string`, `processes: ProcessSnapshot[]`. -- **ProcessSnapshot**: `process_id: string`, `kind: "main"|"exec"`, `terminal: - bool`, `status: ExitStatus|null`, `retained_output: OutputWindow`. -- **OutputWindow**: `first_sequence: u64`, `next_sequence: u64`, `truncated: bool`. -- **ExitStatus**: `{"kind":"exited","value":i32}` or - `{"kind":"signaled","value":i32}`. -- **Signal**: `"term"`, `"kill"`, `"int"`, or `"hup"`. -- **ExecutableIdentity**: `path: string`, `digest: SHA-256 string|null`. -- **BinaryIdentity**: `{"result":"resolved","executable":ExecutableIdentity, - "ancestors":ExecutableIdentity[],"cmdline_paths":string[]}` or - `{"result":"failed","message":string}`. Identity must come from trusted - backend observation; never substitute workload claims on resolution failure. -- **SocketMetadata**: `socket_cookie: u64`, `nonblocking: bool`, - `process_generation: u64`. Omit `socket` for guest host calls without native - sockets. Supply the guest executable path and digest (module or script) as identity; do not - substitute the host runtime or fabricate kernel socket evidence. -- **Timing**: `notification_to_queue_us: u64`, `queue_wait_us: u64`. Measure - backend-monotonic syscall or guest host-call staging; unmeasured durations are - zero. These are diagnostic timings, not authorization evidence. - -## Process I/O, TCP relay, and DNS - -Process I/O uses `u8 channel | u32be payload length | payload`, with a maximum -payload of 65,536 bytes: - -| Channel | Direction | Meaning | -|---|---|---| -| 0 | supervisor → backend | stdin bytes | -| 1 | backend → supervisor | stdout bytes | -| 2 | backend → supervisor | stderr bytes (non-PTY only) | -| 3 | backend → supervisor | UTF-8 JSON ExitStatus | -| 4 | supervisor → backend | stdin closed; empty payload | -| 5 | supervisor → backend | UTF-8 JSON TCP decision | - -PTY sessions merge stderr into stdout. Send exit only after output drains; -closing stdin must not close the response direction. Relay and exec streams -must not starve control or DNS traffic. The reference transport supports 128 -concurrent streams, 256 KiB per-stream windows, and a 48 MiB connection window. - -After `network_connected`, the backend waits for channel 5. The JSON decision is -`"RelayReady"` or `{"Denied":"PolicyDenied|IdentityUnavailable| -InvalidDestination|ResourceExhausted|MediationUnavailable"}` (one exact enum -value, without `|`). Denial leaves the socket uncommitted; mediation loss denies -by default. `RelayReady` switches to raw bidirectional bytes, and does not bypass -subsequent L7 policy. Each TCP open owns an independent `Exchange` stream. - -After `mediation_ready`, DNS uses `u8 kind | u64be stream ID | u32be JSON length | -JSON`, with a maximum JSON payload of 262,144 bytes. Kind 5 carries a backend -query: `request: byte[]`, `transport: "Udp"|"Tcp"`, `identity: BinaryIdentity`, -`timing: Timing`. Kind 6 carries the matching supervisor result: -`{"result":"response","value":byte[]}` or -`{"result":"error","value":string}`. Stream IDs correlate independent queries -on the persistent channel. Malformed framing, unknown kinds, lost mediation, or -queue exhaustion fail closed; they never authorize direct DNS/egress. - -## Conformance and optional retention - -An external backend must exercise descriptor rejection, lifecycle ordering, -confirmation mismatch, exec replay/deadlines, provider publication, forwarding, -TCP/DNS decisions, connection recovery, credential rotation, and enforcement -loss in its actual driver placement. OpenShell's delegated adapter tests verify -opaque payload forwarding and generic evidence without a native Linux audit. -Its existing transport suites cover recovery, credential renewal, and flow -control; Docker lifecycle E2E verifies the built-in placement. - -Suspend/resume is a separate optional capability described in -`sandbox_backend.proto`. Protocol 1.0 implementations may return -`UNIMPLEMENTED` without advertising it. This registration path supports fresh -launches; it does not yet orchestrate state-preserving restoration. From 77faa07fd16ae4e1631c6feb02cb2ce2f390db86 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 15 Oct 2026 23:55:34 -0700 Subject: [PATCH 15/24] refactor(isolation): replace backend traits with RPC clients Signed-off-by: Drew Newberry --- AGENTS.md | 4 +- Cargo.lock | 3 +- .../openshell-isolation-interface/Cargo.toml | 3 +- .../openshell-isolation-interface/README.md | 14 +- .../src/contract.rs | 363 +------ .../openshell-isolation-interface/src/lib.rs | 34 +- .../tests/backend_conformance.rs | 750 +------------- crates/openshell-sandbox-backend/Cargo.toml | 3 + crates/openshell-sandbox-backend/README.md | 13 +- .../src/boundary_protocol.rs | 1 + crates/openshell-sandbox-backend/src/lib.rs | 20 +- .../openshell-sandbox-backend/src/protobuf.rs | 2 +- .../openshell-sandbox-backend/src/runtime.rs | 343 ++++--- .../src/runtime/test_support.rs | 914 ++++++++++++++++++ .../src/runtime/tests/credential_renewal.rs | 2 +- .../src/runtime/tests/delegated.rs | 42 +- .../src/runtime/tests/flow_control.rs | 2 +- .../src/runtime/tests/network_recovery.rs | 14 +- crates/openshell-sandbox/src/boundary_exec.rs | 8 +- crates/openshell-sandbox/src/boundary_io.rs | 6 +- .../openshell-sandbox/src/boundary_server.rs | 38 +- crates/openshell-sandbox/src/delegated.rs | 3 +- crates/openshell-sandbox/src/lib.rs | 2 + crates/openshell-sandbox/src/main_session.rs | 6 +- crates/openshell-sandbox/src/native_api.rs | 70 ++ crates/openshell-server/src/storage_proto.rs | 2 +- .../openshell-supervisor-network/Cargo.toml | 3 + .../src/policy_dns/runtime.rs | 28 +- .../openshell-supervisor-network/src/proxy.rs | 12 +- .../openshell-supervisor-network/src/run.rs | 4 +- .../openshell-supervisor-process/Cargo.toml | 3 + .../src/delegated.rs | 45 +- .../src/main_session.rs | 22 +- .../openshell-supervisor-process/src/ssh.rs | 43 +- .../src/ssh/exec_input_tests.rs | 69 +- .../src/ssh/reconnect_tests.rs | 16 +- .../src/ssh/upload.rs | 8 +- .../src/ssh/upload_tests.rs | 79 +- .../src/supervisor_session.rs | 28 +- crates/openshell-supervisor/Cargo.toml | 2 + .../openshell-supervisor/src/backend_setup.rs | 26 +- .../src/backend_setup/registration.rs | 19 +- .../src/backend_setup/tests.rs | 313 +++--- .../src/provider_readiness.rs | 40 +- examples/isolation-backend-v8/Cargo.lock | 1 - .../isolation-backend-v8/src/lifecycle.rs | 10 +- proto/isolation_backend.proto | 8 +- proto/sandbox_protocol.proto | 1 + 48 files changed, 1681 insertions(+), 1761 deletions(-) create mode 100644 crates/openshell-sandbox-backend/src/runtime/test_support.rs create mode 100644 crates/openshell-sandbox/src/native_api.rs 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 b4f4c70c1f..e9d14c15c5 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", @@ -5100,6 +5099,7 @@ dependencies = [ "openshell-otel", "openshell-policy", "openshell-policy-schema", + "openshell-sandbox-backend", "openshell-supervisor-middleware", "openshell-supervisor-middleware-builtins", "opentelemetry_sdk", @@ -5145,6 +5145,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-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 85334b3621..8cb44ad9c1 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, BTreeSet}; 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; @@ -169,29 +143,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,128 +215,10 @@ 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. /// @@ -558,82 +391,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 +417,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 +458,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 +513,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 // ============================================================================ @@ -1029,25 +695,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 404a094015..0d28b98ffd 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: Some(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: Some(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( @@ -439,375 +85,6 @@ fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { 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_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) - ); - assert_eq!(conn.destination, "203.0.113.10:443".parse().unwrap()); - assert_eq!(conn.socket.unwrap().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] fn sha256_digest_rejects_signed_hex_chunks() { let signed = format!("+0{}", "00".repeat(31)); @@ -815,15 +92,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!( @@ -865,10 +133,6 @@ 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!( @@ -904,3 +168,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 4573c27ae1..ac0032b824 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -10,6 +10,9 @@ 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" } diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 0629606043..491a9a8c53 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -1,11 +1,18 @@ # OpenShell sandbox backend -This crate implements the supervisor-side isolation backend and the versioned +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 `SandboxBackend` control service to +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; @@ -94,4 +101,4 @@ 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. +replace the RPC contracts. diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 567a825ef9..9dffa33f0d 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -991,6 +991,7 @@ pub enum BoundaryErrorKind { Unavailable, Terminated, Process, + Unsupported, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 0333cef99b..a4e207fc49 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -3,12 +3,12 @@ #![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; pub mod boundary_protocol; @@ -21,7 +21,12 @@ pub mod sandbox_auth; pub mod stream_adapter; pub use openshell_core::proto::sandbox::backend::v1 as backend_proto; -pub use runtime::{BoundaryTransportConnector, DelegatedRuntimeBackend, OpenShellRuntimeBackend}; +pub use runtime::{ + BoundBoundary, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, + BoundaryTransportConnector, ConfirmedBoundary, DelegatedRuntimeBackend, ExecSession, + NetworkMediationSource, OpenShellRuntimeBackend, ProcessAttachment, ReadyBoundary, + RunningBoundary, +}; /// Stable isolation backend name implemented by `openshell-sandbox`. pub const BACKEND_NAME: &str = openshell_core::isolation_registration::BUILTIN_BACKEND_NAME; @@ -48,3 +53,6 @@ pub const SUPERVISOR_CA_RUNTIME_DIR: &str = "/run/openshell-supervisor-ca/materi pub mod proto { 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/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index fec2a36c77..0a3381c7a7 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -61,7 +61,7 @@ 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}); +enumeration!(w::BoundaryErrorKind, p::BoundaryErrorKind, {Invalid, Denied, Unavailable, Terminated, Process, Unsupported}); enumeration!(c::OuterFenceGuarantee, p::OuterFenceGuarantee, {DefaultDenyEgress, NoUnmanagedEgressPath, RevocationVerified, ControllerLossFailsClosed}); enumeration!(c::TcpOpenDenial, p::TcpOpenDenial, {PolicyDenied, IdentityUnavailable, InvalidDestination, ResourceExhausted, MediationUnavailable}); enumeration!(c::DnsTransport, p::DnsTransport, {Udp, Tcp}); diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index ee362a0698..131d92b8c5 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -11,8 +11,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; use crate::backend_proto::{ - DescribeSandboxBackendRequest, OpenBoundaryRequest, open_boundary_response::LaunchOutcome, - sandbox_backend_client::SandboxBackendClient, + DescribeIsolationBackendRequest, OpenBoundaryRequest, + isolation_backend_client::IsolationBackendClient, open_boundary_response::LaunchOutcome, }; use crate::isolation_protocol::boundary_chunk; #[cfg(test)] @@ -22,12 +22,10 @@ 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, ResolvedWorkloadIdentity, 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}; @@ -81,7 +79,7 @@ pub trait BoundaryTransportConnector: std::fmt::Debug + Send + Sync { ) -> 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>, @@ -168,6 +166,10 @@ impl OpenShellRuntimeBackend { pub struct DelegatedRuntimeBackend(OpenShellRuntimeBackend); impl DelegatedRuntimeBackend { + pub fn into_client(self) -> OpenShellRuntimeBackend { + self.0 + } + pub fn new( backend_name: String, descriptor: SandboxRuntimeDescriptor, @@ -202,48 +204,46 @@ impl DelegatedRuntimeBackend { } } -#[async_trait] -impl IsolationBackend for DelegatedRuntimeBackend { - fn backend_name(&self) -> &str { +impl DelegatedRuntimeBackend { + pub fn backend_name(&self) -> &str { self.0.backend_name() } - async fn attach( + pub async fn attach( &self, - descriptor: VerifiedBackendDescriptor, + descriptor: BackendDescriptor, sandbox: SandboxContext, - ) -> Result, BackendError> { + ) -> Result, BackendError> { self.0.attach(descriptor, sandbox).await } } -#[async_trait] -impl IsolationBackend for OpenShellRuntimeBackend { - fn backend_name(&self) -> &str { +impl OpenShellRuntimeBackend { + 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> { - if descriptor.backend_name() != self.backend_name() { + ) -> 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 { + 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( + serde_json::from_slice::(&descriptor.payload).map_err( |error| BackendError::Descriptor(format!("decode runtime descriptor: {error}")), )? }; @@ -257,7 +257,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { runtime_descriptor, self.sandbox_bearer.clone(), self.backend_name().to_string(), - descriptor.payload().to_vec(), + descriptor.payload.clone(), ); client.connector = self.connector.clone(); let client = Arc::new(client); @@ -276,7 +276,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { "sandbox session snapshot generation does not match runtime descriptor".to_string(), )); } - Ok(Box::new(RemoteBound { + Ok(Box::new(BoundBoundary { audit_validator: self .delegated .is_none() @@ -285,7 +285,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { 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(), @@ -391,13 +391,13 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result>, 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, @@ -408,17 +408,16 @@ struct RemoteBound { outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, } -#[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)); @@ -444,7 +443,7 @@ impl BoundBoundary for RemoteBound { } 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, @@ -460,7 +459,7 @@ impl BoundBoundary for RemoteBound { } } -struct RemoteReady { +pub struct ReadyBoundary { client: Arc, agent: AgentSpec, policy: openshell_core::policy::SandboxPolicy, @@ -469,9 +468,8 @@ struct RemoteReady { 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() @@ -534,45 +532,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 @@ -582,18 +580,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 { @@ -615,18 +613,28 @@ 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 { - process_id: self.process_id.clone(), - signal: SignalWire::from(signal), + .call_idempotent(if self.exec { + Request::ExecSignal { + process_id: self.process_id.clone(), + signal: SignalWire::from(signal), + } + } else { + Request::Signal { + process_id: self.process_id.clone(), + signal: SignalWire::from(signal), + } }) .await?; 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 { @@ -663,8 +671,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 @@ -713,7 +721,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 @@ -721,7 +729,7 @@ struct RemoteExec { publication_generation: tokio::sync::Mutex, } -impl RemoteExec { +impl BoundaryExec { async fn synchronize( &self, generation: &mut u64, @@ -793,15 +801,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; @@ -809,13 +816,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 { @@ -830,51 +839,13 @@ impl BoundaryLoopbackConnector for RemoteLoopbackConnector { } } -struct RemoteExecProcess { - 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 { +pub struct BoundaryTerminal { 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 { @@ -916,12 +887,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 }; @@ -968,13 +940,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. @@ -1014,7 +985,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 { @@ -2070,12 +2041,12 @@ impl BoundaryClient { channel: tonic::transport::Channel, credential: &BoundaryCredential, ) -> Result<(), BackendError> { - let mut client = SandboxBackendClient::new(channel) + 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(DescribeSandboxBackendRequest { + .describe(DescribeIsolationBackendRequest { supervisor: Some(supervisor.clone()), }) .await @@ -2215,9 +2186,8 @@ fn control_rpc_error(error: tonic::Status) -> BackendError { tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { BackendError::Denied(format!("isolation control authentication: {error}")) } - tonic::Code::InvalidArgument - | tonic::Code::FailedPrecondition - | tonic::Code::Unimplemented => { + 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}")), @@ -2265,6 +2235,7 @@ async fn open_grpc_client_stream_with_authorization( 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( @@ -2398,6 +2369,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), @@ -2410,6 +2382,71 @@ 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; @@ -2423,9 +2460,9 @@ mod tests { use super::*; use crate::backend_proto::{ - DescribeSandboxBackendResponse, OpenBoundaryResponse, ResumeBoundaryRequest, + DescribeIsolationBackendResponse, OpenBoundaryResponse, ResumeBoundaryRequest, SuspendBoundaryRequest, SuspendBoundaryResponse, - sandbox_backend_server::{SandboxBackend as ControlBackend, SandboxBackendServer}, + isolation_backend_server::{IsolationBackend as ControlBackend, IsolationBackendServer}, }; use crate::boundary_protocol::{ExitStatusWire, generate_sandbox_tls_material}; use crate::proto::{ @@ -2464,14 +2501,14 @@ mod tests { impl ControlBackend for TestIsolationBackend { async fn describe( &self, - request: tonic::Request, - ) -> Result, tonic::Status> { + 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(DescribeSandboxBackendResponse { + Ok(tonic::Response::new(DescribeIsolationBackendResponse { backend: self.metadata.clone(), })) } @@ -2556,7 +2593,7 @@ mod tests { .await .unwrap(); tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(service)) + .add_service(IsolationBackendServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( Box::new(stream), ))])) @@ -2595,7 +2632,7 @@ mod tests { .await .unwrap(); tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend { + .add_service(IsolationBackendServer::new(TestIsolationBackend { outcome: outcome as i32, ..TestIsolationBackend::default() })) @@ -2655,7 +2692,7 @@ mod tests { mediation_failures: Arc, mediation_ready: bool, provider_environment_generation: u64, - confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + confirmation: BoundaryConfirmation, } type TestGrpcStream = Pin< @@ -2846,7 +2883,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await @@ -2919,7 +2956,7 @@ mod tests { }; tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), @@ -3031,7 +3068,7 @@ mod tests { }; tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), @@ -3088,7 +3125,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await @@ -3273,7 +3310,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, @@ -3285,7 +3322,7 @@ mod tests { confirmation, }; tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( stream, @@ -3323,7 +3360,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, @@ -3355,7 +3392,7 @@ 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(), @@ -3368,9 +3405,7 @@ mod tests { } } - 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 @@ -3395,13 +3430,13 @@ mod tests { descriptor, test_bearer(&expected_token), )); - let bound = RemoteBound { + let bound = BoundBoundary { audit_validator: Some(Arc::new(crate::audit::LinuxBoundaryAuditValidator)), 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, @@ -3837,7 +3872,7 @@ mod tests { let service = service.clone(); tokio::spawn(async move { tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), @@ -3906,7 +3941,7 @@ mod tests { .await .unwrap(); tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( Box::new(stream), @@ -3920,7 +3955,7 @@ 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)), @@ -3965,7 +4000,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), 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..7b6541973d --- /dev/null +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -0,0 +1,914 @@ +// 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, + boundary_chunk::Payload, + sandbox_boundary_server::{SandboxBoundary, SandboxBoundaryServer}, +}; +use crate::protobuf::WireMessage as _; +use crate::runtime::BoundaryClient; +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, OuterFenceGuarantee, + OuterFenceGuarantees, PendingDnsQuery, PendingTcpOpen, ProviderEnvironmentInstallation, + 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::BoundaryChunk { + p::BoundaryChunk { + 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::ExecSignal { + process_id, + signal: w::SignalWire::Kill, + } => { + self.process(&process_id)?.terminate().await?; + Response::Signaled + } + Request::Signal { process_id, signal } | Request::ExecSignal { 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::ProbeProviderFiles => Response::ProviderFilesSupported, + 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::PortConnected + } + 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::NetworkDecision(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 SandboxBoundary 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(chunk(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::BoundaryChunk{payload:Some(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 + .map_err(|e| tonic::Status::invalid_argument(e.to_string()))?; + assert!(matches!( + first.and_then(|c| c.payload), + Some(Payload::BoundaryId(_)) + )); + let Some(Payload::Request(request)) = input + .message() + .await + .map_err(|e| tonic::Status::invalid_argument(e.to_string()))? + .and_then(|c| c.payload) + else { + return Err(tonic::Status::invalid_argument("missing request")); + }; + RequestEnvelope::from_proto(request).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) { + send( + out, + Payload::Response( + ResponseEnvelope { + request_id: id, + response, + } + .to_proto() + .unwrap(), + ), + ) + .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 crate::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(SandboxBoundaryServer::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(), + outer_fence: OuterFenceGuarantees::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.outer_fence = OuterFenceGuarantees::from_enforcement_evidence( + generation, + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + 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 6a6267b3ef..42b5cc23f5 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs @@ -87,7 +87,7 @@ async fn renewing_boundary_client( let service = server_service.clone(); connections.spawn(async move { tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(service)) .serve_with_incoming( tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo(Box::new(stream)))]) diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs index 74691b817a..55539e5a26 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs @@ -2,15 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendRegistry, EnforcedProperty, -}; +use openshell_isolation_interface::contract::{BackendDescriptor, EnforcedProperty}; const EXTERNAL_NAME: &str = "external-test"; const OPAQUE_PAYLOAD: &[u8] = b"external-driver-v1\0\xffresource"; async fn external_backend( - confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + confirmation: BoundaryConfirmation, ) -> (DelegatedRuntimeBackend, tokio::task::JoinHandle<()>) { let certificate = test_certificate(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -38,7 +36,7 @@ async fn external_backend( confirmation, }; tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend { + .add_service(IsolationBackendServer::new(TestIsolationBackend { backend_name: EXTERNAL_NAME.into(), driver_descriptor: Some(OPAQUE_PAYLOAD.to_vec()), ..TestIsolationBackend::default() @@ -80,7 +78,7 @@ async fn external_backend( (backend, server) } -fn external_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { +fn external_confirmation() -> BoundaryConfirmation { let mut confirmation = test_confirmation(); confirmation.backend_audit = serde_json::json!({"external_boundary": "verified-actor"}); let property = EnforcedProperty::new(true, "external-actor-isolation"); @@ -93,18 +91,11 @@ fn external_confirmation() -> openshell_isolation_interface::contract::BoundaryC confirmation } -async fn attach_external(backend: DelegatedRuntimeBackend) -> Box { - let mut registry = BackendRegistry::new(); - registry.register(Arc::new(backend)).unwrap(); - let (backend, descriptor) = registry - .resolve( - BackendDescriptor { - backend_name: EXTERNAL_NAME.into(), - payload: OPAQUE_PAYLOAD.to_vec(), - }, - EXTERNAL_NAME, - ) - .unwrap(); +async fn attach_external(backend: DelegatedRuntimeBackend) -> Box { + let descriptor = BackendDescriptor { + backend_name: EXTERNAL_NAME.into(), + payload: OPAQUE_PAYLOAD.to_vec(), + }; backend.attach(descriptor, sandbox()).await.unwrap() } @@ -205,17 +196,10 @@ async fn delegated_backend_rejects_reserved_name_and_changed_payload_before_io() Err(BackendError::Descriptor(_)) )); let (backend, server) = external_backend(external_confirmation()).await; - let mut registry = BackendRegistry::new(); - registry.register(Arc::new(backend)).unwrap(); - let (backend, descriptor) = registry - .resolve( - BackendDescriptor { - backend_name: EXTERNAL_NAME.into(), - payload: b"changed".to_vec(), - }, - EXTERNAL_NAME, - ) - .unwrap(); + 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 fba8f2c894..38bf4f6a25 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs @@ -78,7 +78,7 @@ async fn start_boundary(peer: StallingPeer) -> tonic::transport::Channel { .initial_connection_window_size( crate::boundary_protocol::BOUNDARY_CONNECTION_WINDOW_BYTES, ) - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(peer)) .serve_with_incoming(incoming) .await 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 efb725cd0e..23310045bf 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -254,7 +254,7 @@ fn network_response() -> Response { } struct Fixture { - source: RemoteNetworkMediation, + source: NetworkMediationSource, state: Arc, server: tokio::task::JoinHandle<()>, } @@ -304,7 +304,7 @@ impl Fixture { ))]) .chain(tokio_stream::pending()); let serving = tonic::transport::Server::builder() - .add_service(SandboxBackendServer::new(TestIsolationBackend::default())) + .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) .add_service(SandboxBoundaryServer::new(peer)) .serve_with_incoming(incoming); tokio::select! { @@ -350,7 +350,7 @@ impl Fixture { .await .expect("fixture must attach and confirm"); Self { - source: RemoteNetworkMediation { client }, + source: NetworkMediationSource { client }, state, server, } @@ -427,7 +427,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 }); @@ -477,7 +477,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 }); @@ -564,7 +564,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 }); @@ -577,7 +577,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/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 09644dedbb..6b8ddec2cc 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,15 +39,14 @@ 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::{ - DescribeSandboxBackendRequest, DescribeSandboxBackendResponse, OpenBoundaryRequest, + DescribeIsolationBackendRequest, DescribeIsolationBackendResponse, OpenBoundaryRequest, OpenBoundaryResponse, ResumeBoundaryRequest, SuspendBoundaryRequest, SuspendBoundaryResponse, + isolation_backend_server::{IsolationBackend, IsolationBackendServer}, open_boundary_response::LaunchOutcome, - sandbox_backend_server::{SandboxBackend, SandboxBackendServer}, }; #[cfg(test)] use openshell_sandbox_backend::isolation_protocol::{chunk_data, data_chunk}; @@ -614,7 +617,7 @@ mod linux { .initial_stream_window_size(BOUNDARY_STREAM_WINDOW_BYTES) .initial_connection_window_size(BOUNDARY_CONNECTION_WINDOW_BYTES) .add_service( - SandboxBackendServer::new(service.clone()) + IsolationBackendServer::new(service.clone()) .max_decoding_message_size(64 * 1024) .max_encoding_message_size(64 * 1024), ) @@ -779,11 +782,11 @@ mod linux { } #[tonic::async_trait] - impl SandboxBackend for GrpcBoundaryService { + impl IsolationBackend for GrpcBoundaryService { async fn describe( &self, - request: tonic::Request, - ) -> Result, tonic::Status> { + request: tonic::Request, + ) -> Result, tonic::Status> { let backend = openshell_sandbox_backend::isolation_protocol::metadata("openshell/sandbox"); openshell_sandbox_backend::isolation_protocol::negotiate( @@ -791,7 +794,7 @@ mod linux { request.into_inner().supervisor, )?; self.negotiated.store(true, Ordering::Release); - Ok(tonic::Response::new(DescribeSandboxBackendResponse { + Ok(tonic::Response::new(DescribeIsolationBackendResponse { backend: Some(backend), })) } @@ -2280,7 +2283,6 @@ mod linux { stdout, stderr, terminal, - output_status: _, } = session; let Some(stdin) = stdin else { return Err(guest_error( @@ -2291,7 +2293,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, @@ -4093,9 +4095,9 @@ mod linux { request: OpenBoundaryRequest, token: &str, ) -> String { - let mut control = openshell_sandbox_backend::backend_proto::sandbox_backend_client::SandboxBackendClient::new(channel); + let mut control = openshell_sandbox_backend::backend_proto::isolation_backend_client::IsolationBackendClient::new(channel); control - .describe(DescribeSandboxBackendRequest { + .describe(DescribeIsolationBackendRequest { supervisor: Some(openshell_sandbox_backend::isolation_protocol::metadata( "test/supervisor", )), @@ -5273,7 +5275,7 @@ mod linux { .connect() .await .unwrap(); - let mut control = openshell_sandbox_backend::backend_proto::sandbox_backend_client::SandboxBackendClient::new(channel.clone()); + let mut control = openshell_sandbox_backend::backend_proto::isolation_backend_client::IsolationBackendClient::new(channel.clone()); let mut streams = SandboxBoundaryClient::new(channel.clone()); let before_open = streams .exchange(bearer_request( @@ -5295,7 +5297,7 @@ mod linux { ); assert_eq!( control - .describe(DescribeSandboxBackendRequest { supervisor: None }) + .describe(DescribeIsolationBackendRequest { supervisor: None }) .await .unwrap_err() .code(), @@ -5306,7 +5308,7 @@ mod linux { incompatible.protocol_version.as_mut().unwrap().major += 1; assert_eq!( control - .describe(DescribeSandboxBackendRequest { + .describe(DescribeIsolationBackendRequest { supervisor: Some(incompatible) }) .await @@ -5326,7 +5328,7 @@ mod linux { .push("openshell.isolation.future".to_string()); assert_eq!( control - .describe(DescribeSandboxBackendRequest { + .describe(DescribeIsolationBackendRequest { supervisor: Some(unmet_requirement), }) .await @@ -5335,7 +5337,7 @@ mod linux { tonic::Code::FailedPrecondition ); control - .describe(DescribeSandboxBackendRequest { + .describe(DescribeIsolationBackendRequest { supervisor: Some(compatible), }) .await 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..a63a67434e 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -75,3 +75,5 @@ pub fn run( boundary_server::run_boundary(config_path, qualification) .map_err(|error| miette::miette!(error)) } + +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/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index f39a6f00c7..c3e1b86222 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -536,7 +536,7 @@ mod tests { 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, "SandboxBackend"); + assert_eq!(service_name, "IsolationBackend"); isolation_methods.extend( service .method 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 59a5af5fef..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>, @@ -8504,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< @@ -8878,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/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index c4c414ed69..4c6e541b03 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -15,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. @@ -43,7 +43,7 @@ pub struct BackendServices { /// Private 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, } @@ -163,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 @@ -178,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 index 7032e34797..cce42c30c6 100644 --- a/crates/openshell-supervisor/src/backend_setup/registration.rs +++ b/crates/openshell-supervisor/src/backend_setup/registration.rs @@ -114,14 +114,17 @@ impl PreparedBackend for RegisteredLaunch { services: BackendServices, ) -> std::result::Result { let payload = self.payload.clone(); - let backend = Arc::new(DelegatedRuntimeBackend::new( - self.name, - self.descriptor, - self.payload, - services.ca_file_paths, - services.provider_credentials, - services.sandbox_bearer, - )?); + let backend = Arc::new( + DelegatedRuntimeBackend::new( + self.name, + self.descriptor, + self.payload, + services.ca_file_paths, + services.provider_credentials, + services.sandbox_bearer, + )? + .into_client(), + ); Ok(BuiltBackend { backend, payload, diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 1c429ebdb9..cac8eccc16 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -10,11 +10,8 @@ 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, + BoundaryConfirmation, BoundaryProperties, EnforcedProperty, OuterFenceGuarantee, + OuterFenceGuarantees, }; #[cfg(unix)] use openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs; @@ -161,7 +158,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, }, @@ -288,64 +285,115 @@ 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"); + let property = EnforcedProperty::new(true, "rpc-test"); + Response::Confirmed { + confirmation: Box::new(BoundaryConfirmation { + generation: generation.clone(), + identity: identity(), + properties: BoundaryProperties { + filesystem_confinement: property.clone(), + egress_interception: property.clone(), + request_attribution: property.clone(), + privilege_floor: property, + }, + authenticated_supervisor: !observed + .deny_confirmation + .load(Ordering::SeqCst), + session_id, + outer_fence: OuterFenceGuarantees::from_enforcement_evidence( + &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!({"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 { @@ -355,148 +403,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(); @@ -629,6 +535,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()); } @@ -645,6 +553,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()); } @@ -860,8 +769,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()); @@ -890,7 +801,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)); @@ -908,8 +819,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/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/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index a795433657..9fbc1fecab 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -1365,7 +1365,6 @@ dependencies = [ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ - "async-trait", "openshell-core", "serde", "serde_json", diff --git a/examples/isolation-backend-v8/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs index 1324e453f5..a44a0cb30f 100644 --- a/examples/isolation-backend-v8/src/lifecycle.rs +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -527,11 +527,11 @@ impl Runtime { } #[tonic::async_trait] -impl sandbox_backend_server::SandboxBackend for Arc { +impl isolation_backend_server::IsolationBackend for Arc { async fn describe( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let peer = request .into_inner() .supervisor @@ -545,7 +545,7 @@ impl sandbox_backend_server::SandboxBackend for Arc { "incompatible Sandbox Protocol peer", )); } - Ok(Response::new(DescribeSandboxBackendResponse { + Ok(Response::new(DescribeIsolationBackendResponse { backend: Some(protocol::metadata()), })) } @@ -742,7 +742,7 @@ pub async fn serve(state_dir: PathBuf) -> Result<()> { }; tonic::transport::Server::builder() .add_service( - sandbox_backend_server::SandboxBackendServer::new(runtime.clone()) + isolation_backend_server::IsolationBackendServer::new(runtime.clone()) .max_decoding_message_size(64 * 1024) .max_encoding_message_size(64 * 1024), ) diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto index 15feaef403..25bf087063 100644 --- a/proto/isolation_backend.proto +++ b/proto/isolation_backend.proto @@ -20,10 +20,10 @@ import "options.proto"; // 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 SandboxBackend { +service IsolationBackend { // Describe the backend and negotiate before opening a boundary. This call // conveys no sandbox credentials or driver descriptor. - rpc Describe(DescribeSandboxBackendRequest) returns (DescribeSandboxBackendResponse); + rpc Describe(DescribeIsolationBackendRequest) returns (DescribeIsolationBackendResponse); // Bind a driver-provisioned resource to one fresh sandbox launch. The // caller credential must match sandbox_id and generation_id. This call must @@ -52,11 +52,11 @@ service SandboxBackend { rpc ResumeBoundary(ResumeBoundaryRequest) returns (OpenBoundaryResponse); } -message DescribeSandboxBackendRequest { +message DescribeIsolationBackendRequest { openshell.extension.v1.PeerMetadata supervisor = 1; } -message DescribeSandboxBackendResponse { +message DescribeIsolationBackendResponse { openshell.extension.v1.PeerMetadata backend = 1; } diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index d494450fe4..5a29509284 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -219,6 +219,7 @@ enum BoundaryErrorKind { 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; From 2547fa22bd7744ff6c028ee177728f25edaa91f3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 00:03:00 -0700 Subject: [PATCH 16/24] refactor(proto): remove legacy isolation protocol reservations Signed-off-by: Drew Newberry --- proto/isolation_backend.proto | 6 ------ proto/sandbox_protocol.proto | 2 -- 2 files changed, 8 deletions(-) diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto index 25bf087063..0d4c4880f0 100644 --- a/proto/isolation_backend.proto +++ b/proto/isolation_backend.proto @@ -61,9 +61,6 @@ message DescribeIsolationBackendResponse { } message OpenBoundaryRequest { - reserved 6; - reserved "request_id"; - // Stable gateway-assigned sandbox identity, not a public resource name. string sandbox_id = 1; // Fresh for each launch and unchanged for retries of that launch. @@ -96,9 +93,6 @@ message OpenBoundaryResponse { } message SuspendBoundaryRequest { - reserved 4; - reserved "request_id"; - // Retries for this session and generation return the same reference or a // terminal error. string boundary_id = 1; diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 5a29509284..b8384239b5 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -27,8 +27,6 @@ service SandboxBoundary { // Process stdin closes explicitly; ending a gRPC request stream does not end // the independent response stream. Exit follows all retained process output. message BoundaryChunk { - reserved 2; - reserved "data"; oneof payload { string boundary_id = 1; RequestEnvelope request = 3; From f21b20cb23183cafa77750a071a75e2315fb11e7 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 00:20:18 -0700 Subject: [PATCH 17/24] refactor(proto): clarify Sandbox Protocol names Signed-off-by: Drew Newberry --- crates/openshell-sandbox-backend/README.md | 2 +- .../src/boundary_protocol.rs | 28 ++++---- .../src/isolation_protocol.rs | 22 +++---- .../openshell-sandbox-backend/src/protobuf.rs | 25 ++++---- .../openshell-sandbox-backend/src/runtime.rs | 52 +++++++-------- .../src/runtime/test_support.rs | 34 +++++----- .../src/runtime/tests/credential_renewal.rs | 8 +-- .../src/runtime/tests/delegated.rs | 2 +- .../src/runtime/tests/flow_control.rs | 16 ++--- .../src/runtime/tests/network_recovery.rs | 12 ++-- .../src/stream_adapter.rs | 18 +++--- .../openshell-sandbox/src/boundary_server.rs | 64 +++++++++---------- crates/openshell-server/src/storage_proto.rs | 2 +- .../isolation-backend-v8/src/lifecycle.rs | 16 ++--- examples/isolation-backend-v8/src/network.rs | 14 ++-- examples/isolation-backend-v8/src/protocol.rs | 24 +++---- proto/sandbox_protocol.proto | 16 ++--- 17 files changed, 179 insertions(+), 176 deletions(-) diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 491a9a8c53..784d116b3c 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -26,7 +26,7 @@ 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. -`SandboxBoundary.Exchange` and `.Mediate` begin with the handle from +`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 requests, responses, process I/O, network decisions, relay data, and DNS messages. diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 9dffa33f0d..2819d2e560 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -612,7 +612,7 @@ pub struct RequestEnvelope { /// 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, } @@ -620,18 +620,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, }) } @@ -639,7 +639,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 { @@ -658,14 +658,14 @@ pub fn unix_time_millis() -> Result { pub(crate) fn request_envelope_digest( request: &Request, - exec_expires_at_unix_ms: Option, + exec_admission_deadline_unix_ms: Option, ) -> Result { // 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_expires_at_unix_ms, + exec_admission_deadline_unix_ms, request: request.clone(), }; let payload = crate::protobuf::encode(&envelope)?; @@ -919,7 +919,7 @@ pub enum Response { pty: bool, }, Resized, - PortConnected, + LoopbackConnected, MediationReady, NetworkConnected { identity: BinaryIdentityWire, @@ -1591,7 +1591,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(), @@ -1652,18 +1652,18 @@ 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); + retry.exec_admission_deadline_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 = None; assert!(matches!( retry.validate_payload_digest(), Err(FrameError::PayloadDigestMismatch) @@ -1673,9 +1673,9 @@ mod tests { #[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(); } diff --git a/crates/openshell-sandbox-backend/src/isolation_protocol.rs b/crates/openshell-sandbox-backend/src/isolation_protocol.rs index d94b14e229..6786db1ab1 100644 --- a/crates/openshell-sandbox-backend/src/isolation_protocol.rs +++ b/crates/openshell-sandbox-backend/src/isolation_protocol.rs @@ -8,7 +8,7 @@ use openshell_core::proto::extension::v1::{PeerMetadata, ProtocolVersion}; use crate::backend_proto::OpenBoundaryRequest; use crate::boundary_protocol::{BoundaryConfig, SandboxRuntimeDescriptor}; -use crate::proto::{BoundaryChunk, boundary_chunk::Payload}; +use crate::proto::{SandboxFrame, sandbox_frame::Payload}; pub const SANDBOX_PROTOCOL_CAPABILITY: &str = "openshell.sandbox.protocol"; const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 1, minor: 0 }; @@ -66,21 +66,21 @@ pub fn validate_open( } #[must_use] -pub fn data_chunk(data: Vec) -> BoundaryChunk { - BoundaryChunk { +pub fn data_chunk(data: Vec) -> SandboxFrame { + SandboxFrame { payload: Some(Payload::RelayData(data)), } } #[must_use] -pub fn boundary_chunk(boundary_id: &str) -> BoundaryChunk { - BoundaryChunk { +pub fn sandbox_frame(boundary_id: &str) -> SandboxFrame { + SandboxFrame { payload: Some(Payload::BoundaryId(boundary_id.to_string())), } } /// Adapt a single typed frame for callers inspecting a local response. -pub fn chunk_data(chunk: BoundaryChunk) -> Result, tonic::Status> { +pub fn chunk_data(chunk: SandboxFrame) -> 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, @@ -88,7 +88,7 @@ pub fn chunk_data(chunk: BoundaryChunk) -> Result, tonic::Status> { Some(Payload::Stdout(_) | Payload::Stderr(_) | Payload::Exit(_)) => { crate::stream_adapter::Mode::Output } - Some(Payload::NetworkDecision(_)) => crate::stream_adapter::Mode::Decision, + Some(Payload::TcpOpenDecision(_)) => crate::stream_adapter::Mode::Decision, Some(Payload::DnsQuery(_)) => crate::stream_adapter::Mode::DnsQueries, Some(Payload::DnsResponse(_)) => crate::stream_adapter::Mode::DnsResponses, _ => crate::stream_adapter::Mode::Relay, @@ -100,9 +100,9 @@ pub fn chunk_data(chunk: BoundaryChunk) -> Result, tonic::Status> { /// A typed control request after the stream's boundary-binding frame. pub fn request_chunk( request: &crate::boundary_protocol::RequestEnvelope, -) -> Result { +) -> Result { use crate::protobuf::WireMessage; - Ok(BoundaryChunk { + Ok(SandboxFrame { payload: Some(Payload::Request(request.to_proto()?)), }) } @@ -110,9 +110,9 @@ pub fn request_chunk( /// A typed control response, preserving the request's correlation ID. pub fn response_chunk( response: &crate::boundary_protocol::ResponseEnvelope, -) -> Result { +) -> Result { use crate::protobuf::WireMessage; - Ok(BoundaryChunk { + Ok(SandboxFrame { payload: Some(Payload::Response(response.to_proto()?)), }) } diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index 0a3381c7a7..023dca3779 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -557,7 +557,10 @@ impl WireMessage for w::RequestEnvelope { }; Ok(p::RequestEnvelope { request_id: self.request_id.clone(), - exec_expires_at: self.exec_expires_at_unix_ms.map(timestamp).transpose()?, + exec_admission_deadline: self + .exec_admission_deadline_unix_ms + .map(timestamp) + .transpose()?, request: Some(request), }) } @@ -625,14 +628,14 @@ impl WireMessage for w::RequestEnvelope { // 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 expires = v.exec_expires_at.map(millis).transpose()?; + let expires = v.exec_admission_deadline.map(millis).transpose()?; if matches!(request, W::Exec { .. }) != expires.is_some() { return Err(invalid("exec deadline presence does not match operation")); } let mut envelope = Self { request_id: v.request_id, payload_digest: String::new(), - exec_expires_at_unix_ms: expires, + exec_admission_deadline_unix_ms: expires, request, }; envelope.payload_digest = w::request_envelope_digest(&envelope.request, expires)?; @@ -688,7 +691,7 @@ impl WireMessage for w::ResponseEnvelope { pty: *pty, }), W::Resized => P::Resized(p::Resized {}), - W::PortConnected => P::PortConnected(p::PortConnected {}), + W::LoopbackConnected => P::LoopbackConnected(p::LoopbackConnected {}), W::MediationReady => P::MediationReady(p::MediationReady {}), W::NetworkConnected { identity, @@ -758,7 +761,7 @@ impl WireMessage for w::ResponseEnvelope { pty: v.pty, }, P::Resized(_) => W::Resized, - P::PortConnected(_) => W::PortConnected, + P::LoopbackConnected(_) => W::LoopbackConnected, P::MediationReady(_) => W::MediationReady, P::NetworkConnected(v) => W::NetworkConnected { identity: w::BinaryIdentityWire::from_proto(required(v.identity)?)?, @@ -845,7 +848,7 @@ mod tests { let id = uuid::Uuid::new_v4().to_string(); let attach = p::RequestEnvelope { request_id: id.clone(), - exec_expires_at: None, + exec_admission_deadline: None, request: Some(p::request_envelope::Request::Attach(p::Attach { supervisor_instance_id: uuid::Uuid::new_v4().to_string(), policy: None, @@ -855,7 +858,7 @@ mod tests { assert!(w::RequestEnvelope::from_proto(attach).is_err()); let signal = p::RequestEnvelope { request_id: id, - exec_expires_at: None, + exec_admission_deadline: None, request: Some(p::request_envelope::Request::Signal(p::Signal { process_id: "main".into(), signal: 99, @@ -879,13 +882,13 @@ mod tests { }) .unwrap(); let mut value = request.to_proto().unwrap(); - value.exec_expires_at = None; + value.exec_admission_deadline = None; assert!(w::RequestEnvelope::from_proto(value).is_err()); let mut value = request.to_proto().unwrap(); - value.exec_expires_at.as_mut().unwrap().nanos = -1; + value.exec_admission_deadline.as_mut().unwrap().nanos = -1; assert!(w::RequestEnvelope::from_proto(value).is_err()); let mut value = request.to_proto().unwrap(); - value.exec_expires_at.as_mut().unwrap().seconds += 1; + value.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); @@ -895,7 +898,7 @@ mod tests { fn resize_rejects_values_outside_the_rust_contract() { let value = p::RequestEnvelope { request_id: uuid::Uuid::new_v4().to_string(), - exec_expires_at: None, + exec_admission_deadline: None, request: Some(p::request_envelope::Request::Resize(p::Resize { process_id: "main".into(), cols: 65_536, diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 131d92b8c5..7e5e7fccb3 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -14,10 +14,10 @@ use crate::backend_proto::{ DescribeIsolationBackendRequest, OpenBoundaryRequest, isolation_backend_client::IsolationBackendClient, open_boundary_response::LaunchOutcome, }; -use crate::isolation_protocol::boundary_chunk; +use crate::isolation_protocol::sandbox_frame; #[cfg(test)] use crate::isolation_protocol::{chunk_data, data_chunk}; -use crate::proto::{BoundaryChunk, sandbox_boundary_client::SandboxBoundaryClient}; +use crate::proto::{SandboxFrame, sandbox_protocol_client::SandboxProtocolClient}; use async_trait::async_trait; use hyper_util::rt::TokioIo; use openshell_isolation_interface::AgentSpec; @@ -833,8 +833,8 @@ impl BoundaryLoopbackConnector { }) .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)), } } } @@ -2214,13 +2214,13 @@ async fn open_grpc_client_stream_with_authorization( ) -> 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); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel::(64); outbound - .send(boundary_chunk(boundary_id)) + .send(sandbox_frame(boundary_id)) .await .map_err(|_| BackendError::Unavailable("queue boundary binding frame".to_string()))?; tokio::spawn(pump_to_grpc(reader, outbound)); - let mut client = SandboxBoundaryClient::new(channel) + 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 mut request = tonic::Request::new(ReceiverStream::new(outbound_rx)); @@ -2246,7 +2246,7 @@ async fn open_grpc_client_stream_with_authorization( Ok(Box::new(application)) } -async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) +async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) where R: tokio::io::AsyncRead + Unpin, { @@ -2268,7 +2268,7 @@ where } async fn pump_from_grpc( - mut stream: tonic::Streaming, + mut stream: tonic::Streaming, mut writer: W, mut mode: crate::stream_adapter::Mode, ) where @@ -2466,8 +2466,8 @@ mod tests { }; use crate::boundary_protocol::{ExitStatusWire, generate_sandbox_tls_material}; use crate::proto::{ - BoundaryChunk, - sandbox_boundary_server::{SandboxBoundary, SandboxBoundaryServer}, + SandboxFrame, + sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use openshell_core::jwt::{SecretJwt, SessionBearerTokenSlot}; use openshell_core::policy::{ @@ -2550,10 +2550,10 @@ mod tests { } } - async fn test_bind(inbound: &mut tonic::Streaming) { + async fn test_bind(inbound: &mut tonic::Streaming) { assert_eq!( inbound.message().await.unwrap().unwrap(), - boundary_chunk(TEST_BOUNDARY_HANDLE) + sandbox_frame(TEST_BOUNDARY_HANDLE) ); } @@ -2696,17 +2696,17 @@ mod tests { } type TestGrpcStream = Pin< - Box> + Send + 'static>, + Box> + Send + 'static>, >; #[tonic::async_trait] - impl SandboxBoundary for TestGrpcBoundary { + impl SandboxProtocol for TestGrpcBoundary { type ExchangeStream = TestGrpcStream; type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let authorization = request .metadata() @@ -2800,7 +2800,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, .. @@ -2835,7 +2835,7 @@ mod tests { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if self .mediation_failures @@ -2852,7 +2852,7 @@ mod tests { } } - fn test_response_chunk(bytes: Vec) -> BoundaryChunk { + fn test_response_chunk(bytes: Vec) -> SandboxFrame { let response: ResponseEnvelope = decode_frame(&bytes).unwrap(); crate::isolation_protocol::response_chunk(&response).unwrap() } @@ -2884,7 +2884,7 @@ mod tests { let (stream, _) = listener.accept().await.unwrap(); tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await .unwrap(); @@ -2957,7 +2957,7 @@ mod tests { tokio::spawn(async move { tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -3069,7 +3069,7 @@ mod tests { tokio::spawn(async move { tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -3126,7 +3126,7 @@ mod tests { let (stream, _) = listener.accept().await.unwrap(); tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(stream)])) .await .unwrap(); @@ -3323,7 +3323,7 @@ mod tests { }; tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( stream, ))])) @@ -3873,7 +3873,7 @@ mod tests { tokio::spawn(async move { tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>( TestTlsIo(Box::new(stream)), )])) @@ -3942,7 +3942,7 @@ mod tests { .unwrap(); tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( Box::new(stream), ))])) diff --git a/crates/openshell-sandbox-backend/src/runtime/test_support.rs b/crates/openshell-sandbox-backend/src/runtime/test_support.rs index 7b6541973d..34c7d4c86f 100644 --- a/crates/openshell-sandbox-backend/src/runtime/test_support.rs +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -6,8 +6,8 @@ use crate::boundary_protocol::{self as w, Request, RequestEnvelope, Response, ResponseEnvelope}; use crate::proto::{ self as p, - boundary_chunk::Payload, - sandbox_boundary_server::{SandboxBoundary, SandboxBoundaryServer}, + sandbox_frame::Payload, + sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use crate::protobuf::WireMessage as _; use crate::runtime::BoundaryClient; @@ -111,11 +111,11 @@ struct Peer { } type Stream = - Pin> + Send>>; -type Sender = mpsc::Sender>; + Pin> + Send>>; +type Sender = mpsc::Sender>; -fn chunk(payload: Payload) -> p::BoundaryChunk { - p::BoundaryChunk { +fn chunk(payload: Payload) -> p::SandboxFrame { + p::SandboxFrame { payload: Some(payload), } } @@ -137,7 +137,7 @@ impl Peer { } async fn exchange( self: Arc, - mut input: tonic::Streaming, + mut input: tonic::Streaming, out: Sender, request: RequestEnvelope, ) -> Result<(), BackendError> { @@ -264,7 +264,7 @@ impl Peer { .connect(LoopbackTarget::new(host, port)?) .await?, ); - Response::PortConnected + Response::LoopbackConnected } Request::AcceptNetwork => { let open = self @@ -300,7 +300,7 @@ impl Peer { 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::NetworkDecision(decision)) = + if let Some(Payload::TcpOpenDecision(decision)) = input.message().await.ok().flatten().and_then(|c| c.payload) { let decision = TcpOpenDecision::from_proto(decision).unwrap(); @@ -318,12 +318,12 @@ impl Peer { #[derive(Clone)] struct Service(Arc); #[tonic::async_trait] -impl SandboxBoundary for Service { +impl SandboxProtocol for Service { type ExchangeStream = Stream; type MediateStream = Stream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (out, rx) = mpsc::channel(8); let peer = self.0.clone(); @@ -348,7 +348,7 @@ impl SandboxBoundary for Service { } async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (out, rx) = mpsc::channel(8); let source = self.0.network.clone(); @@ -399,7 +399,7 @@ impl SandboxBoundary for Service { match query {Ok(q)=>next=Some(q),Err(_)=>break} }, response=input.message()=>{ - if let Ok(Some(p::BoundaryChunk{payload:Some(Payload::DnsResponse(r))}))=response { + if let Ok(Some(p::SandboxFrame{payload:Some(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); @@ -413,7 +413,7 @@ impl SandboxBoundary for Service { } } async fn read_request( - input: &mut tonic::Streaming, + input: &mut tonic::Streaming, ) -> Result { let first = input .message() @@ -480,7 +480,7 @@ async fn output(mut reader: BoundaryOutput, out: Sender, stderr: bool) { } } async fn process_io( - mut input: tonic::Streaming, + mut input: tonic::Streaming, out: Sender, stdin: Option, stdout: BoundaryOutput, @@ -550,7 +550,7 @@ async fn process_io( read.abort(); } async fn relay_io( - mut input: tonic::Streaming, + mut input: tonic::Streaming, out: Sender, stream: BoundaryDuplexStream, ) { @@ -690,7 +690,7 @@ fn client(peer: Peer) -> Arc { Service(Arc::new(Peer::default())), ), ) - .add_service(SandboxBoundaryServer::new(Service(server_peer))) + .add_service(SandboxProtocolServer::new(Service(server_peer))) .serve_with_incoming(incoming) .await .unwrap(); 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 42b5cc23f5..e8814154a1 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 SandboxBoundary for RenewingGrpcBoundary { +impl SandboxProtocol for RenewingGrpcBoundary { type ExchangeStream = TestGrpcStream; type MediateStream = TestGrpcStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { if self .failures @@ -40,7 +40,7 @@ impl SandboxBoundary for RenewingGrpcBoundary { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { self.exchange(request).await } @@ -88,7 +88,7 @@ async fn renewing_boundary_client( connections.spawn(async move { tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming( tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo(Box::new(stream)))]) .chain(tokio_stream::pending()), diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs index 55539e5a26..2ceba5752a 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs @@ -41,7 +41,7 @@ async fn external_backend( driver_descriptor: Some(OPAQUE_PAYLOAD.to_vec()), ..TestIsolationBackend::default() })) - .add_service(SandboxBoundaryServer::new(service)) + .add_service(SandboxProtocolServer::new(service)) .serve_with_incoming(tokio_stream::iter([Ok::<_, std::io::Error>(TestTlsIo( Box::new(stream), ))])) 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 38bf4f6a25..0a57300358 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs @@ -8,17 +8,17 @@ 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 SandboxBoundary for StallingPeer { +impl SandboxProtocol for StallingPeer { type ExchangeStream = 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(); @@ -52,7 +52,7 @@ impl SandboxBoundary for StallingPeer { async fn mediate( &self, - _request: tonic::Request>, + _request: tonic::Request>, ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("flow-control fixture")) } @@ -79,7 +79,7 @@ async fn start_boundary(peer: StallingPeer) -> tonic::transport::Channel { crate::boundary_protocol::BOUNDARY_CONNECTION_WINDOW_BYTES, ) .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(peer)) + .add_service(SandboxProtocolServer::new(peer)) .serve_with_incoming(incoming) .await .unwrap(); @@ -96,17 +96,17 @@ async fn start_boundary(peer: StallingPeer) -> tonic::transport::Channel { } /// 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(boundary_chunk(TEST_BOUNDARY_HANDLE)) + .send(sandbox_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 = SandboxBoundaryClient::new(channel); + let mut client = SandboxProtocolClient::new(channel); tokio::spawn(async move { let _response = client.exchange(request).await; std::future::pending::<()>().await; 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 23310045bf..199b98e43f 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -51,13 +51,13 @@ struct NetworkPeer { } #[tonic::async_trait] -impl SandboxBoundary for NetworkPeer { +impl SandboxProtocol for NetworkPeer { type ExchangeStream = 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 +80,7 @@ impl SandboxBoundary for NetworkPeer { async fn mediate( &self, - _request: tonic::Request>, + _request: tonic::Request>, ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("TCP fixture")) } @@ -89,8 +89,8 @@ impl SandboxBoundary 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(); @@ -305,7 +305,7 @@ impl Fixture { .chain(tokio_stream::pending()); let serving = tonic::transport::Server::builder() .add_service(IsolationBackendServer::new(TestIsolationBackend::default())) - .add_service(SandboxBoundaryServer::new(peer)) + .add_service(SandboxProtocolServer::new(peer)) .serve_with_incoming(incoming); tokio::select! { result = serving => result.unwrap(), diff --git a/crates/openshell-sandbox-backend/src/stream_adapter.rs b/crates/openshell-sandbox-backend/src/stream_adapter.rs index 74fa87d960..60d5cc009b 100644 --- a/crates/openshell-sandbox-backend/src/stream_adapter.rs +++ b/crates/openshell-sandbox-backend/src/stream_adapter.rs @@ -3,11 +3,11 @@ //! Private adapter for Rust `AsyncRead`/`AsyncWrite` handles. //! -//! Length/channel markers exist only on these in-process pipes; gRPC carries typed `BoundaryChunk` values. +//! Length/channel markers exist only on these in-process pipes; gRPC carries typed `SandboxFrame` 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, boundary_chunk::Payload}, + proto::{self as p, sandbox_frame::Payload}, protobuf::{self, WireMessage}, }; use std::io; @@ -29,8 +29,8 @@ pub enum Mode { fn bad(message: impl ToString) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message.to_string()) } -fn chunk(payload: Payload) -> p::BoundaryChunk { - p::BoundaryChunk { +fn chunk(payload: Payload) -> p::SandboxFrame { + p::SandboxFrame { payload: Some(payload), } } @@ -46,7 +46,7 @@ fn request_mode(v: &w::Request) -> Mode { fn response_mode(v: &w::Response) -> Mode { match v { w::Response::ProcessAttached { .. } | w::Response::ExecStarted { .. } => Mode::Output, - w::Response::NetworkConnected { .. } | w::Response::PortConnected => Mode::Relay, + w::Response::NetworkConnected { .. } | w::Response::LoopbackConnected => Mode::Relay, w::Response::MediationReady => Mode::DnsQueries, _ => Mode::Done, } @@ -71,7 +71,7 @@ fn dns_bytes(kind: m::MediationFrameKind, id: u64, payload: &[u8]) -> io::Result Ok(bytes) } /// Convert a checked typed frame to the local stream interface. -pub fn receive(mode: &mut Mode, value: p::BoundaryChunk) -> io::Result> { +pub fn receive(mode: &mut Mode, value: p::SandboxFrame) -> io::Result> { use prost::Message; if value.encoded_len() > MAX_MESSAGE_BYTES { return Err(bad("protocol message exceeds limit")); @@ -100,7 +100,7 @@ pub fn receive(mode: &mut Mode, value: p::BoundaryChunk) -> io::Result> let v = w::ExitStatusWire::from_proto(v).map_err(bad)?; stream_bytes(w::STREAM_EXIT, &protobuf::encode(&v).map_err(bad)?) } - (Mode::Decision, Payload::NetworkDecision(v)) => { + (Mode::Decision, Payload::TcpOpenDecision(v)) => { let v = openshell_isolation_interface::contract::TcpOpenDecision::from_proto(v) .map_err(bad)?; *mode = if matches!( @@ -161,7 +161,7 @@ async fn control(reader: &mut R) -> io::Result( reader: &mut R, mode: &mut Mode, -) -> io::Result> { +) -> io::Result> { let payload = match *mode { Mode::Request => { let Some(bytes) = control(reader).await? else { @@ -213,7 +213,7 @@ pub async fn send( } else { Mode::Done }; - Payload::NetworkDecision(v.to_proto().map_err(bad)?) + Payload::TcpOpenDecision(v.to_proto().map_err(bad)?) } _ => return Err(bad("invalid local stream channel")), } diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 6b8ddec2cc..cbc86b270c 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -54,11 +54,11 @@ mod linux { self, DnsQueryWire, MediationFrame, MediationFrameKind, }; #[cfg(test)] - use openshell_sandbox_backend::proto::sandbox_boundary_client::SandboxBoundaryClient; + use openshell_sandbox_backend::proto::sandbox_protocol_client::SandboxProtocolClient; use openshell_sandbox_backend::proto::{ - BoundaryChunk, - boundary_chunk::Payload, - sandbox_boundary_server::{SandboxBoundary, SandboxBoundaryServer}, + SandboxFrame, + sandbox_frame::Payload, + sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use openshell_sandbox_backend::sandbox_auth::{ SandboxAuthError, SandboxConnectionId, SandboxConnectionRegistry, @@ -622,7 +622,7 @@ mod linux { .max_encoding_message_size(64 * 1024), ) .add_service( - SandboxBoundaryServer::new(service) + SandboxProtocolServer::new(service) .max_decoding_message_size( openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES, ) @@ -851,8 +851,8 @@ mod linux { impl GrpcBoundaryService { async fn bind_stream( &self, - request: tonic::Request>, - ) -> Result<(tonic::Streaming, SandboxProtocolPrincipal), tonic::Status> + request: tonic::Request>, + ) -> Result<(tonic::Streaming, SandboxProtocolPrincipal), tonic::Status> { let principal = self .runtime @@ -887,16 +887,16 @@ mod linux { } } - type GrpcResponseStream = ReceiverStream>; + type GrpcResponseStream = ReceiverStream>; #[tonic::async_trait] - impl SandboxBoundary for GrpcBoundaryService { + impl SandboxProtocol for GrpcBoundaryService { type ExchangeStream = GrpcResponseStream; type MediateStream = GrpcResponseStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (inbound, principal) = self.bind_stream(request).await?; let (stream, response) = @@ -916,7 +916,7 @@ mod linux { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (inbound, principal) = self.bind_stream(request).await?; let (stream, response) = @@ -932,13 +932,13 @@ mod linux { } fn bridge_grpc_server_stream( - mut inbound: tonic::Streaming, + mut inbound: tonic::Streaming, connection_closed: tokio::sync::watch::Receiver<()>, ) -> (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); + 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(); @@ -1232,7 +1232,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, @@ -1311,7 +1311,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}"))?; @@ -4650,7 +4650,7 @@ mod linux { test_open_boundary(channel.clone(), test_open_request(&runtime.config), &token) .await; sender - .send(openshell_sandbox_backend::isolation_protocol::boundary_chunk(&handle)) + .send(openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle)) .await .unwrap(); let request = RequestEnvelope::new(Request::OpenMediation).unwrap(); @@ -4660,7 +4660,7 @@ mod linux { ) .await .unwrap(); - let mut response = SandboxBoundaryClient::new(channel) + let mut response = SandboxProtocolClient::new(channel) .mediate(bearer_request(ReceiverStream::new(receiver), &token)) .await .unwrap() @@ -5276,7 +5276,7 @@ mod linux { .await .unwrap(); let mut control = openshell_sandbox_backend::backend_proto::isolation_backend_client::IsolationBackendClient::new(channel.clone()); - let mut streams = SandboxBoundaryClient::new(channel.clone()); + let mut streams = SandboxProtocolClient::new(channel.clone()); let before_open = streams .exchange(bearer_request( tokio_stream::iter([data_chunk(Vec::new())]), @@ -5396,8 +5396,8 @@ mod linux { .connect() .await .unwrap(); - let binding = openshell_sandbox_backend::isolation_protocol::boundary_chunk(&handle); - let mut replacement_streams = SandboxBoundaryClient::new(replacement.clone()); + let binding = openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle); + let mut replacement_streams = SandboxProtocolClient::new(replacement.clone()); assert_eq!( replacement_streams .exchange(bearer_request( @@ -5442,10 +5442,10 @@ mod linux { for frames in [ vec![], vec![data_chunk(Vec::new())], - vec![BoundaryChunk { payload: None }], - vec![openshell_sandbox_backend::isolation_protocol::boundary_chunk("")], + vec![SandboxFrame { payload: None }], + vec![openshell_sandbox_backend::isolation_protocol::sandbox_frame("")], vec![ - openshell_sandbox_backend::isolation_protocol::boundary_chunk("other-boundary"), + openshell_sandbox_backend::isolation_protocol::sandbox_frame("other-boundary"), ], ] { let expected = if frames @@ -5475,7 +5475,7 @@ mod linux { expected ); } - let binding = openshell_sandbox_backend::isolation_protocol::boundary_chunk(&handle); + let binding = openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle); let mut response = streams .exchange(bearer_request( tokio_stream::iter([binding.clone(), binding]), @@ -5561,11 +5561,11 @@ mod linux { .expect("encode attach request"); let handle = test_open_boundary(channel.clone(), opening, &token).await; let request_stream = tokio_stream::iter([ - openshell_sandbox_backend::isolation_protocol::boundary_chunk(&handle), + openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle), openshell_sandbox_backend::isolation_protocol::request_chunk(&request) .expect("typed logical request"), ]); - let mut body = SandboxBoundaryClient::new(channel) + let mut body = SandboxProtocolClient::new(channel) .exchange(bearer_request(request_stream, &token)) .await .expect("exchange logical request") @@ -6035,7 +6035,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"); @@ -6352,7 +6352,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(); } @@ -6361,7 +6361,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"); @@ -6371,7 +6371,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"); @@ -6397,7 +6397,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(), @@ -6431,7 +6431,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-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index c3e1b86222..7490513692 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -545,7 +545,7 @@ mod tests { ); } if package == "openshell.sandbox.protocol.v1" { - assert_eq!(service_name, "SandboxBoundary"); + assert_eq!(service_name, "SandboxProtocol"); sandbox_protocol_methods.extend(service.method.iter().map(|method| { method .name diff --git a/examples/isolation-backend-v8/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs index a44a0cb30f..2d8ef058cb 100644 --- a/examples/isolation-backend-v8/src/lifecycle.rs +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -24,7 +24,7 @@ use tokio::sync::{Mutex, watch}; use tonic::{Request, Response, Status, Streaming}; type Chunks = - std::pin::Pin> + Send>>; + std::pin::Pin> + Send>>; struct State { policy: Option, ledger: HashMap, @@ -133,8 +133,8 @@ impl Runtime { } async fn route( &self, - mut request: Request>, - ) -> Result<(SandboxProtocolPrincipal, Streaming), Status> { + mut request: Request>, + ) -> Result<(SandboxProtocolPrincipal, Streaming), Status> { let principal = self.authenticate(&request)?; if !self .opened @@ -148,7 +148,7 @@ impl Runtime { } let first = request.get_mut().message().await?; if first.and_then(|f| f.payload) - != Some(boundary_chunk::Payload::BoundaryId( + != Some(sandbox_frame::Payload::BoundaryId( self.launch.boundary.generation.clone(), )) { @@ -589,18 +589,18 @@ impl isolation_backend_server::IsolationBackend for Arc { } #[tonic::async_trait] -impl sandbox_boundary_server::SandboxBoundary for Arc { +impl sandbox_protocol_server::SandboxProtocol for Arc { type ExchangeStream = Chunks; type MediateStream = Chunks; async fn mediate( &self, - request: Request>, + request: Request>, ) -> Result, Status> { self.exchange(request).await } async fn exchange( &self, - request: Request>, + request: Request>, ) -> Result, Status> { let (principal, mut input) = self.route(request).await?; let runtime = self.clone(); @@ -747,7 +747,7 @@ pub async fn serve(state_dir: PathBuf) -> Result<()> { .max_encoding_message_size(64 * 1024), ) .add_service( - sandbox_boundary_server::SandboxBoundaryServer::new(runtime.clone()) + sandbox_protocol_server::SandboxProtocolServer::new(runtime.clone()) .max_decoding_message_size( openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES, ) diff --git a/examples/isolation-backend-v8/src/network.rs b/examples/isolation-backend-v8/src/network.rs index b0bfdd510e..fee67ca7eb 100644 --- a/examples/isolation-backend-v8/src/network.rs +++ b/examples/isolation-backend-v8/src/network.rs @@ -11,7 +11,7 @@ use openshell_isolation_interface::contract::{TcpOpenDecision, TcpOpenDenial}; use tokio::sync::{mpsc, oneshot, watch}; use tonic::{Status, Streaming}; -use crate::protocol::boundary::{BoundaryChunk, boundary_chunk}; +use crate::protocol::boundary::{SandboxFrame, sandbox_frame}; pub const POLICY_DENIED: i32 = -1; pub const UNAVAILABLE: i32 = -2; @@ -103,9 +103,9 @@ fn parse_request(url: &str) -> Option<(SocketAddr, Vec)> { )) } -async fn data(input: &mut Streaming) -> Result, Status> { +async fn data(input: &mut Streaming) -> Result, Status> { match input.message().await?.and_then(|chunk| chunk.payload) { - Some(boundary_chunk::Payload::RelayData(bytes)) + Some(sandbox_frame::Payload::RelayData(bytes)) if !bytes.is_empty() && bytes.len() <= openshell_sandbox_backend::boundary_protocol::MAX_STREAM_FRAME_BYTES => @@ -116,16 +116,16 @@ async fn data(input: &mut Streaming) -> Result, Status> { } } -pub async fn decision(input: &mut Streaming) -> Result { +pub async fn decision(input: &mut Streaming) -> Result { match input.message().await?.and_then(|chunk| chunk.payload) { - Some(boundary_chunk::Payload::NetworkDecision(value)) => { - crate::protocol::network_decision(value) + Some(sandbox_frame::Payload::TcpOpenDecision(value)) => { + crate::protocol::tcp_open_decision(value) } _ => Err(Status::invalid_argument("network decision required")), } } -pub async fn http_status(input: &mut Streaming) -> Result { +pub async fn http_status(input: &mut Streaming) -> Result { let mut bytes = Vec::new(); loop { let data = data(input).await?; diff --git a/examples/isolation-backend-v8/src/protocol.rs b/examples/isolation-backend-v8/src/protocol.rs index d249c72ef1..09ed01f150 100644 --- a/examples/isolation-backend-v8/src/protocol.rs +++ b/examples/isolation-backend-v8/src/protocol.rs @@ -45,7 +45,7 @@ fn convert(value: T) -> Result, + input: &mut tonic::Streaming, ) -> Result { use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; use prost::Message; @@ -56,23 +56,23 @@ pub async fn read_request( if chunk.encoded_len() > openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES { return Err(tonic::Status::resource_exhausted("request too large")); } - let Some(boundary::boundary_chunk::Payload::Request(request)) = chunk.payload else { + let Some(boundary::sandbox_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 fn data(bytes: Vec) -> boundary::BoundaryChunk { - boundary::BoundaryChunk { - payload: Some(boundary::boundary_chunk::Payload::RelayData(bytes)), +pub fn data(bytes: Vec) -> boundary::SandboxFrame { + boundary::SandboxFrame { + payload: Some(boundary::sandbox_frame::Payload::RelayData(bytes)), } } pub fn response( request_id: String, response: openshell_sandbox_backend::boundary_protocol::Response, -) -> Result { +) -> Result { use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; let value = wire::ResponseEnvelope { request_id, @@ -80,13 +80,13 @@ pub fn response( } .to_proto() .map_err(|error| tonic::Status::internal(error.to_string()))?; - Ok(boundary::BoundaryChunk { - payload: Some(boundary::boundary_chunk::Payload::Response(convert(value)?)), + Ok(boundary::SandboxFrame { + payload: Some(boundary::sandbox_frame::Payload::Response(convert(value)?)), }) } -pub fn stream_frame(channel: u8, payload: &[u8]) -> Result { - use boundary::boundary_chunk::Payload; +pub fn stream_frame(channel: u8, payload: &[u8]) -> Result { + use boundary::sandbox_frame::Payload; use openshell_sandbox_backend::{ boundary_protocol as wire, protobuf::{self, WireMessage}, @@ -109,12 +109,12 @@ pub fn stream_frame(channel: u8, payload: &[u8]) -> Result Result { use openshell_sandbox_backend::protobuf::WireMessage; diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index b8384239b5..f4e1126928 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -17,16 +17,16 @@ import "options.proto"; // Mediate handles OpenMediation followed by correlated DNS queries/responses. // Admission remains Attach -> Confirm -> StartAgent; no untrusted instruction // may execute before StartAgent. Exec and forwarding require Running. -service SandboxBoundary { - rpc Exchange(stream BoundaryChunk) returns (stream BoundaryChunk); - rpc Mediate(stream BoundaryChunk) returns (stream BoundaryChunk); +service SandboxProtocol { + rpc Exchange(stream SandboxFrame) returns (stream SandboxFrame); + rpc Mediate(stream SandboxFrame) returns (stream SandboxFrame); } // 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 BoundaryChunk { +message SandboxFrame { oneof payload { string boundary_id = 1; RequestEnvelope request = 3; @@ -36,7 +36,7 @@ message BoundaryChunk { bytes stderr = 7; ExitStatus exit = 8; StdinClosed stdin_closed = 9; - TcpOpenDecision network_decision = 10; + TcpOpenDecision tcp_open_decision = 10; bytes relay_data = 11; DnsQuery dns_query = 12; DnsResponse dns_response = 13; @@ -57,7 +57,7 @@ message RequestEnvelope { string request_id = 1; // Required only for Exec. An absolute admission/retry deadline, not a process // runtime limit; it remains unchanged on retries and transport recovery. - google.protobuf.Timestamp exec_expires_at = 2; + google.protobuf.Timestamp exec_admission_deadline = 2; oneof request { DiscoverPolicy discover_policy = 10; Attach attach = 11; @@ -94,7 +94,7 @@ message ResponseEnvelope { BoundaryTerminated boundary_terminated = 20; ExecStarted exec_started = 21; Resized resized = 22; - PortConnected port_connected = 23; + LoopbackConnected loopback_connected = 23; MediationReady mediation_ready = 24; NetworkConnected network_connected = 25; Error error = 26; @@ -193,7 +193,7 @@ message ExecStarted { bool pty = 2; } message Resized {} -message PortConnected {} +message LoopbackConnected {} message MediationReady {} message NetworkConnected { BinaryIdentity identity = 1; From 0bd1ee0d14dffa532a30054161390aa563fdc6ad Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 00:27:12 -0700 Subject: [PATCH 18/24] docs(isolation): clarify authentication bootstrap and supervisor APIs Signed-off-by: Drew Newberry --- crates/openshell-supervisor/README.md | 6 +++--- proto/isolation_backend.proto | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index d846507991..28851914c8 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -4,17 +4,17 @@ 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 authenticated CONNECT listener settings. Shared networking starts the listener diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto index 0d4c4880f0..dc6b352610 100644 --- a/proto/isolation_backend.proto +++ b/proto/isolation_backend.proto @@ -12,6 +12,15 @@ import "options.proto"; // 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 From ae13ab8c366460d980549e96350cfe8526fcf434 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 01:20:30 -0700 Subject: [PATCH 19/24] refactor(isolation): simplify policy enforcement confirmation Signed-off-by: Drew Newberry --- .../openshell-driver-docker/src/isolation.rs | 39 ++--- .../src/isolation.rs | 41 ++--- .../openshell-driver-podman/src/isolation.rs | 44 +++--- .../openshell-driver-vm/src/isolation/mod.rs | 36 ++--- .../src/contract.rs | 140 +++--------------- .../tests/backend_conformance.rs | 71 +++------ crates/openshell-sandbox-backend/README.md | 10 +- crates/openshell-sandbox-backend/src/audit.rs | 18 +-- .../src/boundary_protocol.rs | 61 +------- .../src/delegated.rs | 6 +- .../src/isolation_protocol.rs | 2 +- .../openshell-sandbox-backend/src/protobuf.rs | 65 ++------ .../openshell-sandbox-backend/src/runtime.rs | 83 ++++------- .../src/runtime/test_support.rs | 17 +-- .../src/runtime/tests/delegated.rs | 38 +---- .../openshell-sandbox/src/boundary_server.rs | 41 ++--- .../src/backend_setup/tests.rs | 50 ++----- examples/isolation-backend-v8/src/driver.rs | 18 +-- .../isolation-backend-v8/src/lifecycle.rs | 33 ++--- proto/sandbox_protocol.proto | 43 ++---- rfc/0012-isolation-backend/README.md | 21 ++- 21 files changed, 252 insertions(+), 625 deletions(-) diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d921b8c348..7ed22e9d5f 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,30 +29,19 @@ 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) } @@ -97,7 +86,7 @@ impl DockerBoundarySpec { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } let unexpected_networks = Vec::new(); - let outer_fence = DockerOuterFenceEvidence { + let provisioning_evidence = DockerOuterFenceEvidence { container_id: &resource_claims["docker.container_id"], network_mode: "none", unexpected_networks: &unexpected_networks, @@ -119,7 +108,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 +122,7 @@ impl DockerBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - outer_fence, + provisioning_evidence, }, }) } @@ -224,13 +213,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-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/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8cb44ad9c1..2c72ce36c5 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -7,7 +7,7 @@ //! `proto/sandbox_protocol.proto`. This module contains data and validation, //! not a second trait-based backend interface. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -219,54 +219,28 @@ use crate::AgentSpec; // Lifecycle states // ============================================================================ -/// 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()); @@ -275,113 +249,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. -/// -/// `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. +/// Successful policy-enforcement confirmation for one admitted 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( diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 0d28b98ffd..166e80911a 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -16,73 +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()); + assert!(ProvisioningEvidence::from_enforcement_evidence("", b"evidence").is_err()); + assert!(ProvisioningEvidence::from_enforcement_evidence("generation-1", b"").is_err()); + assert_ne!( + fence.evidence_digest, + provisioning_evidence("generation-1", b"different-native-evidence").evidence_digest, + ); } #[test] @@ -109,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, diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 784d116b3c..abcfd1493d 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -48,7 +48,7 @@ startup instead of using a fallback path. 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 outer-fence evidence. Native evidence is +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. @@ -88,9 +88,11 @@ 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. +incomplete or foreign evidence. Successful confirmation asserts that the backend +can enforce the admitted policy; unsupported requirements fail before workload +start. Validator injection does not bypass generation, session, resource, +identity, or provisioning evidence checks. External backends may omit audit data; +they do not need to reproduce Linux mechanism measurements. Concrete platform validators belong to the implementing backend, not this shared transport library. diff --git a/crates/openshell-sandbox-backend/src/audit.rs b/crates/openshell-sandbox-backend/src/audit.rs index 1751ce5ab5..df20818676 100644 --- a/crates/openshell-sandbox-backend/src/audit.rs +++ b/crates/openshell-sandbox-backend/src/audit.rs @@ -3,14 +3,14 @@ //! Backend-selected interpretation of opaque boundary confirmation evidence. -use openshell_isolation_interface::contract::{BackendError, BoundaryProperties}; +use openshell_isolation_interface::contract::BackendError; -/// Validate measured evidence and derive the properties it actually supports. +/// Validate native measurements required by the selected backend. /// 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; + /// Returns an error when native enforcement measurements are incomplete. + fn validate(&self, evidence: &serde_json::Value) -> Result<(), BackendError>; } /// Default evidence interpreter for the Linux `OpenShell` sandbox. @@ -18,13 +18,12 @@ pub trait BoundaryAuditValidator: std::fmt::Debug + Send + Sync { pub struct LinuxBoundaryAuditValidator; impl BoundaryAuditValidator for LinuxBoundaryAuditValidator { - fn validate(&self, evidence: &serde_json::Value) -> Result { + fn validate(&self, 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()?; - Ok(audit.properties()) + audit.validate() } } @@ -52,10 +51,7 @@ 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()); + LinuxBoundaryAuditValidator.validate(&evidence).unwrap(); let mut incomplete = evidence; incomplete["no_new_privileges"] = false.into(); assert!(LinuxBoundaryAuditValidator.validate(&incomplete).is_err()); diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 2819d2e560..a683ffa97f 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -25,8 +25,7 @@ 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::{Deserialize, Serialize}; @@ -161,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. @@ -426,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 { @@ -440,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() } } @@ -494,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, @@ -522,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() } @@ -1493,24 +1456,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] @@ -1518,7 +1468,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] diff --git a/crates/openshell-sandbox-backend/src/delegated.rs b/crates/openshell-sandbox-backend/src/delegated.rs index e5ed88eeec..349141e406 100644 --- a/crates/openshell-sandbox-backend/src/delegated.rs +++ b/crates/openshell-sandbox-backend/src/delegated.rs @@ -8,7 +8,7 @@ use std::net::IpAddr; use openshell_core::SandboxSessionId; use openshell_isolation_interface::contract::{ - BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, }; use serde::{Deserialize, Serialize}; @@ -30,7 +30,7 @@ pub struct DelegatedLaunch { #[serde(default)] pub host_gateway_ip: Option, pub resource_claims: BTreeMap, - pub outer_fence: OuterFenceGuarantees, + pub provisioning_evidence: ProvisioningEvidence, } impl DelegatedLaunch { @@ -49,7 +49,7 @@ impl DelegatedLaunch { tls: self.tls, host_gateway_ip: self.host_gateway_ip, resource_claims: self.resource_claims, - outer_fence: self.outer_fence, + provisioning_evidence: self.provisioning_evidence, }; crate::runtime::validate_runtime_coordinates(&descriptor)?; Ok(descriptor) diff --git a/crates/openshell-sandbox-backend/src/isolation_protocol.rs b/crates/openshell-sandbox-backend/src/isolation_protocol.rs index 6786db1ab1..8128b81795 100644 --- a/crates/openshell-sandbox-backend/src/isolation_protocol.rs +++ b/crates/openshell-sandbox-backend/src/isolation_protocol.rs @@ -56,7 +56,7 @@ pub fn validate_open( || descriptor.session_id != config.session_id || descriptor.workload_identity != config.workload_identity || descriptor.resource_claims != config.resource_claims - || descriptor.outer_fence != config.outer_fence + || descriptor.provisioning_evidence != config.provisioning_evidence { return Err(tonic::Status::permission_denied( "driver descriptor does not match provisioned boundary", diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index 023dca3779..a722dfd6da 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -62,7 +62,6 @@ enumeration!(w::LandlockCompatibilityWire, p::LandlockCompatibility, {BestEffort 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::OuterFenceGuarantee, p::OuterFenceGuarantee, {DefaultDenyEgress, NoUnmanagedEgressPath, RevocationVerified, ControllerLossFailsClosed}); enumeration!(c::TcpOpenDenial, p::TcpOpenDenial, {PolicyDenied, IdentityUnavailable, InvalidDestination, ResourceExhausted, MediationUnavailable}); enumeration!(c::DnsTransport, p::DnsTransport, {Udp, Tcp}); fn enum_value>(value: i32) -> Result { @@ -320,12 +319,6 @@ impl WireMessage for c::TcpOpenDecision { impl WireMessage for c::BoundaryConfirmation { type Proto = p::BoundaryConfirmation; fn to_proto(&self) -> Result { - let prop = |v: &c::EnforcedProperty| { - Some(p::EnforcedProperty { - enforced: v.enforced, - mechanism: v.mechanism.clone(), - }) - }; Ok(p::BoundaryConfirmation { generation: self.generation.clone(), identity: Some(p::ResolvedWorkloadIdentity { @@ -335,40 +328,18 @@ impl WireMessage for c::BoundaryConfirmation { source: self.identity.source.clone(), resource_digest: self.identity.resource_digest.clone(), }), - properties: Some(p::BoundaryProperties { - filesystem_confinement: prop(&self.properties.filesystem_confinement), - egress_interception: prop(&self.properties.egress_interception), - request_attribution: prop(&self.properties.request_attribution), - privilege_floor: prop(&self.properties.privilege_floor), - }), - authenticated_supervisor: self.authenticated_supervisor, session_id: self.session_id.to_string(), - outer_fence: Some(p::OuterFenceGuarantees { - generation: self.outer_fence.generation.clone(), - established: self - .outer_fence - .established - .iter() - .map(|v| v.to_proto().map(|v| v as i32)) - .collect::>()?, - evidence_digest: self.outer_fence.evidence_digest.to_string(), - }), - runtime_exit_terminates_workload: self.runtime_exit_terminates_workload, resource_claims: self.resource_claims.clone(), - backend_audit: serde_json::to_vec(&self.backend_audit).map_err(invalid)?, + 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)?; - let properties = required(v.properties)?; - let fence = required(v.outer_fence)?; - let prop = |v: Option| -> Result<_, FrameError> { - let v = required(v)?; - Ok(c::EnforcedProperty { - enforced: v.enforced, - mechanism: v.mechanism, - }) - }; Ok(Self { generation: v.generation, identity: c::ResolvedWorkloadIdentity::new( @@ -379,26 +350,14 @@ impl WireMessage for c::BoundaryConfirmation { identity.resource_digest, ) .map_err(invalid)?, - properties: c::BoundaryProperties { - filesystem_confinement: prop(properties.filesystem_confinement)?, - egress_interception: prop(properties.egress_interception)?, - request_attribution: prop(properties.request_attribution)?, - privilege_floor: prop(properties.privilege_floor)?, - }, - authenticated_supervisor: v.authenticated_supervisor, session_id: parse(&v.session_id)?, - outer_fence: c::OuterFenceGuarantees { - generation: fence.generation, - established: fence - .established - .into_iter() - .map(|v| c::OuterFenceGuarantee::from_proto(enum_value(v)?)) - .collect::>()?, - evidence_digest: parse(&fence.evidence_digest)?, - }, - runtime_exit_terminates_workload: v.runtime_exit_terminates_workload, resource_claims: v.resource_claims, - backend_audit: serde_json::from_slice(&v.backend_audit).map_err(invalid)?, + 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(invalid)? + }, }) } } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 7e5e7fccb3..c8336256b3 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -161,8 +161,9 @@ impl OpenShellRuntimeBackend { } /// Shared Sandbox Protocol adapter for a trusted, endpoint-registered backend. +/// /// The remote backend validates its native evidence; this adapter validates the -/// common guarantees and immutable launch binding. +/// admitted workload identity and immutable launch binding. pub struct DelegatedRuntimeBackend(OpenShellRuntimeBackend); impl DelegatedRuntimeBackend { @@ -252,7 +253,7 @@ impl OpenShellRuntimeBackend { 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 provisioning_evidence = runtime_descriptor.provisioning_evidence.clone(); let mut client = BoundaryClient::with_backend( runtime_descriptor, self.sandbox_bearer.clone(), @@ -293,7 +294,7 @@ impl OpenShellRuntimeBackend { generation, session_id, resource_claims, - outer_fence, + provisioning_evidence, })) } } @@ -345,7 +346,7 @@ pub fn validate_runtime_coordinates( } validate_resource_claims(&runtime_descriptor.resource_claims)?; runtime_descriptor - .outer_fence + .provisioning_evidence .validate(&runtime_descriptor.generation)?; runtime_descriptor .transport @@ -405,7 +406,7 @@ pub struct BoundBoundary { 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, } impl BoundBoundary { @@ -425,21 +426,16 @@ impl BoundBoundary { 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(), )); } if let Some(validator) = &self.audit_validator { - let properties = validator.validate(&confirmation.backend_audit)?; - if confirmation.properties != properties { - return Err(BackendError::Confirm( - "sandbox confirmation properties do not match validated audit evidence" - .to_string(), - )); - } + validator.validate(&confirmation.backend_audit)?; } let client = self.client.clone(); let confirmed = ConfirmedBoundary::try_new( @@ -2655,17 +2651,10 @@ mod tests { } } - 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( "test-generation", - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], b"test-vm-fence", ) .unwrap() @@ -3260,7 +3249,7 @@ mod tests { tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - outer_fence: test_outer_fence(), + provisioning_evidence: test_provisioning_evidence(), } } @@ -3395,11 +3384,8 @@ mod tests { 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"), } @@ -3424,7 +3410,7 @@ 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, @@ -3452,7 +3438,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!( @@ -3478,32 +3464,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; } @@ -3522,7 +3491,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("")); @@ -3542,7 +3511,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()), @@ -3564,7 +3533,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()), @@ -3586,7 +3555,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"); @@ -3895,7 +3864,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)), )); @@ -4089,7 +4058,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)), ); diff --git a/crates/openshell-sandbox-backend/src/runtime/test_support.rs b/crates/openshell-sandbox-backend/src/runtime/test_support.rs index 34c7d4c86f..6757c3fe92 100644 --- a/crates/openshell-sandbox-backend/src/runtime/test_support.rs +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -16,9 +16,9 @@ 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, OuterFenceGuarantee, - OuterFenceGuarantees, PendingDnsQuery, PendingTcpOpen, ProviderEnvironmentInstallation, - ResolvedWorkloadIdentity, SandboxContext, TcpOpenDecision, + BoundaryOutput, BoundarySignal, ExecSpec, LoopbackTarget, PendingDnsQuery, PendingTcpOpen, + ProviderEnvironmentInstallation, ProvisioningEvidence, ResolvedWorkloadIdentity, + SandboxContext, TcpOpenDecision, }; use std::collections::HashMap; use std::pin::Pin; @@ -712,7 +712,8 @@ fn client(peer: Peer) -> Arc { }, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::default(), - outer_fence: OuterFenceGuarantees::from_enforcement_evidence("test", [], b"test").unwrap(), + provisioning_evidence: ProvisioningEvidence::from_enforcement_evidence("test", b"test") + .unwrap(), }; let bearer = SessionBearerTokenSlot::new( SecretJwt::parse("a".repeat(32)).unwrap(), @@ -854,14 +855,8 @@ pub fn backend( descriptor.session_id = sandbox.session_id; descriptor.generation.clone_from(&generation); descriptor.workload_identity = sandbox.identity; - descriptor.outer_fence = OuterFenceGuarantees::from_enforcement_evidence( + descriptor.provisioning_evidence = ProvisioningEvidence::from_enforcement_evidence( generation, - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], b"in-process-enforcement-evidence", ) .unwrap(); diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs index 2ceba5752a..f509150e2b 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use openshell_isolation_interface::contract::{BackendDescriptor, EnforcedProperty}; +use openshell_isolation_interface::contract::BackendDescriptor; const EXTERNAL_NAME: &str = "external-test"; const OPAQUE_PAYLOAD: &[u8] = b"external-driver-v1\0\xffresource"; @@ -80,14 +80,7 @@ async fn external_backend( fn external_confirmation() -> BoundaryConfirmation { let mut confirmation = test_confirmation(); - confirmation.backend_audit = serde_json::json!({"external_boundary": "verified-actor"}); - let property = EnforcedProperty::new(true, "external-actor-isolation"); - confirmation.properties = openshell_isolation_interface::contract::BoundaryProperties { - filesystem_confinement: property.clone(), - egress_interception: property.clone(), - request_attribution: property.clone(), - privilege_floor: property, - }; + confirmation.backend_audit = serde_json::Value::Null; confirmation } @@ -103,10 +96,7 @@ async fn attach_external(backend: DelegatedRuntimeBackend) -> Box 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_eq!( - confirmed.confirmation().backend_audit["external_boundary"], - "verified-actor" - ); + assert!(confirmed.confirmation().backend_audit.is_null()); let running = confirmed.into_boundary().start_agent().await.unwrap(); assert_eq!( running.agent().wait().await.unwrap(), @@ -118,41 +108,27 @@ async fn external_protocol_backend_runs_without_native_linux_audit() { } #[tokio::test] -async fn external_confirmation_still_requires_common_guarantees_and_launch_binding() { +async fn external_confirmation_requires_admitted_launch_binding() { for field in [ "generation", "session", "identity", - "fence", - "fence_digest", + "provisioning_evidence_digest", "claims", - "properties", - "mechanism", - "containment", - "authentication", ] { 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, - "fence" => confirmation.outer_fence.generation = "other-generation".into(), - "fence_digest" => { - confirmation.outer_fence.evidence_digest = "f".repeat(64).parse().unwrap(); + "provisioning_evidence_digest" => { + confirmation.provisioning_evidence_digest = "f".repeat(64).parse().unwrap(); } "claims" => { confirmation .resource_claims .insert("unexpected".into(), "claim".into()); } - "properties" => confirmation.properties.egress_interception.enforced = false, - "mechanism" => confirmation - .properties - .egress_interception - .mechanism - .clear(), - "containment" => confirmation.runtime_exit_terminates_workload = false, - "authentication" => confirmation.authenticated_supervisor = false, _ => unreachable!(), } let (backend, server) = external_backend(confirmation).await; diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index cbc86b270c..b32eb0dd7e 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -310,7 +310,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 { @@ -2590,17 +2590,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, }) @@ -4079,7 +4075,7 @@ mod linux { }, host_gateway_ip: None, resource_claims: config.resource_claims.clone(), - outer_fence: config.outer_fence.clone(), + provisioning_evidence: config.provisioning_evidence.clone(), }; OpenBoundaryRequest { sandbox_id: config.boundary_id.clone(), @@ -4182,7 +4178,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:?}"); @@ -4296,7 +4292,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)); @@ -4367,7 +4363,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(), @@ -4809,17 +4805,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() @@ -4968,7 +4957,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(), }; @@ -5190,7 +5179,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 [ @@ -5237,7 +5226,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(), }; @@ -5522,7 +5511,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(), @@ -5829,7 +5818,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(), @@ -6164,7 +6153,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(), diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index cac8eccc16..1025860f7e 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -9,10 +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, BoundaryProperties, EnforcedProperty, OuterFenceGuarantee, - OuterFenceGuarantees, -}; +use openshell_isolation_interface::contract::{BoundaryConfirmation, ProvisioningEvidence}; #[cfg(unix)] use openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs; @@ -61,14 +58,8 @@ fn registered_backend_selects_operator_endpoint_and_preserves_opaque_payload() { }, host_gateway_ip: None, resource_claims: BTreeMap::new(), - outer_fence: OuterFenceGuarantees::from_enforcement_evidence( + provisioning_evidence: ProvisioningEvidence::from_enforcement_evidence( "generation-1", - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], b"driver-fence", ) .unwrap(), @@ -80,7 +71,7 @@ fn registered_backend_selects_operator_endpoint_and_preserves_opaque_payload() { match field { "name" => invalid.backend_name = "other".into(), "tls" => invalid.tls.trust_anchor_pem.clear(), - "fence" => invalid.outer_fence.generation = "other".into(), + "fence" => invalid.provisioning_evidence.generation = "other".into(), "identity" => invalid.workload_identity.uid = 0, _ => unreachable!(), } @@ -311,32 +302,22 @@ impl PreparedBackend for TestLaunch { } Request::Confirm => { observed.record("confirm"); - let property = EnforcedProperty::new(true, "rpc-test"); + 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(), - properties: BoundaryProperties { - filesystem_confinement: property.clone(), - egress_interception: property.clone(), - request_attribution: property.clone(), - privilege_floor: property, - }, - authenticated_supervisor: !observed - .deny_confirmation - .load(Ordering::SeqCst), session_id, - outer_fence: OuterFenceGuarantees::from_enforcement_evidence( - &generation, - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], - b"in-process-enforcement-evidence", - )?, - runtime_exit_terminates_workload: true, + 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}), }), @@ -586,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(), diff --git a/examples/isolation-backend-v8/src/driver.rs b/examples/isolation-backend-v8/src/driver.rs index 49386b3f85..0c3e89ba35 100644 --- a/examples/isolation-backend-v8/src/driver.rs +++ b/examples/isolation-backend-v8/src/driver.rs @@ -13,7 +13,7 @@ use openshell_core::{ jwt::SandboxLaunchAuthentication, proto::compute::v1::*, }; -use openshell_isolation_interface::contract::{OuterFenceGuarantee, OuterFenceGuarantees}; +use openshell_isolation_interface::contract::ProvisioningEvidence; use openshell_sandbox_backend::{ boundary_protocol::{SandboxTlsClientConfig, generate_sandbox_tls_material}, delegated::DelegatedLaunch, @@ -125,7 +125,11 @@ impl Driver { }), fence_evidence: Some(DriverFenceEvidence { generation: entry.boundary.generation.clone(), - evidence_digest: entry.boundary.outer_fence.evidence_digest.to_string(), + evidence_digest: entry + .boundary + .provisioning_evidence + .evidence_digest + .to_string(), resource_claims: Default::default(), }), ..Default::default() @@ -167,14 +171,8 @@ impl Driver { 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 = OuterFenceGuarantees::from_enforcement_evidence( + let fence = ProvisioningEvidence::from_enforcement_evidence( auth.supervisor.runtime_generation.to_string(), - [ - OuterFenceGuarantee::DefaultDenyEgress, - OuterFenceGuarantee::NoUnmanagedEgressPath, - OuterFenceGuarantee::RevocationVerified, - OuterFenceGuarantee::ControllerLossFailsClosed, - ], &evidence, ) .map_err(internal)?; @@ -191,7 +189,7 @@ impl Driver { }, host_gateway_ip: None, resource_claims: BTreeMap::new(), - outer_fence: fence, + provisioning_evidence: fence, }; let launch = Launch { boundary: boundary.clone(), diff --git a/examples/isolation-backend-v8/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs index 2d8ef058cb..978e5e152a 100644 --- a/examples/isolation-backend-v8/src/lifecycle.rs +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -9,9 +9,7 @@ use crate::{ }; use anyhow::{Result, ensure}; use openshell_core::jwt::{SandboxId, SessionJwtVerifier, SessionTokenProfile, SystemJwtClock}; -use openshell_isolation_interface::contract::{ - BoundaryConfirmation, BoundaryProperties, EnforcedProperty, -}; +use openshell_isolation_interface::contract::BoundaryConfirmation; use openshell_sandbox_backend::{boundary_protocol as wire, sandbox_auth::*}; use sha2::{Digest, Sha256}; use std::{ @@ -65,21 +63,13 @@ impl Runtime { launch.boundary.workload_identity == crate::launch::host_identity(digest.clone())?, "backend identity differs from the admitted host identity" ); - use openshell_isolation_interface::contract::{ - OuterFenceGuarantee as G, OuterFenceGuarantees, - }; - let fence = OuterFenceGuarantees::from_enforcement_evidence( + use openshell_isolation_interface::contract::ProvisioningEvidence; + let fence = ProvisioningEvidence::from_enforcement_evidence( launch.boundary.generation.clone(), - [ - G::DefaultDenyEgress, - G::NoUnmanagedEgressPath, - G::RevocationVerified, - G::ControllerLossFailsClosed, - ], &crate::launch::capability_evidence(&digest), )?; ensure!( - fence == launch.boundary.outer_fence, + fence == launch.boundary.provisioning_evidence, "native V8 capability evidence differs from driver fence" ); let verifier = SessionJwtVerifier::new( @@ -333,22 +323,17 @@ impl Runtime { self.registry .confirm(principal) .map_err(|e| Status::failed_precondition(e.to_string()))?; - let property = || EnforcedProperty::new(true, "v8-isolate-capabilities"); 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(), - outer_fence: self.launch.boundary.outer_fence.clone(), - authenticated_supervisor: true, - runtime_exit_terminates_workload: true, - properties: BoundaryProperties { - filesystem_confinement: property(), - egress_interception: property(), - request_attribution: property(), - privilege_floor: property(), - }, + 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": "supervisor-http-host-call", "filesystem": "no-host-access"}), }), } diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index f4e1126928..8b4a8e3d3e 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -106,6 +106,12 @@ message Attach { SandboxPolicy policy = 2; map resource_claims = 3; } +// Success asserts that the backend can enforce the attached policy and trusted +// workload identity. Unsupported requirements fail before StartAgent. No +// untrusted instruction may execute until StartAgent installs all launch-time +// controls. 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 {} message ProbeProviderFiles {} message StartAgent { @@ -293,41 +299,20 @@ message ResolvedWorkloadIdentity { string source = 4; string resource_digest = 5; } -message EnforcedProperty { - bool enforced = 1; - string mechanism = 2; -} -message BoundaryProperties { - // Every property is required and must be enforced before confirmation. - EnforcedProperty filesystem_confinement = 1; - EnforcedProperty egress_interception = 2; - EnforcedProperty request_attribution = 3; - EnforcedProperty privilege_floor = 4; -} -message OuterFenceGuarantees { - string generation = 1; - repeated OuterFenceGuarantee established = 2; - string evidence_digest = 3; -} -enum OuterFenceGuarantee { - OUTER_FENCE_GUARANTEE_UNSPECIFIED = 0; - OUTER_FENCE_GUARANTEE_DEFAULT_DENY_EGRESS = 1; - OUTER_FENCE_GUARANTEE_NO_UNMANAGED_EGRESS_PATH = 2; - OUTER_FENCE_GUARANTEE_REVOCATION_VERIFIED = 3; - OUTER_FENCE_GUARANTEE_CONTROLLER_LOSS_FAILS_CLOSED = 4; -} +// 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; - BoundaryProperties properties = 3; - bool authenticated_supervisor = 4; string session_id = 5; - OuterFenceGuarantees outer_fence = 6; - bool runtime_exit_terminates_workload = 7; map resource_claims = 8; - // Opaque backend-owned audit document. The selected backend validates its - // schema; common properties above are typed and checked by the supervisor. + // Optional opaque backend-owned audit document. 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; diff --git a/rfc/0012-isolation-backend/README.md b/rfc/0012-isolation-backend/README.md index b384c13e5e..9ab968b1b3 100644 --- a/rfc/0012-isolation-backend/README.md +++ b/rfc/0012-isolation-backend/README.md @@ -93,9 +93,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 +210,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. From 890a758e8ebf112e509cffe32689f5619a5bdb33 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 01:22:19 -0700 Subject: [PATCH 20/24] fix(sandbox): rotate launch generations and stabilize retry sessions Signed-off-by: Drew Newberry --- crates/openshell-server/README.md | 9 + .../openshell-server/src/auth/sandbox_jwt.rs | 65 +++- crates/openshell-server/src/compute/mod.rs | 318 +++++++++++++----- .../src/compute/tests/error_recovery.rs | 2 +- 4 files changed, 305 insertions(+), 89 deletions(-) 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/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 987281121e..0e91d84044 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -2061,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 @@ -2087,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, @@ -4406,7 +4396,6 @@ impl ComputeRuntime { let sandbox_name = current.object_name().to_string(); self.validate_isolation_backend_binding(¤t) .map_err(|error| error.to_string())?; - let authority = self.restart_authority.get().and_then(Option::as_deref); let already_claimed = current .status .as_ref() @@ -4414,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() @@ -7398,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( @@ -8344,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, @@ -8388,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()), @@ -8699,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") @@ -12443,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!( + crate::auth::sandbox_session::authorize_persisted( + &runtime.store, + &previous_principal + ) + .await + .unwrap_err() + .code(), + Code::Unauthenticated, + ); assert_eq!( - authentication.supervisor.auth_epoch, - stored_identity.auth_epoch + 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!( - authentication.supervisor.runtime_generation, - stored_identity.runtime_generation + 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] @@ -17814,6 +17923,10 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" 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()); @@ -17827,6 +17940,7 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" 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) @@ -17841,6 +17955,32 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" 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 @@ -17857,6 +17997,10 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" 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!( From 7bf776fc751373071b0051d3fe5dd274b73ce5df Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 01:41:57 -0700 Subject: [PATCH 21/24] refactor(sandbox): remove redundant backend adapters and TCP flags Signed-off-by: Drew Newberry --- .../src/contract.rs | 5 +- .../tests/backend_conformance.rs | 4 - crates/openshell-sandbox-backend/README.md | 28 +-- crates/openshell-sandbox-backend/src/audit.rs | 37 +-- crates/openshell-sandbox-backend/src/lib.rs | 7 +- .../openshell-sandbox-backend/src/runtime.rs | 88 +------ .../src/runtime/test_support.rs | 2 +- .../src/runtime/tests/delegated.rs | 12 +- crates/openshell-supervisor/README.md | 2 +- .../openshell-supervisor/src/backend_setup.rs | 2 +- .../src/backend_setup/registration.rs | 23 +- crates/openshell-supervisor/src/lib.rs | 225 +----------------- rfc/0012-isolation-backend/README.md | 18 +- skills/debug-openshell-cluster/SKILL.md | 2 +- 14 files changed, 87 insertions(+), 368 deletions(-) diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 2c72ce36c5..b16dfdd724 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -32,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. @@ -76,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, @@ -90,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}"), diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 166e80911a..e34e6215bd 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -104,10 +104,6 @@ fn error_kinds_map_to_supervisor_status_classes() { 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 diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index abcfd1493d..6f3793e978 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -44,8 +44,8 @@ startup instead of using a fallback path. ## Endpoint-registered backends -`DelegatedRuntimeBackend` uses the same client and lifecycle handles for an -operator-registered backend. Trusted endpoint registration and `DelegatedLaunch` +`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 @@ -86,21 +86,15 @@ 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. Successful confirmation asserts that the backend -can enforce the admitted policy; unsupported requirements fail before workload -start. Validator injection does not bypass generation, session, resource, -identity, or provisioning evidence checks. External backends may omit audit data; -they do not need to reproduce Linux mechanism measurements. - -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. 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 RPC contracts. +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/src/audit.rs b/crates/openshell-sandbox-backend/src/audit.rs index df20818676..c94b92d49a 100644 --- a/crates/openshell-sandbox-backend/src/audit.rs +++ b/crates/openshell-sandbox-backend/src/audit.rs @@ -1,35 +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; -/// Validate native measurements required by the selected backend. -/// Implementations must reject incomplete evidence and unsupported formats. -pub trait BoundaryAuditValidator: std::fmt::Debug + Send + Sync { - /// # Errors - /// Returns an error when native enforcement measurements are incomplete. - fn validate(&self, evidence: &serde_json::Value) -> Result<(), BackendError>; -} - -/// 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<(), 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() - } +/// 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() { @@ -51,17 +38,17 @@ mod tests { "tcp_allow_round_trip": true, "tcp_deny_round_trip": true, "socket_loopback_confinement": true }); - LinuxBoundaryAuditValidator.validate(&evidence).unwrap(); + 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/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index a4e207fc49..dd25b36f4b 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -10,7 +10,7 @@ //! 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; @@ -23,9 +23,8 @@ pub mod stream_adapter; pub use openshell_core::proto::sandbox::backend::v1 as backend_proto; pub use runtime::{ BoundBoundary, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, - BoundaryTransportConnector, ConfirmedBoundary, DelegatedRuntimeBackend, ExecSession, - NetworkMediationSource, OpenShellRuntimeBackend, ProcessAttachment, ReadyBoundary, - RunningBoundary, + ConfirmedBoundary, ExecSession, NetworkMediationSource, OpenShellRuntimeBackend, + ProcessAttachment, ReadyBoundary, RunningBoundary, }; /// Stable isolation backend name implemented by `openshell-sandbox`. diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index c8336256b3..416873c6f9 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -69,10 +69,10 @@ 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, @@ -83,7 +83,6 @@ pub trait BoundaryTransportConnector: std::fmt::Debug + Send + Sync { #[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, @@ -112,17 +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; - client.discover_policy().await + BoundaryClient::new(descriptor, bearer) + .discover_policy() + .await } pub fn new( @@ -132,7 +123,6 @@ impl OpenShellRuntimeBackend { ) -> Self { Self { connector: None, - audit_validator: Arc::new(crate::audit::LinuxBoundaryAuditValidator), ca_file_paths, provider_credentials, sandbox_bearer, @@ -140,38 +130,8 @@ impl OpenShellRuntimeBackend { } } - #[must_use] - pub fn with_transport_connector( - mut self, - connector: Arc, - ) -> Self { - self.connector = Some(connector); - self - } - - /// 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 - } -} - -/// Shared Sandbox Protocol adapter for a trusted, endpoint-registered backend. -/// -/// The remote backend validates its native evidence; this adapter validates the -/// admitted workload identity and immutable launch binding. -pub struct DelegatedRuntimeBackend(OpenShellRuntimeBackend); - -impl DelegatedRuntimeBackend { - pub fn into_client(self) -> OpenShellRuntimeBackend { - self.0 - } - - pub fn new( + /// Construct the same RPC client for an operator-registered backend. + pub fn registered( backend_name: String, descriptor: SandboxRuntimeDescriptor, driver_descriptor: Vec, @@ -181,17 +141,17 @@ impl DelegatedRuntimeBackend { ) -> Result { crate::delegated::validate_backend_name(&backend_name)?; validate_runtime_coordinates(&descriptor)?; - let mut backend = - OpenShellRuntimeBackend::new(ca_file_paths, provider_credentials, sandbox_bearer); + let mut backend = Self::new(ca_file_paths, provider_credentials, sandbox_bearer); backend.delegated = Some(DelegatedRuntime { backend_name, descriptor, driver_descriptor, }); - Ok(Self(backend)) + Ok(backend) } - pub async fn discover_policy( + /// Discover policy using the registered endpoint and opaque driver payload. + pub async fn discover_registered_policy( backend_name: String, descriptor: SandboxRuntimeDescriptor, driver_descriptor: Vec, @@ -203,23 +163,7 @@ impl DelegatedRuntimeBackend { BoundaryClient::with_backend(descriptor, bearer, backend_name, driver_descriptor); client.discover_policy().await } -} - -impl DelegatedRuntimeBackend { - pub fn backend_name(&self) -> &str { - self.0.backend_name() - } - - pub async fn attach( - &self, - descriptor: BackendDescriptor, - sandbox: SandboxContext, - ) -> Result, BackendError> { - self.0.attach(descriptor, sandbox).await - } -} -impl OpenShellRuntimeBackend { pub fn backend_name(&self) -> &str { self.delegated .as_ref() @@ -278,10 +222,6 @@ impl OpenShellRuntimeBackend { )); } Ok(Box::new(BoundBoundary { - audit_validator: self - .delegated - .is_none() - .then(|| self.audit_validator.clone()), client: client.clone(), agent: sandbox.agent, policy: sandbox.policy, @@ -393,7 +333,6 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result>, client: Arc, agent: AgentSpec, policy: openshell_core::policy::SandboxPolicy, @@ -434,8 +373,8 @@ impl BoundBoundary { .to_string(), )); } - if let Some(validator) = &self.audit_validator { - validator.validate(&confirmation.backend_audit)?; + 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( @@ -3417,7 +3356,6 @@ mod tests { test_bearer(&expected_token), )); let bound = BoundBoundary { - audit_validator: Some(Arc::new(crate::audit::LinuxBoundaryAuditValidator)), client: client.clone(), agent: context.agent, policy: context.policy, diff --git a/crates/openshell-sandbox-backend/src/runtime/test_support.rs b/crates/openshell-sandbox-backend/src/runtime/test_support.rs index 6757c3fe92..c341b18548 100644 --- a/crates/openshell-sandbox-backend/src/runtime/test_support.rs +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -641,7 +641,7 @@ impl Drop for Connector { } } #[async_trait] -impl crate::BoundaryTransportConnector for Connector { +impl super::BoundaryTransportConnector for Connector { async fn connect( &self, _: &w::SandboxRuntimeDescriptor, diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs index f509150e2b..86af9ab75e 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/delegated.rs @@ -9,7 +9,7 @@ const OPAQUE_PAYLOAD: &[u8] = b"external-driver-v1\0\xffresource"; async fn external_backend( confirmation: BoundaryConfirmation, -) -> (DelegatedRuntimeBackend, tokio::task::JoinHandle<()>) { +) -> (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(); @@ -53,7 +53,7 @@ async fn external_backend( let descriptor = tls_runtime_descriptor(address, client_tls); let bearer = test_bearer(&"a".repeat(32)); assert_eq!( - DelegatedRuntimeBackend::discover_policy( + OpenShellRuntimeBackend::discover_registered_policy( EXTERNAL_NAME.into(), descriptor.clone(), OPAQUE_PAYLOAD.to_vec(), @@ -63,7 +63,7 @@ async fn external_backend( .unwrap(), (None, false) ); - let backend = DelegatedRuntimeBackend::new( + let backend = OpenShellRuntimeBackend::registered( EXTERNAL_NAME.into(), descriptor, OPAQUE_PAYLOAD.to_vec(), @@ -84,7 +84,7 @@ fn external_confirmation() -> BoundaryConfirmation { confirmation } -async fn attach_external(backend: DelegatedRuntimeBackend) -> Box { +async fn attach_external(backend: OpenShellRuntimeBackend) -> Box { let descriptor = BackendDescriptor { backend_name: EXTERNAL_NAME.into(), payload: OPAQUE_PAYLOAD.to_vec(), @@ -148,7 +148,7 @@ 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!( - DelegatedRuntimeBackend::new( + OpenShellRuntimeBackend::registered( crate::BACKEND_NAME.into(), descriptor.clone(), OPAQUE_PAYLOAD.to_vec(), @@ -162,7 +162,7 @@ async fn delegated_backend_rejects_reserved_name_and_changed_payload_before_io() Err(BackendError::Descriptor(_)) )); assert!(matches!( - DelegatedRuntimeBackend::discover_policy( + OpenShellRuntimeBackend::discover_registered_policy( crate::BACKEND_NAME.into(), descriptor, OPAQUE_PAYLOAD.to_vec(), diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index 28851914c8..753e381423 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -16,7 +16,7 @@ The client receives the supervisor's live provider state, bearer-token slot, and `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 4c6e541b03..fbf770b905 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -40,7 +40,7 @@ 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, diff --git a/crates/openshell-supervisor/src/backend_setup/registration.rs b/crates/openshell-supervisor/src/backend_setup/registration.rs index cce42c30c6..b27ec6cf19 100644 --- a/crates/openshell-supervisor/src/backend_setup/registration.rs +++ b/crates/openshell-supervisor/src/backend_setup/registration.rs @@ -4,7 +4,7 @@ //! Operator-owned endpoint registration; launch payloads never select a route. pub use openshell_core::isolation_registration::BackendRegistration; -use openshell_sandbox_backend::DelegatedRuntimeBackend; +use openshell_sandbox_backend::OpenShellRuntimeBackend; use openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor; use openshell_sandbox_backend::delegated::DelegatedLaunch; use serde::Deserialize; @@ -100,7 +100,7 @@ impl PreparedBackend for RegisteredLaunch { &self, bearer: SessionBearerTokenSlot, ) -> std::result::Result<(Option, bool), BackendError> { - DelegatedRuntimeBackend::discover_policy( + OpenShellRuntimeBackend::discover_registered_policy( self.name.clone(), self.descriptor.clone(), self.payload.clone(), @@ -114,17 +114,14 @@ impl PreparedBackend for RegisteredLaunch { services: BackendServices, ) -> std::result::Result { let payload = self.payload.clone(); - let backend = Arc::new( - DelegatedRuntimeBackend::new( - self.name, - self.descriptor, - self.payload, - services.ca_file_paths, - services.provider_credentials, - services.sandbox_bearer, - )? - .into_client(), - ); + 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, diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 9b42c50e8f..439e49a18c 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -1013,8 +1013,6 @@ pub 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) @@ -1269,10 +1267,6 @@ pub 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, @@ -3028,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, } @@ -3043,9 +3035,6 @@ impl GatewayRuntimeReloadError { fn class(&self) -> GatewayRuntimeFailureClass { match self { Self::PolicyValidation(_) => GatewayRuntimeFailureClass::PolicyValidation, - Self::TransparentTcpPrerequisite(_) => { - GatewayRuntimeFailureClass::TransparentTcpPrerequisite - } Self::MiddlewareRegistry(_) => GatewayRuntimeFailureClass::MiddlewareRegistry, } } @@ -3081,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( @@ -3100,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 { @@ -3111,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)( @@ -3163,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). /// @@ -3798,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. @@ -3994,10 +3939,6 @@ enum GatewayRuntimeFailureDisposition { MiddlewareUnavailable { error: String, }, - TransparentTcpExpansionRejected { - error: String, - active_generation: u64, - }, } fn apply_gateway_runtime_reload_failure( @@ -4019,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(), @@ -4033,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, @@ -4699,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() { @@ -4878,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); @@ -5998,7 +5888,6 @@ network_policies: registry_changed: false, connector: &default_middleware_connector(), }, - TransparentTcpReloadState::default(), Some(VmPolicyIdentity { uid: 1000, gid: 1001, @@ -8248,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, @@ -8327,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, @@ -8342,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), @@ -8350,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(); @@ -8362,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(); } @@ -8804,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(_) @@ -8836,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), @@ -9015,7 +8885,6 @@ network_policies: registry_changed: true, connector: &default_middleware_connector(), }, - TransparentTcpReloadState::default(), ) .await .expect_err("unavailable middleware must fail candidate preparation"); @@ -9036,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/rfc/0012-isolation-backend/README.md b/rfc/0012-isolation-backend/README.md index 9ab968b1b3..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. @@ -403,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 382c52251e..bb1379efdf 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -333,7 +333,7 @@ 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. From fbf18ff09e8e329d5654eb14b9d0add0f38eea2e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 02:05:40 -0700 Subject: [PATCH 22/24] fix(sandbox): repair Docker restarts and macOS lint Signed-off-by: Drew Newberry --- .../openshell-driver-docker/src/isolation.rs | 22 ++-- crates/openshell-driver-docker/src/lib.rs | 11 ++ crates/openshell-driver-docker/src/tests.rs | 111 ++++++++++++++++++ crates/openshell-sandbox/src/lib.rs | 1 + 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 7ed22e9d5f..a817ec484b 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -47,6 +47,19 @@ impl DockerOuterFenceEvidence<'_> { } } +/// 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, @@ -85,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 provisioning_evidence = 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(), 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 15f4f3414a..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 diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index a63a67434e..8ca1738dd7 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -76,4 +76,5 @@ pub fn run( .map_err(|error| miette::miette!(error)) } +#[cfg(target_os = "linux")] mod native_api; From 02df538f36529ef11cf4da19f59c745bb97cd26b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 10:42:38 -0700 Subject: [PATCH 23/24] refactor(isolation): simplify RPC contracts and backend registration Signed-off-by: Drew Newberry --- Cargo.lock | 2 + .../openshell-core/src/extension_protocol.rs | 4 +- .../src/isolation_registration.rs | 20 +- crates/openshell-sandbox-backend/Cargo.toml | 2 + crates/openshell-sandbox-backend/README.md | 11 +- .../src/boundary_protocol.rs | 40 +-- .../src/isolation_protocol.rs | 27 +- .../openshell-sandbox-backend/src/protobuf.rs | 63 ++-- .../openshell-sandbox-backend/src/runtime.rs | 305 +++++++++--------- .../src/runtime/test_support.rs | 94 +++--- .../src/runtime/tests/credential_renewal.rs | 11 +- .../src/runtime/tests/flow_control.rs | 12 +- .../src/runtime/tests/network_recovery.rs | 19 +- .../src/runtime/tests/protocol_errors.rs | 161 +++++++++ .../src/sandbox_auth.rs | 18 ++ .../src/stream_adapter.rs | 277 ++++++++++++---- .../openshell-sandbox/src/boundary_server.rs | 246 +++++++------- crates/openshell-server/src/compute/mod.rs | 23 +- crates/openshell-server/src/test_support.rs | 6 +- docs/extensibility/isolation-backends.mdx | 58 ++-- examples/isolation-backend-v8/Cargo.lock | 60 ++-- examples/isolation-backend-v8/Cargo.toml | 4 +- examples/isolation-backend-v8/README.md | 87 ++--- examples/isolation-backend-v8/guest.js | 13 +- examples/isolation-backend-v8/src/driver.rs | 19 +- examples/isolation-backend-v8/src/guest.rs | 77 ++--- examples/isolation-backend-v8/src/launch.rs | 2 +- examples/isolation-backend-v8/src/lib.rs | 1 - .../isolation-backend-v8/src/lifecycle.rs | 143 +++----- examples/isolation-backend-v8/src/main.rs | 18 +- examples/isolation-backend-v8/src/network.rs | 159 --------- examples/isolation-backend-v8/src/protocol.rs | 64 ++-- examples/isolation-backend-v8/start.sh | 72 ++--- proto/compute_driver.proto | 16 +- proto/isolation_backend.proto | 39 ++- proto/sandbox_protocol.proto | 66 ++-- 36 files changed, 1165 insertions(+), 1074 deletions(-) create mode 100644 crates/openshell-sandbox-backend/src/runtime/tests/protocol_errors.rs delete mode 100644 examples/isolation-backend-v8/src/network.rs diff --git a/Cargo.lock b/Cargo.lock index e9d14c15c5..f9c022162b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4840,6 +4840,7 @@ name = "openshell-sandbox-backend" version = "0.0.0" dependencies = [ "async-trait", + "bytes", "hyper-util", "libc", "openshell-core", @@ -4858,6 +4859,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", + "tokio-util", "tonic", "tonic-prost", "tower", diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index b35c0eceef..9de0b05187 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -20,7 +20,7 @@ pub const PROTOCOL_MINOR: u32 = 0; pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication"; /// Driver stages operator-selected external isolation routes for the supervisor. -pub const COMPUTE_SANDBOX_BACKEND: &str = "openshell.compute.sandbox-backend"; +pub const COMPUTE_ISOLATION_BACKEND: &str = "openshell.compute.isolation-backend"; const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; @@ -124,7 +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_SANDBOX_BACKEND.to_string()); + supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string()); } PeerMetadata { protocol_version: Some(ProtocolVersion { diff --git a/crates/openshell-core/src/isolation_registration.rs b/crates/openshell-core/src/isolation_registration.rs index 4fc8b56c53..1633dbb742 100644 --- a/crates/openshell-core/src/isolation_registration.rs +++ b/crates/openshell-core/src/isolation_registration.rs @@ -90,13 +90,13 @@ pub fn validate_registrations(registrations: &[BackendRegistration]) -> Result<( Ok(()) } -impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::SandboxBackendRegistration { +impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::IsolationBackendRegistration { type Error = &'static str; fn try_from(registration: &BackendRegistration) -> Result { use crate::proto::compute::v1::{ - SandboxBackendTcpEndpoint, SandboxBackendVsockEndpoint, - sandbox_backend_registration::Endpoint, + IsolationBackendTcpEndpoint, IsolationBackendVsockEndpoint, + isolation_backend_registration::Endpoint, }; validate_backend_name(®istration.name)?; registration.endpoint.validate()?; @@ -110,12 +110,12 @@ impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::SandboxBackend SandboxTransport::Tcp { authority, addresses, - } => Endpoint::Tcp(SandboxBackendTcpEndpoint { + } => Endpoint::Tcp(IsolationBackendTcpEndpoint { authority: authority.clone(), addresses: addresses.iter().map(ToString::to_string).collect(), }), SandboxTransport::Vsock { guest_cid, port } => { - Endpoint::Vsock(SandboxBackendVsockEndpoint { + Endpoint::Vsock(IsolationBackendVsockEndpoint { guest_cid: *guest_cid, port: *port, }) @@ -128,13 +128,13 @@ impl TryFrom<&BackendRegistration> for crate::proto::compute::v1::SandboxBackend } } -impl TryFrom<&crate::proto::compute::v1::SandboxBackendRegistration> for BackendRegistration { +impl TryFrom<&crate::proto::compute::v1::IsolationBackendRegistration> for BackendRegistration { type Error = &'static str; fn try_from( - registration: &crate::proto::compute::v1::SandboxBackendRegistration, + registration: &crate::proto::compute::v1::IsolationBackendRegistration, ) -> Result { - use crate::proto::compute::v1::sandbox_backend_registration::Endpoint; + use crate::proto::compute::v1::isolation_backend_registration::Endpoint; let endpoint = match registration .endpoint .as_ref() @@ -172,7 +172,7 @@ impl TryFrom<&crate::proto::compute::v1::SandboxBackendRegistration> for Backend #[cfg(test)] mod tests { use super::*; - use crate::proto::compute::v1::SandboxBackendRegistration; + use crate::proto::compute::v1::IsolationBackendRegistration; #[test] fn operator_routes_survive_compute_protocol_and_supervisor_json() { @@ -196,7 +196,7 @@ mod tests { name: "agent-substrate".into(), endpoint, }; - let wire = SandboxBackendRegistration::try_from(&original).unwrap(); + 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(); diff --git a/crates/openshell-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index ac0032b824..1f5908662d 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -17,6 +17,7 @@ test-support = [] 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 } @@ -29,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 } diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 6f3793e978..534c4c4589 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -29,7 +29,8 @@ 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 -requests, responses, process I/O, network decisions, relay data, and DNS messages. +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. @@ -39,6 +40,11 @@ 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. @@ -89,7 +95,8 @@ Sandbox Protocol. 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. Successful confirmation asserts that the backend can enforce the +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. diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index a683ffa97f..4bbcaca879 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -654,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, @@ -697,10 +693,6 @@ pub enum Request { Exec { spec: ExecSpecWire, }, - ExecSignal { - process_id: String, - signal: SignalWire, - }, Resize { process_id: String, cols: u16, @@ -731,7 +723,6 @@ impl Request { | Self::Signal { .. } | Self::Terminate { .. } | Self::TerminateBoundary - | Self::ExecSignal { .. } | Self::Resize { .. } ) } @@ -744,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, @@ -765,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) @@ -809,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, @@ -856,7 +837,6 @@ pub enum Response { /// before workload launch. confirmation: Box, }, - ProviderFilesSupported, Started { process_id: String, provider_env_revision: u64, @@ -1551,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, @@ -1576,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")); @@ -1613,10 +1585,7 @@ mod tests { Err(FrameError::PayloadDigestMismatch) )); retry.exec_admission_deadline_unix_ms = None; - assert!(matches!( - retry.validate_payload_digest(), - Err(FrameError::PayloadDigestMismatch) - )); + assert!(retry.validate_payload_digest().is_err()); } #[test] @@ -1719,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/isolation_protocol.rs b/crates/openshell-sandbox-backend/src/isolation_protocol.rs index 8128b81795..9a740e4191 100644 --- a/crates/openshell-sandbox-backend/src/isolation_protocol.rs +++ b/crates/openshell-sandbox-backend/src/isolation_protocol.rs @@ -8,7 +8,7 @@ use openshell_core::proto::extension::v1::{PeerMetadata, ProtocolVersion}; use crate::backend_proto::OpenBoundaryRequest; use crate::boundary_protocol::{BoundaryConfig, SandboxRuntimeDescriptor}; -use crate::proto::{SandboxFrame, sandbox_frame::Payload}; +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 }; @@ -34,14 +34,15 @@ pub fn negotiate(local: &PeerMetadata, remote: Option) -> Result<( } /// 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.sandbox_id != config.boundary_id - || request.generation_id != config.generation || request.session_id != config.session_id.to_string() { return Err(tonic::Status::permission_denied( @@ -66,21 +67,21 @@ pub fn validate_open( } #[must_use] -pub fn data_chunk(data: Vec) -> SandboxFrame { - SandboxFrame { +pub fn data_chunk(data: Vec) -> ExchangeFrame { + ExchangeFrame { payload: Some(Payload::RelayData(data)), } } #[must_use] -pub fn sandbox_frame(boundary_id: &str) -> SandboxFrame { - SandboxFrame { +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: SandboxFrame) -> Result, tonic::Status> { +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, @@ -89,8 +90,6 @@ pub fn chunk_data(chunk: SandboxFrame) -> Result, tonic::Status> { crate::stream_adapter::Mode::Output } Some(Payload::TcpOpenDecision(_)) => crate::stream_adapter::Mode::Decision, - Some(Payload::DnsQuery(_)) => crate::stream_adapter::Mode::DnsQueries, - Some(Payload::DnsResponse(_)) => crate::stream_adapter::Mode::DnsResponses, _ => crate::stream_adapter::Mode::Relay, }; crate::stream_adapter::receive(&mut mode, chunk) @@ -100,9 +99,9 @@ pub fn chunk_data(chunk: SandboxFrame) -> Result, tonic::Status> { /// A typed control request after the stream's boundary-binding frame. pub fn request_chunk( request: &crate::boundary_protocol::RequestEnvelope, -) -> Result { +) -> Result { use crate::protobuf::WireMessage; - Ok(SandboxFrame { + Ok(ExchangeFrame { payload: Some(Payload::Request(request.to_proto()?)), }) } @@ -110,9 +109,9 @@ pub fn request_chunk( /// A typed control response, preserving the request's correlation ID. pub fn response_chunk( response: &crate::boundary_protocol::ResponseEnvelope, -) -> Result { +) -> Result { use crate::protobuf::WireMessage; - Ok(SandboxFrame { + Ok(ExchangeFrame { payload: Some(Payload::Response(response.to_proto()?)), }) } diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index a722dfd6da..21c52788f1 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -356,7 +356,9 @@ impl WireMessage for c::BoundaryConfirmation { backend_audit: if v.backend_audit.is_empty() { serde_json::Value::Null } else { - serde_json::from_slice(&v.backend_audit).map_err(invalid)? + serde_json::from_slice(&v.backend_audit).map_err(|error| { + invalid(format!("backend_audit must be UTF-8 JSON: {error}")) + })? }, }) } @@ -439,18 +441,14 @@ impl WireMessage for w::RequestEnvelope { W::Attach { supervisor_instance_id, policy, - resource_claims, } => P::Attach(p::Attach { supervisor_instance_id: supervisor_instance_id.to_string(), policy: Some(policy.to_proto()?), - resource_claims: resource_claims.clone(), }), W::Confirm => P::Confirm(p::Confirm {}), - W::ProbeProviderFiles => P::ProbeProviderFiles(p::ProbeProviderFiles {}), W::StartAgent { sandbox_id, spec, - policy, ca_cert, ca_bundle, provider_env_revision, @@ -459,7 +457,6 @@ impl WireMessage for w::RequestEnvelope { } => P::StartAgent(p::StartAgent { sandbox_id: sandbox_id.clone(), spec: Some(spec.to_proto()?), - policy: Some(policy.to_proto()?), ca_cert: ca_cert.clone(), ca_bundle: ca_bundle.clone(), provider_env_revision: *provider_env_revision, @@ -493,10 +490,10 @@ impl WireMessage for w::RequestEnvelope { W::TerminateBoundary => P::TerminateBoundary(p::TerminateBoundary {}), W::Exec { spec } => P::Exec(p::Exec { spec: Some(spec.to_proto()?), - }), - W::ExecSignal { process_id, signal } => P::ExecSignal(p::ExecSignal { - process_id: process_id.clone(), - signal: signal.to_proto()? as i32, + exec_admission_deadline: Some(timestamp( + self.exec_admission_deadline_unix_ms + .ok_or_else(|| invalid("exec admission deadline missing"))?, + )?), }), W::Resize { process_id, @@ -516,29 +513,23 @@ impl WireMessage for w::RequestEnvelope { }; Ok(p::RequestEnvelope { request_id: self.request_id.clone(), - exec_admission_deadline: self - .exec_admission_deadline_unix_ms - .map(timestamp) - .transpose()?, 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)?)?), - resource_claims: v.resource_claims, }, P::Confirm(_) => W::Confirm, - P::ProbeProviderFiles(_) => W::ProbeProviderFiles, P::StartAgent(v) => W::StartAgent { sandbox_id: v.sandbox_id, spec: w::AgentSpecWire::from_proto(required(v.spec)?)?, - policy: Box::new(w::SandboxPolicyWire::from_proto(required(v.policy)?)?), ca_cert: v.ca_cert, ca_bundle: v.ca_bundle, provider_env_revision: v.provider_env_revision, @@ -565,13 +556,12 @@ impl WireMessage for w::RequestEnvelope { process_id: v.process_id, }, P::TerminateBoundary(_) => W::TerminateBoundary, - P::Exec(v) => W::Exec { - spec: w::ExecSpecWire::from_proto(required(v.spec)?)?, - }, - P::ExecSignal(v) => W::ExecSignal { - process_id: v.process_id, - signal: w::SignalWire::from_proto(enum_value(v.signal)?)?, - }, + 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())?, @@ -587,10 +577,6 @@ impl WireMessage for w::RequestEnvelope { // 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 expires = v.exec_admission_deadline.map(millis).transpose()?; - if matches!(request, W::Exec { .. }) != expires.is_some() { - return Err(invalid("exec deadline presence does not match operation")); - } let mut envelope = Self { request_id: v.request_id, payload_digest: String::new(), @@ -617,7 +603,6 @@ impl WireMessage for w::ResponseEnvelope { W::Confirmed { confirmation } => P::Confirmed(p::Confirmed { confirmation: Some(confirmation.to_proto()?), }), - W::ProviderFilesSupported => P::ProviderFilesSupported(p::ProviderFilesSupported {}), W::Started { process_id, provider_env_revision, @@ -695,7 +680,6 @@ impl WireMessage for w::ResponseEnvelope { v.confirmation, )?)?), }, - P::ProviderFilesSupported(_) => W::ProviderFilesSupported, P::Started(v) => W::Started { process_id: v.process_id, provider_env_revision: v.provider_env_revision, @@ -807,17 +791,14 @@ mod tests { let id = uuid::Uuid::new_v4().to_string(); let attach = p::RequestEnvelope { request_id: id.clone(), - exec_admission_deadline: None, request: Some(p::request_envelope::Request::Attach(p::Attach { supervisor_instance_id: uuid::Uuid::new_v4().to_string(), policy: None, - resource_claims: std::collections::BTreeMap::default(), })), }; assert!(w::RequestEnvelope::from_proto(attach).is_err()); let signal = p::RequestEnvelope { request_id: id, - exec_admission_deadline: None, request: Some(p::request_envelope::Request::Signal(p::Signal { process_id: "main".into(), signal: 99, @@ -841,13 +822,22 @@ mod tests { }) .unwrap(); let mut value = request.to_proto().unwrap(); - value.exec_admission_deadline = None; + 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(); - value.exec_admission_deadline.as_mut().unwrap().nanos = -1; + 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(); - value.exec_admission_deadline.as_mut().unwrap().seconds += 1; + 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); @@ -857,7 +847,6 @@ mod tests { fn resize_rejects_values_outside_the_rust_contract() { let value = p::RequestEnvelope { request_id: uuid::Uuid::new_v4().to_string(), - exec_admission_deadline: None, request: Some(p::request_envelope::Request::Resize(p::Resize { process_id: "main".into(), cols: 65_536, diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 416873c6f9..1be26d12c7 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -14,10 +14,10 @@ use crate::backend_proto::{ DescribeIsolationBackendRequest, OpenBoundaryRequest, isolation_backend_client::IsolationBackendClient, open_boundary_response::LaunchOutcome, }; -use crate::isolation_protocol::sandbox_frame; #[cfg(test)] use crate::isolation_protocol::{chunk_data, data_chunk}; -use crate::proto::{SandboxFrame, sandbox_protocol_client::SandboxProtocolClient}; +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; @@ -210,7 +210,6 @@ impl OpenShellRuntimeBackend { .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 { @@ -224,7 +223,6 @@ impl OpenShellRuntimeBackend { Ok(Box::new(BoundBoundary { client: client.clone(), agent: sandbox.agent, - policy: sandbox.policy, sandbox_id: sandbox.sandbox_id, mediation: Arc::new(NetworkMediationSource { client }), host_gateway_ip, @@ -335,7 +333,6 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result, agent: AgentSpec, - policy: openshell_core::policy::SandboxPolicy, sandbox_id: String, mediation: Arc, host_gateway_ip: Option, @@ -381,7 +378,6 @@ impl BoundBoundary { 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, @@ -397,7 +393,6 @@ impl BoundBoundary { 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, @@ -432,26 +427,11 @@ impl ReadyBoundary { .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, @@ -551,16 +531,9 @@ impl BoundaryProcess { pub async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { let response = self .client - .call_idempotent(if self.exec { - Request::ExecSignal { - process_id: self.process_id.clone(), - signal: SignalWire::from(signal), - } - } else { - Request::Signal { - process_id: self.process_id.clone(), - signal: SignalWire::from(signal), - } + .call_idempotent(Request::Signal { + process_id: self.process_id.clone(), + signal: SignalWire::from(signal), }) .await?; expect_response(response, "signaled") @@ -676,22 +649,6 @@ impl BoundaryExec { .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()) })?; @@ -1504,9 +1461,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!( @@ -1516,9 +1474,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 { @@ -1795,8 +1754,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( @@ -1914,14 +1874,22 @@ impl BoundaryClient { 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(); @@ -1989,11 +1957,8 @@ impl BoundaryClient { .into_inner(); crate::isolation_protocol::negotiate(&supervisor, response.backend) .map_err(control_rpc_error)?; - let descriptor = &self.runtime_descriptor; let mut request = tonic::Request::new(OpenBoundaryRequest { - sandbox_id: descriptor.boundary_id.clone(), - session_id: descriptor.session_id.to_string(), - generation_id: descriptor.generation.clone(), + session_id: self.runtime_descriptor.session_id.to_string(), backend_name: self.backend_name.clone(), driver_descriptor: self.driver_descriptor.clone(), }); @@ -2147,47 +2112,63 @@ async fn open_grpc_client_stream_with_authorization( 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); - outbound - .send(sandbox_frame(boundary_id)) - .await - .map_err(|_| BackendError::Unavailable("queue boundary binding frame".to_string()))?; - tokio::spawn(pump_to_grpc(reader, outbound)); 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 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 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, - crate::stream_adapter::Mode::Response, - )); - Ok(Box::new(application)) + }; + 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, + ))) + } + 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 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) +async fn pump_to_grpc(mut reader: R, sender: tokio::sync::mpsc::Sender) where - R: tokio::io::AsyncRead + Unpin, + R: tokio::io::AsyncRead + Unpin + Send, { let mut mode = crate::stream_adapter::Mode::Request; loop { - match crate::stream_adapter::send(&mut reader, &mut mode).await { + match F::send(&mut reader, &mut mode).await { Ok(Some(chunk)) => { if sender.send(chunk).await.is_err() { return; @@ -2202,38 +2183,38 @@ where } } -async fn pump_from_grpc( - mut stream: tonic::Streaming, - mut writer: W, +/// 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, -) where - W: tokio::io::AsyncWrite + Unpin, -{ - loop { - match stream.message().await { - Ok(Some(chunk)) => { - let Ok(data) = crate::stream_adapter::receive(&mut mode, chunk) else { - let _ = writer.shutdown().await; - return; - }; - if let Err(error) = writer.write_all(&data).await { - tracing::debug!(%error, "boundary gRPC response writer ended"); - return; +) -> 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 } - } - Ok(None) => { - let _ = writer.shutdown().await; - 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; - return; - } - } + 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), } } @@ -2388,7 +2369,9 @@ mod tests { 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}; @@ -2401,7 +2384,7 @@ mod tests { }; use crate::boundary_protocol::{ExitStatusWire, generate_sandbox_tls_material}; use crate::proto::{ - SandboxFrame, + ExchangeFrame, sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use openshell_core::jwt::{SecretJwt, SessionBearerTokenSlot}; @@ -2485,11 +2468,9 @@ mod tests { } } - async fn test_bind(inbound: &mut tonic::Streaming) { - assert_eq!( - inbound.message().await.unwrap().unwrap(), - sandbox_frame(TEST_BOUNDARY_HANDLE) - ); + 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] @@ -2623,19 +2604,14 @@ mod tests { confirmation: BoundaryConfirmation, } - type TestGrpcStream = Pin< - Box> + Send + 'static>, - >; - - #[tonic::async_trait] - impl SandboxProtocol 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") @@ -2658,7 +2634,11 @@ mod tests { loop { match inbound.message().await { Ok(Some(chunk)) => { - frame.extend_from_slice(&chunk_data(chunk).expect("data frame")); + 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; } @@ -2697,7 +2677,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, @@ -2713,9 +2692,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 { @@ -2754,16 +2731,36 @@ mod tests { } else { b"complete response".to_vec() }; - let _ = outbound.send(Ok(test_response_chunk(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 @@ -2776,11 +2773,11 @@ mod tests { "injected mediation transport failure", )); } - self.exchange(request).await + self.serve(request).await } } - fn test_response_chunk(bytes: Vec) -> SandboxFrame { + fn test_response_chunk(bytes: Vec) -> ExchangeFrame { let response: ResponseEnvelope = decode_frame(&bytes).unwrap(); crate::isolation_protocol::response_chunk(&response).unwrap() } @@ -2901,7 +2898,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(), @@ -3013,7 +3009,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(), @@ -3358,7 +3353,6 @@ mod tests { let bound = BoundBoundary { client: client.clone(), agent: context.agent, - policy: context.policy, sandbox_id: context.sandbox_id, mediation: Arc::new(NetworkMediationSource { client: client.clone(), @@ -3868,7 +3862,6 @@ mod tests { 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(), @@ -3948,7 +3941,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, @@ -4008,7 +4000,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 index c341b18548..464bcc83bf 100644 --- a/crates/openshell-sandbox-backend/src/runtime/test_support.rs +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -6,11 +6,12 @@ use crate::boundary_protocol::{self as w, Request, RequestEnvelope, Response, ResponseEnvelope}; use crate::proto::{ self as p, - sandbox_frame::Payload, + 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; @@ -110,12 +111,12 @@ struct Peer { terminals: Mutex>>, } -type Stream = - Pin> + Send>>; -type Sender = mpsc::Sender>; +type Stream = + Pin> + Send>>; +type Sender = mpsc::Sender>; -fn chunk(payload: Payload) -> p::SandboxFrame { - p::SandboxFrame { +fn chunk(payload: Payload) -> p::ExchangeFrame { + p::ExchangeFrame { payload: Some(payload), } } @@ -137,7 +138,7 @@ impl Peer { } async fn exchange( self: Arc, - mut input: tonic::Streaming, + mut input: tonic::Streaming, out: Sender, request: RequestEnvelope, ) -> Result<(), BackendError> { @@ -177,14 +178,14 @@ impl Peer { Request::Wait { process_id } => Response::Exited { status: self.process(&process_id)?.wait().await?.into(), }, - Request::ExecSignal { + Request::Signal { process_id, signal: w::SignalWire::Kill, } => { self.process(&process_id)?.terminate().await?; Response::Signaled } - Request::Signal { process_id, signal } | Request::ExecSignal { process_id, signal } => { + Request::Signal { process_id, signal } => { self.process(&process_id)?.signal(signal.into()).await?; Response::Signaled } @@ -236,7 +237,6 @@ impl Peer { applied: true, } } - Request::ProbeProviderFiles => Response::ProviderFilesSupported, Request::Exec { spec } => { let a = self .exec @@ -320,10 +320,10 @@ struct Service(Arc); #[tonic::async_trait] impl SandboxProtocol for Service { type ExchangeStream = Stream; - type MediateStream = Stream; + type MediateStream = Stream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (out, rx) = mpsc::channel(8); let peer = self.0.clone(); @@ -348,8 +348,8 @@ impl SandboxProtocol for Service { } async fn mediate( &self, - request: tonic::Request>, - ) -> Result, tonic::Status> { + request: tonic::Request>, + ) -> Result>, tonic::Status> { let (out, rx) = mpsc::channel(8); let source = self.0.network.clone(); tokio::spawn(async move { @@ -388,7 +388,13 @@ impl SandboxProtocol for Service { let mut wire = message.to_proto().unwrap(); wire.stream_id = stream_id; pending.insert(stream_id, query.response); - if out.send(Ok(chunk(Payload::DnsQuery(wire)))).await.is_err() { + if out + .send(Ok(p::MediateFrame { + payload: Some(p::mediate_frame::Payload::DnsQuery(wire)), + })) + .await + .is_err() + { break; } stream_id += 1; @@ -399,7 +405,7 @@ impl SandboxProtocol for Service { match query {Ok(q)=>next=Some(q),Err(_)=>break} }, response=input.message()=>{ - if let Ok(Some(p::SandboxFrame{payload:Some(Payload::DnsResponse(r))}))=response { + 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); @@ -412,26 +418,22 @@ impl SandboxProtocol for Service { Ok(tonic::Response::new(Box::pin(ReceiverStream::new(rx)))) } } -async fn read_request( - input: &mut tonic::Streaming, +async fn read_request( + input: &mut tonic::Streaming, ) -> Result { let first = input .message() - .await - .map_err(|e| tonic::Status::invalid_argument(e.to_string()))?; - assert!(matches!( - first.and_then(|c| c.payload), - Some(Payload::BoundaryId(_)) - )); - let Some(Payload::Request(request)) = input + .await? + .ok_or_else(|| tonic::Status::invalid_argument("missing boundary binding"))?; + assert!(first.boundary_id().is_some()); + let request = input .message() - .await - .map_err(|e| tonic::Status::invalid_argument(e.to_string()))? - .and_then(|c| c.payload) - else { - return Err(tonic::Status::invalid_argument("missing request")); - }; - RequestEnvelope::from_proto(request).map_err(|e| tonic::Status::invalid_argument(e.to_string())) + .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 { @@ -445,19 +447,17 @@ fn error_response(e: BackendError) -> Response { message: e.to_string(), } } -async fn send_response(out: &Sender, id: String, response: Response) { - send( - out, - Payload::Response( - ResponseEnvelope { - request_id: id, - response, - } - .to_proto() - .unwrap(), - ), - ) - .await; +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) { @@ -480,7 +480,7 @@ async fn output(mut reader: BoundaryOutput, out: Sender, stderr: bool) { } } async fn process_io( - mut input: tonic::Streaming, + mut input: tonic::Streaming, out: Sender, stdin: Option, stdout: BoundaryOutput, @@ -550,7 +550,7 @@ async fn process_io( read.abort(); } async fn relay_io( - mut input: tonic::Streaming, + mut input: tonic::Streaming, out: Sender, stream: BoundaryDuplexStream, ) { 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 e8814154a1..0c5320a46b 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs @@ -15,11 +15,11 @@ struct RenewingGrpcBoundary { #[tonic::async_trait] 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 SandboxProtocol 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 } } @@ -106,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/flow_control.rs b/crates/openshell-sandbox-backend/src/runtime/tests/flow_control.rs index 0a57300358..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,17 +8,17 @@ 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 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(); @@ -52,7 +52,7 @@ impl SandboxProtocol for StallingPeer { async fn mediate( &self, - _request: tonic::Request>, + _request: tonic::Request>, ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("flow-control fixture")) } @@ -96,10 +96,10 @@ async fn start_boundary(peer: StallingPeer) -> tonic::transport::Channel { } /// 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(sandbox_frame(TEST_BOUNDARY_HANDLE)) + .send(exchange_frame(TEST_BOUNDARY_HANDLE)) .await .unwrap(); let mut request = tonic::Request::new(ReceiverStream::new(receiver)); 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 199b98e43f..4ca5a60605 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -53,11 +53,11 @@ struct NetworkPeer { #[tonic::async_trait] 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 +80,7 @@ impl SandboxProtocol for NetworkPeer { async fn mediate( &self, - _request: tonic::Request>, + _request: tonic::Request>, ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("TCP fixture")) } @@ -89,8 +89,8 @@ impl SandboxProtocol 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(); @@ -198,12 +198,7 @@ impl NetworkPeer { 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, - crate::stream_adapter::Mode::Decision, - )); + 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); @@ -221,7 +216,6 @@ impl NetworkPeer { .unwrap(); }) .await; - pump.abort(); result.expect("accepted network stream must receive its decision and payload"); }); } @@ -341,7 +335,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(); 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 index 60d5cc009b..e23e6c6314 100644 --- a/crates/openshell-sandbox-backend/src/stream_adapter.rs +++ b/crates/openshell-sandbox-backend/src/stream_adapter.rs @@ -3,13 +3,15 @@ //! Private adapter for Rust `AsyncRead`/`AsyncWrite` handles. //! -//! Length/channel markers exist only on these in-process pipes; gRPC carries typed `SandboxFrame` values. +//! 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, sandbox_frame::Payload}, + proto::{self as p, exchange_frame::Payload}, protobuf::{self, WireMessage}, }; +use prost::Message as _; use std::io; use tokio::io::{AsyncRead, AsyncReadExt}; @@ -29,8 +31,8 @@ pub enum Mode { fn bad(message: impl ToString) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message.to_string()) } -fn chunk(payload: Payload) -> p::SandboxFrame { - p::SandboxFrame { +fn chunk(payload: Payload) -> p::ExchangeFrame { + p::ExchangeFrame { payload: Some(payload), } } @@ -71,7 +73,7 @@ fn dns_bytes(kind: m::MediationFrameKind, id: u64, payload: &[u8]) -> io::Result Ok(bytes) } /// Convert a checked typed frame to the local stream interface. -pub fn receive(mode: &mut Mode, value: p::SandboxFrame) -> io::Result> { +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")); @@ -117,24 +119,6 @@ pub fn receive(mode: &mut Mode, value: p::SandboxFrame) -> io::Result> { ) } (Mode::Relay, Payload::RelayData(v)) if v.len() <= w::MAX_STREAM_FRAME_BYTES => Ok(v), - (Mode::DnsQueries, Payload::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, Payload::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 stream operation or direction", )), @@ -161,7 +145,7 @@ async fn control(reader: &mut R) -> io::Result( reader: &mut R, mode: &mut Mode, -) -> io::Result> { +) -> io::Result> { let payload = match *mode { Mode::Request => { let Some(bytes) = control(reader).await? else { @@ -227,30 +211,7 @@ pub async fn send( bytes.truncate(len); Payload::RelayData(bytes) } - 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; - Payload::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; - Payload::DnsResponse(v) - } - _ => return Err(bad("wrong DNS stream direction")), - } - } + Mode::DnsQueries | Mode::DnsResponses => return Err(bad("DNS requires Mediate")), Mode::Done => { let mut byte = [0]; if reader.read(&mut byte).await? != 0 { @@ -262,6 +223,167 @@ pub async fn send( 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::*; @@ -281,17 +403,60 @@ mod tests { } #[tokio::test] - async fn typed_dns_preserves_correlation_and_failure() { - let frame = chunk(Payload::DnsResponse(p::DnsResponse { - stream_id: 9_007_199_254_740_993, - result: Some(p::dns_response::Result::Error( - "resolver unavailable".into(), + 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(), )), - })); - let bytes = receive(&mut Mode::DnsResponses, frame.clone()).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!( - send(&mut reader, &mut Mode::DnsResponses).await.unwrap(), + p::MediateFrame::send(&mut reader, &mut Mode::DnsResponses) + .await + .unwrap(), Some(frame) ); } diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index b32eb0dd7e..a0c3bf5bc5 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -56,8 +56,7 @@ mod linux { #[cfg(test)] use openshell_sandbox_backend::proto::sandbox_protocol_client::SandboxProtocolClient; use openshell_sandbox_backend::proto::{ - SandboxFrame, - sandbox_frame::Payload, + ExchangeFrame, MediateFrame, sandbox_protocol_server::{SandboxProtocol, SandboxProtocolServer}, }; use openshell_sandbox_backend::sandbox_auth::{ @@ -849,11 +848,10 @@ mod linux { } impl GrpcBoundaryService { - async fn bind_stream( + async fn bind_stream( &self, - request: tonic::Request>, - ) -> Result<(tonic::Streaming, SandboxProtocolPrincipal), tonic::Status> - { + request: tonic::Request>, + ) -> Result<(tonic::Streaming, SandboxProtocolPrincipal), tonic::Status> { let principal = self .runtime .authenticate_request(self.connection_id, request.metadata())?; @@ -870,9 +868,9 @@ mod linux { .map_err(|_| { tonic::Status::deadline_exceeded("boundary binding frame timed out") })??; - match frame.and_then(|frame| frame.payload) { - Some(Payload::BoundaryId(id)) if id == self.runtime.routing_handle => {} - Some(Payload::BoundaryId(_)) => { + 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", )); @@ -887,16 +885,16 @@ mod linux { } } - type GrpcResponseStream = ReceiverStream>; + type GrpcResponseStream = ReceiverStream>; #[tonic::async_trait] impl SandboxProtocol for GrpcBoundaryService { type ExchangeStream = GrpcResponseStream; - type MediateStream = GrpcResponseStream; + type MediateStream = GrpcResponseStream; async fn exchange( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (inbound, principal) = self.bind_stream(request).await?; let (stream, response) = @@ -916,7 +914,7 @@ mod linux { async fn mediate( &self, - request: tonic::Request>, + request: tonic::Request>, ) -> Result, tonic::Status> { let (inbound, principal) = self.bind_stream(request).await?; let (stream, response) = @@ -931,14 +929,13 @@ 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(); @@ -951,7 +948,7 @@ mod linux { loop { match inbound.message().await { Ok(Some(chunk)) => { - let data = match openshell_sandbox_backend::stream_adapter::receive(&mut mode,chunk).map_err(|error|tonic::Status::invalid_argument(error.to_string())) { + 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() { @@ -985,7 +982,7 @@ mod linux { () = async { let mut mode = openshell_sandbox_backend::stream_adapter::Mode::Response; loop { - match openshell_sandbox_backend::stream_adapter::send(&mut reader,&mut mode).await { + 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; } @@ -1592,7 +1589,6 @@ mod linux { struct StartedAgent { sandbox_id: String, spec: AgentSpecWire, - policy: SandboxPolicyWire, ca_cert: Option, ca_bundle: Option, provider_env_revision: u64, @@ -1607,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 } @@ -2111,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, @@ -2142,7 +2119,6 @@ mod linux { } => self.start_agent( sandbox_id, spec, - *policy, ca_cert, ca_bundle, provider_env_revision, @@ -2163,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, @@ -2328,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(); @@ -2607,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), @@ -2627,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, @@ -2657,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), @@ -2754,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, @@ -2823,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, @@ -3868,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; @@ -4078,9 +4062,7 @@ mod linux { provisioning_evidence: config.provisioning_evidence.clone(), }; OpenBoundaryRequest { - sandbox_id: config.boundary_id.clone(), session_id: config.session_id.to_string(), - generation_id: config.generation.clone(), backend_name: openshell_sandbox_backend::BACKEND_NAME.to_string(), driver_descriptor: serde_json::to_vec(&descriptor).unwrap(), } @@ -4646,14 +4628,16 @@ mod linux { test_open_boundary(channel.clone(), test_open_request(&runtime.config), &token) .await; sender - .send(openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle)) + .send(MediateFrame { + payload: Some(mediate_frame::Payload::BoundaryId(handle.clone())), + }) .await .unwrap(); let request = RequestEnvelope::new(Request::OpenMediation).unwrap(); sender - .send( - openshell_sandbox_backend::isolation_protocol::request_chunk(&request).unwrap(), - ) + .send(MediateFrame { + payload: Some(mediate_frame::Payload::Request(request.to_proto().unwrap())), + }) .await .unwrap(); let mut response = SandboxProtocolClient::new(channel) @@ -5114,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"); @@ -5339,12 +5278,10 @@ mod linux { .code(), tonic::Code::Unauthenticated ); - for field in ["sandbox", "session", "generation", "backend", "descriptor"] { + for field in ["session", "backend", "descriptor"] { let mut changed = opening.clone(); match field { - "sandbox" => changed.sandbox_id = "other-sandbox".to_string(), "session" => changed.session_id = "other-session".to_string(), - "generation" => changed.generation_id = "other-generation".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(); @@ -5385,7 +5322,7 @@ mod linux { .connect() .await .unwrap(); - let binding = openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle); + let binding = openshell_sandbox_backend::isolation_protocol::exchange_frame(&handle); let mut replacement_streams = SandboxProtocolClient::new(replacement.clone()); assert_eq!( replacement_streams @@ -5401,7 +5338,12 @@ mod linux { ); assert_eq!( replacement_streams - .mediate(bearer_request(tokio_stream::iter([binding]), &token)) + .mediate(bearer_request( + tokio_stream::iter([MediateFrame { + payload: Some(mediate_frame::Payload::BoundaryId(handle.clone())) + }]), + &token + )) .await .err() .unwrap() @@ -5431,10 +5373,10 @@ mod linux { for frames in [ vec![], vec![data_chunk(Vec::new())], - vec![SandboxFrame { payload: None }], - vec![openshell_sandbox_backend::isolation_protocol::sandbox_frame("")], + vec![ExchangeFrame { payload: None }], + vec![openshell_sandbox_backend::isolation_protocol::exchange_frame("")], vec![ - openshell_sandbox_backend::isolation_protocol::sandbox_frame("other-boundary"), + openshell_sandbox_backend::isolation_protocol::exchange_frame("other-boundary"), ], ] { let expected = if frames @@ -5456,7 +5398,16 @@ mod linux { ); assert_eq!( streams - .mediate(bearer_request(tokio_stream::iter(frames), &token)) + .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() @@ -5464,7 +5415,7 @@ mod linux { expected ); } - let binding = openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle); + let binding = openshell_sandbox_backend::isolation_protocol::exchange_frame(&handle); let mut response = streams .exchange(bearer_request( tokio_stream::iter([binding.clone(), binding]), @@ -5545,12 +5496,11 @@ 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 handle = test_open_boundary(channel.clone(), opening, &token).await; let request_stream = tokio_stream::iter([ - openshell_sandbox_backend::isolation_protocol::sandbox_frame(&handle), + openshell_sandbox_backend::isolation_protocol::exchange_frame(&handle), openshell_sandbox_backend::isolation_protocol::request_chunk(&request) .expect("typed logical request"), ]); @@ -5852,7 +5802,6 @@ mod linux { boundary.start_agent( "sandbox-reconnect".to_string(), spec.clone(), - policy.clone(), None, None, 0, @@ -5944,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, @@ -5964,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, @@ -6056,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"); @@ -6169,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, @@ -6187,7 +6163,6 @@ mod linux { boundary.start_agent( "sandbox-retained".to_string(), agent_spec.clone(), - wire_policy.clone(), None, None, 0, @@ -6280,7 +6255,7 @@ mod linux { ); assert!(matches!( - boundary.attach(wire_policy.clone()), + boundary.attach(wire_policy), Response::Attached { .. } )); assert!(matches!(boundary.confirm(), Response::Confirmed { .. })); @@ -6288,7 +6263,6 @@ mod linux { boundary.start_agent( "sandbox-retained".to_string(), agent_spec, - wire_policy, None, None, 99, @@ -6368,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 { diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 0e91d84044..3b9e541c65 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -81,9 +81,9 @@ pub type SharedComputeDriver = Arc + Send + Sync>; fn supports_isolation_registration(capabilities: &[String]) -> bool { - capabilities - .iter() - .any(|capability| capability == openshell_core::extension_protocol::COMPUTE_SANDBOX_BACKEND) + capabilities.iter().any(|capability| { + capability == openshell_core::extension_protocol::COMPUTE_ISOLATION_BACKEND + }) } use provisioning_operation::ProvisioningOperationError; @@ -737,7 +737,7 @@ impl ComputeDriver for RemoteComputeDriver { #[derive(Clone)] pub struct ComputeRuntime { - isolation_backend: Option, + isolation_backend: Option, admission: openshell_core::resource_admission::DriverAdmissionConfig, admission_acknowledgement: String, driver: TracedDriver, @@ -3664,7 +3664,7 @@ impl ComputeRuntime { continue; } if let Err(error) = self.validate_isolation_backend_binding(&sandbox) { - warn!(sandbox_id, %error, "Rejected recovered sandbox backend selection"); + warn!(sandbox_id, %error, "Rejected recovered isolation backend selection"); failed += 1; continue; } @@ -3958,7 +3958,7 @@ impl ComputeRuntime { } SandboxPhase::Starting => { if let Err(error) = self.validate_isolation_backend_binding(&sandbox) { - warn!(%error, "Rejected pending sandbox backend selection"); + warn!(%error, "Rejected pending isolation backend selection"); continue; } if let Err(error) = self.validate_caller_driver_config( @@ -16769,7 +16769,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let socket_path = directory.path().join("compute.sock"); let driver = FakeComputeDriver::new() - .with_sandbox_backend_registration() + .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) @@ -16791,9 +16791,10 @@ 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::SandboxBackendRegistration::try_from(registration) - .unwrap(); + let expected = openshell_core::proto::compute::v1::IsolationBackendRegistration::try_from( + registration, + ) + .unwrap(); let runtime = ComputeRuntime::new_remote_driver( endpoint, store, @@ -17035,7 +17036,7 @@ endpoint = { kind = "unix", socket_path = "/run/agent-substrate/isolation.sock" .unwrap() .supported_capabilities .retain(|capability| { - capability != openshell_core::extension_protocol::COMPUTE_SANDBOX_BACKEND + capability != openshell_core::extension_protocol::COMPUTE_ISOLATION_BACKEND }); let _ = driver.clone().with_capabilities(capabilities); driver.clear_calls(); diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index d72fa930d9..5aa4a70f5c 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -112,7 +112,7 @@ pub enum FakeComputeDriverCall { StartSandbox { sandbox_id: String, sandbox_name: String, - isolation_backend: Option, + isolation_backend: Option, launch_authentication: Vec, }, DeleteSandbox { @@ -205,7 +205,7 @@ impl FakeComputeDriver { } #[must_use] - pub fn with_sandbox_backend_registration(self) -> Self { + pub fn with_isolation_backend_registration(self) -> Self { self.with_state(|state| { state .capabilities @@ -213,7 +213,7 @@ impl FakeComputeDriver { .as_mut() .unwrap() .supported_capabilities - .push(openshell_core::extension_protocol::COMPUTE_SANDBOX_BACKEND.into()); + .push(openshell_core::extension_protocol::COMPUTE_ISOLATION_BACKEND.into()); }); self } 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 index 9fbc1fecab..988cb2a1b5 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -1058,6 +1058,35 @@ dependencies = [ "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" @@ -1389,6 +1418,7 @@ name = "openshell-sandbox-backend" version = "0.0.0" dependencies = [ "async-trait", + "bytes", "hyper-util", "libc", "openshell-core", @@ -1407,6 +1437,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", + "tokio-util", "tonic", "tonic-prost", "tower", @@ -1414,35 +1445,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "openshell-v8-backend-example" -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 = "openssl-probe" version = "0.2.1" diff --git a/examples/isolation-backend-v8/Cargo.toml b/examples/isolation-backend-v8/Cargo.toml index dd2db56dc7..d67d63b2d5 100644 --- a/examples/isolation-backend-v8/Cargo.toml +++ b/examples/isolation-backend-v8/Cargo.toml @@ -4,7 +4,7 @@ [workspace] [package] -name = "openshell-v8-backend-example" +name = "js-sbx" version = "0.0.0" edition = "2024" rust-version = "1.94" @@ -40,5 +40,5 @@ protoc-bin-vendored = "3" tonic-prost-build = "0.14" [[bin]] -name = "v8-backend" +name = "js-sbx" path = "src/main.rs" diff --git a/examples/isolation-backend-v8/README.md b/examples/isolation-backend-v8/README.md index 21e6bebe73..cf85980733 100644 --- a/examples/isolation-backend-v8/README.md +++ b/examples/isolation-backend-v8/README.md @@ -1,24 +1,31 @@ -# Local V8 sandbox backend +# js-sbx -Run a JavaScript file in a V8 isolate and see the supervisor deny its HTTP -request. The driver and backend run as separate processes. Use a non-root -account with the repository's Rust build prerequisites installed. +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: +From this example directory, as a non-root user: ```shell ./start.sh ``` -Pass a JavaScript file to run another script: `./start.sh path/to/script.js`. +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 script builds the binaries, generates local configuration and credentials, -and starts the backend, driver, and gateway. The first build downloads the V8 -library. The script prints the temporary directory containing `gateway.toml` and -logs. The gateway accepts unauthenticated requests on `127.0.0.1:18770`; the -backend uses an authenticated Unix socket. +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 @@ -28,19 +35,21 @@ authentication, and signing-key settings are omitted here. ```toml [openshell.gateway] -compute_driver = "v8-example" +compute_driver = "js-sbx-example" -[openshell.drivers.v8-example] +[openshell.drivers.js-sbx-example] socket_path = "/compute.sock" -isolation_backend = "example-v8" +isolation_backend = "js-sbx" [[openshell.supervisor.isolation_backends]] -name = "example-v8" +name = "js-sbx" endpoint = { kind = "unix", socket_path = "/backend.sock" } ``` -The gateway calls the driver on `compute.sock`. The supervisor resolves the -backend name to `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 @@ -48,50 +57,42 @@ In another terminal, from this example directory: ```shell export OPENSHELL_GATEWAY_ENDPOINT=http://127.0.0.1:18770 -openshell sandbox create --name v8-demo \ +openshell sandbox create --name js-demo \ --keep --no-tty --no-auto-providers -- /work/guest.js ``` -The driver starts the supervisor. The backend loads `guest.js` into a fresh V8 -isolate with `console.log` and `httpGet`. No filesystem, process, or direct -network API is exposed. `httpGet` sends the request through the Sandbox Protocol; -the supervisor's default policy has no network grants and denies it. No policy -file is needed. The guest succeeds only on that policy denial; transport failures -are errors. - -```javascript -const url = "http://1.1.1.1/"; -console.log(`Attempting GET ${url} through the supervisor`); -const result = httpGet(url); -if (result !== -1) { - throw new Error(`Expected a supervisor policy denial, received ${result}`); -} -console.log("Request denied by supervisor network policy (expected)"); -``` +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. -`openshell logs v8-demo` shows the supervisor's policy-denial audit. Add `--detach` to run without attaching; detached creation does not stream guest -output. Use `openshell sandbox connect v8-demo` to attach afterward. +output. Use `openshell sandbox connect js-demo` to attach afterward. ## Inspect and stop ```shell openshell sandbox list -openshell logs v8-demo -openshell sandbox delete v8-demo +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. -This example runs one script at a time, with a 15-second deadline, a 32 MiB -JavaScript heap limit, and 64 KiB per output stream. These limits do not bound -all native V8 resources. It supports synchronous HTTP GET status checks to -numeric addresses through `httpGet`, not the browser `fetch` API. TypeScript, -module imports, DNS, HTTPS, exec, PTY, and recovery are unsupported. +## 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/guest.js b/examples/isolation-backend-v8/guest.js index af524dfc45..ff872b3833 100644 --- a/examples/isolation-backend-v8/guest.js +++ b/examples/isolation-backend-v8/guest.js @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -const url = "http://1.1.1.1/"; -console.log(`Attempting GET ${url} through the supervisor`); -const result = httpGet(url); -if (result !== -1) { - throw new Error(`Expected a supervisor policy denial, received ${result}`); +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); } -console.log("Request denied by supervisor network policy (expected)"); diff --git a/examples/isolation-backend-v8/src/driver.rs b/examples/isolation-backend-v8/src/driver.rs index 0c3e89ba35..9d432d36bd 100644 --- a/examples/isolation-backend-v8/src/driver.rs +++ b/examples/isolation-backend-v8/src/driver.rs @@ -76,9 +76,7 @@ impl Driver { .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 example-v8", - )); + return Err(Status::failed_precondition("this driver requires js-sbx")); } if spec.command != ["/work/guest.js"] || spec.tty { return Err(Status::invalid_argument( @@ -95,7 +93,7 @@ impl Driver { || template.driver_config.is_some() || template.platform_config.is_some() || template.resources.is_some() - || (!template.image.is_empty() && template.image != "v8-local:example")) + || (!template.image.is_empty() && template.image != "js-sbx:example")) { return Err(Status::invalid_argument( "template overrides are unsupported", @@ -203,7 +201,8 @@ impl Driver { private_key: tls.private_key_pem, }; let selected = spec.isolation_backend.as_ref().unwrap(); - let Some(sandbox_backend_registration::Endpoint::UnixSocketPath(path)) = &selected.endpoint + let Some(isolation_backend_registration::Endpoint::UnixSocketPath(path)) = + &selected.endpoint else { return Err(Status::invalid_argument( "the local example requires a Unix backend socket", @@ -249,7 +248,7 @@ impl Driver { } if attempt == 99 { return Err(Status::deadline_exceeded( - "start v8-backend backend before creating the sandbox", + "start js-sbx serve before creating the sandbox", )); } tokio::time::sleep(Duration::from_millis(50)).await; @@ -344,20 +343,20 @@ impl compute_driver_server::ComputeDriver for DriverService { ) -> Result, Status> { let mut extension = extension_protocol::extension_metadata( ExtensionFamily::Compute, - "example-v8", + "js-sbx", "0.0.0", [ extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.into(), - extension_protocol::COMPUTE_SANDBOX_BACKEND.into(), + extension_protocol::COMPUTE_ISOLATION_BACKEND.into(), ], ); extension .required_capabilities .push(extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.into()); Ok(Response::new(GetCapabilitiesResponse { - driver_name: "v8-example".into(), + driver_name: "js-sbx-example".into(), driver_version: "0.0.0".into(), - default_image: "v8-local:example".into(), + default_image: "js-sbx:example".into(), gateway_manages_lifecycle: true, driver_reports_runtime_readiness: false, extension: Some(extension), diff --git a/examples/isolation-backend-v8/src/guest.rs b/examples/isolation-backend-v8/src/guest.rs index fa76be32ec..e2dc8205de 100644 --- a/examples/isolation-backend-v8/src/guest.rs +++ b/examples/isolation-backend-v8/src/guest.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! One V8 isolate with two host functions: bounded logging and mediated HTTP. +//! One V8 isolate with bounded console output and no guest I/O. use std::{ ffi::c_void, fs::File, @@ -17,9 +17,6 @@ use std::{ use anyhow::{Result, ensure}; use serde::Serialize; use sha2::{Digest, Sha256}; -use tokio::sync::watch; - -use crate::network::{self, Network}; const MAX_SCRIPT: usize = 256 * 1024; const MAX_OUTPUT: usize = 64 * 1024; @@ -74,9 +71,8 @@ pub struct GuestResult { } struct State { - network: Option, - stopped: watch::Receiver, stdout: Output, + stderr: Output, rejections: Vec>, } @@ -84,7 +80,6 @@ pub struct Guest { source: String, digest: String, cancelled: AtomicBool, - stopped: watch::Sender, isolate: Mutex>, } @@ -95,24 +90,18 @@ impl Guest { source: std::str::from_utf8(bytes)?.to_owned(), digest: format!("{:x}", Sha256::digest(bytes)), cancelled: AtomicBool::new(false), - stopped: watch::channel(false).0, isolate: Mutex::new(None), }) } pub fn terminate(&self) { self.cancelled.store(true, Ordering::SeqCst); - self.stopped.send_replace(true); if let Some(handle) = self.isolate.lock().unwrap().as_ref() { handle.terminate_execution(); } } - pub fn run( - &self, - network: Option, - ready: impl FnOnce(Output, Output), - ) -> Result { + 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()); @@ -132,7 +121,7 @@ impl Guest { self.terminate(); } }); - let outcome = self.execute(network, stdout.clone(), &stderr); + let outcome = self.execute(stdout.clone(), &stderr); drop(done); outcome })?; @@ -150,21 +139,15 @@ impl Guest { }) } - fn execute( - &self, - network: Option, - stdout: Output, - stderr: &Output, - ) -> Result { + 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 { - network, - stopped: self.stopped.subscribe(), stdout, + stderr: stderr.clone(), rejections: Vec::new(), }); isolate.set_promise_reject_callback(promise_rejected); @@ -175,8 +158,8 @@ impl Guest { v8::scope!(let scope, &mut isolate); let global = v8::ObjectTemplate::new(scope); global.set( - v8::String::new(scope, "httpGet").unwrap().into(), - v8::FunctionTemplate::new(scope, http_get).into(), + v8::String::new(scope, "fetch").unwrap().into(), + v8::FunctionTemplate::new(scope, deny_network).into(), ); let context = v8::Context::new( scope, @@ -192,6 +175,9 @@ impl Guest { 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) @@ -263,6 +249,18 @@ unsafe extern "C" fn heap_limit(data: *mut c_void, current: usize, _initial: usi } 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; }; @@ -271,32 +269,19 @@ fn log(scope: &mut v8::PinScope, args: v8::FunctionCallbackArguments, _result: v return; } let message = text.to_rust_string_lossy(scope); - let output = &scope.get_slot::().unwrap().stdout; + 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 http_get( +fn deny_network( scope: &mut v8::PinScope, - args: v8::FunctionCallbackArguments, - mut result: v8::ReturnValue, + _args: v8::FunctionCallbackArguments, + _result: v8::ReturnValue, ) { - let Ok(url) = v8::Local::::try_from(args.get(0)) else { - result.set_int32(network::INVALID_REQUEST); - return; - }; - if url.length() > network::MAX_URL { - result.set_int32(network::INVALID_REQUEST); - return; - } - let url = url.to_rust_string_lossy(scope); - let state = scope.get_slot::().unwrap(); - let status = state - .network - .as_ref() - .map_or(network::UNAVAILABLE, |network| { - network.http_get(&url, state.stopped.clone()) - }); - result.set_int32(status); + 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 index 84b9807d05..193055e87e 100644 --- a/examples/isolation-backend-v8/src/launch.rs +++ b/examples/isolation-backend-v8/src/launch.rs @@ -38,7 +38,7 @@ pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { /// 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":"supervisor-http-host-call","filesystem":"no-host-access"})).expect("string-valued capability manifest") + 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. diff --git a/examples/isolation-backend-v8/src/lib.rs b/examples/isolation-backend-v8/src/lib.rs index 08cf6498d3..5540a9100b 100644 --- a/examples/isolation-backend-v8/src/lib.rs +++ b/examples/isolation-backend-v8/src/lib.rs @@ -5,6 +5,5 @@ pub mod driver; pub mod guest; pub mod launch; pub mod lifecycle; -pub mod network; pub mod protocol; pub mod transport; diff --git a/examples/isolation-backend-v8/src/lifecycle.rs b/examples/isolation-backend-v8/src/lifecycle.rs index 978e5e152a..9391e237fd 100644 --- a/examples/isolation-backend-v8/src/lifecycle.rs +++ b/examples/isolation-backend-v8/src/lifecycle.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Supervisor-facing lifecycle and mediated HTTP for the local V8 placement. +//! Supervisor-facing lifecycle for the network-disabled JavaScript runtime. use crate::{ guest::{Guest, GuestResult, GuestStatus, read_script}, launch::Launch, @@ -22,7 +22,7 @@ use tokio::sync::{Mutex, watch}; use tonic::{Request, Response, Status, Streaming}; type Chunks = - std::pin::Pin> + Send>>; + std::pin::Pin> + Send>>; struct State { policy: Option, ledger: HashMap, @@ -34,8 +34,6 @@ struct State { pub struct Runtime { launch: Launch, guest: Arc, - network: crate::network::Network, - network_requests: Mutex>, authenticator: SandboxProtocolAuthenticator, pub registry: Arc, opened: Mutex>, @@ -90,11 +88,8 @@ impl Runtime { launch.boundary.session_id, launch.rotation, )); - let (network, network_requests) = crate::network::Network::channel(); Ok(Arc::new(Self { launch, - network, - network_requests: Mutex::new(network_requests), guest: Arc::new(Guest::new(&bytes)?), authenticator, registry, @@ -121,10 +116,11 @@ impl Runtime { .authenticate(id, request.metadata()) .map_err(|_| Status::unauthenticated("invalid sandbox launch credential")) } - async fn route( + async fn route( &self, - mut request: Request>, - ) -> Result<(SandboxProtocolPrincipal, Streaming), Status> { + mut request: Request>, + binding: fn(F) -> Option, + ) -> Result<(SandboxProtocolPrincipal, Streaming), Status> { let principal = self.authenticate(&request)?; if !self .opened @@ -137,11 +133,7 @@ impl Runtime { )); } let first = request.get_mut().message().await?; - if first.and_then(|f| f.payload) - != Some(sandbox_frame::Payload::BoundaryId( - self.launch.boundary.generation.clone(), - )) - { + if first.and_then(binding) != Some(self.launch.boundary.generation.clone()) { return Err(Status::permission_denied("wrong boundary handle")); } Ok((principal, request.into_inner())) @@ -277,7 +269,7 @@ impl Runtime { if let Q::Signal { signal, .. } = &envelope.request && !matches!(signal, wire::SignalWire::Term) { - return Err(Status::unimplemented("this V8 example supports TERM only")); + return Err(Status::unimplemented("js-sbx supports TERM only")); } if matches!( envelope.request, @@ -290,15 +282,8 @@ impl Runtime { } let mut state = self.state.lock().await; let response = match &envelope.request { - Q::Attach { - policy, - resource_claims, - .. - } => { + Q::Attach { policy, .. } => { self.validate_policy(policy)?; - if resource_claims != &self.launch.boundary.resource_claims { - return Err(Status::permission_denied("resource claims mismatch")); - } if state .policy .as_ref() @@ -334,25 +319,19 @@ impl Runtime { .boundary .provisioning_evidence .evidence_digest, - backend_audit: serde_json::json!({"script_sha256": self.launch.boundary.workload_identity.resource_digest, "network": "supervisor-http-host-call", "filesystem": "no-host-access"}), + backend_audit: serde_json::json!({"script_sha256": self.launch.boundary.workload_identity.resource_digest, "network": "disabled", "filesystem": "no-host-access"}), }), } } - Q::ProbeProviderFiles => { - return Err(Status::unimplemented("V8 provider files are unsupported")); - } Q::StartAgent { sandbox_id, spec, - policy, provider_env_revision, provider_env, provider_files, .. } => { - if sandbox_id != &self.launch.boundary.sandbox_id - || state.policy.as_ref() != Some(policy) - { + 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 @@ -375,10 +354,9 @@ impl Runtime { let runtime = self.clone(); let guest = self.guest.clone(); let output = self.output.clone(); - let network = self.network.clone(); tokio::spawn(async move { let result = tokio::task::spawn_blocking(move || { - guest.run(Some(network), |stdout, stderr| { + guest.run(|stdout, stderr| { *output.lock().unwrap() = Some((stdout, stderr)); }) }) @@ -452,7 +430,7 @@ impl Runtime { Q::OpenMediation => R::MediationReady, _ => { return Err(Status::unimplemented( - "operation is not supported by this V8 example", + "operation is not supported by js-sbx", )); } }; @@ -541,9 +519,7 @@ impl isolation_backend_server::IsolationBackend for Arc { let principal = self.authenticate(&request)?; let q = request.into_inner(); let b = &self.launch.boundary; - if q.sandbox_id != b.sandbox_id - || q.session_id != b.session_id.to_string() - || q.generation_id != b.generation + if q.session_id != b.session_id.to_string() || q.backend_name != BACKEND || q.driver_descriptor != self.launch.descriptor { @@ -576,18 +552,40 @@ impl isolation_backend_server::IsolationBackend for Arc { #[tonic::async_trait] impl sandbox_protocol_server::SandboxProtocol for Arc { type ExchangeStream = Chunks; - type MediateStream = Chunks; + type MediateStream = + std::pin::Pin> + Send>>; async fn mediate( &self, - request: Request>, - ) -> Result, Status> { - self.exchange(request).await + 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>, + request: Request>, ) -> Result, Status> { - let (principal, mut input) = self.route(request).await?; + 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?; @@ -597,52 +595,12 @@ impl sandbox_protocol_server::SandboxProtocol for Arc { runtime.registry.require_active(&principal) .map_err(|e| Status::failed_precondition(e.to_string()))?; let mut ended = runtime.ended.subscribe(); - let mut pending = loop { - let queued = tokio::select! { - biased; - _ = ended.wait_for(|value| *value) => Err(Status::unavailable("boundary closed")), - _ = input.message() => Err(Status::unavailable("network accept disconnected")), - pending = async { runtime.network_requests.lock().await.recv().await } => pending.ok_or_else(||Status::unavailable("network source closed")), - }?; - if !queued.result.is_closed() { - break queued; - } - }; - let identity = wire::BinaryIdentityWire::Resolved { - executable: wire::ExecutableIdentityWire { - path: PathBuf::from("/work/guest.js"), - digest: Some(runtime.launch.boundary.workload_identity.resource_digest.parse().map_err(|_|Status::internal("invalid script digest"))?), - }, - ancestors: vec![], - cmdline_paths: vec![], - }; - let response = wire::Response::NetworkConnected { - identity, - destination: pending.destination, - socket: None, - policy_generation: 0, - timing: wire::MediationTimingWire { - notification_to_queue_us: 0, - queue_wait_us: pending.queued_at.elapsed().as_micros().try_into().unwrap_or(u64::MAX), - }, - }; - yield protocol::response(envelope.request_id, response)?; - let decision = tokio::select! { - _ = pending.result.closed() => return, - decision = crate::network::decision(&mut input) => decision, - }?; - use openshell_isolation_interface::contract::TcpOpenDecision; - let status = match decision { - TcpOpenDecision::Denied(reason) => crate::network::denied(reason), - TcpOpenDecision::RelayReady => { - yield protocol::data(pending.request); - tokio::select! { - _ = pending.result.closed() => return, - status = crate::network::http_status(&mut input) => status, - }? - } - }; - let _ = pending.result.send(status); + // 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 { @@ -651,6 +609,7 @@ impl sandbox_protocol_server::SandboxProtocol for Arc { 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(), @@ -682,7 +641,7 @@ pub async fn serve(state_dir: PathBuf) -> Result<()> { let state_dir = state_dir.canonicalize()?; let socket_path = state_dir.join("backend.sock"); println!( - "V8 backend waiting for driver provisioning in {} on {}", + "js-sbx waiting for driver provisioning in {} on {}", state_dir.display(), socket_path.display() ); @@ -704,7 +663,7 @@ pub async fn serve(state_dir: PathBuf) -> Result<()> { let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls)); let runtime = Runtime::new(launch)?; println!( - "V8 boundary provisioned: {}", + "JavaScript runtime provisioned: {}", runtime.launch.boundary.sandbox_id ); let registry = runtime.registry.clone(); diff --git a/examples/isolation-backend-v8/src/main.rs b/examples/isolation-backend-v8/src/main.rs index 5eee81a627..2391cca510 100644 --- a/examples/isolation-backend-v8/src/main.rs +++ b/examples/isolation-backend-v8/src/main.rs @@ -7,7 +7,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(about = "Local V8 backend and example compute driver")] +#[command(about = "Network-disabled JavaScript sandbox and local compute driver")] struct Cli { #[command(subcommand)] command: Command, @@ -15,14 +15,14 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Serve driver-provisioned launches over authenticated TLS. - Backend { - #[arg(long, env = "V8_DEMO_STATE")] + /// 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 = "V8_DEMO_STATE")] + #[arg(long, env = "JS_SBX_STATE")] state_dir: PathBuf, #[arg(long)] socket: Option, @@ -44,11 +44,11 @@ enum Command { #[tokio::main] async fn main() -> Result<()> { match Cli::parse().command { - Command::Backend { state_dir } => { + Command::Serve { state_dir } => { rustls::crypto::aws_lc_rs::default_provider() .install_default() .ok(); - openshell_v8_backend_example::lifecycle::serve(state_dir).await?; + js_sbx::lifecycle::serve(state_dir).await?; } Command::Driver { state_dir, @@ -59,14 +59,14 @@ async fn main() -> Result<()> { policy_rules, } => { let socket = socket.unwrap_or_else(|| state_dir.join("compute.sock")); - let driver = openshell_v8_backend_example::driver::Driver::new( + let driver = js_sbx::driver::Driver::new( state_dir, supervisor, script, gateway_endpoint, policy_rules, )?; - openshell_v8_backend_example::driver::serve(Arc::new(driver), socket).await?; + js_sbx::driver::serve(Arc::new(driver), socket).await?; } } Ok(()) diff --git a/examples/isolation-backend-v8/src/network.rs b/examples/isolation-backend-v8/src/network.rs deleted file mode 100644 index fee67ca7eb..0000000000 --- a/examples/isolation-backend-v8/src/network.rs +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! A deliberately small HTTP host call. Only the supervisor opens upstream sockets. -use std::{ - net::SocketAddr, - time::{Duration, Instant}, -}; - -use openshell_isolation_interface::contract::{TcpOpenDecision, TcpOpenDenial}; -use tokio::sync::{mpsc, oneshot, watch}; -use tonic::{Status, Streaming}; - -use crate::protocol::boundary::{SandboxFrame, sandbox_frame}; - -pub const POLICY_DENIED: i32 = -1; -pub const UNAVAILABLE: i32 = -2; -pub const OTHER_DENIAL: i32 = -3; -pub const INVALID_REQUEST: i32 = -4; -pub const MAX_URL: usize = 512; -const TIMEOUT: Duration = Duration::from_secs(10); - -pub struct HttpOpen { - pub destination: SocketAddr, - pub request: Vec, - pub queued_at: Instant, - pub result: oneshot::Sender, -} - -#[derive(Clone)] -pub struct Network { - sender: mpsc::Sender, - runtime: tokio::runtime::Handle, -} - -impl Network { - pub fn channel() -> (Self, mpsc::Receiver) { - let (sender, receiver) = mpsc::channel(8); - ( - Self { - sender, - runtime: tokio::runtime::Handle::current(), - }, - receiver, - ) - } - - /// Called only from the guest's blocking execution thread. A lost supervisor, - /// full queue, timeout, or cancellation can never become a policy denial. - pub fn http_get(&self, url: &str, mut stopped: watch::Receiver) -> i32 { - let Some((destination, request)) = parse_request(url) else { - return INVALID_REQUEST; - }; - let (result, response) = oneshot::channel(); - let outcome = self.runtime.block_on(async { - tokio::select! { - biased; - _ = stopped.wait_for(|value| *value) => UNAVAILABLE, - result = tokio::time::timeout(TIMEOUT, async { - self.sender.send(HttpOpen { destination, request, queued_at: Instant::now(), result }).await.ok()?; - response.await.ok() - }) => result.ok().flatten().unwrap_or(UNAVAILABLE), - } - }); - println!( - "HTTP GET {url}: {}", - match outcome { - POLICY_DENIED => "denied by supervisor policy".to_string(), - UNAVAILABLE => "supervisor mediation unavailable".to_string(), - OTHER_DENIAL => "denied by supervisor (not a policy denial)".to_string(), - status => format!("HTTP {status}"), - } - ); - outcome - } -} - -// Numeric destinations keep DNS out of this example. No host resolver or socket -// API is exposed to the guest, and CR/LF cannot inject extra HTTP requests. -fn parse_request(url: &str) -> Option<(SocketAddr, Vec)> { - if url.len() > MAX_URL - || !url.is_ascii() - || url - .bytes() - .any(|b| b.is_ascii_control() || b.is_ascii_whitespace()) - { - return None; - } - let url = url.strip_prefix("http://")?; - let (authority, path) = url - .split_once('/') - .map_or((url, "/".to_string()), |(host, path)| { - (host, format!("/{path}")) - }); - let destination = authority - .parse() - .or_else(|_| authority.parse().map(|ip| SocketAddr::new(ip, 80))) - .ok()?; - Some(( - destination, - format!("GET {path} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n") - .into_bytes(), - )) -} - -async fn data(input: &mut Streaming) -> Result, Status> { - match input.message().await?.and_then(|chunk| chunk.payload) { - Some(sandbox_frame::Payload::RelayData(bytes)) - if !bytes.is_empty() - && bytes.len() - <= openshell_sandbox_backend::boundary_protocol::MAX_STREAM_FRAME_BYTES => - { - Ok(bytes) - } - _ => Err(Status::unavailable("network stream closed or invalid")), - } -} - -pub async fn decision(input: &mut Streaming) -> Result { - match input.message().await?.and_then(|chunk| chunk.payload) { - Some(sandbox_frame::Payload::TcpOpenDecision(value)) => { - crate::protocol::tcp_open_decision(value) - } - _ => Err(Status::invalid_argument("network decision required")), - } -} - -pub async fn http_status(input: &mut Streaming) -> Result { - let mut bytes = Vec::new(); - loop { - let data = data(input).await?; - let remaining = 4096 - bytes.len(); - bytes.extend_from_slice(&data[..data.len().min(remaining)]); - if let Some(end) = bytes.windows(2).position(|pair| pair == b"\r\n") { - let line = std::str::from_utf8(&bytes[..end]) - .map_err(|_| Status::invalid_argument("invalid HTTP status"))?; - let mut fields = line.split(' '); - if !matches!(fields.next(), Some("HTTP/1.0" | "HTTP/1.1")) { - return Err(Status::invalid_argument("invalid HTTP response")); - } - return fields - .next() - .and_then(|s| s.parse::().ok()) - .filter(|s| (100..=599).contains(s)) - .ok_or_else(|| Status::invalid_argument("invalid HTTP status")); - } - if bytes.len() == 4096 { - return Err(Status::resource_exhausted("HTTP status line too long")); - } - } -} - -pub fn denied(reason: TcpOpenDenial) -> i32 { - match reason { - TcpOpenDenial::PolicyDenied => POLICY_DENIED, - TcpOpenDenial::MediationUnavailable => UNAVAILABLE, - _ => OTHER_DENIAL, - } -} diff --git a/examples/isolation-backend-v8/src/protocol.rs b/examples/isolation-backend-v8/src/protocol.rs index 09ed01f150..30e793f696 100644 --- a/examples/isolation-backend-v8/src/protocol.rs +++ b/examples/isolation-backend-v8/src/protocol.rs @@ -24,7 +24,7 @@ mod openshell { pub use openshell::extension::v1::{PeerMetadata, ProtocolVersion}; pub use openshell::sandbox::{backend::v1 as control, protocol::v1 as boundary}; -pub const BACKEND: &str = "example-v8"; +pub const BACKEND: &str = "js-sbx"; pub const CAPABILITY: &str = "openshell.sandbox.protocol"; pub fn metadata() -> PeerMetadata { @@ -45,7 +45,7 @@ fn convert(value: T) -> Result, + input: &mut tonic::Streaming, ) -> Result { use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; use prost::Message; @@ -56,23 +56,55 @@ pub async fn read_request( if chunk.encoded_len() > openshell_sandbox_backend::stream_adapter::MAX_MESSAGE_BYTES { return Err(tonic::Status::resource_exhausted("request too large")); } - let Some(boundary::sandbox_frame::Payload::Request(request)) = chunk.payload else { + 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 fn data(bytes: Vec) -> boundary::SandboxFrame { - boundary::SandboxFrame { - payload: Some(boundary::sandbox_frame::Payload::RelayData(bytes)), +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 { +) -> Result { use openshell_sandbox_backend::{boundary_protocol as wire, protobuf::WireMessage}; let value = wire::ResponseEnvelope { request_id, @@ -80,13 +112,13 @@ pub fn response( } .to_proto() .map_err(|error| tonic::Status::internal(error.to_string()))?; - Ok(boundary::SandboxFrame { - payload: Some(boundary::sandbox_frame::Payload::Response(convert(value)?)), + Ok(boundary::ExchangeFrame { + payload: Some(boundary::exchange_frame::Payload::Response(convert(value)?)), }) } -pub fn stream_frame(channel: u8, payload: &[u8]) -> Result { - use boundary::sandbox_frame::Payload; +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}, @@ -109,15 +141,7 @@ pub fn stream_frame(channel: u8, payload: &[u8]) -> Result Result { - use openshell_sandbox_backend::protobuf::WireMessage; - openshell_isolation_interface::contract::TcpOpenDecision::from_proto(convert(value)?) - .map_err(|error| tonic::Status::invalid_argument(error.to_string())) -} diff --git a/examples/isolation-backend-v8/start.sh b/examples/isolation-backend-v8/start.sh index 75dcadeb90..a197e3db6f 100755 --- a/examples/isolation-backend-v8/start.sh +++ b/examples/isolation-backend-v8/start.sh @@ -13,72 +13,72 @@ 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" -v8_state_dir="$(mktemp -d /tmp/openshell-v8.XXXXXX)" -v8_gateway_port="${V8_DEMO_PORT:-18770}" -export OPENSHELL_GATEWAY_ENDPOINT="http://127.0.0.1:$v8_gateway_port" -openshell-gateway generate-certs --output-dir "$v8_state_dir/tls" -cat > "$v8_state_dir/gateway.toml" < "$js_sbx_state_dir/gateway.toml" <=0; i--)); do - kill "${v8_children[i]}" 2>/dev/null || true + for ((i=${#js_sbx_children[@]}-1; i>=0; i--)); do + kill "${js_sbx_children[i]}" 2>/dev/null || true done - for v8_pid in "${v8_children[@]}"; do - wait "$v8_pid" 2>/dev/null || true + 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 -v8-backend backend --state-dir "$v8_state_dir" > "$v8_state_dir/backend.log" 2>&1 & -v8_children+=("$!") -v8-backend driver --state-dir "$v8_state_dir" --script "${1:-guest.js}" > "$v8_state_dir/driver.log" 2>&1 & -v8_children+=("$!") +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 "$v8_state_dir/compute.sock" ]]; then break; fi - if ! kill -0 "${v8_children[1]}" 2>/dev/null; then - cat "$v8_state_dir/driver.log" >&2 + 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 "$v8_state_dir/compute.sock" ]]; then - echo "Timed out waiting for the compute driver; see $v8_state_dir/driver.log" >&2 +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' "$v8_state_dir" +printf '\nDemo state and logs: %s\n' "$js_sbx_state_dir" printf 'Gateway: %s\n' "$OPENSHELL_GATEWAY_ENDPOINT" -printf 'Guest request results: tail -f %q/backend.log\n' "$v8_state_dir" +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 "$v8_state_dir/gateway.toml" \ - --db-url "sqlite://$v8_state_dir/gateway.db?mode=rwc" & -v8_children+=("$!") -wait "${v8_children[2]}" +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/compute_driver.proto b/proto/compute_driver.proto index 1b7bbe95e7..2d3f67b5d7 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -212,8 +212,8 @@ message DriverSandboxSpec { 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.sandbox-backend. - SandboxBackendRegistration isolation_backend = 17; + // runtime. Requires openshell.compute.isolation-backend. + IsolationBackendRegistration isolation_backend = 17; } // Trusted operator registration delivered to the compute driver on every @@ -221,24 +221,24 @@ message DriverSandboxSpec { // 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 SandboxBackendRegistration { +message IsolationBackendRegistration { string name = 1; oneof endpoint { // Absolute path in the supervisor's filesystem namespace. string unix_socket_path = 2; - SandboxBackendTcpEndpoint tcp = 3; - SandboxBackendVsockEndpoint vsock = 4; + IsolationBackendTcpEndpoint tcp = 3; + IsolationBackendVsockEndpoint vsock = 4; } } -message SandboxBackendTcpEndpoint { +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 SandboxBackendVsockEndpoint { +message IsolationBackendVsockEndpoint { uint32 guest_cid = 1; uint32 port = 2; } @@ -461,7 +461,7 @@ message StartSandboxRequest { // 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. - SandboxBackendRegistration isolation_backend = 6; + IsolationBackendRegistration isolation_backend = 6; } message StartSandboxResponse { diff --git a/proto/isolation_backend.proto b/proto/isolation_backend.proto index dc6b352610..fc9a2023c1 100644 --- a/proto/isolation_backend.proto +++ b/proto/isolation_backend.proto @@ -8,6 +8,9 @@ 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 @@ -34,14 +37,13 @@ service IsolationBackend { // conveys no sandbox credentials or driver descriptor. rpc Describe(DescribeIsolationBackendRequest) returns (DescribeIsolationBackendResponse); - // Bind a driver-provisioned resource to one fresh sandbox launch. The - // caller credential must match sandbox_id and generation_id. This call must - // not run untrusted workload code. The subsequent Sandbox Protocol Attach, - // Confirm, and StartAgent sequence remains mandatory. A resource that already is - // the boundary may only validate and reserve its binding, then return a handle. + // 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. A route opened before attach never authorizes execution. + // binding fails. // Driver deletion invalidates all handles and retained-state references, // including abandoned launches and suspended boundaries. rpc OpenBoundary(OpenBoundaryRequest) returns (OpenBoundaryResponse); @@ -53,10 +55,9 @@ service IsolationBackend { // calls and streams are rejected and the old workload cannot make progress. rpc SuspendBoundary(SuspendBoundaryRequest) returns (SuspendBoundaryResponse); - // Restore a suspended boundary into a fresh launch session and generation. - // The restored workload remains frozen until attach, confirm, and start - // complete under the new supervisor: no untrusted workload code executes - // before start. A permitted cold start is reported. Unsupported optional + // 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); } @@ -69,13 +70,12 @@ 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 { - // Stable gateway-assigned sandbox identity, not a public resource name. - string sandbox_id = 1; - // Fresh for each launch and unchanged for retries of that launch. string session_id = 2; - // Fresh runtime generation bound to the supervisor's launch credential. - string generation_id = 3; // Must match trusted admission and the configured endpoint registration. string backend_name = 4; // Opaque descriptor from the trusted compute driver. The backend validates @@ -95,9 +95,8 @@ message OpenBoundaryResponse { LAUNCH_OUTCOME_COLD_START_FALLBACK = 3; } - // Required. A restoration result remains frozen until the new supervisor - // authenticates, confirms enforcement, installs current policy and secrets, - // and explicitly starts the boundary. Fallback must be visible to callers. + // Required. Distinguishes restoration from cold-start fallback; admission + // follows SandboxProtocol for either outcome. LaunchOutcome outcome = 2; } @@ -118,8 +117,8 @@ message SuspendBoundaryResponse { } message ResumeBoundaryRequest { - // Fresh launch identity and trusted driver descriptor. Replays reuse the - // same launch request; the old session and generation are never reused. + // 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. diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 8b4a8e3d3e..40a35cf096 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -9,24 +9,28 @@ 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. -// Admission remains Attach -> Confirm -> StartAgent; no untrusted instruction -// may execute before StartAgent. Exec and forwarding require Running. +// 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 SandboxFrame) returns (stream SandboxFrame); - rpc Mediate(stream SandboxFrame) returns (stream SandboxFrame); + 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 SandboxFrame { +message ExchangeFrame { oneof payload { string boundary_id = 1; RequestEnvelope request = 3; @@ -38,6 +42,16 @@ message SandboxFrame { 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; } @@ -51,18 +65,14 @@ message StdinClosed {} // 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, ExecSignal, Terminate, TerminateBoundary, and Resize. Exec -// outcomes remain retained through the deadline even after process exit. +// Exec, Signal, Terminate, TerminateBoundary, and Resize. Exec outcomes remain +// retained through the deadline even after process exit. message RequestEnvelope { string request_id = 1; - // Required only for Exec. An absolute admission/retry deadline, not a process - // runtime limit; it remains unchanged on retries and transport recovery. - google.protobuf.Timestamp exec_admission_deadline = 2; oneof request { DiscoverPolicy discover_policy = 10; Attach attach = 11; Confirm confirm = 12; - ProbeProviderFiles probe_provider_files = 13; StartAgent start_agent = 14; UpdateProviderEnvironment update_provider_environment = 15; AttachProcess attach_process = 16; @@ -71,7 +81,6 @@ message RequestEnvelope { Terminate terminate = 19; TerminateBoundary terminate_boundary = 20; Exec exec = 21; - ExecSignal exec_signal = 22; Resize resize = 23; LoopbackConnect loopback_connect = 24; OpenMediation open_mediation = 25; @@ -84,7 +93,6 @@ message ResponseEnvelope { ImagePolicy image_policy = 10; Attached attached = 11; Confirmed confirmed = 12; - ProviderFilesSupported provider_files_supported = 13; Started started = 14; ProviderEnvironmentUpdated provider_environment_updated = 15; ProcessAttached process_attached = 16; @@ -100,30 +108,31 @@ message ResponseEnvelope { Error error = 26; } } +// Read-only image-policy discovery, permitted before attachment. message DiscoverPolicy {} message Attach { string supervisor_instance_id = 1; SandboxPolicy policy = 2; - map resource_claims = 3; } // Success asserts that the backend can enforce the attached policy and trusted -// workload identity. Unsupported requirements fail before StartAgent. No -// untrusted instruction may execute until StartAgent installs all launch-time -// controls. Supervisor or transport loss must never relax enforcement or open -// an unmediated egress path; supervisor-dependent operations fail closed. +// 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 {} -message ProbeProviderFiles {} +// 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; - SandboxPolicy policy = 3; 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; @@ -136,6 +145,7 @@ message AttachProcess { 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; @@ -146,10 +156,9 @@ message Terminate { message TerminateBoundary {} message Exec { ExecSpec spec = 1; -} -message ExecSignal { - string process_id = 1; - BoundarySignal signal = 2; + // 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; @@ -164,6 +173,8 @@ message LoopbackConnect { 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; @@ -174,7 +185,6 @@ message Attached { message Confirmed { BoundaryConfirmation confirmation = 1; } -message ProviderFilesSupported {} message Started { string process_id = 1; uint64 provider_env_revision = 2; @@ -305,9 +315,11 @@ 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 opaque backend-owned audit document. Native clients may validate - // its schema; external backends need not provide Linux mechanism evidence. + // 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 From 6c5072a3d2d2879d60cdba613ae50d6f6fb0c3e3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 16 Oct 2026 11:31:24 -0700 Subject: [PATCH 24/24] fix(sandbox): deliver TCP denials before closing the RPC Signed-off-by: Drew Newberry --- .../openshell-sandbox-backend/src/runtime.rs | 9 ++++++ .../src/runtime/tests/network_recovery.rs | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 1be26d12c7..bcda27830c 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -912,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; } } 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 4ca5a60605..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, } @@ -183,7 +184,8 @@ impl NetworkPeer { | Reply::DisconnectLate | Reply::DisconnectGated | Reply::DropFirstAccept - | Reply::Blackhole => network_response(), + | Reply::Blackhole + | Reply::Deny => network_response(), } } _ => unreachable!(), @@ -202,6 +204,14 @@ impl NetworkPeer { 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!( crate::protobuf::decode::(&payload).unwrap(), TcpOpenDecision::RelayReady @@ -383,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;