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-wasmuserspace host adapter built oncapos-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(requiresshared-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_SHAREDmmap. - 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-rtsurface 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=caposalternative to Go-on-WASI.
Research Grounding
Relevant research and external references:
- WASI Preview 2 launch — Bytecode Alliance, “WASI 0.2 Launched”.
- Component Model status — eunomia, “WASI and the WebAssembly Component Model: Current Status”.
- WIT resources / portable plugins — Medium, “WASI 2.0 Components: Portable, Fast Plugins”.
- Externref design — Bytecode Alliance, “WebAssembly Reference Types in Wasmtime”.
- Rust target stabilization — Rust Blog, “Changes to Rust’s WASI targets” and “wasm32-wasip2 Tier 2”.
- TinyGo WASI — TinyGo WASI guide, wasmCloud, “Compile Go directly to WebAssembly components with TinyGo and WASI P2”.
- Runtime survey — Wasmi v0.32 release notes, arXiv 2404.12621 “Research on WebAssembly Runtimes”, Colin Breck, “Choosing a WebAssembly Run-Time”.
- Runtime repos — wasmi, WAMR, wasmtime, wasm3, wasmer.
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
- 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. - 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.
- 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.
- The wasm sandbox is defence in depth, not the isolation boundary.
The capOS process boundary remains primary. Wasm bounds checking and
immutable
Modulevalidation add a second software-enforced boundary inside the host process so an entire untrusted module image can be confined. - 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.
- 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. - 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.
| Constraint | wasmi | WAMR | wasm3 | wasmtime |
|---|---|---|---|---|
| Pure Rust, drops into capOS workspace | yes | C (needs cc/build glue, no libcapos yet) | C (same problem) | yes |
no_std + alloc | yes, advertised explicitly | partial (embedded, libc-shaped) | yes (bare metal) | no (needs std and a futures executor) |
| License | Apache-2.0 / MIT | Apache-2.0 with LLVM exception | MIT | Apache-2.0 |
| Footprint | small register-based bytecode (v0.32 5x speedup) | ~29 KB AOT, ~58 KB interpreter | ~64 KB code, ~10 KB RAM | large (Cranelift JIT) |
| Sandboxing | wasm spec + execution-engine isolation | wasm spec + AOT validation | wasm spec | wasm spec + Cranelift verifier |
| Fuel/gas metering | yes, built-in | not advertised | yes | yes |
| Capability transfer | externref since 0.24; component model on roadmap | reference types yes; component model partial | partial reference types | full component model (best-in-class) |
| WASI versions | preview1 stable; preview2 on roadmap | preview1 stable; preview2 partial | preview1 partial | preview1 + preview2 + components |
| Host function interface | mirrors wasmtime API | C API; Rust through wamr-rust-sdk | C API | Rust + C |
| Maintenance | wasmi-labs, two security audits (2023, 2024) | Bytecode Alliance, TSC-governed | maintainer in minimal-maintenance phase | Bytecode Alliance flagship |
| Threading | not in current scope | yes (wasi-threads) | no | yes |
Why wasmi for v0:
- Pure Rust drops directly into the capOS workspace. No C build chain
required — the same chain
libcaposdoes not yet provide. - Genuine
no_std + allocsupport means no host-side OS abstraction is required for the runtime itself; it sits cleanly oncapos-rt. - Built-in fuel metering matches capOS’s preference for explicit resource accounting.
externrefsupport 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
stduserspace and a futures executor. capOS userspace isno_std + alloctoday; this is the same blocker that keeps the Rustcapnp-rpccrate (v0.25) offcapos-rtand 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
libcaposexist 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 import | capOS host-adapter implementation |
|---|---|
args_get / args_sizes_get | Read from a future capOS LaunchParameters cap or per-instance arena. Empty by default until that surface lands. |
environ_get / environ_sizes_get | Read 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_get | The 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 files | Translate to the typed File capability behind the host-side fd table entry. |
fd_close | Drop the typed cap handle (release-on-drop in capos-rt). |
fd_seek / fd_tell / fd_filestat_get | Methods on the File cap. |
fd_prestat_get / fd_prestat_dir_name | Enumerate the host adapter’s preopened-directory table built from manifest grants. |
sock_send / sock_recv / sock_shutdown | Translate to typed TcpSocket / UdpSocket cap calls. |
poll_oneoff | Multiplex over the host’s capability ring; CQEs are the event source. Open question §3. |
fd_advise / fd_allocate / fd_renumber | Stub or ERRNO_NOSYS until needed. |
sched_yield | No-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 / interface | capOS 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-handler | Match 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:
- 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 existingwasip1binary unchanged. A wasm module cannot pass a typed cap to another wasm module without going through the host. - Custom
externrefimport (alternative; not recommended). Requires thereference-typesproposal (supported by wasmi >=0.24, wasmtime, wasmer; partial in wasm3). The host adapter exports custom imports likecap_call_refthat take anexternreftyped 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. - Preview 2 / Component Model resources (target for W.7+).
Resources in the Component Model are unforgeable typed handles.
Components that import
wasi:filesystem/types.descriptorreceive a handle that is the host-sideOwnedCapability<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:
- One wasm instance per
capos-wasmprocess (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. - Many instances per
capos-wasmprocess (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; thepoll_oneoffreactor 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
| Boundary | Native capOS service | WASI host adapter + module |
|---|---|---|
| Authority source | Process CapSet | Host CapSet then per-instance subset |
| Memory isolation | Page tables | Wasm linear-memory bounds-check plus page tables (host process) |
| Code integrity | W^X + NX | Wasm module validation plus immutable WebAssembly.Module |
| Cap forgery | Kernel-owned CapTable | Host-owned per-instance fd table or resource-handle table; module sees opaque ints/handles only |
| Resource limits | Kernel quotas | Wasm fuel + memory cap + host-side per-instance time/byte budgets |
| Side channels | Hardware-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 useposix_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: requiresshared-memory+atomics+bulk-memoryfeatures and a runtime that supports them. Out of scope for v0. - Live
mmapof 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 oncapos-rt. - Vendor the chosen runtime (wasmi recommended; one local cargo dep
patched for
no_std + allocif needed). - Host process can
WebAssembly.compile(bytes)theninstantiate(no imports)then run an empty_start. No imports resolved yet. - Manifest: new
system-wasm-host.cueboots one host process with one embedded.wasmblob (the smoke binary). - Smoke:
make run-wasm-hostboots, 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.cueempty-instantiation manifest,make run-wasm-hostsmoke, 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 asERRNO_NOSYSincludingrandom_get(Phase W.4 promotes that to production). The wasm-host smoke now drives a 114-byte hand-encoded probe module that callsrandom_get, stores the returned errno in an exported global, and refuses to print thenosys=52proof line unless it equalsERRNO_NOSYS. -
W.2 sub-slice 3 (landed 2026-05-07 09:36 UTC): Rust
hello, wasismoke (demos/wasi-hello-rust/,system-wasi-hello-rust.cue,make run-wasi-hello-rust). The wasm-host binary now optionally reads aBootPackagecap, walks the manifest’sbinaries[]for thewasi-payloadentry, instantiates it through the same Preview 1 linker, and explicitly invokes the_startexport (wasmi’sinstantiate_and_startruns the WebAssemblystartsection, NOT WASI’s_start). The sub-slice 1+2 regression keeps running first; the existingmake run-wasm-hostsmoke continues to pass because it does not grantboot. -
W.2 sub-slice 4 (landed 2026-05-07 10:53 UTC): C
hello, wasismoke (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.wasmpayload too — sub-slice 4 only added the C toolchain wiring (system clang-18 with--target=wasm32-wasi --sysroot=/usragainst the Ubuntu wasi-libc +libclang-rt-18-dev-wasm32packages), 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.cueempty-instantiation manifest,tools/qemu-wasm-host-smoke.shassertion harness, and the userspace-image budget bump that wasmi’s ~3 MiB BSS requires. USER_STACK_BASE moved from 0x60_0000 to 0x100_0000 incapos-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 thesystem-spawn.cuestack-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, wasismokes 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-hostuserspace binary built on the W.1 scaffold, instantiating an inline 8-byte empty wasm module throughwasmi::Linker::instantiate_and_start. - Manifest
system-wasm-host.cue(empty-instantiation regression). - Smoke
make run-wasm-host(asserted bytools/qemu-wasm-host-smoke.sh).
Sub-slice 2 (landed) delivered:
capos-wasm/src/wasi/preview1.rsPreview 1 import resolver on top of the existing wasm-host binary, registering 46wasi_snapshot_preview1imports against a fixed-aritywasmi::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’sTimerClient(CLOCKID_REALTIMEreturnsERRNO_NOSYSuntil a wall-clock cap exists);proc_exitviacapos_rt::syscall::exit;fd_write(1, …)andfd_write(2, …)via the host’sConsole.writebyte path with a fixed 4 KiB scratch ceiling (oversize total →ERRNO_INVAL); all other Preview 1 imports stubbed asERRNO_NOSYS(includingrandom_get, which Phase W.4 promotes againstEntropySource). - Manifest update (
system-wasm-host.cuenow grants Console + Timer) and smoke harness update (tools/qemu-wasm-host-smoke.shasserts the new[wasm-host] preview1 imports linked: ...; nosys=52proof 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 asERRNO_NOSYS = 52.
Sub-slice 3 (landed 2026-05-07 09:36 UTC) delivered:
demos/wasi-hello-rust/standalone crate built against the upstreamwasm32-wasip1target. Source is a singleprintln!; the producedhello.wasm(~40 KiB) importsenviron_get,environ_sizes_get,fd_write, andproc_exitfromwasi_snapshot_preview1, all of which the sub-slice 2 resolver already implements.capos_wasm::payloadhelper module: streams the capnp-encodedSystemManifestblob throughBootPackage.readManifestChunk(4 KiB chunks) and walksbinaries[]via raw capnp readers to return the bytes for a named entry. The wasm-host binary calls this only when the manifest grants the optionalboot(BootPackage) cap, so the sub-slice 1+2make run-wasm-hostsmoke – which does not grantboot– keeps passing unchanged.system-wasi-hello-rust.cuemanifest: lists the wasm-host ELF and thewasi-payloadblob, grants Console + Timer + BootPackage to the wasm-host, and reuses the sharedcue/defaultspackage.tools/qemu-wasi-hello-rust-smoke.shsmoke harness: asserts the existing sub-slice 1 + 2 proof lines, the newHello from WASI on capOSpayload 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_startis allowed to terminate viaproc_exitfrom 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-buildcargo wrapper that clearsRUSTFLAGS/CARGO_ENCODED_RUSTFLAGSso the kernel-target rustflags pinned in the repo.cargo/config.tomldo not leak into the wasm build.- capos-rt re-export additions:
capos_capnpanddefault_reader_optionsare now reachable fromcapos_rt::*socapos-wasmkeeps a single direct path-dep on capos-rt and the vendored wasmi tree (addingcapos-configdirectly to capos-wasm triggered an unrelated cargo workspace-inheritance error against the vendored wasmi atvendor/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 Makefilewasi-hello-c-buildtarget). Source is a singleprintf("Hello, wasi from capOS C\n")main()compiled with--target=wasm32-wasi --sysroot=/usragainst the Ubuntu wasi-libc +libclang-rt-18-dev-wasm32apt packages; the producedhello-c.wasm(~46 KiB) imports five functions fromwasi_snapshot_preview1:fd_close,fd_fdstat_get,fd_seek,fd_write, andproc_exit.fd_writeandproc_exitreach the host’s granted Console cap and the clean capos-rt exit path implemented in sub-slice 2;fd_close,fd_fdstat_get, andfd_seekreturnERRNO_NOSYS = 52from the same sub-slice 2 stub surface, which is sufficient for wasi-libc’s stdout-only path.system-wasi-hello-c.cuemanifest: same shape assystem-wasi-hello-rust.cue, lists the wasm-host ELF and thewasi-payloadblob, grants Console + Timer + BootPackage to the wasm-host, and reuses the sharedcue/defaultspackage.tools/qemu-wasi-hello-c-smoke.shsmoke harness: asserts the existing sub-slice 1 + 2 proof lines, the newHello, wasi from capOS Cpayload stdout (the load-bearing evidence), and the clean process/scheduler exit pair.make wasi-hello-c-buildtarget that runs system clang withRUSTFLAGS/CARGO_ENCODED_RUSTFLAGScleared (matching thewasi-hello-rust-buildshape 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.wasmpayload 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 thewasiArgsbounded-text grant alongside the cap list. Future phases (W.4 entropy, W.5 namespaces, W.6 sockets) will extend the samecapsblock with their respective surfaces. - Bounded-text argv grant:
initConfig.init.wasiArgsis a CUE text list. Schema/schema/capos.capnpis unchanged becauseinitConfigis alreadyCueValueand unknown sub-fields underinitConfig.initare ignored by the existing manifest decoder. The wasm-host walks the field directly through raw capnp readers incapos-wasm/src/payload.rs::read_wasi_args. An absent or emptywasiArgskeeps the W.2 “no argv” behaviour (args_sizes_getreports zero,args_getwrites nothing) so the existingmake run-wasm-host,make run-wasi-hello-rust, andmake run-wasi-hello-csmokes stay unchanged. - Bounded-text environment grant:
initConfig.init.wasiEnvis a CUE text list of entries such asKEY=value. It uses the same raw capnp reader path aswasiArgs, the same no-schema-changeinitConfigCueValueextension point, and the same empty-by- default behavior: absent or emptywasiEnvmakesenviron_sizes_getreport zero andenviron_getwrite 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
LaunchParameterscap: when capOS gains a capability-shapedLaunchParameterssurface (the same one envisioned bydocs/proposals/userspace-binaries-proposal.mdPart 5 and the future shell launch flow), the wasm-host will swapread_wasi_argsfor a typedLaunchParametersClientlookup and the manifest-sidewasiArgsfield becomes redundant. The bounds constants stay relevant either way (a typedLaunchParameterscap will still need byte ceilings before it ships argv into wasm linear memory). - Smoke:
demos/wasi-cli-args/(Rust,wasm32-wasip1) readsargv[1]and prints it throughprintln!->fd_write(1, …)-> the host’sConsolecap. The harness (tools/qemu-wasi-cli-args-smoke.sh) asserts the existing sub-slice 1 + 2 regression lines plus the load-bearingcapos-wasi-cli-args-sentinelline.
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
EntropySourcecap (the in-tree CSPRNG capability; seeEntropySourceClientandKernelCapSource::EntropySource) through the host adapter as the backing forrandom_get. The same cap is the natural future analogue of the browser’scrypto.getRandomValuessurface. - Wall-clock support stays deferred until capOS has a typed
WallClock/RealTimeClockcap.clock_time_get(CLOCKID_REALTIME)keeps returning the W.2 sub-slice 2 sentinelERRNO_NOSYSso 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-grantedTimercap 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=trueline, 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_NOSYS – poll_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
Directorycap from the per-instance CapSet. The v0 surface ships a single preopen at fd 3 named/preopen-0; the manifest CapSet slot name isroot(matching the POSIX adapter P1.4 Slice 4 bootstrap).Namespace/Storeintegration 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, andfd_prestat_dir_nameagainst the kernelDirectory/Filecap interface incapos-wasm/src/wasi/fs.rs. The resolver mirrors POSIX P1.4 Slice 4 (libcapos-posix/src/path.rs): non-leaf segments walkDirectory.sub; the leaf mints either an existing or freshly createdFileviaDirectory.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_openrefuses requested base or inheriting rights outside the preopen’s inheriting set, and opened File fds retain exactly the requested rights.fd_fdstat_getreports those stored rights, andfd_fdstat_set_rightscan only attenuate them.fd_read,fd_write,fd_pread,fd_pwrite,fd_seek,fd_tell,fd_filestat_get, andfd_filestat_set_sizecheck the stored File rights before constructing aFileClient;path_create_directory,path_remove_directory,path_unlink_file,path_filestat_get,fd_readdir, and preopenfd_filestat_getcheck the preopen rights before constructing aDirectoryClientor resolving the path. - WASI
fd_closeonly releases the local cap-table slot. The kernel-sideFile.close()would invalidate theArc<FileCap>that the parentDirectoryholds keyed by entry name, breaking re-open of the same path; WASI semantics expectfd_closeto release the per-process fd without deleting the underlying file. Newpath_opencalls 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 (..,.) withERRNO_NOTCAPABLE = 76. The single preopen has no parent reachable through any path syntax. - The
make run-wasi-fssmoke (system-wasi-fs.cue,demos/wasi-fs/,tools/qemu-wasi-fs-smoke.sh) completes a fullpath_open(CREAT+TRUNC)/fd_write/fd_close/ re-open /fd_filestat_get/fd_seek/fd_readround trip, asserts both the absolute-path refusal and the parent-escape refusal, and proves narrowed File/preopen rights fail closed withERRNO_NOTCAPABLEbefore the underlying File/Directory client call. Themake run-wasi-preview1-refusalssmoke continues to prove the fail-closed contract for an ungranted manifest:path_open(3, ...),fd_prestat_get(3), andfd_read(3, ...)now returnERRNO_BADF = 8(no preopen) instead of the pre-W.5 stubERRNO_NOSYS = 52(path_create_directoryjoined this BADF group 2026-05-24 10:09 UTC, andfd_pread/fd_pwritejoined when positional I/O landed – see below); only the socket imports continue to returnERRNO_NOSYS. - Kernel authority surface landed 2026-05-14 (RAM-backed
File,Directory,Store, andNamespacekernel caps with QEMU smokesrun-file-server-smoke,run-directory-server-smoke,run-store-namespace-smoke). W.5 wires the wasm-host adapter to theDirectory/Filesubset of that authority;Store/Namespaceintegration is deferred until a use case requires it. fd_readdirlanded 2026-05-24 08:44 UTC over the existing preopenDirectorycap (DirectoryClient::list– no schema or generated-bindings change).fs::fd_readdir_implenumerates the preopen, rejecting open file fds withERRNO_NOTDIR = 54and unknown fds withERRNO_BADF = 8;preview1::fd_readdirserializes the fixed 24-byte little-endian Preview 1direntrecords (d_next, zerod_ino,d_namlen,d_typefromDirEntry.is_dir) followed by name bytes, with cookie-based resume and a short-buffer truncation contract that never writes pastbuf_len. Themake run-wasi-fssmoke now also enumerates thesmoke.txtit created (readdir_found_smoke=true) and proves the short-buffer truncation.fd_tellandfd_filestat_set_sizelanded 2026-05-24 09:34 UTC, completing the File-cap method triad (no schema or generated-bindings change –File.truncatealready shipped).fs::fd_tell_implis a pure host-side read of the maintainedFileEntry::position(symmetric withfd_seek’s SET/CUR branches);fs::fd_filestat_set_size_implcallsFileClient::truncate_waitand leaves the file offset unchanged per the WASI contract.preview1::fd_tellreturnsERRNO_SPIPE = 70on a stdio fd (mirroringfd_seek) and writes the position as LE-u64;preview1::fd_filestat_set_sizerejects a negativesizewithERRNO_INVAL = 28and maps non-file fds toERRNO_BADF = 8. Themake run-wasi-fssmoke now assertsfd_tellreports the post-write position (tell_ok=true) andfd_filestat_set_sizeshrinks the file (truncate_size=4), plus the stdio refusals for both imports.path_create_directoryandpath_remove_directorylanded 2026-05-24 10:09 UTC over the preopenDirectorycap (DirectoryClient::mkdir/remove– no schema or generated-bindings change;Directory.mkdir/removealready shipped).fs::path_create_directory_impl/path_remove_directory_implreuse thepath_openresolve-parent-and-leaf path and the same preopen sandbox, so absolute paths and..segments are refused withERRNO_NOTCAPABLE = 76before any kernel call; themkdirresult-cap (a freshDirectoryhandle the WASI layer does not retain) is released immediately to avoid leaking a cap-table slot. Themake run-wasi-fssmoke now createssubdir, confirms it viafd_readdir(directoryd_type), removes it, confirms it is gone, and asserts the directory-write sandbox refusals (mkdir_ok=true rmdir_ok=true dir_escape_refused=true). Implementingpath_create_directorymoves its no-preopen errno fromERRNO_NOSYS = 52toERRNO_BADF = 8(the base-fd preopen lookup precedes the path), so themake run-wasi-preview1-refusalsharness now asserts it in the BADF group.fd_preadandfd_pwritelanded 2026-05-30 14:49 UTC as positional I/O over the hostFilecap (no schema or generated-bindings change – the kernelFile.read/File.writemethods already carry an explicit byte offset, andfd_read/fd_writealready drive them).fs::fd_pread_impl/fs::fd_pwrite_implmirrorfd_read_impl/fd_write_file_implbut use the WASI-suppliedoffsetand, per the WASI Preview 1 contract, leaveFileEntry::positionuntouched – the defining positional-I/O invariant.preview1::fd_pread/fd_pwritereuse the same guest-memory iovec gather/scatter helpersfd_read/fd_writewere refactored onto (one walker, not two), reject a negativeoffsetwithERRNO_INVAL = 28, and returnERRNO_SPIPE = 70on a stdio fd (mirroringfd_seek/fd_tell). Themake run-wasi-fssmoke 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_NOSYSerror (ppos_stdio_refused=true). Themake run-wasi-preview1-refusalsharness moves both imports into the BADF group (fd 3 is a bad descriptor against an absent preopen).path_filestat_getandpath_unlink_filelanded 2026-05-30 as path-resolved metadata/removal over the hostFile/Directorycaps (no schema / generated-bindings change).fs::path_filestat_get_implresolves the leaf under the preopen, opens a transient read-onlyFile(flags = 0), runsFile.stat, and releases the transient cap before returning the size;fs::path_unlink_file_impldeletes the named entry throughDirectory.remove(the same void-result oppath_remove_directoryuses, which removes file leaves). Both enforce the absolute/..ERRNO_NOTCAPABLEsandbox inresolve_parent_and_leafbefore any kernel call;preview1::path_filestat_getaccepts and ignores thelookupflagssymlink-follow bit (no symlinks in v0) and writes the 64-byte filestat viawrite_filestat. Themake run-wasi-fssmoke statssmoke.txtby path (size 4, regular-file type) and unlinks it, andmake run-wasi-preview1-refusalsmoves both imports into the BADF group. The remainingERRNO_NOSYSreturns 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. againstTcpSocket/UdpSocketcaps when the userspace network stack lands.- Until then, an HTTP client over
Fetch/HttpEndpointis a reasonable shim for HTTP-only use. make run-wasi-preview1-refusalsproves representative socket imports (sock_send,sock_recv,sock_shutdown) fail closed withERRNO_NOSYS = 52when 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.capnpfor component-model resource bridging. Serialise with other schema-touching plans (docs/backlog/index.mdConcurrency 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 aScriptPackage(future package-cap surface, same shape as the plannedLaunchParameterswork). - 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.
| Language | WASI status | Toolchain | Native capOS alternative | When WASI wins |
|---|---|---|---|---|
| Rust | wasm32-wasip1 Tier 2 since 1.78; wasm32-wasip2 Tier 2 since 1.82 | cargo build --target wasm32-wasip2 | targets/x86_64-unknown-capos.json (implemented) | Untrusted Rust plugins. Cross-compiled tools. |
| C / C++ | wasi-libc + Clang --target=wasm32-wasi; wasi-sdk packaged | clang --target=wasm32-wasi | future libcapos | Any 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 build | future GOOS=capos (go-runtime-proposal.md) | CUE evaluation, go run style tools, single-goroutine compute. |
| TinyGo | wasip1 supported; wasip2 supported in dev branch | tinygo build -target=wasip2 | n/a | Smaller Go binaries; Component Model export of typed interfaces. |
| Python (CPython) | wasm32-unknown-wasip1 Tier 2 (PEP 11) | Upstream CPython build | future native CPython through POSIX adapter | Sandboxed Python plugins, configuration scripts. |
| AssemblyScript | Designed for wasm; WASI host integration via runtime | asc | n/a | Lightweight typed scripting. Less interesting on capOS than Lua. |
| Zig | Native wasm32-wasi target; no runtime overhead | zig build-exe -target wasm32-wasi | n/a | Zig systems code in a sandbox. |
| Lua / interpreters in general | A Lua interpreter compiled to wasi runs Lua scripts in a wasm sandbox | Compile any C interpreter to wasm32-wasi | Lua piccolo runner (lua-scripting-proposal.md) | When Lua scripts are untrusted. The piccolo native-Rust runner remains the right answer for trusted capOS scripting. |
| JavaScript | QuickJS-on-wasi works today | Compile QuickJS to wasm32-wasi | QuickJS native runner (future) | Untrusted JS plugins; portable JS without writing a native QuickJS runtime. |
| .NET (mono-wasi) | Experimental | dotnet wasi-experimental | n/a | If 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_writeto a fd it was not granted, cannot open a path outside its preopened namespaces, and cannot call an unimplemented WASI function without receivingERRNO_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
-
Per-instance vs per-process. One wasm instance per
capos-wasmprocess (recommended) or many? Affects fuel/budget enforcement and the manifest shape. Resolved 2026-05-13 16:46 UTC — one wasm instance percapos-wasmprocess. Phases W.2–W.4 shipped on top of this shape:capos_wasm::Runtimeowns exactly onewasmi::Engineand oneStore<HostState>, andHostStateaggregates the per-instanceConsole/Timer/RingClient/ optionalEntropySource/ optionalBootPackageclients plus the per-instanceWasiArgs/WasiEnvbundles. 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 — onecapos-wasmprocess 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 andpoll_oneoffreactor design, and the audit/observability shape; it does not block any current phase. -
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.
-
poll_oneoffsemantics over the capOS ring. Block the host process’scap_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 — blockingcap_enteragainst 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 2ERRNO_NOSYSstub already incapos-wasm/src/wasi/preview1.rs: there is no portable subset ofpoll_oneoffwe can answer correctly withoutNamespace/File/TcpSocket/UdpSocketcaps, and the existingmake run-wasi-preview1-refusalsharness proves the refusal closes cleanly. Phase W.5 (filesystem) is the first phase that consumes a real subscription kind —eventtype_clockagainst monotonic time pluseventtype_fd_read/eventtype_fd_writeagainst preopen-fdFilehandles — and will implement those subscription kinds by walking the subscription array, demultiplexing each subscription onto a single blockingcap_enterover the per-process ring, and returning the events the kernel completes. Phase W.6 adds the socket subscription kinds againstTcpSocket/UdpSocketonce the userspace network stack lands. A multi-instance reactor stays out of scope: §1 resolves to one wasm instance percapos-wasmprocess, sopoll_oneoffonly 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 remainERRNO_NOSYSuntil a typedWallClockcap exists (same ceiling asclock_time_get(CLOCKID_REALTIME)). -
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
FuelGrantcap)? Affects the cap surface. Working answer: trap-and-exit default; defer theFuelGrantcap until long-running plugins exist. -
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. -
environ_getsource. Empty-by-default, or backed by aKeyValueScope/ConfigOverlaycap? 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 futureLaunchParameterscap 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 capOSLaunchParameterssurface 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 asinitConfig.init.wasiEnv, a CUE text list under the existinginitConfigCueValuefield (noschema/capos.capnpchange). Capacity bounds incapos-wasm/src/payload.rs:WASI_ENV_MAX_COUNT = 32environment entries.WASI_ENV_MAX_ENTRY_BYTES = 4096per entry (NUL terminator not included).WASI_ENV_MAX_TOTAL_BYTES = 8192for the packed environment buffer including per-entry NUL terminators. Interior NUL bytes inside an entry are rejected. The decoder tolerates an absent or emptywasiEnv, in which case Preview 1environ_get/environ_sizes_getreport zero entries (the W.2 behavior). A futureLaunchParameterscap remains the migration path for argv and environ together.
-
args_getsource. Reuse a future capOSLaunchParameterssurface (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 oninitConfig.init.wasiArgs, migrating to the futureLaunchParameterscap once it exists. Original working answer: ship a small bounded text grant for v0; migrate to the futureLaunchParameterssurface once it exists. Phase W.3 decision (2026-05-07 18:25 UTC): shipped asinitConfig.init.wasiArgs, a CUE text list under the existinginitConfigCueValuefield (noschema/capos.capnpchange). Capacity bounds incapos-wasm/src/payload.rs:WASI_ARGS_MAX_COUNT = 32argv entries.WASI_ARGS_MAX_ARG_BYTES = 4096per entry (NUL terminator not included).WASI_ARGS_MAX_TOTAL_BYTES = 8192for 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 emptywasiArgs, in which case Preview 1args_get/args_sizes_getreport zero entries (W.2 behaviour). Migration to the futureLaunchParameterscap stays the open path per the original working answer.
-
Vendoring posture for wasmi.
vendor/wasmi-no_std/(forked, patched) or acargo-vendor-style mirror of upstreamdefault-features = false? Same question as the piccolo Lua track. Resolved 2026-05-05 19:12 UTC: mirror-as-is. The vendored snapshot atvendor/wasmi-no_std/wasmi-1.0.9/is a static-pinned copy of upstreamv1.0.9with no source patches; cargodefault-features = falsestripsstd/watcleanly out of the box. Provenance and refresh procedure are recorded invendor/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’sVENDORED_FROM.md. -
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. -
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.
-
Out-of-tree wasm packaging. Will capOS ship pre-built
.wasmbinaries from the boot manifest only, or will operators bring their own? Same scoping question as the futureLaunchParameters/ package-cap surfaces. Working answer: in-tree only for v0–v6; out-of-tree once aStorecap can hold blobs. -
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=caposalternative 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/Namespacesurfaces that Phase W.5 consumes. - Networking defines the
TcpSocket/UdpSocketsurfaces that Phase W.6 consumes. - Service Architecture defines
Fetch/HttpEndpoint, useful as the v0 networking shim before the full userspace network stack lands.