diff --git a/CHANGELOG.md b/CHANGELOG.md index cab10b4..c33939c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Added +- **`runtime::capsule_set_epoch()` — loaded-capsule-set fingerprint.** The `sys` module moves to the + additive `astrid:sys@1.1.0` superset (every `@1.0.0` call preserved; the host backs both). New call + returns a `CapsuleSetEpoch` (serde-transparent newtype over `u64`) that moves whenever a capsule is + installed, removed, or upgraded, is load-order independent, and is restart-stable for an unchanged + set. Lets a capsule that caches set-derived data (e.g. a describe-fan-out tool list) store the epoch + beside the cache and re-derive only when it moves — picking up a runtime install without waiting on a + per-principal invalidation broadcast a backend-mediated principal may never receive. Infallible (`0` + when the registry is momentarily unavailable). (`unicity-astrid/astrid#982`) + - **`astrid:http@1.1.0` per-request controls.** The `http` module moves to the additive `@1.1.0` host interface; an unset option reproduces `@1.0.0` behaviour exactly. New `Request` builders: `.timeout(Duration)` (total), `.connect_timeout`, `.first_byte_timeout`, `.read_timeout` diff --git a/astrid-sdk/src/lib.rs b/astrid-sdk/src/lib.rs index 98daecb..1b9f21e 100644 --- a/astrid-sdk/src/lib.rs +++ b/astrid-sdk/src/lib.rs @@ -57,7 +57,11 @@ use astrid_sys::astrid::ipc::host as wit_ipc; use astrid_sys::astrid::kv::host as wit_kv; use astrid_sys::astrid::net::host as wit_net; use astrid_sys::astrid::process::host as wit_process; -use astrid_sys::astrid::sys::host as wit_sys; +// `sys` moved to the @1.1.0 superset (adds `capsule-set-epoch`, every @1.0.0 +// call preserved). With `sys@1.0.0` and `sys@1.1.0` staged side by side, +// wit-bindgen disambiguates the generated module by version (`sys1_1_0`); the +// alias absorbs that so every sys wrapper keeps referring to `wit_sys`. +use astrid_sys::astrid::sys1_1_0::host as wit_sys; use astrid_sys::astrid::uplink::host as wit_uplink; use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -469,6 +473,35 @@ pub mod runtime { let len = u64::try_from(length).unwrap_or(u64::MAX); wit_sys::random_bytes(len).map_err(host_err) } + + /// A stable fingerprint of the currently-loaded capsule set — the value + /// returned by [`capsule_set_epoch`]. + /// + /// Opaque: its only meaning is equality. Two reads returning the same epoch + /// mean the loaded set is unchanged; a different epoch means a capsule was + /// installed, removed, or upgraded in between. Serde-transparent so it can + /// be persisted beside set-derived cached data. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + pub struct CapsuleSetEpoch(pub u64); + + /// Return a stable fingerprint of the currently-loaded capsule set. + /// + /// The epoch moves whenever a capsule is installed, removed, or upgraded; it + /// is independent of load order and stable across daemon restarts for an + /// unchanged set. A capsule that caches data derived from the loaded set — + /// e.g. a tool-schema list collected by describe fan-out — can store the + /// epoch beside the cache and re-validate cheaply on its next turn, + /// re-deriving only when the live epoch differs from the stored one. That + /// picks up a runtime install without waiting on a per-principal + /// invalidation broadcast (which a backend-mediated principal may never + /// receive). Infallible: `0` if the registry is momentarily unavailable, a + /// value that won't match a populated cache's stored epoch and so biases + /// toward a refresh rather than stale reuse. + #[must_use] + pub fn capsule_set_epoch() -> CapsuleSetEpoch { + CapsuleSetEpoch(wit_sys::capsule_set_epoch()) + } } /// Capability introspection. diff --git a/astrid-sys/src/lib.rs b/astrid-sys/src/lib.rs index 42048e3..5a636c3 100644 --- a/astrid-sys/src/lib.rs +++ b/astrid-sys/src/lib.rs @@ -76,7 +76,7 @@ mod generated { import astrid:kv/host@1.0.0; import astrid:net/host@1.0.0; import astrid:http/host@1.1.0; - import astrid:sys/host@1.0.0; + import astrid:sys/host@1.1.0; import astrid:process/host@1.0.0; import astrid:uplink/host@1.0.0; import astrid:elicit/host@1.0.0; @@ -148,7 +148,7 @@ unsafe extern "Rust" fn __getrandom_v03_custom( let mut written = 0usize; while written < len { let want = core::cmp::min(CHUNK, len - written); - let chunk = generated::astrid::sys::host::random_bytes(want as u64) + let chunk = generated::astrid::sys1_1_0::host::random_bytes(want as u64) .map_err(|_| getrandom::Error::new_custom(1))?; if chunk.is_empty() { return Err(getrandom::Error::new_custom(2)); diff --git a/astrid-sys/wit-staging/deps/astrid-sys@1.1.0/sys@1.1.0.wit b/astrid-sys/wit-staging/deps/astrid-sys@1.1.0/sys@1.1.0.wit new file mode 100644 index 0000000..23223d2 --- /dev/null +++ b/astrid-sys/wit-staging/deps/astrid-sys@1.1.0/sys@1.1.0.wit @@ -0,0 +1,192 @@ +/// System-level runtime functions: logging, config, time, caller context, +/// entropy, sleep, capability introspection — version 1.1.0. +/// +/// Additive superset successor to `astrid:sys@1.0.0`. Every @1.0.0 type +/// (`error-code`, `log-level`, `caller-context`, `capability-check-request`, +/// `capability-check-response`) and every @1.0.0 function (`get-config`, +/// `get-caller`, `log`, `signal-ready`, `clock-ms`, `clock-monotonic-ns`, +/// `sleep-ns`, `random-bytes`, `check-capsule-capability`, +/// `enumerate-capabilities`) is preserved verbatim, so a guest can bind only +/// this version and still reach the whole system surface; @1.1.0 then adds one +/// orthogonal introspection function — `capsule-set-epoch` — at the end. The +/// host backs both versions from one implementation: the @1.1.0 carry-over +/// functions delegate to the @1.0.0 path, identical in behaviour. +/// +/// 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 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.1.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 — self or + /// any other capsule (by UUID). The per-name dual of + /// `enumerate-capabilities`. + /// + /// Fail-closed: returns `allowed: false` for unknown UUIDs and + /// unknown capabilities. Returns `registry-unavailable` when the + /// registry itself can't be consulted. Ungated: capability posture is + /// structural metadata, not a secret (enforce-don't-conceal) — knowing a + /// capability conveys no ability to use it. Audit: not recorded. + check-capsule-capability: func(request: capability-check-request) -> result; + + /// Enumerate the CALLING capsule's OWN held capability NAMES — the + /// categories the kernel enforces at host-call time (e.g. `host_process`, + /// `net_connect`, `fs_read`), NOT the scoped arguments within them (a + /// `host_process` allowlist, `host:port`, paths). It is the list dual of + /// `check-capsule-capability`: the set of names for which a self-`check` + /// returns true. + /// + /// Argument-free — the kernel already knows the caller. The set is the + /// capsule's manifest-declared capabilities as the kernel registered them; + /// capsule capabilities are not revoked at runtime (the grant/revoke model + /// is principal-scoped, a separate axis), so "what I declared" and "what I + /// can do" coincide at the capsule level. + /// + /// Ungated, like `check-capsule-capability`: a capsule grounds its + /// behaviour in what it can actually do — a reusable supervisor binary + /// deployed under different manifests reads its real set instead of + /// hard-coding it; any capsule avoids code-vs-manifest drift by grounding + /// on the registered set. Audit: not recorded (read-only self- + /// introspection). See RFC: host_abi ("Capability introspection"). + /// + /// Infallible: the kernel always knows the caller's own registered set, so + /// there is no failure mode (an empty list is the valid "no capabilities" + /// answer) — like `clock-ms` / `clock-monotonic-ns`, no `result` wrapper. + enumerate-capabilities: func() -> list; + + /// Return a stable hash of the currently-loaded capsule set — the sorted + /// (capsule-id, package-version) pairs across every registered capsule. The + /// value MOVES on install / removal / version-bump, is load-order + /// independent, and is restart-stable (no process state), so a consumer + /// keyed on the loaded set (e.g. a per-principal tool-schema cache) can + /// re-validate cheaply on its own next turn. Ungated, read-only + /// introspection like enumerate-capabilities. Infallible — returns 0 if the + /// registry is momentarily unavailable (a value that won't match a + /// populated cache's stored epoch, biasing toward a safe refresh). + capsule-set-epoch: func() -> u64; +} diff --git a/contracts b/contracts index 812c833..ec21510 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 812c833892060517f9fc23551867f1971bef5506 +Subproject commit ec21510c1b0a883da95cbc70eae81e552a7b75a5