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 key, binding the singleton Unix socket (acquiring the single-instance flock), writing the PID file, and generating the session token — so the kernel could only ever be constructed on a native host that supplies exactly those facilities. Resource acquisition is now inverted to the caller: a newKernel::with_resourcestakes an injectedKernelResourcesbundle (home, KV, audit log, runtime key, session token, socket listener, singleton lock) and performs all the side-effect-free wiring, whileKernel::newkeeps its exact signature and becomes the thin native composition root that acquires those resources and delegates. An alternate host (e.g. a browser WebAssembly build) can build its ownKernelResourcesand callwith_resourcesdirectly. The kernel also now imports its engine-agnostic capsule vocabulary (CapsuleId,CapsuleManifest,FuelLedger/FuelRateLimiter,MemoryLedger,CapsuleRuntimeLimits,HttpLimits,CapsuleError/CapsuleResult) directly fromastrid-capsule-typesrather than throughastrid-capsule's re-exports — the same types, so no behaviour change.Kernel.kvstays the concreteSurrealKvStorebecause the shutdown flush calls its inherentclose(), which is not on theKvStoretrait. No public signature changes; nativebuild/test/clippy/fmtare unaffected. Closes #1136.
Fixed
- The kernel now actually boots on
wasm32-unknown-unknown— the first wasm smoke boot ofKernel::with_resourcessurfaced three compiles-but-fails-at-runtime portability bugs, all fixed. The compile-only portability guard cannot see code that builds for wasm but panics the moment it runs, and the first real boot hit three in sequence. (1)CapabilityStorepresented a sync API over the asyncKvStorethrough a privateblock_onhelper that built a throwaway current-thread tokio runtime (whose time driver readsstd::time::Instant— an instant panic on wasm) or parked a scoped OS thread (wasm has neither); the persistence-touching methods (with_kv_store/with_persistence,add,get,find_capability/has_capability,revoke,use_token/mark_used/is_used,list_tokens,cleanup_expired) andCapabilityValidator::check/validate_by_idare now genuinelyasyncandblock_onis deleted — callers were already in async contexts. This is also required for the browser host regardless of the panic: an IndexedDB-backedKvStoreis natively async and can never be blocked on. The used-token set moves behind atokio::sync::RwLocksomark_usedcan keep holding the write guard across the persist — the guard-across-write is what closes the single-use-token TOCTOU replay window, and an async-aware lock preserves it legally across the.await. (2) The fuel-rate-limiter window stamps inastrid-capsule-typesand the IPC rate-limiter/route-queue stamps inastrid-eventsusedstd::time::Instantdirectly (FuelRateLimiter::default()panicked at kernel boot); they now useweb_time::Instant, which isstd::time::Instanton native (same type, zero change for native callers) and the JS performance clock on wasm — and the routed receiver's park-with-deadline goes through theastrid-runtimetimer facade instead oftokio::time::timeout. (3) The boot-timeINTERNAL_SUBSCRIBER_COUNTdebug assert is target-gated: the browser profile wires only the event dispatcher and the bus activity monitor (the router pair, connection tracker, and grant-on-first-use observer are native-gated machinery). With these,Kernel::with_resourcesboots on wasm under a JS event loop, the injected KV round-trips through the kernel handle, and the event bus delivers published events — verified by awasm-bindgen-testsmoke boot on node, which becomes the browser repo's regression harness for exactly this class of bug. This is a deliberate 0.x break: the listedastrid-capabilitiesmethods are nowasync. Native behaviour is unchanged. Closes #1152. - The
astrid-auditsurface no longer panics onwasm32-unknown-unknown— its storage layer is now genuinely async end-to-end. Same class as theCapabilityStoreboot panic (#1152):astrid-auditbridged its syncAuditStoragetrait to the asyncKvStorethrough a privateblock_onhelper whose no-runtime path built a current-thread tokio runtime withenable_all(), and that time driver readsstd::time::Instant— an instant panic on the browser profile.AuditLog::in_memoryconstructed fine, but everyappend/get/get_session_entries/count/verify_*/list_sessions/flushcall panicked, blocking the browser audit read path. TheAuditStoragetrait (viaasync_trait) and theAuditLogread/write methods are nowasyncandblock_onis deleted — the KV calls are awaited directly. The per-chain head cache moves behind atokio::sync::RwLocksoappendkeeps holding the write guard across the persist.await; that guard-across-write is what keeps read-prev-hash → sign → persist → advance-head atomic per chain, preserving the signed-ordering invariant that stops concurrent same-chain appends from forking the hash chain (thestdguard could not legally live across an.await). Chain linkage, ed25519 signing, entry ordering, and the fail-secure "audit failure = continue + alert" posture are all unchanged. The kernel boot (open_audit_log→verify_all), the admin-request audit path (record_admin_audit), the gateway historical-query handler, and the MCP secure client were already in async contexts and nowawait. The one remaining synchronous caller — the native-only wasmtime host-audit sink (HostAuditSink/KernelAuditSink) — keeps a native-gated block-on that runs inside the host fn's existingbounded_block_onblocking context (no new concurrency class, no OS thread spawn); the whole sink module is#[cfg]-gated offwasm32-unknown-unknown, so no sync-over-async remains reachable on the browser profile. This is a deliberate 0.x break: the listedAuditLogandAuditStoragemethods are nowasync. Native behaviour is unchanged. Closes #1154. - The event dispatcher no longer resolves the Astrid home from the process environment — running
cargo testcan no longer scaffold fixture principals into the developer's real~/.astrid. The dispatcher's per-principal auto-provision calledAstridHome::resolve()($ASTRID_HOME/$HOME) and wrote directory trees from library code at the point of use, so a workspace test run with noASTRID_HOMEisolation created over a thousand fixture principal homes (user-0…user-999,alice,bob, …) inside the operator's production Astrid home, alongside a live daemon. The home is now injected at construction: the kernel passes its already-booted home via a newwith_home(...)builder (mirroringwith_identity_store/with_access_resolver), tests pass a tempdir home and get filesystem isolation for free, and with no home injected auto-provisioning is disabled entirely (fail-closed) — the dispatch path never consults the environment and never writes to the filesystem. The provisioning logic moves into a focuseddispatcher::provisionmodule with the identity-store gating, retry-on-failure, and known-principal caching behaviour unchanged. Production behaviour is identical: the kernel's booted home is the same home the old call resolved. Closes #1145. - A stalled uplink client can no longer freeze the entire CLI proxy — the host's framed socket writes are now bounded by a deadline. The capsule-cli guest services every uplink connection from a single run loop and broadcasts subscription messages to all matching clients; that send path lands in the host's framed
write/write-bytes, which wrappedwrite_allin a concurrency-and-cancellation guard but bounded no duration. One connected client that stopped draining its socket (e.g. an orphanedastrid mcp servewhose output pipe had filled) let the kernel send buffer fill andwrite_allawait forever, freezing accepts, binds, and forwards for every uplink while the daemon otherwise stayed healthy — recoverable only viaastrid restart. Both host write paths now bound the whole operation — mutex-lock acquisition included, since a stuck write holds the per-stream lock and a second write would otherwise block unbounded on the lock itself — withtokio::time::timeout: an explicitwrite_timeouton a TCP slot where set, otherwise a payload-scaled ceiling (5000 + len/1024ms) that mirrors the framed payload-read bound. A non-draining peer now becomes a prompt write error, which the guest proxy already handles by evicting the client, so the run loop keeps serving everyone else. The Unix arm (which carries no per-slot timeout) always uses the default ceiling. Closes #1144. - A spawned daemon now detaches into its own session, so a spawner teardown can't kill it before it shuts down cleanly. The CLI spawned the daemon as a plain child process with no
setsid/process_group, so it inherited the spawner's session and process group. The daemon handlesSIGTERM/SIGINTgracefully but notSIGHUP(default disposition = terminate, no cleanup), so when the spawner's process group or session tore down — a smoke-test harness exiting (killpg), a controlling terminal closing (SIGHUP) — the daemon was killed without running its graceful shutdown: no shutdown log, no crash report, and stale run files (run/system.{sock,pid,ready,token}) left behind for the nextastrid startto trip on. It also made a persistentastrid startfrom a terminal a latent orphan that died on terminal close. Fixed defence in depth: (1) the daemon binary callssetsid(2)as the very first action inmain, before the async runtime or any boot work, becoming a session leader with no controlling terminal and immune to the spawner's SIGHUP/killpg— one change covering both the ephemeral and persistent spawn modes; (2) an unexpectedSIGHUPis now routed through the same clean shutdown path asSIGTERMinstead of the default terminate; and (3)astrid startself-heals a crashed daemon's stale run-dir — when the socket is unreachable and no recorded daemon PID is still alive (a crashed daemon), it clears all stale sentinels (socket, readiness, PID) and spawns a fresh daemon onto a clean run-dir, so recovery no longer needs arestart. It never kills or clobbers a live daemon: a reachable daemon takes the "already running" fast path, and a recorded PID that is still alive but unreachable (a daemon mid-boot still binding its socket, mid-shutdown, wedged, or a recycled PID) is left strictly alone with a pointer toastrid restart, which owns the identity-gated force-recycle. Distinct from theastrid stopwedge fix (#1120): that was a live daemon reporting success on ACK; this is a dead daemon killed by session teardown. Closes #1138. astrid stop/astrid restartno longer report success while a daemon wedges mid-shutdown and leaks the state-db lock. The graceful stop path treated the daemon's shutdown acknowledgement as proof it had exited: it printed "Astrid daemon stopped" and deleted the recorded PID file the moment the daemon ACK'd theShutdownrequest — before the process actually exited and released the singleton/state-db lock. A daemon that then wedged during its own shutdown sequence (a capsule run-loop that would not cancel, a hung audit-DB flush) kept the lock forever, but the PID file — the only handlerestart's orphan-killer needs — was already gone, sorestartskipped the kill and the nextstartdied on the held lock with a raw "Database … is already locked by another process". Stop now captures the recorded PID before requesting shutdown, and after the ACK waits for that process to actually exit; if it is still alive past the grace window it escalates through the same identity-gatedSIGTERM→SIGKILLpath used for an unreachable orphan. Runtime files (socket, readiness, PID) are removed only once the daemon is confirmed gone — when a kill cannot confirm exit (StillAlive/Unverified) they are kept sostart/restartstill find the recorded PID and print an actionable message instead of failing on the lock. The unreachable-daemon path is also hardened to sweep a wedged process whose socket has already vanished. Closes #1120.- The wedged-daemon identity gate now survives an in-place binary upgrade. Before signalling a stale daemon, the CLI confirms the live process's executable matches the path the daemon recorded at boot — a fail-secure guard against killing an unrelated process that recycled the PID. It resolved the live executable with
canonicalize, which fails withENOENTthe momentbrew upgrade/astrid updateunlinks or replaces the running binary, so a daemon left wedged by an upgrade could never be confirmed and never reaped (the exact scenario that surfaced #1120 during the 0.9.1→0.9.2 rollout). The gate now compares against the kernel's retained exec path (Linux/proc/<pid>/exewith its" (deleted)"marker stripped; macOSproc_pidpath) with an exact match, falling back tocanonicalizeonly while the file still exists — so a swapped-out daemon still matches its recorded path, while a recycled PID running a different binary still does not. Refs #1120. astrid update --checknow reports an available update on Homebrew and cargo installs, not only self-managed ones. The command returned inside its Homebrew branch — "installed via Homebrew, runbrew upgrade" — before it ever queried the release, so on a brew install (the common macOS case) it never learned whether an update actually existed, and the session-start doctor nudge that shellsastrid update --checkstayed silent for exactly those users. The check now runs first for every install method: it fetches the latest GitHub release and compares versions regardless of how Astrid was installed, then reportsUpdate available: vX → vY. Run <command>with the command appropriate to the install (brew upgrade astrid,cargo install astrid --force, orastrid update). Only applying an update still defers to an external package manager; a self-managed binary is still swapped in place. Install method is classified from the resolved binary path (InstallMethod::detect:Cellar→ Homebrew,.cargo/bin→ cargo, else self-managed), and the CLI's own startup banner uses the same classification so cargo installs get the right instruction too. Works on both macOS and Linux. Closes #1121.- A capsule tool that is advertised but has no execute route no longer fails silently — the daemon warns at load, naming the exact missing line. A tool is advertised straight from its
#[astrid::tool]annotation (the kernel'stool_describeprobe bypasses the subscribe ACL), but the dispatcher routes execute calls solely from the manifest's[subscribe]handlers. So a tool whoseCapsule.tomlis missing (or has a mistyped)tool.v1.execute.<name>subscription appears intools/listyet silently never runs: notool.v1.execute.<name>dispatch, no capsule-log entry, no error — the call is dropped because it matches no interceptor route. A capsule author lost real time to this (the model fell back to hand-computing the result). The kernel now cross-checks each advertised tool against the capsule's interceptor routes at load (inpublish_capsules_loaded, using the sametopic_matchesthe dispatcher uses, so wildcard subscriptions don't false-positive) and emits awarn!for any advertised-but-unroutable tool, naming the exact[subscribe]line to add. Pure diagnostic — no routing or gating behaviour changes. Closes #1127.
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