Skip to content

v0.9.3

Choose a tag to compare

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

Added

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

Changed

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

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

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

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

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

  • The kernel's boot is now split into a portable composition root and a native one, groundwork for alternate host profiles. Kernel::new performed every platform-specific side-effect inline — resolving the Astrid home, opening the SurrealKV store and the audit log, loading the runtime signing 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 new Kernel::with_resources takes an injected KernelResources bundle (home, KV, audit log, runtime key, session token, socket listener, singleton lock) and performs all the side-effect-free wiring, while Kernel::new keeps 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 own KernelResources and call with_resources directly. The kernel also now imports its engine-agnostic capsule vocabulary (CapsuleId, CapsuleManifest, FuelLedger/FuelRateLimiter, MemoryLedger, CapsuleRuntimeLimits, HttpLimits, CapsuleError/CapsuleResult) directly from astrid-capsule-types rather than through astrid-capsule's re-exports — the same types, so no behaviour change. Kernel.kv stays the concrete SurrealKvStore because the shutdown flush calls its inherent close(), which is not on the KvStore trait. No public signature changes; native build/test/clippy/fmt are unaffected. Closes #1136.

Fixed

  • The kernel now actually boots on wasm32-unknown-unknown — the first wasm smoke boot of Kernel::with_resources surfaced 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) CapabilityStore presented a sync API over the async KvStore through a private block_on helper that built a throwaway current-thread tokio runtime (whose time driver reads std::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) and CapabilityValidator::check/validate_by_id are now genuinely async and block_on is deleted — callers were already in async contexts. This is also required for the browser host regardless of the panic: an IndexedDB-backed KvStore is natively async and can never be blocked on. The used-token set moves behind a tokio::sync::RwLock so mark_used can 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 in astrid-capsule-types and the IPC rate-limiter/route-queue stamps in astrid-events used std::time::Instant directly (FuelRateLimiter::default() panicked at kernel boot); they now use web_time::Instant, which is std::time::Instant on 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 the astrid-runtime timer facade instead of tokio::time::timeout. (3) The boot-time INTERNAL_SUBSCRIBER_COUNT debug 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_resources boots 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 a wasm-bindgen-test smoke 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 listed astrid-capabilities methods are now async. Native behaviour is unchanged. Closes #1152.
  • The astrid-audit surface no longer panics on wasm32-unknown-unknown — its storage layer is now genuinely async end-to-end. Same class as the CapabilityStore boot panic (#1152): astrid-audit bridged its sync AuditStorage trait to the async KvStore through a private block_on helper whose no-runtime path built a current-thread tokio runtime with enable_all(), and that time driver reads std::time::Instant — an instant panic on the browser profile. AuditLog::in_memory constructed fine, but every append/get/get_session_entries/count/verify_*/list_sessions/flush call panicked, blocking the browser audit read path. The AuditStorage trait (via async_trait) and the AuditLog read/write methods are now async and block_on is deleted — the KV calls are awaited directly. The per-chain head cache moves behind a tokio::sync::RwLock so append keeps 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 (the std guard 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_logverify_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 now await. 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 existing bounded_block_on blocking context (no new concurrency class, no OS thread spawn); the whole sink module is #[cfg]-gated off wasm32-unknown-unknown, so no sync-over-async remains reachable on the browser profile. This is a deliberate 0.x break: the listed AuditLog and AuditStorage methods are now async. Native behaviour is unchanged. Closes #1154.
  • The event dispatcher no longer resolves the Astrid home from the process environment — running cargo test can no longer scaffold fixture principals into the developer's real ~/.astrid. The dispatcher's per-principal auto-provision called AstridHome::resolve() ($ASTRID_HOME/$HOME) and wrote directory trees from library code at the point of use, so a workspace test run with no ASTRID_HOME isolation created over a thousand fixture principal homes (user-0user-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 new with_home(...) builder (mirroring with_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 focused dispatcher::provision module 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 wrapped write_all in a concurrency-and-cancellation guard but bounded no duration. One connected client that stopped draining its socket (e.g. an orphaned astrid mcp serve whose output pipe had filled) let the kernel send buffer fill and write_all await forever, freezing accepts, binds, and forwards for every uplink while the daemon otherwise stayed healthy — recoverable only via astrid 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 — with tokio::time::timeout: an explicit write_timeout on a TCP slot where set, otherwise a payload-scaled ceiling (5000 + len/1024 ms) 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 handles SIGTERM/SIGINT gracefully but not SIGHUP (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 next astrid start to trip on. It also made a persistent astrid start from a terminal a latent orphan that died on terminal close. Fixed defence in depth: (1) the daemon binary calls setsid(2) as the very first action in main, 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 unexpected SIGHUP is now routed through the same clean shutdown path as SIGTERM instead of the default terminate; and (3) astrid start self-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 a restart. 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 to astrid restart, which owns the identity-gated force-recycle. Distinct from the astrid stop wedge 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 restart no 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 the Shutdown request — 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 handle restart's orphan-killer needs — was already gone, so restart skipped the kill and the next start died 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-gated SIGTERMSIGKILL path 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 so start/restart still 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 with ENOENT the moment brew upgrade/astrid update unlinks 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>/exe with its " (deleted)" marker stripped; macOS proc_pidpath) with an exact match, falling back to canonicalize only 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 --check now reports an available update on Homebrew and cargo installs, not only self-managed ones. The command returned inside its Homebrew branch — "installed via Homebrew, run brew 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 shells astrid update --check stayed 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 reports Update available: vX → vY. Run <command> with the command appropriate to the install (brew upgrade astrid, cargo install astrid --force, or astrid 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's tool_describe probe bypasses the subscribe ACL), but the dispatcher routes execute calls solely from the manifest's [subscribe] handlers. So a tool whose Capsule.toml is missing (or has a mistyped) tool.v1.execute.<name> subscription appears in tools/list yet silently never runs: no tool.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 (in publish_capsules_loaded, using the same topic_matches the dispatcher uses, so wildcard subscriptions don't false-positive) and emits a warn! 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