Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Proposal: WASI Host Adapter

How capOS should host WebAssembly modules through the WebAssembly System Interface, without recreating ambient authority and without committing to a runtime that the userspace baseline cannot support today.

Problem

WASI is the natural sandboxed-execution path for capOS:

  • It is already designed to remove ambient authority. Preview 1 requires preopens — every file descriptor a module sees was granted by the host at startup. Preview 2 makes typed handles first-class through the Component Model.
  • A single host adapter unlocks every language with a useful WASI target: Rust, C/C++, Go (GOOS=wasip1), TinyGo, Python, Zig, AssemblyScript, any interpreter compiled to wasm.
  • Wasm linear-memory bounds checks plus capability scoping give defence in depth for untrusted plugins and third-party code without weakening the capOS isolation model.

The risk pattern is the same as POSIX: a host adapter that grants ambient authority would erase the property that makes WASI worth doing. Every WASI import must be backed by a typed capability the host process already holds. If the host does not hold the cap, the module cannot reach it.

WASI is not a substitute for native ports of languages that need real OS threads, full asynchronous I/O, signals, or large POSIX surfaces. Those remain the native runtime tracks. WASI is the right tool for sandboxing untrusted plugins, third-party scripts, isolated workloads, CPU-bound portable tools, and language ecosystems whose native capOS port has not yet been built.

Scope

In scope:

  • A capos-wasm userspace host adapter built on capos-rt.
  • A WASI Preview 1 surface whose imports map 1:1 to typed capOS capabilities.
  • Per-instance CapSet projection: each module sees only the caps the host grants for that instance.
  • Phase decomposition that picks one runtime for v0, lets later phases migrate to the Component Model and richer runtimes, and stays explicitly outside ambient authority.
  • Validation through QEMU smokes that prove granted and ungranted paths.

Out of scope for the first implementation:

  • wasi-threads (requires shared-memory + atomics + bulk-memory).
  • fork()-shaped semantics. Cannot clone wasm linear memory; same constraint as the browser-wasm proposal.
  • Synchronous signal delivery inside a wasm module. Fuel exhaustion plus host-driven termination are the only deterministic interruptions.
  • File-backed MAP_SHARED mmap.
  • Treating the wasm sandbox as the only isolation boundary for hostile modules — the capOS process boundary remains the primary boundary.
  • A custom non-portable WIT dialect with externref-typed cap handles. This proposal explicitly defers richer cap handles to Component Model resources (Phase W.7).

Current Manual Pages

  • Programming Languages summarizes WASI’s current status relative to Rust, Python, Go, C/C++, Lua, and POSIX adapter tracks.
  • Userspace Binaries Part 5 sketches the WASI host adapter at a higher level. This proposal supersedes that sketch with a full design surface; the userspace-binaries proposal continues to own the broader native-binary, language, and POSIX-adapter roadmap.
  • Userspace Runtime documents the implemented capos-rt surface that the host adapter consumes.
  • Browser/WASM covers the separate browser-hosted wasm experiment. The two proposals share wasm-runtime insight but target different substrates: WASI host adapter runs on capOS hardware; the browser proposal runs capOS concepts in a browser tab.
  • Lua Scripting covers a similar capability-scoped script runner shape; the WASI track is the untrusted / portable counterpart to that proposal’s trusted native runner.
  • Go Runtime covers the native GOOS=capos alternative to Go-on-WASI.

Research Grounding

Relevant research and external references:

In-tree references: this proposal lifts the capability-mapping table from docs/proposals/userspace-binaries-proposal.md Part 5 and the runtime survey/phase decomposition shape from comparable language-runtime planning work; concrete repo evidence appears inline below.

Design Principles

  1. WASI is not a kernel feature. The kernel sees a normal userspace process with a CapSet and a capability ring. The host adapter is one of many capos-rt-based binaries.
  2. The host adapter’s CapSet is the authority. WASI module bytes are data. They cannot create authority. Every import is satisfied by a cap the host already holds; absent caps are refused, not synthesised.
  3. Per-instance CapSets are subsets, not supersets. Each loaded module gets only the caps the manifest grants for that instance. The host’s own CapSet may be larger; the module never sees the parent.
  4. The wasm sandbox is defence in depth, not the isolation boundary. The capOS process boundary remains primary. Wasm bounds checking and immutable Module validation add a second software-enforced boundary inside the host process so an entire untrusted module image can be confined.
  5. Schema-first capability mapping. Each WASI function is backed by a typed capability, not by emulated POSIX semantics. POSIX-shaped integer fds in Preview 1 are a Preview 1 ABI requirement, not a capability model concession.
  6. Pick portable WASI, skip non-portable extensions. Custom imports with externref-typed cap handles would lock capOS into a non-portable WIT dialect that no other host implements. The Component Model’s typed resources are the right answer for first-class typed cap handles in wasm; defer to that path rather than inventing a one-vendor dialect.
  7. Fail closed. Any unimplemented WASI call returns ERRNO_NOSYS. Any cap lookup that fails returns the appropriate Preview 1 errno (ERRNO_BADF, ERRNO_ACCES, ERRNO_NOSYS). Modules cannot probe absent caps for ambient behavior.

Architecture

flowchart TD
    Manifest[boot manifest:<br/>system-wasm-host.cue] --> Host[capos-wasm process]
    Host --> Runtime[wasm runtime<br/>wasmi v0]
    Host --> Rt[capos-rt typed clients]
    Rt --> Ring[capability ring]
    Ring --> Kernel[kernel CapObject dispatch]
    Ring --> Services[userspace services]

    Runtime --> Module[wasm module instance]
    Module --> Imports{WASI imports}
    Imports --> FdTable[per-instance fd table /<br/>Preview 2 resource handles]
    FdTable --> Caps[granted typed caps]
    Caps --> Rt

capos-wasm is one userspace process. It hosts one or more wasm module instances. The runtime engine (wasmi for v0; see Runtime Selection below) is linked into that process. WASI imports are resolved by the host adapter’s import-resolver module against typed capOS clients. Each instance has its own per-instance fd table (Preview 1) or resource bundle (Preview 2) populated from the manifest grants for that instance.

The runtime exposes only what the host process can fulfil. If the host does not hold an EntropySource cap, random_get returns ERRNO_NOSYS. If the manifest did not grant a home namespace, the module’s preopen table does not contain it and path_open("/home/...") resolves to nothing.

Runtime Selection

For v0 (Phases W.1 through W.6), use wasmi. For W.7+, evaluate migration to wasmtime when capOS userspace gains std support and a futures executor, or to WAMR if minimal footprint becomes the dominant constraint and the C build path lands.

ConstraintwasmiWAMRwasm3wasmtime
Pure Rust, drops into capOS workspaceyesC (needs cc/build glue, no libcapos yet)C (same problem)yes
no_std + allocyes, advertised explicitlypartial (embedded, libc-shaped)yes (bare metal)no (needs std and a futures executor)
LicenseApache-2.0 / MITApache-2.0 with LLVM exceptionMITApache-2.0
Footprintsmall register-based bytecode (v0.32 5x speedup)~29 KB AOT, ~58 KB interpreter~64 KB code, ~10 KB RAMlarge (Cranelift JIT)
Sandboxingwasm spec + execution-engine isolationwasm spec + AOT validationwasm specwasm spec + Cranelift verifier
Fuel/gas meteringyes, built-innot advertisedyesyes
Capability transferexternref since 0.24; component model on roadmapreference types yes; component model partialpartial reference typesfull component model (best-in-class)
WASI versionspreview1 stable; preview2 on roadmappreview1 stable; preview2 partialpreview1 partialpreview1 + preview2 + components
Host function interfacemirrors wasmtime APIC API; Rust through wamr-rust-sdkC APIRust + C
Maintenancewasmi-labs, two security audits (2023, 2024)Bytecode Alliance, TSC-governedmaintainer in minimal-maintenance phaseBytecode Alliance flagship
Threadingnot in current scopeyes (wasi-threads)noyes

Why wasmi for v0:

  • Pure Rust drops directly into the capOS workspace. No C build chain required — the same chain libcapos does not yet provide.
  • Genuine no_std + alloc support means no host-side OS abstraction is required for the runtime itself; it sits cleanly on capos-rt.
  • Built-in fuel metering matches capOS’s preference for explicit resource accounting.
  • externref support is sufficient for any future v1 capability-handle experiment that does not block on the Component Model.
  • Mirroring the wasmtime API means that migrating to wasmtime in W.7 is rewiring imports, not rewriting host calls.

Not chosen for v0:

  • wasmtime needs std userspace and a futures executor. capOS userspace is no_std + alloc today; this is the same blocker that keeps the Rust capnp-rpc crate (v0.25) off capos-rt and queues the remote-session-client capnp-rpc rewrite behind an async runtime decision.
  • wasm3 is in maintainer-declared minimal-maintenance phase; not a good fit for a long-horizon capOS substrate.
  • wasmer has similar weight to wasmtime and does not align as cleanly with the Bytecode Alliance Preview 2 trajectory.
  • WAMR is a strong candidate when a C toolchain and libcapos exist and minimal footprint is the goal. It is the migration target for high-density wasm hosting later, but it is not the v0 baseline because the C substrate is not in tree.

WASI Version Stance

  • Preview 1 for v0 (Phases W.1 through W.6). POSIX-shaped, file-descriptor-based, C-friendly. Tier 2 in upstream Rust since 1.78 (May 2024); supported by Go 1.21+ (GOOS=wasip1 GOARCH=wasm), TinyGo, Clang --target=wasm32-wasi, Zig. This is the immediate unlock.
  • Preview 2 / Component Model for W.7+. Resources are first-class typed handles. They are the natural mapping for capOS capabilities — closer in shape to OwnedCapability<T> than to integer fds. WIT interfaces let cap-aware Rust crates export typed APIs that a wasm component on capOS or a native capOS service can consume the same way it consumes a capnp interface.

Skipping Preview 1 entirely and starting at Preview 2 is possible with wasmtime today, but harder with wasmi; doing so would push the entire v0 unlock behind the std-userspace decision. The Preview 1 first / Preview 2 later sequencing is the smaller-step path to running C, Rust, Go, Python, TinyGo on capOS.

Capability Mapping Surface

Preview 1: per-import mapping

Each Preview 1 import is backed by a typed capOS capability the host adapter already holds. POSIX inherits ambient authority through global path namespaces, integer fds, and a process credential table; WASI removes that by requiring preopens, and capOS pushes it further by requiring an explicit per-import cap mapping in the host adapter.

WASI preview1 importcapOS host-adapter implementation
args_get / args_sizes_getRead from a future capOS LaunchParameters cap or per-instance arena. Empty by default until that surface lands.
environ_get / environ_sizes_getRead from a KeyValueScope / ConfigOverlay cap when one exists; empty by default. Open question §6.
clock_time_get(MONOTONIC)Timer.now() over the host’s TimerClient.
clock_time_get(REALTIME)Future wall-clock cap; until then return ERRNO_NOSYS or ERRNO_INVAL.
proc_exit(code)Map to a host-internal “instance exited with code” status. The host process does not exit; the wasm instance does.
random_getThe kernel EntropySource cap (the in-tree CSPRNG capability; see schema/capos.capnp interface EntropySource and KernelCapSource::EntropySource). Refuse with ERRNO_NOSYS when the host adapter was not granted entropy authority.
fd_write(1, ...) / fd_write(2, ...)Pre-opened fd 1 to host’s Console / TerminalSession write path; fd 2 to same or a separate log cap if granted.
fd_read(0, ...)Pre-opened fd 0 to a granted TerminalSession or future StdIO input cap if available; else ERRNO_BADF. No bare in-tree StdinReader cap exists today; non-terminal stdin requires a future input cap.
path_open(preopened_dir_fd, path, ...)Resolve path inside the Namespace cap mounted as that preopen, then open through the namespace’s Store / File capability.
fd_read / fd_write on opened filesTranslate to the typed File capability behind the host-side fd table entry.
fd_closeDrop the typed cap handle (release-on-drop in capos-rt).
fd_seek / fd_tell / fd_filestat_getMethods on the File cap.
fd_prestat_get / fd_prestat_dir_nameEnumerate the host adapter’s preopened-directory table built from manifest grants.
sock_send / sock_recv / sock_shutdownTranslate to typed TcpSocket / UdpSocket cap calls.
poll_oneoffMultiplex over the host’s capability ring; CQEs are the event source. Open question §3.
fd_advise / fd_allocate / fd_renumberStub or ERRNO_NOSYS until needed.
sched_yieldNo-op or single-tick yield through the runtime’s scheduler.

Preview 2: WIT-resource mapping

When the host adapter migrates to Preview 2 (Phase W.7+), the imports become typed capOS capabilities directly through WIT resources:

WIT package / interfacecapOS host-side cap
wasi:io/streams (input-stream, output-stream resources)Wrap one capOS cap per stream (Console / TerminalSession / File / TcpSocket). The resource handle in wasm corresponds 1:1 to a host-side OwnedCapability<T>.
wasi:filesystem/types (descriptor resource)One OwnedCapability<File> or OwnedCapability<Directory> per descriptor. Preopened dirs become resource handles passed at instantiation.
wasi:clocks/{monotonic-clock,wall-clock}Timer / future wall-clock cap.
wasi:random/{random,insecure}EntropySource cap.
wasi:sockets/tcp (tcp-socket resource)TcpSocket cap.
wasi:cli/{stdin,stdout,stderr,environment,exit}Per-instance CapSet projection.
wasi:http/incoming-handler / outgoing-handlerMatch capOS HttpEndpoint / Fetch (drafted in service-architecture-proposal.md).

Components in the same store can pass resources to other components; the host mediates the move. This maps directly to capOS capability transfer semantics — the same shape as the kernel’s result-cap insertion for typed cap returns from a CALL.

Capability Handle Path in the Module

How a wasm module receives and refers to a capOS capability is one of the load-bearing design questions. Three options:

  1. Preview 1 + integer fds, host-side fd table only (recommended for v0). All caps live in the host process. The module sees integer fds. The host adapter maps fds to OwnedCapability<T> slots in its own per- instance table. Works with every existing wasip1 binary unchanged. A wasm module cannot pass a typed cap to another wasm module without going through the host.
  2. Custom externref import (alternative; not recommended). Requires the reference-types proposal (supported by wasmi >=0.24, wasmtime, wasmer; partial in wasm3). The host adapter exports custom imports like cap_call_ref that take an externref typed handle. This is non-standard and locks capOS into a one-vendor WIT dialect that no other host implements; it would also delay Preview 2 adoption because the dialect would need its own mapping policy.
  3. Preview 2 / Component Model resources (target for W.7+). Resources in the Component Model are unforgeable typed handles. Components that import wasi:filesystem/types.descriptor receive a handle that is the host-side OwnedCapability<File>. Components can pass resources to other components in the same store; the host mediates. Direct match to capOS capability transfer semantics.

Recommendation: ship Preview 1 + integer fds for v0; defer rich typed-cap-in-module support to Preview 2 in W.7. Skip the externref custom-import path entirely.

Per-Instance vs Per-Process Model

Two reasonable shapes:

  1. One wasm instance per capos-wasm process (recommended for v0). Faults are isolated at the capOS process boundary. Fuel and budget enforcement are per-process and use the existing capOS resource accounting. Manifest-grant shape stays simple: each manifest entry names one binary and one cap bundle.
  2. Many instances per capos-wasm process (alternative). Better density. Suits hosting many small modules (plugin systems, embedded scripts). Adds host-side scheduling concerns: a runaway instance can starve siblings; fuel/budget enforcement now has to demultiplex; the poll_oneoff reactor question becomes load-bearing.

Recommendation: one instance per process for v0. Revisit when instance count actually matters. The capOS process boundary is already a strong isolation primitive; trading it away for density before density is needed adds complexity for no v0 unlock.

Per-Instance CapSet Plumbing

Each loaded module gets a per-instance capability bundle. The host adapter receives manifest grants and projects them onto WASI imports.

The shape needs to land alongside argv/env passing — argv for wasm modules has the same lifecycle question as argv for native processes. When a future capOS LaunchParameters surface lands it becomes the canonical source for both argv and env. Until then, a small bounded text grant in the host adapter manifest is acceptable for v0 (Open Question §6 / §7).

Sketch of the manifest shape (pre-LaunchParameters):

wasm_host: {
    binary: "thing.wasm"
    args: ["--input", "data"]
    caps: {
        console:   @console
        timer:     @timer
        random:    @random
        // preopen 3 → home namespace; preopen 4 → tmp namespace, etc.
        preopens: [
            { fd: 3, namespace: @home_namespace, name: "/home" }
            { fd: 4, namespace: @tmp_namespace,  name: "/tmp" }
        ]
    }
}

Same authority model the rest of capOS uses: every cap the module sees is named in the manifest and granted by the parent. The wasm sandbox is defence in depth on top of capability scoping, not a replacement.

Trust Boundaries

BoundaryNative capOS serviceWASI host adapter + module
Authority sourceProcess CapSetHost CapSet then per-instance subset
Memory isolationPage tablesWasm linear-memory bounds-check plus page tables (host process)
Code integrityW^X + NXWasm module validation plus immutable WebAssembly.Module
Cap forgeryKernel-owned CapTableHost-owned per-instance fd table or resource-handle table; module sees opaque ints/handles only
Resource limitsKernel quotasWasm fuel + memory cap + host-side per-instance time/byte budgets
Side channelsHardware-level (Spectre etc.)Same hardware level, plus wasm-specific (e.g. timer resolution)

Wasm does not weaken capOS isolation; it adds a second software-enforced boundary that contains an entire untrusted module image. This is exactly the property that makes WASI a good fit for plugin and script loading.

What WASI Does Not Solve

  • fork(): cannot clone wasm linear memory mid-execution. Same reason the browser-wasm proposal documents. POSIX programs that fork-then-exec must use posix_spawn-shaped equivalents, or the host adapter must spawn a new wasm instance.
  • Synchronous signals: no preemption inside a wasm module without cooperative yield points or interrupted execution. Fuel exhaustion is the only deterministic interruption; gross preemption is “host kills the instance”. Acceptable for plugins.
  • Threads without wasm-threads: requires shared-memory + atomics + bulk-memory features and a runtime that supports them. Out of scope for v0.
  • Live mmap of files: wasm linear memory is not file-backed. Workable only for small read-or-write cycles.

Phase Decomposition

Smallest reviewable slices ordered by dependency. Each phase is independently demoable and gates the next.

Phase W.0 — Decision and host runtime selection (planning)

  • Decide runtime: wasmi vs WAMR (recommendation above).
  • Land this proposal and the matching docs/tasks/ task record for the first WASI host-adapter slice.
  • Resolve cross-cutting open questions §1, §3, §6, §7, and §8 below (the §8 vendoring posture decision gates the W.1 scaffold layout).

Deliverable: agreed proposal plus dispatchable task record. No code.

Phase W.1 — capos-wasm host process scaffold (no WASI yet)

Status: host-runtime scaffold landed 2026-05-05 19:12 UTC. Manifest and make run-wasm-host smoke moved into Phase W.2 (see Status note below).

  • New crate capos-wasm/ — userspace process built on capos-rt.
  • Vendor the chosen runtime (wasmi recommended; one local cargo dep patched for no_std + alloc if needed).
  • Host process can WebAssembly.compile(bytes) then instantiate(no imports) then run an empty _start. No imports resolved yet.
  • Manifest: new system-wasm-host.cue boots one host process with one embedded .wasm blob (the smoke binary).
  • Smoke: make run-wasm-host boots, host loads the empty blob, prints [wasm-host] empty module instantiated and exited, host exits cleanly.

Status note (revised 2026-05-06 20:19 UTC): the v0 W.1 slice landed only the host-runtime substrate — the capos-wasm/ standalone crate, the vendored vendor/wasmi-no_std/wasmi-1.0.9/ snapshot, and the make capos-wasm-build target — without a wasm-host binary, system-wasm-host.cue manifest, or make run-wasm-host smoke. The binary/manifest/smoke trio was rolled into Phase W.2 and landed there in W.2 sub-slice 1 (2026-05-06 20:19 UTC) using an inline 8-byte empty wasm module as the payload. Earlier drafts of this status note worried about re-cutting the same host binary twice (once empty, once with a Preview 1 surface) and proposed deferring the empty-module smoke until “hello, wasi” was ready; the actual outcome went the other way: the empty-module regression is its own slice that exercises wasmi’s Module::new + Linker::instantiate_and_start end-to-end on capOS, and later W.2 sub-slices extend the same binary in place with the Preview 1 import resolver and language-level smokes.

Deliverable: a wasm runtime crate compiles and links inside the capOS userspace no_std + alloc build. No imports, no host functions, no WASI. Validates the runtime crate works in no_std + alloc userspace and that the vendored wasmi snapshot exposes Engine and Store<HostState> to a future host binary.

Validation: make capos-wasm-build succeeds against targets/x86_64-unknown-capos.json with no_std + alloc; make fmt-check and the host test gates remain green; the kernel and other userspace crates are untouched (no kernel surface, no schema/capos.capnp change, no init/ change).

Phase W.2 — WASI Preview 1 stdout-only

Inherits from W.1: the wasm-host binary, system-wasm-host.cue manifest, and make run-wasm-host smoke originally listed under W.1 land here in sub-slice 1, so the same binary that future sub-slices extend with the Preview 1 import surface also provides the empty-instantiation smoke.

The phase is landing in four sub-slices, not one big drop, to keep each diff reviewable. random_get production wiring stays owned by Phase W.4 (entropy + clocks production-ready); W.2 leaves it stubbed as ERRNO_NOSYS:

  • W.2 sub-slice 1 (landed): wasm-host binary, system-wasm-host.cue empty-instantiation manifest, make run-wasm-host smoke, and the one-time userspace ABI bump (USER_STACK_BASE etc.) that wasmi’s ~3 MiB BSS forced.

  • W.2 sub-slice 2 (landed 2026-05-07 08:03 UTC): Preview 1 stdout-only imports (args/environ as empty, clock_time_get(MONOTONIC), proc_exit, fd_write(1,…)/fd_write(2,…)); everything else stubs as ERRNO_NOSYS including random_get (Phase W.4 promotes that to production). The wasm-host smoke now drives a 114-byte hand-encoded probe module that calls random_get, stores the returned errno in an exported global, and refuses to print the nosys=52 proof line unless it equals ERRNO_NOSYS.

  • W.2 sub-slice 3 (landed 2026-05-07 09:36 UTC): Rust hello, wasi smoke (demos/wasi-hello-rust/, system-wasi-hello-rust.cue, make run-wasi-hello-rust). The wasm-host binary now optionally reads a BootPackage cap, walks the manifest’s binaries[] for the wasi-payload entry, instantiates it through the same Preview 1 linker, and explicitly invokes the _start export (wasmi’s instantiate_and_start runs the WebAssembly start section, NOT WASI’s _start). The sub-slice 1+2 regression keeps running first; the existing make run-wasm-host smoke continues to pass because it does not grant boot.

  • W.2 sub-slice 4 (landed 2026-05-07 10:53 UTC): C hello, wasi smoke (demos/wasi-hello-c/, system-wasi-hello-c.cue, make run-wasi-hello-c). The wasm-host payload-load path landed in sub-slice 3 carries the C .wasm payload too — sub-slice 4 only added the C toolchain wiring (system clang-18 with --target=wasm32-wasi --sysroot=/usr against the Ubuntu wasi-libc + libclang-rt-18-dev-wasm32 packages), the second manifest, the matching smoke harness, and these closeout stamps. Phase W.2 is done.

  • W.2 sub-slice 1 (landed 2026-05-06 20:19 UTC): the wasm-host userspace binary, system-wasm-host.cue empty-instantiation manifest, tools/qemu-wasm-host-smoke.sh assertion harness, and the userspace-image budget bump that wasmi’s ~3 MiB BSS requires. USER_STACK_BASE moved from 0x60_0000 to 0x100_0000 in capos-config/src/process_layout.rs; RING_VADDR (capos-config/src/ring.rs) and CAPSET_VADDR (capos-config/src/capset.rs) shifted in lockstep, and every linker.ld assertion (init/, capos-rt/, demos/, shell/, capos-wasm/) and the system-spawn.cue stack-overlap-elf fixture were updated to match. No Preview 1 imports yet — the binary instantiates the inline 8-byte empty wasm module and exits cleanly through the existing capos-rt entrypoint.

  • W.2 sub-slice 3 (landed 2026-05-07 09:36 UTC) and W.2 sub-slice 4 (landed 2026-05-07 10:53 UTC): language-level Rust + C hello, wasi smokes plus the manifest-payload load path on the wasm-host binary. Phase W.2 is closed by sub-slice 4.

Sub-slice 1 (landed) delivered:

  • The wasm-host userspace binary built on the W.1 scaffold, instantiating an inline 8-byte empty wasm module through wasmi::Linker::instantiate_and_start.
  • Manifest system-wasm-host.cue (empty-instantiation regression).
  • Smoke make run-wasm-host (asserted by tools/qemu-wasm-host-smoke.sh).

Sub-slice 2 (landed) delivered:

  • capos-wasm/src/wasi/preview1.rs Preview 1 import resolver on top of the existing wasm-host binary, registering 46 wasi_snapshot_preview1 imports against a fixed-arity wasmi::Linker<HostState>.
  • Implemented surface: args_get, args_sizes_get, environ_get, environ_sizes_get (all return zero counts / empty buffers); clock_time_get(CLOCKID_MONOTONIC) via the host’s TimerClient (CLOCKID_REALTIME returns ERRNO_NOSYS until a wall-clock cap exists); proc_exit via capos_rt::syscall::exit; fd_write(1, …) and fd_write(2, …) via the host’s Console.write byte path with a fixed 4 KiB scratch ceiling (oversize total → ERRNO_INVAL); all other Preview 1 imports stubbed as ERRNO_NOSYS (including random_get, which Phase W.4 promotes against EntropySource).
  • Manifest update (system-wasm-host.cue now grants Console + Timer) and smoke harness update (tools/qemu-wasm-host-smoke.sh asserts the new [wasm-host] preview1 imports linked: ...; nosys=52 proof line in addition to the empty-instantiation regression).
  • Probe-driven evidence: a 114-byte hand-encoded probe module imports random_get, calls it once at instantiation, stores the returned errno in an exported global, and the host refuses to print the proof line unless the global reads back as ERRNO_NOSYS = 52.

Sub-slice 3 (landed 2026-05-07 09:36 UTC) delivered:

  • demos/wasi-hello-rust/ standalone crate built against the upstream wasm32-wasip1 target. Source is a single println!; the produced hello.wasm (~40 KiB) imports environ_get, environ_sizes_get, fd_write, and proc_exit from wasi_snapshot_preview1, all of which the sub-slice 2 resolver already implements.
  • capos_wasm::payload helper module: streams the capnp-encoded SystemManifest blob through BootPackage.readManifestChunk (4 KiB chunks) and walks binaries[] via raw capnp readers to return the bytes for a named entry. The wasm-host binary calls this only when the manifest grants the optional boot (BootPackage) cap, so the sub-slice 1+2 make run-wasm-host smoke – which does not grant boot – keeps passing unchanged.
  • system-wasi-hello-rust.cue manifest: lists the wasm-host ELF and the wasi-payload blob, grants Console + Timer + BootPackage to the wasm-host, and reuses the shared cue/defaults package.
  • tools/qemu-wasi-hello-rust-smoke.sh smoke harness: asserts the existing sub-slice 1 + 2 proof lines, the new Hello from WASI on capOS payload stdout (the load-bearing evidence), and the clean process/scheduler exit pair. The wasm-host payload-stage proof line is not asserted because wasi-libc’s _start is allowed to terminate via proc_exit from inside the Preview 1 import handler, in which case the host process exits before the wasm-host can print its post-_start proof line.
  • make wasi-hello-rust-build cargo wrapper that clears RUSTFLAGS/CARGO_ENCODED_RUSTFLAGS so the kernel-target rustflags pinned in the repo .cargo/config.toml do not leak into the wasm build.
  • capos-rt re-export additions: capos_capnp and default_reader_options are now reachable from capos_rt::* so capos-wasm keeps a single direct path-dep on capos-rt and the vendored wasmi tree (adding capos-config directly to capos-wasm triggered an unrelated cargo workspace-inheritance error against the vendored wasmi at vendor/wasmi-no_std/wasmi-1.0.9/).

Sub-slice 4 (landed 2026-05-07 10:53 UTC) delivered:

  • demos/wasi-hello-c/ standalone C smoke (NOT a Cargo crate; built directly with system clang-18 + lld via the Makefile wasi-hello-c-build target). Source is a single printf("Hello, wasi from capOS C\n") main() compiled with --target=wasm32-wasi --sysroot=/usr against the Ubuntu wasi-libc + libclang-rt-18-dev-wasm32 apt packages; the produced hello-c.wasm (~46 KiB) imports five functions from wasi_snapshot_preview1: fd_close, fd_fdstat_get, fd_seek, fd_write, and proc_exit. fd_write and proc_exit reach the host’s granted Console cap and the clean capos-rt exit path implemented in sub-slice 2; fd_close, fd_fdstat_get, and fd_seek return ERRNO_NOSYS = 52 from the same sub-slice 2 stub surface, which is sufficient for wasi-libc’s stdout-only path.
  • system-wasi-hello-c.cue manifest: same shape as system-wasi-hello-rust.cue, lists the wasm-host ELF and the wasi-payload blob, grants Console + Timer + BootPackage to the wasm-host, and reuses the shared cue/defaults package.
  • tools/qemu-wasi-hello-c-smoke.sh smoke harness: asserts the existing sub-slice 1 + 2 proof lines, the new Hello, wasi from capOS C payload stdout (the load-bearing evidence), and the clean process/scheduler exit pair.
  • make wasi-hello-c-build target that runs system clang with RUSTFLAGS/CARGO_ENCODED_RUSTFLAGS cleared (matching the wasi-hello-rust-build shape so the two flows stay symmetric).
  • No host-side change to capos-wasm/: the manifest-payload load path landed in sub-slice 3 carries the C .wasm payload through the same wasm-host binary unchanged.

Deliverable: the first WASI-hosted, sandboxed portable-payload language path lands on capOS. Both Rust (wasm32-wasip1) and C (wasm32-wasi) hello, wasi payloads run inside the wasmi interpreter under the wasm-host capOS process and reach the host’s granted Console cap through Preview 1 fd_write. Native C already boots through the libcapos C-substrate (make run-c-hello) and the POSIX adapter (make run-posix-dns-smoke); this phase specifically adds the WASI-hosted path – in particular, C runs on capOS through the WASI surface without requiring any libcapos/POSIX work in tree, because the wasm-host’s host-side imports cover everything the wasi-libc stdout-only path needs.

Phase W.2 closed 2026-05-07 10:53 UTC. Phase W.3 closed 2026-05-07 18:25 UTC. Phase W.4 closed 2026-05-07 20:09 UTC.

Phase W.3 — Per-instance CapSet plumbing + LaunchParameters

Status: landed 2026-05-07 18:25 UTC. Per-instance CapSet selection keeps using the existing manifest cap-grant block on initConfig.init.caps (no new cap needed for the v0 argv path); the new surface is the bounded-text argv grant on initConfig.init.wasiArgs. The wasm-host pulls it out of the manifest blob through its already-granted BootPackage cap, validates it against the bounds in capos-wasm/src/payload.rs (WASI_ARGS_MAX_COUNT = 32, WASI_ARGS_MAX_ARG_BYTES = 4096, WASI_ARGS_MAX_TOTAL_BYTES = 8192), packs it into a per-instance HostState argv buffer, and reflects it back through Preview 1 args_get / args_sizes_get. A 2026-05-13 successor mirrors the same bounded-text pattern for environment variables through initConfig.init.wasiEnv, validated against WASI_ENV_MAX_COUNT = 32, WASI_ENV_MAX_ENTRY_BYTES = 4096, and WASI_ENV_MAX_TOTAL_BYTES = 8192, with interior NULs rejected before the payload instantiates. Open Question §5 / §6 / §7 status is recorded in the section below; a future capOS LaunchParameters cap is still the migration path for argv and environment together.

  • Per-instance CapSet selection: keeps using the manifest-defined cap-grant block (initConfig.init.caps) the W.2 sub-slice 3 / 4 smokes already exercised. Phase W.3 does not add a new cap; it adds the wasiArgs bounded-text grant alongside the cap list. Future phases (W.4 entropy, W.5 namespaces, W.6 sockets) will extend the same caps block with their respective surfaces.
  • Bounded-text argv grant: initConfig.init.wasiArgs is a CUE text list. Schema/schema/capos.capnp is unchanged because initConfig is already CueValue and unknown sub-fields under initConfig.init are ignored by the existing manifest decoder. The wasm-host walks the field directly through raw capnp readers in capos-wasm/src/payload.rs::read_wasi_args. An absent or empty wasiArgs keeps the W.2 “no argv” behaviour (args_sizes_get reports zero, args_get writes nothing) so the existing make run-wasm-host, make run-wasi-hello-rust, and make run-wasi-hello-c smokes stay unchanged.
  • Bounded-text environment grant: initConfig.init.wasiEnv is a CUE text list of entries such as KEY=value. It uses the same raw capnp reader path as wasiArgs, the same no-schema-change initConfig CueValue extension point, and the same empty-by- default behavior: absent or empty wasiEnv makes environ_sizes_get report zero and environ_get write nothing. Oversized entry count, oversized individual entries, oversized packed total bytes, and interior NUL bytes make wasm-host abort with stable exit codes rather than truncating or corrupting the WASI Preview 1 NUL-terminated layout.
  • Migration to a future LaunchParameters cap: when capOS gains a capability-shaped LaunchParameters surface (the same one envisioned by docs/proposals/userspace-binaries-proposal.md Part 5 and the future shell launch flow), the wasm-host will swap read_wasi_args for a typed LaunchParametersClient lookup and the manifest-side wasiArgs field becomes redundant. The bounds constants stay relevant either way (a typed LaunchParameters cap will still need byte ceilings before it ships argv into wasm linear memory).
  • Smoke: demos/wasi-cli-args/ (Rust, wasm32-wasip1) reads argv[1] and prints it through println! -> fd_write(1, …) -> the host’s Console cap. The harness (tools/qemu-wasi-cli-args-smoke.sh) asserts the existing sub-slice 1 + 2 regression lines plus the load-bearing capos-wasi-cli-args-sentinel line.

Deliverable: per-instance CapSet selection works (commit landed 2026-05-07 18:25 UTC; smoke make run-wasi-cli-args).

Phase W.4 — WASI Preview 1 random + clocks production-ready

Status: landed 2026-05-07 20:09 UTC. The wasm-host looks up an optional per-instance EntropySource cap from the CapSet under the well-known name random. When the manifest grants it, the typed EntropySourceClient is installed on HostState after the W.2 sub-slice 2 probe regression runs (so the probe’s random_get(0, 0) call still observes the closed-fail ERRNO_NOSYS = 52 path byte-identically with the W.2/W.3 proof line). Preview 1 random_get then drains arbitrary wasm-supplied byte ranges into the manifest-granted entropy stream by chunking against the kernel cap’s per-call MAX_ENTROPY_FILL_BYTES = 64 ceiling and walking up to RANDOM_GET_MAX_BYTES = 65_536 total bytes per Preview 1 invocation. Truncated kernel responses, RDRAND unavailable status, and any transport-level error surface as ERRNO_IO; out-of-bounds wasm pointer writes surface as ERRNO_FAULT; oversized requests surface as ERRNO_INVAL. The ungranted-variant manifest still routes Preview 1 random_get through the no-grant refusal branch which never enters the kernel, so an instance without an EntropySource grant cannot leak entropy.

  • Wire the kernel EntropySource cap (the in-tree CSPRNG capability; see EntropySourceClient and KernelCapSource::EntropySource) through the host adapter as the backing for random_get. The same cap is the natural future analogue of the browser’s crypto.getRandomValues surface.
  • Wall-clock support stays deferred until capOS has a typed WallClock / RealTimeClock cap. clock_time_get(CLOCKID_REALTIME) keeps returning the W.2 sub-slice 2 sentinel ERRNO_NOSYS so a Preview 1 guest can distinguish “host refused” from a kernel / transport failure; future phases promote it once the wall-clock cap lands. The monotonic clock keeps using the manifest-granted Timer cap unchanged.
  • Smoke: demos/wasi-random/ (Rust, wasm32-wasip1) reads N=64 bytes via a raw Preview 1 import binding (avoiding wasi-libc’s panic-on-errno wrapper so the ungranted-variant payload can print a refusal sentinel and exit with code 52 rather than aborting). The granted-variant smoke (make run-wasi-random / tools/qemu-wasi-random-smoke.sh) asserts the W.2 sub-slice 1 + 2 regression proof lines, the load-bearing [wasi-random] entropy_bytes=64 entropy_bound_ok=true line, and a clean exit; the ungranted-variant smoke (make run-wasi-random-ungranted / tools/qemu-wasi-random-ungranted-smoke.sh) asserts the same regression lines plus the load-bearing [wasi-random] random_get returned errno=52 (ENOSYS) refusal sentinel and refuses the granted-variant entropy line.

Deliverable: Preview 1 random_get is wired to the kernel EntropySource cap with the closed-fail refusal contract, the clock_time_get(REALTIME) deferral is documented, and the ungranted-variant smoke proves both. A 2026-05-13 compatibility slice also promotes authority-free Preview 1 imports that need no new cap: clock_res_get(CLOCKID_MONOTONIC) returns the monotonic nanosecond resolution, sched_yield returns success as a no-op, fd_fdstat_get for stdout/stderr returns character-device write metadata, and fd_seek for stdout/stderr returns ERRNO_SPIPE. The direct-import make run-wasi-stdio-fd smoke requires all promoted imports to return non-ERRNO_NOSYS results. The remaining non-filesystem / non-socket Preview 1 imports that still return ERRNO_NOSYSpoll_oneoff, proc_raise, fd operations that need file or close-state authority, and the path_* paths – stay future work; promoting each to “honest” needs either the typed capability it would route through (for example a WallClock / RealTimeClock cap for REALTIME or namespace/file caps for storage fds and paths) or an explicit decision to keep the NOSYS refusal as the v0 honest behaviour. Phase W.4 closed 2026-05-07 20:09 UTC.

Harness-hardening landed on 2026-05-13: make run-wasi-preview1-refusals boots a direct-import payload that calls representative blocked filesystem/socket imports with no Namespace/File/Store/socket authority in the manifest and requires each return to equal ERRNO_NOSYS = 52. The initial slice (2026-05-13 08:50 UTC) covered path_open, fd_prestat_get, fd_read, sock_send, sock_recv; a follow-up (2026-05-13 21:15 UTC) extended the harness to also cover fd_pread, fd_pwrite, path_create_directory, and sock_shutdown, bringing the total to nine covered imports. As each filesystem import gains a real implementation its no-preopen errno migrates from ERRNO_NOSYS = 52 to ERRNO_BADF = 8 (path_open / fd_prestat_get / fd_read with Phase W.5; path_create_directory on 2026-05-24 10:09 UTC; fd_pread / fd_pwrite when positional I/O landed – see below); the harness asserts the current errno per import rather than a blanket NOSYS. Only the socket imports (sock_send / sock_recv / sock_shutdown) still return ERRNO_NOSYS = 52. This records fail-closed evidence for the current surface only; it does not implement W.6 behavior.

Phase W.5 — WASI Preview 1 filesystem (landed 2026-05-17 05:42 UTC)

  • Map preopened-dir fds to a manifest-granted root Directory cap from the per-instance CapSet. The v0 surface ships a single preopen at fd 3 named /preopen-0; the manifest CapSet slot name is root (matching the POSIX adapter P1.4 Slice 4 bootstrap). Namespace / Store integration is deferred until a use case requires the content-addressed pseudo-fs shape – the kernel caps remain available for a future slice (storage Phase 3 slice 3 landed them).
  • Implement path_open, fd_read, fd_write, fd_seek, fd_close, fd_filestat_get, fd_prestat_get, and fd_prestat_dir_name against the kernel Directory / File cap interface in capos-wasm/src/wasi/fs.rs. The resolver mirrors POSIX P1.4 Slice 4 (libcapos-posix/src/path.rs): non-leaf segments walk Directory.sub; the leaf mints either an existing or freshly created File via Directory.open(flags=CREATE|TRUNCATE).
  • Preview 1 base and inheriting rights are stored in the host fd table. The single preopen advertises only implemented directory/path rights and inheritable File rights; path_open refuses requested base or inheriting rights outside the preopen’s inheriting set, and opened File fds retain exactly the requested rights. fd_fdstat_get reports those stored rights, and fd_fdstat_set_rights can only attenuate them. fd_read, fd_write, fd_pread, fd_pwrite, fd_seek, fd_tell, fd_filestat_get, and fd_filestat_set_size check the stored File rights before constructing a FileClient; path_create_directory, path_remove_directory, path_unlink_file, path_filestat_get, fd_readdir, and preopen fd_filestat_get check the preopen rights before constructing a DirectoryClient or resolving the path.
  • WASI fd_close only releases the local cap-table slot. The kernel-side File.close() would invalidate the Arc<FileCap> that the parent Directory holds keyed by entry name, breaking re-open of the same path; WASI semantics expect fd_close to release the per-process fd without deleting the underlying file. New path_open calls for the same path mint a fresh local handle against the same kernel-side entry.
  • Preopen sandbox: the resolver refuses absolute paths (leading /) and parent-escape segments (.., .) with ERRNO_NOTCAPABLE = 76. The single preopen has no parent reachable through any path syntax.
  • The make run-wasi-fs smoke (system-wasi-fs.cue, demos/wasi-fs/, tools/qemu-wasi-fs-smoke.sh) completes a full path_open(CREAT+TRUNC) / fd_write / fd_close / re-open / fd_filestat_get / fd_seek / fd_read round trip, asserts both the absolute-path refusal and the parent-escape refusal, and proves narrowed File/preopen rights fail closed with ERRNO_NOTCAPABLE before the underlying File/Directory client call. The make run-wasi-preview1-refusals smoke continues to prove the fail-closed contract for an ungranted manifest: path_open(3, ...), fd_prestat_get(3), and fd_read(3, ...) now return ERRNO_BADF = 8 (no preopen) instead of the pre-W.5 stub ERRNO_NOSYS = 52 (path_create_directory joined this BADF group 2026-05-24 10:09 UTC, and fd_pread / fd_pwrite joined when positional I/O landed – see below); only the socket imports continue to return ERRNO_NOSYS.
  • Kernel authority surface landed 2026-05-14 (RAM-backed File, Directory, Store, and Namespace kernel caps with QEMU smokes run-file-server-smoke, run-directory-server-smoke, run-store-namespace-smoke). W.5 wires the wasm-host adapter to the Directory / File subset of that authority; Store / Namespace integration is deferred until a use case requires it.
  • fd_readdir landed 2026-05-24 08:44 UTC over the existing preopen Directory cap (DirectoryClient::list – no schema or generated-bindings change). fs::fd_readdir_impl enumerates the preopen, rejecting open file fds with ERRNO_NOTDIR = 54 and unknown fds with ERRNO_BADF = 8; preview1::fd_readdir serializes the fixed 24-byte little-endian Preview 1 dirent records (d_next, zero d_ino, d_namlen, d_type from DirEntry.is_dir) followed by name bytes, with cookie-based resume and a short-buffer truncation contract that never writes past buf_len. The make run-wasi-fs smoke now also enumerates the smoke.txt it created (readdir_found_smoke=true) and proves the short-buffer truncation.
  • fd_tell and fd_filestat_set_size landed 2026-05-24 09:34 UTC, completing the File-cap method triad (no schema or generated-bindings change – File.truncate already shipped). fs::fd_tell_impl is a pure host-side read of the maintained FileEntry::position (symmetric with fd_seek’s SET/CUR branches); fs::fd_filestat_set_size_impl calls FileClient::truncate_wait and leaves the file offset unchanged per the WASI contract. preview1::fd_tell returns ERRNO_SPIPE = 70 on a stdio fd (mirroring fd_seek) and writes the position as LE-u64; preview1::fd_filestat_set_size rejects a negative size with ERRNO_INVAL = 28 and maps non-file fds to ERRNO_BADF = 8. The make run-wasi-fs smoke now asserts fd_tell reports the post-write position (tell_ok=true) and fd_filestat_set_size shrinks the file (truncate_size=4), plus the stdio refusals for both imports.
  • path_create_directory and path_remove_directory landed 2026-05-24 10:09 UTC over the preopen Directory cap (DirectoryClient::mkdir / remove – no schema or generated-bindings change; Directory.mkdir/remove already shipped). fs::path_create_directory_impl / path_remove_directory_impl reuse the path_open resolve-parent-and-leaf path and the same preopen sandbox, so absolute paths and .. segments are refused with ERRNO_NOTCAPABLE = 76 before any kernel call; the mkdir result-cap (a fresh Directory handle the WASI layer does not retain) is released immediately to avoid leaking a cap-table slot. The make run-wasi-fs smoke now creates subdir, confirms it via fd_readdir (directory d_type), removes it, confirms it is gone, and asserts the directory-write sandbox refusals (mkdir_ok=true rmdir_ok=true dir_escape_refused=true). Implementing path_create_directory moves its no-preopen errno from ERRNO_NOSYS = 52 to ERRNO_BADF = 8 (the base-fd preopen lookup precedes the path), so the make run-wasi-preview1-refusals harness now asserts it in the BADF group.
  • fd_pread and fd_pwrite landed 2026-05-30 14:49 UTC as positional I/O over the host File cap (no schema or generated-bindings change – the kernel File.read / File.write methods already carry an explicit byte offset, and fd_read / fd_write already drive them). fs::fd_pread_impl / fs::fd_pwrite_impl mirror fd_read_impl / fd_write_file_impl but use the WASI-supplied offset and, per the WASI Preview 1 contract, leave FileEntry::position untouched – the defining positional-I/O invariant. preview1::fd_pread / fd_pwrite reuse the same guest-memory iovec gather/scatter helpers fd_read / fd_write were refactored onto (one walker, not two), reject a negative offset with ERRNO_INVAL = 28, and return ERRNO_SPIPE = 70 on a stdio fd (mirroring fd_seek / fd_tell). The make run-wasi-fs smoke now writes “ABCD” at offset 2, reads it back at offset 2, and asserts the fd’s stream position is unchanged (pwrite_pread_ok=true pos_unchanged=true), that a negative offset is refused (pread_neg_offset_inval=true), and that a stdio fd surfaces a non-ERRNO_NOSYS error (ppos_stdio_refused=true). The make run-wasi-preview1-refusals harness moves both imports into the BADF group (fd 3 is a bad descriptor against an absent preopen).
  • path_filestat_get and path_unlink_file landed 2026-05-30 as path-resolved metadata/removal over the host File / Directory caps (no schema / generated-bindings change). fs::path_filestat_get_impl resolves the leaf under the preopen, opens a transient read-only File (flags = 0), runs File.stat, and releases the transient cap before returning the size; fs::path_unlink_file_impl deletes the named entry through Directory.remove (the same void-result op path_remove_directory uses, which removes file leaves). Both enforce the absolute/.. ERRNO_NOTCAPABLE sandbox in resolve_parent_and_leaf before any kernel call; preview1::path_filestat_get accepts and ignores the lookupflags symlink-follow bit (no symlinks in v0) and writes the 64-byte filestat via write_filestat. The make run-wasi-fs smoke stats smoke.txt by path (size 4, regular-file type) and unlinks it, and make run-wasi-preview1-refusals moves both imports into the BADF group. The remaining ERRNO_NOSYS returns are the deliberately deferred surfaces (fd_advise, fd_allocate, the sync family, the path timestamp/symlink/link family (path_filestat_set_times, path_symlink, path_readlink, path_link, path_rename), poll_oneoff, proc_raise, and the W.6-blocked socket family).

Deliverable: a wasm module can read and write files inside a preopened capOS directory.

Phase W.6 — WASI Preview 1 sockets (gated on userspace network stack)

  • sock_send, sock_recv, etc. against TcpSocket / UdpSocket caps when the userspace network stack lands.
  • Until then, an HTTP client over Fetch / HttpEndpoint is a reasonable shim for HTTP-only use.
  • make run-wasi-preview1-refusals proves representative socket imports (sock_send, sock_recv, sock_shutdown) fail closed with ERRNO_NOSYS = 52 when no socket cap is present. This is current refusal evidence only; W.6 remains blocked until the networking authority exists.

Deliverable: a wasm module can serve HTTP requests inside a capOS process.

Phase W.7 — Move to wasmtime or migrate to WASI Preview 2 / Component Model

  • If the runtime selected in W.0 was wasmi, decide whether to swap to wasmtime once std/futures runtime is available in capOS userspace.
  • Or instead promote wasmi to wasip2 / Component Model support (wasmi roadmap covers components, but maturity is behind wasmtime).
  • Map WIT resources to typed OwnedCapability<T> slots. This is the natural place to bridge capOS capabilities into wasm as first-class typed handles. Capability transfer between wasm components becomes a host-mediated resource handoff.
  • Component-Model support enables cap-aware Rust crates to export their typed interfaces as WIT, which a Rust capOS service can consume the same way it consumes a capnp interface.
  • Schema serial-surface coordination: this phase will likely add new variants under schema/capos.capnp for component-model resource bridging. Serialise with other schema-touching plans (docs/backlog/index.md Concurrency Notes).

Deliverable: a wasm component on capOS exports a typed interface that a native capOS process can call.

Phase W.8 — TinyGo / Go-on-WASI integration for CUE

  • Build a CUE evaluator binary against TinyGo or upstream Go’s GOOS=wasip1. Run it in the host adapter against a CUE source blob granted as a ScriptPackage (future package-cap surface, same shape as the planned LaunchParameters work).
  • Reuses existing CUE workflows; capOS just hosts the evaluator.

Deliverable: capOS can evaluate CUE manifests at runtime without the host toolchain. Bridges to the eventual native Go track (go-runtime-proposal.md).

Languages Targeting WASI

What capOS gets “for free” once the host adapter exists, ranked by how mature each language’s WASI target is. This is the leverage argument: one host adapter unlocks every row at once.

LanguageWASI statusToolchainNative capOS alternativeWhen WASI wins
Rustwasm32-wasip1 Tier 2 since 1.78; wasm32-wasip2 Tier 2 since 1.82cargo build --target wasm32-wasip2targets/x86_64-unknown-capos.json (implemented)Untrusted Rust plugins. Cross-compiled tools.
C / C++wasi-libc + Clang --target=wasm32-wasi; wasi-sdk packagedclang --target=wasm32-wasifuture libcaposAny C/C++ tool needing portability before libcapos lands. CPython-on-WASI today is the canonical example.
Go (upstream)GOOS=wasip1 since Go 1.21 (Aug 2023). Single-thread, blocking I/O, no goroutine parallelism.GOOS=wasip1 GOARCH=wasm go buildfuture GOOS=capos (go-runtime-proposal.md)CUE evaluation, go run style tools, single-goroutine compute.
TinyGowasip1 supported; wasip2 supported in dev branchtinygo build -target=wasip2n/aSmaller Go binaries; Component Model export of typed interfaces.
Python (CPython)wasm32-unknown-wasip1 Tier 2 (PEP 11)Upstream CPython buildfuture native CPython through POSIX adapterSandboxed Python plugins, configuration scripts.
AssemblyScriptDesigned for wasm; WASI host integration via runtimeascn/aLightweight typed scripting. Less interesting on capOS than Lua.
ZigNative wasm32-wasi target; no runtime overheadzig build-exe -target wasm32-wasin/aZig systems code in a sandbox.
Lua / interpreters in generalA Lua interpreter compiled to wasi runs Lua scripts in a wasm sandboxCompile any C interpreter to wasm32-wasiLua piccolo runner (lua-scripting-proposal.md)When Lua scripts are untrusted. The piccolo native-Rust runner remains the right answer for trusted capOS scripting.
JavaScriptQuickJS-on-wasi works todayCompile QuickJS to wasm32-wasiQuickJS native runner (future)Untrusted JS plugins; portable JS without writing a native QuickJS runtime.
.NET (mono-wasi)Experimentaldotnet wasi-experimentaln/aIf a port of a .NET tool is required. Low priority.

When WASI vs Native

These are complementary tracks, not competitors.

  • Native wins for foundational services, performance-critical code, anything calling typed capOS caps directly, anything needing real threads, full async I/O, or first-class participation in the cap graph.
  • WASI wins for portability or untrusted code execution, for any existing C/C++ program with wasi-libc support that cannot wait for libcapos, for CPU-bound CUE evaluation before native Go lands, and for sandboxed user-submitted scripts.

The browser-wasm proposal captures the same intuition: the cap-ring layer is the only stable interface that survives substrate swaps. The WASI host adapter is another substrate swap, this time at the language level instead of the hardware level.

Validation

The first implementation is not complete until it has QEMU evidence:

  • A wasm module prints through a granted Console / TerminalSession.
  • The same module cannot use fd_write to a fd it was not granted, cannot open a path outside its preopened namespaces, and cannot call an unimplemented WASI function without receiving ERRNO_NOSYS.
  • A missing or wrong-interface cap lookup returns the appropriate WASI errno (not a host-side panic, not silent success).
  • An owned result cap is released deterministically when the instance exits.
  • The host adapter exits cleanly and does not wedge the kernel.

Host tests should cover WASI value conversion and import-resolver generation once those pieces are pure enough to test outside QEMU. Do not claim “WASI works” from host tests alone; the useful behavior is authority-shaped wasm execution in capOS.

Open Questions

  1. Per-instance vs per-process. One wasm instance per capos-wasm process (recommended) or many? Affects fuel/budget enforcement and the manifest shape. Resolved 2026-05-13 16:46 UTC — one wasm instance per capos-wasm process. Phases W.2–W.4 shipped on top of this shape: capos_wasm::Runtime owns exactly one wasmi::Engine and one Store<HostState>, and HostState aggregates the per-instance Console / Timer / RingClient / optional EntropySource / optional BootPackage clients plus the per-instance WasiArgs / WasiEnv bundles. That host state IS the per-instance state; there is no second instance to demultiplex against. The decision aligns with capOS capability discipline: the per-process CapSet is the authority boundary, manifest grants are scoped one binary at a time (docs/capability-model.md), and the capOS process boundary already provides the fault, fuel/budget, and audit isolation a multi-tenant wasm host would otherwise need to rebuild inside the runtime. Preview 2 / Component Model migration in Phase W.7 inherits the same per-process shape — one capos-wasm process per top-level component — and gains nothing from packing many components into one process while the OS-level isolation is free. A future multi-instance host (plugin sandboxes, embedded scripts) is allowed but must come back as a separate proposal that names the density target, the fuel and poll_oneoff reactor design, and the audit/observability shape; it does not block any current phase.

  2. Capability handle path: extension import or pure WASI-only? Custom externref imports lock capOS into a non-portable WIT dialect. Working answer: skip the custom-import path entirely; jump straight to Preview 2 / Component Model in Phase W.7.

  3. poll_oneoff semantics over the capOS ring. Block the host process’s cap_enter (simple, scales to one instance per process), or run a single-thread reactor that drives multiple instances in round-robin (scales to many instances per process)? Coupled to Q1. Resolved 2026-05-13 16:46 UTC — blocking cap_enter against the single per-process instance, with the surface expanded one subscription kind at a time as the underlying caps land. v0 keeps the W.2 sub-slice 2 ERRNO_NOSYS stub already in capos-wasm/src/wasi/preview1.rs: there is no portable subset of poll_oneoff we can answer correctly without Namespace / File / TcpSocket / UdpSocket caps, and the existing make run-wasi-preview1-refusals harness proves the refusal closes cleanly. Phase W.5 (filesystem) is the first phase that consumes a real subscription kind — eventtype_clock against monotonic time plus eventtype_fd_read / eventtype_fd_write against preopen-fd File handles — and will implement those subscription kinds by walking the subscription array, demultiplexing each subscription onto a single blocking cap_enter over the per-process ring, and returning the events the kernel completes. Phase W.6 adds the socket subscription kinds against TcpSocket / UdpSocket once the userspace network stack lands. A multi-instance reactor stays out of scope: §1 resolves to one wasm instance per capos-wasm process, so poll_oneoff only ever has to demultiplex one instance’s subscription set, and the kernel ring is already a completion-queue primitive that fits that shape directly. Realtime clock subscriptions remain ERRNO_NOSYS until a typed WallClock cap exists (same ceiling as clock_time_get(CLOCKID_REALTIME)).

  4. Fuel budget defaults and exhaustion semantics. wasmi exposes fuel; what is the default budget per instance, and what is the exhaustion behaviour (instance traps and exits, or instance pauses pending refill from a FuelGrant cap)? Affects the cap surface. Working answer: trap-and-exit default; defer the FuelGrant cap until long-running plugins exist.

  5. Typed result-cap from a host call into a wasm module. Preview 1 has no externref. How does the host hand a typed cap back to the instance after a CALL that returns a transferred result cap? Working answer: v0 reifies result caps as integer fds in the per-instance fd table; the host returns fd numbers from capability-issuing imports. Defer typed caps in wasm imports to Preview 2 / Component Model in Phase W.7, where WIT resources match the shape directly. Phase W.3 status (2026-05-07 18:25 UTC): unchanged. W.3 does not introduce any capability-issuing import, so no result-cap reification path landed; the working answer carries forward into W.5 (filesystem) / W.6 (sockets), which are the first phases that will exercise it.

  6. environ_get source. Empty-by-default, or backed by a KeyValueScope / ConfigOverlay cap? Resolved by Phase W.3 (2026-05-07 18:25 UTC) and the 2026-05-13 follow-up — bounded manifest-provided text grant, empty when absent. Migration to a future LaunchParameters cap remains the open path. Original working answer: empty for v0 unless the manifest supplies a bounded text environment grant; bind to whatever environment cap a future capOS LaunchParameters surface produces (no in-tree plan owns this yet; the shell proposal sketches the broader launch-args/environment discussion). Phase W.3 decision (2026-05-07 18:25 UTC): kept empty-by-default and shipped the argv text grant only. 2026-05-13 update: the same bounded manifest-text pattern now exists as initConfig.init.wasiEnv, a CUE text list under the existing initConfig CueValue field (no schema/capos.capnp change). Capacity bounds in capos-wasm/src/payload.rs:

    • WASI_ENV_MAX_COUNT = 32 environment entries.
    • WASI_ENV_MAX_ENTRY_BYTES = 4096 per entry (NUL terminator not included).
    • WASI_ENV_MAX_TOTAL_BYTES = 8192 for the packed environment buffer including per-entry NUL terminators. Interior NUL bytes inside an entry are rejected. The decoder tolerates an absent or empty wasiEnv, in which case Preview 1 environ_get / environ_sizes_get report zero entries (the W.2 behavior). A future LaunchParameters cap remains the migration path for argv and environ together.
  7. args_get source. Reuse a future capOS LaunchParameters surface (not yet in tree), or ship a wasm-host-specific text grant in the manifest until that surface lands? Resolved by Phase W.3 (2026-05-07 18:25 UTC) — bounded manifest-provided argv text grant on initConfig.init.wasiArgs, migrating to the future LaunchParameters cap once it exists. Original working answer: ship a small bounded text grant for v0; migrate to the future LaunchParameters surface once it exists. Phase W.3 decision (2026-05-07 18:25 UTC): shipped as initConfig.init.wasiArgs, a CUE text list under the existing initConfig CueValue field (no schema/capos.capnp change). Capacity bounds in capos-wasm/src/payload.rs:

    • WASI_ARGS_MAX_COUNT = 32 argv entries.
    • WASI_ARGS_MAX_ARG_BYTES = 4096 per entry (NUL terminator not included).
    • WASI_ARGS_MAX_TOTAL_BYTES = 8192 for the packed argv buffer including per-entry NUL terminators. Interior NUL bytes inside an argv entry are rejected (would corrupt the WASI Preview 1 NUL-terminated layout). Each violation surfaces through a stable wasm-host exit code so harnesses can distinguish them from generic decode failures. The decoder tolerates an absent or empty wasiArgs, in which case Preview 1 args_get / args_sizes_get report zero entries (W.2 behaviour). Migration to the future LaunchParameters cap stays the open path per the original working answer.
  8. Vendoring posture for wasmi. vendor/wasmi-no_std/ (forked, patched) or a cargo-vendor-style mirror of upstream default-features = false? Same question as the piccolo Lua track. Resolved 2026-05-05 19:12 UTC: mirror-as-is. The vendored snapshot at vendor/wasmi-no_std/wasmi-1.0.9/ is a static-pinned copy of upstream v1.0.9 with no source patches; cargo default-features = false strips std/wat cleanly out of the box. Provenance and refresh procedure are recorded in vendor/wasmi-no_std/VENDORED_FROM.md. This posture is independent of what the Lua track chooses; if the two tracks diverge, document the divergence in each track’s VENDORED_FROM.md.

  9. WASI module distribution and versioning. Shipped inline in a manifest blob (today), or via a future Store/Namespace? Working answer: inline blobs for v0; revisit after the storage proposals land.

  10. Component-Model adoption timeline. Skip Preview 1 entirely and target Preview 2 from day one? Possible with wasmtime, harder with wasmi today. Working answer: ship Preview 1 first because it unlocks Rust, C, Go, Python, TinyGo immediately; layer Preview 2 on once wasmi’s component support hardens or migrate to wasmtime.

  11. Out-of-tree wasm packaging. Will capOS ship pre-built .wasm binaries from the boot manifest only, or will operators bring their own? Same scoping question as the future LaunchParameters / package-cap surfaces. Working answer: in-tree only for v0–v6; out-of-tree once a Store cap can hold blobs.

  12. Audit cap shape for wasm instance lifecycle events. Same open question as Lua scripting Phase 4. Component-Model paths benefit from per-instance audit because resource handoffs are interesting events to record. Working answer: defer until the userspace audit cap surface exists.

Progress 2026-05-13 16:46 UTC: §1 (per-instance vs per-process) and §3 (poll_oneoff semantics) resolved. §1 is locked at one wasm instance per capos-wasm process, matching the per-process Runtime + Store<HostState> shape shipped through Phases W.2–W.4 and the per-process CapSet authority boundary; future multi-instance hosting must come back as a separate proposal. §3 keeps the W.2 sub-slice 2 ERRNO_NOSYS poll_oneoff stub for v0 and pre-commits Phases W.5 / W.6 to extend it one subscription kind at a time (monotonic clock + fd read/write in W.5 against Namespace/File caps, sockets in W.6 against TcpSocket/UdpSocket caps), demultiplexed onto a single blocking cap_enter over the per-process ring; multi-instance reactors remain out of scope. §6 (environ_get) and §7 (args_get) reclassified as resolved by Phase W.3 (2026-05-07 18:25 UTC) with the bounded manifest-text grants on initConfig.init.wasiEnv / initConfig.init.wasiArgs; the migration path to a future LaunchParameters cap is preserved.

Relationship to Other Proposals

  • Userspace Binaries owns the broader native-binary, language, and POSIX-adapter roadmap. This proposal supersedes Part 5 of that proposal with the full WASI host adapter design.
  • Programming Languages is the reader-facing summary of language support; the WASI row points at this proposal.
  • Browser/WASM is the separate browser-hosted wasm experiment. Both proposals share wasm-runtime insight but target different substrates.
  • Lua Scripting is the trusted capability-scoped script runner using a native (likely piccolo) Lua VM. WASI-hosted Lua is the untrusted alternative.
  • Go Runtime is the native GOOS=capos alternative to Go-on-WASI. Go-on-WASI is the v0 path for CUE evaluation; native Go is the path for full Go runtime semantics.
  • Storage and Naming defines the Directory / File / Store / Namespace surfaces that Phase W.5 consumes.
  • Networking defines the TcpSocket / UdpSocket surfaces that Phase W.6 consumes.
  • Service Architecture defines Fetch / HttpEndpoint, useful as the v0 networking shim before the full userspace network stack lands.