diff --git a/CHANGELOG.md b/CHANGELOG.md index 81562e4..da3f29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ## [Unreleased] +### Added + +- **`capabilities::enumerate` — list the calling capsule's own held capability names.** The list dual of `capabilities::check`: returns the capability categories declared in this capsule's `[capabilities]` manifest block (`host_process`, `net_connect`, `fs_read`, …) — the names, not the scoped arguments within them (allowlists, `host:port`, paths). Argument-free (the kernel already knows the caller) and infallible — an empty list is the valid "no capabilities" answer — so a reusable capsule can ground its behaviour in what it can actually do instead of hard-coding it, avoiding code-vs-manifest drift. Backed by unicity-astrid/wit#13's `astrid:sys/host.enumerate-capabilities`; contracts submodule bumped accordingly. +- **`process` persistent-process tier — `Command::spawn_persistent`, `PersistentProcess`, and `process::{attach, list, status_many}`.** Mirrors the host `astrid:process@1.0.0` persistent tier: a background child that **outlives the pooled, stateless instance** that started it (unlike `Process`, whose kernel resource is reaped on instance reset). `Command` gains the persistent-only builder knobs — `label`, `keep_stdin_open`, `overflow`, `log_ring_bytes`, `max_lifetime`, `idle_timeout`, `exit_retention`, `limits` — and a `spawn_persistent()` terminal returning a `PersistentProcess` keyed by an opaque `ProcessId`. `PersistentProcess` exposes `status` / `read_logs` (drain) / `read_since` (non-draining cursor, byte-faithful `LogChunk`) / `write_stdin` / `close_stdin` / `signal` / `wait` (bounded) / `stop` (consumes the handle; SIGTERM→grace→SIGKILL, frees the slot) / `release` (consumes). Persist the `ProcessId` (e.g. in KV) and `process::attach(id)` from a later invocation to reattach — the SDK `attach` is a thin id-wrapper, so it works without the host's deferred `attach` resource fn; the first id-keyed call validates ownership. `process::list` / `status_many` enumerate the capsule+principal's persistent processes. New types: `ProcessId`, `ProcessInfo`, `ProcessPhase`, `LogStream`, `LogCursor`, `LogChunk`, `OverflowPolicy`, `ResourceLimits`; `Signal` gains `Stop` / `Cont`. The host's `watch` / `unwatch` lifecycle-event channel and resource-limit enforcement are not yet wired (poll via `status` + bounded `wait`). Backed by unicity-astrid/wit#12; contracts submodule bumped to the merged `astrid:process@1.0.0` persistent tier. + ## [0.7.0] - 2026-05-26 ### Breaking diff --git a/astrid-sdk/src/lib.rs b/astrid-sdk/src/lib.rs index d02f5f6..c57b8dc 100644 --- a/astrid-sdk/src/lib.rs +++ b/astrid-sdk/src/lib.rs @@ -467,11 +467,14 @@ pub mod runtime { } } -/// Cross-capsule capability queries. +/// Capability introspection. /// -/// Allows a capsule to check whether another capsule (identified by its -/// IPC session UUID) has a specific manifest capability. Used by the -/// prompt builder to enforce `allow_prompt_injection` gating. +/// [`check`] asks whether a capsule (self or any other, by IPC session UUID) +/// holds a specific manifest capability — used by the prompt builder to +/// enforce `allow_prompt_injection` gating. [`enumerate`] is the list dual for +/// the calling capsule's own set: the names for which a self-`check` returns +/// `true`. Capability posture is structural metadata, not a secret +/// (enforce-don't-conceal), so both are ungated. pub mod capabilities { use super::*; @@ -488,6 +491,23 @@ pub mod capabilities { let response = wit_sys::check_capsule_capability(&request).map_err(host_err)?; Ok(response.allowed) } + + /// Enumerate the calling capsule's own held capability names. + /// + /// Returns the capability categories declared in this capsule's + /// `[capabilities]` manifest block (`host_process`, `net_connect`, + /// `fs_read`, …) — the names, not the scoped arguments within them + /// (allowlists, `host:port`, paths). This is exactly the set of names for + /// which [`check`] against this capsule's own UUID returns `true`. + /// + /// Argument-free (the kernel already knows the caller) and infallible: the + /// kernel always knows the caller's own registered set, so there is no + /// error path — an empty list is the valid "no capabilities" answer. Lets + /// a reusable capsule ground its behaviour in what it can actually do + /// instead of hard-coding it, avoiding code-vs-manifest drift. + pub fn enumerate() -> Vec { + wit_sys::enumerate_capabilities() + } } pub mod net; diff --git a/astrid-sdk/src/process.rs b/astrid-sdk/src/process.rs index d1dfb40..c826f54 100644 --- a/astrid-sdk/src/process.rs +++ b/astrid-sdk/src/process.rs @@ -88,6 +88,11 @@ pub enum Signal { Usr1, Usr2, Int, + /// SIGSTOP — pause the child (cannot be caught). Lets a supervisor + /// throttle a runaway without killing it. + Stop, + /// SIGCONT — resume a paused child. + Cont, } impl Signal { @@ -98,6 +103,8 @@ impl Signal { Self::Usr1 => wit_process::ProcessSignal::Usr1, Self::Usr2 => wit_process::ProcessSignal::Usr2, Self::Int => wit_process::ProcessSignal::Int, + Self::Stop => wit_process::ProcessSignal::Stop, + Self::Cont => wit_process::ProcessSignal::Cont, } } } @@ -113,6 +120,16 @@ pub struct Command { stdin: Option>, env: Vec<(String, String)>, cwd: Option, + // Persistent-tier knobs — honored ONLY by [`spawn_persistent`], ignored + // by [`spawn`] / [`spawn_background`] (per the WIT contract). + label: Option, + keep_stdin_open: bool, + overflow: Option, + log_ring_bytes: Option, + max_lifetime: Option, + idle_timeout: Option, + exit_retention: Option, + limits: Option, } impl Command { @@ -163,7 +180,74 @@ impl Command { self } + /// Operator-readable label surfaced in [`list`] / [`PersistentProcess::status`]. + /// Persistent-tier only. `None` (default) derives a label from `cmd`. + #[must_use] + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Keep a writable stdin pipe open after the optional `stdin` prelude so + /// [`PersistentProcess::write_stdin`] works across invocations (REPL / + /// `psql` / MCP-stdio children). Persistent-tier only. + #[must_use] + pub fn keep_stdin_open(mut self, keep: bool) -> Self { + self.keep_stdin_open = keep; + self + } + + /// Per-stream ring overflow policy. Persistent-tier only. + /// `None` (default) → [`OverflowPolicy::DropOldest`]. + #[must_use] + pub fn overflow(mut self, policy: OverflowPolicy) -> Self { + self.overflow = Some(policy); + self + } + + /// Per-stream output ring capacity in bytes (stdout and stderr each). + /// Persistent-tier only. Host-clamped to the profile ceiling. + #[must_use] + pub fn log_ring_bytes(mut self, bytes: u32) -> Self { + self.log_ring_bytes = Some(bytes); + self + } + + /// Wall-clock lifetime ceiling from spawn (SIGTERM → grace → SIGKILL on + /// expiry). Persistent-tier only. Any value is clamped DOWN to the host + /// ceiling — a guest cannot request an unbounded lifetime. + #[must_use] + pub fn max_lifetime(mut self, dur: std::time::Duration) -> Self { + self.max_lifetime = Some(dur); + self + } + + /// Reap an untouched persistent process (no read / wait / signal / write) + /// after this idle interval — the primary anti-leak backstop for + /// spawn-and-forget. Persistent-tier only. + #[must_use] + pub fn idle_timeout(mut self, dur: std::time::Duration) -> Self { + self.idle_timeout = Some(dur); + self + } + + /// After a persistent process exits, retain its id + drained log tail this + /// long before the host auto-reaps it. Persistent-tier only. + #[must_use] + pub fn exit_retention(mut self, dur: std::time::Duration) -> Self { + self.exit_retention = Some(dur); + self + } + + /// Per-child OS resource ceilings (applies to every tier). + #[must_use] + pub fn limits(mut self, limits: ResourceLimits) -> Self { + self.limits = Some(limits); + self + } + fn into_wit(self) -> wit_process::SpawnRequest { + let ms = |d: std::time::Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX); wit_process::SpawnRequest { cmd: self.cmd, args: self.args, @@ -174,6 +258,14 @@ impl Command { .map(|(key, value)| wit_process::EnvVar { key, value }) .collect(), cwd: self.cwd, + limits: self.limits.map(ResourceLimits::into_wit), + label: self.label, + keep_stdin_open: self.keep_stdin_open.then_some(true), + overflow: self.overflow.map(OverflowPolicy::to_wit), + log_ring_bytes: self.log_ring_bytes, + max_lifetime_ms: self.max_lifetime.map(ms), + idle_timeout_ms: self.idle_timeout.map(ms), + exit_retention_ms: self.exit_retention.map(ms), } } @@ -194,6 +286,26 @@ impl Command { let inner = wit_process::spawn_background(&req).map_err(host_err)?; Ok(Process { inner }) } + + /// Spawn a PERSISTENT background process whose lifetime is decoupled from + /// the calling instance. Returns a [`PersistentProcess`] keyed by an + /// opaque [`ProcessId`] that ANY later invocation of the same capsule + /// under the same principal can [`attach`] to — unlike [`spawn_background`], + /// it survives the pooled instance being reset between tool invocations. + /// + /// Gated on `host_process`. Counts against the per-principal concurrent + /// cap (shared with `spawn_background`) and the retained-id cap. + /// + /// Beyond the `host_process` check, this can also fail when the + /// invocation has no authenticated principal in scope (the owner-fallback + /// case): a persistent id must be scoped to a real principal, so an + /// unauthenticated path is refused with a host error rather than sharing a + /// `default` namespace that `list`/`status_many` would enumerate. + pub fn spawn_persistent(self) -> Result { + let req = self.into_wit(); + let id = wit_process::spawn_persistent(&req).map_err(host_err)?; + Ok(PersistentProcess { id: ProcessId(id) }) + } } /// Synchronous spawn helper — `cmd` plus its args, no env / cwd / stdin. @@ -302,3 +414,370 @@ impl Process { self.inner.os_pid().map_err(host_err) } } + +// ============================================================ +// PERSISTENT TIER +// +// A persistent process survives the pooled, stateless instance that spawned +// it (unlike the ephemeral [`Process`], whose kernel resource is reaped on +// instance reset). It is keyed by an opaque [`ProcessId`] that any later +// invocation of the same capsule+principal can [`attach`] to. +// ============================================================ + +/// Opaque, principal-scoped identity for a persistent process that survives +/// instance churn. Persist it (e.g. in KV) to reattach later — `Serialize` / +/// `Deserialize` let it ride in a state struct alongside a [`LogCursor`]. +/// Treat as opaque — never parse or synthesize it. (A leaked id is inert +/// across the principal/capsule boundary: the host re-checks ownership on +/// every id-keyed call, so it is a handle, not a credential.) +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct ProcessId(String); + +impl ProcessId { + /// The id as a string slice (e.g. to store it for later [`attach`]). + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume into the owned `String`. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl core::fmt::Display for ProcessId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for ProcessId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for ProcessId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// Lifecycle phase of a persistent process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessPhase { + /// Spawn accepted; child not yet confirmed running. + Starting, + /// Child is running. + Running, + /// Terminated; exit info available; logs readable until released / TTL. + Exited, +} + +impl ProcessPhase { + fn from_wit(p: wit_process::ProcessPhase) -> Self { + match p { + wit_process::ProcessPhase::Starting => Self::Starting, + wit_process::ProcessPhase::Running => Self::Running, + wit_process::ProcessPhase::Exited => Self::Exited, + } + } +} + +/// Which stream a cursor read addresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogStream { + Stdout, + Stderr, +} + +impl LogStream { + fn to_wit(self) -> wit_process::LogStream { + match self { + Self::Stdout => wit_process::LogStream::Stdout, + Self::Stderr => wit_process::LogStream::Stderr, + } + } +} + +/// Per-stream ring overflow policy for a persistent process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverflowPolicy { + /// Drop oldest bytes to make room; loss surfaces as `bytes_dropped`. + DropOldest, + /// Stop draining the pipe when full so the child blocks on write — + /// correct for REPL / MCP-stdio children where dropping corrupts framing. + Backpressure, +} + +impl OverflowPolicy { + fn to_wit(self) -> wit_process::OverflowPolicy { + match self { + Self::DropOldest => wit_process::OverflowPolicy::DropOldest, + Self::Backpressure => wit_process::OverflowPolicy::Backpressure, + } + } +} + +/// Per-child OS resource ceilings. `None` per field → the principal's profile +/// default. (Host enforcement is not yet wired — see the WIT.) +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ResourceLimits { + /// Address-space / RSS ceiling. + pub max_memory_bytes: Option, + /// Cumulative CPU-time ceiling in whole seconds. + pub max_cpu_secs: Option, + /// Max concurrent child PIDs (a fork-bomb fence). + pub max_pids: Option, + /// Max open file descriptors. + pub max_open_files: Option, +} + +impl ResourceLimits { + fn into_wit(self) -> wit_process::ResourceLimits { + wit_process::ResourceLimits { + max_memory_bytes: self.max_memory_bytes, + max_cpu_secs: self.max_cpu_secs, + max_pids: self.max_pids, + max_open_files: self.max_open_files, + } + } +} + +/// Opaque, resumable cursor into a persistent process's log stream. Use +/// [`LogCursor::start`] for the first read; pass [`LogChunk::next`] back to +/// resume exactly where you left off. Treat as opaque. `Serialize` / +/// `Deserialize` let a capsule persist the cursor (e.g. in KV) and resume +/// `read_since` from the same position in a later invocation. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LogCursor { + token: Option, +} + +impl LogCursor { + /// A cursor positioned at the oldest retained byte. + #[must_use] + pub fn start() -> Self { + Self { token: None } + } + + fn from_wit(c: wit_process::LogCursor) -> Self { + Self { token: c.token } + } + + fn to_wit(&self) -> wit_process::LogCursor { + wit_process::LogCursor { + token: self.token.clone(), + } + } +} + +/// A non-draining slice of a persistent process's stream, addressed by cursor. +/// Multiple independent readers each keep their own cursor and observe the +/// full retained stream. +#[derive(Debug, Clone)] +pub struct LogChunk { + /// Bytes in `[requested-cursor, next)`. Byte-faithful — non-UTF-8 safe. + pub data: Vec, + /// Cursor to pass to the next [`PersistentProcess::read_since`] to resume. + pub next: LogCursor, + /// Cumulative bytes evicted before they could be delivered through this + /// cursor (0 unless the reader fell behind). + pub bytes_dropped: u64, + /// `true` once the child exited AND all retained output on this stream was + /// delivered through this cursor — the clean EOF. + pub drained_eof: bool, +} + +impl LogChunk { + fn from_wit(c: wit_process::LogChunk) -> Self { + Self { + data: c.data, + next: LogCursor::from_wit(c.next), + bytes_dropped: c.bytes_dropped, + drained_eof: c.drained_eof, + } + } +} + +/// A non-draining status snapshot of one persistent process. Repeated reads +/// never consume log bytes and never mutate process state. +#[derive(Debug, Clone)] +pub struct ProcessInfo { + /// Reattach key, stable across invocations / instances. + pub id: ProcessId, + /// Operator label (or one derived from `cmd`). + pub label: String, + /// cmd + args, as the capsule requested it. + pub command: String, + /// OS PID while running; `None` once reaped. Advisory only. + pub os_pid: Option, + /// Lifecycle phase. + pub phase: ProcessPhase, + /// Present once exited. + pub exit: Option, + /// Uptime since spawn. + pub age: std::time::Duration, + /// Time since the last operation touched this process (drives idle reap). + pub idle: std::time::Duration, + /// Bytes currently buffered and drainable (stdout + stderr). + pub buffered_bytes: u64, + /// Cumulative bytes evicted from the rings since spawn. + pub bytes_dropped: u64, + /// Whether stdin is still open for `write_stdin`. + pub stdin_open: bool, + /// Cumulative CPU time consumed. `None` until the host populates it. + pub cpu: Option, + /// Peak resident memory. `None` until the host populates it. + pub mem_bytes_peak: Option, +} + +impl ProcessInfo { + fn from_wit(i: wit_process::ProcessInfo) -> Self { + Self { + id: ProcessId(i.id), + label: i.label, + command: i.command, + os_pid: i.os_pid, + phase: ProcessPhase::from_wit(i.phase), + exit: i.exit.map(ExitInfo::from_wit), + age: std::time::Duration::from_millis(i.age_ms), + idle: std::time::Duration::from_millis(i.idle_ms), + buffered_bytes: i.buffered_bytes, + bytes_dropped: i.bytes_dropped, + stdin_open: i.stdin_open, + cpu: i.cpu_ms.map(std::time::Duration::from_millis), + mem_bytes_peak: i.mem_bytes_peak, + } + } +} + +/// A handle to a PERSISTENT background process, keyed by its [`ProcessId`]. +/// +/// Unlike [`Process`], dropping this handle does NOT reap the underlying +/// process — it is a detached view. The process is reaped only by [`stop`](Self::stop), +/// [`release`](Self::release), or the host's idle / max-lifetime / +/// exit-retention TTLs. Obtain one from [`Command::spawn_persistent`] or +/// [`attach`]. +#[derive(Debug, Clone)] +pub struct PersistentProcess { + id: ProcessId, +} + +impl PersistentProcess { + /// The process id — persist it (e.g. in KV) to reattach from a later + /// invocation via [`attach`]. + #[must_use] + pub fn id(&self) -> &ProcessId { + &self.id + } + + /// Non-draining status snapshot. + pub fn status(&self) -> Result { + wit_process::status(self.id.as_str()) + .map(ProcessInfo::from_wit) + .map_err(host_err) + } + + /// Drain newly-buffered stdout/stderr since the previous read, and report + /// whether the process is still running. Drains the single shared ring — + /// for independent multi-reader or byte-faithful reads use [`read_since`](Self::read_since). + pub fn read_logs(&self) -> Result { + let r = wit_process::read_logs(self.id.as_str()).map_err(host_err)?; + Ok(Logs { + stdout: r.stdout, + stderr: r.stderr, + running: r.running, + exit: r.exit.map(ExitInfo::from_wit), + }) + } + + /// Non-draining, cursor-addressed, byte-faithful read of one stream. Start + /// with [`LogCursor::start`]; pass [`LogChunk::next`] back to resume. + pub fn read_since( + &self, + stream: LogStream, + cursor: &LogCursor, + max_bytes: u32, + ) -> Result { + wit_process::read_since( + self.id.as_str(), + stream.to_wit(), + &cursor.to_wit(), + max_bytes, + ) + .map(LogChunk::from_wit) + .map_err(host_err) + } + + /// Write to stdin. Requires the process was spawned with + /// [`Command::keep_stdin_open`]. Returns bytes written. + pub fn write_stdin(&self, data: &[u8]) -> Result { + wit_process::write_stdin(self.id.as_str(), data).map_err(host_err) + } + + /// Close stdin; the child observes EOF on read. + pub fn close_stdin(&self) -> Result<(), SysError> { + wit_process::close_stdin(self.id.as_str()).map_err(host_err) + } + + /// Send a fire-and-forget signal. + pub fn signal(&self, sig: Signal) -> Result<(), SysError> { + wit_process::signal(self.id.as_str(), sig.to_wit()).map_err(host_err) + } + + /// Wait up to `timeout` for the process to exit. Bounded by design — an + /// unbounded wait would pin the pooled instance. Does NOT reap. + pub fn wait(&self, timeout: std::time::Duration) -> Result { + let ms = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX); + wit_process::wait(self.id.as_str(), ms) + .map(ExitInfo::from_wit) + .map_err(host_err) + } + + /// Graceful terminal stop: SIGTERM, wait up to `grace`, then SIGKILL, and + /// REMOVE the id (frees the slot). `grace` of `None` uses the host default. + /// Consumes the handle. To keep a child's last output, drain it with + /// [`read_since`](Self::read_since) BEFORE calling `stop`. + pub fn stop(self, grace: Option) -> Result { + let ms = grace.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)); + wit_process::stop(self.id.as_str(), ms) + .map(ExitInfo::from_wit) + .map_err(host_err) + } + + /// Drop the host's retention of an ALREADY-EXITED process — frees the slot + /// and discards the buffered tail. Errors if still running (use + /// [`stop`](Self::stop) for that). Consumes the handle. + pub fn release(self) -> Result<(), SysError> { + wit_process::release_process(self.id.as_str()).map_err(host_err) + } +} + +/// Reattach to a persistent process by id — e.g. one saved in KV across tool +/// invocations. This just wraps the id; the first id-keyed call validates +/// ownership, so an id that is unknown / not yours / reaped surfaces +/// `no-such-process` on use rather than here. +#[must_use] +pub fn attach(id: impl Into) -> PersistentProcess { + PersistentProcess { id: id.into() } +} + +/// List the calling capsule + principal's persistent processes, optionally +/// filtered by a label substring. Empty is normal (post-reap recovery signal). +pub fn list(label_filter: Option<&str>) -> Result, SysError> { + wit_process::list_processes(label_filter) + .map(|v| v.into_iter().map(ProcessInfo::from_wit).collect()) + .map_err(host_err) +} + +/// Batch status for many ids in one host call. Unknown / unowned ids are +/// simply absent from the result. +pub fn status_many(ids: &[ProcessId]) -> Result, SysError> { + let raw: Vec = ids.iter().map(|i| i.0.clone()).collect(); + wit_process::status_many(&raw) + .map(|v| v.into_iter().map(ProcessInfo::from_wit).collect()) + .map_err(host_err) +} diff --git a/astrid-sdk/wit/astrid-contracts.wit b/astrid-sdk/wit/astrid-contracts.wit index 143311f..b568adb 100644 --- a/astrid-sdk/wit/astrid-contracts.wit +++ b/astrid-sdk/wit/astrid-contracts.wit @@ -296,6 +296,27 @@ interface hook { /// Merged data from interceptor responses (opaque JSON). data: option, } + + /// Payload of `hook.v1.event.` envelopes published by the + /// hook bridge during fan-out to subscriber capsules. + /// + /// Subscribers wishing to influence the outcome (merge strategies + /// `ToolCallBefore` / `LastNonNull`) reply on the per-request + /// scoped topic `hook.v1.response..`. + /// When `correlation-id` is absent, the dispatch is fire-and-forget + /// (merge semantics `None`) and no reply topic exists. + record hook-event-request { + /// Semantic hook name (e.g. `before_tool_call`, `session_start`, + /// `on_compaction_started`). Matches the mapping table in + /// `astrid-capsule-hook-bridge`. + hook: string, + /// Original lifecycle event payload, serialized as JSON. Opaque + /// to the bus — subscribers parse based on the hook name. + payload: string, + /// Per-request correlation id for scoped replies. `none` for + /// fire-and-forget hooks. + correlation-id: option, + } } // ── llm ────────────────────────────────────────────────────────── diff --git a/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit index a819553..cbc27ee 100644 --- a/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit +++ b/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit @@ -12,15 +12,33 @@ /// child-process workflows on the unikernel target; remaining /// workloads stay desktop-only by design. /// -/// Child stdio is byte-oriented (`write-stdin` / `read-logs`) rather -/// than stream-based. The bus already handles high-throughput -/// capsule-to-capsule traffic; stream halves on `process-handle` would -/// pull weight only for niche "splice TCP into child into TCP" media- -/// gateway scenarios that haven't materialised yet. Add as -/// `process-handle@1.1.0` if/when concrete need arises. +/// 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. /// -/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes -/// ship as a new file at a new version path; never edit this file. +/// 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.0.0; @@ -36,7 +54,8 @@ interface host { invalid-input, /// `cwd` resolved outside the workspace. boundary-escape, - /// Per-capsule background-process cap exhausted (max 8). + /// 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. @@ -47,6 +66,24 @@ interface host { 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), } @@ -67,14 +104,59 @@ interface host { 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, + } + /// Request to spawn a host process. record spawn-request { /// Command to execute. @@ -92,6 +174,41 @@ interface host { /// 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, + + // ---- 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. @@ -139,11 +256,121 @@ interface host { stderr: string, } + // ================================================================ + // PERSISTENT TIER value types. (NOT YET IMPLEMENTED — the host stubs + // the persistent functions below; the SHAPES are fixed now.) + // ================================================================ + + /// 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. /// - /// Per-capsule cap: 8 concurrent background processes. + /// 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 @@ -153,9 +380,11 @@ interface host { /// 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 @@ -172,6 +401,11 @@ interface host { /// 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 / @@ -189,11 +423,16 @@ interface host { /// Pollable that fires when the process has exited. Compose /// with other pollables to multiplex "wait on child OR - /// receive IPC event." + /// 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`. + /// 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; } @@ -208,4 +447,106 @@ interface host { /// stdout/stderr are buffered (1 MiB per stream, ring buffer). /// Audit: recorded. spawn-background: func(request: spawn-request) -> result; + + // ================================================================ + // PERSISTENT TIER functions. (NOT YET IMPLEMENTED — the host stubs + // these, returning `persist-unsupported` / `no-such-process` / `unknown` + // until the registry lands. The SHAPES are fixed now.) + // + // 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`. + /// (NOT YET IMPLEMENTED — stdin pipe capture pending.) + write-stdin: func(id: process-id, data: list) -> result; + + /// `attach(id)?.close-stdin()`. (NOT YET IMPLEMENTED.) + 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>; } diff --git a/astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit index af39e06..138653e 100644 --- a/astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit +++ b/astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit @@ -133,10 +133,39 @@ interface host { /// Audit: not recorded (read-only, no side effects). random-bytes: func(length: u64) -> result, error-code>; - /// Check whether a capsule has a specific manifest capability. + /// Check whether a capsule has a specific manifest capability — self or + /// any other capsule (by UUID). The per-name dual of + /// `enumerate-capabilities`. /// /// Fail-closed: returns `allowed: false` for unknown UUIDs and /// unknown capabilities. Returns `registry-unavailable` when the - /// registry itself can't be consulted. + /// registry itself can't be consulted. Ungated: capability posture is + /// structural metadata, not a secret (enforce-don't-conceal) — knowing a + /// capability conveys no ability to use it. Audit: not recorded. check-capsule-capability: func(request: capability-check-request) -> result; + + /// Enumerate the CALLING capsule's OWN held capability NAMES — the + /// categories the kernel enforces at host-call time (e.g. `host_process`, + /// `net_connect`, `fs_read`), NOT the scoped arguments within them (a + /// `host_process` allowlist, `host:port`, paths). It is the list dual of + /// `check-capsule-capability`: the set of names for which a self-`check` + /// returns true. + /// + /// Argument-free — the kernel already knows the caller. The set is the + /// capsule's manifest-declared capabilities as the kernel registered them; + /// capsule capabilities are not revoked at runtime (the grant/revoke model + /// is principal-scoped, a separate axis), so "what I declared" and "what I + /// can do" coincide at the capsule level. + /// + /// Ungated, like `check-capsule-capability`: a capsule grounds its + /// behaviour in what it can actually do — a reusable supervisor binary + /// deployed under different manifests reads its real set instead of + /// hard-coding it; any capsule avoids code-vs-manifest drift by grounding + /// on the registered set. Audit: not recorded (read-only self- + /// introspection). See RFC: host_abi ("Capability introspection"). + /// + /// Infallible: the kernel always knows the caller's own registered set, so + /// there is no failure mode (an empty list is the valid "no capabilities" + /// answer) — like `clock-ms` / `clock-monotonic-ns`, no `result` wrapper. + enumerate-capabilities: func() -> list; } diff --git a/contracts b/contracts index 467240d..9742f80 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 467240deeba2cf7ff28ba32d12b5a88485be5b3d +Subproject commit 9742f80e83abb92e9ae954ef82664bdd2fcb89cd