v0.7.0
Breaking
KernelRequest/KernelResponse/CommandInfo/CapsuleMetadataEntry/DaemonStatus/SYSTEM_SESSION_UUIDmoved fromastrid_types::kerneltoastrid_core::kernel_api. Re-exports underastrid_events::kernel_apiare preserved for migration ergonomics. The reason:astrid-typesis the WASM-compatible shared-types crate intended to compile onwasm32-unknown-unknownfor capsule SDK consumption — it cannot depend onastrid-core(which referencesPrincipalId,Quotas, and the rest of the kernel-only type universe). The kernel-management RPC surface (CLI ↔ daemon) doesn't belong in a WASM-compatible crate to begin with. CLI commands,socket_client,admin_client, the TUI, and the integration tests have all been updated to import fromastrid_core::kernel_api.
Added
astrid:fs/host.fs-mkdir-all— unstubbed. Idempotent recursive directory creation via the existing VFSmkdircall (every VFS impl already routes throughstd::fs::create_dir_allunder the hood). Capability gating, audit envelope (astrid:fs/host.fs-mkdir-all), and error mapping matchfs-mkdir. Unblocks capsule code that wants thestd::fs::create_dir_all-style idempotent variant instead of the strictfs-mkdir. (Closes one item of #753.)- Outbound TCP for capsules —
net.connect-tcphost fn +net_connectcapability. Capsules can now open persistent TCP connections via the newastrid:capsule/net.net-connect-tcp(host, port) -> stream-handlehost fn, gated by a per-capsulenet_connect = ["host:port", "host:*"]allowlist inCapsule.toml. The returned handle flows through the existingnet-read/net-write/net-close-streamplumbing, and the kernel reuses the sameis_safe_ipairlock that gateshttp-requestto reject loopback / private / link-local / multicast IPs after DNS resolution. Connect timeout bounded to 10s; per-capsule active-stream cap (MAX_ACTIVE_STREAMS = 8) shared with inboundnet-accept. Unblocks WebSocket clients, MQTT, Discord/Telegram gateways, postgres/redis, and the immediate motivator: a Unicity-network capsule wrapping Sphere SDK (Fulcrum + Nostr WebSocket transports). Tracking issue: #745. RFC: rfcs#27. WIT contract: wit#5. NetStreamenum (Unix + Tcp) inengine::wasm::host_state— replaces the bareArc<Mutex<UnixStream>>value type inactive_streams. Thenet_read/net_writedispatchers match on the variant; the inner framing (read_frame/write_framegeneric helpers) is shared viatokio::io::AsyncRead + AsyncWritetrait bounds. Single-variant capsules see no behavior change.CapsuleSecurityGate::check_net_connect(capsule_id, host, port)— new trait method, default-deny.ManifestSecurityGateimplements it by matching the requestedhost:portagainst the manifest'snet_connectallowlist (case-insensitive host, exact-or-*port).astrid-buildis target-agnostic now. Drops the hardcoded--target wasm32-wasip2flag oncargo build; instead lets the capsule's own.cargo/config.tomlselect the target. After compilation, probestarget/wasm32-unknown-unknown/release/first,target/wasm32-wasip2/release/second (and the workspace root) for the produced.wasm. When the produced artifact is a core wasm module (no Component Model layer = 1 in the magic bytes) — which is whatwasm32-unknown-unknownproduces —wit_component::ComponentEncoder::default().validate(true).module(&core).encode()wraps it into a Component Model component. Required because the Astrid-canonical guest target iswasm32-unknown-unknown(zerowasi:*imports), andcargodoes not produce a component directly for that target.astrid-build'sensure_componentoverwrites the original.wasmartifact instead of writing a sibling.component.wasm. CapsuleCapsule.toml [[component]] file = "<crate>.wasm"directives keep resolving without per-target conditional logic — the toolchain hides which target produced the artifact.astrid-typesclockfeature. Default OFF — gatesIpcMessage::new()and the#[serde(default = "Utc::now")]timestamp default behind a Cargo feature. Kernel-side consumers (astrid-events, the daemon) enable it via their dep declaration. Capsule-side consumers (viaastrid-sdkonwasm32-unknown-unknown) leave it off; absent timestamps fall back to the Unix epoch via afrom_timestamp(0,0)default. Capsule code reads timestamps from kernel-published messages, never constructs them.chrono = { default-features = false, features = ["serde"] }workspace-wide. Theclockfeature is what pullswasm-bindgen+js-sysonwasm32-unknown-unknown, which then inject__wbindgen_placeholder__imports thatwit-component's encoder refuses to round-trip. Records that need a clock value receive it fromastrid_sdk::time(audited host fn);DateTime<Utc>is only used as a serializable field shape, never constructed viaUtc::now()in capsule code.uuid = { default-features = false, features = ["v4", "v5", "serde", "rng-getrandom"] }workspace-wide. The default features pull ajs-based RNG onwasm32-unknown-unknown(viawasm-bindgen).rng-getrandomroutes v4 generation throughgetrandom, whichastrid-sysconfigures with a custom backend (astrid:sys.random-bytes) on capsule builds. Same dep wiring insdk-rust'sastrid-sdk.
Changed
- Kernel exposes zero
wasi:*.configure_kernel_linkerno longer registerswasmtime_wasi::p2::add_to_linker_sync. The Astrid-canonical guest target (wasm32-unknown-unknown) produces wasm with zerowasi:*imports; a capsule that somehow ships with awasi:*import fails to instantiate at load time with a clear "interface not found" error — the intended posture, not a bug. Capsules that historically targetedwasm32-wasip2and relied on auto-injectedwasi:*imports will fail to load until they migrate towasm32-unknown-unknownvia the upcoming SDK + capsule sweep (a separate PR cluster, blocked on this one landing first). crates/astrid-capsule/src/manifest.rssplit into amanifest/submodule. The 1000-line single file becamemanifest/mod.rs+manifest/capabilities.rs+manifest/topics.rs.CapabilitiesDef,PublishDef,SubscribeDef(with their custom deserializers and TOML parsing tests) live in dedicated submodules; the top-levelmanifest::*public API is preserved viapub usere-exports — no consumer-side change required.astrid-storage::kvsplit into a submodule directory.kv.rs(now ~1200 lines after the CAS addition) becamekv/mod.rs(validators, helpers, trait, re-exports) +kv/memory.rs(MemoryKvStore) +kv/surreal.rs(SurrealKvStore, behind thekvfeature) +kv/scoped.rs(ScopedKvStore), each well under the 1000-line CI ceiling. Public API preserved verbatim viapub use.wit/astrid-capsule.witresynced from canonicalunicity-astrid/witto pick up the newnet-connect-tcpfn and theipc-message.principalfield (canonical PR #4 from May; was missing from the in-tree copy). InternalIpcMessage → WitIpcMessageconversion now forwardsprincipal.
Fixed
TcpStream::read_bytes/peekreturnErr(ErrorCode::Closed)on cancellation. Previously both methods collapsed cancel toOk(Vec::new()), which is indistinguishable from a clean EOF in byte-stream reads. Capsules with EOF finalizers (write trailers, send last-message IPC, flush logs) would execute those finalizers under a forced unload. Now matchesread_frame/write_bytes/shutdown— Closed surfaces cancellation as its own signal. Empty Vec retains its "clean EOF" meaning.- Per-domain WIT review fixups (PR #752). A multi-agent review surfaced fixes addressed in-branch before merge:
ipc::recvmixed-principal batches are now truncated at the first publisher boundary so tail messages can't be silently mis-stamped with the head's principal context; newtruncate_to_homogeneous_principalunit tests cover the boundary cases.TcpStream::writesurfaces peer-disconnect IO kinds asErrorCode::ConnectionResetinstead of swallowing them asOk(()); pure-function tests pin theBrokenPipe / ConnectionReset / ConnectionAborted / UnexpectedEof → ConnectionResetmapping and usetokio::io::duplexto reproduce the live close.TcpStream::readcancellation now returnsNetReadStatus::Closed(notPending) so a cancelled run-loop terminates instead of busy-looping.spawn_backgroundregisters the spawned PID in theProcessTracker, and theProcessHandledrop path unregisters; previously atool.v1.request.cancelcould never reach backgrounded children.Subscriptionrecv keeps the resource handle valid across calls — theEventReceiverlives behind anArc<Mutex<...>>so the wasmtime resource is no longer deleted-and-re-pushed each blocking wait.read_filere-checks the post-read payload size forTooLargerather than relying on a pre-stat TOCTOU.ProcessHandle::waitusestokio::task::spawn_blocking(child.wait)racing atokio::time::timeoutinstead of the 50mstry_waitbusy-poll.unix_listener::acceptretries credential-rejected connections with a 100ms back-off to avoid a CPU-pinned spin against a hostile peer.http-streamper-chunk timeout extracted toHTTP_STREAM_READ_TIMEOUTnamed constant.- Build script now invalidates the WIT staging dir on
.gitmoduleschanges so CI runners that lazilygit submodule updatedon't compile against a stale tree.
- Per-domain WIT review fixups round 2 (Gemini, PR #752).
MAX_ACTIVE_STREAMS/MAX_SUBSCRIPTIONS/MAX_BACKGROUND_PROCESSESquota gates now read O(1) counter fields onHostState(net_stream_count,subscription_count,process_count_total,process_count_by_principal) instead of walking the entireResourceTable. Each successful resource insert bumps the counter; the matchingdropimpl decrements. Per-principal sub-budgets forspawn-backgrounduse aHashMap<PrincipalId, usize>keyed on the creator. Single-threaded: wasmtime stores are owned by exactly one OS thread.- CI workflows (
ci.yml) now check out thewit/submodule recursively soastrid-capsule's build script can stage the per-domain WIT packages. Previously every job that touched the build (check,clippy,test,msrv) panicked withread wit/host: No such file or directory.
- Atomic
kv_casacross capsules. The host fn used to emulate compare-and-swap with agetfollowed by aset, which raced across capsules running concurrently in the same kernel.KvStorenow exposes an atomiccompare_and_swap(namespace, key, expected, new)primitive;MemoryKvStoreserializes read+conditional-write under its existingRwLock, andSurrealKvStoreruns the comparison inside a single MVCC transaction guarded by an internal mutex that closes the TOCTOU between surrealkv'svalidate_write_conflictsand its actual write phase. Two concurrent-spawn regression tests (memory + surreal) assert exactly one of 16–32 racing tasks succeeds the swap. The capsulekv_cashost fn delegates straight to the new primitive — no more emulation.
Install
From source (requires Rust 1.94+):
cargo install astrid
Pre-built binaries:
Download the archive for your platform, extract, and add to PATH:
tar xzf astrid-*-$(uname -m)-*.tar.gz
sudo mv astrid-*/astrid astrid-*/astrid-daemon astrid-*/astrid-build /usr/local/bin/
Then run astrid init to set up capsules.
With many thanks from the following Astrinauts 🚀
- Joshua J. Bouw