From b026b644a1f5283bf09eabd1583798ce8df09634 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 2 Jul 2026 23:15:42 +0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(capsule)!:=20serve=20astrid:process=201?= =?UTF-8?q?.0.0=20and=201.1.0=20dual-version=20=E2=80=94=20restore=20compa?= =?UTF-8?q?t=20with=20sdk-0.7.x=20capsules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-spawn file-injection extension (#881) originally landed in-place on the frozen astrid:process@1.0.0 WIT (wit#15), adding a file-injections field to spawn-request. The component-model linker matches host imports structurally by package version, so every capsule built against the published pre-extension contract (all shipped capsules, on astrid-sys 0.7.x) failed to instantiate on 0.9.0: 'component imports instance astrid:process/host@1.0.0, but a matching implementation was not found in the linker'. The sage supervisor could not astrid init and astrid-capsule-shell v0.2.0 failed to load. Fix mirrors the existing astrid:http@1.0.0/@1.1.0 dual-version precedent: the kernel world imports both astrid:process/host@1.0.0 (restored to its published shape) and astrid:process/host@1.1.0 (the additive successor carrying the injection surface). Bindgen emits per-version modules; the single Kernel::add_to_linker call in configure_kernel_linker wires both, so the engine and lifecycle linkers serve both versions. The @1.1.0 Host/HostProcessHandle impls carry the real backend (mod.rs/handle.rs, injections included); the @1.0.0 impls are thin shims (compat.rs) that convert record/enum shapes and delegate with an empty file-injections list, so @1.0.0 spawn behaves as spawn-with-no-injections. Audit/capability/quota paths are shared and version-agnostic. Existing capsules load again with no rebuild. Refs #1107, wit#19, #881, #890. Claude-Session: https://claude.ai/code/session_01NvX2tE7tgXuCRevqqiXTGU --- CHANGELOG.md | 8 + .../src/engine/wasm/bindings.rs | 1 + .../src/engine/wasm/host/process/compat.rs | 636 +++++++++++++++++ .../src/engine/wasm/host/process/handle.rs | 2 +- .../src/engine/wasm/host/process/inject.rs | 2 +- .../src/engine/wasm/host/process/mod.rs | 12 +- .../wasm/host/process/persistent/config.rs | 2 +- .../wasm/host/process/persistent/entry.rs | 2 +- .../wasm/host/process/persistent/mod.rs | 2 +- .../host/process/persistent/registry_tests.rs | 6 +- .../wasm/host/process/persistent/ring.rs | 2 +- .../astrid-process@1.0.0/process@1.0.0.wit | 67 -- .../astrid-process@1.1.0/process@1.1.0.wit | 638 ++++++++++++++++++ 13 files changed, 1302 insertions(+), 78 deletions(-) create mode 100644 crates/astrid-capsule/src/engine/wasm/host/process/compat.rs create mode 100644 crates/astrid-capsule/wit-staging/deps/astrid-process@1.1.0/process@1.1.0.wit diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4623017..677741e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ## [Unreleased] +### Added + +- **`astrid:process@1.1.0` host interface.** Additive successor to the frozen `astrid:process@1.0.0`, carrying the host-verified, read-only per-spawn file-injection surface (the `file-injection` record, the `injection-placement` variant, and the `spawn-request.file-injections` field). The kernel serves both versions off one implementation; capsules opt into injection by importing `@1.1.0`, while `@1.0.0` behaves as spawn-with-no-injections. Refs #1107, wit#19, #881. + +### Fixed + +- **Shipped capsules that import `astrid:process@1.0.0` instantiate again — no rebuild required.** The per-spawn file-injection extension originally landed in-place on the frozen `astrid:process@1.0.0` WIT (wit#15), changing the shape of `spawn-request`. The component-model linker matches host imports structurally by package version, so every capsule compiled against the published pre-extension contract (all shipped capsules, built on astrid-sys 0.7.x) failed to instantiate on 0.9.0 with `component imports instance astrid:process/host@1.0.0, but a matching implementation was not found in the linker` — the sage supervisor could not `astrid init` and `astrid-capsule-shell` v0.2.0 failed to load. The kernel now restores `@1.0.0` to its published shape and serves it alongside the additive `@1.1.0` (dual-version host, mirroring the `astrid:http@1.0.0`/`@1.1.0` precedent), so existing capsules load unchanged. Refs #1107, wit#19, #881, #890. + ## [0.9.0] - 2026-07-02 ### Breaking diff --git a/crates/astrid-capsule/src/engine/wasm/bindings.rs b/crates/astrid-capsule/src/engine/wasm/bindings.rs index 4455f3876..9f70d88dc 100644 --- a/crates/astrid-capsule/src/engine/wasm/bindings.rs +++ b/crates/astrid-capsule/src/engine/wasm/bindings.rs @@ -61,6 +61,7 @@ wasmtime::component::bindgen!({ import astrid:http/host@1.1.0; import astrid:sys/host@1.0.0; import astrid:process/host@1.0.0; + import astrid:process/host@1.1.0; import astrid:uplink/host@1.0.0; import astrid:elicit/host@1.0.0; import astrid:approval/host@1.0.0; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/compat.rs b/crates/astrid-capsule/src/engine/wasm/host/process/compat.rs new file mode 100644 index 000000000..23f04c268 --- /dev/null +++ b/crates/astrid-capsule/src/engine/wasm/host/process/compat.rs @@ -0,0 +1,636 @@ +//! `astrid:process@1.0.0` compatibility shims. +//! +//! Both contract versions are backed by ONE implementation. The real backend +//! lives in `mod.rs` / `handle.rs` behind the `@1.1.0` trait impls (aliased +//! `v11` here); `@1.1.0` is the SUPERSET — it carries the per-spawn read-only +//! `file-injection` surface (#881) that `@1.0.0` never had. +//! +//! The `@1.0.0` `Host` / `HostProcessHandle` impls below are THIN SHIMS: every +//! method converts the `@1.0.0` record/enum shapes to their `@1.1.0` twins, +//! delegates to the `@1.1.0` impl, and converts the result back. `spawn`, +//! `spawn-background`, and `spawn-persistent` build a `@1.1.0` `spawn-request` +//! with an EMPTY `file-injections` list — so on the `@1.0.0` path spawn behaves +//! exactly as spawn-with-no-injections, reproducing the published behaviour. No +//! spawn logic is duplicated; audit / capability / quota paths are shared and +//! version-agnostic (they run inside the `@1.1.0` impl). +//! +//! Why this exists: the injection extension originally landed in-place on the +//! frozen `astrid:process@1.0.0` WIT (wit#15), which structurally broke every +//! capsule built against the published contract — the component-model linker +//! matches package versions by structure, so a capsule importing the old +//! `spawn-request` shape no longer found a matching host implementation +//! (#1107). Restoring `@1.0.0` to its published shape and re-homing the +//! extension on an additive `@1.1.0` (wit#19) lets old and new capsules coexist +//! off the same linker. +//! +//! Resource bridging: `process-handle` is a host-owned resource stored in the +//! shared resource table as a concrete `ManagedProcess`, keyed by rep. The +//! `@1.0.0` and `@1.1.0` `ProcessHandle` marker types are distinct zero-sized +//! bindgen structs over the SAME rep space, so `Resource::new_*` re-tags a +//! handle between versions for free (same pattern as `http`'s `HttpStream`). + +use wasmtime::component::Resource; +use wasmtime_wasi::p2::DynPollable; + +use crate::engine::wasm::bindings::astrid::process1_0_0::host as v10; +use crate::engine::wasm::bindings::astrid::process1_1_0::host as v11; +use crate::engine::wasm::host_state::HostState; + +// ── @1.1.0 ⇄ @1.0.0 type bridging ────────────────────────────────────── +// +// The two contract versions generate DISTINCT Rust types for every shared +// shape. Only `spawn-request` differs structurally (`@1.1.0` adds +// `file-injections`); the rest are field-for-field identical but nominally +// separate, so each still needs an explicit boundary conversion. + +/// Map a `@1.1.0` `error-code` back to the `@1.0.0` arm set. The arms are +/// identical between versions (the injection extension added no error-code), +/// so this is a total 1:1 mapping — no wildcard, so a future `@1.1.0`-only arm +/// forces a compile error here rather than a silent mis-map. +pub(super) fn err_v11_to_v10(e: v11::ErrorCode) -> v10::ErrorCode { + match e { + v11::ErrorCode::CapabilityDenied => v10::ErrorCode::CapabilityDenied, + v11::ErrorCode::InvalidInput => v10::ErrorCode::InvalidInput, + v11::ErrorCode::BoundaryEscape => v10::ErrorCode::BoundaryEscape, + v11::ErrorCode::Quota => v10::ErrorCode::Quota, + v11::ErrorCode::TooLarge => v10::ErrorCode::TooLarge, + v11::ErrorCode::Closed => v10::ErrorCode::Closed, + v11::ErrorCode::Cancelled => v10::ErrorCode::Cancelled, + v11::ErrorCode::WaitTimeout => v10::ErrorCode::WaitTimeout, + v11::ErrorCode::NoSuchProcess => v10::ErrorCode::NoSuchProcess, + v11::ErrorCode::RegistryFull => v10::ErrorCode::RegistryFull, + v11::ErrorCode::PersistUnsupported => v10::ErrorCode::PersistUnsupported, + v11::ErrorCode::Unknown(s) => v10::ErrorCode::Unknown(s), + } +} + +fn exit_v11_to_v10(e: v11::ExitInfo) -> v10::ExitInfo { + v10::ExitInfo { + exit_code: e.exit_code, + signal: e.signal, + } +} + +fn process_result_v11_to_v10(r: v11::ProcessResult) -> v10::ProcessResult { + v10::ProcessResult { + stdout: r.stdout, + stderr: r.stderr, + exit: exit_v11_to_v10(r.exit), + } +} + +fn read_logs_v11_to_v10(r: v11::ReadLogsResult) -> v10::ReadLogsResult { + v10::ReadLogsResult { + stdout: r.stdout, + stderr: r.stderr, + running: r.running, + exit: r.exit.map(exit_v11_to_v10), + } +} + +fn kill_result_v11_to_v10(r: v11::KillResult) -> v10::KillResult { + v10::KillResult { + killed: r.killed, + exit: r.exit.map(exit_v11_to_v10), + stdout: r.stdout, + stderr: r.stderr, + } +} + +fn phase_v11_to_v10(p: v11::ProcessPhase) -> v10::ProcessPhase { + match p { + v11::ProcessPhase::Starting => v10::ProcessPhase::Starting, + v11::ProcessPhase::Running => v10::ProcessPhase::Running, + v11::ProcessPhase::Exited => v10::ProcessPhase::Exited, + } +} + +fn process_info_v11_to_v10(i: v11::ProcessInfo) -> v10::ProcessInfo { + v10::ProcessInfo { + id: i.id, + label: i.label, + command: i.command, + os_pid: i.os_pid, + phase: phase_v11_to_v10(i.phase), + exit: i.exit.map(exit_v11_to_v10), + age_ms: i.age_ms, + idle_ms: i.idle_ms, + buffered_bytes: i.buffered_bytes, + bytes_dropped: i.bytes_dropped, + stdin_open: i.stdin_open, + cpu_ms: i.cpu_ms, + mem_bytes_peak: i.mem_bytes_peak, + } +} + +fn cursor_v11_to_v10(c: v11::LogCursor) -> v10::LogCursor { + v10::LogCursor { token: c.token } +} + +fn cursor_v10_to_v11(c: v10::LogCursor) -> v11::LogCursor { + v11::LogCursor { token: c.token } +} + +fn log_chunk_v11_to_v10(c: v11::LogChunk) -> v10::LogChunk { + v10::LogChunk { + data: c.data, + next: cursor_v11_to_v10(c.next), + bytes_dropped: c.bytes_dropped, + drained_eof: c.drained_eof, + } +} + +fn log_stream_v10_to_v11(s: v10::LogStream) -> v11::LogStream { + match s { + v10::LogStream::Stdout => v11::LogStream::Stdout, + v10::LogStream::Stderr => v11::LogStream::Stderr, + } +} + +fn signal_v10_to_v11(s: v10::ProcessSignal) -> v11::ProcessSignal { + match s { + v10::ProcessSignal::Term => v11::ProcessSignal::Term, + v10::ProcessSignal::Hup => v11::ProcessSignal::Hup, + v10::ProcessSignal::Usr1 => v11::ProcessSignal::Usr1, + v10::ProcessSignal::Usr2 => v11::ProcessSignal::Usr2, + v10::ProcessSignal::Int => v11::ProcessSignal::Int, + v10::ProcessSignal::Stop => v11::ProcessSignal::Stop, + v10::ProcessSignal::Cont => v11::ProcessSignal::Cont, + } +} + +fn env_var_v10_to_v11(e: v10::EnvVar) -> v11::EnvVar { + v11::EnvVar { + key: e.key, + value: e.value, + } +} + +fn overflow_v10_to_v11(o: v10::OverflowPolicy) -> v11::OverflowPolicy { + match o { + v10::OverflowPolicy::DropOldest => v11::OverflowPolicy::DropOldest, + v10::OverflowPolicy::Backpressure => v11::OverflowPolicy::Backpressure, + } +} + +fn resource_limits_v10_to_v11(l: v10::ResourceLimits) -> v11::ResourceLimits { + v11::ResourceLimits { + max_memory_bytes: l.max_memory_bytes, + max_cpu_secs: l.max_cpu_secs, + max_pids: l.max_pids, + max_open_files: l.max_open_files, + } +} + +/// Map a `@1.0.0` `spawn-request` to `@1.1.0` with an EMPTY `file-injections` +/// list. Empty injections == the published `@1.0.0` behaviour: the host +/// snapshots/binds nothing extra into the child's sandbox. +fn spawn_request_v10_to_v11(r: v10::SpawnRequest) -> v11::SpawnRequest { + v11::SpawnRequest { + cmd: r.cmd, + args: r.args, + stdin: r.stdin, + env: r.env.into_iter().map(env_var_v10_to_v11).collect(), + cwd: r.cwd, + limits: r.limits.map(resource_limits_v10_to_v11), + label: r.label, + keep_stdin_open: r.keep_stdin_open, + overflow: r.overflow.map(overflow_v10_to_v11), + log_ring_bytes: r.log_ring_bytes, + max_lifetime_ms: r.max_lifetime_ms, + idle_timeout_ms: r.idle_timeout_ms, + exit_retention_ms: r.exit_retention_ms, + // `@1.0.0` has no injection surface: the child sees exactly what its + // args / env / cwd / stdin dictate, nothing host-injected. + file_injections: Vec::new(), + } +} + +/// Re-tag a `@1.0.0` handle as the `@1.1.0` marker over the SAME rep. Both +/// index one `ManagedProcess` in the shared resource table, so this is a +/// zero-cost nominal re-tag, not a table operation. +fn handle_v10_to_v11(h: &Resource) -> Resource { + Resource::new_borrow(h.rep()) +} + +// ── @1.0.0 host impl (thin shims over the @1.1.0 backend) ─────────────── + +impl v10::Host for HostState { + fn spawn(&mut self, request: v10::SpawnRequest) -> Result { + v11::Host::spawn(self, spawn_request_v10_to_v11(request)) + .map(process_result_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn spawn_background( + &mut self, + request: v10::SpawnRequest, + ) -> Result, v10::ErrorCode> { + match v11::Host::spawn_background(self, spawn_request_v10_to_v11(request)) { + Ok(h) => Ok(Resource::new_own(h.rep())), + Err(e) => Err(err_v11_to_v10(e)), + } + } + + fn spawn_persistent(&mut self, request: v10::SpawnRequest) -> Result { + v11::Host::spawn_persistent(self, spawn_request_v10_to_v11(request)).map_err(err_v11_to_v10) + } + + fn attach(&mut self, id: String) -> Result, v10::ErrorCode> { + match v11::Host::attach(self, id) { + Ok(h) => Ok(Resource::new_own(h.rep())), + Err(e) => Err(err_v11_to_v10(e)), + } + } + + fn list_processes( + &mut self, + label_filter: Option, + ) -> Result, v10::ErrorCode> { + v11::Host::list_processes(self, label_filter) + .map(|v| v.into_iter().map(process_info_v11_to_v10).collect()) + .map_err(err_v11_to_v10) + } + + fn status(&mut self, id: String) -> Result { + v11::Host::status(self, id) + .map(process_info_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn status_many(&mut self, ids: Vec) -> Result, v10::ErrorCode> { + v11::Host::status_many(self, ids) + .map(|v| v.into_iter().map(process_info_v11_to_v10).collect()) + .map_err(err_v11_to_v10) + } + + fn read_logs(&mut self, id: String) -> Result { + v11::Host::read_logs(self, id) + .map(read_logs_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn read_since( + &mut self, + id: String, + which_stream: v10::LogStream, + cursor: v10::LogCursor, + max_bytes: u32, + ) -> Result { + v11::Host::read_since( + self, + id, + log_stream_v10_to_v11(which_stream), + cursor_v10_to_v11(cursor), + max_bytes, + ) + .map(log_chunk_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn write_stdin(&mut self, id: String, data: Vec) -> Result { + v11::Host::write_stdin(self, id, data).map_err(err_v11_to_v10) + } + + fn close_stdin(&mut self, id: String) -> Result<(), v10::ErrorCode> { + v11::Host::close_stdin(self, id).map_err(err_v11_to_v10) + } + + fn signal(&mut self, id: String, sig: v10::ProcessSignal) -> Result<(), v10::ErrorCode> { + v11::Host::signal(self, id, signal_v10_to_v11(sig)).map_err(err_v11_to_v10) + } + + fn wait(&mut self, id: String, timeout_ms: u64) -> Result { + v11::Host::wait(self, id, timeout_ms) + .map(exit_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn stop(&mut self, id: String, grace_ms: Option) -> Result { + v11::Host::stop(self, id, grace_ms) + .map(exit_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn release_process(&mut self, id: String) -> Result<(), v10::ErrorCode> { + v11::Host::release_process(self, id).map_err(err_v11_to_v10) + } + + fn watch(&mut self, id: String, suffix: Option) -> Result<(), v10::ErrorCode> { + v11::Host::watch(self, id, suffix).map_err(err_v11_to_v10) + } + + fn unwatch(&mut self, id: String) -> Result<(), v10::ErrorCode> { + v11::Host::unwatch(self, id).map_err(err_v11_to_v10) + } +} + +impl v10::HostProcessHandle for HostState { + fn read_logs( + &mut self, + self_: Resource, + ) -> Result { + v11::HostProcessHandle::read_logs(self, handle_v10_to_v11(&self_)) + .map(read_logs_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn write_stdin( + &mut self, + self_: Resource, + data: Vec, + ) -> Result { + v11::HostProcessHandle::write_stdin(self, handle_v10_to_v11(&self_), data) + .map_err(err_v11_to_v10) + } + + fn close_stdin(&mut self, self_: Resource) -> Result<(), v10::ErrorCode> { + v11::HostProcessHandle::close_stdin(self, handle_v10_to_v11(&self_)).map_err(err_v11_to_v10) + } + + fn signal( + &mut self, + self_: Resource, + sig: v10::ProcessSignal, + ) -> Result<(), v10::ErrorCode> { + v11::HostProcessHandle::signal(self, handle_v10_to_v11(&self_), signal_v10_to_v11(sig)) + .map_err(err_v11_to_v10) + } + + fn kill( + &mut self, + self_: Resource, + ) -> Result { + v11::HostProcessHandle::kill(self, handle_v10_to_v11(&self_)) + .map(kill_result_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn wait( + &mut self, + self_: Resource, + timeout_ms: Option, + ) -> Result { + v11::HostProcessHandle::wait(self, handle_v10_to_v11(&self_), timeout_ms) + .map(exit_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn wait_with_output( + &mut self, + self_: Resource, + timeout_ms: Option, + ) -> Result { + v11::HostProcessHandle::wait_with_output(self, handle_v10_to_v11(&self_), timeout_ms) + .map(process_result_v11_to_v10) + .map_err(err_v11_to_v10) + } + + fn os_pid(&mut self, self_: Resource) -> Result { + v11::HostProcessHandle::os_pid(self, handle_v10_to_v11(&self_)).map_err(err_v11_to_v10) + } + + fn subscribe_exit(&mut self, self_: Resource) -> Resource { + v11::HostProcessHandle::subscribe_exit(self, handle_v10_to_v11(&self_)) + } + + fn subscribe_logs(&mut self, self_: Resource) -> Resource { + v11::HostProcessHandle::subscribe_logs(self, handle_v10_to_v11(&self_)) + } + + fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + // Own re-tag: the `@1.1.0` drop consumes the handle and deletes the + // backing `ManagedProcess` from the shared table by rep. + v11::HostProcessHandle::drop(self, Resource::new_own(rep.rep())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every `@1.1.0` error arm maps to the identically-named `@1.0.0` arm. + /// The two arm sets are equal (injection added no error-code), so this is + /// a faithful 1:1 — mirrors http's `v11_error_maps_to_v10_arm_set`. + #[test] + fn err_v11_to_v10_is_total_and_identity() { + use v10::ErrorCode as A; + use v11::ErrorCode as B; + assert!(matches!( + err_v11_to_v10(B::CapabilityDenied), + A::CapabilityDenied + )); + assert!(matches!(err_v11_to_v10(B::InvalidInput), A::InvalidInput)); + assert!(matches!( + err_v11_to_v10(B::BoundaryEscape), + A::BoundaryEscape + )); + assert!(matches!(err_v11_to_v10(B::Quota), A::Quota)); + assert!(matches!(err_v11_to_v10(B::TooLarge), A::TooLarge)); + assert!(matches!(err_v11_to_v10(B::Closed), A::Closed)); + assert!(matches!(err_v11_to_v10(B::Cancelled), A::Cancelled)); + assert!(matches!(err_v11_to_v10(B::WaitTimeout), A::WaitTimeout)); + assert!(matches!(err_v11_to_v10(B::NoSuchProcess), A::NoSuchProcess)); + assert!(matches!(err_v11_to_v10(B::RegistryFull), A::RegistryFull)); + assert!(matches!( + err_v11_to_v10(B::PersistUnsupported), + A::PersistUnsupported + )); + match err_v11_to_v10(B::Unknown("boom".into())) { + A::Unknown(s) => assert_eq!(s, "boom"), + other => panic!("Unknown did not round-trip: {other:?}"), + } + } + + /// A `@1.0.0` spawn becomes a `@1.1.0` spawn-with-NO-injections, with every + /// other field carried through byte-for-byte. This is the core compat + /// invariant: old capsules get exactly their published spawn semantics. + #[test] + fn v10_spawn_request_becomes_v11_with_empty_injections() { + let req = v10::SpawnRequest { + cmd: "echo".into(), + args: vec!["hi".into(), "there".into()], + stdin: Some(vec![1, 2, 3]), + env: vec![v10::EnvVar { + key: "K".into(), + value: "V".into(), + }], + cwd: Some("sub/dir".into()), + limits: Some(v10::ResourceLimits { + max_memory_bytes: Some(1024), + max_cpu_secs: Some(5), + max_pids: Some(8), + max_open_files: Some(64), + }), + label: Some("job".into()), + keep_stdin_open: Some(true), + overflow: Some(v10::OverflowPolicy::Backpressure), + log_ring_bytes: Some(4096), + max_lifetime_ms: Some(60_000), + idle_timeout_ms: Some(30_000), + exit_retention_ms: Some(15_000), + }; + + let out = spawn_request_v10_to_v11(req); + + assert!( + out.file_injections.is_empty(), + "@1.0.0 spawn must inject nothing" + ); + assert_eq!(out.cmd, "echo"); + assert_eq!(out.args, vec!["hi".to_string(), "there".to_string()]); + assert_eq!(out.stdin, Some(vec![1, 2, 3])); + assert_eq!(out.env.len(), 1); + assert_eq!(out.env[0].key, "K"); + assert_eq!(out.env[0].value, "V"); + assert_eq!(out.cwd.as_deref(), Some("sub/dir")); + let limits = out.limits.expect("limits preserved"); + assert_eq!(limits.max_memory_bytes, Some(1024)); + assert_eq!(limits.max_cpu_secs, Some(5)); + assert_eq!(limits.max_pids, Some(8)); + assert_eq!(limits.max_open_files, Some(64)); + assert_eq!(out.label.as_deref(), Some("job")); + assert_eq!(out.keep_stdin_open, Some(true)); + assert!(matches!( + out.overflow, + Some(v11::OverflowPolicy::Backpressure) + )); + assert_eq!(out.log_ring_bytes, Some(4096)); + assert_eq!(out.max_lifetime_ms, Some(60_000)); + assert_eq!(out.idle_timeout_ms, Some(30_000)); + assert_eq!(out.exit_retention_ms, Some(15_000)); + } + + /// A fully-populated `@1.1.0` `process-info` round-trips to `@1.0.0` with + /// every field and the phase enum preserved. + #[test] + fn process_info_round_trips_v11_to_v10() { + let info = v11::ProcessInfo { + id: "abc".into(), + label: "svc".into(), + command: "sleep 1".into(), + os_pid: Some(4321), + phase: v11::ProcessPhase::Running, + exit: Some(v11::ExitInfo { + exit_code: Some(0), + signal: None, + }), + age_ms: 100, + idle_ms: 10, + buffered_bytes: 2048, + bytes_dropped: 3, + stdin_open: true, + cpu_ms: Some(7), + mem_bytes_peak: Some(9999), + }; + + let out = process_info_v11_to_v10(info); + assert_eq!(out.id, "abc"); + assert_eq!(out.label, "svc"); + assert_eq!(out.command, "sleep 1"); + assert_eq!(out.os_pid, Some(4321)); + assert!(matches!(out.phase, v10::ProcessPhase::Running)); + let exit = out.exit.expect("exit preserved"); + assert_eq!(exit.exit_code, Some(0)); + assert_eq!(exit.signal, None); + assert_eq!(out.age_ms, 100); + assert_eq!(out.idle_ms, 10); + assert_eq!(out.buffered_bytes, 2048); + assert_eq!(out.bytes_dropped, 3); + assert!(out.stdin_open); + assert_eq!(out.cpu_ms, Some(7)); + assert_eq!(out.mem_bytes_peak, Some(9999)); + } + + /// Input enums map 1:1 across the version boundary. + #[test] + fn signal_and_log_stream_map_across_versions() { + assert!(matches!( + signal_v10_to_v11(v10::ProcessSignal::Term), + v11::ProcessSignal::Term + )); + assert!(matches!( + signal_v10_to_v11(v10::ProcessSignal::Cont), + v11::ProcessSignal::Cont + )); + assert!(matches!( + log_stream_v10_to_v11(v10::LogStream::Stderr), + v11::LogStream::Stderr + )); + } + + /// A minimal component that imports `astrid:process/host@{version}` and + /// depends on the `release-process` function (whose signature is identical + /// across both contract versions). A NON-EMPTY import forces the wasmtime + /// linker to actually provide the named+versioned instance — an EMPTY + /// `(instance)` import is trivially satisfiable and would not discriminate. + fn process_import_component(version: &str) -> String { + format!( + r#"(component + (import "astrid:process/host@{version}" (instance + (type $error-code (variant + (case "capability-denied") (case "invalid-input") + (case "boundary-escape") (case "quota") (case "too-large") + (case "closed") (case "cancelled") (case "wait-timeout") + (case "no-such-process") (case "registry-full") + (case "persist-unsupported") (case "unknown" string))) + (export "error-code" (type $ec (eq $error-code))) + (type $rel (func (param "id" string) (result (result (error $ec))))) + (export "release-process" (func (type $rel))))))"# + ) + } + + /// Negative control: a component importing a `process` version the kernel + /// does NOT serve fails to link. This is what proves the two positive + /// assertions below actually exercise registration (rather than passing + /// vacuously) — the component-model linker matches by exact package+version. + #[test] + fn unknown_process_version_is_not_served() { + let engine = crate::engine::wasm::build_wasmtime_engine().unwrap(); + let mut linker = wasmtime::component::Linker::::new(&engine); + crate::engine::wasm::configure_kernel_linker(&mut linker).unwrap(); + let component = + wasmtime::component::Component::new(&engine, process_import_component("9.9.9")) + .unwrap(); + let res = linker.instantiate_pre(&component).map(|_| ()); + assert!(res.is_err(), "unserved version must NOT link: {res:?}"); + } + + /// The regression guard: the kernel linker resolves BOTH the restored + /// `astrid:process/host@1.0.0` AND the additive `@1.1.0`. Before the + /// dual-version fix a capsule built against either published shape hit + /// "a matching implementation was not found in the linker" (#1107). + #[test] + fn both_process_versions_resolve_in_kernel_linker() { + let engine = crate::engine::wasm::build_wasmtime_engine().unwrap(); + let mut linker = wasmtime::component::Linker::::new(&engine); + crate::engine::wasm::configure_kernel_linker(&mut linker).unwrap(); + for v in ["1.0.0", "1.1.0"] { + let component = + wasmtime::component::Component::new(&engine, process_import_component(v)).unwrap(); + let res = linker.instantiate_pre(&component).map(|_| ()); + assert!( + res.is_ok(), + "process@{v} must resolve in the linker: {res:?}" + ); + } + } + + /// `log-chunk` (with its nested cursor) round-trips back to `@1.0.0`. + #[test] + fn log_chunk_round_trips_v11_to_v10() { + let chunk = v11::LogChunk { + data: vec![9, 8, 7], + next: v11::LogCursor { + token: Some("cur".into()), + }, + bytes_dropped: 42, + drained_eof: true, + }; + let out = log_chunk_v11_to_v10(chunk); + assert_eq!(out.data, vec![9, 8, 7]); + assert_eq!(out.next.token.as_deref(), Some("cur")); + assert_eq!(out.bytes_dropped, 42); + assert!(out.drained_eof); + } +} diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs b/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs index 16a346758..d11ea6962 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs @@ -19,7 +19,7 @@ use wasmtime::component::Resource; use wasmtime_wasi::p2::DynPollable; use super::managed::{ManagedProcess, drain_buffer, kill_and_reap}; -use crate::engine::wasm::bindings::astrid::process::host::{ +use crate::engine::wasm::bindings::astrid::process1_1_0::host::{ ErrorCode, ExitInfo, HostProcessHandle, KillResult, ProcessHandle, ProcessResult, ProcessSignal, ReadLogsResult, }; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs b/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs index 0fe0b37bc..de9a6b799 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs @@ -40,7 +40,7 @@ use std::path::PathBuf; use astrid_crypto::ContentHash; -use crate::engine::wasm::bindings::astrid::process::host::{ +use crate::engine::wasm::bindings::astrid::process1_1_0::host::{ ErrorCode, FileInjection, InjectionPlacement, }; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs index 44ce55814..791ba0801 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs @@ -1,4 +1,11 @@ -//! `astrid:process@1.0.0` host implementation. +//! `astrid:process@1.1.0` host implementation (the `@1.0.0` shims live in +//! `compat.rs`). +//! +//! Both frozen contract versions are served off one implementation. This +//! module implements the `@1.1.0` `Host` / `HostProcessHandle` traits — the +//! SUPERSET, carrying the per-spawn read-only `file-injection` surface. The +//! `@1.0.0` traits are thin delegating shims in `compat.rs` that spawn with an +//! empty injection list; see that module for the version-bridging rationale. //! //! Desktop-only package (the WIT header explicitly notes hermit-rs //! unikernel targets do not provide it). The kernel here: @@ -18,6 +25,7 @@ //! table is the canonical storage for `ManagedProcess`. mod audit; +mod compat; mod handle; mod inject; mod managed; @@ -32,7 +40,7 @@ use tokio::process::Command as TokioCommand; use tracing::warn; use wasmtime::component::Resource; -use crate::engine::wasm::bindings::astrid::process::host::{ +use crate::engine::wasm::bindings::astrid::process1_1_0::host::{ self as process, EnvVar, ErrorCode, ExitInfo, LogChunk, LogCursor, LogStream, ProcessHandle, ProcessInfo, ProcessResult, ProcessSignal, ReadLogsResult, SpawnRequest, }; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/config.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/config.rs index 328d86902..ac75b01f7 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/config.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/config.rs @@ -5,7 +5,7 @@ use std::time::Duration; -use crate::engine::wasm::bindings::astrid::process::host::OverflowPolicy; +use crate::engine::wasm::bindings::astrid::process1_1_0::host::OverflowPolicy; use super::ring::Overflow; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs index 79cc41463..3ceac9e5f 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use astrid_core::principal::PrincipalId; use tokio::sync::watch; -use crate::engine::wasm::bindings::astrid::process::host::{ +use crate::engine::wasm::bindings::astrid::process1_1_0::host::{ ErrorCode, ExitInfo, ProcessInfo, ProcessPhase, ProcessSignal, }; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs index beabb68fa..ed7e19e0f 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs @@ -59,7 +59,7 @@ use rand::{TryRng, rngs::SysRng}; use tokio::io::AsyncWriteExt; use tokio::sync::watch; -use crate::engine::wasm::bindings::astrid::process::host::{ +use crate::engine::wasm::bindings::astrid::process1_1_0::host::{ ErrorCode, ExitInfo, LogChunk, LogCursor, LogStream, OverflowPolicy, ProcessInfo, ProcessSignal, ReadLogsResult, }; diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs index 9e4b372c1..9702b675f 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs @@ -195,7 +195,7 @@ async fn concurrent_cap_enforced_and_stop_reaps() { assert!( matches!( err, - crate::engine::wasm::bindings::astrid::process::host::ErrorCode::Quota + crate::engine::wasm::bindings::astrid::process1_1_0::host::ErrorCode::Quota ), "expected Quota, got {err:?}" ); @@ -216,7 +216,7 @@ async fn concurrent_cap_enforced_and_stop_reaps() { #[tokio::test] async fn read_since_is_non_draining_with_cursor() { - use crate::engine::wasm::bindings::astrid::process::host::{LogCursor, LogStream}; + use crate::engine::wasm::bindings::astrid::process1_1_0::host::{LogCursor, LogStream}; let reg = PersistentProcessRegistry::new(tokio::runtime::Handle::current()); let p = PrincipalId::new("alice").unwrap(); @@ -268,7 +268,7 @@ async fn read_since_is_non_draining_with_cursor() { #[tokio::test] async fn write_stdin_delivers_survives_reset_and_close_eofs() { - use crate::engine::wasm::bindings::astrid::process::host::ErrorCode; + use crate::engine::wasm::bindings::astrid::process1_1_0::host::ErrorCode; let reg = PersistentProcessRegistry::new(tokio::runtime::Handle::current()); let alice = PrincipalId::new("alice").unwrap(); diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/ring.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/ring.rs index adec1d578..80151f0d6 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/ring.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/ring.rs @@ -6,7 +6,7 @@ use std::collections::VecDeque; -use crate::engine::wasm::bindings::astrid::process::host::{ErrorCode, LogCursor}; +use crate::engine::wasm::bindings::astrid::process1_1_0::host::{ErrorCode, LogCursor}; /// Ring overflow behaviour for one stream. #[derive(Clone, Copy)] diff --git a/crates/astrid-capsule/wit-staging/deps/astrid-process@1.0.0/process@1.0.0.wit b/crates/astrid-capsule/wit-staging/deps/astrid-process@1.0.0/process@1.0.0.wit index 495ef003d..34aaf4de3 100644 --- a/crates/astrid-capsule/wit-staging/deps/astrid-process@1.0.0/process@1.0.0.wit +++ b/crates/astrid-capsule/wit-staging/deps/astrid-process@1.0.0/process@1.0.0.wit @@ -157,66 +157,6 @@ interface host { max-open-files: option, } - /// Host-verified, read-only bytes exposed to a spawned child's OS sandbox. - /// The motivating consumer is un-overridable per-spawn governance (a - /// supervised agent reads a policy file a prompt-injected session cannot - /// rewrite), but the primitive is AGENT-NEUTRAL: the capsule hands over the - /// bytes plus how the child should find them (see `injection-placement`), and - /// the host owns placement, integrity, and exposure. `content` is OPAQUE to - /// the host — it never parses, validates, or filters the bytes. - /// - /// Write-protection invariant: the bytes the child reads MUST NOT be writable - /// by (a) the child or any subprocess it spawns, NOR (b) the spawning - /// principal's capsule `fs` surface (`fs_*` runs in capsule space, OUTSIDE the - /// child's sandbox; a `home://`-spanning `fs` capability could otherwise - /// rewrite a home-staged file between authoring and read). The host therefore - /// SNAPSHOTS `content` into a host-owned path outside every VFS mount, - /// BLAKE3-hashes the snapshot, VERIFIES the exposed bytes against the pin - /// (closing the copy->expose TOCTOU), records the hash in the spawn audit, and - /// exposes ONLY that host-owned snapshot — never a live bind of bytes the - /// capsule can still reach. - /// - /// No new capability: injection rides `host_process` and is permitted only - /// into the caller's OWN child. It is strictly a RESTRICTION surface — a - /// capsule that already dictates the child's `args` / `env` / `cwd` / `stdin` - /// gains nothing from also handing it an UNMODIFIABLE file. The host owns the - /// materialized path (`env-pointer`) or only remaps within the child's own - /// namespace (`fixed-path`), so it never writes to a caller-named host path. - record file-injection { - /// The bytes to expose. The capsule already holds them (it authored the - /// policy), so there is no host-side file read, no read gate, and no - /// home-staged intermediate file the `fs` surface could race. - content: list, - /// How the child is pointed at the bytes. - placement: injection-placement, - } - - /// How an injected file is exposed to the child. Both modes expose the SAME - /// verified bytes read-only; they differ only in how the agent finds them, - /// chosen to match the target agent's config mechanism. - variant injection-placement { - /// The host materializes the verified snapshot at a HOST-OWNED path - /// (outside every VFS mount), exposes it read-only (Linux `--ro-bind P P` - /// in the `bwrap` namespace; macOS Seatbelt `allow file-read*` plus a - /// trailing `deny file-write*` on that literal path), and sets the named - /// environment variable on the child to that path. The host owns the path, - /// so there is no caller-chosen target and no host write to a caller-named - /// path. Works on Linux AND macOS — the OS-agnostic mode. For agents whose - /// un-overridable config tier is reachable via an env-redirected file - /// (Claude `CLAUDE_CODE_MANAGED_SETTINGS_PATH`, Gemini - /// `GEMINI_CLI_SYSTEM_SETTINGS_PATH`). The string is the env-var name; the - /// host supplies its value. - env-pointer(string), - /// The host ro-binds the verified snapshot at this absolute in-sandbox - /// path (`--ro-bind ` in the `bwrap` namespace, which - /// creates the mount point, so `path` need not exist on the host). LINUX - /// ONLY: rejected on macOS with `invalid-input`, since Seatbelt has no - /// mount namespace and materializing at a caller-named host path would be - /// an arbitrary host write (escalation). For agents whose enforced tier is - /// a FIXED path with no env redirect (Codex `/etc/codex/requirements.toml`). - fixed-path(string), - } - /// Request to spawn a host process. record spawn-request { /// Command to execute. @@ -237,13 +177,6 @@ interface host { /// Per-child OS resource ceilings. Applies to EVERY tier. /// (NOT YET ENFORCED — see `resource-limits`.) limits: option, - /// Read-only files the host exposes inside the child's sandbox. Applies - /// to EVERY tier. Each entry hands the host verified, unmodifiable bytes - /// plus how the child should find them (see `file-injection` / - /// `injection-placement`); empty => no injection. The host never parses - /// the bytes; the BLAKE3 hash of each snapshot is recorded in the spawn - /// audit. - file-injections: list, // ---- the fields below are honored ONLY by `spawn-persistent` // and ignored by `spawn` / `spawn-background`. ---- diff --git a/crates/astrid-capsule/wit-staging/deps/astrid-process@1.1.0/process@1.1.0.wit b/crates/astrid-capsule/wit-staging/deps/astrid-process@1.1.0/process@1.1.0.wit new file mode 100644 index 000000000..65f3434a8 --- /dev/null +++ b/crates/astrid-capsule/wit-staging/deps/astrid-process@1.1.0/process@1.1.0.wit @@ -0,0 +1,638 @@ +/// Host-side process spawning with OS-level sandboxing — version 1.1.0. +/// +/// Additive successor to `astrid:process@1.0.0`: identical surface plus +/// host-verified, read-only per-spawn file injection (`file-injection` / +/// `injection-placement`, and the `file-injections` field on +/// `spawn-request`). The @1.0.0 package stays frozen at its published +/// shape — the injection extension originally landed in-place on 1.0.0 +/// (#15), which structurally broke every capsule built against the +/// published contract (#19); it lives here instead. Hosts serve both +/// versions; capsules opt in by importing @1.1.0. +/// +/// Commands are wrapped in platform-specific sandbox tools +/// (`sandbox-exec` on macOS, `bwrap` on Linux) scoped to the workspace +/// directory. All processes are tracked for cancellation. +/// Security-gated: requires `host_process` capability. +/// +/// **Desktop-kernel only.** This package depends on a POSIX-style +/// fork/exec model. Unikernel targets (hermit-rs, etc.) do not implement +/// it — capsules importing `astrid:process` will fail to load on those +/// kernels. Capsule-to-capsule patterns over the IPC bus replace most +/// child-process workflows on the unikernel target; remaining +/// workloads stay desktop-only by design. +/// +/// Two tiers. EPHEMERAL — `spawn` / `spawn-background`, returning a +/// `process-handle` resource owned by the spawning instance's resource +/// table and reaped on resource (instance) drop. PERSISTENT — host-owned, +/// via `spawn-persistent`, returning an opaque, principal-scoped +/// `process-id` whose child lives in a host registry (lifetime = the +/// capsule, not the instance) and is reattachable from any later +/// invocation, including a different pooled instance. The persistent tier +/// exists because a pooled, stateless instance is reset between tool +/// invocations, so an instance-owned handle cannot survive the +/// start-then-read-then-stop pattern. See RFC: host_abi ("Process: +/// ephemeral and persistent tiers") for the full design, security model, +/// and lifecycle. +/// +/// Child stdio is byte-oriented (pipe-based) rather than stream-based; stream +/// halves stay deferred to a concrete need per the original rationale. The +/// byte-faithful read path is `read-since` (`list`); the convenience +/// `read-logs` / `read-logs-result` (and the synchronous `process-result`) +/// surface stdout/stderr as UTF-8, which is LOSSY on non-UTF-8 output — fine +/// for text, use `read-since` for binary. +/// +/// IMPLEMENTATION STATUS. This file is the full 1.0 contract; the host +/// fills it in incrementally. Symbols tagged `(NOT YET IMPLEMENTED)`, +/// `(NOT YET ENFORCED)`, or `(NOT YET POPULATED)` parse and link but the +/// host currently stubs them (returns `unknown` / `persist-unsupported`, +/// leaves an `option` `none`, or skips enforcement) — the SHAPE is fixed +/// now so behaviour can land without a breaking change. Draft until the +/// 1.0 release freezes it per the ABI evolution discipline (RFC: host_abi). + +package astrid:process@1.1.0; + +interface host { + use astrid:io/poll@1.0.0.{pollable}; + + /// Typed error returned from process operations. + variant error-code { + /// Capsule lacks the `host_process` capability. + capability-denied, + /// Command / args / cwd / env failed validation (NUL bytes, + /// control chars, max length). + invalid-input, + /// `cwd` resolved outside the workspace. + boundary-escape, + /// Per-principal CONCURRENT background-process cap exhausted (shared + /// between ephemeral `spawn-background` and `spawn-persistent`). + quota, + /// Stdin payload exceeded the per-call 1 MB cap or the + /// cumulative-per-process write quota. + too-large, + /// Handle has been closed (process exited and reaped). + closed, + /// Spawn cancelled (capsule unloading). + cancelled, + /// Wait timed out before the process exited. + wait-timeout, + /// No PERSISTENT process with this id is visible to the calling + /// principal: unknown, already released/reaped, owned by a different + /// principal, owned by a different capsule, OR structurally malformed. + /// The host MUST NOT distinguish these — "exists but not yours" and + /// "well-formed vs garbage" are both cross-principal/structural oracles. + /// (The host MAY fast-reject obvious garbage internally to avoid a + /// lookup, but the guest-visible result is identical.) The post-restart + /// / post-reap recovery signal is an empty `list-processes`, not this + /// error. + no-such-process, + /// The per-principal/per-capsule RETAINED-id cap is full (live + + /// exited-but-unreleased). Distinct from `quota` (the CONCURRENT cap). + registry-full, + /// `spawn-persistent` refused: the host cannot keep a detached child + /// contained for this configuration (sandbox disabled; macOS without + /// the operator persistence opt-in; or an owner-fallback principal). + /// Fail-closed: never persist a process the host cannot reap. + persist-unsupported, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Signal a background process can receive (Unix semantics; the + /// kernel maps to the closest equivalent on Windows). + /// + /// `kill` (SIGKILL) is a separate method because it is non- + /// graceful and drains stdout/stderr buffers into the kill result. + enum process-signal { + /// SIGTERM — graceful shutdown request. + term, + /// SIGHUP — reload configuration. + hup, + /// SIGUSR1 — user-defined. + usr1, + /// SIGUSR2 — user-defined. + usr2, + /// SIGINT — interrupt (Ctrl-C equivalent). + int, + /// SIGSTOP — pause the child (cannot be caught/ignored). Lets a + /// supervisor throttle a runaway without killing it. + stop, + /// SIGCONT — resume a paused child. + cont, + } + + /// Environment variable to pass to a spawned process. + /// + /// Environment is the ONLY path into the child: the sandbox strips the + /// host's ambient environment except a small kernel passthrough + /// allowlist. Carries operator-reviewable, non-secret configuration; a + /// SECRET reaches a child over `write-stdin` / `spawn-request.stdin`, + /// NOT `args` (which are recorded verbatim in the audit log). + record env-var { + key: string, + value: string, + } + + /// Per-stream ring-overflow policy for a persistent process's buffered + /// stdout/stderr (the ephemeral tier is always `drop-oldest`). + enum overflow-policy { + /// Drop oldest bytes to make room; cumulative loss is surfaced as + /// `bytes-dropped` so the reader knows the stream is non-contiguous. + drop-oldest, + /// Stop draining the child's pipe when the ring is full, so the OS + /// pipe buffer fills and the child blocks on write. Parks the host + /// reader task only — never the WASM task. Correct for REPL / + /// MCP-stdio children where dropping output corrupts protocol framing. + backpressure, + } + + /// Per-child OS resource ceilings (RLIMIT / cgroup), applicable to every + /// tier. `none` per field => the principal's profile default. + /// + /// (NOT YET ENFORCED) — declared so the record shape is stable; the host + /// currently bounds only the process slot, log buffers, and lifetime, so + /// a single child can still consume unbounded CPU/RAM. Including the + /// fields now avoids a breaking record change when enforcement lands. + record resource-limits { + /// Address-space / RSS ceiling (RLIMIT_AS or cgroup memory.max). + max-memory-bytes: option, + /// Cumulative CPU-time ceiling in WHOLE SECONDS (RLIMIT_CPU -> SIGXCPU + /// then SIGKILL; second-granularity is all RLIMIT_CPU enforces, so the + /// unit is seconds, not ms). + max-cpu-secs: option, + /// Max concurrent child PIDs (RLIMIT_NPROC / cgroup pids.max) — a + /// fork-bomb fence. + max-pids: option, + /// Max open file descriptors (RLIMIT_NOFILE). + max-open-files: option, + } + + /// Host-verified, read-only bytes exposed to a spawned child's OS sandbox. + /// The motivating consumer is un-overridable per-spawn governance (a + /// supervised agent reads a policy file a prompt-injected session cannot + /// rewrite), but the primitive is AGENT-NEUTRAL: the capsule hands over the + /// bytes plus how the child should find them (see `injection-placement`), and + /// the host owns placement, integrity, and exposure. `content` is OPAQUE to + /// the host — it never parses, validates, or filters the bytes. + /// + /// Write-protection invariant: the bytes the child reads MUST NOT be writable + /// by (a) the child or any subprocess it spawns, NOR (b) the spawning + /// principal's capsule `fs` surface (`fs_*` runs in capsule space, OUTSIDE the + /// child's sandbox; a `home://`-spanning `fs` capability could otherwise + /// rewrite a home-staged file between authoring and read). The host therefore + /// SNAPSHOTS `content` into a host-owned path outside every VFS mount, + /// BLAKE3-hashes the snapshot, VERIFIES the exposed bytes against the pin + /// (closing the copy->expose TOCTOU), records the hash in the spawn audit, and + /// exposes ONLY that host-owned snapshot — never a live bind of bytes the + /// capsule can still reach. + /// + /// No new capability: injection rides `host_process` and is permitted only + /// into the caller's OWN child. It is strictly a RESTRICTION surface — a + /// capsule that already dictates the child's `args` / `env` / `cwd` / `stdin` + /// gains nothing from also handing it an UNMODIFIABLE file. The host owns the + /// materialized path (`env-pointer`) or only remaps within the child's own + /// namespace (`fixed-path`), so it never writes to a caller-named host path. + record file-injection { + /// The bytes to expose. The capsule already holds them (it authored the + /// policy), so there is no host-side file read, no read gate, and no + /// home-staged intermediate file the `fs` surface could race. + content: list, + /// How the child is pointed at the bytes. + placement: injection-placement, + } + + /// How an injected file is exposed to the child. Both modes expose the SAME + /// verified bytes read-only; they differ only in how the agent finds them, + /// chosen to match the target agent's config mechanism. + variant injection-placement { + /// The host materializes the verified snapshot at a HOST-OWNED path + /// (outside every VFS mount), exposes it read-only (Linux `--ro-bind P P` + /// in the `bwrap` namespace; macOS Seatbelt `allow file-read*` plus a + /// trailing `deny file-write*` on that literal path), and sets the named + /// environment variable on the child to that path. The host owns the path, + /// so there is no caller-chosen target and no host write to a caller-named + /// path. Works on Linux AND macOS — the OS-agnostic mode. For agents whose + /// un-overridable config tier is reachable via an env-redirected file + /// (Claude `CLAUDE_CODE_MANAGED_SETTINGS_PATH`, Gemini + /// `GEMINI_CLI_SYSTEM_SETTINGS_PATH`). The string is the env-var name; the + /// host supplies its value. + env-pointer(string), + /// The host ro-binds the verified snapshot at this absolute in-sandbox + /// path (`--ro-bind ` in the `bwrap` namespace, which + /// creates the mount point, so `path` need not exist on the host). LINUX + /// ONLY: rejected on macOS with `invalid-input`, since Seatbelt has no + /// mount namespace and materializing at a caller-named host path would be + /// an arbitrary host write (escalation). For agents whose enforced tier is + /// a FIXED path with no env redirect (Codex `/etc/codex/requirements.toml`). + fixed-path(string), + } + + /// Request to spawn a host process. + record spawn-request { + /// Command to execute. + cmd: string, + /// Command arguments. + args: list, + /// Optional stdin bytes piped to the spawned process. For + /// long-lived stdin streaming use `process-handle.write-stdin`. + /// Capped at 4 MiB per spawn (cumulative). + stdin: option>, + /// Environment variables. Replaces the host's default sandbox + /// environment except for a small kernel-passthrough allowlist. + env: list, + /// Working directory relative to the workspace. Must resolve + /// inside the sandbox; absolute paths and `..` escapes are + /// rejected with `boundary-escape`. + cwd: option, + /// Per-child OS resource ceilings. Applies to EVERY tier. + /// (NOT YET ENFORCED — see `resource-limits`.) + limits: option, + /// Read-only files the host exposes inside the child's sandbox. Applies + /// to EVERY tier. Each entry hands the host verified, unmodifiable bytes + /// plus how the child should find them (see `file-injection` / + /// `injection-placement`); empty => no injection. The host never parses + /// the bytes; the BLAKE3 hash of each snapshot is recorded in the spawn + /// audit. + file-injections: list, + + // ---- the fields below are honored ONLY by `spawn-persistent` + // and ignored by `spawn` / `spawn-background`. ---- + + /// Operator-readable label surfaced in `list-processes` / audit / + /// `astrid ps`-style tooling. Untrusted: host length-clamps (<= 128 + /// bytes) + strips control chars. NOT an identity — the `process-id` + /// is the only identity. `none` => derived from `cmd`. + label: option, + /// Keep a writable stdin pipe open after the optional `stdin` prelude + /// so `write-stdin` works across invocations (REPL / `psql` / + /// MCP-stdio). `none` => false (stdin closed after the prelude). + keep-stdin-open: option, + /// Ring-overflow policy. `none` => `drop-oldest`. + overflow: option, + /// Per-stream output ring capacity (bytes), stdout and stderr each. + /// `none` => host default (1 MiB). Host-clamped to the profile ceiling. + log-ring-bytes: option, + /// Wall-clock lifetime ceiling from spawn. On expiry: SIGTERM -> grace + /// -> SIGKILL -> reap. `none` => the profile ceiling. Any value is + /// clamped DOWN to that ceiling — a guest CANNOT request an unbounded + /// lifetime. + max-lifetime-ms: option, + /// Reap an UNTOUCHED persistent process (no read / wait / reattach / + /// signal / write) after this idle interval — the primary anti-leak + /// backstop for spawn-and-forget. `none` => profile default. + /// `some(0)` is rejected with `invalid-input`. + idle-timeout-ms: option, + /// After a persistent process EXITS, retain its id + drained log tail + /// this long before the host auto-reaps the registry entry (the "read + /// its output later" window). `none` => profile default. Host-clamped. + exit-retention-ms: option, + } + + /// Exit information for a process that has terminated. + record exit-info { + /// Normal exit code if exited normally; `none` if killed by + /// signal or platform didn't surface one. + exit-code: option, + /// Signal that killed the process (Unix), if any. Distinguishes + /// SIGKILL (oom-killer, parent kill) from SIGTERM (graceful + /// shutdown) from normal exit. + signal: option, + } + + /// Result of a synchronous process execution. + record process-result { + /// Captured standard output. + stdout: string, + /// Captured standard error. + stderr: string, + /// How the process terminated. + exit: exit-info, + } + + /// Logs and status from a background process. + record read-logs-result { + /// Buffered stdout since last read. + stdout: string, + /// Buffered stderr since last read. + stderr: string, + /// Whether the process is still running. + running: bool, + /// Exit info if the process has terminated. + exit: option, + } + + /// Result of killing a background process. + record kill-result { + /// Whether the process was successfully killed. + killed: bool, + /// Exit info if available. + exit: option, + /// Final buffered stdout. + stdout: string, + /// Final buffered stderr. + stderr: string, + } + + // ================================================================ + // PERSISTENT TIER value types. The persistent registry has landed; these + // shapes are in use. The SHAPES are frozen. + // ================================================================ + + /// Opaque, unforgeable, principal-scoped identity for a PERSISTENT + /// process that survives instance churn (pool reset / per-principal + /// multiplexing). + /// + /// Wire form: a 256-bit host-minted CSPRNG token (host entropy, NEVER the + /// guest), **lowercase base32** (RFC 4648 `a-z2-7`, NO padding) — chosen so + /// the id is itself a valid IPC topic suffix (`[a-z0-9._-]+`), usable + /// directly as the `watch` suffix without sanitisation. The host stores + /// only a keyed hash. Possession is + /// necessary but NOT sufficient — the host re-checks the calling + /// principal == creator on EVERY id-keyed call, so a token that leaks + /// across the principal boundary is inert. Treat as an opaque secret: + /// never parse, pattern-match, or synthesize it. + type process-id = string; + + /// Lifecycle phase of a persistent process, reported WITHOUT draining log + /// buffers (see `status` / `process-info`). There is deliberately NO + /// `reaped` phase: once a process is reaped, its id resolves to + /// `no-such-process` and drops out of `list-processes`, so `exited` is the + /// terminal observable phase. The reap (and its reason) is delivered via + /// the `watch` exit event, not `status`. + enum process-phase { + /// Spawn accepted; child not yet confirmed running. + starting, + /// Child is running. + running, + /// Terminated; `exit-info` available; logs readable until released or + /// the exit-retention TTL elapses, after which the id becomes + /// `no-such-process`. + exited, + } + + /// Opaque, host-encoded, resumable cursor into a persistent process's + /// log streams, for NON-DRAINING reads (`read-since`). Encodes per-stream + /// offset + a generation, so a stale cursor after ring eviction / reap + /// resolves to reported drops or `no-such-process`, never to another + /// process's bytes. `token: none` on the first read = from the oldest + /// retained byte. Treat as opaque; do not construct or parse. + record log-cursor { + token: option, + } + + /// Which stream a cursor read addresses. + enum log-stream { + stdout, + stderr, + } + + /// A NON-DRAINING slice of a persistent process's stream, addressed by + /// cursor — the polling-safe complement to `read-logs` (which DRAINS). + /// Multiple independent readers each keep their own cursor and observe + /// the full retained stream. + record log-chunk { + /// Bytes in `[requested-cursor, next)`. `list` not `string`: + /// children emit non-UTF-8; the caller decodes. + data: list, + /// Cursor to pass to the NEXT `read-since` to resume exactly here. + next: log-cursor, + /// Cumulative bytes evicted from this stream's ring before they could + /// be delivered through this cursor (0 unless the reader fell behind). + bytes-dropped: u64, + /// `true` once the child has exited AND all retained output on this + /// stream has been delivered through this cursor — the clean EOF. + drained-eof: bool, + } + + /// A NON-DRAINING status snapshot of one persistent process — the unit of + /// `list-processes` and the return of `status`. Repeated calls never + /// consume log bytes and never mutate process state. + record process-info { + /// Reattach key. Stable across invocations / instances. + id: process-id, + /// Operator-supplied label (or one derived from `cmd`). + label: string, + /// cmd + args, pre-sandbox-wrap, as the capsule requested it. + command: string, + /// OS PID while running; `none` once reaped. Advisory ONLY (PIDs are + /// recycled); never use as cross-call identity — use `id`. + os-pid: option, + /// Lifecycle phase (no buffer drain). + phase: process-phase, + /// Present once `exited` / `reaped`. + exit: option, + /// Host-monotonic milliseconds since spawn (uptime). + age-ms: u64, + /// Milliseconds since the last operation touched this process (drives + /// `idle-timeout-ms`). + idle-ms: u64, + /// Bytes currently buffered and drainable (stdout + stderr). + buffered-bytes: u64, + /// Cumulative bytes evicted from the rings since spawn. + bytes-dropped: u64, + /// Whether stdin is still open for `write-stdin`. + stdin-open: bool, + /// Cumulative CPU time the child has consumed. (NOT YET POPULATED.) + cpu-ms: option, + /// Peak resident memory observed for the child. (NOT YET POPULATED.) + mem-bytes-peak: option, + } + + /// A running or recently-terminated background process. Drop is + /// automatic — capsules don't need to explicitly close; the + /// host reaps the process on resource drop. + /// + /// Obtained from `spawn-background` (reap-on-drop) or `attach` + /// (DETACH-on-drop — dropping it does NOT reap the underlying persistent + /// process; it severs this instance's view). + /// + /// Per-principal cap: see `quota` (shared between ephemeral and + /// persistent spawns). + resource process-handle { + /// Read buffered logs since the last call. Drains the buffers + /// (subsequent reads return only new data). Buffers are 1 MiB + /// ring per stream. + read-logs: func() -> result; + + /// Write to the process's stdin. Useful for REPL-style + /// children (`python -i`, `psql`, MCP stdio subprocesses). + /// Returns bytes actually written; capped at 1 MB per call. + /// (NOT YET IMPLEMENTED — stdin pipe capture pending.) + write-stdin: func(data: list) -> result; + + /// Close the stdin pipe (child observes EOF on read). + /// (NOT YET IMPLEMENTED — stdin pipe capture pending.) + close-stdin: func() -> result<_, error-code>; + + /// Send a signal. Fire-and-forget; for graceful shutdown use + /// `term`. Use `kill` (SIGKILL) for non-graceful with log + /// drainage. + signal: func(sig: process-signal) -> result<_, error-code>; + + /// Send SIGKILL and drain stdout/stderr buffers. Returns the + /// final state including exit-info if available. + kill: func() -> result; + + /// Wait for the process to exit. `timeout-ms: none` waits + /// indefinitely; bounded values drive request-response + /// patterns that mustn't hang on a runaway child. + /// Returns exit-info on exit, `wait-timeout` error if the + /// timeout elapsed first. + /// + /// On a handle obtained via `attach` (a persistent process), `none` is + /// REJECTED with `invalid-input`: an unbounded wait would pin the + /// pooled instance. Use a bounded timeout, the id-keyed `wait` (also + /// bounded), or the durable `watch` event for long-lived waits. + wait: func(timeout-ms: option) -> result; + + /// Wait for the process to exit AND drain remaining stdout / + /// stderr buffers atomically. Mirrors + /// `std::process::Child::wait_with_output`. Closes the + /// read-logs race for short-lived children that may have + /// terminal output not yet drained when `wait` observes exit. + wait-with-output: func(timeout-ms: option) -> result; + + /// The OS-level PID of the process. Useful for capsules that + /// correlate kernel-level events (cgroup IDs, /proc paths) + /// with child processes that log their own PID. Returns + /// `closed` if the process has already been reaped. + os-pid: func() -> result; + + /// Pollable that fires when the process has exited. Compose + /// with other pollables to multiplex "wait on child OR + /// receive IPC event." Instance-local: dies on instance reset, so + /// for a PERSISTENT process the durable exit signal is the event + /// model (`watch`), not this pollable. + /// (NOT YET IMPLEMENTED — returns an always-ready stub pollable.) + subscribe-exit: func() -> pollable; + + /// Pollable that fires when stdout / stderr has buffered + /// data ready to be drained via `read-logs`. Same instance-local + /// caveat as `subscribe-exit`. + /// (NOT YET IMPLEMENTED — returns an always-ready stub pollable.) + subscribe-logs: func() -> pollable; + } + + /// Spawn a synchronous (blocking) process. Blocks the WASM + /// task until the process exits or is cancelled. Cancelled + /// processes return `cancelled` error. + /// Audit: recorded (cmd + args + cwd, not env or stdin bytes). + spawn: func(request: spawn-request) -> result; + + /// Spawn a background (non-blocking) process. Returns a handle + /// for subsequent log/wait/signal/kill calls. + /// stdout/stderr are buffered (1 MiB per stream, ring buffer). + /// Audit: recorded. + spawn-background: func(request: spawn-request) -> result; + + // ================================================================ + // PERSISTENT TIER functions. The host registry has LANDED — these are + // implemented and audited. Still deferred (the host returns a stub + // `unknown`): `attach` (resource-handle materialisation — the id-keyed ops + // below ARE the documented `attach(id)?.method()` equivalent) and `watch` / + // `unwatch` (the lifecycle-event channel, RFC-open). The SHAPES are frozen. + // + // Every id-keyed function re-resolves the live calling principal + + // capsule and checks them against the recorded creator BEFORE touching + // the entry; wrong-owner / unknown / reaped all collapse to + // `no-such-process` (no oracle). Audited (op + principal + capsule + + // id-hash; never the raw id, env, stdin, or log contents). + // ================================================================ + + /// Spawn a PERSISTENT background process whose lifetime is decoupled from + /// the spawning instance. Returns a `process-id` any LATER invocation of + /// the SAME capsule under the SAME principal can reattach to. Gated on + /// `host_process` AND the operator `allow_persistent` sub-grant; counts + /// against BOTH the per-principal concurrent cap (shared with + /// `spawn-background`) AND the retained-id cap. Fail-closed refusals + /// (`persist-unsupported`): owner-fallback principal; sandbox disabled; + /// macOS without the operator persistence opt-in. + spawn-persistent: func(request: spawn-request) -> result; + + /// Re-materialise a `process-handle` over a persistent process for THIS + /// invocation — the composition primitive (every ephemeral method works). + /// DETACH-on-drop: dropping it (incl. on instance reset) does NOT reap. + /// `no-such-process` if the id is unknown to the caller. + attach: func(id: process-id) -> result; + + /// List the caller `(capsule, principal)`'s persistent processes, + /// optionally filtered by a label substring. NEVER another principal's or + /// capsule's. Empty is normal (post-restart / post-reap recovery signal). + list-processes: func(label-filter: option) -> result, error-code>; + + /// Resolve an id to its `process-info` WITHOUT draining buffers — the safe + /// poll primitive. `no-such-process` if unknown to the caller. + status: func(id: process-id) -> result; + + /// Batch `status`: one registry pass + one audit record for a supervisor + /// polling N children on a timer. Unknown ids are absent from the result. + status-many: func(ids: list) -> result, error-code>; + + /// `attach(id)?.read-logs()` — DRAINS the SINGLE shared ring per + /// `process-id`: whoever drains first consumes those bytes, so concurrent + /// drainers across invocations/attachments race over one cursor. For + /// independent multi-reader use (a UI tailer AND the capsule's own parser) + /// OR byte-faithful output, use `read-since` (per-cursor, non-draining, + /// `list`). `read-logs-result` stdout/stderr are UTF-8 (lossy on + /// non-UTF-8 child output). + read-logs: func(id: process-id) -> result; + + /// NON-DRAINING, cursor-addressed, resumable read from a persistent + /// process's stream. Works on an exited process's retained tail until + /// released / TTL. `max-bytes` bounds the chunk (host also hard-caps). + read-since: func(id: process-id, which-stream: log-stream, cursor: log-cursor, max-bytes: u32) -> result; + + /// `attach(id)?.write-stdin(data)`. Requires `keep-stdin-open = true`. + /// Caps at 1 MiB/call (`too-large` above that). The retained pipe lives in + /// the host registry, so a later instance that re-attached keeps writing to + /// the same child across pooled-instance resets. A non-reading child + /// backpressures the call until the OS pipe buffer drains. A successful + /// return wrote every byte of `data`, in order, and yields `len(data)`. An + /// error is NOT all-or-nothing: a prefix may already have reached the child, + /// and the stream is then marked closed (`closed` thereafter) — a caller + /// that needs intact framing must treat a failed write as a torn frame, not + /// a no-op. + write-stdin: func(id: process-id, data: list) -> result; + + /// `attach(id)?.close-stdin()`. Child observes EOF; idempotent. + close-stdin: func(id: process-id) -> result<_, error-code>; + + /// `attach(id)?.signal(sig)`. Fire-and-forget signal delivery. + signal: func(id: process-id, sig: process-signal) -> result<_, error-code>; + + /// `attach(id)?.wait(timeout-ms)` with a REQUIRED bounded timeout (an + /// unbounded wait would pin a pooled instance). Does NOT release on exit. + /// `wait-timeout` on elapse. For long-lived waits use `watch`. + wait: func(id: process-id, timeout-ms: u64) -> result; + + /// Graceful terminal stop: SIGTERM, wait up to `grace-ms`, then SIGKILL; + /// REMOVE the id (frees the concurrent + retained slot). `grace-ms: none` + /// => host default. (For an immediate SIGKILL use `attach(id)?.kill()`.) + /// + /// Returns only `exit-info`, NOT the final buffers: `stop` reaps the id, so + /// to keep a child's last output drain it with `read-since` (byte-faithful) + /// BEFORE `stop`. Returning the buffers here would hand back stdout/stderr + /// the guest must UTF-8-decode (lossy on non-UTF-8 output); `read-since` is + /// the byte-exact path. + stop: func(id: process-id, grace-ms: option) -> result; + + /// Drop the host's retention of an ALREADY-EXITED process: frees the + /// retained-id slot + discards the buffered tail WITHOUT signalling. + /// `invalid-input` if still running (use `stop`). Idempotent. + release-process: func(id: process-id) -> result<_, error-code>; + + /// Arm DURABLE lifecycle events for `id`, published by the host UNDER THE + /// CREATOR PRINCIPAL on + /// `astrid.process.v1.{exited,log-ready,stdin-drained}.` (a + /// pollable cannot survive instance reset, so the durable channel is the + /// IPC bus the capsule already subscribes to). The `exited` payload carries + /// a reason (self-exit / killed / reaped-ttl / reaped-shutdown). `suffix: + /// none` => the id. Idempotent. `no-such-process` if unknown to the caller. + /// OPEN (RFC: host_abi): whether the host-published topics need a manifest + /// `[publish]` declaration or are a kernel-authored topic class — `watch` + /// may be cut in favour of `status` + bounded `wait` polling. + watch: func(id: process-id, suffix: option) -> result<_, error-code>; + + /// Stop publishing lifecycle events for `id` (the child keeps running). + /// Idempotent. + unwatch: func(id: process-id) -> result<_, error-code>; +} From d51ee1137861f944763a9a0e6f764276d91455dd Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 2 Jul 2026 23:24:10 +0400 Subject: [PATCH 2/2] chore(wit): bump submodule pin to the restored process@1.0.0 + new @1.1.0 Points the wit submodule at unicity-astrid/wit@278dbca (PR #20), so the committed wit-staging matches the pinned contract and the CI drift check passes. Pairs with the dual-version host serving in this branch. Claude-Session: https://claude.ai/code/session_01NvX2tE7tgXuCRevqqiXTGU --- wit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wit b/wit index 812c83389..278dbca3e 160000 --- a/wit +++ b/wit @@ -1 +1 @@ -Subproject commit 812c833892060517f9fc23551867f1971bef5506 +Subproject commit 278dbca3e32f327d0f2358644fc86559779ba0fd