Skip to content

Releases: astrid-runtime/astrid

v0.9.4

Choose a tag to compare

@github-actions github-actions released this 09 Jul 20:24
v0.9.4
dcde6f6

Added

  • WIT pin retention and contracts-skew visibility. Capsules record BLAKE3 pins of their vendored WIT files in meta.json (wit_files), but the bytes behind an astrid-contracts.wit pin were only incidentally retained and no astrid capsule surface reported when a capsule's contracts snapshot drifted from the daemon's — a real gap found on a live deployment where one side-loaded dev capsule pinned newer contracts than 13 registry capsules, invisible until someone b3sum-ed files by hand. Because the contracts are pure data-shape records over the bus (zero WIT funcs), such skew never fails at link time; it fails silently at runtime when a record shape moves. Install now content-addresses every vendored WIT file into a dedicated blob store at ~/.astrid/wit/store/<hash>.wit (moved out of the top of wit/, which now holds only the daemon's canonical named copies, so astrid capsule wit gc sweeps the store without endangering them), and seeds the daemon canonical astrid-contracts.wit first-writer-wins on the first install that vendors it. Three read paths surface skew, warn-only: astrid capsule show <name> prints the capsule's contracts pin with a match / MISMATCH marker; astrid capsule list prints one summary line naming any mismatching capsule(s) (per-capsule pins in --verbose); and a successful install whose contracts pin differs from the canonical prints a notice. Side-loading an ahead-of-daemon dev build is legitimate, so skew is never a failure — install and every read path succeed regardless, and an absent canonical (fresh home, daemon never booted) degrades silently. Closes #1163.

Fixed

  • astrid mcp serve now keeps an ephemeral daemon alive for the MCP session lifetime. The MCP stdio shim could be alive while the daemon still aged out underneath it, because the daemon's idle monitor only counts authenticated uplink connections and mcp serve had no dedicated session-lifetime guard. The shim now quietly ensures the daemon before starting, then opens a separate authenticated guard uplink for the same principal and holds it until the MCP process exits; if the daemon drops the connection, the guard reconnects. This makes Codex/Claude plugin sessions behave as real daemon clients without writing anything to the MCP stdout transport.

  • The daemon is now always killable by SIGTERM, even when every worker is pinned by guest compute. A live daemon wedged: alive but ignoring SIGTERM (only SIGKILL freed it), holding the audit-DB LOCK, with astrid status failing. Root cause: signals were handled by a tokio::select!, which can only fire when a tokio worker is free to poll the reactor — and enough concurrent exempt guest compute (an uplink broker's run-loop, pinned at epoch_deadline = u64::MAX with no yield callback) pinned every worker, so the SIGTERM arm never ran. SIGTERM/SIGINT/SIGHUP are now owned by a dedicated OS-thread watchdog (astrid-daemon/src/signal.rs) scheduled by the OS, independent of the tokio runtime: the first signal requests a graceful shutdown through the kernel's shutdown_tx watch channel (so the normal kernel.shutdown().await still runs when the reactor is healthy) and arms an 8s hard deadline on a separate thread that force-exits if the graceful path itself wedges; a second signal before the deadline force-exits immediately. The tokio signal arms were removed so the watchdog is the single owner (no double-handling). Killable-but-abrupt beats wedged, and the OS releases every lock on the forced exit.

  • Exempt capsules can no longer starve the daemon. An exempt capsule (owner holds CAP_RESOURCES_UNBOUNDED / CAP_NET_BIND / CAP_UPLINK — e.g. an sage-mcp/sibyl-mcp uplink broker) ran its run-loop guest with the wasmtime epoch deadline pinned at u64::MAX and no callback, so the guest fiber never reached a cooperative yield point; enough concurrent exempt compute pinned every tokio worker and starved the reactor (the direct cause of the SIGTERM-deafness above). Exempt run-loops now use the same finite epoch window as bound run-loops but with a yield-always policy (exempt_epoch_action): they remain unbounded CPU — never Interrupt-trapped, never fuel-out — yet cooperatively Yield the tokio worker every window and re-arm, so they can no longer starve the daemon. An OS cgroup remains the backstop for raw CPU burn. (The exempt interceptor-pool epoch path is deliberately unchanged this round — see the STOP-AND-REPORT note in review.)

  • The daemon acquires its singleton lock before opening any shared store, so a boot-race loser reports "already running" instead of a raw store-lock error. Kernel::new opened the KV and audit stores before acquiring the singleton advisory flock, so a second daemon losing the boot race died on a raw surrealkv Database ... LOCK is already locked after having already opened (and touched) the shared stores it should never open, instead of the actionable "already running (singleton lock held)" message. The flock is now the FIRST fallible boot step (socket::acquire_boot_singleton_lock), before the KV/audit opens; the listener bind (socket::bind_listener) runs later and no longer re-acquires it. The loser now fails at the flock and never opens the shared stores.

  • The audit log is now closed on graceful shutdown, releasing its LOCK on exit rather than only on process death — and the store locks are released before the capsule drain. Kernel::shutdown closed the KV store but never closed the audit log, so its persistent surrealkv LOCK was held until the process died — which is why a wedged daemon needed SIGKILL to free the audit lock, and why the next boot could then race the still-held lock. AuditLog::close (which flushes and closes through the backend's shared Arc<dyn KvStore>, no exclusive ownership required) is now called during shutdown. Shutdown also reorders so the KV and audit stores close first, before the best-effort capsule drain: the drain is bounded per capsule but a large fleet could otherwise exceed the watchdog's force-exit grace and be killed with the lock still held. The drain itself is now concurrent (drain() yields one Arc per distinct runtime, so unload tasks never contend on the same Arc::get_mut), keeping the whole graceful path well under the grace so MCP subprocesses are actually disconnected rather than force-exited mid-drain.

  • astrid mcp serve no longer orphans forever when its launching session dies without closing stdin. The MCP stdio shim blocked on stdin EOF with no parent-death handling, so a client (Claude Desktop, an IDE, another agent runtime) that died without closing stdin left the shim alive indefinitely — an orphan pinning its daemon uplinks open (observed in the field: a 4-day orphan, each pinning ≥2 uplinks). serve() now races the stdin-EOF quit against a portable parent-death future (mcp/parent_death.rs, a low-frequency getppid()-change poll that works on macOS and Linux without platform syscalls); when the launching session dies the shim drops its transport and exits, freeing the uplinks.

  • A crash-looping capsule no longer thrashes-and-leaks a fresh instance every ~10s, and a busy capsule that hiccups is no longer permanently disabled. restart_capsule tore the old instance down via Arc::get_mut + unload, which is skipped when a live dispatcher consumer still holds an Arc clone (strong_count > 1) — yet it returned Ok, so the health monitor cleared the capsule's RestartTracker, reset its attempt budget every tick, and reloaded the capsule forever, leaking the old instance's run-loop/epoch-thread/subprocess each round. The old instance is now cooperatively stopped without exclusive ownership: request_cancel cancels the instance token, and the run-loop task races that token against its guest call (select!), so even a compute-bound run-loop stops (it now reaches a yield point every epoch window). The retry cap is now driven by consecutive health failures, pruned the moment the capsule recovers — not by the restart-call outcome. A restart that leaves the old instance lingering behind a still-held Arc (normal for a busy capsule whose dispatcher consumer holds a clone for up to its idle grace) is not counted as a failure, so a transient hiccup on a loaded capsule can no longer exhaust the 5-attempt cap and permanently disable it; only a capsule that keeps failing health across ticks is capped.

  • Raised the per-principal routed sub-queue message-count cap from 256 to 16384 so a legitimate high-rate LLM token stream is no longer silently truncated or hung. The per-principal routed queue (PENDING_PER_PRINCIPAL_FALLBACK in astrid-events) drops the oldest events once a principal's bucket exceeds the cap — a count-based flood guard secondary to the primary 1 MiB byte budget. An LLM token stream publishes one small event per token, so a fast provider (~2000 tok/s) emits thousands of tiny messages in a 1-2 s burst — far past 256, while the byte budget sits at ~80 KB for a typical response. The queue then dropped oldest, which either corrupted the accumulated response (missing interior tokens) or, when the terminal Done was the dropped event, hung the consuming turn indefinitely; short responses stayed under the cap and always completed, which is why only long generations were affected. For real (non-empty) deltas the 1 MiB byte budget binds first — it trips long before a bucket reaches the count cap — so the raised cap only governs a flood of near-0-byte messages the byte budget cannot see. Each queued slot is an ~8-byte Arc handle, but the Arc retains the whole event allocation, so that pathological flood is bounded per bucket at roughly the cap × per-event struct overhead (a few MiB), not a few KiB; proper memory-based budgeting for it is tracked in #1159. This admits a legitimate high-rate stream without weakening the anti-flood memory guard. Closes #...

Read more

v0.9.3

Choose a tag to compare

@github-actions github-actions released this 06 Jul 04:46
v0.9.3
3c5b964

Added

  • astrid capsule check — a static, CI-friendly linter for a capsule's tool wiring. Several capsule-authoring mistakes fail silently at runtime: a #[astrid::tool] with no [subscribe] "tool.v1.execute.<name>" route advertises in tools/list but never executes (#1127); a tool capsule without the mandatory [publish] boilerplate can't return results or answer the describe fan-out; a [subscribe] handler that isn't tool_execute_<name> routes then gets denied. astrid capsule check [PATH] cross-checks the #[astrid::tool("…")] annotations in src/ against the Capsule.toml [subscribe]/[publish] tables and reports each problem with the exact line to add, exiting non-zero on any finding so it drops straight into a CI job or pre-commit hook. It is deliberately static — it derives the advertised-tool set from source, not by instantiating the WASM component — so it needs no build, no daemon, no WASM runtime, and no ASTRID_HOME, making it fast and deterministic (cargo check for capsules). Check #1 reuses the same tools_missing_execute_route predicate as the kernel's load-time warning, so the CI gate and the runtime can never disagree; a --deep mode (ephemeral-load + the real tool_describe) is a documented later addition. Closes #1129.

Changed

  • astrid-kernel now compiles for wasm32-unknown-unknown, completing the browser-host portability seam. The portable composition root (Kernel::with_resources) built cleanly, but the kernel crate still unconditionally compiled its native-only machinery: the Wasmtime capsule loader and MCP host client, the cap-std VFS and per-principal overlay registry, the disk-backed capsule install/discovery/reload/health paths, the Unix socket manager (UnixListener bind + singleton flock), the management-API router, and the idle-shutdown monitor (process::exit). Those are now target-gated to native (cfg(not(all(target_arch = "wasm32", target_os = "unknown"))), with the socket manager and native composition root Kernel::new under cfg(unix)), and their native-only dependencies (astrid-mcp, astrid-vfs, astrid-capsule-install, and tokio's full/signal features) move into a matching target-specific dependency table; the base tokio set is trimmed to the wasm-clean subset. The Kernel.kv and KernelResources.kv fields change from the concrete Arc<SurrealKvStore> to Arc<dyn KvStore> so a portable host can inject its own backend — a new default KvStore::close trait method (a no-op for backends with nothing to flush, delegating to the inherent flush on SurrealKvStore) carries the shutdown flush that previously required the concrete type, superseding the #1136 note. The pairing token/invite stores keep their types and validation but gate disk persistence to native (in-memory, no-op on the browser arm), and astrid-runtime gains a spawn_blocking facade (native tokio re-export; inline execution on the single-threaded wasm arm). astrid-kernel is added to the wasm32-unknown-unknown portability CI guard. This is a deliberate 0.x break: Kernel.kv/KernelResources.kv are now trait objects, and Kernel::new/the socket module now require cfg(unix). Native build/test/clippy/fmt are unaffected. Closes #1148.

  • astrid-capsule's engine-agnostic machinery now compiles for wasm32-unknown-unknown, groundwork for an alternate capsule host. The dispatcher, registry, access resolver, context, trait layer, and manifest analysis (readiness/topic/toposort) are pure logic, but the crate unconditionally compiled the Wasmtime engine and its native-only dependencies (wasmtime/wasmtime-wasi/wasmparser, reqwest, socket2, tokio-util, notify, nix, astrid-mcp, astrid-workspace, and tokio's net/rt-multi-thread features), so no alternate engine host could reuse the shared machinery. The Wasmtime engine, the process-backed MCP engine, manifest discovery, the file-watch hot-reload path, and the per-Store StoreMemoryMeter are now target-gated to native (cfg(not(all(target_arch = "wasm32", target_os = "unknown")))), and their native-only dependencies move into a matching target-specific dependency table; the portable modules route task spawning and timers through the astrid-runtime facade. The host audit-sink contract (HostAuditSink, HostAuditEvent, HostAuditOutcome) — pure Rust with no Wasmtime dependency — moves out of the engine subtree to astrid_capsule::audit_sink; the crate-root re-exports (astrid_capsule::{HostAuditSink, HostAuditEvent, HostAuditOutcome, CapsuleRuntimeLimits, HttpLimits}) stay reachable and unchanged. An alternate host can now supply its own ExecutionEngine behind the same dyn Capsule trait. astrid-capsule is added to the wasm32-unknown-unknown portability CI guard; native build/test/clippy/fmt are unaffected. Closes #1142.

  • The kernel's task-spawning and time surface is now selected per target through a new astrid-runtime facade crate, groundwork for alternate host profiles. The kernel spawned tasks and read the clock via tokio::spawn, tokio::time::{sleep, interval, timeout, Instant, MissedTickBehavior}, tokio::time::Instant/std::time::Instant, and std::time::SystemTime directly at ~50 call sites — none of which exist on wasm32-unknown-unknown (no tokio runtime, no OS threads, no std::time clock), so a portable host could not reuse the kernel's scheduling and timekeeping. Those calls now route through astrid-runtime, a single seam that selects the surface at compile time: the native arm (cfg(not(target_family = "wasm"))) is pure tokio/std re-exports — every item resolves to the exact same type it replaced, so it is zero-cost with no behaviour change on native — while the wasm arm (cfg(target_family = "wasm")) maps the task surface to the JS microtask queue (wasm-bindgen-futures), the timer surface to JS timers (wasmtimer), and the wall clock to the browser time bridge (web-time). The facade is deliberately a cfg-gated re-export rather than a trait: the implementation never varies within one platform build, so there is no runtime polymorphism to model. astrid-runtime is added to the wasm32-unknown-unknown portability CI guard; native build/test/clippy/fmt are unaffected. See #1140.

  • The kernel's pure-semantics crates now compile for wasm32-unknown-unknown, groundwork for a browser host profile. astrid-types, astrid-core, astrid-crypto, astrid-capabilities, astrid-audit, astrid-events, astrid-config, and astrid-approval build for the wasm target with no behaviour change on native. Two structural blockers were cleared. First, the workspace-level tokio dependency pinned an additive base feature set (net, signal, rt-multi-thread) onto every workspace = true consumer — including leaf crates that only asked for sync/time — dragging in mio (which compile_error!s on wasm) and the multi-threaded runtime (which needs OS threads). The base is trimmed to the portable subset (sync, macros, time, rt, io-util); the native-only features are now declared locally by the crates that actually use them (uplink/cli/kernel/gateway/capsule for net, daemon/kernel for signal, daemon/emit/cli/capsule-install/capsule/hooks/kernel/gateway for rt-multi-thread). Second, getrandom (reached via uuid/rand at major 0.4 and surrealkv at major 0.3) refuses to build for wasm without a backend selection: the wasm_js feature is now enabled through [target.'cfg(target_family = "wasm")'.dependencies] entries on the crates owning those edges, paired with the build-time --cfg getrandom_backend="wasm_js" that scripts/check-wasm-portability.sh (a new cargo check-only regression guard, wired into CI) sets. The audit store's block_in_place fast path (native-only) is cfg-gated to its scoped-thread fallback on wasm. No public API changes; native build/test/clippy/fmt are unaffected.

  • Engine-agnostic capsule types extracted into a new astrid-capsule-types crate. The pure data the kernel routes and gates on — the capsule manifest (CapsuleManifest and its validation/error types), the capsule id (CapsuleId), the per-principal fuel and memory peak ledgers (FuelLedger/FuelRateLimiter, MemoryLedger), and the resource/HTTP limit types (CapsuleRuntimeLimits, HttpLimits) — lived inside astrid-capsule, the crate that owns the Wasmtime execution engine. Because those types were trapped in the engine crate, every component that needs only the data — the kernel included — transitively depended on Wasmtime, and a second capsule engine profile could not share the vocabulary without dragging in the engine it replaces. They now live in a wasmtime-free, tokio-free astrid-capsule-types crate that every engine can depend on; astrid-capsule re-exports each moved item at its exact original path, so the kernel and all other consumers compile unchanged. Behaviour that genuinely touches the engine stays behind: the tokio-Semaphore constructors on CapsuleRuntimeLimits become the CapsuleRuntimeLimitsExt extension trait, and the per-Store StoreMemoryMeter (wasmtime::ResourceLimiter) keeps feeding the shared MemoryLedger. A pure move for every consumer that uses the re-exported paths — no behaviour changes, and the manifest's untrusted-input skip_deserializing protections and parser-isolation tests move intact. One API-shape caveat for external consumers of astrid-capsule: the semaphore constructors are now trait methods, so CapsuleRuntimeLimitsExt must be in scope to call blocking_semaphore()/io_semaphore(). Closes #1133.

  • The kernel's boot is now split into a portable composition root and a native one, groundwork for alternate host profiles. Kernel::new performed every platform-specific side-effect inline — resolving the Astrid home, opening the SurrealKV store and the audit log, loading the runtime signing ...

Read more

v0.9.2

Choose a tag to compare

@github-actions github-actions released this 03 Jul 04:22
v0.9.2
15d2d76

Fixed

  • First-run consent no longer storms a GrantRequired for every ungranted capsule in the caller's view. The dispatch access gate ran the grant-on-use check — and emitted a GrantRequired signal — for every ungranted capsule in the caller's view before checking whether the capsule's subscription matched the dispatched topic. A single tools/call on a fresh principal therefore fired a grant prompt for every ungranted view capsule, including all the capsules the call never touched, making first-run consent effectively unconvergeable. The gate now evaluates the cheap, manifest-local interceptor topic-match first (using the same crate::topic::topic_matches the delivery push uses) and engages the per-principal access gate only for a capsule that actually provides an interceptor for the requested topic; a non-matching capsule never reaches the gate. A matching ungranted capsule still fail-closed drops and signals exactly once, and behaviour for granted capsules, interceptor ordering, and non-tool topics is unchanged. Closes #1113.
  • The MCP shim now resolves every grant a single tools/call needs, instead of only the first. The shim resolved exactly one grant_required per call — it elicited consent, re-sent the call once, and passed the re-send's reply straight to the result reshaper — so when the re-sent call tripped the access gate on a different ungranted capsule, that second grant_required reply was returned to the client as the tool result (empty content, isError: false): a phantom empty success, while the broker kept a pending marker for a prompt that never surfaced. The shim now loops resolve→re-send→re-classify until the reply is grant-free or a bound (MAX_GRANT_RESOLUTIONS = 8, a distro's worth) trips; a present grant signal is never classified as terminal, so the only exits are a grant-free reply or an honest isError (malformed / denied / bound exceeded) — never a fabricated empty success. Ingress, approval-elicitation, grant dedup/marker, and fail-secure deny paths are preserved. Closes #1117.
  • astrid mcp serve now actually pushes notifications/tools/list_changed when a capsule is installed/loaded/unloaded during a live session, so newly hot-loaded tools light up without a session restart. The hot-reload watcher advertised tools.listChanged but never delivered a notification: (1) it seeded its baseline on a separate, short-lived uplink and then only ever read, so its own watch uplink was never bound to a principal — and the cli-proxy delivers a principal-stamped astrid.v1.capsules_loaded broadcast only to bound uplinks, so the watcher was silently starved of every reload signal; and (2) on a reload it re-enumerated the tool set over a fresh tools/list round trip that timed out precisely when a reload was in flight (the broker busy with its own describe fan-out), swallowing the push. The watcher now seeds its baseline on the watch uplink itself (that first request binds the connection) and reads the new tool surface directly from the capsules_loaded payload (capsules[].meta.tools[].name, principal-filtered) — the signal's documented intended contract — so the notification fires ~2 s after an install with no dependency on a follow-up request. Refs #1118.

Install

From source (requires Rust 1.95+):

cargo install astrid

Pre-built binaries:
Download the archive for your platform, extract, and add to PATH:

tar xzf astrid-*-$(uname -m)-*.tar.gz
sudo mv astrid-*/astrid astrid-*/astrid-daemon astrid-*/astrid-build astrid-*/astrid-emit /usr/local/bin/

Then run astrid init to set up capsules.


With many thanks from the following Astrinauts 🚀

  • Joshua J. Bouw

v0.9.1

Choose a tag to compare

@github-actions github-actions released this 02 Jul 20:37
v0.9.1
7fd2d25

Added

  • astrid:process@1.1.0 host interface. Additive successor to the frozen astrid:process@1.0.0, carrying the host-verified, read-only per-spawn file-injection surface (the file-injection record, the injection-placement variant, and the spawn-request.file-injections field). The kernel serves both versions off one implementation; capsules opt into injection by importing @1.1.0, while @1.0.0 behaves as spawn-with-no-injections. Refs #1107, wit#19, #881.

Fixed

  • Shipped capsules that import astrid:process@1.0.0 instantiate again — no rebuild required. The per-spawn file-injection extension originally landed in-place on the frozen astrid:process@1.0.0 WIT (wit#15), changing the shape of spawn-request. The component-model linker matches host imports structurally by package version, so every capsule compiled against the published pre-extension contract (all shipped capsules, built on astrid-sys 0.7.x) failed to instantiate on 0.9.0 with component imports instance astrid:process/host@1.0.0, but a matching implementation was not found in the linker — the sage supervisor could not astrid init and astrid-capsule-shell v0.2.0 failed to load. The kernel now restores @1.0.0 to its published shape and serves it alongside the additive @1.1.0 (dual-version host, mirroring the astrid:http@1.0.0/@1.1.0 precedent), so existing capsules load unchanged. Refs #1107, wit#19, #881, #890.

Install

From source (requires Rust 1.95+):

cargo install astrid

Pre-built binaries:
Download the archive for your platform, extract, and add to PATH:

tar xzf astrid-*-$(uname -m)-*.tar.gz
sudo mv astrid-*/astrid astrid-*/astrid-daemon astrid-*/astrid-build astrid-*/astrid-emit /usr/local/bin/

Then run astrid init to set up capsules.


With many thanks from the following Astrinauts 🚀

  • Joshua J. Bouw

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 02 Jul 18:16
v0.9.0
1f8133d

Breaking

  • astrid-uplink's kernel-request client API is now typed instead of anyhow. KernelClient::request and BusKernelClient::request return Result<KernelResponse, KernelClientError> instead of anyhow::Result; the public is_timeout() / RequestTimeout marker items are removed, and KernelClientError / TimeoutKind are added. Semver-major for this internal crate, but there are no external consumers and every in-workspace call site is updated (the gateway matches KernelClientError::Timeout directly for the new 504). This underpins the gateway↔kernel timeout change described under Changed (#1092).
  • Capsule loading is now principal-view aware while preserving content-addressed artifact reuse. The runtime registry keys loaded instances by (principal, wasm_hash) and records per-principal views as principal -> capsule id -> hash, so two principals that install the same capsule bytes reuse the same on-disk artifact while keeping separate in-memory runtime instances, capsule sets, and versions. Dispatcher selection now fail-closes user-facing execute/describe surfaces to the caller's view, grant-on-use only fires for capsules already in that view, and kernel install/reload/unload target the authenticated caller's view rather than mutating default. Per-invocation CPU/memory attribution remains keyed to the caller principal through the existing fuel/memory ledgers. Closes #1069.
  • The HTTP gateway's admin routes now enforce per-device capability scope (#999), closing a gap where a device-scoped bearer reached the kernel cap-gate as unattenuated full-principal authority. The KernelClient transport (astrid-uplink) behind GET /api/sys/status, the new GET /api/sys/readiness, POST /api/sys/capsules/reload, and the /api/capsules list/get/install routes stamped only the caller's principal on outbound requests, never the authenticating device's key_id — so the kernel's authorize_request saw device_key_id = None and skipped the DeviceScope attenuation floor. A use-only device whose scope denies capsule:list / capsule:reload / capsule:install but whose principal holds it could therefore drive those routes (e.g. read the full loaded-capsule inventory via /api/sys/readiness, or trigger a reload). KernelClient now carries an optional device_key_id (with_device_key_id) and stamps it on every request via a unit-tested message builder; all six gateway call sites thread CallerContext.device_key_id, mirroring the bus-direct BusAdminClient path that already did so. Full-authority bearers stamp None (unattenuated — single-tenant behaviour unchanged). No WIT / host-ABI change — uplink transport + gateway routes only. Found by the branch's own adversarial security review. Refs #999.
  • Capsule access is now per-principal, enforced kernel-side at dispatch — closing the gap where the capsule tool surface was global and any principal could invoke any installed capsule's tools. Previously find_matching_interceptors matched the entire global registry on topic alone, so a non-admin principal (a restricted/claude-group identity or a delegated sub-agent) could invoke security/identity-sensitive capsule tools (users.set_public_key, identity.save_identity, registry.set_active_model) that only the operator should reach — per-principal isolation existed only at the data/resource layer (home/KV/secrets/ledgers), never at the tool surface. Access is now a per-principal grant set: a new capsules: Vec<String> field on PrincipalProfile (#[serde(default)], empty by default, validated like a CapsuleId) names the capsule ids a principal may invoke, and the dispatcher filters topic-matched capsules to that set for the user-invocable surface onlytool.v1.execute.* and cli.v1.command.execute. Every other topic dispatches unchanged: the internal orchestration mesh (session.*, spark.*, registry.*, prompt_builder.*, context_engine.*, lifecycle/hooks, llm streams) is never gated, so the runtime does not wedge and a dual-role capsule (e.g. identity handling both tool.v1.execute.save_identity and spark.v1.request.build) has its tool gated while its orchestration role stays open — the gate is keyed on the topic, not the capsule. An admin holding * bypasses the filter entirely (resolved via the same CapabilityCheck machinery authorize_request uses), so single-tenant default and admins are unaffected with zero migration. The resolver reuses the kernel-owned, in-memory profile_cache (an RwLock-read on the hot path, no per-event write — the lock-free discipline the fuel/memory ledgers follow) and the live group config, cloned into the dispatcher exactly as the ledgers are. Fail-closed: an unknown / None / anonymous caller, or any profile-resolution error, yields an empty grant set → no tool capsules visible; new principals inherit no capsule access. The principal used for the check is the kernel-stamped value on the IPC message, never a caller-supplied claim, so it cannot be forged. Operators grant/revoke via astrid agent modify --add-capsule <id> / --remove-capsule <id> (and the HTTP management API's add_capsules/remove_capsules), mirroring the existing --add-group/--remove-group mechanism exactly (same agent:modify gating, same audit path, idempotent). No WIT / host-ABI change — kernel-internal dispatch + profile schema + admin contract only. Refs #992.
  • astrid-audit::AuditAction gains NetConnect / NetBind / ProcessSpawn variants and is now #[non_exhaustive], and the astrid-capsule context structs CapsuleContext, HostState, and LifecycleConfig gain a public audit_sink field. These land the per-action host-call audit coverage described under Security (#994). The changes are additive at the source level but semver-major for these internal crate APIs: downstream code matching AuditAction exhaustively must now carry a wildcard arm (the #[non_exhaustive] marker makes every future variant addition non-breaking), and any struct-literal construction of CapsuleContext / HostState / LifecycleConfig must include the new audit_sink: Option<Arc<dyn HostAuditSink>> field. There is no backwards-compatible form of these edits — adding a variant to an exhaustive enum and a field to an all-public struct are breaking by Rust's semver rules, and nothing is being superseded, so there is nothing to deprecate in their place. These are internal, unpublished workspace crates with no external consumers and every in-tree call site updated. Refs #994.

Fixed

  • astrid init now honours the global --principal flag, provisioning the named principal's home instead of always default. astrid init --principal <x> (or ASTRID_PRINCIPAL / the active-agent context) previously wrote the distro's per-capsule env files and the Distro.lock under default even though the capsule files themselves already landed under the resolved principal — so a scoped principal was left with an empty capsule view, an empty-hash lock, and a re-provision on every run (its lock lived under the wrong home and never satisfied the principal-aware auto-init freshness check). Init now threads the resolved principal — the same identity astrid capsule install uses — through the lock path, env files, per-provider onboarding, and every post-install read-back, both on the online path and the offline .shuttle path. Provisioning is also honest: a run where every capsule failed no longer prints "Installation complete" or writes a Distro.lock (a stale lock would wedge re-runs) and exits non-zero; a partial run writes NO lock and reports the true installed/total count so a re-run retries the missing capsules instead of short-circuiting at the version-only freshness gate; the shell-PATH edit is idempotent via a whole-path-component match (never a bare substring, so it neither appends a duplicate block nor silently skips setup when an unrelated …/bin_backup path is present); and GitHub release/source resolution now authenticates with GH_TOKEN / GITHUB_TOKEN when present — lifting the anonymous 60/hr API ceiling that a full distro can exhaust mid-provision — while degrading gracefully to anonymous when no token is set. No WIT / host-ABI change — CLI provisioning only. Closes #1095.
  • Restored shared-by-hash single-instance capsule loading, correcting the per-principal-instance regression that #1083 introduced against #1069's own spec. #1083 keyed loaded runtime instances by (principal, wasm_hash) and built a separate Arc<dyn Capsule> runtime (and WASM build) per principal, so a capsule referenced by N principals loaded N times — memory scaled principals × capsules. The registry now keys instances by content hash alone: a hash referenced by N principals loads exactly once (one runtime, one WASM build), with the per-principal name -> hash views layered on top. A per-hash refcount tracks how many principal views reference the shared runtime; it is cancelled/unloaded only when the last view releases it (unregister_for now returns whether the instance was torn down, so kernel unload/restart never cancels a runtime other principals still use). The kernel load path dedups by hash: before building, a loaded hash short-circuits to a view-add via register_existing/contains_hash (no second runtime). Kept from #1083: per-principal dispatch/visibility views (cloned_values_for scoping, admin bypass, fail-closed view-scoped describe fan-out), per-principal on-disk install sets, and per-invocation env/KV/secret/home overlays. Cancellation granularity now matches the shared-instance model too: each invocation waits on a per-principal child token of the instance token (installed with the other per-invocation overlays), and a non-last view release calls a new request_cancel_for(principal) that cancels exactly the departing principal's in-flight blocking host calls (approval/elicit...
Read more

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 11 Jun 14:44
0360425

A large release — roughly fifty PRs since v0.7.0 — landing three bodies of work plus a security-hardening pass:

  • HTTP admin gateway. The new astrid-gateway crate fronts the kernel admin/request IPC surfaces over HTTP: principals, caps, quotas, groups, invites, per-principal capsule env, audit SSE + historical audit queries, agent-prompt SSE, OpenAPI spec emission, native rustls TLS, CORS, Prometheus metrics — with a bus-direct admin path (285× admin throughput) and invite/keypair CLI verbs for operator parity.
  • Runtime concurrency overhaul. The orchestration concurrency cliff is closed end-to-end: per-(capsule, topic, principal) routed IPC with DRR fairness and byte budgets, async Wasmtime (guest calls no longer pin tokio workers), dynamic host-sized per-capsule instance pools, split blocking/async-I/O host-call semaphores, and per-principal CPU-fuel + peak-memory ledgers with rate enforcement and usage reporting.
  • Host process + introspection surface. The astrid:process@1.0.0 persistent-process tier is implemented (background children that outlive the pooled WASM instance, id-keyed reattach/stdin/log-cursor ops, operator allow_persistent opt-in), capability introspection lands (enumerate-capabilities + a completed check-capsule-capability), astrid mcp serve exposes capsule tools + capability consent to any MCP client, and astrid-emit gives agent hook processes an agent-agnostic stdio→bus pipe.
  • Security. The macOS 15+ native-subprocess sandbox is no longer silently disabled; capsule audit-feed subscriptions are principal-scoped by default; failed token redeems now audit as failures; self:agent:list no longer leaks the principal roster; both unauthenticated redeem routes share per-IP rate limiting; bearers gain revocation-on-principal-delete (wire format v2 — in-flight v0.7.0 bearers must re-redeem).

Breaking changes to note: Capsule.toml moves to [publish] / [subscribe] tables as the only IPC-intent surface ([[interceptor]], the ipc_publish / ipc_subscribe arrays, and [[topic]] are removed), the bearer wire format is v2, MSRV is 1.95, and the astrid-openclaw build path is removed.

Breaking

  • Capsule.toml: the [[interceptor]] block is removed — interceptor bindings are now [subscribe] entries with a handler and an optional priority. A capsule used to bind an interceptor either via a [[interceptor]] block (event + action + optional priority) or a [subscribe] entry with a handler — the two overlapped, and only the legacy block could set a priority (the [subscribe] form hardcoded the default 100). [subscribe] now carries an optional priority (lower fires first; default 100; a priority on a handler-less ACL-only entry is rejected at parse time), making it the single interceptor-binding form, and [[interceptor]] is gone. A manifest still declaring [[interceptor]] no longer binds those handlers — the block is ignored. Migrate each block to a [subscribe] entry: "<event>" = { wit = "<typed-payload>", handler = "<action>", priority = <n> }. Note [subscribe] requires a typed wit payload reference that [[interceptor]] did not, so each migrated binding declares its payload type (typed IPC everywhere). Closes #858.
  • Capsule.toml: the capabilities.ipc_publish / ipc_subscribe string arrays are removed — [publish] / [subscribe] tables are the only IPC ACL. The tables already superseded the arrays (their keys are the ACL when present); the arrays are now gone, so [publish] / [subscribe] is the single way to declare what a capsule may publish or subscribe to (an empty table = may not publish/subscribe, fail-closed). A capsule still declaring ipc_publish = [...] / ipc_subscribe = [...] under [capabilities] no longer grants any IPC ACL — the unknown keys are ignored. Migrate each pattern to a table entry: a publish pattern becomes [publish] "<topic>" = { wit = "<typed-payload>" }; an ACL-only subscribe pattern (no handler, e.g. an uplink proxy) becomes [subscribe] "<topic>" = { wit = "opaque" }. Closes #864.
  • Capsule.toml: the [[topic]] table is removed; topic schemas are sourced from the [publish] / [subscribe] wit refs. The [[topic]] block let a capsule self-describe a topic with an inline JSON Schema file, baked into meta.json at install. That self-description (flaky to keep in sync) is dropped in favour of the typed wit payload ref already on every [publish] / [subscribe] entry: the A2UI schema catalog (SchemaCatalog) now registers each topic from those tables and records its wit ref for the A2UI bridge to resolve to a schema + description via the WIT registry. Removed: the [[topic]] table (TopicDef / TopicDirection), the install-time bake_topics path, the meta.topics / BakedTopic install metadata, and the astrid capsule list baked-topic display. No capsule declared [[topic]], so there is nothing to migrate. Closes #865.
  • Bearer wire format bumped to v2 (4 segments, was 3). The token now carries an iat (issued-at epoch) claim alongside principal and exp. Required by the new revocation machinery: without iat the only revocation semantics available would be "blanket reject forever," which surprises an operator who later re-creates a principal with the same id. Dashboard sessions issued by the v0.7.0 gateway no longer verify after upgrade — clients must re-redeem. CLI astrid invite redeem, the existing pair-device flow, and every browser session that goes through /api/auth/redeem mint the new shape automatically; only pre-existing in-flight bearers are affected. Format spec: b64url(principal) "." b64url(iat) "." b64url(exp) "." hex(sig), with sig over principal:iat:exp. Closes #772.
  • MSRV bumped to 1.95.0. surrealdb 3.0.0-beta.3's kv-mem feature pulled in surrealmx v0.21.0ferntree v0.7.0, which uses std::hint::cold_path stabilised in Rust 1.95. Upstream declared no rust-version, so cargo's resolver silently picks 0.7 even though the workspace MSRV says 1.94. Bumping our MSRV is the smallest fix that keeps CI deterministic without committing Cargo.lock (which is intentionally gitignored). Affects cargo install astrid consumers — installers on 1.94 will see a clear "requires rustc 1.95" error rather than the cryptic cold_path failure.

Added

  • astrid mcp serve — an MCP server surface exposing Astrid capsule tools + capability consent to any MCP client. Astrid already had an MCP client (astrid-mcp manages external tool servers) and a tool broker (sage-mcp discovers capsule tools and shapes MCP descriptors), but no way for an MCP client — the managed claude -p, or Codex/Gemini/any client — to consume Astrid's capsule tools over the standard MCP wire protocol. astrid mcp serve (a subcommand of the astrid CLI, not a separate binary) is a thin rmcp stdio ServerHandler that delegates to the existing sage-mcp broker over the already-allowlisted astrid.v1.request.mcp.* / astrid.v1.response.<req_id> topics — the crypto/audit/enforcement stay in Astrid; the shim only terminates the MCP wire protocol. get_info advertises tools + tools.list_changed; list_tools / call_tool publish on an uplink and await the single-segment reply (one-response invariant, so the shim never hangs); the calling principal is stamped from --principal (default = active/default principal). Capability approvals are relayed into the client's own UI via MCP elicitation (ctx.peer.elicit::<ApprovalChoice>{ApproveOnce,ApproveSession,ApproveAlways,Deny}, gated on the client advertising the elicitation capability; decline/cancel → Deny; never elicits secrets). Hot-reload subscribes astrid.v1.capsules_loaded, re-enumerates, diffs, and peer.notify_tool_list_changed() (no-op notifications suppressed). mcp serve owns stdout for the JSON-RPC stream, so logging is forced off stdout (to file, else stderr) for this command regardless of operator config, so a stray frame cannot corrupt the protocol stream. This is the foundation for registering a named sage server in the managed claude -p (so mcp__sage__* resolves natively and mcp_tool hooks / channels can bind by name) and for the agent-neutral backplane. rmcp 0.15 → 1.7.0 workspace-wide; the existing astrid-mcp client (ClientHandler / RoleClient) is migrated to the 1.7 API. No kernel / WIT / allowlist change. Closes #879.
  • astrid:process@1.0.0 persistent-process tier — implemented. A capsule can now spawn a background child that outlives the pooled, stateless WASM instance that started it. Previously an ephemeral process-handle is reaped when its instance resets on return to the dynamic pool, so a process started in one tool invocation could not survive to the next — the split spawn → read → stop pattern was impossible. A new host-owned PersistentProcessRegistry — cloned into every pooled HostState exactly like the cancellation ProcessTracker, so a process-id survives instance churn — owns the child (spawned on the daemon runtime under the same bwrap/Seatbelt sandbox as the ephemeral tier), its per-stream log rings, and its stdin pipe. Implemented: spawn-persistent (returns a 256-bit host-minted CSPRNG process-id, lowercase base32 so it doubles as an IPC topic suffix; the registry stores only a keyed BLAKE3 hash, never the raw token), status / status-many / list-processes, read-logs (drain) + read-since (non-draining, cursor-addressed, byte-faithful list<u8>), signal (incl. stop/cont), bounded wait, stop (SIGTERM→grace→SIGKILL, frees the slot), release-process, and write-stdin / close-stdin (via keep-stdin-open capture). Every id-keyed call re-resolves the live (principal, capsule) and checks it against the recorded creator, so a leaked id is inert across the principal/capsule boundary — unknown / wrong-owner / wrong-capsule / reaped all collapse to no-such-process...
Read more

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 25 May 18:57
v0.7.0
403c4a7

Breaking

  • KernelRequest / KernelResponse / CommandInfo / CapsuleMetadataEntry / DaemonStatus / SYSTEM_SESSION_UUID moved from astrid_types::kernel to astrid_core::kernel_api. Re-exports under astrid_events::kernel_api are preserved for migration ergonomics. The reason: astrid-types is the WASM-compatible shared-types crate intended to compile on wasm32-unknown-unknown for capsule SDK consumption — it cannot depend on astrid-core (which references PrincipalId, Quotas, and the rest of the kernel-only type universe). The kernel-management RPC surface (CLI ↔ daemon) doesn't belong in a WASM-compatible crate to begin with. CLI commands, socket_client, admin_client, the TUI, and the integration tests have all been updated to import from astrid_core::kernel_api.

Added

  • astrid:fs/host.fs-mkdir-all — unstubbed. Idempotent recursive directory creation via the existing VFS mkdir call (every VFS impl already routes through std::fs::create_dir_all under the hood). Capability gating, audit envelope (astrid:fs/host.fs-mkdir-all), and error mapping match fs-mkdir. Unblocks capsule code that wants the std::fs::create_dir_all-style idempotent variant instead of the strict fs-mkdir. (Closes one item of #753.)
  • Outbound TCP for capsules — net.connect-tcp host fn + net_connect capability. Capsules can now open persistent TCP connections via the new astrid:capsule/net.net-connect-tcp(host, port) -> stream-handle host fn, gated by a per-capsule net_connect = ["host:port", "host:*"] allowlist in Capsule.toml. The returned handle flows through the existing net-read / net-write / net-close-stream plumbing, and the kernel reuses the same is_safe_ip airlock that gates http-request to reject loopback / private / link-local / multicast IPs after DNS resolution. Connect timeout bounded to 10s; per-capsule active-stream cap (MAX_ACTIVE_STREAMS = 8) shared with inbound net-accept. Unblocks WebSocket clients, MQTT, Discord/Telegram gateways, postgres/redis, and the immediate motivator: a Unicity-network capsule wrapping Sphere SDK (Fulcrum + Nostr WebSocket transports). Tracking issue: #745. RFC: rfcs#27. WIT contract: wit#5.
  • NetStream enum (Unix + Tcp) in engine::wasm::host_state — replaces the bare Arc<Mutex<UnixStream>> value type in active_streams. The net_read / net_write dispatchers match on the variant; the inner framing (read_frame / write_frame generic helpers) is shared via tokio::io::AsyncRead + AsyncWrite trait bounds. Single-variant capsules see no behavior change.
  • CapsuleSecurityGate::check_net_connect(capsule_id, host, port) — new trait method, default-deny. ManifestSecurityGate implements it by matching the requested host:port against the manifest's net_connect allowlist (case-insensitive host, exact-or-* port).
  • astrid-build is target-agnostic now. Drops the hardcoded --target wasm32-wasip2 flag on cargo build; instead lets the capsule's own .cargo/config.toml select the target. After compilation, probes target/wasm32-unknown-unknown/release/ first, target/wasm32-wasip2/release/ second (and the workspace root) for the produced .wasm. When the produced artifact is a core wasm module (no Component Model layer = 1 in the magic bytes) — which is what wasm32-unknown-unknown produces — wit_component::ComponentEncoder::default().validate(true).module(&core).encode() wraps it into a Component Model component. Required because the Astrid-canonical guest target is wasm32-unknown-unknown (zero wasi:* imports), and cargo does not produce a component directly for that target.
  • astrid-build's ensure_component overwrites the original .wasm artifact instead of writing a sibling .component.wasm. Capsule Capsule.toml [[component]] file = "<crate>.wasm" directives keep resolving without per-target conditional logic — the toolchain hides which target produced the artifact.
  • astrid-types clock feature. Default OFF — gates IpcMessage::new() and the #[serde(default = "Utc::now")] timestamp default behind a Cargo feature. Kernel-side consumers (astrid-events, the daemon) enable it via their dep declaration. Capsule-side consumers (via astrid-sdk on wasm32-unknown-unknown) leave it off; absent timestamps fall back to the Unix epoch via a from_timestamp(0,0) default. Capsule code reads timestamps from kernel-published messages, never constructs them.
  • chrono = { default-features = false, features = ["serde"] } workspace-wide. The clock feature is what pulls wasm-bindgen + js-sys on wasm32-unknown-unknown, which then inject __wbindgen_placeholder__ imports that wit-component's encoder refuses to round-trip. Records that need a clock value receive it from astrid_sdk::time (audited host fn); DateTime<Utc> is only used as a serializable field shape, never constructed via Utc::now() in capsule code.
  • uuid = { default-features = false, features = ["v4", "v5", "serde", "rng-getrandom"] } workspace-wide. The default features pull a js-based RNG on wasm32-unknown-unknown (via wasm-bindgen). rng-getrandom routes v4 generation through getrandom, which astrid-sys configures with a custom backend (astrid:sys.random-bytes) on capsule builds. Same dep wiring in sdk-rust's astrid-sdk.

Changed

  • Kernel exposes zero wasi:*. configure_kernel_linker no longer registers wasmtime_wasi::p2::add_to_linker_sync. The Astrid-canonical guest target (wasm32-unknown-unknown) produces wasm with zero wasi:* imports; a capsule that somehow ships with a wasi:* import fails to instantiate at load time with a clear "interface not found" error — the intended posture, not a bug. Capsules that historically targeted wasm32-wasip2 and relied on auto-injected wasi:* imports will fail to load until they migrate to wasm32-unknown-unknown via the upcoming SDK + capsule sweep (a separate PR cluster, blocked on this one landing first).
  • crates/astrid-capsule/src/manifest.rs split into a manifest/ submodule. The 1000-line single file became manifest/mod.rs + manifest/capabilities.rs + manifest/topics.rs. CapabilitiesDef, PublishDef, SubscribeDef (with their custom deserializers and TOML parsing tests) live in dedicated submodules; the top-level manifest::* public API is preserved via pub use re-exports — no consumer-side change required.
  • astrid-storage::kv split into a submodule directory. kv.rs (now ~1200 lines after the CAS addition) became kv/mod.rs (validators, helpers, trait, re-exports) + kv/memory.rs (MemoryKvStore) + kv/surreal.rs (SurrealKvStore, behind the kv feature) + kv/scoped.rs (ScopedKvStore), each well under the 1000-line CI ceiling. Public API preserved verbatim via pub use.
  • wit/astrid-capsule.wit resynced from canonical unicity-astrid/wit to pick up the new net-connect-tcp fn and the ipc-message.principal field (canonical PR #4 from May; was missing from the in-tree copy). Internal IpcMessage → WitIpcMessage conversion now forwards principal.

Fixed

  • TcpStream::read_bytes / peek return Err(ErrorCode::Closed) on cancellation. Previously both methods collapsed cancel to Ok(Vec::new()), which is indistinguishable from a clean EOF in byte-stream reads. Capsules with EOF finalizers (write trailers, send last-message IPC, flush logs) would execute those finalizers under a forced unload. Now matches read_frame / write_bytes / shutdown — Closed surfaces cancellation as its own signal. Empty Vec retains its "clean EOF" meaning.
  • Per-domain WIT review fixups (PR #752). A multi-agent review surfaced fixes addressed in-branch before merge:
    • ipc::recv mixed-principal batches are now truncated at the first publisher boundary so tail messages can't be silently mis-stamped with the head's principal context; new truncate_to_homogeneous_principal unit tests cover the boundary cases.
    • TcpStream::write surfaces peer-disconnect IO kinds as ErrorCode::ConnectionReset instead of swallowing them as Ok(()); pure-function tests pin the BrokenPipe / ConnectionReset / ConnectionAborted / UnexpectedEof → ConnectionReset mapping and use tokio::io::duplex to reproduce the live close.
    • TcpStream::read cancellation now returns NetReadStatus::Closed (not Pending) so a cancelled run-loop terminates instead of busy-looping.
    • spawn_background registers the spawned PID in the ProcessTracker, and the ProcessHandle drop path unregisters; previously a tool.v1.request.cancel could never reach backgrounded children.
    • Subscription recv keeps the resource handle valid across calls — the EventReceiver lives behind an Arc<Mutex<...>> so the wasmtime resource is no longer deleted-and-re-pushed each blocking wait.
    • read_file re-checks the post-read payload size for TooLarge rather than relying on a pre-stat TOCTOU.
    • ProcessHandle::wait uses tokio::task::spawn_blocking(child.wait) racing a tokio::time::timeout instead of the 50ms try_wait busy-poll.
    • unix_listener::accept retries credential-rejected connections with a 100ms back-off to avoid a CPU-pinned spin against a hostile peer.
    • http-stream per-chunk timeout extracted to HTTP_STREAM_READ_TIMEOUT named constant.
    • Build script now invalidates the WIT staging dir on .gitmodules changes so CI runners that lazily git submodule update don't compile against a stale tree.
  • Per-domain WIT review fixups round 2 (Gemini, PR #752).
    • MAX_ACTIVE_STREAMS / MAX_SUBSCRIPTIONS / MAX_BACKGROUND_PROCESSES quota gates now read O(1) counter fields on HostState (net_stream_count, subscription_count, process_count_total, process_count_by_principal) instead of walking the entire ResourceTable. Each successful resou...
Read more

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 19 May 01:12
b8efa7c

Breaking

  • Native subprocess capsules refuse to launch when the OS-level sandbox is unavailable (security, fixes #655). Previously, when bwrap failed its user-namespace probe — most commonly on Ubuntu 24.04+ where kernel.apparmor_restrict_unprivileged_userns=1 ships enabled — Astrid silently fell through to an unsandboxed launch with a single tracing::warn! line as the only signal. This contradicted the README's "subprocess capsules are sandboxed" promise: a Node.js MCP server (or OpenClaw Tier 2 plugin) could read ~/.ssh/id_rsa, write to ~/.bashrc, or punch through the ~/.astrid tmpfs overlay without any capability check firing. The new default policy is Required: ProcessSandboxConfig::sandbox_prefix() returns an actionable Err instead of Ok(None) when the sandbox can't be applied, and the MCP server-startup path propagates the error so the daemon refuses to launch the subprocess. The only escape hatch is ASTRID_SANDBOX_POLICY=off, which silently launches without a sandbox — for trusted dev environments and CI runners where the kernel can't be configured. There is no "warn and fall through" middle state: a soft fallback hides the security gap and that is the bug. The error message names the sysctl remediation (sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0) and the env-var escape hatch directly. Breaking for any Ubuntu 24.04+ install that was tacitly relying on the silent fallback — those deployments now need to either flip the sysctl (recommended) or set ASTRID_SANDBOX_POLICY=off (only if the sandbox bypass is intentional).
  • caps grant <agent> "*" now requires --unsafe-admin acknowledgement. Mirrors the long-standing rail on group create --caps "*". Without it, the group-level safety check was trivially bypassable: instead of creating a custom admin-equivalent group (kernel rejects without unsafe_admin = true), an operator could caps grant bob "*" and silently promote bob to universal admin via direct grant. Layer 6's mutate_caps now refuses any Grant that includes the literal bare * capability unless unsafe_admin is true on the request; the CLI surfaces this as caps grant bob "*" --unsafe-admin and rejects the call client-side before any IPC round-trip. Multi-segment wildcards (network:egress:*, self:capsule:*) are unaffected — they're inherently scoped, the gate only triggers on the universal pattern. Breaking for automation: the new AdminRequestKind::CapsGrant.unsafe_admin: bool field defaults to false via #[serde(default)] so wire-format clients that omit it land on the safe side, but any tooling that previously assumed a bare * grant would succeed must now pass unsafe_admin = true (or set --unsafe-admin on the CLI).
  • PrincipalProfile files moved out of the principal home directory. Per-principal profile.toml now lives at ~/.astrid/etc/profiles/{principal}.toml instead of ~/.astrid/home/{principal}/.config/profile.toml. Profile contents are 100% system policy (enabled, groups, grants, revokes, quotas, auth public keys, egress, process allowlist) — keeping them inside the principal's home directory let any capsule with fs_read = ["home://"] read its own policy file (and fs_write would have let it self-elevate). The new location sits outside the home:// VFS scheme entirely. PrincipalProfile::path_for(&PrincipalHome) is now PrincipalProfile::path_for(&AstridHome, &PrincipalId); same for load/save. A one-shot migration in seed_default_principal_admin_profile moves any legacy home/{principal}/.config/profile.toml to the new location on next boot. (#672)
  • AdminKernelRequest and AdminKernelResponse are now wrapper structs with the typed body on a kind/body field, plus an optional request_id for client-side correlation. Pre-existing test fixtures using AdminKernelRequest::AgentCreate { ... } should construct the variant on AdminRequestKind and convert (AdminRequestKind::AgentCreate { ... }.into() or AdminKernelRequest::new(...)). The wire format is forward-compatible: request_id is omitted when None. (#672)
  • AuditAction::AdminRequest gained a params: Option<serde_json::Value> field. Forward-compatible (#[serde(default)] + skip_serializing_if), but external consumers parsing audit entries with strict schemas may need to add the field. Capture is for forensic replay (issue #672). (#672)
  • PermissionError gained a PrincipalDisabled variant thrown by the Layer 5 enforcement preamble when the caller's profile has enabled = false. Existing match blocks against the enum need a new arm. (#672)
  • Kernel.groups field type changed from Arc<GroupConfig> to Arc<ArcSwap<GroupConfig>> (issue #672 — Layer 6). The boot-loaded group config is now hot-reloadable through the admin IPC topics (astrid.v1.admin.group.*); every authorization check clones the current Arc via load_full() on each request. Enforcement preambles hold their own Arc snapshot per check, so in-flight checks observe a consistent config even during a swap. Any direct consumer matching on Arc<GroupConfig> must migrate to kernel.groups.load_full().
  • Built-in agent group gains self:quota:get and self:agent:list capabilities (issue #672). self:* already subsumed both, but operators inspecting or matching on the exact capability vector see a minor widening. This makes agent self-service visibility into their own row and quotas an explicit contract rather than an incidental consequence of self:*.
  • Default principal's profile now carries groups = ["admin"] after boot (issue #670). bootstrap_cli_root_user writes groups = ["admin"] to ~/.astrid/home/default/.config/profile.toml on any boot where the profile is absent or has groups=[] && grants=[] && revokes=[]. Operators who previously ran with an explicitly empty groups list — and no grants/revokes — will see the default principal gain full management-API capabilities on the next boot. Either edit the profile to add a non-admin group (e.g. restricted), or add an explicit grants/revokes entry, to block the auto-seed. Profiles that already name any group, grant, or revoke are left untouched. (#670)
  • CapabilityToken signing format bumped v1 → v2 with principal signed into the payload (Layer 4 multi-tenancy, issue #668). Existing v1 persistent tokens on disk fail verify_signature() after upgrade and get rejected with InvalidSignature plus a tracing::error! pointing operators at re-mint. There is no silent migration path — changing the signing payload is a cryptographic break, not a data migration. CapabilityToken::create and create_with_options now take a required principal: PrincipalId argument. (#668)
  • Allowance.principal: PrincipalId is now required at construction. AllowanceStore keys on (principal, id); find_matching, find_matching_and_consume, consume_use, export_session_allowances, export_workspace_allowances, and clear_session_allowances now take &PrincipalId. A new clear_all_session_allowances retains the global sweep for kernel-initiated shutdown. (#668)
  • CapabilityStore::has_capability / find_capability now take &PrincipalId. Tokens whose principal does not match the caller are rejected up front, even if the resource pattern matches — fail-closed cross-principal check. Revocation and single-use consumption stay global (they are about the token's identity, not the caller). Persistent KV keys changed from caps:tokens/{token_id} to caps:tokens/{principal}/{token_id}. CapabilityValidator::check and validate_by_id also thread &PrincipalId. (#668)
  • Kernel.active_connections is now per-principal (DashMap<PrincipalId, AtomicUsize>). connection_opened(&PrincipalId) / connection_closed(&PrincipalId) take the connecting principal; only that principal's session allowances are cleared on last-disconnect. New total_connection_count() sums across principals for ephemeral-shutdown. (#668)
  • Kernel.overlay_vfs replaced by Kernel.overlay_registry: Arc<OverlayVfsRegistry>. Each invoking principal resolves their own OverlayVfs on first use; Agent A's workspace writes never reach Agent B's view of the same tree. The registry is bounded (default 1024 principals, tunable via ASTRID_OVERLAY_REGISTRY_MAX_PRINCIPALS) with idle-eviction. Kernel.vfs now points at a plain workspace HostVfs — kernel-internal paths that do not know a principal keep using that field. (#668)
  • SecurityInterceptor::intercept and ApprovalManager::check_approval take &PrincipalId. Single-tenant callers pass PrincipalId::default(). (#668)
  • ApprovalDecision::ApproveWithAllowance now boxes Allowance (Box<Allowance>) — the added principal field pushed the enum past clippy's large_enum_variant threshold. (#668)
  • WASM engine migrated from Extism to wasmtime Component Model. The kernel now loads Component Model binaries via Component::from_binary, not Extism modules. Existing capsules compiled with extism-pdk will not load — they must be rebuilt with the migrated SDK targeting wasm32-wasip2. This is a coordinated multi-repo migration (SDK + 16 capsule repos). (#632)
  • WIT host function signatures retyped. All 49 functions now use proper typed params/returns (result<T, string>, WIT records, u64 handles) instead of string-based JSON blobs. The HostResult 0x00/0x01 prefix encoding is removed — errors are returned via WIT result types. (#632)
  • Guest export astrid-hook-trigger signature changed. Was func(input: list<u8>) -> list<u8>. Now func(action: string, payload: list<u8>) -> capsule-result. The action name and payload are separate typed parameters; the return is the typed capsule-result record. (#632)
  • capsule_abi module removed from astrid-core. Types (CapsuleAbiContext, CapsuleAbiResult, LogLevel, etc.) are replaced by `wasmtime::component::bin...
Read more

v0.5.1

Choose a tag to compare

@github-actions github-actions released this 24 Mar 23:18
a166325

Added

  • cargo install astrid now also installs astrid-build (capsule compiler) alongside astrid and astrid-daemon. Previously required a separate cargo install astrid-build.

Fixed

  • astrid capsule install no longer blocks when a new capsule exports an interface already exported by an installed capsule. Multiple providers (e.g. two LLM providers) can now coexist — prints an informational note instead of prompting for replacement.

Install

From source (requires Rust 1.94+):

cargo install astrid

Pre-built binaries:
Download the archive for your platform, extract, and add to PATH:

tar xzf astrid-*-$(uname -m)-*.tar.gz
sudo mv astrid-*/astrid astrid-*/astrid-daemon astrid-*/astrid-build /usr/local/bin/

Then run astrid init to set up capsules.


With many thanks from the following Astrinauts 🚀

  • Joshua J. Bouw

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 24 Mar 20:24
112bec5

Changed

  • workspace:// VFS scheme renamed to cwd:// — the scheme maps to the daemon's CWD at boot; the old name implied a structured project workspace concept that was never implemented.

  • Tools are now a pure IPC convention. Removed kernel-side tool dispatch (WasmCapsuleTool, CapsuleTool trait, inject_tool_schemas, CapsuleToolContext), ToolDef and [[tool]] from manifest, inject_tool_schemas from astrid-build. The kernel no longer parses or manages tool schemas. Tool capsules use IPC interceptors on tool.v1.execute.<name> and tool.v1.request.describe. The router capsule handles discovery and dispatch.

  • LLM providers are now a pure IPC convention. Removed LlmProviderDef and [[llm_provider]] from manifest, LlmProviderInfo and llm_providers from CapsuleMetadataEntry. The kernel no longer parses or manages provider metadata. LLM capsules self-describe via llm.v1.request.describe interceptors; the registry capsule discovers them via hooks::trigger.

  • Removed dead cron host functions. astrid_cron_schedule and astrid_cron_cancel were never implemented (stubs only). CronDef and [[cron]] removed from manifest. WIT spec updated: 49 host functions across 10 domain interfaces.

  • Append-only artifact store — bin/ and wit/ are never deleted on capsule remove. Content-addressed artifacts are the audit trail; deleting them breaks provability. Future astrid gc for explicit cleanup.

  • Replace [dependencies] provides/requires string arrays with [imports]/[exports] namespaced TOML tables — semver version requirements on imports (^1.0), exact versions on exports (1.0.0), optional imports, namespace/interface name validation

  • WIT spec: Rewrite wit/astrid-capsule.wit to document all 51 host ABI functions (was 7). Split monolithic host interface into 11 domain-specific interfaces (fs, ipc, uplink, kv, net, http, sys, cron, process, elicit, approval, identity). Updated guest exports to reflect actual entry points (astrid_hook_trigger, astrid_tool_call, run, astrid_install, astrid_upgrade). Bumped package version to 0.2.0.

Added

  • cargo install astrid installs both astrid (CLI) and astrid-daemon binaries from a single crate. The CLI crate now includes the daemon as a second [[bin]] entry point.
  • astrid self-update command — checks GitHub releases for newer versions, downloads platform-specific binary to ~/.astrid/bin/, no sudo required. Startup update banner (cached 24h) notifies on interactive commands.
  • astrid init PATH setup — detects shell (zsh/bash/fish), offers to append ~/.astrid/bin to the appropriate RC file
  • Standard WIT interface installation during astrid init — fetches 9 WIT files (llm, session, spark, context, prompt, tool, hook, registry, types) from the canonical WIT repo and installs to ~/.astrid/home/{principal}/wit/ for capsule and LLM access via home://wit/
  • Short-circuit interceptor chain — interceptors return Continue, Final, or Deny to control the middleware chain. A guard at priority 10 can veto an event before the core handler at priority 100 ever sees it. Wire format: discriminant byte (0x00/0x01/0x02) + payload, backward compatible with existing capsules.
  • Export conflict detection on capsule install — detects when a new capsule exports interfaces already provided by an installed capsule, prompts user to replace. Nix-aligned approach: conflicts derived from exports data, no name-based supersedes field needed.
  • Interceptor priority — priority field on [[interceptor]] in Capsule.toml (lower fires first, default 100). Enables layered interception (e.g. input guard before react loop).
  • Distro.lock regeneration on astrid capsule update — keeps the lockfile in sync after capsule updates
  • Content-addressed WIT storage — capsule install hashes .wit files into ~/.astrid/wit/, capsule remove cleans up unreferenced WIT files, wit_files field in meta.json
  • astrid capsule tree command — renders the imports/exports dependency graph of all installed capsules, showing which capsule exports satisfy each import, with unsatisfied imports highlighted in red (astrid capsule deps retained as hidden alias)
  • astrid init with distro-based capsule installation — fetches Distro.toml, multi-select provider groups, shared variable prompts with {{ var }} template resolution, progress bars, writes Distro.lock for reproducibility. Supports --distro flag for custom distros.
  • Distro.toml parser and Distro.lock generator — parse distro manifests with full os-release style metadata, shared variables with {{ var }} templates, provider groups, uplink roles, and semver validation. Atomic lockfile writes with BLAKE3 hashes for reproducible installs.
  • Kernel boot validation — validates every capsule's required [imports] has a matching [exports] from another loaded capsule, logs errors for unsatisfied required imports and info for optional ones
  • astrid capsule remove command with dependency safety checks — blocks removal if the capsule is the sole exporter of an interface that another capsule imports (--force to override), cleans up content-addressed WASM binaries from bin/ when no other capsule references the same hash
  • Install capsules from GitHub release WASM assets — astrid capsule install @org/repo now downloads pre-built .wasm binaries from release assets before falling back to clone + build from source
  • Per-principal audit chain splitting — each principal maintains its own independent chain per session, independently verifiable via verify_principal_chain() and get_principal_entries()
  • AuditLog::append_with_principal() for principal-tagged audit entries
  • Auto-provisioning gated on identity store — only "default" principal is auto-provisioned when identity store is configured
  • Linux FHS-aligned directory layout (etc/, var/, run/, log/, keys/, bin/, home/) replacing the flat ~/.astrid/ structure
  • PrincipalId type for multi-principal (multi-user) deployments — each principal gets isolated capsules, KV, audit, tokens, and config under home/{principal}/
  • Content-addressed WASM binaries in bin/ using BLAKE3 hashing — integrity verified on every capsule load (no hash = no load, wrong hash = no load)
  • Per-capsule daily log rotation at home/{principal}/.local/log/{capsule}/{YYYY-MM-DD}.log with 7-day retention
  • /tmp VFS mount backed by home/{principal}/.local/tmp/ for per-principal temp isolation
  • Multi-source capsule discovery with precedence: principal > workspace (dedup by name)
  • PrincipalHome struct with .local/ and .config/ following XDG conventions
  • Per-invocation principal resolution — KV, audit, logging, and capability checks scope to the calling user per IPC message, not per capsule load
  • IpcMessage.principal field for carrying the acting principal through event chains (transparent to capsules)
  • AstridUserId.principal field mapping platform identities to PrincipalId with auto-derivation from display name
  • Dynamic KV scoping via invocation_kv on HostState — capsules call kv::get("key") and the kernel returns the right value for the current principal
  • Principal auto-propagation on ipc_publish — capsules never touch the principal, it flows through event chains automatically
  • Auto-provisioning of principal home directories on first encounter
  • astrid_get_caller host function now returns { principal, source_id, timestamp } instead of empty object
  • Dynamic per-principal log routing — cross-principal invocations write to the target principal's log directory
  • AuditEntry.principal field with length-delimited signing data encoding
  • ScopedKvStore::with_namespace() for creating scoped views sharing the same underlying store
  • AuditEntry::create_with_principal() builder for principal-tagged audit entries
  • layout-version sentinel in etc/ for future migration support
  • lib/ directory reserved for future WIT shared WASM component libraries
  • End-to-end Tier 2 OpenClaw plugin support: TypeScript plugins with npm dependencies install, transpile, sandbox, and run as MCP capsules with full tool integration
  • OXC strip_types() transpiler for Tier 2 TS→JS (preserves ESM, unlike Tier 1's CJS conversion)
  • Node.js binary resolution at build time: prefers versioned Homebrew installs (node@22+), validates each candidate
  • MCP-discovered tools are now merged into the LLM tool schema injection alongside WASM capsule tools
  • astrid_net_read now uses a self-describing NetReadStatus wire format: every response is prefixed with a discriminant byte (0x00 = data, 0x01 = closed, 0x02 = pending), replacing the previous single-byte sentinel hack
  • Headless mode: astrid -p "prompt" for non-interactive single-prompt execution with stdin piping support
  • Post-install onboarding: astrid capsule install now prompts for [env] fields immediately after install
  • Shared astrid_telemetry::log_config_from() behind config feature flag — replaces duplicate config bridge code
  • --snapshot-tui mode — renders the full TUI to stdout as ANSI-colored text frames using ratatui's TestBackend. Each significant event (ready, input, tool call, approval, response) produces a frame dump. Configurable with --tui-width and --tui-height. Enables automated smoke testing without an interactive terminal.

Fixed

  • cwd:// VFS scheme was handled in the security gate (capability checks) but not in the runtime path resolver — capsules using cwd:// paths at runtime received a security denial because the path resolved to <cwd>/cwd:/path instead of <cwd>/path
  • sandbox-exec (Seatbelt) crashes with SIGABRT on macOS 15+ (Darwin >= 24) — skip sandboxing on affected versions
  • Headless approval response published to wrong IPC topic (astrid.v1.approval.response instead of astrid.v1.approval.response.{request_id}) and used wrong decision string (allow instead of approve)
  • `[[component]]....
Read more