Proposal: Running capOS in the Browser (WebAssembly, Worker-per-Process)
How capOS goes from “boots in QEMU” to “boots in a browser tab,” with each capOS process executing in its own Web Worker and the kernel acting as the scheduler/dispatcher across them.
The goal is a teaching and demo target, not a production runtime. It should
preserve the capability model — typed endpoints, ring-based IPC, no ambient
authority — while replacing the hardware substrate (page tables, IDT,
preemptive timer, privilege rings) with browser primitives (Worker
boundaries, SharedArrayBuffer, Atomics.wait/notify).
Depends on: Stage 5 (Scheduling), Stage 6 (IPC) — the capability ring is
the only kernel/user interface we want to port. Anything still sitting behind
the transitional write/exit syscalls must migrate to ring opcodes first.
Complements: userspace-binaries-proposal.md (language/runtime story),
service-architecture-proposal.md (process lifecycle). A browser port
stresses both: the runtime must build for wasm32-unknown-unknown, and
process spawn becomes “instantiate a Worker” rather than “map an ELF.”
Non-goals:
- Running the existing x86_64 kernel unmodified in the browser. That’s a separate question (QEMU-WASM / v86) and is a simulator, not a port.
- Emulating the MMU, IDT, or PIT in WASM. The whole point is to replace them with primitives the browser already gives us for free.
- Any persistence, networking, or storage beyond what a hosted demo needs.
Current State
capOS is x86_64-only. Arch-specific code lives under kernel/src/arch/x86_64/
and relies on:
| Mechanism | File | Browser equivalent |
|---|---|---|
| Page tables, W^X, user/kernel split | mem/paging.rs, arch/x86_64/smap.rs | Worker + linear-memory isolation (structural) |
| Preemptive timer (PIT @ 100 Hz) | arch/x86_64/pit.rs, idt.rs | setTimeout/MessageChannel + cooperative yield |
| Syscall entry (SYSCALL/SYSRET) | arch/x86_64/syscall.rs | Direct Atomics.notify on ring doorbell |
| Context switch | arch/x86_64/context.rs | None — each process is its own Worker, OS schedules |
| ELF loading | elf.rs, main.rs | WebAssembly.instantiate from module bytes |
| Frame allocator | mem/frame.rs | memory.grow inside each instance |
| Capability ring | capos-config/src/ring.rs, cap/ring.rs | Reused unchanged — shared via SharedArrayBuffer |
| CapTable, CapObject | capos-lib/src/cap_table.rs | Reused unchanged in kernel Worker |
The capability-ring layer is the only stable interface that survives the port
intact. Everything below cap/ring.rs is arch work; everything above is
schema-driven capnp dispatch that doesn’t care about the substrate.
Architecture
flowchart LR
subgraph Tab[Browser Tab / Origin]
direction LR
Main[Main thread<br/>xterm.js, UI, loader]
subgraph KW[Kernel Worker]
Kernel[capOS kernel<br/>CapTable, scheduler,<br/>ring dispatch]
end
subgraph P1[Process Worker #1<br/>init]
RT1[capos-rt] --> App1[init binary]
end
subgraph P2[Process Worker #2<br/>service<br/>spawned by init]
RT2[capos-rt] --> App2[service binary]
end
SAB1[(SharedArrayBuffer<br/>ring #1)]
SAB2[(SharedArrayBuffer<br/>ring #2)]
Main <-->|postMessage| KW
KW <-->|SAB + Atomics| SAB1
KW <-->|SAB + Atomics| SAB2
P1 <-->|SAB + Atomics| SAB1
P2 <-->|SAB + Atomics| SAB2
P1 -.spawn.-> KW
KW -.new Worker.-> P2
end
One Worker per capOS process. Each process is a WASM instance in its own
Worker, with its own linear memory. Cross-process access is structurally
impossible — postMessage and shared ring buffers are the only channels.
Kernel in a dedicated Worker. Not on the main thread: the main thread is
reserved for UI (terminal, loader, error display). The kernel Worker owns
the CapTable, holds the Arc<dyn CapObject> registry, dispatches SQEs,
and maintains one SharedArrayBuffer per process for that process’s
ring. It directly spawns init; all further processes are created via the
ProcessSpawner cap it serves.
Capability ring over SharedArrayBuffer. The existing
CapRingHeader/CapSqe/CapCqe layout in capos-config/src/ring.rs already
uses volatile access helpers for cross-agent visibility. Mapping it onto a
SharedArrayBuffer is a change of backing store, not of protocol. Both sides
see the same bytes; Atomics.load/Atomics.store replace the volatile reads
on the host side; on the Rust/WASM side the existing read_volatile/
write_volatile lower to plain atomic loads/stores under
wasm32-unknown-unknown with the atomics feature enabled.
cap_enter becomes Atomics.wait. The process Worker calls
Atomics.wait on a doorbell word in the SAB after publishing SQEs. The
kernel Worker (or its scheduler tick) calls Atomics.notify after producing
completions. That is exactly the io_uring-inspired “syscall-free submit,
blocking wait on completion” the ring was designed around — the browser
happens to give us the primitive for free.
No preemption inside a process. A Worker runs to completion on its event
loop turn; the kernel can’t interrupt it. This is fine: each process is
single-threaded in its own isolate, and the scheduler only needs to wake the
next process after Atomics.wait, not forcibly remove the running one.
This is closer to a cooperative capnp-rpc vat model than to the current
timer-preempted kernel, and matches what the capability ring already assumes.
Mapping capOS Concepts to WASM/Browser
Process isolation
The Worker boundary replaces the page table. Two capOS processes cannot
observe each other’s linear memory, cannot jump into each other’s code (code
is out-of-band in WASM — not addressable as data), and cannot share globals.
The SharedArrayBuffer containing the ring is the only intentional shared
region, and it is created by the kernel Worker and transferred to the process
Worker at spawn time.
No W^X enforcement is needed within a Worker because WASM has no writable
code region to begin with — WebAssembly.Module is validated and immutable.
The MMU’s job is done by the WASM type system and validator.
Address space / memory
Each Worker’s WASM instance has one linear memory. capos-rt’s fixed heap
initialization uses memory.grow instead of VirtualMemory::map. The
VirtualMemory capability still exists in the schema, but its
implementation in the browser port is a thin wrapper over memory.grow with
bookkeeping for “logical unmap” (zeroing + tracking a free list — WASM
doesn’t return pages to the host).
Protection flags (PROT_READ/PROT_WRITE/PROT_EXEC) become no-ops with a
documented caveat in the proposal: the browser port does not enforce
intra-process protection. Cross-process protection is structural and
stronger than the native build.
Syscalls
The three transitional syscalls (write, exit, cap_enter) collapse to:
write— already slated for removal once init is cap-native. In the browser port, do not implement it at all. Force the port to drive the existing cap-native Console ring path, which forces the rest of the tree to be cap-native too. A forcing function, not a cost.exit—postMessage({type: 'exit', code})to the kernel Worker, which terminates the Worker viaworker.terminate()and reaps the process entry.cap_enter—Atomics.waiton the ring doorbell after publishing SQEs, with awaitAsyncvariant for cooperative mode if we ever want to avoid blocking the Worker’s event loop.
Scheduler
Round-robin is gone; the browser scheduler is the OS scheduler. The kernel Worker’s “scheduler” is reduced to:
- A poll loop that drains each process’s SQ (the existing
cap/ring.rs::process_sqeslogic, called on everynotifyor on asetTimeout(0)tick). - A completion-fanout step that pushes CQEs and
Atomics.notifys the target Worker.
No context switch, no run queue, no per-process kernel stack. The code
deleted here is exactly the code that smp-proposal.md says needs per-CPU
structures — an orthogonal win: the browser port has no SMP problem because
each process is structurally on its own agent.
Process spawning
The kernel Worker spawns exactly one process Worker directly — init —
with a fixed cap bundle: Console, ProcessSpawner, FrameAllocator,
VirtualMemory, BootPackage, and any host-backed caps (Fetch,
etc.) granted to it.
// Kernel Worker bootstrap
const initMod = await WebAssembly.compileStreaming(fetch('/init.wasm'));
const initRing = new SharedArrayBuffer(RING_SIZE);
const initWorker = new Worker('process-worker.js', {type: 'module'});
kernel.registerProcess(initWorker, initRing, buildInitCapBundle());
initWorker.postMessage(
{type: 'boot', mod: initMod, ring: initRing, capSet: initCapSet,
bootPackage: manifestBytes},
[/* transfer */]);
All further processes come from init invoking ProcessSpawner.spawn.
ProcessSpawner is served by the kernel Worker; each invocation:
- Compiles the referenced binary bytes (
WebAssembly.compileover theNamedBlobfromBootPackage). - Creates a
new Workerand aSharedArrayBufferfor its ring. - Builds the child’s
CapTablefrom theProcessSpecthe caller passed, applying move/copy semantics to caps transferred from the caller’s table. - Returns a
ProcessHandlecap.
Init composes service caps in userspace: hold Fetch, attenuate to
per-origin HttpEndpoint, hand each child only the caps its
ProcessSpec names. Same shape as native after Stage 6.
Host-backed capability services
Some capabilities in the browser port are implemented by talking to the
browser rather than to hardware. Fetch and HttpEndpoint — drafted in
service-architecture-proposal.md —
are the canonical example. On native capOS they run over a userspace
TCP/IP stack on virtio-net/ENA/gVNIC. In the browser port, the service
process is replaced by a thin implementation living in the kernel Worker
(or a dedicated “host bridge” Worker) that dispatches each capnp call
by calling fetch / new WebSocket and returning the response as a
CQE. The attenuation story is unchanged: Fetch can reach any URL,
HttpEndpoint is bound to one origin at mint time, derived from
Fetch by a policy process.
This is not a back door. The capability is granted through the manifest
exactly as on native. Processes without the cap cannot reach the host’s
network, cannot discover it, and cannot forge one. The only difference
from native is the implementation of the service behind the CapObject
trait — same schema, same TYPE_ID, same error model.
The same pattern applies to anything else the browser provides natively. Candidate future interfaces (no schema yet, mentioned so the port is considered when they are designed):
Clipboardovernavigator.clipboardLocalStorage/KvStoreover IndexedDB (naturalStorebackend for the storage proposal in the browser)Display/Canvasover anOffscreenCanvasposted back to the main threadRandomSourceovercrypto.getRandomValues— trivial but needs a cap rather than a syscall
Other drafted network interfaces — TcpSocket, TcpListener,
UdpSocket, NetworkManager from
networking-proposal.md — do not have a clean
browser mapping. The browser exposes no raw-socket primitives, so these
caps cannot be served in the browser port at all. Applications that need
networking in the browser must go through Fetch/HttpEndpoint, and
the POSIX shim’s socket path must detect the absence of NetworkManager
and route connect("http://...") through Fetch instead (or fail
closed for other schemes). CloudMetadata from
cloud-metadata-proposal.md is simply not
granted in the browser; there is no cloud instance to describe.
Each host-backed cap is opt-in per-process via the manifest; each has a native counterpart that the schema is already the contract for. This is a substantial point in favor of the port: host-provided services slot into the existing capability model without widening it.
CapSet bootstrap
The read-only CapSet page at CAPSET_VADDR is replaced by a structured-clone
payload in the initial postMessage. capos-rt::capset::find still parses
the same CapSetHeader/CapSetEntry layout, just out of a Uint8Array
placed at a known offset in the process’s linear memory by the boot shim.
Binary Portability
Source-portable, not binary-portable. An ELF built for x86_64-unknown-capos
does not run; the same source rebuilt for wasm32-unknown-unknown (with the
atomics target feature) does, provided it stays inside the supported API
surface.
Rust binaries on capos-rt
Port cleanly:
- Any binary that uses only
capos-rt’s public API — typed cap clients (ConsoleClient, futureFileClient, etc.), ring submission/completion,CapSet::find,exit,cap_enter,alloc::*. - Pure computation,
core/alloccontainers, serde/capnp message building.
Do not port:
- Anything that uses
core::arch::x86_64, inlineasm!, orglobal_asm!. - Binaries with a custom
_startor a linker script baking in0x200000. capos-rt owns the entry shape; the wasm entry is set by the host (WebAssembly.instantiate+ an exported init), so the prologue differs. #[thread_local]relying on FS base until the wasm TLS story is decided (per-Worker globals, or the wasm threads proposal’s TLS).- Code that assumes a fixed-size static heap region and reaches it with
raw pointers. The wasm arch uses
memory.grow;alloc::*hides this,unsafe { &mut HEAP[..] }does not. - Anything that still calls the transitional
writesyscall shim — the browser build deliberately omits it.
Binaries mixing target features across the workspace produce silently-
broken atomics. A single rustflags set for the browser build is required.
POSIX binaries (when the shim lands)
The POSIX compatibility layer described in
userspace-binaries-proposal.md Part 4
sits on top of capos-rt. If capos-rt builds for wasm, the shim builds for
wasm, and well-behaved POSIX code rebuilt for a wasm-targeted
libcapos (clang --target=wasm32-unknown-unknown + our libc) ports too.
Ports cleanly:
- Pure computation, string/number handling, data-structure libraries.
stdioover Console / future File caps.malloc/free, C++new/delete, static constructors.select/poll/epollimplemented over the ring (ring CQEs are exactly the event source these APIs want).posix_spawnoverProcessSpawner— spawning a new process becomes “instantiate a new Worker,” which is the native shape of the browser anyway.- Networking via
Fetch/HttpEndpoint(drafted in service-architecture-proposal.md) if the manifest grants the cap. The browser port serves these against the host’sfetch/WebSocket — not ambient authority, because only processes granted the cap can invoke it. RawAF_INET/AF_INET6sockets via theTcpSocket/NetworkManagerinterfaces in networking-proposal.md are not available in the browser (no raw-socket primitive); POSIX networking code wants URLs in practice, and a libc shim can mapgetaddrinfo+connect+writeoverFetch/HttpEndpointfor the HTTP(S) case, failing closed otherwise.
Does not port without new work, possibly ever:
fork. Cannot clone a Worker’s linear memory into a new Worker and resume at theforkcall site — there is no COW, no MMU, no way to duplicate an opaque WASM module’s mid-execution state. This is the same reason Emscripten/WASI don’t supportfork. POSIX programs that fork-then-exec can be rewritten toposix_spawn; programs that fork-for-concurrency (Apache prefork, some Redis paths) cannot.- Signals. No preemption inside a Worker means no asynchronous signal
delivery.
SIGALRM,SIGINT,SIGSEGVall need cooperative polling at best;kill(pid, SIGKILL)maps toworker.terminate()and nothing finer.setjmp/longjmpworks within a function call tree;siglongjmpout of a signal handler does not exist. mmapof files withMAP_SHARED. WASM linear memory is not file-backed and cannot be.MAP_PRIVATE | MAP_ANONYMOUSworks trivially (it’s justmemory.grow+ a free list). File-backed mappings require a userspace emulation that reads on fault and writes back on unmap — workable for small files, a lie for the memory- mapped-database case.- Threads without the wasm threads proposal. pthreads over Workers
sharing a memory is the only implementation strategy, and it requires
the wasm
atomics/bulk-memory/shared-memoryfeature set plus careful runtime support. Single-threaded POSIX code works now; multithreaded POSIX code needs the in-process-threading track from the native roadmap and its wasm counterpart. - Address-arithmetic tricks. Wasm validates loads/stores against the linear-memory bounds. Code that relies on unmapped trap pages (guard pages, end-of-allocation sentinels) or on specific virtual addresses fails.
dlopen. A wasm module is immutable after instantiation. Dynamic loading requires loading a second module and linking via exported tables — possible with the component model, nowhere near drop-indlopen. Static linking is the pragmatic answer.
Rough guide: if a POSIX program compiles cleanly under WASI and uses only WASI-supported syscalls, it will almost certainly port to capOS-on-wasm with the shim, because the constraints overlap. If it needs features WASI doesn’t support (fork, signals, shared mmap), the capOS browser port will not magically fix that — the limitations come from the substrate, not from the POSIX shim’s completeness.
Build Path
Three new cargo targets, no workspace restructuring required:
-
capos-libonwasm32-unknown-unknown. Alreadyno_std + alloc, no arch-specific code. Should build as-is; verify undercargo check --target wasm32-unknown-unknown -p capos-lib. -
capos-configonwasm32-unknown-unknown. Same — pure logic, the ring structs and volatile helpers are portable. -
capos-rtonwasm32-unknown-unknownwithatomicsfeature. The standalone userspace runtime currently hard-codes x86_64 syscall instructions. Introduce anarchmodule split:arch/x86_64.rs(existingsyscall.rscontents)arch/wasm.rs(new —Atomics.waitviacore::arch::wasm32::memory_atomic_wait32,exitvia host import)
Gate at the
syscallboundary, not deeper; the ring client above it is arch-agnostic. -
Demos on
wasm32-unknown-unknown. Same arch split applied viacapos-rt. No per-demo changes expected if the split is clean.
The kernel does not build for wasm. Instead, a new crate
capos-kernel-wasm/ (peer to kernel/) reuses capos-lib’s CapTable and
capos-config’s ring structs and implements the dispatch loop against JS
host imports for Worker management. It is, deliberately, not the same kernel
binary. Trying to build kernel/ for wasm would pull in IDT/GDT/paging code
that has no meaning in the browser.
Phased Plan
Phase A: Port the pure crates
- Verify
capos-lib,capos-configbuild clean onwasm32-unknown-unknown. CI job:cargo check --target wasm32-unknown-unknown -p capos-lib -p capos-config. - Add a host-side
ring-tests-jsharness that exercises the same invariants astests/ring_loom.rsbut with a real JS producer and a Rust/wasm consumer, both sharing aSharedArrayBuffer. Proves the volatile access helpers are portable before anything else depends on them.
Phase B: capos-rt arch split
- Introduce
capos-rt/src/arch/{x86_64,wasm}.rsbehind a#[cfg(target_arch)]. - Rewire
syscall/ring/clientto call through the arch module. - Add
make capos-rt-wasm-checktarget. Existingmake capos-rt-checkstays for x86_64.
Phase C: Kernel Worker + init
capos-kernel-wasm/with a Console capability that renders to xterm.js viapostMessageback to the main thread.- Kernel Worker spawns init. Init prints “hello” through Console and exits.
Phase D: ProcessSpawner + Endpoint
ProcessSpawnerserved by the kernel Worker, granted to init.- Init parses its
BootPackageand spawns theendpoint-roundtripandipc-server/ipc-clientdemos viaProcessSpawner.spawn. These stress capability transfer across Workers: does a cap handed from A to B via the ring land correctly in B’s ring, and does B’s subsequent invocation route back to the right holder? - This phase turns the port into a validation surface for the
capability-transfer and badge-propagation invariants in
docs/authority-accounting-transfer-design.md, and a second implementation of the Stage 6 spawn primitive.
Phase E: Integration with demos page
- Hosted page at a project URL; xterm.js terminal; selector for which demo manifest to boot.
- Serve
.wasmartifacts as static assets.
Security Boundary Analysis
The browser port changes what is trusted and what is verified. Summary:
| Boundary | Native (x86_64) | Browser (WASM-Workers) |
|---|---|---|
| Process ↔ process | Page tables + rings | Worker agents + SAB (structural) |
| Process ↔ kernel | Syscall MSRs + SMEP/SMAP | postMessage + validated host imports |
| Code integrity | W^X + NX | WASM validator + immutable Module |
| Capability forgery | Kernel-owned CapTable | Kernel-Worker-owned CapTable |
| Capability transfer | Ring SQE validated in kernel | Ring SQE validated in kernel Worker — same code path |
The capability-forgery story is the same in both: an unforgeable 64-bit
CapId is assigned by the kernel and can only be resolved through the
kernel’s CapTable. A process Worker cannot synthesize a valid CapId
because it never sees the CapTable; it only sees SQEs it submits and CQEs
it receives. This property is what makes the port worth doing — the
capability model is preserved exactly.
What weakens: no SMAP/SMEP equivalent, but also no corresponding attack
surface (the “kernel” Worker has no pointer into process memory; it can only
copy bytes out of the shared ring). No DMA problem. No side-channel parity
with docs/dma-isolation-design.md — Spectre/meltdown in the browser is the
browser’s problem, mitigated by site isolation and COOP/COEP.
Required headers: Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp — SharedArrayBuffer is gated
on these. A hosted demo page must set them.
What This Port Buys Us
- Shareable demos. A URL that boots capOS in ~1s, with no QEMU, no local install. Valuable for documentation and recruiting.
- A second substrate for the capability model. If the cap-transfer protocol has a bug, reproducing it under Workers (single-threaded, deterministic scheduling) is much easier than under SMP x86_64. A second implementation of the dispatch surface is a correctness asset.
- Forcing function for
writesyscall removal. The browser port cannot support the transitionalwritepath without importing host I/O as a back door, which is exactly the ambient authority we want to avoid. Shipping a browser demo at all requires finishing the migration to the Console capability over the ring. - Teaching surface. Workers give a much clearer visual of “one process, one memory, one cap table” than a bare-metal kernel ever will. The isolation story renders in the DevTools panel.
What It Does Not Buy Us
- Not a validation surface for the x86_64 kernel. Page tables, IDT, context switch, SMP — none of that runs. Bugs in those subsystems will not appear in the browser build.
- Not a performance story. WASM + Workers + SAB is slower than native QEMU-KVM for the parts it does overlap on, and does not exercise the hardware features capOS eventually cares about (IOMMU, NVMe, virtio-net).
- Not a path to “capOS on Cloudflare Workers” or similar. Cloudflare’s runtime is a single isolate per request, no SAB, no threads — a different environment that would need its own proposal.
Open Questions
- Do we ship one
capos-kernel-wasmcrate, or does the kernel Worker run plain JS that imports a thincapos-dispatchwasm? JS-hosted kernel is simpler (no second wasm toolchain for the kernel side) but duplicates cap-dispatch logic. Preferred: Rust/wasm kernel Worker reusingcapos-lib— dispatch code stays single-sourced. - How do we surface kernel panics in the browser? Native capOS halts
the CPU; the browser equivalent is posting an error to the main thread
and tearing down all Workers. Should match the
panic = "abort"contract — no recovery attempted. - Do we implement
VirtualMemoryas a no-op or as a real allocator? No-op is faster to ship; a real allocator overmemory.growexercises more of the capability surface. Lean toward real, gated behind abrowser-shimflag so the demo doesn’t silently diverge from the native semantics. - Manifest format: keep capnp, or add JSON for hand-authored demo configs? Keep capnp. The manifest is already the contract; adding a parallel format is exactly the drift the project has been careful to avoid.
Relationship to Other Proposals
userspace-binaries-proposal.md— the wasm32 runtime story lives there eventually. This proposal is narrower: just enough runtime to boot the existing demo set in a browser. If the userspace proposal lands a richer runtime first, this one adopts it.smp-proposal.md— structurally irrelevant to the browser port (each Worker is its own agent). The browser port does inform SMP testing, because the cap-transfer protocol under Workers is a cleaner model of “messages cross agents asynchronously” than single-CPU preempted kernels.service-architecture-proposal.md— process spawn in the browser becomes Worker instantiation. The lifecycle primitives (supervise, restart, retarget) map naturally. Live upgrade (live-upgrade-proposal.md) is even more natural under Workers than under in-kernel retargeting — swap theWebAssembly.Modulebehind a Worker while the ring stays live.security-and-verification-proposal.md— the browser port adds a CI job (wasm builds + JS-side ring tests) but does not change the verification story for the native kernel.