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 waits, net/io/ipc waits) — so acapsule removewith a pending approval can no longer wedge the shared runtime for every other principal, while a full unload still cancels everything via the instance token cascade. Closes #1069; corrects #1083. - Closed the cross-principal host-state fallback that a shared runtime would otherwise open. A shared instance is loaded under no real principal's identity, so its load-time host state is a NEUTRAL, fail-closed placeholder: the
kvis a physically-isolated in-memory store and thesecret_storeis deny-all — neverdefault's (or anyone's) real KV, secrets, or home. Theeffective_kv/effective_secret_store/effective_home/effective_tmp/effective_capsule_logaccessors therefore fall back to that neutral placeholder for a principal-less / load-time invocation (which denies rather than exposing any principal's state), while EVERY caller that carries a principal — the owner/defaultincluded — gets its owninvocation_*overlay scoped to the invoking principal. A regression test constructs a shared instance and asserts abob-stamped invocation reads/writes bob's KV and secret namespaces, analice-stamped invocation lands in alice's, and a principal-less invocation hits only the neutral placeholder. Per-invocation env config is unaffected: it is read per call from the invoking principal's.config/env/{capsule}.env.json, not baked into the load-time instance. Relates to #977, #1069. - Made the shared capsule runtime present one stable, content-addressed IPC
source_idto every principal, and kept the gateway's reply-trust derivation byte-identical. The WASM engine'scapsule_uuidseed and the gateway'scapsule_source_id_v1both drop the principal segment (now{capsule_id}\0{content_hash}, same fixed namespace), so a shared runtime's replies carry one id all principals accept. Cross-principal reply routing is unaffected because it already keys on the principal-scoped routed subscription plus the body correlation id — thesource_idis only an authenticity gate. A forged reply (wrong capsule/hash) is still rejected, and a new lockstep test pins the two derivations together so they cannot drift. Relates to #1069.
Added
- Added a standalone
fuzz/package with initial structured fuzz targets for Tier 1 security predicates: IP block-set behavior, topic wildcard semantics, crypto wire encodings, capability resource patterns, and SSRF/local-egress helper predicates. The storage-heavy capability target and Wasmtime-heavy SSRF target are feature-gated so normal workspace builds stay unchanged. Closes #1086. - Runtime capsule commands can now use the existing
elicit()host API, not only install/upgrade lifecycle hooks. The same host-generated request id, principal-stamped request stream, same-principal response filter, bounded timeout, and cancellation path apply. Runtime E2E now proves a normal capsule command elicit ignores a wrong-principal response before accepting the caller's answer, and crash recovery now kills the daemon while an elicit waiter is in flight to prove the command fails bounded instead of hanging. Closes #1068. - Daemon capsule discovery now includes the configured workspace root's
.astrid/capsulesdirectory in addition to the default principal install directory, so capsules installed by local workspace flows are picked up when the daemon starts or reloads from that workspace. Closes #1068. - Added runtime e2e coverage gates for the built-in CLI, gateway HTTP routes, and first-party capsule commands, plus a dedicated fake OpenAI-compatible runtime harness and CI workflow that installs current capsule artifacts in an isolated
ASTRID_HOME. Closes #1068. DevicePubkey<T>now exposes the same additive string/serde trait surface as the adjacent device-key newtypes. The public typed wrapper for canonical ed25519 device public keys now supportsTryFrom<String>,FromStr, genericPartialEqcomparisons, and serde serialization/deserialization while preserving the existing string-shaped profile and management API structs. Closes #1056.- Registered and implemented
astrid:http@1.1.0host-side — the per-request control surface for the HTTP client — alongside the frozen@1.0.0. Both versions are now registered in the wasmtime linker and backed by ONE shared implementation, so existing capsules are untouched and new ones opt in package-by-package. A capsule importing@1.1.0gets caller-set controls via an all-optionalrequest-optionsrecord (an empty value reproduces@1.0.0exactly): four-phase timeouts (connect/first-byte/between-bytes/total), redirect policy (follow/error/manual) with mandatory per-hop SSRF re-validation, response and decompressed-body size caps, anauto-decompresstoggle,https-onlyscheme enforcement, and subresource integrity (sha256/sha384/sha512); plusresponse-meta(final URL after redirects, redirect count, elapsed ms, wire bytes) on every buffered response. The@1.0.0http-request/http-stream-startare thin shims that reproduce prior behaviour exactly (follow ≤10 redirects, 30 s timeout, 10 MB cap) and delegate to the same backend. Security: redirects on BOTH the buffered and streaming paths are now followed MANUALLY by the host, so every hop re-runs the full airlock — scheme check, the async per-capsule allow-list gate, and the egress airlock — withAuthorization/Cookiestripped on a cross-origin hop and IP-literal targets blocked. This closes a redirect-SSRF gap on the streaming path where the per-capsule gate previously ran only on the initial URL (the IP airlock already ran per hop).astrid:httpis the first host package to ship two versions, so this also threads the multi-version WIT staging (build.rskeys each stageddeps/dir byname@version) and the dual-trait registration. Deferred to follow-ups (honest stubs, not silent gaps): thehttp-uploadstreaming request body, responsetrailersextraction, and un-stubbing the stream pollable /body-stream. No@1.0.0shape change — it is frozen and untouched. Closes #1049. Part of #1012. - Operators can now tune the
astrid:httphost's seven previously-hardcoded limits via a new[http]config section, defaulting to today's exact constants so an absent section changes nothing. The host's outbound HTTP ceilings — buffered-path default total timeout (default_timeout_secs, 30), streaming connect timeout (stream_connect_timeout_secs, 30), per-chunk streaming read timeout (stream_read_timeout_secs, 120), time-to-first-byte header-deadline floor (header_deadline_secs, 120), max redirect hops (max_redirects, 10), per-capsule concurrent-stream cap (max_concurrent_streams, 4), and the buffered-body default/ceiling (max_response_bytes, 10 MiB) — were fixed module constants. Each is now an operator knob resolved once by the daemon from[http]into a typedHttpLimitsvalue (seconds pre-converted toDurationoff the request hot path), stored on the kernel, and snapshotted onto every pooledHostStateat load — a global value, the same for every capsule (it threads through theWasmEngine/loader like the runtime concurrency limits, not per-capsule like the local-egress allowlist). The request path reads its defaults and ceilings offHostState.http_limitsinstead of the constants: a per-requestrequest-optionsvalue may still tighten a limit (a shorter timeout, fewer redirects, a smaller body cap) but a configured ceiling can only lower the bound, never let a caller exceed it — andmax_response_bytesis additionally hard-clamped to the shared absoluteMAX_GUEST_PAYLOAD_LENhost payload limit, so config can lower but never raise it above the host hard cap. Operator config only: like[security.capsule_local_egress], a project/workspace config layer cannot set or widen[http](merge::restrictreverts the whole section to the operator baseline as a unit; regression-tested), so untrusted project config can never relax the host's HTTP limits. New unit + config-merge + host tests cover the defaults reproducing the constants, partial-section parsing, the operator-only merge guard,config showsurfacing the section, and that a non-default config actually changes behaviour (max_concurrent_streams = 2rejects the 3rd stream withQuota,default_timeout_secs = 5drives the resolved default total,max_redirects = 3clamps a larger caller value). No WIT / host-ABI change — kernel-internal threading + config only.
Changed
- Gateway-to-kernel requests now survive legitimately slow kernel operations instead of failing them with a spurious timeout, and a genuine timeout now returns
504 Gateway Timeoutrather than a generic500. The IPC response wait was a blanket 15-second total deadline: a heavy operation such as installing a capsule (unpack, load, then run the capsule's install lifecycle hook, which instantiates and executes WASM) could legitimately exceed it under load, and when it did the gateway mapped the transport timeout to an opaque500— the source of intermittent "capsule install expected HTTP 200, got 500" flakiness. The kernel now emits a lightweightWorkingkeepalive on a request's response channel every 5 seconds while a slow handler is still in flight (a fast operation finishes before the first keepalive and emits none, so no extra bus traffic on the common path), and every request-family uplink treats its timeout as an inactivity window — max silence between frames — that each keepalive resets, so the total wait is bounded only by how long the kernel keeps signalling liveness. An absolute 10-minute ceiling (checked against each read, so it cannot be overshot by a full inactivity window) still bounds a wedged-but-still-signalling kernel, and runaway guest execution remains bounded independently by the fuel ledger, the epoch-interrupt deadline, and the memory ledgers — the IPC timeout was never that guard. The kernel-request client paths now return a typedKernelClientError(preserving the underlying transport error as asourcerather than a flattened string): aTimeoutmaps to504(retryable, request may still be completing) while connection loss, bus shutdown, and decode failures stay500and a real install/authorization failure stays403, all unchanged. The keepalive is a liveness signal only: it is never a terminal response and is swallowed by the uplink, so it never reaches an HTTP client. Closes #1092. - Marked the public field-bearing structs and enums touched by the shared-by-hash capsule work
#[non_exhaustive]so future additive changes (a new field or variant) are non-breaking. Coversastrid-capsule'sHostState,NetStream,TcpStreamSlot,InterceptorHandle,PrincipalMount,ConnectionIdentity,Unregistered, andCapsuleRegistry, plusastrid-storage'sSecretStoreError,ScopedKvStore,KvSecretStore,FileSecretStore, and (under thekeychainfeature)KeychainSecretStore/FallbackSecretStore. To keepHostStateconstructible fromastrid-hooksafter the marker (a cross-crate struct literal no longer compiles), added aHostState::for_hook(HookHostStateParams)constructor inastrid-capsulethat fills every fail-closed hook default and takes only the pieces a hook varies; the hook handler now builds through it instead of naming ~50 fields.LifecyclePhaseis deliberately left exhaustive: its unit variants are constructed by name in other crates (astrid-capsule-install, integration tests), and#[non_exhaustive]would forbid that cross-crate construction with no in-scope fix. Relates to #1069. - Restored
HostState::principal_kv_namespace()as#[deprecated]rather than removed, with a corrected body. The method is superseded byeffective_kv()and returns only a namespace string, so it cannot express the fail-closed/neutral fallback a shared runtime uses for principal-less contexts. Its body now anchors the INVOKING principal (effective_principal(), falling back to the load owner only when no caller is in scope) instead of the raw load owner, so a stray caller gets the current principal's namespace rather than the misleading owner-only value the shared-runtime model made incorrect. Deprecating (not deleting) keeps the public API backward-compatible. Relates to #1069. - Device key identifiers now have typed core wrappers while preserving string-shaped public API and wire formats. Added generic
DeviceKeyId<T>andDevicePubkey<T>views for paired-device handles and canonical ed25519 public-key hex, then routed profile validation, lookup, pair-device redeem/revoke, and socket handshake verification through those typed paths. This removes duplicated normalization and makes key-id/pubkey mixups harder without changing serialized profile or management API structs. Closes #1047. - CI clippy checks updated to run with
--all-targetsand all pre-existing clippy lints resolved across test, example, and benchmark targets. Refactored test-only code to address dead code, field reassignments with default, misplaced imports, and excessively long functions workspace-wide. Closes #1004. - Added support for Copilot automated agents in CI checks. Added
Copilotto.github/contributors.ymlundermaintainersand bypassed the contributor account age check in.github/workflows/pr-checks.ymlfor automated agent PRs to allow successful workflow runs.
Docs
- Added
docs/models.md-- user guide for LLM model selection (astrid models list/current/set/unset), provider discovery (openai and openai-compat capsules), install-time onboarding, and the no-model error path. Relates to #961.
Security
-
Closed the cross-principal host-state bleed a shared capsule runtime opened, replacing the load-time owner fallback with a neutral fail-closed placeholder (corrects the earlier #1069/#1083 restoration). A content-addressed runtime is shared across every principal viewing the same WASM hash and is loaded under
PrincipalId::default().defaultis an ordinary principal that merely holds admin, so the previouseffective_kv/effective_secret_store/effective_home/effective_tmp/effective_capsule_logowner fallback meant a principal-less, owner, or overlay-construction-failed invocation resolved todefault's real KV namespace / secrets / home — an "anything can readdefault" bleed. Two live paths were exploitable: (1) the guest-pulledipc::recvpath never installedinvocation_secret_store, so a non-defaultpublisher'shas_secret/ secret reads fell through todefault's secrets; (2) both the interceptor and recv paths skipped installing an overlay when the caller equalled the load-owner, and the degrade path (overlay construction failing) left the overlayNoneand fell back to the owner. Fixes: the shared runtime's load-timekv/secret_store/home/tmp/capsule_logare now NEUTRAL placeholders holding no real principal's data —kvis a fresh, physically-isolated in-memory store on a reserved namespace (neutral.fail-closed:capsule, unproducible by any real principal since principal ids are[a-zA-Z0-9_-]),secret_storeis a new deny-allDenySecretStore(exists→false,get→None,set/delete→error), andhome/tmp/capsule_logareNone. A new separatekv_backendhandle carries the real shared backend used only to build per-invocation overlays. EVERY invocation carrying a present, parseable principal — the owner/defaultincluded — now installs its own{principal}:capsule:{id}overlay via one sharedinstall_principal_overlays*helper (used by both the interceptor and recv paths so they cannot drift); a caller with no parseable principal (system/lifecycle events:watchdog.tick,capsules_loaded) installs no overlay and resolves to the neutral placeholder. The degrade path (overlay construction failure for principal B) now fails closed to the neutral placeholder, never the owner. The registry additionally REJECTS registering a hash already loaded under a different owner (register_fornow errors, forcing cross-principal shares throughregister_existing), so no code path can create a shared instance owned by a real non-default principal whose fields would become a fallback. New tests pin: recv-path secret isolation (a non-defaultpublisher resolves its OWN secret store, notdefault's), no-cross-principal fallback / degrade to neutral (notdefault, not B-reads-another), every caller (owner included) gets its own explicit overlay, overlay writes land on the real backend while the neutral store stays empty, and the deny-all secret store. No WIT / host-ABI change — kernel-internal host-state + storage placeholder + registry guard only. Corrects #1083; closes #1069. Relates to #977. -
A shared failed capsule runtime is now restarted exactly once and rebuilt for every principal view, instead of firing N redundant restarts and leaving non-requesting principals with a dead view. The health monitor iterates
cloned_values_with_principal(), which yields one(principal, Arc)pair PER VIEW — so a failed shared runtime (issue #1069) with N views produced N failure entries and N restart attempts, and the restart tracker keyed byprincipal/capsule_idtracked N independent restart budgets for one instance. Failures are now deduplicated by capsule id (one restart per shared runtime) and the restart tracker keys by capsule id alone. Separately,restart_capsulefor a non-last view previously released only the requesting principal's view, left the still-failed runtime alive, and (because the hash was still loaded) merely re-attached that view viaregister_existing— a no-op that left a dead runtime. It now captures all viewing principals, tears the shared runtime down completely, then rebuilds it and re-attaches every view, so no principal is left pointing at a dead runtime. Kernel-internal only. Relates to #1069. -
Updated the locked
ammoniatransitive dependency to4.1.3, clearingRUSTSEC-2026-0193/GHSA-9jh8-v38h-cvhrfrom the RustSec audit. -
Hardened the public supply-chain evidence path for repository trust scanners. CI now commits the workspace
Cargo.lock, runs dependency automation through Dependabot, pins GitHub Actions to immutable SHAs, declares least-privilege workflow token permissions, adds CodeQL scanning and public API compatibility checks, verifies the lockfile before RustSec audit, reviews dependency changes on PRs, and signs plus attests release tarballs/checksums with keyless Sigstore/GitHub provenance bundles. -
Sensitive per-action capsule host calls (filesystem read/write/delete, outbound network connect/bind, process spawn) now land on the kernel's durable, signed, hash-chained audit log — allowed, failed, OR denied. Previously only kernel admin-API requests reached the chain; the fs/net/process host functions emitted off-by-default
tracingobservability lines (debug level) that never persisted, and a security-gate denial left no trace at all (the gate early-returned before any audit envelope). A new synchronousHostAuditSinkseam closes this: the WASM host engine — which has noastrid-auditdependency and no custody of the runtime signing key — reports a neutral, primitives-only event (FileRead/FileWrite/FileDelete/NetConnect/NetBind/ProcessSpawn×Allowed/Failed/Denied) to the sink, stamped with the host-resolvedeffective_principal(never guest-supplied data); the kernel implements the sink (it holds both the log and the key), maps the event onto a signedAuditAction, and appends it synchronously on the host fn's existing blocking thread. The append is NOT routed over the broadcast event bus (a lag-droppable record is not provable) and a persistence failure is logged and swallowed — audit degrades to "continue + alert", never panicking or blocking the host call (mirroring the admin-audit path). NewAuditAction::{NetConnect, NetBind, ProcessSpawn}variants (kernel-internal, not WIT). Denials are recorded exactly once: the gate records theDeniedevent before its early return, so the success-path envelope is never also reached. Producer-side (capsule) and consumer-side (kernel) tests pin the principal stamping, event/outcome mapping, theDenied→Failure+Denied-proof shape, and that the signed chain still verifies after high-frequency appends. Follow-ups (documented, not silent gaps):FileWritecontent-hash fidelity at this seam (a zero placeholder is recorded today), KV host-op gating+audit, reserved-namespace hardening of the live audit bus topic, and the chain-as-spine + content-addressed value-store sink optimization for high-volume workloads. Review hardening: (1) the per-chain append is now atomic —AuditLog::append_innerreads the chain head, signs the entry against it, persists, and advances the head all under thechain_headswrite lock, so two concurrent same-(session, principal)appends can no longer both read the same parent hash and fork the signed chain (which madeverify_chainreportvalid = falseunder ordinary concurrent host-call load); signing under the lock serializes same-chain appends, which is correct because the hash chain's order is the product. (2) Guest-controlled strings (path/host/addr/command) are truncated to 1024 bytes (UTF-8 char-boundary safe) at the kernel sink before signing and persisting, closing a disk/CPU amplification path where a zero-capability capsule drives unbounded audit growth by passing multi-megabyte arguments to gate-denied host calls. (3) The fs/net/process error arms that previously returned via?before any audit now record aFailed-outcome entry: a failed sandbox build orspawn/spawn-backgroundexec, a resource-table push failure after a child has already forked (spawn-background), and a resource-table push failure after a TCP connect already succeeded (connect-tcp) — so a fork or a fork-then-abort never leaves the chain with no trace. Regression tests cover concurrent same-chain append integrity, the sink-boundary truncation, and a gate denial driven through the publicconnect-tcphost fn. Refs #994. -
Made capsule-session's per-principal KV isolation an explicit, tested invariant (closes #977).
capsule-sessionkeys its store as the principal-lesssession.data.{id}, so cross-principal isolation comes entirely from the host KV namespace ({principal}:capsule:{capsule_id}), not from the capsule's keys — correct today, but previously implicit.HostState::effective_kvnow documents this contract and the exact boundary of its owner-store fallback (correct only when no caller is in scope or the caller is the owner), and new regression tests pin it: two principals writing the identicalsession.data.defaultkey resolve to different namespaces and never collide, the accessor prefers the per-invocation store and falls back only as specified, and the absent-principal in-scope case falls back to the owner store (the documented edge that holds safe because every producer of a principal-scoped topic stamps an authenticated principal). Thedebug_assert_invocation_field_setdoc now records why a host-wide fail-closed assert on the absent-principal case is intentionally NOT added: principal-less system/lifecycle handlers (capsule-react'sastrid.v1.watchdog.tick, capsule-registry'sastrid.v1.capsules_loaded) legitimately fall back to the owner/global store, so such an assert would fire on sound paths. No WIT / host-ABI change — docs + tests only. Closes #977. -
A capsule can now reach a loopback/private LLM endpoint (LM Studio on
127.0.0.1:1234, Ollama, a LAN IP) through operator consent instead of a hand-editedconfig.toml, without weakening the SSRF airlock (closes #1028). Two composed mechanisms ship together. (A) A host-stamped, capsule-unforgeable transport-origin marker plus runtime elicitation. A new host-onlyorigin: MessageOrigin { System | LocalSocket | RemoteGateway }field on the kernel-internalIpcMessagerecords where a request entered the system — stamped exactly likedevice_key_id(host-derived internal-bus metadata, no WIT change; the guest-facingrecord ipc-messageis unchanged andto_wit_message()keeps dropping it, so it stays invisible to capsules). The kernel stampsLocalSocketonly for a handshake-bound Unix-socket connection (an unbound/unauthenticated local connection staysSystem— fail-closed, non-local — parallel to theanonymousprincipal),RemoteGatewayat the gateway bus-publish sites, and propagates the originating request's origin through guest publish/fan-out andpublish-as— so aRemoteGatewayrequest flowing through a fan-out capsule (react → openai-compat) cannot be elevated toLocalSocket. Absent/unknown/legacy/CLI-proxy-wire frames decode toSystemvia#[serde(default)]+#[serde(other)]. At the airlock-rejected egress arm for an IP-literal local endpoint,astrid:httpnow consults transport origin: aLocalSocketrequest that is not already pre-blessed/granted elicits one-shot operator consent via the existingrequest_approvalprimitive (ApprovalRequiredonastrid.v1.approval, resource =host:portonly); on approve/approve_session it caches a per-principal, per-capsule session grant (AllowanceStore; the grant carries the requestingcapsule_id, so neither principal A's grant exempts principal B nor capsule A's grant exempts capsule B reaching the samehost:port), and on approve_always it also persists to that principal's capsule-keyedprofile.network.capsule_egress[<capsule>]map on disk (a flat per-principal list would have let a remembered grant for one capsule silently exempt every other capsule that principal runs). Because the in-memoryAllowanceStorestarts empty on every daemon boot, the egress fast path also consults that persistedcapsule_egressmap for the effective principal and current capsule before eliciting — so anapprove_alwaysgrant actually survives a restart (the "remember across restarts" contract) instead of being silently forgotten and re-prompted. That persisted consult preserves both isolation axes (it reads only this principal's profile and only this capsule's bucket, so a remembered grant for capsule A / principal X never permits capsule B / principal Y) and is reached only after the upstream origin gate and SSRF airlock, so it short-circuits the elicitation, never the origin/airlock checks. Any non-LocalSocketorigin (System/RemoteGateway/unbound) never elicits and fails closed, so a remote gateway caller driving the same react/openai-compat egress path can neither see nor answer a local-egress prompt. A consent-granted endpoint re-enters the exempt path, refusing redirects identically to a pre-blessed one. The consent round-trip reuses the sharedastrid.v1.approval[.response.*]channel rather than a distinct egress namespace — a documented, low-severity residual (only the trusted operator uplink and MCP broker hold publish rights on that response topic; an arbitrary untrusted capsule cannot), deferred because a distinct namespace would require an out-of-repo operator-surface publish-ACL change in lockstep. V1 covers IP-literal local endpoints (127.0.0.1,[::1],192.168.x,10.x,169.254.x); hostname endpoints (localhost:1234) still require a pre-bless. (B) A CLI guided pre-bless.astrid init/astrid capsule config/ capsule install now detect when an entered providerbase_urlpoints at a loopback/private/link-local address and prompt the operator ([y/N]) to add the[security.capsule_local_egress]exemption keyed by capsule id — the operator is unambiguously local by construction in the CLI process, so this is guided pre-bless, not runtime elicitation. Free-text/non-resolving hosts are skipped. No WIT / host-ABI change — the marker is host-only, threaded through kernel-internal IPC, gateway routes, and CLI config. New unit + regression tests cover the origin serde floor, bound/unbound socket stamping, fan-out origin propagation (no elevation), pool-reset clearing, the per-principal/per-capsule consent fast path / elicit / approve-always disk persist, per-principal AND per-capsule grant isolation (areactgrant does not exemptopenai-compatreaching the same endpoint — in-memory session grant AND persistedcapsule_egress), the persisted-grant fast path surviving an empty store on a fresh daemon while still eliciting for the wrong capsule or wrong principal, the fail-closed non-local paths,is_local_addressdetection, and the operator-config write. Hardening: the global SSRF escape hatch is no longer a bypass of the new consent gate.ASTRID_TEST_ALLOW_LOCAL_IPis now gated behindcfg(test)— the daemon's release binary never even reads it, so a stray test env var in production can no longer disable the airlock (and with it the consent gate) for every capsule.ASTRID_ALLOW_LOCAL_IPSstays recognized in production for operators/CI but is now deprecated, emitting a one-time warning that is honest about its blast radius (it disables the airlock for ALL capsules AND exposes loopback/private endpoints to REMOTERemoteGatewayAPI callers, bypassing the per-capsule consent gate) and directing operators to the per-capsule[security.capsule_local_egress]allowlist; it will be removed in a future release. A new end-to-end regression test locks the anti-forge propagation invariant: aRemoteGateway-stamped request driven through the real fan-out hop chain (caller_context origin →publish→publish_inner→ republished bus message → downstream caller_context) is observed ASRemoteGatewayat the egress site andconsent_local_egressDECLINES — it can never be reset toSystem/LocalSocketacross a republish. Revocation caveat (documented, no behaviour change): neither exemption source is revoked live — the operator[security.capsule_local_egress]allowlist is a load-time snapshot and a persistedapprove_alwaysgrant lives in a process-lifetime profile cache with no TTL/watcher — so removing an entry or grant only takes effect after a daemon restart. This caveat is now stated at the consent gate (module doc) and printed by the CLI add-path so an operator editing config to revoke is not falsely reassured (a live-revocation verb is tracked separately). Closes #1028. Relates to #961, #1026; references the airlock work #965. -
The host
elicit()waiter now enforces that an answer comes from the same principal the input is being collected for, closing a cross-principal elicit hijack. A capsule's lifecycleelicit()mints arequest_idand the prompt SSE stream forwards it verbatim to every connected client, so any authenticated caller who observed an in-flightrequest_idcould publish anElicitResponseon the derived reply topic and answer — or cancel — another principal's elicit (now also reachable from the newPOST /api/agent/elicit-responseendpoint). The waiter previously keyed only on the reply-topic suffix and the payload variant, never on who sent the reply. It now stamps the originating principal on the outgoingElicitRequestand only unblocks on a reply carrying a matching principal; a mismatched (or unstamped) reply is logged and rejected, and the wait continues on its original deadline so a flood of forged replies cannot extend or DoS the legitimate one. The in-process CLI install answerer now echoes the request's principal back on its reply so interactiveastrid capsule installonboarding keeps working; socket-driven answerers (TUI, chat) already carry the host-verified principal via the uplink'spublish-asself-stamp. -
The
astrid:httpSSRF airlock now covers IP-literal request URLs and redirect hops, and surfaces the typedairlock-rejectederror instead of a generic connection failure. The host'sSafeDnsResolveris consulted by reqwest only for hostnames, so a request whose authority is already an IP literal (http://127.0.0.1:1234,http://[::1],http://192.168.x) connected straight through with the airlock never running — a capsule holdingnet = ["*"](e.g.openai-compat) could reach loopback/private/metadata addresses over HTTP. The same gap let a public, allow-listed host bounce a capsule onto an internal service via a302 Location:pointing at an IP literal (a redirect-SSRF that returned the internal response body). Bothhttp_requestandhttp_stream_startnow (a) run a pre-flight airlock on any IP-literal request URL before issuing it, and (b) install an airlock redirect policy that re-checks every hop, refusing IP-literal targets that fail the airlock and capping the hop count; hostname targets remain airlocked at DNS-resolution time. When the resolver or the redirect policy blocks a request, the error is now mapped to the existingastrid:http@1.0.0airlock-rejectedvariant (previously a blocked hostname surfaced as an opaqueconnection-error, indistinguishable from "server down"), so a capsule can tell an SSRF rejection from a real connection failure. Separately,is_safe_ipnow decodes IPv6 transition/translation forms that embed an IPv4 address — the NAT64 well-known prefix (64:ff9b::/96), 6to4 (2002::/16), and Teredo (2001:0::/32, server + obfuscated client) — and rejects any embedding a private/loopback/metadata IPv4 (e.g.64:ff9b::a9fe:a9fe→ 169.254.169.254), plus deprecated site-local (fec0::/10); transition addresses embedding a public IPv4 are unaffected. Thenet.connect-tcppath already airlocked resolved literals and is unchanged. The process-wideASTRID_ALLOW_LOCAL_IPS/ASTRID_TEST_ALLOW_LOCAL_IPescape hatch still disables the airlock for all capsules (unchanged); a sanctioned per-capsule local-endpoint allowance remains open work on #965. Eleven new unit tests pin the literal pre-flight, the redirect classification, the resolver trip flag, and the IPv6 embedding cases. No WIT / host-ABI change — theairlock-rejectedvariant already existed. Closes #1020. Refs #965 (the local-LLM allowance follow-up). -
Operators can now sanction a specific capsule reaching a loopback/private LLM endpoint via a per-capsule local-egress allowlist, instead of the all-or-nothing global SSRF bypass (closes #965). A new operator config map
[security.capsule_local_egress]keys a capsule id to a list ofhost:port/host:*endpoints that are exempt from theastrid:httpSSRF airlock for that capsule only — the sanctioned way to point e.g.openai-compatat LM Studio on127.0.0.1:1234or Ollama on a LAN address. Previously the only escape hatch wasASTRID_ALLOW_LOCAL_IPS, which disables SSRF protection for every loaded capsule. The allowlist is resolved once by the daemon from config, stored on the kernel, and each capsule is handed its own slice at load (snapshotted onto every pooledHostState, likecapability_names). The exemption is decided at pre-flight, where the request's host and port are known, and the matched hostname is propagated toSafeDnsResolveras a scopedexempt_hostso a hostname endpoint (localhost:1234) resolves through to its loopback address while staying port-specific (localhost:9999is still airlocked) and host-scoped (redirect hops to other hostnames are still airlocked). This is operator config: a capsule's own (untrusted)Capsule.tomlcannot set it (mirrors theEnvScopeuntrusted-manifest invariant), and a project/workspace config layer cannot introduce or widen it (merge::restrictblock_workspace_override, with regression tests). It only exempts endpoints from the airlock; it never grants network access the capsule's manifestnetallowlist doesn't already declare, and an empty/absent entry is fully fail-closed. Scope is the HTTP egress surface (http_request+http_stream_start); thenet.connect-tcppath is unchanged and a parallel allowance there is a follow-up. Reuses the manifestnet_connecthost:portmatcher so semantics are identical. New unit + config-merge regression tests cover host:port matching, port-specificity, the operator-only merge guard, and the resolver exempt-host behaviour. No WIT / host-ABI change — kernel-internal threading + config only. Closes #965.
Added
-
The HTTP gateway can now expose a principal's conversation threads over two authenticated routes:
GET /api/agent/sessions(paginated thread list with metadata) andGET /api/agent/sessions/{id}/messages(one thread's full transcript). Previously the only agent-facing route wasPOST /api/agent/prompt(the SSE invocation stream); a dashboard or automation client had no way to enumerate a principal's past threads or read a transcript back. Both routes are a thin proxy to thecapsule-sessioncapsule over the in-process event bus, mirroring the request/reply-over-bus shapebus_admin.rsuses for kernel admin ops but targeting capsule topics: the gateway generates a freshcorrelation_id, subscribes FIRST to a per-correlation scoped response topic (session.v1.response.{verb}.<correlation_id>— no request-id filtering needed, the scoped topic isolates the reply, with a defensivecorrelation_idre-check on the body), publishes the principal-stamped request (session.v1.request.list/ the existingsession.v1.request.get_messagesverb), and awaits one reply with a 15s timeout. The principal stamp is the only authority: the caller's verifiedCallerContext.principal(plusdevice_key_idwhen the bearer is device-scoped) is stamped on every outbound request, the kernel scopescapsule-session's KV reads to that principal's namespace, and the path{id}/ query params are payload data only — they never substitute for the principal and never reach a topic segment, so a maliciousidcan neither cross into another principal's threads nor hijack another request's reply.listtakeslimit(default 50, max 200; over-cap → 400), an opaquecursor, andinclude_archived(default false) and returns{ sessions, next_cursor, total }(total= the principal's thread count when cheaply known, elsenull); transcript validatesid(non-empty, ≤256 chars, no control chars → 400) and returns{ session_id, messages }withmessagespassed through as opaque JSON. A non-existent thread returns an emptymessageslist (not a 404 — the capsule cannot distinguish "never existed" from "empty", so empty avoids leaking thread existence). A closed bus → 500; a capsule that doesn't reply in time → 502 (timeout, distinguishable from a gateway fault). To support a mixed 1.0/1.1 fleet,listfirst probes the loaded capsule set — via an in-process, capability-freeCapsuleTopicProbe(a live-registry read, deliberately not the capability-gatedGetCapsuleMetadata, which would 403 ordinary callers and leak the capsule inventory) — for a handler ofsession.v1.request.listand returns an immediate 501 Not Implemented when no session capsule implements the 1.1listverb — rather than hanging to the bus timeout on a verb nobody handles; the transcript route needs no gate (itsget_messagesverb exists in session 1.0). Both routes are registered in the OpenAPI doc and each increments a count-only metric (no principal/session id in any label). The gateway adds no WIT or IPC-contract change itself (transport only); it consumes the newsession.v1.request.listverb defined by theastrid-bus:session1.1 contract, shipped separately in the wit + capsule-session changes. Closes #972. -
The HTTP gateway now exposes full conversation-thread management plus a per-principal live conversation feed, completing the dashboard/multi-device surface over
capsule-session. Four new thread routes —GET /api/agent/sessions/{id}(one thread's metadata; 404 when the thread is not in the caller's namespace),PATCH /api/agent/sessions/{id}(updatetitle/archived/meta),DELETE /api/agent/sessions/{id}({ deleted: bool }), andGET /api/agent/sessions/search?q=&limit=&cursor=&include_archived=(full-text search,qrequired → 400 on empty,limitdefault 20/max 100) — proxy to thecapsule-sessionget_meta/update/delete/search1.1 verbs over the same subscribe-first, per-correlation-scoped, principal-stamped request/reply-over-bus primitive the list/transcript routes use. PATCH is by-presence: the gateway reads the body as a raw JSON object and forwards only the keys the client actually sent (a minimal patch ofcorrelation_id+session_id+ whichever oftitle/archived/metawere present), so an absent key leaves the field unchanged, a present key sets it, and""clears it; unknown body keys are dropped andsession_idis always the gateway's path value (never the body), so a client cannot smuggle another field into the capsule's update.update/delete/searchare gated on a 1.1 session capsule (ensure_list_supported→ 501 when none is loaded);deletedeliberately does not 404 ondeleted:false(the principal-scoped delete is idempotent). TheSessionSummaryschema gains the frozentitle/last_message_preview/archived/metafields.GET /api/agent/stream(#973) is the new per-principal live SSE feed — the multi-device sync channel: it opens two routed subscriptions, both scoped toSome(Some(caller.principal))—agent.v1.*(in-flight turn events) andsession.v1.event.*(thread lifecycle created/updated/deleted) — and merges them into one stream (event: agent/event: session_event, each wrapping{ topic, data }so a client can disambiguate sub-kinds), emitting an initialreadyevent with a 15s keep-alive and a 1-hour connection cap (clients reconnect). Cross-principal isolation is the load-bearing #973 property and is enforced structurally at the bus: the innerSome(principal)scope drops any foreign-principal event at enqueue, so a second device only ever sees its own principal's conversation — a co-resident (buggy or hostile) publisher on those topics cannot leak another principal's threads onto this feed. The feed is not session-scoped (it fans all of the principal's threads) and is not 501-gated (it gracefully carries whatever is published;agent.v1.*exists in session 1.0). All new routes are registered in the OpenAPI doc, principal-stamp every outbound request (the stamp is the only authority —{id}/query/body never substitute for it), and each increments a count-only metric (astrid_gateway_agent_sessions_{get_meta,update,delete,search}_total,astrid_gateway_agent_stream_total; no principal/session id in any label). Transport only — no WIT change, no IPC-contract change, no RFC; consumes theastrid-bus:session1.1 verbs and thesession.v1.event.*lifecycle events shipped separately. Closes #973, refs #972. -
Headless and offline distro seeding:
astrid initgains a non-interactive--yespath and a signed, self-contained.shuttlebundle, so a distro (including an LLM provider) can be seeded in a Docker build or an air-gapped environment with no stdin prompts and no GitHub access at build time. Previouslyinitprompted on stdin (select_capsules/collect_variables) and fetchedDistro.tomlplus every capsule from GitHub, so it could not run in a container build.--yestakes group defaults (a newdefault = truemarker on a distro capsule) and resolves variables from--var KEY=VALUE>ASTRID_VAR_<KEY>> the manifest default (hard error if a required one is unset; secrets never logged), with--offline/--allow-unsigned/--accept-new-keyalongside.astrid distro seal --key <ed25519>produces a deterministic.shuttle(gzipped tar ofDistro.toml+ resolvedDistro.lock+Distro.sig+ pre-builtcapsules/<name>.capsule);astrid init ./x.shuttleunpacks it (traversal/symlink/size-hardened), verifies the ed25519 signature against a per-distro TOFU-pinned key (~/.astrid/trust/<id>.pub, with a compiled-inOFFICIAL_KEYSpre-pin and a hard fail on an unexpected key change), enforces themanifest_hashbinding that tiesDistro.tomlto the signed lock (mandatory when signed), checks every capsule's BLAKE3 against the lock, and installs entirely offline. Capsule version pinning is now real:version/tagresolve to a concrete GitHub release tag (replacing the prior unconditionalreleases/latestthat ignored the pin and silently builtHEAD), the lock records the actually-resolved ref, and a pinned-but-unresolvable version is a hard error rather than a HEAD build; distros are release-only (branch/revare rejected in a manifest — git-ref builds remain available viaastrid capsule install). Addsastrid keypair pubkey <name> --format wire(emitsed25519:<base64>for[distro.signing]) and adocs/distro-signing.mdrunbook. Closes #964. -
astrid initnow multi-selects LLM providers and, for each, collects credentials and a default model picked from a live list — instead of typing a model id blind into one free-text field. Onboarding previously framed the model as a single free-text shared[variables]entry, so the operator had to already know a valid model id for their provider and type it exactly. Two additions close that: (1) a capsule[env]field may now declareoptions-from = { http, bearer, select, after }, marking it a dynamic SELECT whose option list is fetched live at prompt time — the native installer (which has network; the sandboxed capsule does not) substitutes already-entered values into thehttp/bearertemplates, performsGET <http>(withAuthorization: Bearer <bearer>attached only when the token is non-empty after trim and the resolved fetch URL has the same scheme, host, and port as the user-configured providerbase_urlthe template is built from — so a capsule cannot pointhttpat an attacker host (nor a cleartexthttp://downgrade of anhttps://base_url, even on the same explicit port 443) and exfiltrate the user's key; on a mismatch the bearer is withheld and a warning logged, and the unauthenticated request typically 401s into the free-text fallback), streams the response body under a hard 5 MB cap that aborts the transfer the moment the running total crosses the limit (generous for large aggregator/v1/modelscatalogs, still bounds the OOM vector even for a chunked / unknown-Content-Lengthresponse that a header-only check could not; an over-limit body errors into the same fallback), and parses the standard OpenAIdata[].idshape into a deduped, server-ordered list; theafterkeys are collected first so the URL/credential placeholders resolve. Any discovery failure (network error, non-2xx, non-JSON, empty data) degrades gracefully to a free-text prompt with a hint — a discovery miss never aborts the install. (2) During distro install thegroup = "llm"capsules are presented as a provider multi-select ("Which LLM provider(s) do you want to set up?"), and each selected provider then runs its own[env]schema prompt after install so capsule-specific fields (api_key,base_url) and the dynamicmodelselect resolve from the installed manifest; shared[variables]still template genuinely-cross-capsule values, and the prompt skips already-set keys. The end state is that every selected provider is installed and configured (creds + chosen default model), so the model registry spans them all. Manifest-schema + installer code only — no WIT change, no RFC. The discovery HTTP call reuses the CLI's existingreqwestdependency. Closes #1015. -
New
POST /api/agent/elicit-responsegateway endpoint lets an HTTP client answer an agent's follow-up input request. The agent-prompt SSE stream already forwardedastrid.v1.elicitevents (a capsule asking for follow-up input during, e.g., an agent-driven capsule install or onboarding), but there was no way for the HTTP client to send the answer back — so the capsule's host-sideelicit()waiter blocked the full 120s timeout and the prompt SSE waited out its five-minute upper bound. The new endpoint publishes the answer onto the per-request reply topic the host waiter is subscribed to and returns202 Acceptedimmediately; the agent's continuation then streams back over the still-open prompt SSE connection. The body carries therequest_idfrom the forwardedelicitevent plusvalue/values(omit both to cancel, matching the host cancel sentinel). The reply is stamped with the caller's bearer-verified principal, which the host waiter checks against the principal the elicit was collected for. -
Agent-loop readiness introspection: a new
GET /api/sys/readinessroute, anastrid doctorcheck, and a boot warning surface whether the installed capsule set can actually serve an agent chat turn — andPOST /api/agent/promptnow returns an immediate error instead of timing out when the loop is unconfigured. Readiness is computed name-agnostically from the loaded capsule manifests (no capsule name is hardcoded): some capsule's[subscribe]must matchuser.v1.prompt, some capsule's[publish]must matchagent.v1.response, and every required (non-optional)[imports]entry must be satisfied by another loaded capsule's[exports]. Previously a fresh daemon — which hard-requires only the socket uplink — booted clean yet silently dropped every prompt: the publish no-oped, the client gotready+ keep-alives + an empty close after a five-minute timeout, with no way to know what was missing. The sharedastrid_capsule::readinessprimitive is the single source of truth (the boot validator's required-import check now delegates to it); a newKernelRequest::GetAgentReadinessexposes the detailed view (gatedcapsule:list) for the route andastrid doctor. ThePOST /api/agent/promptfail-fast instead reads an in-process readiness probe the daemon wires from the live registry — agent-loop serviceability is global daemon health, not per-principal authorization — so the fast-fail fires for every authenticated prompt caller (single- and multi-tenant alike), with no capability check and no socket round-trip, while the inventory-revealing detail stays behind the gated route. The boot warning is computed from the loaded registry after load completes (not the pre-load discovered set), so a manifest that fails to load is not mistaken for a working capability. -
The HTTP gateway can now list and bind a principal's active LLM model over three thin, per-principal-scoped routes — closing the gap where the only model-selection surface was the TUI's interactive
/modelspicker. A script, remote dashboard, or automation client talking to the gateway had no route to discover the available models for a principal or bind one; the route table wired principals/caps/quotas/groups/invites/capsules/env/audit/agent/system but nothing for the model registry. New routes:GET /api/models(list the caller's available provider-entries),GET /api/models/active(the bound model, or JSONnullwhen nothing is bound —nullis a valid 200, not an error), andPUT /api/models/activewith{ "id": "<capsule>:<model>" }(bind by id; returns the persisted active entry on success, 400 surfacing the registry'sunknown model: …/ ambiguity message verbatim on a bad id, and 400 on an emptyidbefore the bus is even touched). The gateway is a thin transport: it publishes the existing registry IPC (registry.v1.{get_providers,get_active_model,set_active_model}) stamped with the caller's verified principal, awaits the reply on a principal-scoped routed subscription (subscribe_topic_routed_scoped(.., Some(Some(principal)))), and relays the registry's own success/error unchanged — it never resolves ids, enumerates models, or persists selection (that stays the registry capsule's job). Per-principal isolation is the core security property and is enforced structurally: the principal is the verifiedCallerContext.principalfrom the signed bearer (never a body/query field), the request is stamped with it (routing it into the caller's registry KV scope and making it the value the reply is matched against), and the reply is awaited on a route scoped to that principal so a foreign-principal reply is dropped at enqueue — a co-resident principal can neither read nor set another's active model. The three handlers and theSetActiveModelRequestschema are registered in the OpenAPI doc (GET /api/openapi.jsonlists/api/modelsand/api/models/active). A per-request model override onPOST /api/agent/promptis deliberately excluded (no capsule consumes a per-promptmodeltoday; shipping it would forward a silently-dropped value). No WIT change, no IPC contract change, no RFC. Closes #1009. -
Capsule CLI verbs are now reachable at the root:
astrid <verb> [args…]resolves the same daemon-backed capsule verb asastrid capsule <verb>, with a typo guard that preserves clap's "did you mean …?" hint for mistyped built-ins. Previously the only way to invoke a capsule-contributedkind = "cli"verb was the namespacedastrid capsule <verb>; the rootCommandsenum had no external-subcommand catch-all, so an unrecognised first token was a clap parse error. A capsule verb (e.g.identity-export) can now be run directly asastrid identity-export …, the same shorthandgh/git/kubectlgive their plugin verbs, whileastrid capsule <verb>stays the canonical, unshadowable form. The shorthand is a second entry point into the unchanged resolution path (commands::capsule_verb::run_external): same provider resolution, samecli.v1.command.run.<provider>dispatch, same per-principal RBAC scoping, same exit-code mapping — it adds no new authority. Built-in root verbs always win because clap matches every declaredCommandsvariant before theexternal_subcommandcatch-all, so a capsule can never shadowstatus,mcp serve,version, etc. Adding the catch-all intentionally disables clap's built-in "did you mean …?" engine for root tokens, so a new pure typo guard restores it:commands::verb_suggest::nearest_builtin(Levenshtein≤ max(2, len/3), exact matches and non-near-misses excluded, ties broken alphabetically for deterministic output) flags an unrecognised token that is a near-miss of a built-in (astrid statuss→ "did you mean 'status'?") and exits2without booting the daemon — so a typo storm or a tab-completion misfire cannot repeatedly auto-start the daemon, and a capsule cannot register a near-miss verb to harvest fat-fingered admin commands. Only a token that is not a near-miss of any built-in (astrid identity-export,astrid models) is forwarded to capsule resolution. The known residual tradeoff: a future core built-in added toCommandswill begin shadowing a capsule's root shorthand of the same name — acceptable becauseastrid capsule <verb>remains the stable, unshadowable fallback. No change toRESERVED_CAPSULE_VERBS, no WIT change, no IPC contract change, no RFC. Closes #1008. -
Pair-device tokens now carry a per-device capability scope, so a device added to an existing principal can hold a subset of the principal's capabilities instead of its full authority — and the scope is enforced on BOTH auth transports. Previously
AuthConfig.public_keyswas a flatVec<String>of ed25519 keys, every paired device was capability-identical to the principal, and a guest/use-only device could not be expressed; the only revocation granularity was whole-principalagent delete/disable. Each entry is now aDeviceKey { key_id, pubkey, scope, label, created_at }carrying aDeviceScopeofFull(no attenuation) orScoped { allow, deny }(deny wins, same grammar as grants/revokes). Legacy bareed25519:<hex>strings migrate transparently toFull(no behaviour change for existing principals). At pair-device issue the operator supplies the scope (--scope use-only|full, or explicit--allow/--deny, or a friendly HTTPscope/allow/denybody); the kernel validatesrequested ⊆ issuer effective capsagainst the issuer's attenuated effective set, a scoped child inherits the issuer's denies (monotonic narrowing), and minting an unattenuatedFulldevice requires the issuer to itself be unattenuated AND hold the newself:auth:pair:admincapability. The authenticating device'skey_idreaches the kernel cap-gate tamper-evidently on both paths — host-derived from the per-connection registry for the socket/CLI keypair handshake (it rides the existingingress/publish-asenforcement, no WIT change), and bound inside a signed 5-segment bearer for the gateway HTTP path — whereauthorize_requestresolves the device'sDeviceKeyfrom the principal's own profile and intersectsprincipal_caps ∩ device_caps, failing closed if thekey_idresolves to no registered device. So ause-onlydevice can drivePOST /api/agent/promptbut itsPairDeviceIssuereturns 403 even though the principal holdsself:auth:pair, regardless of whether it connects over HTTP or the socket. Newpair-device list/revokeadmin ops (CLIastrid pair-device list|revoke <key_id>, HTTPGET/DELETE /api/sys/principals/{id}/devices[/{key_id}]) make individual devices inspectable and evictable: revoke removes theDeviceKeyso the handshake can no longer match it and the kernel gate fails closed, and the gateway evicts a live device-scoped bearer immediately via a per-key_idrevocation watcher. Audit rows for issue/redeem/list/revoke carry the non-secretkey_idand scope, never a raw key or token. No WIT change, no RFC, no capsule-cli change. Closes #947. -
The
astrid mcp serveshim now elicits interactive consent on a grant-on-use signal and, on Grant, re-sends the original tool call — instead of returning a bare access error. Consumer half of grant-on-first-use (#998/#1001): the kernel access-gate DROPS an ungrantedtool.calland the broker replies agrant_requiredsignal ({ request_id, capsule_id, principal, tool_name, call_id }) rather than a terminal result. The shim turns that into a binary Grant / Deny elicitation ("Grant this capsule to the identity?") — binary, not the four approve-verbs, because the kernel grant is always persistent (profile.capsules), so "allow once" is not expressible. This MIRRORS the ingress flow (gate → consent → re-send), NOT capability-approval (park → consent → resume): the original call was dropped, so on approve the shim re-sends it with a freshreq_idand the identical name+arguments (now passing the gate), and the re-sent reply still flows into the capability-approval gate so a tool that is both ungranted and capability-gated resolves in sequence. On approve the shim publishes{ req_id, request_id, decision:"approve" }on the newastrid.v1.request.mcp.grant.respondfront door and readsgrantedfrom the{ kind:"grant.respond" }ack; the kernel persists the capsule on the principal. Marker discipline (the divergence from ingress): the broker holds a per-(principal, capsule)pending marker consumed on EVERY respond, so the shim ALWAYS responds — adenyis published on decline / cancel / no-elicitation-capability / elicit error too, or the marker would stick and a later call would get a benign "already pending" terminal instead of a fresh prompt. Fail-secure: any non-accept path publishesdenyand returns a clean "Capsule access was not granted for this tool." MCP error; an accept the broker could not persist (ackgranted:false) also returns the error rather than re-sending a call that would just trip the gate again; no panic/unwrap on the parse or elicit paths.astrid.v1.request.mcp.grant.respondis added to the non-retriable arm of the shim's connection-loss auto-retry allow-set — it mutates state (persists a capsule), so it must never be transparently re-issued. The grant target(principal, capsule)is never a respond body field — the kernel derives it from its own observed signal — so it is structurally un-spoofable from the shim. CLI/shim-internal, no WIT change; paired with the sage-mcp broker half. Refs #1002. -
The
astrid mcp serveshim now elicits interactive consent on a grant-on-use signal and, on Grant, re-sends the original tool call — instead of returning a bare access error. Consumer half of grant-on-first-use (#998/#1001): the kernel access-gate DROPS an ungrantedtool.calland the broker replies agrant_requiredsignal ({ request_id, capsule_id, principal, tool_name, call_id }) rather than a terminal result. The shim turns that into a binary Grant / Deny elicitation ("Grant this capsule to the identity?") — binary, not the four approve-verbs, because the kernel grant is always persistent (profile.capsules), so "allow once" is not expressible. This MIRRORS the ingress flow (gate → consent → re-send), NOT capability-approval (park → consent → resume): the original call was dropped, so on approve the shim re-sends it with a freshreq_idand the identical name+arguments (now passing the gate), and the re-sent reply still flows into the capability-approval gate so a tool that is both ungranted and capability-gated resolves in sequence. On approve the shim publishes{ req_id, request_id, decision:"approve", capsule_id }on the newastrid.v1.request.mcp.grant.respondfront door (thecapsule_idis echoed back so the broker can clear its per-(principal, capsule)dedup marker; it is display/routing only and never a grant target) and readsgrantedfrom the{ kind:"grant.respond" }ack; the kernel persists the capsule on the principal. Agrant_requiredsignal missing a non-emptyrequest_idorcapsule_id, or otherwise unparseable, is treated as malformed and surfaces a terminal MCP error rather than letting the dropped call masquerade as an empty success. Marker discipline (the divergence from ingress): the broker holds a per-(principal, capsule)pending marker consumed on EVERY respond, so the shim ALWAYS responds — adenyis published on decline / cancel / no-elicitation-capability / elicit error too, or the marker would stick and a later call would get a benign "already pending" terminal instead of a fresh prompt. Fail-secure: any non-accept path publishesdenyand returns a clean "Capsule access was not granted for this tool." MCP error; an accept the broker could not persist (ackgranted:false) also returns the error rather than re-sending a call that would just trip the gate again; no panic/unwrap on the parse or elicit paths.astrid.v1.request.mcp.grant.respondis added to the non-retriable arm of the shim's connection-loss auto-retry allow-set — it mutates state (persists a capsule), so it must never be transparently re-issued. The grant target(principal, capsule)is never a respond body field — the kernel derives it from its own observed signal — so it is structurally un-spoofable from the shim. CLI/shim-internal, no WIT change; paired with the sage-mcp broker half. Refs #1002. -
astrid capsule buildnow bakes each capsule's tool descriptors into the installedmeta.json, andastrid capsule showlists them — the tool surface is a static, offline-inspectable artifact instead of a runtime-only describe fan-out. Previously the only way to learn a capsule's tools (names, descriptions, input JSON schemas) was to load it into a running daemon and collect thetool.v1.request.describefan-out at runtime; an installed-but-not-running capsule was opaque, and the fan-out was the single source of truth. The build command now, after the leanastrid-buildcompanion compiles the component and packs the.capsule, instantiates the freshly-built component exactly as the kernel would at load time (a newastrid_capsule::describe_capsule_toolsharness: same loader path, an in-memory KV store, a fresh event bus, a no-op MCP client — no socket, no daemon, no identity/profile wiring), drives its#[astrid::tool]-generatedtool_describeinterceptor, and writes the captured descriptors totools.jsoninside the archive. At install,tools.jsonis read into a newCapsuleMeta.toolsfield (absent ⇒ empty, never a failure — install itself never instantiates WASM and stays daemon-free).astrid capsule showthen lists each tool's name + first-line description and the count (Tools (8)), orTools (none captured at build). The capture preserves the WASM engine's fail-secure integrity check: it stages a transientmeta.jsoncarrying the BLAKE3 hash of the very bytes being loaded (the check is satisfied honestly — the wasm matches what its meta claims — not bypassed), and removes it afterward. A capsule with no tools, a non-Rust/MCP-import build, or a capsule built before this capability all yield an empty tool list rather than an error. This is the foundation for later retiring the runtime describe fan-out; the sage-mcp broker is unchanged (a separate follow-up). Closes #978. -
astrid capsule new <name>scaffolds a complete, first-try-compiling tool capsule — with a toolchain preflight and a self-contained authoring guide. Previously a new capsule author (human or a fresh agent) had to hand-assemble the skeleton from memory or by copying an existing capsule, and the most common first-build failure was a missing.cargo/config.tomlgetrandom backend cfg — without--cfg=getrandom_backend="custom", anything pullinggetrandom(uuid v4, theHashMapRandomState) fails to link onwasm32-unknown-unknownwith an opaque error. The new command writes.cargo/config.toml(with that fix), a pinnedrust-toolchain.toml, aCargo.toml(cdylib+ size-optimised release profile), aCapsule.toml(component block + the mandatory tool-bus[publish]/[subscribe]ACL), a workingsrc/lib.rs(ahello#[astrid::tool]example), aREADME.md, and anAUTHORING.md. TheAUTHORING.mdis a from-zero, self-contained guide — a Claude reading only it can author the capsule without touching Astrid's source: the#[capsule]+#[astrid::tool]pattern with a worked example, theCapsule.tomlsections (including[capabilities]and the[publish]/[subscribe]ACL, with the fail-closed "empty table = deny all" rule), the getrandom footgun (already wired by the scaffold), the tool-bus topic conventions and WIT references a tool capsule needs, and the build → install → call dev loop. Before generating, the command runs a fail-friendly Rust-toolchain preflight: it checks thatcargo/rustcare on PATH and thewasm32-unknown-unknowntarget is installed, and if anything is missing it prints actionable guidance (the rustup install one-liner andrustup target add wasm32-unknown-unknown) — and, whenrustupis present, offers to add the target interactively (or non-interactively via--install-target). A missing toolchain never aborts: the skeleton is still written so the author has something to build once they install what they need. The command validates the name as a Rust package name, refuses a non-empty target directory (unless--force), accepts--path <dir>and--kind tool(onlytoolis supported in v1), and prints next-steps. Thewit/directory is intentionally not generated — it is produced at build time. Closes #912. -
Grant-on-first-use: an ungranted tool call now elicits consent and, on approve, grants the capsule — instead of silently dropping the call and timing the caller out. Building on the per-principal capsule-access gate (#992/#993), an authenticated non-admin principal that invokes a capsule tool it does not yet hold previously had its call silent-dropped at dispatch, so the caller simply hung until its ~50s timeout with no signal. The dispatcher now, at that access-gate miss, publishes a new
IpcPayload::GrantRequired { request_id, principal, capsule_id }onastrid.v1.approval(a distinct variant fromApprovalRequired, so a broker can tell a grant-on-use from a plain capability approval), carrying the kernel-stamped caller principal and the missing capsule id with an unguessable UUID correlation id — a synchronous fire-and-forget publish that adds no lock,.await, or blocking call to the dispatch hot path. A new kernel consent handler observes those signals on a single permanentastrid.v1.approvalsubscriber, and for each spawns a short-lived, timeout-bounded awaiter (with a fixed in-flight cap, fail-closed dropping at saturation) that subscribes to the per-requestastrid.v1.approval.response.<id>topic; on an APPROVE response (the exactapprove/approve_session/approve_alwaysset the host approval flow uses) it grants the capsule to the principal by reusing the #993 admin grant machinery (load → set-delta → validate → save → cache-invalidate) under the sameadmin_write_lock, so a grant-on-use and a concurrentagent modifycannot race the same profile. Security: the grant target(principal, capsule_id)comes only from the kernel-observedGrantRequired(captured by value into the awaiter); the response payload conveys only adecisionand is never read for a target, so the grantee is structurally un-spoofable. Response authenticity rides on the existing publish-ACL forastrid.v1.approval.response.*(only the uplink and broker may publish it; a tool capsule cannot forge an approve), consumed without a separate provenance check exactly as the host approval flow does. Every failure path — invalid principal, missing/unloadable profile, validation/save error, deny, unknown decision, timeout, uncorrelated response — is a fail-closedwarn!(security_event = true)no-op that never creates a phantom principal or default-allows. The external consent shim/broker is out of scope here (kernel-side only). Refs #998.
Fixed
-
Rate-limited kernel management requests now receive their error reply on the response topic instead of timing out. When a management request (
astrid.v1.request.<op>.<correlation>) tripped theManagementRateLimiter, the kernel router derived the error's reply topic with a no-opreplace("kernel.request.", "kernel.response.")— a substring that never appears in the realastrid.v1.request.*topics — so theRate limitederror was published back on the request topic and the client, waiting onastrid.v1.response.<op>.<correlation>, hung until its own timeout. The rate-limit path now uses the sameastrid.v1.request.→astrid.v1.response.derivation as the normal request path (extracted into a sharedresponse_topic_forhelper), so the rejection reaches the caller immediately. Kernel-internal, no WIT change. Found in PR review. -
astrid init/astrid distro applynow enforce a distro's[distro].astrid-versionfloor against the running CLI before any prompting or install, instead of silently proceeding into a broken onboarding on an outdated client. A distro manifest declares a CLI-version requirement (e.g.astrid-version = ">=0.6.0"), but the CLI only validated it as well-formed semver (VersionReq::parse(...).is_err()) and never compared it to the running binary — so a manifest that bumped its floor, fetched from the repomaintip (raw.githubusercontent.com/.../main/Distro.toml), would pass validation and break the install mid-flight on an older CLI with a confusing error instead of an actionable one.run_init(the path bothastrid initandastrid distro applytake) now calls a newenforce_astrid_versionimmediately afterfetch_and_parse_manifestand before lock-freshness / select / install; if the running CLI (CARGO_PKG_VERSION, the same sourceastrid versionprints) does not satisfy the floor it fails fast withThis distro requires astrid {req}, but you are running {version}. Run \astrid update` to upgrade.. For a plain release floor the comparison strips the running version's prerelease/build metadata and matches on the release triple, so a locally-built / dev / prerelease CLI at or above the floor (e.g.0.6.0-dev.3vs>=0.6.0) is **not** falsely rejected by the defaultsemverprerelease rule, while a CLI whose release triple is genuinely below the floor still fails. When the requirement *itself* names a prerelease (e.g.>=0.6.0-rc.3) the triple-strip is dropped and the running version is compared as-is under exact semver semantics, so a lower prerelease of the same triple (0.6.0-rc.2) is correctly rejected rather than being lifted to0.6.0and over-accepted. A manifest with noastrid-versionimposes no requirement, and a malformedastrid-versionremains a manifest error. Scope is the[distro].astrid-versionCLI gate only — the[distro.requires.astrid]` interface version reqs are untouched. CLI-internal, no WIT change. Closes #1023. -
astrid-mcp compiles against rmcp 1.8.0, and rmcp is pinned to its patch range. rmcp 1.8.0 (released 2026-06-23) changed Peer::peer_info() to return Option<Arc> instead of Option<&InitializeResult>, which broke the ServerInfo::from_rmcp(&InitializeResult, ...) call in the MCP client and failed the whole workspace build. Because the repo intentionally commits no Cargo.lock, every PR's CI resolves the latest compatible deps.
-
Build-time tool capture no longer fails for pure-interceptor capsules (e.g. the broker), so they are marked captured instead of unknown.
describe_capsule_toolstreated a capsule with no#[astrid::tool]as "no tools" only on aNotSupportederror, but a capsule that has other interceptors and no#[astrid::tool]— notably the sage-mcp broker itself — has its generated dispatch deny the unknown action withDeny("unknown hook action: tool_describe"), which surfaced as a hard error. The capsule then installed withmeta.jsontools: None(uncaptured). That broke the deterministic tool surface end-to-end: the broker's all-or-nothing static-surface check requires every loaded capsule captured (including the broker), so with the broker uncaptured the static path never fired and every daemon fell back to the describe fan-out.tool_describenow treats aDeny("unknown hook action")as "no tools" (Ok(vec![])), exactly likeNotSupported; any other deny reason is still a genuine refusal and surfaces as an error. Caught by a live end-to-end run. Closes #985. -
astrid mcp servenow fails loud instead of silently serving the MCP bridge as the no-capabilityanonymousidentity. When the shim is asked to serve as a principal whose keypair is missing (keys/<principal>.keyabsent — e.g. a stale running binary that predates the signed handshake, or a principal that was never minted), the handshake falls to the legacy single-frame path and the daemon binds the connectionanonymous. The bridge then came up "successfully" yet everytools/callfailed the ingress-trust and capability checks and, to the client, simply hung/timed out with no diagnostic (this cost real debugging time tracing a governed-Claude tool timeout back to ananonymous-bound shim).SocketClientnow records whether the handshake authenticated as the requested principal (took the signed path) versus fell to the legacyanonymouspath (exposed asis_authenticated()), andastrid mcp serverefuses to start when a non-anonymousprincipal did not authenticate — emitting an actionable error (runastrid agent create <principal>to mint its keypair, or pass--principal anonymousto serve unauthenticated on purpose). Requestinganonymousexplicitly still serves. CLI/uplink-internal, no WIT change. Closes #950. -
Client-connection lifecycle events are now emitted by the host, not the capsule-cli proxy, fixing an unbounded per-principal connection-count leak. The kernel connection tracker keys its per-principal
active_connectionscount off the principal stamped onclient.v1.connect/client.v1.disconnectbus events (+1/-1, driving the ephemeral idle-shutdown gate andastrid who). The proxy emitted both viaipc::publish-as, but the hostpublish-asdeliberately derives the effective principal from the connection's handshake-verified identity (the anti-forge boundary, #45/#852) and clears that identity on close. The proxy'sclient.v1.disconnectfired after the connection — and its verified identity — was already gone, so the host stamped it the reserved no-capabilityanonymous: connect incremented the real principal, disconnect decrementedanonymous(a saturating no-op), and the real principal's count leaked+1per connection, unbounded. (This reproduced on current main and was not a capsule-cli version issue — the root cause is the emission site.) The emission moves into the host, which holds the verified principal for the whole connection lifetime:net.unix-listener.{accept,poll-accept}record the verified principal (the reservedanonymousfor a legacy/unauthenticated peer, so the pair still balances) in a new sharedclient_connectionsregistry keyed by the stream resource rep and emitclient.v1.connect; thetcp-streamdrop path emitsclient.v1.disconnectstamped with the same stored principal and removes the entry — so connect and disconnect for one connection always pair on the identical host-verified principal and the counter balances. Outbound TCP (connect-tcp) is never registered, so a capsule-dialed socket never moves the connection counter; only inbound uplink connections do. The emitted principal is always the host-verified one, never a guest/capsule-supplied value, preserving the anti-forge boundary. Requires the capsule-cli proxy to stop emitting the same events (capsule-cli#32) in lockstep, or connections double-count. Kernel/host-internal, no WIT change. Closes #948. -
agent createnow backfills a missing per-principal keypair onto an existing keyless principal, so upgraders auto-heal instead of staying stuck asanonymous. Per-connection auth (#45/#852) makesagent createmint+register a per-principal ed25519 keypair so the principal can sign the socket handshake and be bound to its own scoped identity; a principal with no keypair gets stamped the no-capabilityanonymous, which masks its furnished home and every capability-scoped tool. Principals created BEFORE that feature landed are keyless (auth.methods = [],auth.public_keys = [], nokeys/<principal>.key), andagent createused to hard-error "already exists" on any existing profile — so an upgrader's pre-feature principal (e.g. the plugin'sclaude-code) never got a keypair and stayed permanentlyanonymous, even though the plugin'sastrid-upre-runsagent create <p>on every boot. A bareagent create <existing-principal>now surgically backfills the missing credential: it loads the existing profile, and if it carries no ed25519 credential (noKeypairmethod and noed25519:public key) mints the keypair via the same path a fresh create uses (private key tokeys/<principal>.key0600 in system custody, public key +Keypairmethod registered on the profile) and persists it — soastrid-up's per-boot re-run auto-heals upgraders. The backfill is security-positive and strictly additive: it never widens groups/grants and never touches network/process/quotas/home/state — it only adds the missing auth credential. A principal that already has a keypair is left untouched (still errors "already exists", never re-minted), and a re-create carrying any profile-shaping input (--clone/--inherit-from/--group/--grant) still errors rather than being silently dropped — a backfill is not a re-create. Kernel-internal, no WIT change. #45/#852. -
Local per-connection principal authentication: the socket handshake cryptographically proves which principal a connection acts as, and the kernel now binds that verified identity to the connection's outbound traffic (kills the self-stamp). Each principal already gets a per-principal ed25519 keypair on
agent create(private key written tokeys/<principal>.key0600, public key registered ased25519:<hex>on the profile); this extends the kernel-side socket handshake to verify that key over a challenge-response, and records the verified identity per connection. The handshake gains an OPTIONAL second frame: when a client setsclaimed_principalon its first request, the daemon replies with a random 32-byte challenge nonce, the client returns an ed25519 signature overastrid-principal-auth:v1:{principal}:{nonce_hex}, and the daemon verifies it against a key registered in that principal'sAuthConfig.public_keys. A valid signature yields a verifiedPrincipalId; an invalid one fails the handshake closed (a bad signature is an attack, never a fallback to unauthenticated). A request WITHOUTclaimed_principalcompletes in the legacy single round trip with no verified principal, so legacy clients and legacy daemons keep interoperating (the new wire fields are#[serde(default)], no WIT change — the handshake is kernel-internal plain Rust). The verified principal is stored in a per-connection registry onHostState(connection_principals, keyed by the stream resource rep, shared across the capsule's pooled instances and torn down when the stream drops). The uplink client signs the challenge automatically whenkeys/<principal>.keyexists for the connecting principal, falling back to the single-frame flow otherwise. The bootstrapdefaultprincipal now also gets a keypair minted at boot so the operator can authenticate asdefault. Enforcement (the self-stamp fix): when the uplink (capsule-cli) forwards a client frame to the bus viapublish-as, the host now stamps the connection's verified principal in place of the capsule-supplied name — so a socket client can no longer name a principal it has not proven, which was the whole point of #45/#852. The framedtcp-stream.readthat pulls a frame off a kernel-bound connection records that connection's verified principal onHostState(ingress_principal); the immediately-followingpublish-asconsumes it (the uplink's run loop reads a frame then forwards it before reading the next, so the binding lives exactly as long as the in-flight frame, and a non-data read clears it). A connection with no kernel binding is stamped the reserved no-capabilityanonymousidentity (see the Security entry "Unauthenticated socket connections..." below — this closes the unbound self-stamp); the operator and every agent authenticate via their auto-minted keypair, so they are bound and act as themselves. capsule-cli is unchanged (the host derives the principal from the connection, ignoring any capsule-supplied name), and there is no WIT change — the derivation is entirely host-side. This makes the crypto-authenticated client path enforce end-to-end. #45/#852. -
Capability-token lifecycle management: human "Always Allow" is now permanent, plus admin verbs to mint / revoke / list
mcp://tokens. "Always Allow" previously minted a capability token with a hard 1-hour TTL — so a decision the user expressed as always silently expired after an hour and the agent re-prompted.handle_allow_alwaysnow mints a permanent token (ttl: None, valid until explicitly revoked); the bounded-grant case is served instead by the new admin mint--ttl. New admin verbs let an operator pre-grant tool access so an agent never hits a per-use approval elicitation:astrid caps token mint <principal> <resource> [--permission <p>] [--ttl <duration>],astrid caps token revoke <token-id>, andastrid caps token list <principal>. Tokens are signed by the runtime ed25519 key — the same key the approval interceptor's validator trusts as issuer — so a minted token authorizes immediately on the secure path and survives daemon restarts (persistent scope). To reach the signing key from the admin handlers without loading it from disk twice, the runtime key is now loaded once at boot, shared (Arc) with the audit log, and exposed asKernel::runtime_key. Minted tokens are revocable (revocation is global and final), principal-scoped (carry the principal in the signed payload per #668 — a token minted for Alice never authorizes Bob), admin-gated (newcaps:token:{mint,revoke,list}capabilities, covered by theadmingroup's*; a scopedagentprincipal must not hold them), and audited (every verb records anadmin.caps.token.*audit entry through the existing admin-audit path).permissiondefaults toinvoke; an unknown permission string or a non-existent principal is rejected with a bad-input error and no token is created. Tokens are kernel-internal — no WIT change, no RFC. Closes #929. -
astrid agent create --clone <source>replicates an existing agent's full profile and state in one atomic admin call.--inherit-fromcopies state only (env/KV/secrets) and deliberately leaves the capability profile empty, so there was no first-class way to replicate an agent — duplicating one meant rebuilding its groups, grants, revokes, egress, process allow-list, and quotas by hand viacaps grant/agent modify/quota set.AgentCreategainsclone_from: Option<PrincipalId>andallow_admin_clone: bool(both#[serde(default)], so older serialized requests deserialize unchanged). Whenclone_fromis set, the kernel builds the new principal as a full replica of the source — groups + grants + revokes + network egress + process-spawn allow-list + quotas — and then performs the same env/KV/secret state copyinherit_fromdoes. Deliberately NOT copied: the source'sauth(each principal keeps its own keys/authenticators — cloning is profile+state, never credentials) and itsenabledflag (a fresh clone is always enabled, even if the source was disabled).--cloneis mutually exclusive with the profile/quota-shaping flags (--group,--egress,--process-allow,--inherit-from,--memory/--timeout/--storage/--processes): the CLI enforces this via clapconflicts_withand the kernel rejects a request that sets both — defense in depth against a hand-built request. Admin-source guard: cloning a source that resolves to the universal*(the built-inadmingroup, a*grant, or a customunsafe_admingroup — resolved through the liveGroupConfig, not a literal scan) is rejected unless the operator passes--unsafe-admin, mirroring the acknowledgement oncaps grant '*'andgroup create --caps '*'— so--clone defaultcannot silently mint a second admin. The source is validated to exist and not be the new principal itself before any write runs. The HTTP management API constructsclone_from: None(an API-level clone is a follow-up). Closes #927. -
Gateway
POST /api/capsulesnow resolves GitHub sources (@org/repo,github.com/org/repo,https://github.com/org/repo) to a local archive in the uplink before install; the daemon still never fetches URLs. Previously the gateway forwardedsourceverbatim to the kernel's path-onlyInstallCapsulehandler, which rejects every remote-shaped source — so an integrator that only speaks gateway HTTP could not install a registry/GitHub capsule even though the CLI could (the CLI resolves GitHub sources client-side and hands the kernel a local path). The gateway, an uplink like the CLI, now does the same: for a GitHub-shaped source it fetches the repo's latest release, selects the matching.capsuleasset (the lone one, or the one named via a new optionalcapsulerequest field for a multi-capsule release), streams it to a temp file with a hard 50 MB cap, and calls the existingInstallCapsulewith that LOCAL path (workspaceforcedfalse). The kernel install path is unchanged and still rejects URLs — the fetch lives ONLY in the gateway. The five pure GitHub source-parsing/asset-selection helpers move intoastrid-capsule-install::github_source(a network-free module the CLI and gateway share); the kernel and the install crate gain no network dependency. The gateway implements a narrower path than the CLI on purpose: no clone-and-build fallback, no local-Cargo auto-build, no multi-asset "install all" — it requires resolving to exactly one.capsule. New 404 (no release / no.capsuleasset) and 400 (ambiguous multi-capsule release, or archive over 50 MB) responses. Closes #960. -
The
astrid mcp serveshim now elicits interactive user consent when the sage-mcp broker reports an untrusted ingress, replacing the static operator allow-list. Previously the broker gated its state-mutatingtools/callfront door on an operator-maintainedtrusted_ingress_idslist; an ingress that was not pre-pinned was hard-denied. The broker now replies aningress_approval_requiredsignal for a not-yet-trusted ingress, and the shim turns it into a user consent prompt via the MCP elicitation capability ("Allow Astrid tool calls from this session?"). On accept the shim forwardsastrid.v1.request.mcp.ingress.respond{ req_id, accept }, the broker records trust keyed on the kernel-stamped callersource_id(never a body field), and the shim re-sends the original call. Fail-secure: a client without elicitation, a decline / cancel, an elicit error, or a broker that could not persist the grant all return an MCP error to the client and record no trust. Manual end-to-end verification of the live elicit flow is pending (needs an elicitation-capable client, Claude Code ≥2.1.76). Paired with the sage-mcp broker change. Closes #917.
Removed
- Build-time tool baking (
tools.json) is removed — it is superseded by the kernel's load-time describe (#987), which needs no static artifact and no rebuild. With every capsule's tools now reaching a consumer via the livetool_describeprobe at load, the build-time machinery was redundant. Removed:astrid capsule buildinstantiating the compiled component to capture descriptors and inject atools.jsoninto the archive (it reverts to plain compile + pack — no wasmtime, no integrity-meta staging, no MCP-hybrid guard); the build-timedescribe_capsule_toolsand its ephemeral-instantiation helpers (the shareddescribe_loaded_capsulethe kernel uses is kept);CapsuleMeta.toolsand the installtools.jsonread;astrid capsule show's tool listing; the kernel's now-vestigial baked-tools check (it always probes); and the build-onlyastrid-auditdependency onastrid-capsule. The one capability given up — offline / pre-install tool inspection (listing a capsule's tools without a running daemon) — is deferrable and re-derivable later if the consent/disclosure plane needs it. Verified live: with notools.jsonproduced or installed anywhere, a daemon still serves a tool capsule's full surface via the broker's static path. Closes #989.
Changed
-
Principal profile field validation now uses typed domain wrappers internally while preserving the public string-shaped profile API.
GroupName,CapabilityPattern, andCapsuleGrantdistinguish group memberships, direct capability patterns, and capsule grants at validation and authorization edges, with a borrowedValidatedProfileFieldsview for policy checks.PrincipalProfileremains TOML/JSON/admin-API compatible (Vec<String>fields), so callers keep the existing wire shape while kernel/admin paths validate incoming strings before persistence and capability checks fail closed on malformed profile fields. Closes #1033. -
IPC topics are now a typed
Topicnewtype with per-family constructors, replacing scatteredformat!strings and duplicatedconst *_TOPIC/*_PREFIXliterals.astrid_types::Topicis a#[serde(transparent)]newtype overString(so the JSON shape and the gueststringABI are byte-identical to before — no wire-format change, no WIT change, no version bump).IpcMessage.topicandSelectionRequired.callback_topicare nowTopic;IpcMessage::newtakesimpl Into<Topic>. Typed constructors (Topic::elicit_response,approval_response,kernel_request/kernel_response,admin_request/admin_response,reload_capsule_request/reload_capsule_response,session_response,cli_command_run/cli_command_result,user_prompt,agent_response,client_connect/client_disconnect,audit_entry, …) are the single source of truth for each family's exact wire string; afrom_rawescape hatch covers the WIT bindgen boundary and genuinely dynamic topics. Construction sites across the kernel router, uplink clients, capsule host fns, gateway, CLI, andastrid-emitwere migrated, and the duplicated topic-prefix consts (REQUEST_PREFIX,RESPONSE_PREFIX,ADMIN_INPUT_PREFIX/ADMIN_RESPONSE_PREFIX,APPROVAL_TOPIC,CLIENT_*_TOPIC, the mcp shim'sRESPONSE_PREFIX) were collapsed onto the constructors. Read-side match/subscribe-pattern literals (including wildcard patterns and the deliberately capsule-localAUDIT_TOPICpinning anchor) are unchanged. Unit tests pin every constructor's output and proveserdetransparency, guarding the byte-identical-on-the-wire invariant at build time. Internal refactor only. -
Group config validation now uses typed domain wrappers internally while preserving the public string-shaped groups API.
GroupConfigandGroupremain TOML/JSON/admin-API compatible (HashMap<String, Group>andVec<String>fields), but load, mutation, and capability-check edges now convert group names and group capability entries into borrowedGroupName/CapabilityPatternviews. This rejects invalid quoted group keys such asgroups."ops/team"before persistence and makes group inheritance fail closed if an invalid in-memory config is constructed manually. Closes #1043. -
The deterministic tool surface is now automatic — the kernel describes any un-baked capsule at load, so no rebuild is needed to retire the describe fan-out. Build-time tool baking (#978) only takes effect once a capsule is rebuilt and re-released; until then it is uncaptured and the broker falls back to the racy fan-out, so activation was gated on a fleet rebuild. Now
publish_capsules_loadedprobes each loaded capsule once — invoking its existingtool_describeinterceptor (the same hook the dispatcher already routes) and injecting the captured descriptors into thecapsules_loadedpayload. The kernel invokes-and-forwards — it never interprets the descriptors; the broker still owns all policy. A describe failure leavestoolsabsent for that capsule this cycle (the consumer falls back to its fan-out). Net: every loaded capsule — including un-rebuilt and third-party ones — contributes a complete, deterministic tool surface from the signal the broker already consumes, with no rebuild, no SDK change, and no broker change. This shifts the kernel from a pure opaque-forward (#980) to a probe-at-load (PCI/driver-probe-style: invoke a known hook, forward the opaque result). (With this in place, the build-timetools.jsonbaking became redundant and is removed — see the Removed entry above.) Closes #987. -
A capsule's baked tool surface now records whether it was captured at build, distinguishing "authoritatively no tools" from "not captured yet". #978 baked tool descriptors into
meta.json, but an emptytoolswas ambiguous — a capsule that genuinely has no tools (the cli socket-owner, run-loop capsules) looked identical to one built before tool-baking. A consumer (the sage-mcp broker, next) can't retire the runtime describe fan-out without telling these apart, or it would silently drop tools from any un-rebuilt capsule.CapsuleMeta.toolsis nowOption<Vec<ToolDescriptor>>:None= surface not captured (discover at runtime),Some([])= captured and authoritatively no tools,Some([..])= the captured tools.astrid capsule buildwritestools.jsonwhenever the WASM capture succeeds — even for a zero-tool capsule, so the file's presence is the marker — except when the capsule declares an[[mcp_server]](its tools come from the MCP engine, not#[astrid::tool], so the WASM capture is incomplete and it is left unmarked →None→ fan-out). Install readstools.jsonintoSome(absent/corrupt →None);astrid capsule shownow distinguishes "not captured at build" from "captured, no tools". Backward-compatible (oldmeta.jsonwith notoolskey →None). Closes #983. -
The
astrid.v1.capsules_loadedbroadcast now carries each loaded capsule's installed metadata, so a sandboxed consumer can derive a deterministic tool surface from the change signal instead of a racydescribefan-out. A WASM-sandboxed capsule (e.g. the sage-mcp broker) has no channel to learn the loaded-capsule set or its tools from the kernel —ListCapsules/GetCapsuleMetadataare CLI-onlyKernelRequests and it cannot read the install dir'smeta.jsonfrom inside the sandbox — so tool discovery has had to go through atool.v1.request.describebroadcast that races a timeout (the first-prompt-after-boot non-determinism). Now that tool descriptors are baked into each capsule'smeta.jsonat build time (#978), the kernel surfaces that static surface over the signal consumers already receive: thecapsules_loadedpayload keepsstatus: "ready"(so existing subscribers — theastrid mcp serveshim, the TUI — that treat it as a bare signal are unaffected) and additively gainscapsules: [{ name, meta }], wheremetais the capsule'smeta.jsonforwarded as an opaque JSON value. The kernel does not parse or interpret that metadata (no tool awareness, no typed tool field on any kernel type) — it surfaces metadata about the capsules it owns, the way a Linux uevent carries a device's attributes and leaves all interpretation to userspace; the consumer extracts whatever it needs. This gives a deterministic tool source without the kernel gaining tool knowledge and without widening the consumer's own capabilities. The per-capsulemeta.jsonis read off the registry lock and is best-effort (a missing/unreadable/non-JSON file contributes anullmetaand never blocks the broadcast). Kernel-internal (the event payload is opaqueRawJson); no WIT change, no RFC. Foundation for the sage-mcp broker switch that retires the fan-out. Closes #980. -
astrid capsule removenow unloads the capsule from a running daemon — a removal takes effect live, no restart needed. Install (#958) and upgrade (#968) already went live, butremovewas disk-only: it deleted the capsule from disk yet left it registered in the running kernel, so the removed capsule kept receiving events and its tools lingered in theastrid mcp servetool surface (callable, erroring) until a restart. A new targetedKernelRequest::UnloadCapsule { id }unregisters a single capsule from the running registry and explicitly unloads its WASM component (there is no asyncDrop, so this is done eagerly to avoid leaking the component and any MCP subprocesses), then publishesastrid.v1.capsules_loadedso the shim's MCPnotifications/tools/list_changedfires and a connected agent sees the tools disappear live.astrid capsule removesends this best-effort after its existing removal: the on-disk removal stays authoritative and dependency-checked (the sole-provider guard is unchanged) and the live unload only mirrors it into the running daemon — non-fatal as with install/upgrade (no reachable daemon is silent; a capsule that was never loaded live is a quiet no-op rather than a false "unloaded"; a declined or timed-out unload only prints a note; the remove's exit status is never affected). The verb is gated behind a new, distinctcapsule:removecapability — removal is destructive and globally-scoped, so it is separately grantable/revocable rather than reusingcapsule:reload; it is held by theadmingroup via*and deliberately not granted to theagentgroup (unlike the restorativeself:capsule:reload). The kernel verb is dependency-blind by design — the CLI runs the sole-provider check before nudging, and kernel-side dep-safety is deferred to any future direct/recursive caller. The client-supplied id is validated (CapsuleId::new) before it is used as a registry key, the call is rate-limited, and it is kernel-internal (no WIT change). Paired with the sage-mcp broker cache-eviction so a removed (or upgraded) capsule's tools leave the surface immediately instead of lingering for the descriptor-cache TTL. Closes #970. -
astrid capsule install/updatenow hot-swap an already-loaded capsule in a running daemon — a reinstall or update goes live without a restart, not just a fresh add. #959 hot-added a freshly-installed capsule via a best-effort broadcastReloadCapsules, butload_all_capsulesskips already-registered capsules, so iterating on a capsule (rebuild → reinstall) orastrid capsule updatestill needed a restart to take effect. A new targetedKernelRequest::ReloadCapsule { id }resolves as add-or-restart: a registered capsule is hot-swapped to the new on-disk bytes (Kernel::restart_capsuleunregisters and re-loads from the source dir, and the loader reads the content-addressed WASM by the hash a reinstall has bumped — so unregister-then-reload picks up the new bytes), while an unregistered one is loaded fresh. The install nudge now targets this verb per just-installed capsule id (superseding the broadcast) and moves intoinstall_capsule, so theupdateand TUI install paths inherit live hot-swap too. Either branch publishesastrid.v1.capsules_loaded, so theastrid mcp serveshim's MCPnotifications/tools/list_changedfires and a connected agent sees the change live. Best-effort and non-fatal as before — no reachable daemon leaves the standalone install untouched (the capsule loads on the next daemon start), a declined or timed-out reload only prints a note, and the install's exit status is never affected.ReloadCapsuleis capability-gated (capsule:reload, the same capability asReloadCapsules, so no new authority), rate-limited, and kernel-internal (no WIT change). Live remove (unload) lands in #970. Closes #968. -
astrid capsule installnow hot-loads the new capsule into a running daemon, so a freshly-installed capsule is usable without a restart. The on-disk install is deliberately standalone and daemon-independent (it must work with no daemon running) and stays that way — but it never told a live daemon about the new capsule, so the capsule's tools weren't available until the daemon was restarted (friction for the build-a-capsule → use-it loop). After a successful install the CLI now sends a best-effortKernelRequest::ReloadCapsulesto a reachable daemon: the daemon re-scans the install dir and loads the newly-present capsule, and the resultingastrid.v1.capsules_loadedis already turned by theastrid mcp serveshim into an MCPnotifications/tools/list_changed, so a connected agent sees the new tools live. The nudge is best-effort and non-fatal — no daemon (socket absent or unreachable) leaves the standalone install untouched (the capsule loads on the next daemon start), and a daemon that declines or times out only prints a note; the install's success and exit status are never affected. It is wired into the manualastrid capsule installdispatch arm only (notupdate,remove, the distro-batch install, or the TUI, which has its own/refresh), andReloadCapsulesstays capability-gated (capsule:reload) kernel-side, so this adds no new authority. Scope: loads a newly-installed capsule — re-installing a capsule that is already loaded (same id) is skipped by the loader's already-registered guard, so swapping an already-loaded capsule's code still needs a restart (in-place live upgrade is a separate, later change). CLI-only, no WIT change. Closes #958. -
Extracted the inline
#[cfg(test)] mod testsblocks fromdirs.rs,secret.rs,kernel_router/mod.rs,manifest_gate.rs, andpolicy.rsinto sibling test files to keep each production file under the 1000-line file-size cap; pure mechanical move, no behaviour change. Closes #956. -
CI
Testjob strips debug info so linking theastrid-capsuletest binary no longer exhausts the runner. Theastrid-capsuletest binary statically links the entire dependency tree (wasmtime, aws-lc, ring, surrealkv, …); after a GitHububuntu-latestrunner-image rotation the final link began dying withcollect2: fatal error: ld terminated with signal 7 [Bus error](an OOM/disk-pressure failure), turning everyTest (ubuntu-latest)run red and cancellingmacos-latestvia matrix fail-fast — independent of the change under test (it reproduced on a main commit that touched neither capsule code). The Test job now setsCARGO_PROFILE_DEV_DEBUG=0/CARGO_PROFILE_TEST_DEBUG=0, dropping debug info (the dominant contributor to both linker peak memory andtarget/size) for the test build only. CI-scoped via Cargo profile env overrides — local and dev builds keep full debug info. Closes #954. -
Manual
astrid capsule install @org/repoagainst a multi-capsule release now installs every capsule the release ships by default, with--capsule <name>to pick one. #921/#922 threaded the distro capsulenameintopick_capsuleso a monorepo release could select the right archive, but left the manual install path with no selector: a release shipping several.capsuleassets passed no hint and hitpick_capsule's "no capsule name to pick one" hard error, so there was no way to install such a release by hand at all. The manual path now defaults to installing all capsules in the release —install_from_githubcollects every.capsuleasset (via a new purecapsule_assetshelper over the release JSON) and, when no selector is given, downloads and unpacks each one. Installation is best-effort: it printsInstalling <name>...per archive, reports any that fail and keeps going, then returns an error naming all failures if any did — never silently swallowing a failed install. A new--capsule <NAME>flag opts back into single-capsule selection (<name>.capsulevia the unchangedpick_capsule), and a single-asset release installs that one capsule exactly as before. The distro path (install_capsule_batch) is unchanged — it keeps selecting by the distro capsulename. Theupdatepath now passes the capsule being updated as the selector so a monorepo source refreshes only that one capsule, not the whole release. The clone+build fallback (no release assets) stays single-capsule; makingastrid-buildhandle a virtual-workspace root is a separate gap. Closes #925. -
Guest
ERROR-level logs now surface in the daemon log, not only the per-capsule log file. When a run-loop capsule'srun()returnsErrbefore signaling ready, the daemon log showed only a contextlessCapsule run loop exited before signaling ready— for the sole-socket-owner uplink (the cli proxy) that takes the whole daemon down, and diagnosing it meant hours of guessing. The reason was never actually lost: the SDK#[astrid::run]macro logsrun loop exited with error: {e}at ERROR level before returning, butsys::logwrites guest logs to a per-capsule file (effective_capsule_log()) and only fell back to the daemon's tracing subscriber when there was no per-capsule file — so the reason landed in a separate file operators don't check during a crash. ERROR-level guest logs are now emitted to the daemon's tracing subscriber in addition to the per-capsule file (lower levels stay file-only when captured, preserving the daemon log's signal-to-noise). The silent run-loop crash is now a one-line diagnosis (ERROR …host::sys: run loop exited with error: … plugin=<capsule>) sitting right next to the kernel's message. No ABI/WIT change. Refs #884. -
Publish/subscribe ACL wildcard matching now uses the route-layer subtree semantics — a declared trailing
*covers the whole namespace. ACL authorization used the stricttopic::topic_matches(a*matches exactly one segment) while event delivery usesastrid_events::TopicMatcher(a trailing*is a subtree wildcard matching one or more segments at any depth). That divergence forced capsule manifests to enumerate wildcard depth (astrid.v1.admin.*/*.*/*.*.*) merely to authorize publishing topics whose depth varies or is unknown (uuid suffixes, variable sub-paths) — the same matcher asymmetry behind the cli run-loop subscribe confusion, now on the publish side. The two ACL checks (publish_inner,check_subscribe_acl) now authorize via a single allocation-freeastrid_events::topic_pattern_matches— extracted as the one source of truth now used by routed delivery (TopicMatcher), broadcast delivery (EventReceiver), and ACL authorization alike — so a declaredastrid.v1.admin.*authorizes every admin topic at any depth and the three paths can never silently diverge again (this also folds away a duplicated matcher and fixes a latent equal-segment-count bug in the broadcast path's hand-rolled iterator form). Scope is the two ACL sites only — interceptor dispatch keeps strict matching, and the runtime "wildcard must be terminal" subscribe gate is unchanged. The change is permissive (authorizes more, denies nothing previously allowed; delivery was already subtree); breadth is the operator's decision at install, not something the matcher enforces by forcing enumeration. Refs #882. -
The
astrid:processWIT now documents the id-keyed persistentwrite-stdin/close-stdinas implemented. These landed in #867 — the persistent-process registry retains the child's stdin pipe host-side across pooled-instance resets (1 MiB-capped, backpressured, ownership-checked, audited) — but the WIT still tagged them(NOT YET IMPLEMENTED)and carried two stalePERSISTENT TIER"stubbed until the registry lands" banners. The tags are dropped and the banners corrected; function signatures are unchanged (doc-only). The ephemeralProcessHandleform,attach, andwatch/unwatchremain genuinely deferred and stay tagged. An acceptance test now locks the id-keyed path: deliver bytes → a by-id re-write (needing only registry + id, exactly what a post-reset instance holds) reaches the same child →close-stdinyields a clean EOF exit → over-cap returnstoo-large→ wrong-owner returnsno-such-process→ post-close returnsclosed. Refs #870. -
Host-call concurrency is split into separate blocking vs async-I/O semaphores — the LLM-path throughput gate. A single
host_semaphoresized atcores - 2previously gated every host call: both the blocking ones thatblock_in_place+block_onand pin a tokio worker for the whole permit-held wait (KV, identity, sys, fs, the net/process security gates, DNS, sockets) and the async-I/O ones that.awaitreal I/O and free the worker (HTTP request/stream,ipc::recv). Thecores - 2cap is right for the blocking class — it must not approach the worker-pool size or blocking host work starves the scheduler — but it throttles outbound I/O far below what the host sustains, capping the LLM/HTTP path.HostStatenow carries two gates:blocking_semaphore(host-derivedcores - 2, the same ceiling — but no longer contended by I/O calls, so strictly more headroom) andio_semaphore(host-derived, cores-scaled and clamped by half the processRLIMIT_NOFILE— floored at 1, so on an fd-scarce host the descriptor budget wins and the ceiling can fall below theIO_MINfloor, preventingEMFILE— since each in-flight async call may hold a socket fd). The four genericbounded_*host helpers are unchanged; each call site passes the semaphore matching its class (theblock_onhelpers take blocking, theawaithelpers take I/O). Net stays on the blocking semaphore for now — reclassifying its socket/accept/DNS paths, which do real I/O but currently use the blockingblock_onhelpers, to the async semaphore is a follow-up. Refs #816. -
Per-Store memory limiting now also meters per-principal peak usage. The plain
wasmtime::StoreLimitsthat capped each capsule instance's linear memory is replaced by aStoreMemoryMeterthat keeps the same enforcement (the per-invocationmax_memory_bytesceiling) and records, into a kernel-owned sharedMemoryLedger, the high-water linear-memory size each invoking principal grows a Store to — the RAM analogue of the per-principalFuelLedger. It is keyed by the same invoking principal (caller → owner → default) the fuel ledger charges, and re-targeted per invocation since a pooled Store is leased across principals. Sharded + atomic like the fuel ledger, so recording stays off the lock — and unlike the fuel ledger it is bounded (capped at a max principal count, evicting the lowest-peak entry when full) so a flood of ephemeral sub-agent principals cannot grow it without limit (astrid#827). Telemetry only — no deny path. Refs #816. -
The per-capsule instance pool is now dynamic and host-sized — the fixed
INSTANCE_POOL_SIZE = 16is gone. Each non-run-loop capsule used to eagerly instantiate exactly 16(Store, Instance)pairs at load regardless of machine size or actual concurrency — 16× the linear-memory footprint up front even for an idle capsule, and a hard concurrency ceiling of 16 on a large host. The pool now warm-starts at a smallmin_idle, grows lazily (a checkout that finds no warm instance mints a fresh one while holding a permit, so the total never exceeds the max) toward a host-derived max (cores-scaled, replacing the magic 16), and an idle-eviction timer trims the warm set back down tomin_idleafter a burst subsides, reclaiming the memory of instances built under load. Net effect: less resting memory for idle capsules, more peak concurrency on big hosts. The max is operator-overridable via[capsule].instance_pool_size,ASTRID_CAPSULE_INSTANCE_POOL_SIZE, orastrid-daemon --instance-pool-size. Run-loop andhost_processcapsules stay pinned to a single Store — thehost_processcarve-out can never lease a second one (enforced byallow_grow = false, belt-and-suspenders over its always-warm single instance). Free-checkout soundness is unchanged: a lazily-grown instance is built by the sameHostStatefactory as an eager one and reset identically on return. A RAM-budget-derived max lands with the per-principal memory ledger. Refs #816. -
Per-principal interceptor CPU is now summed cross-capsule into one kernel-owned
FuelLedger. Each capsule'sWasmEnginepreviously kept its own per-principal fuelHashMap, fragmenting a principal that drove N capsules into N independent sub-totals; the ledger is now a single kernel-ownedArc<DashMap<PrincipalId, AtomicU64>>cloned into every engine, summing a principal's interceptor CPU across all capsules. Sharded +