From 07681a0f9f06ae12e72c8177a692584518dff9bb Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 2 Jul 2026 23:19:23 +0400 Subject: [PATCH] fix!: restore frozen process@1.0.0; move file-injection to new process@1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 added file-injections to spawn-request inside the frozen astrid:process@1.0.0 package. The component-model linker matches package versions structurally, so every capsule built against the published contract (astrid-sys 0.7.1 embeds the pre-#15 shape) fails to instantiate on a host serving the mutated 1.0.0 — the #890 frozen-WIT-mutation class. process@1.0.0 is restored byte-identical to 83ebc6c (the shape sdk 0.7.1 ships); the injection extension moves to astrid:process@1.1.0, the same dual-version pattern as http@1.0.0/1.1.0. Closes #19 Claude-Session: https://claude.ai/code/session_01NvX2tE7tgXuCRevqqiXTGU --- README.md | 1 + host/process@1.0.0.wit | 67 ----- host/process@1.1.0.wit | 638 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 639 insertions(+), 67 deletions(-) create mode 100644 host/process@1.1.0.wit diff --git a/README.md b/README.md index 755f933..8cf73ca 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | `host/http@1.1.0.wit` | `astrid:http@1.1.0` | Additive successor to `@1.0.0` — caller-set per-request timeouts, redirect policy, response/decompression size caps, scheme restriction, subresource integrity, response metadata, streaming request bodies (uploads), and response trailers. An empty `request-options` reproduces `@1.0.0` behaviour. | | `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, entropy, sleep, capability introspection. | | `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn (with stdin/env/cwd), wait, signal, kill, read-logs, stdin streaming. | +| `host/process@1.1.0.wit` | `astrid:process@1.1.0` | Additive successor to `@1.0.0` — host-verified, read-only per-spawn file injection (`file-injections` on `spawn-request`). Empty `file-injections` reproduces `@1.0.0` behaviour. | | `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | | `host/approval@1.0.0.wit` | `astrid:approval@1.0.0` | Human-in-the-loop approval gate for sensitive actions. | | `host/identity@1.0.0.wit` | `astrid:identity@1.0.0` | Multi-platform identity resolve and link. | diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index 495ef00..34aaf4d 100644 --- a/host/process@1.0.0.wit +++ b/host/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/host/process@1.1.0.wit b/host/process@1.1.0.wit new file mode 100644 index 0000000..65f3434 --- /dev/null +++ b/host/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>; +}