Releases: astrid-runtime/astrid
Release list
v0.9.4
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 anastrid-contracts.witpin were only incidentally retained and noastrid capsulesurface 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 someoneb3sum-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 ofwit/, which now holds only the daemon's canonical named copies, soastrid capsule wit gcsweeps the store without endangering them), and seeds the daemon canonicalastrid-contracts.witfirst-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 listprints 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 servenow 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 andmcp servehad 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 ignoringSIGTERM(onlySIGKILLfreed it), holding the audit-DBLOCK, withastrid statusfailing. Root cause: signals were handled by atokio::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 atepoch_deadline = u64::MAXwith no yield callback) pinned every worker, so theSIGTERMarm 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'sshutdown_txwatch channel (so the normalkernel.shutdown().awaitstill 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. ansage-mcp/sibyl-mcpuplink broker) ran its run-loop guest with the wasmtime epoch deadline pinned atu64::MAXand 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 theSIGTERM-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 — neverInterrupt-trapped, never fuel-out — yet cooperativelyYieldthe 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::newopened the KV and audit stores before acquiring the singleton advisory flock, so a second daemon losing the boot race died on a raw surrealkvDatabase ... LOCK is already lockedafter 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
LOCKon exit rather than only on process death — and the store locks are released before the capsule drain.Kernel::shutdownclosed the KV store but never closed the audit log, so its persistent surrealkvLOCKwas held until the process died — which is why a wedged daemon neededSIGKILLto 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 sharedArc<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 sameArc::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 serveno 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-frequencygetppid()-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_capsuletore the old instance down viaArc::get_mut+unload, which is skipped when a live dispatcher consumer still holds anArcclone (strong_count > 1) — yet it returnedOk, so the health monitor cleared the capsule'sRestartTracker, 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_cancelcancels 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-heldArc(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_FALLBACKinastrid-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 terminalDonewas 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-byteArchandle, but theArcretains 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 #...
v0.9.3
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 intools/listbut 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'ttool_execute_<name>routes then gets denied.astrid capsule check [PATH]cross-checks the#[astrid::tool("…")]annotations insrc/against theCapsule.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 noASTRID_HOME, making it fast and deterministic (cargo checkfor capsules). Check #1 reuses the sametools_missing_execute_routepredicate as the kernel's load-time warning, so the CI gate and the runtime can never disagree; a--deepmode (ephemeral-load + the realtool_describe) is a documented later addition. Closes #1129.
Changed
-
astrid-kernelnow compiles forwasm32-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, thecap-stdVFS and per-principal overlay registry, the disk-backed capsule install/discovery/reload/health paths, the Unix socket manager (UnixListenerbind + 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 rootKernel::newundercfg(unix)), and their native-only dependencies (astrid-mcp,astrid-vfs,astrid-capsule-install, andtokio'sfull/signalfeatures) move into a matching target-specific dependency table; the basetokioset is trimmed to the wasm-clean subset. TheKernel.kvandKernelResources.kvfields change from the concreteArc<SurrealKvStore>toArc<dyn KvStore>so a portable host can inject its own backend — a new defaultKvStore::closetrait method (a no-op for backends with nothing to flush, delegating to the inherent flush onSurrealKvStore) 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), andastrid-runtimegains aspawn_blockingfacade (nativetokiore-export; inline execution on the single-threaded wasm arm).astrid-kernelis added to thewasm32-unknown-unknownportability CI guard. This is a deliberate 0.x break:Kernel.kv/KernelResources.kvare now trait objects, andKernel::new/thesocketmodule now requirecfg(unix). Nativebuild/test/clippy/fmtare unaffected. Closes #1148. -
astrid-capsule's engine-agnostic machinery now compiles forwasm32-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, andtokio'snet/rt-multi-threadfeatures), 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-StoreStoreMemoryMeterare 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 theastrid-runtimefacade. The host audit-sink contract (HostAuditSink,HostAuditEvent,HostAuditOutcome) — pure Rust with no Wasmtime dependency — moves out of the engine subtree toastrid_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 ownExecutionEnginebehind the samedyn Capsuletrait.astrid-capsuleis added to thewasm32-unknown-unknownportability CI guard; nativebuild/test/clippy/fmtare unaffected. Closes #1142. -
The kernel's task-spawning and time surface is now selected per target through a new
astrid-runtimefacade crate, groundwork for alternate host profiles. The kernel spawned tasks and read the clock viatokio::spawn,tokio::time::{sleep, interval, timeout, Instant, MissedTickBehavior},tokio::time::Instant/std::time::Instant, andstd::time::SystemTimedirectly at ~50 call sites — none of which exist onwasm32-unknown-unknown(no tokio runtime, no OS threads, nostd::timeclock), so a portable host could not reuse the kernel's scheduling and timekeeping. Those calls now route throughastrid-runtime, a single seam that selects the surface at compile time: the native arm (cfg(not(target_family = "wasm"))) is puretokio/stdre-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 acfg-gated re-export rather than a trait: the implementation never varies within one platform build, so there is no runtime polymorphism to model.astrid-runtimeis added to thewasm32-unknown-unknownportability CI guard; nativebuild/test/clippy/fmtare 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, andastrid-approvalbuild for the wasm target with no behaviour change on native. Two structural blockers were cleared. First, the workspace-leveltokiodependency pinned an additive base feature set (net,signal,rt-multi-thread) onto everyworkspace = trueconsumer — including leaf crates that only asked forsync/time— dragging inmio(whichcompile_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 fornet, daemon/kernel forsignal, daemon/emit/cli/capsule-install/capsule/hooks/kernel/gateway forrt-multi-thread). Second,getrandom(reached viauuid/randat major 0.4 andsurrealkvat major 0.3) refuses to build for wasm without a backend selection: thewasm_jsfeature 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"thatscripts/check-wasm-portability.sh(a newcargo check-only regression guard, wired into CI) sets. The audit store'sblock_in_placefast path (native-only) is cfg-gated to its scoped-thread fallback on wasm. No public API changes; nativebuild/test/clippy/fmtare unaffected. -
Engine-agnostic capsule types extracted into a new
astrid-capsule-typescrate. The pure data the kernel routes and gates on — the capsule manifest (CapsuleManifestand 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 insideastrid-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 awasmtime-free,tokio-freeastrid-capsule-typescrate that every engine can depend on;astrid-capsulere-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-Semaphoreconstructors onCapsuleRuntimeLimitsbecome theCapsuleRuntimeLimitsExtextension trait, and the per-StoreStoreMemoryMeter(wasmtime::ResourceLimiter) keeps feeding the sharedMemoryLedger. A pure move for every consumer that uses the re-exported paths — no behaviour changes, and the manifest's untrusted-inputskip_deserializingprotections and parser-isolation tests move intact. One API-shape caveat for external consumers ofastrid-capsule: the semaphore constructors are now trait methods, soCapsuleRuntimeLimitsExtmust be in scope to callblocking_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::newperformed every platform-specific side-effect inline — resolving the Astrid home, opening the SurrealKV store and the audit log, loading the runtime signing ...
v0.9.2
Fixed
- First-run consent no longer storms a
GrantRequiredfor every ungranted capsule in the caller's view. The dispatch access gate ran the grant-on-use check — and emitted aGrantRequiredsignal — for every ungranted capsule in the caller's view before checking whether the capsule's subscription matched the dispatched topic. A singletools/callon 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 samecrate::topic::topic_matchesthe 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/callneeds, instead of only the first. The shim resolved exactly onegrant_requiredper 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 secondgrant_requiredreply 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 honestisError(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 servenow actually pushesnotifications/tools/list_changedwhen 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 advertisedtools.listChangedbut 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-stampedastrid.v1.capsules_loadedbroadcast 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 freshtools/listround 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 thecapsules_loadedpayload (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
Added
astrid:process@1.1.0host interface. Additive successor to the frozenastrid:process@1.0.0, carrying the host-verified, read-only per-spawn file-injection surface (thefile-injectionrecord, theinjection-placementvariant, and thespawn-request.file-injectionsfield). The kernel serves both versions off one implementation; capsules opt into injection by importing@1.1.0, while@1.0.0behaves as spawn-with-no-injections. Refs #1107, wit#19, #881.
Fixed
- Shipped capsules that import
astrid:process@1.0.0instantiate again — no rebuild required. The per-spawn file-injection extension originally landed in-place on the frozenastrid:process@1.0.0WIT (wit#15), changing the shape ofspawn-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 withcomponent imports instance astrid:process/host@1.0.0, but a matching implementation was not found in the linker— the sage supervisor could notastrid initandastrid-capsule-shellv0.2.0 failed to load. The kernel now restores@1.0.0to its published shape and serves it alongside the additive@1.1.0(dual-version host, mirroring theastrid:http@1.0.0/@1.1.0precedent), 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
Breaking
astrid-uplink's kernel-request client API is now typed instead ofanyhow.KernelClient::requestandBusKernelClient::requestreturnResult<KernelResponse, KernelClientError>instead ofanyhow::Result; the publicis_timeout()/RequestTimeoutmarker items are removed, andKernelClientError/TimeoutKindare added. Semver-major for this internal crate, but there are no external consumers and every in-workspace call site is updated (the gateway matchesKernelClientError::Timeoutdirectly for the new504). 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 asprincipal -> 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 mutatingdefault. 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
KernelClienttransport (astrid-uplink) behindGET /api/sys/status, the newGET /api/sys/readiness,POST /api/sys/capsules/reload, and the/api/capsuleslist/get/install routes stamped only the caller's principal on outbound requests, never the authenticating device'skey_id— so the kernel'sauthorize_requestsawdevice_key_id = Noneand skipped theDeviceScopeattenuation floor. Ause-onlydevice whose scope deniescapsule:list/capsule:reload/capsule:installbut 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).KernelClientnow carries an optionaldevice_key_id(with_device_key_id) and stamps it on every request via a unit-tested message builder; all six gateway call sites threadCallerContext.device_key_id, mirroring the bus-directBusAdminClientpath that already did so. Full-authority bearers stampNone(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_interceptorsmatched the entire global registry on topic alone, so a non-admin principal (arestricted/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 newcapsules: Vec<String>field onPrincipalProfile(#[serde(default)], empty by default, validated like aCapsuleId) names the capsule ids a principal may invoke, and the dispatcher filters topic-matched capsules to that set for the user-invocable surface only —tool.v1.execute.*andcli.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.identityhandling bothtool.v1.execute.save_identityandspark.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 sameCapabilityCheckmachineryauthorize_requestuses), so single-tenantdefaultand admins are unaffected with zero migration. The resolver reuses the kernel-owned, in-memoryprofile_cache(anRwLock-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/anonymouscaller, 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 viaastrid agent modify --add-capsule <id>/--remove-capsule <id>(and the HTTP management API'sadd_capsules/remove_capsules), mirroring the existing--add-group/--remove-groupmechanism exactly (sameagent:modifygating, same audit path, idempotent). No WIT / host-ABI change — kernel-internal dispatch + profile schema + admin contract only. Refs #992. astrid-audit::AuditActiongainsNetConnect/NetBind/ProcessSpawnvariants and is now#[non_exhaustive], and theastrid-capsulecontext structsCapsuleContext,HostState, andLifecycleConfiggain a publicaudit_sinkfield. 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 matchingAuditActionexhaustively must now carry a wildcard arm (the#[non_exhaustive]marker makes every future variant addition non-breaking), and any struct-literal construction ofCapsuleContext/HostState/LifecycleConfigmust include the newaudit_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 initnow honours the global--principalflag, provisioning the named principal's home instead of alwaysdefault.astrid init --principal <x>(orASTRID_PRINCIPAL/ the active-agent context) previously wrote the distro's per-capsule env files and theDistro.lockunderdefaulteven 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 identityastrid capsule installuses — through the lock path, env files, per-provider onboarding, and every post-install read-back, both on the online path and the offline.shuttlepath. Provisioning is also honest: a run where every capsule failed no longer prints "Installation complete" or writes aDistro.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_backuppath is present); and GitHub release/source resolution now authenticates withGH_TOKEN/GITHUB_TOKENwhen 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 separateArc<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-principalname -> hashviews 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_fornow 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 viaregister_existing/contains_hash(no second runtime). Kept from #1083: per-principal dispatch/visibility views (cloned_values_forscoping, 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 newrequest_cancel_for(principal)that cancels exactly the departing principal's in-flight blocking host calls (approval/elicit...
v0.8.0
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-gatewaycrate 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.0persistent-process tier is implemented (background children that outlive the pooled WASM instance, id-keyed reattach/stdin/log-cursor ops, operatorallow_persistentopt-in), capability introspection lands (enumerate-capabilities+ a completedcheck-capsule-capability),astrid mcp serveexposes capsule tools + capability consent to any MCP client, andastrid-emitgives 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:listno 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 ahandlerand an optionalpriority. A capsule used to bind an interceptor either via a[[interceptor]]block (event+action+ optionalpriority) or a[subscribe]entry with ahandler— the two overlapped, and only the legacy block could set apriority(the[subscribe]form hardcoded the default 100).[subscribe]now carries an optionalpriority(lower fires first; default 100; apriorityon 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 typedwitpayload reference that[[interceptor]]did not, so each migrated binding declares its payload type (typed IPC everywhere). Closes #858.Capsule.toml: thecapabilities.ipc_publish/ipc_subscribestring 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 declaringipc_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]witrefs. The[[topic]]block let a capsule self-describe a topic with an inline JSON Schema file, baked intometa.jsonat install. That self-description (flaky to keep in sync) is dropped in favour of the typedwitpayload ref already on every[publish]/[subscribe]entry: the A2UI schema catalog (SchemaCatalog) now registers each topic from those tables and records itswitref for the A2UI bridge to resolve to a schema + description via the WIT registry. Removed: the[[topic]]table (TopicDef/TopicDirection), the install-timebake_topicspath, themeta.topics/BakedTopicinstall metadata, and theastrid capsule listbaked-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 alongsideprincipalandexp. Required by the new revocation machinery: withoutiatthe 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. CLIastrid invite redeem, the existing pair-device flow, and every browser session that goes through/api/auth/redeemmint the new shape automatically; only pre-existing in-flight bearers are affected. Format spec:b64url(principal) "." b64url(iat) "." b64url(exp) "." hex(sig), with sig overprincipal:iat:exp. Closes #772. - MSRV bumped to 1.95.0.
surrealdb 3.0.0-beta.3'skv-memfeature pulled insurrealmx v0.21.0→ferntree v0.7.0, which usesstd::hint::cold_pathstabilised in Rust 1.95. Upstream declared norust-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 committingCargo.lock(which is intentionally gitignored). Affectscargo install astridconsumers — installers on 1.94 will see a clear "requires rustc 1.95" error rather than the crypticcold_pathfailure.
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-mcpmanages external tool servers) and a tool broker (sage-mcpdiscovers capsule tools and shapes MCP descriptors), but no way for an MCP client — the managedclaude -p, or Codex/Gemini/any client — to consume Astrid's capsule tools over the standard MCP wire protocol.astrid mcp serve(a subcommand of theastridCLI, not a separate binary) is a thin rmcp stdioServerHandlerthat delegates to the existingsage-mcpbroker over the already-allowlistedastrid.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_infoadvertises tools +tools.list_changed;list_tools/call_toolpublish 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 subscribesastrid.v1.capsules_loaded, re-enumerates, diffs, andpeer.notify_tool_list_changed()(no-op notifications suppressed).mcp serveowns 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 namedsageserver in the managedclaude -p(somcp__sage__*resolves natively andmcp_toolhooks / channels can bind by name) and for the agent-neutral backplane. rmcp0.15 → 1.7.0workspace-wide; the existingastrid-mcpclient (ClientHandler/RoleClient) is migrated to the 1.7 API. No kernel / WIT / allowlist change. Closes #879.astrid:process@1.0.0persistent-process tier — implemented. A capsule can now spawn a background child that outlives the pooled, stateless WASM instance that started it. Previously an ephemeralprocess-handleis 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 splitspawn → read → stoppattern was impossible. A new host-ownedPersistentProcessRegistry— cloned into every pooledHostStateexactly like the cancellationProcessTracker, so aprocess-idsurvives instance churn — owns the child (spawned on the daemon runtime under the samebwrap/Seatbelt sandbox as the ephemeral tier), its per-stream log rings, and its stdin pipe. Implemented:spawn-persistent(returns a 256-bit host-minted CSPRNGprocess-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-faithfullist<u8>),signal(incl.stop/cont), boundedwait,stop(SIGTERM→grace→SIGKILL, frees the slot),release-process, andwrite-stdin/close-stdin(viakeep-stdin-opencapture). 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 tono-such-process...
v0.7.0
Breaking
KernelRequest/KernelResponse/CommandInfo/CapsuleMetadataEntry/DaemonStatus/SYSTEM_SESSION_UUIDmoved fromastrid_types::kerneltoastrid_core::kernel_api. Re-exports underastrid_events::kernel_apiare preserved for migration ergonomics. The reason:astrid-typesis the WASM-compatible shared-types crate intended to compile onwasm32-unknown-unknownfor capsule SDK consumption — it cannot depend onastrid-core(which referencesPrincipalId,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 fromastrid_core::kernel_api.
Added
astrid:fs/host.fs-mkdir-all— unstubbed. Idempotent recursive directory creation via the existing VFSmkdircall (every VFS impl already routes throughstd::fs::create_dir_allunder the hood). Capability gating, audit envelope (astrid:fs/host.fs-mkdir-all), and error mapping matchfs-mkdir. Unblocks capsule code that wants thestd::fs::create_dir_all-style idempotent variant instead of the strictfs-mkdir. (Closes one item of #753.)- Outbound TCP for capsules —
net.connect-tcphost fn +net_connectcapability. Capsules can now open persistent TCP connections via the newastrid:capsule/net.net-connect-tcp(host, port) -> stream-handlehost fn, gated by a per-capsulenet_connect = ["host:port", "host:*"]allowlist inCapsule.toml. The returned handle flows through the existingnet-read/net-write/net-close-streamplumbing, and the kernel reuses the sameis_safe_ipairlock that gateshttp-requestto 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 inboundnet-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. NetStreamenum (Unix + Tcp) inengine::wasm::host_state— replaces the bareArc<Mutex<UnixStream>>value type inactive_streams. Thenet_read/net_writedispatchers match on the variant; the inner framing (read_frame/write_framegeneric helpers) is shared viatokio::io::AsyncRead + AsyncWritetrait bounds. Single-variant capsules see no behavior change.CapsuleSecurityGate::check_net_connect(capsule_id, host, port)— new trait method, default-deny.ManifestSecurityGateimplements it by matching the requestedhost:portagainst the manifest'snet_connectallowlist (case-insensitive host, exact-or-*port).astrid-buildis target-agnostic now. Drops the hardcoded--target wasm32-wasip2flag oncargo build; instead lets the capsule's own.cargo/config.tomlselect the target. After compilation, probestarget/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 whatwasm32-unknown-unknownproduces —wit_component::ComponentEncoder::default().validate(true).module(&core).encode()wraps it into a Component Model component. Required because the Astrid-canonical guest target iswasm32-unknown-unknown(zerowasi:*imports), andcargodoes not produce a component directly for that target.astrid-build'sensure_componentoverwrites the original.wasmartifact instead of writing a sibling.component.wasm. CapsuleCapsule.toml [[component]] file = "<crate>.wasm"directives keep resolving without per-target conditional logic — the toolchain hides which target produced the artifact.astrid-typesclockfeature. Default OFF — gatesIpcMessage::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 (viaastrid-sdkonwasm32-unknown-unknown) leave it off; absent timestamps fall back to the Unix epoch via afrom_timestamp(0,0)default. Capsule code reads timestamps from kernel-published messages, never constructs them.chrono = { default-features = false, features = ["serde"] }workspace-wide. Theclockfeature is what pullswasm-bindgen+js-sysonwasm32-unknown-unknown, which then inject__wbindgen_placeholder__imports thatwit-component's encoder refuses to round-trip. Records that need a clock value receive it fromastrid_sdk::time(audited host fn);DateTime<Utc>is only used as a serializable field shape, never constructed viaUtc::now()in capsule code.uuid = { default-features = false, features = ["v4", "v5", "serde", "rng-getrandom"] }workspace-wide. The default features pull ajs-based RNG onwasm32-unknown-unknown(viawasm-bindgen).rng-getrandomroutes v4 generation throughgetrandom, whichastrid-sysconfigures with a custom backend (astrid:sys.random-bytes) on capsule builds. Same dep wiring insdk-rust'sastrid-sdk.
Changed
- Kernel exposes zero
wasi:*.configure_kernel_linkerno longer registerswasmtime_wasi::p2::add_to_linker_sync. The Astrid-canonical guest target (wasm32-unknown-unknown) produces wasm with zerowasi:*imports; a capsule that somehow ships with awasi:*import fails to instantiate at load time with a clear "interface not found" error — the intended posture, not a bug. Capsules that historically targetedwasm32-wasip2and relied on auto-injectedwasi:*imports will fail to load until they migrate towasm32-unknown-unknownvia the upcoming SDK + capsule sweep (a separate PR cluster, blocked on this one landing first). crates/astrid-capsule/src/manifest.rssplit into amanifest/submodule. The 1000-line single file becamemanifest/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-levelmanifest::*public API is preserved viapub usere-exports — no consumer-side change required.astrid-storage::kvsplit into a submodule directory.kv.rs(now ~1200 lines after the CAS addition) becamekv/mod.rs(validators, helpers, trait, re-exports) +kv/memory.rs(MemoryKvStore) +kv/surreal.rs(SurrealKvStore, behind thekvfeature) +kv/scoped.rs(ScopedKvStore), each well under the 1000-line CI ceiling. Public API preserved verbatim viapub use.wit/astrid-capsule.witresynced from canonicalunicity-astrid/witto pick up the newnet-connect-tcpfn and theipc-message.principalfield (canonical PR #4 from May; was missing from the in-tree copy). InternalIpcMessage → WitIpcMessageconversion now forwardsprincipal.
Fixed
TcpStream::read_bytes/peekreturnErr(ErrorCode::Closed)on cancellation. Previously both methods collapsed cancel toOk(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 matchesread_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::recvmixed-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; newtruncate_to_homogeneous_principalunit tests cover the boundary cases.TcpStream::writesurfaces peer-disconnect IO kinds asErrorCode::ConnectionResetinstead of swallowing them asOk(()); pure-function tests pin theBrokenPipe / ConnectionReset / ConnectionAborted / UnexpectedEof → ConnectionResetmapping and usetokio::io::duplexto reproduce the live close.TcpStream::readcancellation now returnsNetReadStatus::Closed(notPending) so a cancelled run-loop terminates instead of busy-looping.spawn_backgroundregisters the spawned PID in theProcessTracker, and theProcessHandledrop path unregisters; previously atool.v1.request.cancelcould never reach backgrounded children.Subscriptionrecv keeps the resource handle valid across calls — theEventReceiverlives behind anArc<Mutex<...>>so the wasmtime resource is no longer deleted-and-re-pushed each blocking wait.read_filere-checks the post-read payload size forTooLargerather than relying on a pre-stat TOCTOU.ProcessHandle::waitusestokio::task::spawn_blocking(child.wait)racing atokio::time::timeoutinstead of the 50mstry_waitbusy-poll.unix_listener::acceptretries credential-rejected connections with a 100ms back-off to avoid a CPU-pinned spin against a hostile peer.http-streamper-chunk timeout extracted toHTTP_STREAM_READ_TIMEOUTnamed constant.- Build script now invalidates the WIT staging dir on
.gitmoduleschanges so CI runners that lazilygit submodule updatedon't compile against a stale tree.
- Per-domain WIT review fixups round 2 (Gemini, PR #752).
MAX_ACTIVE_STREAMS/MAX_SUBSCRIPTIONS/MAX_BACKGROUND_PROCESSESquota gates now read O(1) counter fields onHostState(net_stream_count,subscription_count,process_count_total,process_count_by_principal) instead of walking the entireResourceTable. Each successful resou...
v0.6.0
Breaking
- Native subprocess capsules refuse to launch when the OS-level sandbox is unavailable (security, fixes #655). Previously, when
bwrapfailed its user-namespace probe — most commonly on Ubuntu 24.04+ wherekernel.apparmor_restrict_unprivileged_userns=1ships enabled — Astrid silently fell through to an unsandboxed launch with a singletracing::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~/.astridtmpfs overlay without any capability check firing. The new default policy isRequired:ProcessSandboxConfig::sandbox_prefix()returns an actionableErrinstead ofOk(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 isASTRID_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 setASTRID_SANDBOX_POLICY=off(only if the sandbox bypass is intentional). caps grant <agent> "*"now requires--unsafe-adminacknowledgement. Mirrors the long-standing rail ongroup create --caps "*". Without it, the group-level safety check was trivially bypassable: instead of creating a custom admin-equivalent group (kernel rejects withoutunsafe_admin = true), an operator couldcaps grant bob "*"and silently promotebobto universal admin via direct grant. Layer 6'smutate_capsnow refuses anyGrantthat includes the literal bare*capability unlessunsafe_adminis true on the request; the CLI surfaces this ascaps grant bob "*" --unsafe-adminand 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 newAdminRequestKind::CapsGrant.unsafe_admin: boolfield defaults tofalsevia#[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 passunsafe_admin = true(or set--unsafe-adminon the CLI).PrincipalProfilefiles moved out of the principal home directory. Per-principalprofile.tomlnow lives at~/.astrid/etc/profiles/{principal}.tomlinstead 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 withfs_read = ["home://"]read its own policy file (andfs_writewould have let it self-elevate). The new location sits outside thehome://VFS scheme entirely.PrincipalProfile::path_for(&PrincipalHome)is nowPrincipalProfile::path_for(&AstridHome, &PrincipalId); same forload/save. A one-shot migration inseed_default_principal_admin_profilemoves any legacyhome/{principal}/.config/profile.tomlto the new location on next boot. (#672)AdminKernelRequestandAdminKernelResponseare now wrapper structs with the typed body on akind/bodyfield, plus an optionalrequest_idfor client-side correlation. Pre-existing test fixtures usingAdminKernelRequest::AgentCreate { ... }should construct the variant onAdminRequestKindand convert (AdminRequestKind::AgentCreate { ... }.into()orAdminKernelRequest::new(...)). The wire format is forward-compatible:request_idis omitted whenNone. (#672)AuditAction::AdminRequestgained aparams: 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)PermissionErrorgained aPrincipalDisabledvariant thrown by the Layer 5 enforcement preamble when the caller's profile hasenabled = false. Existingmatchblocks against the enum need a new arm. (#672)Kernel.groupsfield type changed fromArc<GroupConfig>toArc<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 currentArcviaload_full()on each request. Enforcement preambles hold their ownArcsnapshot per check, so in-flight checks observe a consistent config even during a swap. Any direct consumer matching onArc<GroupConfig>must migrate tokernel.groups.load_full().- Built-in
agentgroup gainsself:quota:getandself:agent:listcapabilities (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 ofself:*. - Default principal's profile now carries
groups = ["admin"]after boot (issue #670).bootstrap_cli_root_userwritesgroups = ["admin"]to~/.astrid/home/default/.config/profile.tomlon any boot where the profile is absent or hasgroups=[] && grants=[] && revokes=[]. Operators who previously ran with an explicitly emptygroupslist — 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 explicitgrants/revokesentry, to block the auto-seed. Profiles that already name any group, grant, or revoke are left untouched. (#670) CapabilityTokensigning format bumped v1 → v2 withprincipalsigned into the payload (Layer 4 multi-tenancy, issue #668). Existing v1 persistent tokens on disk failverify_signature()after upgrade and get rejected withInvalidSignatureplus atracing::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::createandcreate_with_optionsnow take a requiredprincipal: PrincipalIdargument. (#668)Allowance.principal: PrincipalIdis now required at construction.AllowanceStorekeys on(principal, id);find_matching,find_matching_and_consume,consume_use,export_session_allowances,export_workspace_allowances, andclear_session_allowancesnow take&PrincipalId. A newclear_all_session_allowancesretains the global sweep for kernel-initiated shutdown. (#668)CapabilityStore::has_capability/find_capabilitynow take&PrincipalId. Tokens whoseprincipaldoes 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 fromcaps:tokens/{token_id}tocaps:tokens/{principal}/{token_id}.CapabilityValidator::checkandvalidate_by_idalso thread&PrincipalId. (#668)Kernel.active_connectionsis 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. Newtotal_connection_count()sums across principals for ephemeral-shutdown. (#668)Kernel.overlay_vfsreplaced byKernel.overlay_registry: Arc<OverlayVfsRegistry>. Each invoking principal resolves their ownOverlayVfson 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 viaASTRID_OVERLAY_REGISTRY_MAX_PRINCIPALS) with idle-eviction.Kernel.vfsnow points at a plain workspaceHostVfs— kernel-internal paths that do not know a principal keep using that field. (#668)SecurityInterceptor::interceptandApprovalManager::check_approvaltake&PrincipalId. Single-tenant callers passPrincipalId::default(). (#668)ApprovalDecision::ApproveWithAllowancenow boxesAllowance(Box<Allowance>) — the addedprincipalfield pushed the enum past clippy'slarge_enum_variantthreshold. (#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 withextism-pdkwill not load — they must be rebuilt with the migrated SDK targetingwasm32-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,u64handles) instead ofstring-based JSON blobs. TheHostResult0x00/0x01 prefix encoding is removed — errors are returned via WITresulttypes. (#632) - Guest export
astrid-hook-triggersignature changed. Wasfunc(input: list<u8>) -> list<u8>. Nowfunc(action: string, payload: list<u8>) -> capsule-result. The action name and payload are separate typed parameters; the return is the typedcapsule-resultrecord. (#632) capsule_abimodule removed fromastrid-core. Types (CapsuleAbiContext,CapsuleAbiResult,LogLevel, etc.) are replaced by `wasmtime::component::bin...
v0.5.1
Added
cargo install astridnow also installsastrid-build(capsule compiler) alongsideastridandastrid-daemon. Previously required a separatecargo install astrid-build.
Fixed
astrid capsule installno 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
Changed
-
workspace://VFS scheme renamed tocwd://— 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,CapsuleTooltrait,inject_tool_schemas,CapsuleToolContext),ToolDefand[[tool]]from manifest,inject_tool_schemasfromastrid-build. The kernel no longer parses or manages tool schemas. Tool capsules use IPC interceptors ontool.v1.execute.<name>andtool.v1.request.describe. The router capsule handles discovery and dispatch. -
LLM providers are now a pure IPC convention. Removed
LlmProviderDefand[[llm_provider]]from manifest,LlmProviderInfoandllm_providersfromCapsuleMetadataEntry. The kernel no longer parses or manages provider metadata. LLM capsules self-describe viallm.v1.request.describeinterceptors; the registry capsule discovers them viahooks::trigger. -
Removed dead cron host functions.
astrid_cron_scheduleandastrid_cron_cancelwere never implemented (stubs only).CronDefand[[cron]]removed from manifest. WIT spec updated: 49 host functions across 10 domain interfaces. -
Append-only artifact store —
bin/andwit/are never deleted on capsule remove. Content-addressed artifacts are the audit trail; deleting them breaks provability. Futureastrid gcfor 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.witto document all 51 host ABI functions (was 7). Split monolithichostinterface 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 to0.2.0.
Added
cargo install astridinstalls bothastrid(CLI) andastrid-daemonbinaries from a single crate. The CLI crate now includes the daemon as a second[[bin]]entry point.astrid self-updatecommand — 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 initPATH setup — detects shell (zsh/bash/fish), offers to append~/.astrid/binto 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 viahome://wit/ - Short-circuit interceptor chain — interceptors return
Continue,Final, orDenyto 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-basedsupersedesfield needed. - Interceptor priority —
priorityfield 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
.witfiles into~/.astrid/wit/, capsule remove cleans up unreferenced WIT files,wit_filesfield inmeta.json astrid capsule treecommand — 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 depsretained as hidden alias)astrid initwith 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--distroflag 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 removecommand with dependency safety checks — blocks removal if the capsule is the sole exporter of an interface that another capsule imports (--forceto override), cleans up content-addressed WASM binaries frombin/when no other capsule references the same hash- Install capsules from GitHub release WASM assets —
astrid capsule install @org/reponow downloads pre-built.wasmbinaries 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()andget_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 PrincipalIdtype for multi-principal (multi-user) deployments — each principal gets isolated capsules, KV, audit, tokens, and config underhome/{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}.logwith 7-day retention /tmpVFS mount backed byhome/{principal}/.local/tmp/for per-principal temp isolation- Multi-source capsule discovery with precedence: principal > workspace (dedup by name)
PrincipalHomestruct 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.principalfield for carrying the acting principal through event chains (transparent to capsules)AstridUserId.principalfield mapping platform identities toPrincipalIdwith auto-derivation from display name- Dynamic KV scoping via
invocation_kvonHostState— capsules callkv::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_callerhost 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.principalfield with length-delimited signing data encodingScopedKvStore::with_namespace()for creating scoped views sharing the same underlying storeAuditEntry::create_with_principal()builder for principal-tagged audit entrieslayout-versionsentinel inetc/for future migration supportlib/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_readnow uses a self-describingNetReadStatuswire 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 installnow prompts for[env]fields immediately after install - Shared
astrid_telemetry::log_config_from()behindconfigfeature flag — replaces duplicate config bridge code --snapshot-tuimode — renders the full TUI to stdout as ANSI-colored text frames using ratatui'sTestBackend. Each significant event (ready, input, tool call, approval, response) produces a frame dump. Configurable with--tui-widthand--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 usingcwd://paths at runtime received a security denial because the path resolved to<cwd>/cwd:/pathinstead of<cwd>/pathsandbox-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.responseinstead ofastrid.v1.approval.response.{request_id}) and used wrong decision string (allowinstead ofapprove) - `[[component]]....