Skip to content

v0.9.4

Latest

Choose a tag to compare

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

Added

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

Fixed

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

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

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

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

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

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

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

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

  • The daemon now seeds the canonical contracts baseline at boot, so already-installed fleets get skew visibility without waiting for an install. #1164's canonical ~/.astrid/wit/astrid-contracts.wit was seeded first-writer-wins by the next capsule install, which left existing homes with two gaps: an upgraded rig saw no skew for its current fleet until something installed, and on a mixed fleet whichever capsule installed first defined the baseline — so a lone side-load landing first could make the healthy majority read as skewed and the one drifted capsule read as clean (inverted noise on a security-adjacent surface). The daemon is now authoritative for the canonical: at boot it rewrites wit/astrid-contracts.wit from its own system fleet — the contracts pin the plurality of the daemon's install_principal capsules agree on, dereferenced from the retained content-addressed store (wit/store/<pin>.wit), never from a per-principal home/<principal>/wit/ mirror (which is last-writer-wins and would reproduce the order dependence). Refresh is daemon-authoritative: absent canonical is written, a differing canonical is overwritten (a fleet or daemon upgrade legitimately moves the baseline, and skew is measured versus the running daemon), byte-identical is a no-op. The plurality rule makes the boot baseline order-independent — a single side-loaded capsule on a different pin can no longer flip it away from the majority fleet. Because the pin is read back from untrusted on-disk meta.json, it is validated as a BLAKE3 hex digest before it is ever used to build a wit/store/<pin>.wit lookup path, so a tampered pin (e.g. a ../ traversal sequence) cannot dereference a file outside the store into the canonical. Install-time seeding from #1164 stays as a bootstrap fallback for CLI-only flows where no daemon ever booted. The refresh is best-effort and warn-only: a write failure logs a warning and never breaks boot, and a fresh home (or one whose fleet predates pin retention) simply writes nothing. WIT blob backfill for pre-retention installs remains impossible by definition — this heals the canonical/baseline only. Closes #1165.

  • A git-managed workspace no longer diverts capsule writes into the in-process copy-on-write overlay. The workspace VFS was built as an OverlayVfs unconditionally at capsule load, so every capsule write landed in a per-capsule temporary upper directory instead of the real workspace — invisible to spawned cargo/tooling and to the user, with no commit path to reconcile it. When the workspace is inside a git work tree the overlay must not engage: git is already the rollback, and writes have to reach the real files that other processes read. Capsule load now detects git management with gitoxide work-tree discovery (gix-discover's upwards, which walks up from workspace_root for an enclosing repository exactly as git does — correctly following the .git file a submodule/linked worktree uses, validating the discovered .git, and stopping at filesystem boundaries) and, when a work tree is found, backs HostState.vfs with a direct HostVfs registered at workspace_root (no overlay, no upper tempdir — overlay_vfs/upper_dir stay None, exactly like the existing plain-HostVfs lifecycle-hook state). Detection is upward-only — git has no robust "contains a repo below" primitive, and the agent workspaces this guards are themselves git roots — and fails safe to the overlay when no repository is found. A non-git workspace keeps today's overlay behaviour unchanged; detection is automatic with no config flag. The OS-sandbox writable root, path confinement, and the security gate are untouched — direct writes still target workspace_root, already the sandbox's writable bind.

  • A non-git workspace now gets real OS-level copy-on-write, so spawned processes and the fs host share one filesystem. The in-process OverlayVfs (the non-git branch of the fix above) diverted capsule writes into a tempdir upper that only the fs host could see; a spawned cargo/git/build.rs opened the workspace through the OS and read the pristine lower, never the upper, so copy-on-write was invisible to exactly the tools that matter. Capsule load now backs a non-git workspace with a new astrid_vfs::workspace_cow mechanism — a WorkspaceCow trait with an APFS clonefile(2) backend (macOS), an overlayfs mount backend (Linux, native mount(2) when the daemon holds mount authority, else fuse-overlayfs), and a fail-closed No-CoW backend — that establishes a single real merged directory the principal writes to AND spawned processes run in. HostState.workspace_root (the fs-host confinement root, the security gate's root, and the OS-sandbox writable root + cwd for every spawn) becomes that merged_path, so a spawned cargo builds against the copy-on-write tree where the writes actually are; promote (commit the merged tree into the pristine workspace) and rollback (discard it) are the safety net, exposed as audited PromoteWorkspace/RollbackWorkspace kernel admin requests (capabilities self:workspace:promote / self:workspace:rollback) and torn down on capsule unload. The copy-on-write upper/pristine directories are masked from spawned children (a new extra_masks set threaded into the sandbox's hidden-path list) so a child cannot write them directly and bypass the promote/rollback gate. When the clone/mount cannot be established the backend fails closed to direct writes (No-CoW) with a warning naming the reason — never a silently faked copy-on-write. The APFS and No-CoW backends are runtime-tested; the Linux overlayfs mount is compile-checked with an #[ignore]d CI-validated integration test. Making the workspace per-principal is a separate follow-up.

Security

  • Bumped the locked crossbeam-epoch transitive dependency to 0.9.20, clearing RUSTSEC-2026-0204 (published 2026-07-06 — an invalid pointer dereference in the fmt::Pointer / pre-0.9 fmt::Display impls for Atomic and Shared when the underlying pointer is null or invalid) from the RustSec audit. The advisory landed the day after the 0.9.3 release and turned the Security Audit CI check red on main and every open PR; nothing in our own code changed. Lockfile-only, same-minor patch — no other dependency moved. Closes #1161.

Install

One command (installs or upgrades, wires host plugins):

curl -fsSL https://astridos.org/install.sh | sh

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
  • dependabot[bot]