From b74c3a117b5189458b365bdd8d6dd93787f31d98 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 26 May 2026 02:32:49 +0400 Subject: [PATCH 1/2] fix(release): make astrid-sys cargo-publishable Closes #48 `cargo publish -p astrid-sys` failed verifier compile because `build.rs` reads `../contracts/host/` (the unicity-astrid/wit workspace submodule) which isn't part of the .crate tarball. The `include = ["../contracts/host/**/*.wit", ...]` directive was meant to bundle it but cargo silently ignores `..` paths in include. Same class as unicity-astrid/astrid#763 (astrid-capsule wit) and #765 (astrid-cli apparmor). Cargo's include can't reach outside the crate dir; published crates must be self-contained. Fix: - Commit wit-staging/ to the crate (drop from .gitignore). 14 staged WIT files ship in the tarball. - build.rs becomes tolerant: if ../contracts/host/ doesn't exist OR contains no .wit files (uninitialised submodule), short-circuit and treat committed wit-staging/ as authoritative. - Cargo.toml include: drop ../contracts/host path, add wit-staging. Workspace builds: clean+re-stage from submodule, committed copy stays in lockstep. Published builds: short-circuit; wit_bindgen::generate! reads the committed copy directly. Verified: cargo publish -p astrid-sys --dry-run --allow-dirty packages and verifier-compiles cleanly. The downstream tier (astrid-sdk-macros, astrid-sdk) chains on top once astrid-sys lands on crates.io. --- astrid-sys/.gitignore | 6 +- astrid-sys/Cargo.toml | 2 +- astrid-sys/build.rs | 48 ++- .../deps/astrid-approval/approval@1.0.0.wit | 73 ++++ .../deps/astrid-elicit/elicit@1.0.0.wit | 78 ++++ .../wit-staging/deps/astrid-fs/fs@1.0.0.wit | 285 +++++++++++++ .../deps/astrid-guest/guest@1.0.0.wit | 89 +++++ .../deps/astrid-http/http@1.0.0.wit | 141 +++++++ .../deps/astrid-identity/identity@1.0.0.wit | 105 +++++ .../wit-staging/deps/astrid-io/io@1.0.0.wit | 289 ++++++++++++++ .../wit-staging/deps/astrid-ipc/ipc@1.0.0.wit | 163 ++++++++ .../wit-staging/deps/astrid-kv/kv@1.0.0.wit | 93 +++++ .../wit-staging/deps/astrid-net/net@1.0.0.wit | 373 ++++++++++++++++++ .../deps/astrid-process/process@1.0.0.wit | 211 ++++++++++ .../wit-staging/deps/astrid-sys/sys@1.0.0.wit | 142 +++++++ .../deps/astrid-uplink/uplink@1.0.0.wit | 59 +++ astrid-sys/wit-staging/root.wit | 1 + 17 files changed, 2147 insertions(+), 11 deletions(-) create mode 100644 astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-fs/fs@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-guest/guest@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-http/http@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-identity/identity@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-io/io@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-ipc/ipc@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-kv/kv@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-net/net@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit create mode 100644 astrid-sys/wit-staging/deps/astrid-uplink/uplink@1.0.0.wit create mode 100644 astrid-sys/wit-staging/root.wit diff --git a/astrid-sys/.gitignore b/astrid-sys/.gitignore index 3584bf1..aaff369 100644 --- a/astrid-sys/.gitignore +++ b/astrid-sys/.gitignore @@ -1 +1,5 @@ -wit-staging/ +# wit-staging/ is committed: build.rs regenerates it from the +# `unicity-astrid/wit` submodule on workspace builds, but the +# committed copy ships with the published crate so `cargo install` +# works without the submodule on the consumer's machine. + diff --git a/astrid-sys/Cargo.toml b/astrid-sys/Cargo.toml index 9c252d8..b8a2b1c 100644 --- a/astrid-sys/Cargo.toml +++ b/astrid-sys/Cargo.toml @@ -11,7 +11,7 @@ build = "build.rs" include = [ "src/**/*", "build.rs", - "../contracts/host/**/*.wit", + "wit-staging/**/*.wit", "Cargo.toml", "LICENSE-*", "README.md", diff --git a/astrid-sys/build.rs b/astrid-sys/build.rs index e7889ad..7475d55 100644 --- a/astrid-sys/build.rs +++ b/astrid-sys/build.rs @@ -14,9 +14,20 @@ //! //! No external WIT packages are vendored — the contract is fully //! Astrid-owned (`astrid:*` only, no `wasi:*` dependency). +//! +//! Two execution modes: +//! +//! - **Workspace builds**: the `contracts/` submodule is present at +//! `sdk-rust/contracts/host/`. Clean and re-stage `wit-staging/` from +//! the submodule so the committed copy stays in lockstep with the +//! canonical source. +//! - **Published builds** (`cargo install`, `cargo publish` verifier): +//! the submodule isn't part of the `.crate` tarball. Skip staging — +//! the committed `wit-staging/` ships with the crate and is what +//! `wit_bindgen::generate!` consumes. use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; fn main() { // Tell rustc the `getrandom_backend="custom"` cfg flag is known — @@ -26,14 +37,38 @@ fn main() { println!("cargo::rustc-check-cfg=cfg(getrandom_backend, values(\"custom\"))"); let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let contracts_root = crate_root + let host_src = crate_root .parent() // sdk-rust/ .expect("astrid-sys must live under the sdk-rust workspace root") - .join("contracts"); + .join("contracts") + .join("host"); let staging = crate_root.join("wit-staging"); let deps = staging.join("deps"); + // Published-crate path: the `unicity-astrid/wit` submodule isn't + // available on a consumer's machine. The committed `wit-staging/` + // ships with the crate; `src/lib.rs`'s `wit_bindgen::generate!` + // reads it directly. Skip the stage step. + // + // Empty-submodule path: a fresh clone without `git submodule + // update --init` leaves `host_src/` non-existent or empty. Treat + // identically to the published-crate path so we don't wipe the + // committed wit-staging. + let has_wit_files = fs::read_dir(&host_src) + .map(|entries| { + entries.filter_map(Result::ok).any(|e| { + e.path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + }) + }) + .unwrap_or(false); + if !has_wit_files { + println!("cargo:rerun-if-changed=wit-staging"); + return; + } + if staging.exists() { fs::remove_dir_all(&staging).expect("clean wit-staging"); } @@ -48,7 +83,6 @@ fn main() { ) .expect("write root.wit"); - let host_src = contracts_root.join("host"); for entry in fs::read_dir(&host_src).expect("read contracts/host") { let entry = entry.unwrap(); let path = entry.path(); @@ -70,7 +104,7 @@ fn main() { println!("cargo:rerun-if-changed={}", path.display()); } - rerun_if_dir_changed(&host_src); + println!("cargo:rerun-if-changed={}", host_src.display()); println!("cargo:rerun-if-changed=build.rs"); // CI environments may run `git submodule update` lazily; the // .gitmodules pointer changing without the working tree yet @@ -80,7 +114,3 @@ fn main() { crate_root.parent().unwrap().join(".gitmodules").display() ); } - -fn rerun_if_dir_changed(dir: &Path) { - println!("cargo:rerun-if-changed={}", dir.display()); -} diff --git a/astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit new file mode 100644 index 0000000..f236ec1 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit @@ -0,0 +1,73 @@ +/// Human-in-the-loop approval for sensitive actions. +/// +/// Checks the AllowanceStore first (instant path for pre-approved +/// patterns), then publishes an ApprovalRequired IPC event and blocks +/// until the frontend user responds or the request times out (60s). +/// +/// Note: a capsule-to-capsule `astrid-bus:approval@1.0.0` package +/// exists in `interfaces/` for approval-event schemas on the IPC bus. +/// This host interface and that bus contract are distinct concerns; +/// the namespace split keeps them from colliding. +/// +/// 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. + +package astrid:approval@1.0.0; + +interface host { + /// Typed error returned from approval operations. + variant error-code { + /// Action or resource string failed sanitization (control + /// chars, exceeded max length, NUL bytes). + invalid-input, + /// User did not respond within 60s. + timeout, + /// AllowanceStore is temporarily unavailable. + store-unavailable, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Approval request from a capsule to the host. + /// + /// The capsule declares the action and resource. The kernel + /// classifies risk and manages approval policy — the capsule sees + /// only approved/denied. + record approval-request { + /// The action being requested (e.g. "git push"). Sanitized: + /// control chars stripped, max 256 chars. + action: string, + /// Full resource description (e.g. "git push origin main"). + /// Sanitized: max 1024 chars. + target-resource: string, + } + + /// Decision returned by the user (or by the AllowanceStore for a + /// pre-approved pattern). + enum approval-decision { + /// Denied — capsule must not proceed. + denied, + /// Approved once. + approved, + /// Approved for the current session. + approved-session, + /// Approved permanently (stored in the AllowanceStore). + approved-always, + /// Auto-approved via an existing allowance pattern. + allowance, + } + + /// Approval response from the host. Carries the specific decision + /// (not just approved/denied) so capsule UI can communicate WHY + /// (e.g. "stored as always-approve") for transparency. + record approval-response { + /// The specific decision class. + decision: approval-decision, + } + + /// Request human approval for a sensitive action. + /// + /// Audit: every approval call recorded — request, response, and + /// resolution path (AllowanceStore hit vs user prompt). + request-approval: func(request: approval-request) -> result; +} diff --git a/astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit new file mode 100644 index 0000000..cd5525a --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit @@ -0,0 +1,78 @@ +/// Interactive user input collection during lifecycle hooks. +/// +/// Only callable during `astrid-install` or `astrid-upgrade` lifecycle +/// phases. Blocks the WASM thread until the frontend (TUI/CLI) collects +/// user input and publishes a response, or the request times out (120s). +/// +/// Note: a capsule-to-capsule `astrid-bus:elicit@1.0.0` package exists +/// in `interfaces/` for elicit-event schemas on the IPC bus. This host +/// interface and that bus contract are distinct concerns; the namespace +/// split keeps them from colliding. +/// +/// 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. + +package astrid:elicit@1.0.0; + +interface host { + /// Typed error returned from elicit operations. + variant error-code { + /// Called outside an install/upgrade lifecycle phase. + not-in-lifecycle, + /// User did not respond within 120s. + timeout, + /// User cancelled (closed prompt, sent CTRL-C, etc.). + cancelled, + /// Input failed validation (e.g. select value not in options). + invalid-input, + /// SecretStore (keychain + KV fallback) is unavailable. + store-unavailable, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Type of input being elicited. + enum elicit-type { + /// Plain text input. + text, + /// Secret (stored in SecretStore, never returned in plaintext). + secret, + /// Single selection from `options`. + %select, + /// Multiple selections from `options` (returned as JSON array). + array, + } + + /// Request for user input during capsule lifecycle. + record elicit-request { + /// Input type. + kind: elicit-type, + /// Key for storing the collected value. + key: string, + /// Human-readable prompt description. + description: string, + /// Options for select-type inputs. + options: option>, + /// Default value. + default-value: option, + } + + /// Response from an elicit call. + variant elicit-response { + /// Single text/select value. + value(string), + /// Multiple values (from `array` type). + values(list), + /// Secret stored in SecretStore; value not returned. + secret-stored, + } + + /// Prompt the user for input. + /// Audit: every elicit call recorded. + elicit: func(request: elicit-request) -> result; + + /// Check whether a secret key has been stored for this capsule. + /// Uses the SecretStore abstraction (OS keychain with KV fallback). + /// Audit: not recorded (read-only). + has-secret: func(key: string) -> result; +} diff --git a/astrid-sys/wit-staging/deps/astrid-fs/fs@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-fs/fs@1.0.0.wit new file mode 100644 index 0000000..4b26aa3 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-fs/fs@1.0.0.wit @@ -0,0 +1,285 @@ +/// Filesystem operations within the capsule's workspace boundary. +/// +/// All paths are resolved relative to a VFS scheme (`workspace://`, +/// `home://`, `tmp://`). The kernel re-resolves and re-validates every +/// path on every call — `fs-canonicalize` is for display and equality +/// only, NOT a one-time security check that subsequent calls can rely +/// on. Symlink traversal is canonicalized to prevent bypass; results +/// returned from `fs-canonicalize` and `fs-read-link` are always +/// VFS-scheme paths (e.g. `workspace://foo`), never host real-paths. +/// +/// Error strings (the `unknown(string)` arm of `error-code`) never +/// contain host real-paths, IP addresses, UUIDs, or capability names — +/// no host-side information leaks through error messages. +/// +/// 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. + +package astrid:fs@1.0.0; + +interface host { + // ----------------------------------------------------------------- + // Error type + // ----------------------------------------------------------------- + + /// Typed error returned from every fallible fs operation. Specific + /// variants for the common cases; `unknown(string)` carries the raw + /// host detail for everything else. Capsules pattern-match on the + /// specific arms and log/propagate the unknown arm. + variant error-code { + /// File or directory not found. + not-found, + /// Host filesystem denied permission (distinct from + /// `capability-denied` which is Astrid's gate). + access, + /// Capsule lacks the required capability for this operation + /// (e.g. `fs_write` to a `home://` path). + capability-denied, + /// Path resolved outside the VFS scope — sandbox violation. + /// Audit-logged at high severity. + boundary-escape, + /// Path string failed validation (NUL byte, control chars, not + /// UTF-8 NFC, exceeded max length, malformed VFS scheme). + invalid-path, + /// Operation would block on a non-blocking handle. + would-block, + /// Target is a directory where a file was expected. + is-directory, + /// Target is a regular file where a directory was expected. + not-directory, + /// Directory is not empty (for non-recursive removes). + not-empty, + /// Payload exceeded the per-call cap (`read-file` / `write-file` + /// hard 10 MB; `read-at` / `write-at` hard 1 MB) or cumulative + /// per-capsule write quota. + too-large, + /// Server-side resource quota exhausted (open handles, total + /// file count under a VFS scheme). + quota, + /// Cross-VFS-scheme rename / link attempted (e.g. workspace:// + /// to home://). Refused to preserve atomic-rename contracts. + cross-vfs, + /// File or directory already exists where exclusive creation + /// was requested. + already-exists, + /// File handle has been closed (Drop'd) and is no longer valid. + closed, + /// Unspecific I/O error from the host. Detail is best-effort and + /// not part of the contract — capsules log it but do not branch + /// on its content. + unknown(string), + } + + // ----------------------------------------------------------------- + // Value types + // ----------------------------------------------------------------- + + /// File kind. Matches `wasi:filesystem/types.descriptor-type`. + enum file-type { + /// Type could not be determined (rare; non-POSIX hosts). + type-unknown, + /// Regular file. + regular, + /// Directory. + directory, + /// Symbolic link (only seen via `fs-stat-symlink`; following + /// stat resolves the link's target type). + symlink, + /// Block device. + block-device, + /// Character device. + character-device, + /// Named pipe (FIFO). + fifo, + /// Unix-domain socket. + socket, + } + + /// Timestamp matching `wasi:clocks/wall-clock.datetime`. Signed + /// seconds allow pre-1970 timestamps (archive restores, etc.). + record datetime { + seconds: s64, + nanoseconds: u32, + } + + /// File metadata returned by `fs-stat`, `fs-stat-symlink`, and + /// `file-handle.stat`. + record file-stat { + /// File size in bytes. + size: u64, + /// Kind of file (regular / directory / symlink / device / etc.). + kind: file-type, + /// POSIX mode bits (file type + permissions). Zero on platforms + /// without a mode concept; best-effort cross-platform. + mode: u32, + /// Last modification time, if available. + modified: option, + /// Creation (birth) time, if available. + created: option, + /// Last access time, if available. + accessed: option, + } + + /// Open mode for `fs-open`. Matches the common subset of + /// `std::fs::OpenOptions`. + enum open-mode { + /// Open existing file for reading only. Fails if absent. + read, + /// Open or create for writing; truncates existing content. + write, + /// Open or create for writing; appends to existing content. + append, + /// Open existing file for both reading and writing. Fails if + /// absent. + read-write, + } + + // ----------------------------------------------------------------- + // File handle resource + // ----------------------------------------------------------------- + + /// An open file handle. Returned from `fs-open`. The host releases + /// the underlying file descriptor automatically when the resource + /// is dropped (the guest's component-model runtime invokes the + /// destructor) — capsules don't need to close handles explicitly. + /// + /// Per-capsule cap: 16 open file handles. + resource file-handle { + /// Read up to `max-bytes` from the file at byte `offset`. + /// Mirrors POSIX `pread(2)`. Returns the bytes read; an empty + /// list signals EOF at the requested offset. Per-call payload + /// capped at 1 MB. + read-at: func(offset: u64, max-bytes: u32) -> result, error-code>; + + /// Write `data` to the file at byte `offset`. Mirrors POSIX + /// `pwrite(2)`. Returns the number of bytes actually written. + /// Per-call payload capped at 1 MB; cumulative writes counted + /// against the per-capsule write quota. + write-at: func(offset: u64, data: list) -> result; + + /// Flush buffered data to disk (`fdatasync(2)` — data only, not + /// metadata). Mirrors `std::fs::File::sync_data`. + sync-data: func() -> result<_, error-code>; + + /// Flush both data and metadata to disk (`fsync(2)`). Mirrors + /// `std::fs::File::sync_all`. Required for durability-sensitive + /// workloads (databases, WALs); slower than `sync-data`. + sync-all: func() -> result<_, error-code>; + + /// Get file metadata on the open handle (`fstat(2)`). Race-free + /// counterpart to `fs-stat(path)`; safe to call after `fs-open` + /// without re-resolving the path. + stat: func() -> result; + + /// Truncate or extend the file to `size` bytes (`ftruncate(2)`). + /// Mirrors `std::fs::File::set_len`. Extending past end fills + /// with zeros. + set-len: func(size: u64) -> result<_, error-code>; + } + + // ----------------------------------------------------------------- + // Path-based operations + // ----------------------------------------------------------------- + + /// Open a file and return a handle. Mirrors `std::fs::OpenOptions`. + /// Required capability depends on `mode` (read modes need + /// `fs_read`; write/append/read-write need `fs_write`). + fs-open: func(path: string, mode: open-mode) -> result; + + /// Check whether a path exists. Returns true / false / error + /// (rather than collapsing access-denied into "doesn't exist"). + fs-exists: func(path: string) -> result; + + /// Create a directory. Mirrors `std::fs::create_dir` / + /// `mkdir(2)`. Strict — fails with `already-exists` if the path + /// exists, and fails with `not-found` if the parent directory + /// does not exist. Use this for lock-dir patterns and exclusive + /// scratch dirs where the race "another writer got here first" + /// matters. For the "ensure this exists" pattern use + /// `fs-mkdir-all`. + fs-mkdir: func(path: string) -> result<_, error-code>; + + /// Create a directory and all missing parents. Mirrors + /// `std::fs::create_dir_all`. Idempotent — succeeds if the + /// directory already exists. For the strict "must not exist" + /// variant use `fs-mkdir`. + fs-mkdir-all: func(path: string) -> result<_, error-code>; + + /// List entries in a directory. Returns entry names (not full + /// paths). Per-call cap: 4096 entries; larger directories must use + /// `fs-readdir-page` (not in 1.0; tracked for 1.x). + fs-readdir: func(path: string) -> result, error-code>; + + /// Get file metadata. Follows symlinks (`stat(2)`). + fs-stat: func(path: string) -> result; + + /// Get file metadata without following symlinks (`lstat(2)`). + /// Mirrors `std::fs::symlink_metadata`. Use when you specifically + /// want to inspect a symlink rather than its target. + fs-stat-symlink: func(path: string) -> result; + + /// Remove a regular file. Mirrors `std::fs::remove_file`. Returns + /// `is-directory` if the path names a directory. + fs-unlink: func(path: string) -> result<_, error-code>; + + /// Read a whole file as bytes. Convenience wrapper around + /// `fs-open` + `file-handle.read-at`. Files larger than 10 MB are + /// rejected with `too-large`; use `fs-open` + handle reads for + /// streaming over large files. + read-file: func(path: string) -> result, error-code>; + + /// Write content to a file (truncate-or-create). Mirrors + /// `std::fs::write`. Capped at 10 MB per call; cumulative against + /// the per-capsule write quota. + write-file: func(path: string, content: list) -> result<_, error-code>; + + /// Append content to a file, creating it if absent. Avoids the + /// read-modify-write hazard of `read-file` + `write-file` for + /// log-style writes. 10 MB per call; cumulative against the + /// per-capsule write quota. + fs-append: func(path: string, content: list) -> result<_, error-code>; + + /// Copy a file from `src` to `dst`. Mirrors `std::fs::copy`. + /// Overwrites `dst`. Directory copies are not supported; `src` + /// must name a regular file. + fs-copy: func(src: string, dst: string) -> result<_, error-code>; + + /// Rename (move) within the same VFS scheme. Mirrors + /// `std::fs::rename`. Cross-scheme renames return `cross-vfs`. + fs-rename: func(src: string, dst: string) -> result<_, error-code>; + + /// Remove a directory and all its contents recursively. Refuses + /// to traverse symlinks to prevent sandbox escapes. Returns the + /// count of removed entries (files + subdirectories). + fs-remove-dir-all: func(path: string) -> result; + + /// Resolve a path to its canonical form, following symlinks. + /// Returns a VFS-scheme path (`workspace://`, `home://`, `tmp://`), + /// NEVER a host real-path. Resolutions escaping the input's VFS + /// scope return `boundary-escape`. + /// + /// Note: `fs-canonicalize` is for display and path-equality only. + /// The result is NOT a security check that subsequent calls can + /// trust — the kernel re-resolves every path on every call, so + /// `fs-canonicalize` then `fs-open` is NOT TOCTOU-safe. + fs-canonicalize: func(path: string) -> result; + + /// Read the target of a symbolic link without following it. + /// Mirrors `std::fs::read_link`. Returns a VFS-scheme path; links + /// pointing outside the input's scope return `boundary-escape`. + fs-read-link: func(path: string) -> result; + + /// Create a hard link from `link-path` to `src`. Mirrors + /// `std::fs::hard_link`. Both paths must resolve to the same VFS + /// scheme; cross-scheme links return `cross-vfs`. Cannot link + /// directories. The kernel enforces that both endpoints stay + /// inside the VFS scope at link time. + /// + /// (Symlink creation — `fs-symlink` — is deliberately omitted: it + /// allows capsules to encode boundary-escape paths into the + /// workspace, and the read-only `fs-read-link` / `fs-stat-symlink` + /// pair is sufficient for handling existing symlinks. Revisit if + /// a concrete use case emerges with a security model.) + /// Security-gated: requires file-write capability on both paths. + fs-hard-link: func(src: string, link-path: string) -> result<_, error-code>; +} diff --git a/astrid-sys/wit-staging/deps/astrid-guest/guest@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-guest/guest@1.0.0.wit new file mode 100644 index 0000000..b4caafd --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-guest/guest@1.0.0.wit @@ -0,0 +1,89 @@ +/// Guest export contract — lifecycle and interceptor entry points the +/// kernel calls into a capsule. +/// +/// Each entry point lives in its own world so capsules `include` only the +/// ones they actually implement. The CM toolchain auto-stubs every export +/// declared in a world the component targets; co-mingling optional exports +/// in a single world forces stubs for unused ones and pushes the kernel +/// into parsing the wasm binary to detect them. Per-export worlds put the +/// declaration where the implementation is, and the kernel sees exports +/// only when they are real. +/// +/// Typical capsule worlds: +/// +/// ```wit +/// // Interceptor-only capsule (e.g. router): +/// world my-capsule { +/// include astrid:guest/interceptor@1.0.0; +/// import astrid:ipc/host@1.0.0; +/// } +/// +/// // Run-loop capsule with install hook (e.g. cli uplink): +/// world my-capsule { +/// include astrid:guest/interceptor@1.0.0; +/// include astrid:guest/background@1.0.0; +/// include astrid:guest/installable@1.0.0; +/// import astrid:ipc/host@1.0.0; +/// import astrid:uplink/host@1.0.0; +/// } +/// ``` +/// +/// The package is named `astrid:guest` to mirror the per-domain `host` +/// packages: `host` interfaces are kernel-side (imported by capsules), +/// `guest` exports are capsule-side (called by the kernel). Note the +/// distinct `astrid:hook@1.0.0` in `interfaces/hook.wit` — that one is a +/// capsule-to-capsule IPC contract for lifecycle fan-out, not a guest +/// export contract. +/// +/// 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. + +package astrid:guest@1.0.0; + +interface lifecycle { + /// Result returned by a capsule after hook execution. + record capsule-result { + /// Action directive (e.g. "continue", "abort", "modify"). + action: string, + /// Optional payload as a JSON string. + data: option, + } +} + +/// Interceptor entry point. +/// +/// Almost every capsule includes this. The kernel calls `astrid-hook-trigger` +/// with an action name (interceptor handler to invoke) and an event payload +/// as raw bytes. The guest returns a `capsule-result` directing the kernel +/// how to proceed. +world interceptor { + use lifecycle.{capsule-result}; + + export astrid-hook-trigger: func(action: string, payload: list) -> capsule-result; +} + +/// Background run loop. +/// +/// Capsules that export `run` are started as background tasks. The function +/// should block indefinitely (event loop pattern), processing IPC messages +/// via subscriptions set up before calling `signal-ready`. The kernel +/// manages the lifecycle. +world background { + export run: func(); +} + +/// First-time installation lifecycle hook. +/// +/// Called once when a capsule is installed. May use `elicit` to collect +/// secrets and configuration from the user interactively. +world installable { + export astrid-install: func(); +} + +/// Upgrade lifecycle hook. +/// +/// Called when a capsule is upgraded from a previous version. May use +/// `elicit` to collect new configuration. +world upgradable { + export astrid-upgrade: func(); +} diff --git a/astrid-sys/wit-staging/deps/astrid-http/http@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-http/http@1.0.0.wit new file mode 100644 index 0000000..b62b282 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-http/http@1.0.0.wit @@ -0,0 +1,141 @@ +/// HTTP client operations with SSRF protection. +/// +/// DNS resolution blocks connections to private, loopback, link-local, +/// multicast, and unspecified IP addresses. IPv4-mapped and IPv4- +/// compatible IPv6 addresses are also checked. The security gate +/// enforces per-capsule allow-lists and rate limits. +/// +/// 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. + +package astrid:http@1.0.0; + +interface host { + use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream}; + + /// Typed error returned from every fallible http operation. + variant error-code { + /// Capsule lacks the `net` capability. + capability-denied, + /// Hostname failed validation (NUL bytes, control chars, etc.) + /// or URL doesn't parse. + invalid-request, + /// DNS could not resolve the hostname. + dns-error, + /// All resolved IPs were SSRF-blocked (private/loopback/etc). + airlock-rejected, + /// TLS handshake / certificate problem. + tls-error, + /// Request or per-chunk read exceeded its timeout. + timeout, + /// Connection refused or reset by peer. + connection-error, + /// Response body exceeded the 10 MB cap on buffered requests + /// (use `http-stream-start` for larger). + body-too-large, + /// Stream handle has been closed. + closed, + /// Server-side resource quota exhausted (4 concurrent streams). + quota, + /// Unspecific HTTP-protocol-level error from the host. + protocol(string), + /// Unspecific I/O error from the host; detail is best-effort. + unknown(string), + } + + /// HTTP request method. Matches `wasi:http/types.method`. `other` + /// carries non-standard methods (PROPFIND, etc.) by name. + variant http-method { + get, + head, + post, + put, + %delete, + connect, + options, + trace, + patch, + other(string), + } + + /// A key-value pair used for typed header lists. + record key-value-pair { + key: string, + value: string, + } + + /// HTTP request sent by a capsule to the host. + record http-request-data { + /// Target URL. + url: string, + /// HTTP method. + method: http-method, + /// Request headers as key-value pairs. Duplicates allowed + /// (e.g. multiple `Cookie` lines). + headers: list, + /// Optional request body as raw bytes. JSON/text callers + /// encode their string payload to UTF-8 bytes; binary uploads + /// (image, protobuf, multipart) pass their bytes directly. + body: option>, + } + + /// HTTP response returned from `http-request`. + record http-response-data { + /// HTTP status code. + status: u16, + /// Response headers. + headers: list, + /// Response body as raw bytes. + body: list, + } + + /// HTTP streaming response handle. The kernel buffers chunks + /// server-side; the capsule reads them via `read-chunk` until + /// EOF or `close`. Per-chunk timeout: 120s. + /// + /// Per-capsule cap: 4 concurrent HTTP streams. Drop is automatic + /// — capsules don't need to call close explicitly. + resource http-stream { + /// HTTP status code returned at stream start. + status: func() -> u16; + + /// Response headers from the initial response. + headers: func() -> list; + + /// Read the next chunk. Returns an empty list at EOF; + /// callers loop until the list is empty. + read-chunk: func() -> result, error-code>; + + /// Close the stream explicitly. Idempotent. Equivalent to + /// dropping the resource. + close: func() -> result<_, error-code>; + + /// Pollable that fires when the next chunk is ready to read + /// (or EOF has arrived). Compose with other pollables for + /// multiplexed I/O — e.g. a capsule streaming an HTTP + /// response while also handling IPC requests. + subscribe-readable: func() -> pollable; + + /// The response body as an `input-stream`. Use this instead + /// of `read-chunk` when forwarding the body to another sink + /// (e.g. a TCP stream via `output-stream.splice`) — the splice + /// path moves bytes host-side without crossing the WASM + /// boundary per chunk. + /// + /// `body-stream` and `read-chunk` share the underlying + /// response cursor; the kernel serializes access. Pick one. + body-stream: func() -> input-stream; + } + + /// Perform a buffered HTTP request (full response in memory). + /// 30s timeout, max response body 10 MB. + /// Audit: recorded (URL host + method + status; body bytes not + /// logged). + http-request: func(request: http-request-data) -> result; + + /// Start a streaming HTTP request (headers returned on the + /// resource; body streamed via `read-chunk`). + /// Audit: recorded (start + close). + http-stream-start: func(request: http-request-data) -> result; +} diff --git a/astrid-sys/wit-staging/deps/astrid-identity/identity@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-identity/identity@1.0.0.wit new file mode 100644 index 0000000..6e78344 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-identity/identity@1.0.0.wit @@ -0,0 +1,105 @@ +/// Multi-platform identity resolution and linking. +/// +/// Maps external platform identities (Discord user, GitHub user, etc.) +/// to internal Astrid user IDs. All operations are security-gated per +/// operation type and audit-logged. +/// +/// 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. + +package astrid:identity@1.0.0; + +interface host { + /// Typed error returned from identity operations. + variant error-code { + /// Capsule lacks the `identity` capability. + capability-denied, + /// Platform / user-id / display-name / method failed + /// validation (NUL bytes, control chars, max length). + invalid-input, + /// User ID does not exist. + user-not-found, + /// No link exists for the given platform user (resolve / unlink). + link-not-found, + /// Link already exists (link operation collision). + already-linked, + /// Identity store is temporarily unavailable. + store-unavailable, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Request to resolve a platform identity to an Astrid user. + record identity-resolve-request { + /// External platform (e.g. "discord", "github"). + platform: string, + /// User ID on the external platform. + platform-user-id: string, + } + + /// Successful resolution; carries the user info. Returns + /// `link-not-found` error instead of a flag-based "found: bool". + record identity-resolve-response { + /// The Astrid user ID. + user-id: string, + /// The user's display name, if available. + display-name: option, + } + + /// Request to link a platform identity to an Astrid user. + record identity-link-request { + platform: string, + platform-user-id: string, + astrid-user-id: string, + /// Authentication method (e.g. "passkey", "token"). + method: string, + } + + /// Request to unlink a platform identity. + record identity-unlink-request { + platform: string, + platform-user-id: string, + } + + /// Request to create a new Astrid user. + record identity-create-user-request { + /// Optional display name for the new user. + display-name: option, + } + + /// Response from `identity-create-user`. + record identity-create-user-response { + /// The newly created Astrid user ID. + user-id: string, + } + + /// One platform link in a user's link list. + record platform-link { + platform: string, + platform-user-id: string, + /// When the link was created (ISO 8601). + linked-at: string, + /// Authentication method used at link time. + method: string, + } + + /// Resolve a platform identity to an Astrid user. Returns + /// `link-not-found` if the platform user is not linked to anyone. + identity-resolve: func(request: identity-resolve-request) -> result; + + /// Link a platform identity to an Astrid user. Returns + /// `already-linked` if a link for this (platform, platform-user-id) + /// already exists. + identity-link: func(request: identity-link-request) -> result<_, error-code>; + + /// Unlink a platform identity. Returns `link-not-found` if no link + /// exists. + identity-unlink: func(request: identity-unlink-request) -> result<_, error-code>; + + /// Create a new Astrid user. + identity-create-user: func(request: identity-create-user-request) -> result; + + /// List all platform links for an Astrid user. Returns the + /// (possibly empty) link list directly — no JSON blob. + identity-list-links: func(astrid-user-id: string) -> result, error-code>; +} diff --git a/astrid-sys/wit-staging/deps/astrid-io/io@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-io/io@1.0.0.wit new file mode 100644 index 0000000..ffc6ca8 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-io/io@1.0.0.wit @@ -0,0 +1,289 @@ +/// Foundation I/O primitives — Astrid-owned readiness multiplexing, +/// downcastable error resource, and byte streams. +/// +/// The shape mirrors `wasi:io@0.2.0` (error / poll / streams) because the +/// Component Model conventions for these primitives are mature and well- +/// understood. What differs is ownership: Astrid implements all three +/// interfaces itself rather than re-exporting `wasi:io`, so every +/// operation is gated, principal-scoped, audited, cancellable, and +/// quota-bounded by the kernel's capability layer. No wasi:* carve-outs. +/// +/// Why Astrid-owned and not wasi:io: +/// +/// - `pollable.block()` and `poll.poll(...)` race against the calling +/// capsule's cancellation token. On capsule unload, blocking calls +/// return `cancelled` immediately rather than stranding host tasks on +/// futures that may never complete. +/// - Every read/write/skip/splice on a stream is audited (per-principal, +/// with bytes transferred and elapsed time). +/// - Pollable and stream resource handles are bounded by the per-principal +/// quota profile; exceeding it returns `quota` from the host fn that +/// would have allocated them. +/// - Pollables created in capsule A's store cannot be passed to capsule +/// B (wasmtime resource-table boundary). +/// +/// Forward-looking: when Astrid ships as a hermit-rs unikernel, the +/// kernel-side impls dispatch to unikernel wait/io primitives rather +/// than wasmtime-wasi-backed futures. The WIT contract is stable across +/// host implementations. +/// +/// 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. + +package astrid:io@1.0.0; + +/// A downcastable error resource carried by `stream-error::last-operation-failed`. +/// +/// Mirrors `wasi:io/error`. Other host interfaces (`astrid:net`, +/// `astrid:http`, `astrid:process`, …) may provide downcast functions +/// that take `borrow` and return a typed error-code from their +/// own domain — e.g. converting a stream's last-operation-failed into +/// a `net.error-code::connection-reset`. +interface error { + /// An opaque error value tagged with a host-side identifier. + resource error { + /// Human-readable diagnostic. + /// + /// WARNING: do not parse this string. Its content is best-effort + /// and changes across platforms / kernel revisions. For typed + /// classification use a domain-specific downcast function on a + /// `borrow`. + to-debug-string: func() -> string; + } +} + +/// Readiness multiplexing. +/// +/// Pollables are returned by `subscribe-*` methods on other Astrid host +/// resources and let capsules wait on heterogeneous readiness signals +/// via a single `poll` call. +interface poll { + /// Typed error returned from fallible poll operations. + variant error-code { + /// `poll` was called with an empty list. Polling on nothing + /// is undefined; the host rejects rather than blocking forever. + invalid-input, + /// Pollable handle was dropped (or never valid in this store). + closed, + /// Caller exceeded the hard per-call cap (256 pollables) on + /// `poll`. The cap is sized so a capsule at its full IPC + /// subscription quota (128) plus its TCP / UDP / HTTP / process + /// stream pollables can wait on them all in one call. + /// Subdivide the wait set or use resource-specific blocking. + too-large, + /// Block was cancelled because the capsule is unloading. + cancelled, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// An opaque handle to a future readiness signal. + resource pollable { + /// Non-blocking readiness check. + /// + /// Returns `true` if a subsequent `block` would return + /// immediately, `false` otherwise. Side-effect free; not + /// audit-recorded per call (high-volume). + ready: func() -> bool; + + /// Block the calling guest task until the pollable is ready. + /// + /// Returns when the underlying signal fires OR when the + /// capsule's cancellation token is triggered. Returns + /// `cancelled` in the latter case; capsules should treat + /// that as graceful shutdown. + /// + /// Audit: recorded with the calling principal and wait + /// duration in milliseconds. + block: func() -> result<_, error-code>; + } + + /// Wait until at least one of the given pollables is ready. + /// + /// Returns the indices (into the input list) of every pollable + /// that was ready when at least one became so. The returned list + /// is sorted ascending and contains at least one entry on success. + /// + /// Per-call cap: 256 pollables. Larger lists return `too-large`. + /// The cap is sized so a capsule at full IPC subscription quota + /// (128) plus its TCP / UDP / HTTP / process pollables can wait + /// on them all in a single call. Per-principal quota profiles + /// may lower the effective cap further but never raise it above + /// 256. + /// + /// Returns `cancelled` if the capsule unloads mid-poll. + /// + /// Audit: every `poll` call recorded (per-principal, with handle + /// count and wait duration). + poll: func(pollables: list>) -> result, error-code>; +} + +/// Byte streams. +/// +/// `input-stream` is the read end of a byte source; `output-stream` is +/// the write end of a byte sink. Both are non-blocking by default; +/// blocking variants are provided for ergonomic use. `splice` moves +/// bytes from an input to an output in the host without crossing the +/// WASM boundary per byte — the primary throughput primitive for +/// proxying / forwarding capsules (e.g. capsule-hosted TCP servers). +/// +/// Streams are not constructed directly by capsules. They are obtained +/// from `subscribe-*` / `*-stream` methods on other host resources: +/// +/// - `astrid:net/host.tcp-stream.{read-stream, write-stream}` — TCP byte halves +/// - `astrid:http/host.http-stream.body-stream` — HTTP response body +/// - `astrid:process/host.process-handle.{stdin, stdout, stderr}` — child stdio +/// +/// Each per-call read / write / splice is audited (per-principal, with +/// bytes transferred). Blocking variants race against the calling +/// capsule's cancellation token. Per-capsule stream-handle quotas are +/// bounded by the principal's profile. +interface streams { + use error.{error}; + use poll.{pollable}; + + /// Error variant for stream operations. + /// + /// Matches the `wasi:io/streams.stream-error` shape so capsule SDKs + /// can reason uniformly about stream failures. After a stream + /// returns `last-operation-failed`, the stream is closed; all + /// subsequent calls return `closed`. + variant stream-error { + /// The last read / write / splice / flush failed before + /// completion. The `error` payload is downcastable to a + /// domain-specific error-code via interfaces that source the + /// stream (e.g. `astrid:net` for TCP streams, `astrid:http` + /// for HTTP body streams). + last-operation-failed(error), + /// Stream end: no more bytes will be produced (input) or + /// accepted (output). Returned by every operation on a closed + /// stream. + closed, + } + + /// Read end of a byte stream. + /// + /// `read` is non-blocking; returns up to `len` bytes (possibly zero) + /// if any are promptly available. To wait, take the `subscribe` + /// pollable and `block` on it, or use `blocking-read`. + resource input-stream { + /// Non-blocking read up to `len` bytes. Empty list = no data + /// available right now (not EOF). Use `subscribe` to wait. + /// EOF or peer close surfaces as `closed` on the next call. + /// + /// The host may return fewer than `len` bytes — `len` is the + /// caller's *upper bound*, and the host applies its own + /// internal buffer ceiling (currently 1 MiB) to bound a single + /// call's transfer. Callers loop on `read` to drain larger + /// volumes; truncation is not a stream error. Per `wasi:io` + /// semantics, a trap only occurs when `len` exceeds what + /// wasm32 can address (~4 GiB). + /// + /// Audit: recorded (per-principal, with bytes read). + read: func(len: u64) -> result, stream-error>; + + /// Block until at least one byte is available, then read up to + /// `len`. Identical to `read` except for the wait. Races + /// against the cancellation token; `last-operation-failed` + /// surfaces if the capsule is unloading. + blocking-read: func(len: u64) -> result, stream-error>; + + /// Skip up to `len` bytes without buffering them in the guest. + /// Equivalent to `read` followed by discarding, but skips the + /// data copy. Returns the number of bytes actually skipped. + skip: func(len: u64) -> result; + + /// Blocking variant of `skip`. + blocking-skip: func(len: u64) -> result; + + /// Pollable that fires when bytes are available to read OR the + /// stream has closed. Once ready, `read` is guaranteed to + /// return at least one byte OR a `closed` error. + /// + /// The pollable is a child resource: dropping the input-stream + /// before all derived pollables traps. + subscribe: func() -> pollable; + } + + /// Write end of a byte stream. + /// + /// `write` is non-blocking; `check-write` reports how many bytes + /// may be written before the next `write` would block. `splice` is + /// the primary throughput primitive — bytes move host-side without + /// crossing the WASM boundary per byte. + resource output-stream { + /// How many bytes may be written in the next `write` call. + /// Returns 0 if the stream is not currently writable; + /// `subscribe` will fire when that changes. + /// + /// Calling `write` with more bytes than `check-write` permits + /// traps. This mirrors `wasi:io/streams` exactly. + check-write: func() -> result; + + /// Write bytes. `contents.len()` must be <= the last + /// `check-write` permit. Returns `closed` if the stream closed + /// since the permit was issued. + /// + /// Audit: recorded (per-principal, with bytes written). + write: func(contents: list) -> result<_, stream-error>; + + /// Convenience: write `contents` and flush, blocking until all + /// bytes are accepted and the flush completes. Internally + /// drives `check-write` / `subscribe` / `write` / `flush` in a + /// loop, so callers don't need an outer loop of their own. + /// No fixed cap on payload size — the host segments large + /// transfers internally and yields between chunks to keep + /// other capsules responsive. (The `wasi:io` documentation + /// mentions 4096 in pseudocode but does not actually cap.) + /// + /// Audit: recorded (per-principal, with total bytes written). + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + + /// Request flush of all bytes passed to `write` prior to this + /// call. Non-blocking. While the flush is in progress, + /// `check-write` returns 0; `subscribe` fires when it + /// completes. + flush: func() -> result<_, stream-error>; + + /// Block until flush completes and the stream is ready to + /// accept more writes. Races against cancellation. + blocking-flush: func() -> result<_, stream-error>; + + /// Pollable that fires when the stream is ready to accept + /// writes (i.e. `check-write` will return > 0) OR the stream + /// closed. The pollable is a child resource: dropping the + /// output-stream before all derived pollables traps. + subscribe: func() -> pollable; + + /// Write `len` zero bytes. Same preconditions as `write`: + /// `len` must be <= last `check-write` permit. Useful for + /// sparse-file extension and protocol padding. + write-zeroes: func(len: u64) -> result<_, stream-error>; + + /// Convenience: write `len` zeroes and flush, blocking until + /// complete. No fixed cap on `len` — no guest-side buffer is + /// involved, so the host can stream arbitrary zero-fill sizes + /// efficiently. The host yields between chunks to keep other + /// capsules responsive. + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + + /// Move up to `len` bytes from `src` to this stream in the + /// host. The kernel handles the read-then-write loop without + /// crossing the WASM boundary per byte — the primary + /// throughput primitive for proxy / forwarder capsules. + /// + /// Behaviour is equivalent to: + /// 1. `check-write` on this stream + /// 2. `read` on `src` with the smaller of the permit and `len` + /// 3. `write` on this stream with the bytes read + /// Any error in those steps ends the splice and is reported. + /// + /// Audit: recorded (per-principal, with bytes spliced). + splice: func(src: borrow, len: u64) -> result; + + /// Blocking variant of `splice`. Blocks until `src` has data + /// AND this stream is writable, then splices. Races against + /// cancellation. + blocking-splice: func(src: borrow, len: u64) -> result; + } +} diff --git a/astrid-sys/wit-staging/deps/astrid-ipc/ipc@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-ipc/ipc@1.0.0.wit new file mode 100644 index 0000000..4bc0421 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-ipc/ipc@1.0.0.wit @@ -0,0 +1,163 @@ +/// Inter-Process Communication (IPC) event bus. +/// +/// Capsules communicate via a publish/subscribe event bus. Topics are +/// dot-delimited strings ([a-z0-9._-]+ per segment, max 8 segments, +/// max 256 bytes). Publishing and subscribing are independently +/// ACL-gated via `ipc_publish` and `ipc_subscribe` patterns in +/// `Capsule.toml [capabilities]`. +/// +/// Provenance is tracked via `ipc-message.source-id` (the publishing +/// capsule's session UUID) and `ipc-message.principal` (variant +/// carrying both the principal AND the trust marker — verified vs +/// claimed). Downstream consumers MUST check the principal-attribution +/// variant for sensitive actions: claimed principals are uplink- +/// asserted and not kernel-verified. +/// +/// 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. + +package astrid:ipc@1.0.0; + +interface host { + use astrid:io/poll@1.0.0.{pollable}; + + /// Typed error returned from every fallible ipc operation. + variant error-code { + /// Capsule lacks `ipc_publish` / `ipc_subscribe` matching the + /// topic, or lacks `uplink` for `publish-as`. + capability-denied, + /// Topic / payload / principal failed validation. + invalid-input, + /// Subscription handle has been dropped. + closed, + /// Publish was rate-limited (per-capsule limit). + rate-limited, + /// Bus queue overflow (buffer full beyond drop threshold). + backpressure, + /// Max subscriptions per capsule (128) reached. + quota, + /// Recv timed out before a message arrived. + timeout, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Provenance + trust attribution for an IPC message's principal. + /// + /// Capsules MUST check this variant on sensitive actions. A + /// `claimed` principal is uplink-asserted (via `publish-as`) and + /// the kernel has NOT verified the asserting uplink actually + /// authenticated the named principal. Treat `claimed` as caller- + /// input, not authenticated context. + variant principal-attribution { + /// Principal kernel-verified from the publishing capsule's + /// invocation context. Trusted: capability checks may use + /// this directly. + verified(string), + /// Principal claimed by an uplink capsule via `publish-as`. + /// Uplink-asserted, NOT kernel-verified. Downstream capability + /// checks on sensitive actions MUST require `verified` or + /// perform additional authentication. + claimed(string), + /// System / kernel-originated event with no attributable + /// principal (e.g. lifecycle events from the kernel itself). + system, + } + + /// IPC message envelope returned by `subscription.poll` / + /// `subscription.recv`. + record ipc-envelope { + /// List of messages received since last poll/recv. + messages: list, + /// Number of messages dropped due to buffer overflow. + dropped: u64, + /// Cumulative lag (messages missed due to slow consumption). + lagged: u64, + } + + /// A single IPC message. + record ipc-message { + /// Topic the message was published on. + topic: string, + /// Message payload as JSON. + payload: string, + /// UUID of the capsule that sent this message. + source-id: string, + /// Principal attribution — see `principal-attribution`. + /// Subscribers processing multi-message recv batches MUST + /// read this per-message rather than rely on invocation + /// context (which only reflects the first message's + /// publisher). + principal: principal-attribution, + } + + /// Pre-registered interceptor handle mapping. + record interceptor-binding { + /// Subscription handle ID. + handle-id: u64, + /// Interceptor action name. + action: string, + /// IPC topic pattern this binding is subscribed to. + topic: string, + } + + /// An active subscription to an IPC topic pattern. Drop is + /// automatic — capsules don't call unsubscribe explicitly. + /// Per-capsule cap: 128 subscriptions. + resource subscription { + /// Non-blocking poll for messages. Returns whatever's queued + /// since the last poll/recv. + poll: func() -> result; + + /// Block until a message arrives, or `timeout-ms` elapses. + /// Max timeout: 60_000 ms (capped by host). Returns the + /// envelope on message arrival or `timeout` error on + /// timeout. + recv: func(timeout-ms: u64) -> result; + + /// Pollable that fires when messages are queued. Compose with + /// other pollables (net, future http-stream, etc.) via + /// `astrid:io/poll.poll` for multiplexed I/O in bridge and + /// fan-out capsules. + subscribe-readiness: func() -> pollable; + } + + /// Publish a UTF-8 JSON payload to a topic. Principal is + /// automatically attributed from the caller's invocation context + /// as `verified`. + /// + /// Re-entrancy: publish does NOT synchronously invoke subscribers. + /// Subscribers receive the message on their next `recv`/`poll`. + /// Publishing from within an interceptor handler is allowed but + /// counts against the per-invocation publish-depth budget (host + /// default: 8) to prevent unbounded reentry. + /// + /// Fan-out: a single publish dispatches to at most 256 matching + /// subscribers; further matches return `backpressure` (the kernel + /// preserves the first 256 deliveries and counts the rest as + /// dropped in subscribers' lag/drop counters). + /// + /// Audit: recorded. + publish: func(topic: string, payload: string) -> result<_, error-code>; + + /// Publish on behalf of a specific principal (uplink-claimed, + /// NOT kernel-verified — see `principal-attribution::claimed`). + /// Reserved for uplinks (`uplink = true` in + /// `[capabilities]`); other callers get `capability-denied`. + /// Subscribers see the principal as `claimed(...)`, not + /// `verified(...)`. + /// Audit: recorded with BOTH the uplink's true principal AND + /// the claimed principal. + publish-as: func(topic: string, payload: string, principal: string) -> result<_, error-code>; + + /// Subscribe to a topic pattern. Supports exact matches and + /// trailing-suffix wildcards (`foo.bar.*`). Mid-segment wildcards + /// are rejected. + subscribe: func(topic-pattern: string) -> result; + + /// Get pre-registered interceptor handles for run-loop capsules. + /// Returns ONLY the calling capsule's own interceptors — does + /// NOT enumerate other capsules' bindings (capability-inference + /// scope discipline). + get-interceptor-bindings: func() -> result, error-code>; +} diff --git a/astrid-sys/wit-staging/deps/astrid-kv/kv@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-kv/kv@1.0.0.wit new file mode 100644 index 0000000..0f8220b --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-kv/kv@1.0.0.wit @@ -0,0 +1,93 @@ +/// Key-Value persistent storage. +/// +/// Keys are scoped per-principal and per-capsule. Each capsule sees only +/// its own namespace (`wasm:{capsule_id}`), and per-invocation principal +/// scoping ensures different users' data is isolated even when the same +/// capsule serves multiple principals. +/// +/// Keys are UTF-8 NFC, no NUL or control characters, max 256 bytes. +/// Values are arbitrary bytes (max 1 MiB per value). Server-side +/// cumulative quota per (principal, capsule) namespace is bounded; +/// `quota` is returned when the quota is exhausted. +/// +/// 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. + +package astrid:kv@1.0.0; + +interface host { + /// Typed error returned from every fallible kv operation. + variant error-code { + /// Key string failed validation (NUL byte, control chars, not + /// UTF-8 NFC, exceeded max length). + invalid-key, + /// Value exceeded the 1 MiB per-value cap. + too-large, + /// Per-(principal, capsule) cumulative quota exhausted. + quota, + /// `kv-cas` saw a value other than `expected` (or saw a value + /// at all when `expected` was `none`). Caller's lost-race + /// retry path: re-read the key, recompute, retry the CAS. + cas-mismatch, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// A page of keys returned by `kv-list-keys-page`. + record key-page { + /// Keys matching the prefix in this page. + keys: list, + /// Cursor to pass to the next `kv-list-keys-page` call. `none` + /// when this is the last page. + next-cursor: option, + } + + /// Read a value by key. + /// + /// Returns the stored bytes, or `none` if the key does not exist. + /// Audit: not recorded per-call (high-volume read; sampled at + /// kernel level). + kv-get: func(key: string) -> result>, error-code>; + + /// Write a value by key. Atomic per-key replacement. + /// Audit: recorded. + kv-set: func(key: string, value: list) -> result<_, error-code>; + + /// Delete a key. Idempotent — deleting a missing key is a no-op. + /// Audit: recorded. + kv-delete: func(key: string) -> result<_, error-code>; + + /// List all keys matching a prefix. Convenience for small stores + /// where the caller is sure the result fits. + /// + /// Capped server-side at 1024 keys per call — larger result sets + /// return `too-large` directing the caller to `kv-list-keys-page`. + kv-list-keys: func(prefix: string) -> result, error-code>; + + /// Paginated key listing for unbounded stores. + /// + /// Pass `none` for `cursor` on the first call and the cursor + /// returned in `next-cursor` on subsequent calls. `limit` is + /// capped at 1024 per page; 0 means "use the server default." + kv-list-keys-page: func(prefix: string, cursor: option, limit: u32) -> result; + + /// Delete all keys matching a prefix. + /// + /// Returns the count of deleted keys. + /// Audit: recorded. + kv-clear-prefix: func(prefix: string) -> result; + + /// Atomically compare-and-swap a key. + /// + /// Returns `Ok(())` if the key's current value equals `expected` + /// and the swap was applied. Returns `Err(cas-mismatch)` if + /// `expected` did not match (the routine lost-race retry path). + /// `expected` of `none` means "swap only if the key does not + /// currently exist" (create-if-absent). + /// + /// Required for any concurrent coordination on shared state — the + /// kernel runs capsule invocations across the multi-threaded tokio + /// worker pool, so RMW patterns on shared keys race without this. + /// Audit: recorded. + kv-cas: func(key: string, expected: option>, new: list) -> result<_, error-code>; +} diff --git a/astrid-sys/wit-staging/deps/astrid-net/net@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-net/net@1.0.0.wit new file mode 100644 index 0000000..b4b3b40 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-net/net@1.0.0.wit @@ -0,0 +1,373 @@ +/// Networking — Unix domain sockets, outbound TCP, UDP, and DNS resolution. +/// +/// The kernel pre-binds a single `UnixListener` per capsule. Capsules +/// accept client connections via the provided listener, then read/write +/// length-prefixed frames. Session token handshake authentication is +/// enforced on each accepted connection. Outbound TCP and UDP are gated +/// by `net_connect` / `net_udp` allowlists and the SSRF airlock blocks +/// private/loopback/link-local/multicast/unspecified IPs on resolved +/// peer addresses. +/// +/// Max 8 concurrent TCP streams + 4 UDP sockets per capsule. +/// +/// 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. + +package astrid:net@1.0.0; + +interface host { + use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream, output-stream}; + + // ----------------------------------------------------------------- + // Error type + // ----------------------------------------------------------------- + + /// Typed error returned from every fallible net operation. + variant error-code { + /// Operation would block (non-blocking reads with no data, + /// non-blocking accept with no pending). + would-block, + /// Stream / socket has been closed by the peer or locally. + closed, + /// Capsule lacks the required capability. + capability-denied, + /// Peer address resolved into the SSRF-blocked range + /// (private/loopback/link-local/multicast/unspecified). + airlock-rejected, + /// Connect attempt refused by peer. + connection-refused, + /// Established connection was reset. + connection-reset, + /// Connect / read / write exceeded the call's timeout. + timeout, + /// Local address already in use (rare for outbound; possible + /// for UDP binds). + address-in-use, + /// Local address can't be bound (parsing error, unsupported + /// family). + address-not-available, + /// DNS resolution returned no results, or all results were + /// airlock-rejected. + name-unresolvable, + /// Stream / socket handle has been dropped and is no longer + /// valid. + invalid-handle, + /// Tried to call a TCP-only option on a Unix-domain stream + /// (e.g. `set-nodelay`, `keepalive`, `set-hop-limit`). + not-tcp, + /// Server-side resource quota exhausted (open streams, open + /// UDP sockets). + quota, + /// Unspecific I/O error from the host. Detail is best-effort. + unknown(string), + } + + // ----------------------------------------------------------------- + // Value types + // ----------------------------------------------------------------- + + /// Status of a length-prefixed framed read. + variant net-read-status { + /// Data frame received. + data(list), + /// Stream closed by peer. + closed, + /// No data available (non-blocking). + pending, + } + + /// Direction argument for `tcp-stream.shutdown`. Mirrors + /// `wasi:sockets` `shutdown-type` (and `std::net::Shutdown`). + enum shutdown-how { + /// Half-close the read side. Subsequent reads return EOF. + receive, + /// Half-close the write side. Peer sees EOF on its read side. + send, + /// Close both directions. + both, + } + + /// Datagram returned by `udp-socket.recv-from`. + record udp-datagram { + /// Payload bytes received. + data: list, + /// Peer host (numeric IP, IPv4 or IPv6). + peer-host: string, + /// Peer UDP port. + peer-port: u16, + } + + // ----------------------------------------------------------------- + // Listeners (Unix-domain + TCP) + // ----------------------------------------------------------------- + + /// The capsule's pre-bound Unix domain listener. One per capsule; + /// the kernel binds it at load time and the capsule activates it + /// with `bind-unix`. + resource unix-listener { + /// Blocking accept. Performs peer credential verification + /// (UID match on Unix) and session token handshake. + accept: func() -> result; + + /// Polling accept with caller-controlled timeout. Returns + /// `none` if no connection arrived within `timeout-ms` + /// (capped at 60_000 by the host). + poll-accept: func(timeout-ms: u64) -> result, error-code>; + + /// Pollable that fires when a connection is ready to be + /// accepted. Compose with other pollables via + /// `astrid:io/poll.poll` for multiplexed I/O. + subscribe-readiness: func() -> pollable; + } + + /// A bound TCP listener accepting inbound connections from the + /// network. For self-hosted webhook receivers, gRPC endpoints, + /// Prometheus scrape ports, etc. Distinct from `unix-listener` + /// which serves only locally-authenticated clients on the + /// kernel's pre-bound Unix socket. + /// + /// Per-capsule cap: 4 TCP listeners. Drop closes the socket. + resource tcp-listener { + /// Blocking accept for an inbound TCP connection. + accept: func() -> result; + + /// Polling accept with caller-controlled timeout. + poll-accept: func(timeout-ms: u64) -> result, error-code>; + + /// Local bind address as `"ip:port"`. + local-addr: func() -> result; + + /// Pollable that fires when a connection is ready to accept. + subscribe-readiness: func() -> pollable; + } + + // ----------------------------------------------------------------- + // TCP stream — Unix-domain and outbound-TCP streams share this type + // ----------------------------------------------------------------- + + /// A bidirectional stream. Used for both accepted Unix-domain + /// connections (`unix-listener.accept`) and outbound TCP + /// (`connect-tcp`). TCP-only options return `not-tcp` on Unix + /// streams. + resource tcp-stream { + // ---- Length-prefixed framed I/O (uplink-proxy use case) ---- + + /// Read the next length-prefixed frame. Returns Data/Closed/ + /// Pending. Max frame size: 10 MB. + read: func() -> result; + + /// Write a length-prefixed frame. Non-fatal on failure (dead + /// stream cleaned up on next read). + write: func(data: list) -> result<_, error-code>; + + // ---- Byte-stream I/O (general protocols) ---- + + /// Read up to `max-bytes` from the stream without length- + /// prefix framing. Mirrors `std::net::TcpStream::read`. Empty + /// list = no data ready within the current read timeout. + read-bytes: func(max-bytes: u32) -> result, error-code>; + + /// Write bytes without length-prefix framing. Mirrors + /// `::write`. Returns bytes actually + /// written (may be < `data.len()` under buffer pressure). + write-bytes: func(data: list) -> result; + + /// Peek without consuming. Mirrors `std::net::TcpStream::peek`. + peek: func(max-bytes: u32) -> result, error-code>; + + // ---- Lifecycle ---- + + /// Shut down read / write / both. Mirrors + /// `std::net::TcpStream::shutdown`. After `shutdown(both)` + /// accessors (`peer-addr` etc.) still work until the stream + /// is dropped. + shutdown: func(how: shutdown-how) -> result<_, error-code>; + + // ---- Address accessors ---- + + /// Remote peer address as `"ip:port"`. Returns `not-tcp` for + /// Unix-domain streams. + peer-addr: func() -> result; + + /// Local socket address as `"ip:port"`. Returns `not-tcp` for + /// Unix-domain streams. + local-addr: func() -> result; + + // ---- TCP socket options (return `not-tcp` for Unix) ---- + + /// Enable / disable `TCP_NODELAY` (Nagle off when true). + set-nodelay: func(nodelay: bool) -> result<_, error-code>; + nodelay: func() -> result; + + /// Read timeout. `none` = no timeout. + set-read-timeout: func(timeout-ms: option) -> result<_, error-code>; + read-timeout: func() -> result, error-code>; + + /// Write timeout. `none` = no timeout. + set-write-timeout: func(timeout-ms: option) -> result<_, error-code>; + write-timeout: func() -> result, error-code>; + + /// IPv6 hop limit / IPv4 TTL (`IP_TTL`/`IPV6_UNICAST_HOPS`). + /// Mirrors WASI `set-hop-limit`. Returns `not-tcp` for Unix + /// streams. + set-hop-limit: func(hops: u32) -> result<_, error-code>; + hop-limit: func() -> result; + + /// TCP keepalive. `none` disables; `some(secs)` enables with + /// the given probe interval. Required for long-lived + /// connections to detect silently-dead peers. + set-keepalive: func(keepalive-secs: option) -> result<_, error-code>; + keepalive: func() -> result, error-code>; + + /// `SO_LINGER`. `none` = default graceful close; `some(0)` = + /// immediate RST drop unsent; `some(t)` = drain up to t ms. + set-linger: func(linger-ms: option) -> result<_, error-code>; + linger: func() -> result, error-code>; + + /// `SO_REUSEADDR`. Useful for accepted connections from a + /// `tcp-listener` so the listener can re-bind to the same + /// port immediately after a restart without waiting for + /// TIME_WAIT to expire. Has no effect on outbound streams. + /// Returns `not-tcp` for Unix-domain streams. + set-reuseaddr: func(reuse: bool) -> result<_, error-code>; + reuseaddr: func() -> result; + + // ---- Readiness ---- + + /// Pollable that fires when the stream is ready to read. + /// Compose with other pollables for multiplexed I/O. + subscribe-readable: func() -> pollable; + + // ---- Stream halves (high-throughput byte movement) ---- + + /// The read half as an `input-stream`. Use the standard stream + /// methods (`read` / `blocking-read` / `skip` / `subscribe`) + /// for low-level byte access, or pass the stream into + /// `output-stream.splice` to move bytes from this TCP + /// connection into another stream without crossing the WASM + /// boundary per byte — the throughput primitive for proxy / + /// forwarder capsules (e.g. capsule-hosted TCP servers). + /// + /// `read-stream` and `write-stream` share the underlying + /// socket with the per-frame `read` / `write` and per-byte + /// `read-bytes` / `write-bytes` methods above; the kernel + /// serializes access. Pick one access pattern per use case to + /// avoid interleaving surprises. + read-stream: func() -> input-stream; + + /// The write half as an `output-stream`. Use `write` / + /// `blocking-write-and-flush` for byte writes, or + /// `output-stream.splice(input-stream, len)` to forward bytes + /// from another stream into this connection. Pollable + /// composability via `subscribe` enables back-pressure-aware + /// pipelines. + write-stream: func() -> output-stream; + } + + // ----------------------------------------------------------------- + // UDP socket resource + // ----------------------------------------------------------------- + + /// A UDP datagram socket. Two modes: + /// + /// 1. **Unconnected** (default after `udp-bind`) — use `send-to` / + /// `recv-from` with per-call peer addressing. SSRF airlock + /// applies on every send. + /// 2. **Connected** (after `connect`) — use `send` / `recv` + /// without per-call peer. SSRF airlock applies once at + /// connect time; the kernel filters out datagrams from peers + /// other than the connected one. Faster syscall path for + /// chatty protocols (QUIC, DNS-over-UDP, syslog-over-UDP). + resource udp-socket { + // ---- Unconnected-mode I/O ---- + + /// Send to a peer. Returns bytes sent. + send-to: func(data: list, peer-host: string, peer-port: u16) -> result; + + /// Receive up to `max-bytes`. `none` if no datagram arrived + /// within the read timeout. + recv-from: func(max-bytes: u32) -> result, error-code>; + + // ---- Connected-mode I/O ---- + + /// Lock this socket to a single peer. SSRF airlock runs on + /// the peer address once. After connect, `send` / `recv` + /// work without per-call peer arguments, and the kernel + /// filters out datagrams from any peer other than this one. + /// Calling `connect` again rebinds to the new peer. + connect: func(peer-host: string, peer-port: u16) -> result<_, error-code>; + + /// Unbind from the connected peer; reverts to unconnected + /// mode (send-to / recv-from with per-call peer). + disconnect: func() -> result<_, error-code>; + + /// Send to the connected peer. Returns bytes sent. Errors + /// with `not-tcp`-equivalent (`unknown` arm — TODO future + /// `not-connected` variant) if the socket is unconnected. + send: func(data: list) -> result; + + /// Receive from the connected peer. `none` if no datagram + /// arrived within the read timeout. + recv: func(max-bytes: u32) -> result>, error-code>; + + /// Currently connected peer as `"ip:port"`. `none` if the + /// socket is unconnected. + peer-addr: func() -> result, error-code>; + + // ---- Common ---- + + /// Set the read timeout for `recv-from` / `recv`. + set-read-timeout: func(timeout-ms: option) -> result<_, error-code>; + + /// Local bound address as `"ip:port"`. + local-addr: func() -> result; + + /// Pollable that fires when a datagram is ready to receive. + subscribe-readable: func() -> pollable; + } + + // ----------------------------------------------------------------- + // Factory functions at interface scope + // ----------------------------------------------------------------- + + /// Bind and activate the pre-provisioned Unix listener. The kernel + /// binds it at load time per capsule. + bind-unix: func() -> result; + + /// Bind a TCP listener for inbound connections. + /// + /// `"0.0.0.0"` / `"::"` exposes the listener to every network + /// interface (server posture) — restrict in `Capsule.toml + /// [capabilities] net_tcp_bind` to loopback-only patterns unless + /// the capsule genuinely needs to serve. Port `0` selects an + /// ephemeral port. Gated by a `net_tcp_bind` capability allowlist + /// distinct from `net_connect`. + bind-tcp: func(host: string, port: u16) -> result; + + /// Open an outbound TCP connection to `host:port`. Goes through + /// the SSRF airlock (resolves DNS, rejects private/loopback/etc.). + /// Requires `net_connect` allowlist match. + connect-tcp: func(host: string, port: u16) -> result; + + /// Bind a UDP socket. + /// + /// Bind addresses other than `127.0.0.1` / `::1` make the capsule + /// reachable from outside the host — `"0.0.0.0"` / `"::"` exposes + /// the socket to every network interface, which is a server + /// posture, not a client posture. Restrict in `Capsule.toml + /// [capabilities] net_udp` to loopback-only patterns + /// (`"bind:127.0.0.1:*"` / `"bind:[::1]:*"`) unless the capsule + /// genuinely needs to serve. + /// + /// Port `0` selects an ephemeral port. + udp-bind: func(host: string, port: u16) -> result; + + /// Resolve a hostname to a list of `"ip:port"` (or `"ip"` if no + /// port in input) strings. SSRF airlock applies — private, + /// loopback, link-local, multicast, unspecified ranges are + /// stripped silently. Empty list = all results filtered. + /// Requires `net_connect` or `net_udp` capability matching the + /// hostname. + lookup-host: func(host: string) -> result, error-code>; +} 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 new file mode 100644 index 0000000..a819553 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit @@ -0,0 +1,211 @@ +/// Host-side process spawning with OS-level sandboxing. +/// +/// 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. +/// +/// 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. +/// +/// 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. + +package astrid:process@1.0.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-capsule background-process cap exhausted (max 8). + 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, + /// 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, + } + + /// Environment variable to pass to a spawned process. + record env-var { + key: string, + value: 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, + } + + /// 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, + } + + /// 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. + 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. + write-stdin: func(data: list) -> result; + + /// Close the stdin pipe (child observes EOF on read). + 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. + 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." + subscribe-exit: func() -> pollable; + + /// Pollable that fires when stdout / stderr has buffered + /// data ready to be drained via `read-logs`. + 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; +} 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 new file mode 100644 index 0000000..af39e06 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-sys/sys@1.0.0.wit @@ -0,0 +1,142 @@ +/// System-level runtime functions: logging, config, time, caller context, +/// entropy, sleep, capability introspection. +/// +/// Astrid does not expose any `wasi:*` interfaces to capsules. The host +/// ABI is fully Astrid-owned: every call is gated, principal-scoped, +/// audited, and dispatched through the kernel's capability layer. +/// Readiness multiplexing — the one place capsules historically reached +/// for `wasi:io/poll` — is provided by Astrid's own `astrid:io/poll@1.0.0` +/// interface. Primitives capsules need (random bytes, monotonic clock, +/// sleep) live in this `sys` package for the same reason: a single +/// audit/principal layer covering every host call. +/// +/// This namespace ownership matters for the unikernel target as well — +/// when Astrid ships on hermit-rs, the WIT contract is unchanged and the +/// kernel-side impls dispatch to unikernel syscalls instead of +/// wasmtime-wasi-backed futures. +/// +/// 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. + +package astrid:sys@1.0.0; + +interface host { + /// Typed error returned from every fallible sys operation. + variant error-code { + /// Capsule lacks the required capability. + capability-denied, + /// Config key is reserved and cannot be read (e.g. internal + /// kernel-only entries). + config-key-reserved, + /// Random-bytes / sleep length exceeded the server-side cap. + too-large, + /// Capsule registry is temporarily unavailable for capability + /// checks (fail-closed: returns this rather than allowed:false). + registry-unavailable, + /// Sleep was interrupted because the capsule is unloading. + cancelled, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Log severity level for structured capsule logging. + enum log-level { + trace, + debug, + info, + warn, + error, + } + + /// Caller context returned by `get-caller`. + record caller-context { + /// The acting principal for this invocation. + principal: option, + /// UUID of the capsule that originated the IPC message. + source-id: string, + /// ISO 8601 timestamp of the originating message. + timestamp: string, + } + + /// Request to check a capsule's capability. + record capability-check-request { + /// UUID of the capsule to check. + source-uuid: string, + /// Capability name to check. + capability: string, + } + + /// Response from a capability check. + record capability-check-response { + /// Whether the capability is allowed. + allowed: bool, + } + + /// Read a configuration value from the capsule's manifest `[config]`. + /// + /// Returns the raw config value, or `none` if the key is not set in + /// the manifest. String values are returned without JSON-encoding + /// (no extra quotes); the empty string is a valid value distinct + /// from `none`. Audit: not recorded (read-only manifest access). + get-config: func(key: string) -> result, error-code>; + + /// Get the caller context for the current invocation. + /// + /// Returns the acting principal, originating capsule UUID, and message + /// timestamp. Returns an empty context if no caller is available. + /// Audit: not recorded. + get-caller: func() -> result; + + /// Emit a structured log message attributed to the calling capsule. + /// + /// Logs are routed to the current principal's log directory with + /// daily rotation. Cross-principal invocations write to the target + /// principal's log directory. + /// Audit: every log call recorded as a structured audit entry. + log: func(level: log-level, message: string); + + /// Signal that the capsule's run loop is ready. + /// + /// Called by the WASM guest after setting up IPC subscriptions. + /// Notifies the kernel to proceed with loading dependent capsules. + /// No-op if no readiness channel is configured. + signal-ready: func(); + + /// Get the current wall-clock time as milliseconds since UNIX epoch. + /// Infallible (matches `Instant::now` style; pre-1970 clocks are a + /// host misconfiguration, not a capsule concern). + clock-ms: func() -> u64; + + /// Get the current monotonic clock reading in nanoseconds. + /// + /// Suitable for measuring elapsed time within a process. Does not + /// jump with NTP adjustments. The absolute value is meaningless + /// across processes or capsule reloads — only differences are. + /// Infallible. + clock-monotonic-ns: func() -> u64; + + /// Block the calling guest task for the given duration in nanoseconds. + /// + /// Capped server-side at 60 seconds per call (callers needing longer + /// waits loop on shorter sleeps and check for cancellation between + /// iterations). Returns when the duration has elapsed; returns + /// `cancelled` if the capsule is unloading mid-sleep. + sleep-ns: func(duration-ns: u64) -> result<_, error-code>; + + /// Fill the caller's requested length with cryptographically secure + /// random bytes from the host's OS-level CSPRNG. Length matches + /// WASI random-bytes (`u64`). + /// + /// `length` is capped at 4096 bytes per call (cryptographic use + /// cases fit comfortably; bulk entropy callers loop). Larger + /// requests return `too-large`. + /// 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. + /// + /// Fail-closed: returns `allowed: false` for unknown UUIDs and + /// unknown capabilities. Returns `registry-unavailable` when the + /// registry itself can't be consulted. + check-capsule-capability: func(request: capability-check-request) -> result; +} diff --git a/astrid-sys/wit-staging/deps/astrid-uplink/uplink@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-uplink/uplink@1.0.0.wit new file mode 100644 index 0000000..1e03c83 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-uplink/uplink@1.0.0.wit @@ -0,0 +1,59 @@ +/// Uplink communications — inbound message ingestion from external platforms. +/// +/// Capsules register uplinks (named endpoints) for platforms they bridge +/// (e.g. Discord, Slack) and then forward inbound user messages to the +/// kernel's processing pipeline. +/// +/// 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. + +package astrid:uplink@1.0.0; + +interface host { + /// Typed error returned from uplink operations. + variant error-code { + /// Capsule lacks the `uplink` capability. + capability-denied, + /// Name / platform / profile failed validation (NUL bytes, + /// control chars, max length). + invalid-input, + /// Profile not in the recognized set (`chat`, `interactive`, + /// `notify`, `bridge`). + invalid-profile, + /// Uplink ID not registered or already unregistered. + unknown-uplink, + /// No active session for the target principal — message was + /// intentionally dropped. + no-session, + /// Server-side resource quota exhausted. + quota, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Profile for a registered uplink. Determines how the kernel + /// routes inbound messages. + enum uplink-profile { + /// Conversational chat (Telegram, Discord DM, Slack DM). + chat, + /// Long-lived interactive session (CLI TTY). + interactive, + /// One-way notification sink. + notify, + /// Bidirectional bridge to another runtime. + bridge, + } + + /// Register an uplink endpoint. + /// + /// Returns the assigned uplink UUID. + /// Audit: recorded. + uplink-register: func(name: string, platform: string, profile: uplink-profile) -> result; + + /// Send an inbound message through a registered uplink. + /// + /// Returns `true` if sent, `false` if intentionally dropped + /// (no `no-session` error — the drop is normal flow). + /// Audit: recorded. + uplink-send: func(uplink-id: string, platform-user-id: string, content: string) -> result; +} diff --git a/astrid-sys/wit-staging/root.wit b/astrid-sys/wit-staging/root.wit new file mode 100644 index 0000000..f44a09f --- /dev/null +++ b/astrid-sys/wit-staging/root.wit @@ -0,0 +1 @@ +package astrid-root:placeholder; From 5045e53bad9f48fe8f93b51641dcfdeed0509eb1 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 26 May 2026 02:36:23 +0400 Subject: [PATCH 2/2] fix(build): register rerun-if-changed watches in early-return path Gemini #49 finding. The published-crate / uninitialised-submodule early-return registered `cargo:rerun-if-changed=wit-staging` but not the submodule path or `.gitmodules`. After a developer runs `git submodule update --init` on a fresh clone, Cargo wouldn't detect the newly-present WIT files and wouldn't rerun build.rs; the committed wit-staging would stay stale relative to the now- checked-out submodule. Now: register the same surface in both code paths (wit-staging, host_src, build.rs, .gitmodules) so a freshly-initialised submodule triggers a rebuild. --- astrid-sys/build.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/astrid-sys/build.rs b/astrid-sys/build.rs index 7475d55..c8f71d7 100644 --- a/astrid-sys/build.rs +++ b/astrid-sys/build.rs @@ -65,7 +65,18 @@ fn main() { }) .unwrap_or(false); if !has_wit_files { + // Watch the same surface we'd watch in the staging path. Without + // these, Cargo won't rerun build.rs after a developer runs + // `git submodule update --init` against a fresh clone, so the + // committed wit-staging would stay stale relative to the now- + // checked-out submodule. println!("cargo:rerun-if-changed=wit-staging"); + println!("cargo:rerun-if-changed={}", host_src.display()); + println!("cargo:rerun-if-changed=build.rs"); + println!( + "cargo:rerun-if-changed={}", + crate_root.parent().unwrap().join(".gitmodules").display() + ); return; }