Proposal: POSIX Compatibility Adapter
How capOS should host POSIX-shaped C software without recreating the ambient authority that makes POSIX hard to confine, and which two ports validate the adapter for the first time.
Problem
capOS is not POSIX and is not trying to become POSIX. But useful software – DNS resolvers, line-editing libraries, shells, archivers, compilers, network clients – assumes a POSIX surface. Rewriting each of these in capability- native Rust would forfeit decades of debugging, security review, and performance work for no isolation gain: a POSIX program whose only authority is a typed capability set is already as confined as an equivalent native one.
The risk pattern is the one POSIX historically gets wrong: a translation layer
that synthesises ambient authority (a global /, an inherited credential
table, a kernel-managed file descriptor map) rebuilds the property capOS is
trying to leave behind. A useful adapter must do the opposite – every POSIX
call must be backed by a typed capability the calling process already holds,
or it must fail closed with a documented errno.
Two upstream programs are the natural first validators of that adapter:
- A POSIX shell exercises the broadest surface (process, pipe, file, env, signal stubs, stdio).
- A DNS resolver exercises the smallest network surface (UDP socket, one-shot poll-equivalent, time, log).
Both are already small, mature, and BSD/MIT-licensed. Picking the smallest representative of each category makes the adapter’s first job a real port, not a synthetic test.
Scope
In scope:
- A two-layer C substrate:
libcapos(thin Rust staticlib, capability ring + CapSet + raw syscalls + heap, C ABI) andlibcapos-posix(POSIX shape on top: fd table, errno, path resolution, posix_spawn shim, signal stubs, pthread mapping). - A first POSIX shell port that builds against
libcapos-posixwith no hidden ambient authority. - A first DNS resolver port that builds against
libcapos-posixwith no hidden ambient authority. - Phase decomposition (P1.1, P1.2, P1.3) that defers the adapter’s biggest dependencies (Namespace + File caps for the shell file path; UDP cap for the resolver) into clearly-named gating phases.
- Validation through QEMU smokes that prove granted and ungranted paths.
Out of scope for the first implementation:
- Binary compatibility with Linux ELFs. Both ports are sources-on-disk
recompiled against
libcapos-posix. - Full POSIX compliance. The adapter ships exactly the surface dash and dns.c exercise, plus any free additions that fall out.
- Real
fork()(parent state inheritance, COW, sibling address-space surgery before exec). Onlyfork()followed promptly byexecve()is supported, via aposix_spawn-shaped shim. - Real signal delivery.
signal()/sigaction()accept the call, store the handler, never invoke it.kill(2)requires a futureProcessHandlecap. - Job control, process groups, sessions, controlling terminals.
- musl, glibc, or any other host libc. The substrate is Rust-authored and exposes a C ABI; it is not a libc port.
- Hosted C++. ABI decisions for C++ remain tracked in
docs/proposals/userspace-binaries-proposal.md.
Current Manual Pages
- Programming Languages summarizes POSIX
adapter status relative to Rust, C/C++, Python, Go, Lua, and WASI tracks;
the C row records the shipped
libcapos.a+libcapos_posix.asurface, and the POSIX-shaped software row records P1.1/P1.2/P1.3 closeouts plus the in-progress P1.4 dash-port phase shape over the bootstrap-granted rootDirectorycap surface, including the signal/time stub closeout. - Userspace Binaries Part 4: POSIX Compatibility Adapter sketches the POSIX adapter at a higher level. This proposal supersedes that sketch with the full design surface; the userspace-binaries proposal continues to own the broader native-binary, language, and adapter roadmap.
- Userspace Runtime documents the
implemented
capos-rtsurface thatlibcaposmirrors for C consumers. - Networking defines
NetworkManager,TcpListener, andTcpSocketand explicitly defersUdpSocketuntil DNS / userspace-network work needs it. The DNS resolver port in this proposal defines the UDP cap surface; the TCP cap surface is reused unchanged. - Storage and Naming defines the
Namespace,Directory,File, andStorecap shape; these gate the shell port’s filesystem surface (Phase 2/3 of that proposal). - Service Architecture frames the future
Resolvercap as the long-term consumer of the resolver process built in this track. - Shell covers the native
capos-shell. The POSIX shell port (dash) is for porting validation, not as a replacement for the native shell. - WASI Host Adapter is the parallel untrusted-portable execution path; both proposals share fd-table and per-import authority insight, but target different substrates.
Research Grounding
Relevant research and external references:
- POSIX shell candidates surveyed: dash (Debian Almquist Shell, ~13 kSLOC,
BSD; the canonical small POSIX-strict shell); busybox
ash; OpenBSD ksh (oksh); toyboxtoysh. Source repositories cited inline in the candidate comparison table. - DNS resolver candidates surveyed:
dns.cby William Ahern (single-file MIT, ~10 kSLOC, no dependencies); c-ares; GNU adns; udns; SPCDNS; musl’s embeddedres_query; trust-dns-resolver. Source repositories cited inline in the candidate comparison table. - libcapos prior art: this proposal builds on the
libcaposshape sketched in Userspace Binaries “Future: C vialibcapos” / “Future Phase: libcapos for C”. The C substrate is designed as a Rust staticlib with a C ABI rather than musl, redox relibc, or a hand-rolled libc. Fuchsia’s fdio + musl pattern and Redox’s relibc pattern are the comparable points; capOS deliberately picks neither. - POSIX surface translation: Cygwin’s
fork()emulation is the closest prior art for fork-for-exec semantics on top of a non-fork substrate; the capOS shim inverts the default (capOS cannot fork; the shim emulates the useful case) but uses the same call-pattern recognition.
In-tree research grounding:
- Genode – per-session typed service interfaces and resource accounting are the closest precedent for routing every POSIX wrapper through a typed cap rather than through an ambient kernel syscall table. POSIX adapter wrappers should follow the same pattern at the library boundary instead of the kernel boundary.
- OS Error Handling – cross-OS
comparison of error-model surfaces. Informs the bidirectional mapping
between
CapError/CapExceptionand POSIX errno (Open Question §4) and the decision to keep one shared mapping table at the C boundary rather than per-wrapper bespoke mappings. - LLVM Target – target triple, calling
convention, and bare-metal toolchain options for capOS C consumers.
Informs Open Question §11 on the linker / toolchain choice (
clang --target=x86_64-unknown-none-elf -nostdlib -static).
This proposal also lifts the capability-mapping shape and the “every
translation has authority backing” property from the WASI host adapter
proposal, and the libcapos staticlib shape from the userspace-binaries
proposal Part 2. It deliberately does not adopt the musl + __syscall
hook pattern noted in the userspace-binaries proposal “musl as a Base
(Optional, Later)” section, because the layered Rust staticlib shape is
preferred over a libc port for the v0 surface.
External:
- dash – Debian Almquist Shell, ~13 kSLOC, Debian’s
/bin/shsince Squeeze (2011). - busybox
ash– alternative Almquist port, embedded. - oksh – portable OpenBSD ksh, public domain, larger surface.
- toybox toysh – 0BSD, currently incomplete.
- c-ares – modern async DNS resolver, MIT, larger.
- dns.c – single-file non-blocking DNS, MIT, no deps.
- GNU adns – async DNS resolver, GPL-2.0+.
- musl resolver – embedded in musl libc; not available without linking musl.
- udns – small async stub-only resolver, LGPL-2.1.
Design Principles
- POSIX is not a kernel feature. The kernel sees ordinary userspace
processes with a CapSet and a capability ring.
libcaposandlibcapos-posixare static libraries linked into those processes. - Two layers, one C ABI per layer.
libcaposis the C-ABI mirror ofcapos-rt: capability ring, CapSet, raw syscalls, heap. It has no errno, no fd table, noopen/read/write.libcapos-posixbuilds the POSIX shape on top. Programs that do not need POSIX semantics may link onlylibcapos. - Authority is per-process, granted at spawn. Every fd a POSIX program
sees was granted to its parent process at spawn time and projected onto
an fd by
libcapos-posix. There is no ambient/, no inherited credential table, no global signal source. - Schema-first, not POSIX-first, at the boundary. Each POSIX wrapper is backed by a typed capability call with a documented errno mapping. POSIX-shaped integer fds and POSIX-shaped errno are an ABI requirement of the C substrate, not a capability-model concession.
- Fail closed. Any unimplemented POSIX call returns
ENOSYSand sets errno. Any cap lookup that fails returns the documented errno. Programs cannot probe absent caps for ambient behaviour. - No fork without exec. Only
fork()followed byexecve()is supported. The shim turns the pair intoposix_spawn(). Barefork()used to clone state in-process fails on the next non-trivial syscall. - No real signals. Handlers are accepted and stored, never delivered.
kill(2)requires a futureProcessHandlecap and even then is limited toSIGKILL. Programs that depend onSIGCHLDjob control are out of scope. - The C substrate is Rust.
libcaposandlibcapos-posixare Rust crates withcrate-type = ["staticlib"], all symbols#[no_mangle] extern "C". This is not musl, not a hand-rolled libc.
Architecture
flowchart TD
Shell["POSIX shell binary<br/>(e.g. dash)"]
Resolver["DNS resolver binary<br/>(e.g. dns.c)"]
Posix["libcapos-posix<br/>(POSIX adapter, Rust staticlib, C ABI)"]
PosixDetail["fd table per process<br/>path resolver over Namespace + Store<br/>errno mapping (TLS cell)<br/>posix_spawn over ProcessSpawner<br/>signal stubs<br/>pthread over ThreadSpawner"]
Posix --> PosixDetail
Capos["libcapos<br/>(thin Rust staticlib, C ABI)"]
CaposDetail["cap_call / capset_get / capset_iter<br/>sys_exit / sys_cap_enter<br/>heap (malloc/free over capos-rt allocator)<br/>typed wrappers for Console / Terminal / etc."]
Capos --> CaposDetail
Rt["capos-rt<br/>(no_std + alloc Rust)"]
Ring["capability ring"]
Kernel["kernel CapObject dispatch"]
Services["userspace services"]
Shell -->|"open/read/write/exec/..."| Posix
Resolver -->|"socket/sendto/recvfrom"| Posix
Posix -->|"extern C"| Capos
Capos -->|"Rust FFI re-export"| Rt
Rt --> Ring
Ring --> Kernel
Ring --> Services
libcapos is the C-ABI projection of capos-rt. libcapos-posix is the
POSIX projection on top. Every POSIX call ultimately resolves to either a
capability invocation through the ring or a synthetic answer (errno,
ENOSYS) computed without authority.
libcapos: C-Facing Substrate
Headers expected to ship under include/capos/:
// capos.h -- capability primitives only
typedef struct cap_ring cap_ring_t;
typedef uint32_t cap_id_t;
typedef uint64_t iface_id_t;
cap_ring_t *capos_ring(void); // process ring handle
int cap_call(cap_ring_t *ring,
cap_id_t cap, uint16_t method,
const void *params, size_t plen,
void *result, size_t rlen,
size_t *out_len);
int capset_get(const char *name,
cap_id_t *out_cap, iface_id_t *out_iface);
size_t capset_iter(void (*cb)(const char*, cap_id_t, iface_id_t,
void*), void *ud);
_Noreturn void sys_exit(int code);
uint32_t sys_cap_enter(uint32_t min_complete, uint64_t timeout_ns);
// Heap (backed by capos-rt fixed heap; grow-on-demand later if needed)
void *capos_malloc(size_t);
void capos_free(void*);
void *capos_calloc(size_t, size_t);
void *capos_realloc(void*, size_t);
There is no errno here, no open/read/write. Those live one
layer up. libcapos is the C-ABI mirror of capos-rt: startup, ring,
CapSet, raw syscalls, heap.
Build artifact: target/.../libcapos.a plus headers. Naming for the C
library is intentionally just libcapos, mirroring how the Rust
runtime crate is capos-rt. The C library name libcapos is
distinct from any Rust service framework that may carry a similar name;
this proposal owns the C-substrate name and treats Rust-framework
naming as out of scope.
libcapos-posix: POSIX Surface
Headers under include/capos/posix/: unistd.h, fcntl.h, errno.h,
sys/socket.h, netdb.h, sys/stat.h, dirent.h, string.h, stdlib.h
(subset), sys/types.h, pthread.h (subset), signal.h (stub).
Implementation language: Rust, same crate-type pattern as libcapos,
but linked separately so a binary that does not need POSIX can omit it.
Errno bridge: per-thread errno cell stored in TLS slot owned by
libcapos-posix; populated by every wrapper that maps a Rust CapError to
a POSIX errno value. See “errno Convention” below.
File descriptor table
Per-process userspace state inside libcapos-posix. Not a kernel object –
neither libcapos nor the kernel know anything about fds.
#![allow(unused)]
fn main() {
// libcapos-posix/src/fd.rs (sketch)
struct FdEntry {
backing: FdBacking, // Console / Stream / Listener / File / Dir
flags: i32, // O_NONBLOCK, FD_CLOEXEC, ...
cursor: u64, // for seekable backings
}
enum FdBacking {
Stdin, // Console / TerminalSession (read side)
Stdout, // Console (write side)
Stderr, // Console (write side)
File { file: Cap<File>, dirty: bool },
Dir { dir: Cap<Directory>, iter: usize },
Tcp { sock: Cap<TcpSocket> },
Udp { sock: Cap<UdpSocket> },
Listener { l: Cap<TcpListener> },
}
static FD_TABLE: Mutex<BTreeMap<i32, FdEntry>> = ...;
static NEXT_FD: AtomicI32 = AtomicI32::new(3);
}
dup/dup2/close operate on this table. dup increments a refcount on
the underlying cap; close releases when the last fd holding the cap drops.
Cap drop runs through capos-rt owned-handle release. The fd table is a
strict per-process userspace structure; it is not shared with the kernel
and is never serialised on the wire.
Standard fds wired at _start:
- fd 0:
stdincap from CapSet (TerminalSession, Console, or future StdinReader-shaped cap, whichever is granted). - fd 1:
stdoutConsole cap. - fd 2:
stderrConsole cap (or distinct Log cap if granted).
Process model: fork-for-exec only
capOS process creation is ProcessSpawner.spawn(name, binaryName, grants)
(kernel/src/cap/process_spawner.rs). There is no fork(), no
exec()-in-place.
Decision matrix (working answers; the policy choice is Open Question §6 and is not settled until that question is confirmed):
| Option | What it provides | Cost | Working answer |
|---|---|---|---|
Emulate fork() as posix_spawn with inherited cap-set, recording inter-call dup2/close as posix_spawn file actions | Existing fork+exec and fork+dup2+exec pipeline patterns work with one patch site | Daemonisation and arbitrary COW state inheritance between fork and exec still break | Recommended primary for the shell, with documented “fork-for-exec only” semantics. Whether the shim records inter-call file actions or requires the port to call posix_spawn with explicit file actions is Open Question §6. |
Return ENOSYS for any fork() | Honest | Every POSIX program that uses fork must be patched | Recommended safety net when fork-for-exec is misused |
| Process-shadow: a “POSIX process” wraps a capOS process | General | Large kernel + runtime change; doubles process accounting | Recommended reject for v0; revisit only if a real POSIX program needs it |
Working answer: fork-for-exec, with hard-fail as the safety net (subject to
Open Question §6 confirmation before P1.3 begins). Two libcapos-posix
shim variants are on the table; §6 selects between them:
- Variant A – recording shim.
libcapos-posixexposesfork()andexecve()as a coupled shim that:fork()records “next exec is the real spawn” in TLS and returns 0 unconditionally. Only theif (pid == 0)branch ever executes; the legacyelsebranch is unreachable becausepidis always 0. Porters MUST move the parent flow (drop unused write end, drain read end,waitpid) to AFTER the if-block, with the synthetic pid handed off viachild = execve(...);near the end of the if-body. Pictorially:
There is nopid_t child = fork(); // returns 0 unconditionally if (child == 0) { dup2(...); close(...); // recorded into TLS child = execve(...); // returns synthetic_pid > 0 if (child < 0) { // surface to error path goto exec_failed; } } /* parent flow runs here, NOT in an else branch */ close(...); read(...); waitpid(child, ...);elsebranch in the v0 contract, only the post-if parent flow.dup2()/close()calls betweenfork()andexecve()are recorded asposix_spawnfile actions on the pending spawn rather than mutating the parent’s fd table.execve(path, argv, envp)consumes the recorded intent, callsProcessSpawner.spawn()with attenuated grants and the recorded file actions, and returns the synthetic child pid as its own return value (a deliberate v0 deviation from POSIX). The pseudo-child branch is still the original parent process, so porters MUST NOT call_exit()on failure:_exit()would terminate the actual shell. The recommended pattern surfaces the failure to the caller’s normal error path:
On failureint spawn_pid = execve(...); if (spawn_pid < 0) { /* execve() failed before any spawn; recording state is * already cleared and the parent fd table is unchanged. * Return up to the caller with the matching errno. */ goto exec_failed; /* or equivalent error-recovery path */ } child = spawn_pid; /* parent flow: waitpid(child) */execve()returns -1 witherrnoset; callers MUST surface the failure to their normal error path rather than calling_exit(), because the pseudo-child branch is still the parent process and_exit()would terminate the actual shell.- Any
fork()not followed byexecve()before a syscall outside the recorded-action allowlist (e.g.setsid) returns -1 / ENOSYS on that downstream call.
- Variant B – patched-port shim.
libcapos-posixexposes onlyposix_spawn()with explicit file actions, plus stubfork()/execve()that return -1 / ENOSYS. Each port (dash and successors) is patched to translate its fork+dup2+exec sequence into a singleposix_spawn()call with the equivalent file actions.
posix_spawn() is the preferred primitive in either variant and gets a
direct mapping to ProcessSpawner.spawn(). The choice between Variant
A and Variant B is Open Question §6.
fd-backing-cap inheritance (kernel precursor). For a fork/execve child to
inherit a parent fd that is backed by an opened Directory/File cap, that cap
must be forwardable through ProcessSpawner.spawn. Read-only Directory/File
caps are now minted Copy/SameSession (directory::transfer_result_cap,
readonly_fs, installable_image, and the kernel:directory/kernel:file
bootstrap sources), so the shim can forward an opened read-only directory or
file to the spawned child as a Raw spawn grant; the child looks it up by name
from its CapSet and projects it back onto the inherited fd. The disk-backed
writable filesystem stays NonTransferable (single-writer policy), so a
writable fd cannot be inherited this way. The kernel handoff is proven in
isolation by make run-spawn-grant-directory; see
Capability Model “Read-only filesystem caps are
forwardable”. The recording shim emits these grants. As of posix-recording-shim-full-fd-inherit
(done 2026-05-27) inheritance is full-fd-table by default, matching POSIX
fork+execve: execve forwards every open parent slot – not only
dup2/close-touched ones – as a stdio_<child-slot> spawn grant, with the
recorded actions applied as edits on top of that baseline. Per backing:
Directory/Console/File/TerminalSession forward as SpawnGrantMode::Raw
over the Copy-transferable cap (the parent keeps its own fd; an aliased slot
Copy-shares to several child slots), and a Pipe end forwards as a single
Move (leaving the parent slot a Moved sentinel). A slot marked
FD_CLOEXEC/O_CLOEXEC is dropped from the child unless an explicit recorded
dup2 named that child slot (POSIX dup2 clears close-on-exec). A
non-forwardable backing inherited implicitly (Udp, an already-moved slot, or a
shared Pipe) is skipped non-fatally; an explicit dup2 of one fails closed.
The child’s posix_inherit_stdio() reconstructs each grant into the matching fd
slot by interface id, wrapping an inherited directory fd through fdopendir().
End-to-end proofs make run-posix-fd-inherit-default (parent inherits stdio +
directory by default with no stdio dup2; CLOEXEC fd excluded; terminal retained
via Raw; Copy-share alias) and make run-posix-execve-inherit-smoke (the
explicit-dup2 parent, now redundant but still correct). Because the v0 POSIX
open surface mints only Copy/SameSession File/Directory caps, the
disk-backed writable NonTransferable filesystem cannot enter the fd table
here; if a future writable open path mints one, full inheritance needs a
pre-spawn transferability check to skip it (today it would surface as the
whole-spawn ENOEXEC). An inherited File resets to offset 0 (the parent’s seek
position is userspace state that does not travel with the cap).
The recording-shim execve(path, argv, envp) path also forwards argv without
changing the generated ProcessSpawner.spawn(name, binaryName, grants) schema:
the parent validates the C argv vector, writes a bounded binary argv record into
a private Pipe, and grants only the read end to the child as posix_argv.
Child code opts in with posix_args(), which prefers posix_argv when present
and otherwise falls back to manifest initConfig.init.posixArgs through the
boot BootPackage cap. The pipe payload is capped by the existing 4 KiB Pipe
transport, so direct large manifest posixArgs remain the wider PID-1 channel.
Malformed or over-budget execve argv fails before fd-action replay; the focused
proof asserts this does not mutate the parent’s fd table.
Signals
Stubbed. capOS has no signal mechanism today and the cap model disagrees with ambient asynchronous interrupts.
signal()/sigaction()accept the call, store the handler in a per-process table, never invoke it. Return success.kill(pid, sig)returns -1 / EPERM unless the caller has aProcessHandlecap for the target – and even then the only signal honoured would beSIGKILL, which maps to a futureProcessHandle.kill()outside this v0 POSIX surface.raise(sig)returns -1 / ENOSYS. Self-delivery is still signal delivery, and capOS v0 intentionally does not fake it.sigemptyset/sigfillset/sigaddset/sigdelset/sigismemberare real bit operations on the caller’ssigset_t(auint64_t).sigprocmaskkeeps a per-process blocked mask so ports can save and restore it during job control, honoursSIG_BLOCK/SIG_UNBLOCK/SIG_SETMASK, and force-clearsSIGKILL/SIGSTOPper POSIX – but the mask is stored, never enforced, because there is no delivery to block.sigpendingalways reports an empty set for the same reason.pause()/sigsuspend()/sigwait()block forever (or with timeout) viasys_cap_enter(0, timeout); they never wake from a signal.SIGPIPEis never delivered. Writes on a closed connection return -1 / EPIPE.
This is acceptable for a shell + DNS resolver. Anything that depends on
real signals (job control with Ctrl-Z, Ctrl-C across pipelines, real
SIGCHLD) is out of scope for the first port. Job control in the shell
must be reimplemented over typed control caps, not signals.
errno convention
Per-thread errno cell in TLS owned by libcapos-posix. Mapping table
(libcapos-posix/src/errno_map.rs):
capOS CapError / CapException | POSIX errno |
|---|---|
CapError::NotFound | ENOENT |
CapError::PermissionDenied | EACCES |
CapError::Disconnected | ECONNRESET |
CapError::Timeout | ETIMEDOUT |
CapError::ResourceExhausted | ENOMEM / EMFILE (context dependent) |
CapError::InvalidArgument | EINVAL |
CapError::WouldBlock | EAGAIN |
| (fall-through) | EIO |
Wrappers always: clear errno, call, on error set errno + return -1 (int) or NULL (pointer). Same convention as glibc / musl.
Threading
pthreads -> capOS in-process threading. Substrate already exists in the
kernel: ThreadSpawner, ThreadControl, ThreadHandle, per-thread
FS-base, ParkSpace.
Mapping:
pthread_create->ThreadSpawner.spawn+ start-routine trampoline.pthread_exit->ThreadControl.exitThread.pthread_join->ThreadHandle.join(block viacap_enter).pthread_self-> TLS slot orThreadControl.currentId.pthread_mutex_*-> ParkSpace-backed mutex (futex-style park / unpark).pthread_cond_*-> ParkSpace + bounded waiter queue.pthread_key_*-> fixed-size TLS slot table per thread.
This is in scope but not on the critical path for the shell or DNS resolver – both can run single-threaded for v0. The pthread shim is deferred to a v1 successor.
First Port: POSIX Shell
Candidate survey
| Shell | License | Size | Deps | POSIX coverage | Verdict |
|---|---|---|---|---|---|
| dash (upstream) | BSD | ~13 kSLOC, ~134 KB | tiny libc subset; no readline; no termcap | Strict POSIX, no extensions | Recommended primary |
| busybox ash (upstream) | GPL-2.0 | ~8 kSLOC of shell/ash.c + busybox infra | Designed for embedded, modular | POSIX + selectable extensions | Heavier framework cost; useful later when capOS wants a coreutils set |
| toybox toysh (upstream) | 0BSD | currently incomplete | Designed for self-contained ELF | POSIX + Bash compat target, not finished | Skip – explicitly described upstream as still under development |
| oksh (upstream) | Public domain | ~308 KB binary, 0 deps | Optional ncurses for clear-screen only | Korn-shell superset of POSIX | Bigger surface than v0 needs to validate libcapos-posix |
| Custom Rust shell | n/a | n/a | n/a | n/a | Reject – defeats the purpose of porting C. Native shell already exists at shell/ (capos-shell). |
Recommended primary: dash.
Reasons:
- Smallest established POSIX-strict shell. ~13 kSLOC is small enough for the porting team to read the entire codebase.
- No readline / termcap dependency. The shell talks to whatever fd 0
gives it. This is exactly what
libcapos-posixprovides throughTerminalSessionorConsole. - Strict POSIX means the port does not accidentally validate Bash
extensions that
libcapos-posixdoes not implement. - Already proven as a porting target on Linux from Scratch, OpenWrt, and
Alpine. Patterns for replacing the libc layer (
__syscall, stubbedsigaction) are well documented. - Debian uses it as
/bin/shsince Squeeze (2011), so any “POSIX shell only” script base in the wild is dash-compatible.
Open Question §1 below records this candidate as the final decision
(Decided (P1.4 Slice 1, 2026-05-24 00:53 UTC)).
Required POSIX surface (v0)
What a dash instance actually exercises before printing a prompt and
running ls | grep foo:
| Group | Calls (minimum set) | Backed by |
|---|---|---|
| Process startup | _start shim, argv/envp parsing, exit | libcapos _start, sys_exit |
| Stdio | read(0,...), write(1,...), write(2,...) | Console / TerminalSession cap |
| Allocation | malloc/free/calloc/realloc | libcapos heap |
| String/format | printf/fprintf/memcpy/strlen/strcmp/strchr/strncpy/… | libcapos-posix string/printf subset |
| File I/O | open/close/read/write/lseek/stat/fstat/access/unlink | Namespace + File caps |
| Directory | opendir/readdir/closedir | Directory cap |
| Pipes | pipe(), dup2(), close() on fds | NEW Pipe capability (P1.3) |
| Process | fork+execve (fork-for-exec only), posix_spawn, wait/waitpid | ProcessSpawner + ProcessHandle.wait |
| Env | getenv/setenv/putenv | Per-process env vector in libcapos-posix; populated from a future LaunchParameters cap when one lands |
| Signals | signal/kill/sigaction (stubs) | TLS-stored handlers, never delivered |
| Time | time/gettimeofday/nanosleep | Timer cap |
| Control flow | setjmp/longjmp over jmp_buf | libcapos x86_64 SysV global_asm (<setjmp.h>); no sigsetjmp |
| Misc | getpid/getuid/getgid | getpid from capos-rt bootstrap pid; uid/gid hardcoded for v0 |
The control-flow row was absent from the original minimum set above; dash’s
exception/interpreter control flow is built on setjmp/longjmp over a real
jmp_buf (pervasive in error.h/main.c/eval.c/parser.c/trap.c), so it
is a hard precursor for the dash build pipeline. It landed via the
libc-setjmp-longjmp task: the x86_64 SysV primitive in libcapos/src/setjmp.rs
with a <setjmp.h> header, re-exposed under libcapos-posix/include/capos/posix/,
and proven in QEMU by make run-posix-setjmp. sigsetjmp/siglongjmp are
intentionally absent (dash uses only the plain primitive; the v0 signal layer
has no asynchronous delivery and thus no signal mask to save).
Like the control-flow row, the table above also understated the header
layout and breadth of the libc surface a program of dash’s size needs. A
-nostdinc compile/link probe of the full vendored dash TU set
(2026-05-25 21:40 EEST) showed dash uses bare POSIX includes
(<unistd.h>, <fcntl.h>, …) — not the capOS capos/posix/*.h namespace — so
it requires a -nostdinc capOS POSIX sysroot plus a missing surface. This
landed (2026-05-25 22:23 UTC, libc-dash-sysroot-surface):
libcapos-posix/sysroot/include/ is the bare-header sysroot forwarding into
the capos/posix/* namespace, and the surface was completed —
strerror/qsort/umask/abort/setlocale/getrlimit/times/tcgetattr/
strtoll/strtoull/sig_atomic_t/NSIG/sigsuspend, the str* set, the
<termios.h>/<sys/resource.h>/<sys/times.h>/<locale.h>/<sys/types.h>
headers, and further items the table still understated: the C/POSIX-locale
multibyte layer (<wchar.h>/<wctype.h>, mbrtowc/wctype/iswctype/…) that
expand.c uses unconditionally, strpbrk, lstat, getgroups, wait3,
vfork, byte-order helpers, environ, and the sys_siglist array. The full
vendored dash TU set now compiles -nostdinc against the sysroot with no
unresolved libc symbols; proof make run-c-libc-surface. The dash build
pipeline (posix-p1-4-dash-build-pipeline) landed on top of it
(2026-05-26 05:11 UTC): make dash builds and links target/dash/dash.elf.
See docs/backlog/posix-adapter-dash-port.md Slice 12.5.
Critical gap: pipe(). The shell pipeline ls | grep foo requires fd 1
of ls to feed fd 0 of grep. capOS has no pipe capability today. This is
the first-port-blocking item; see Phase P1.3.
What dash will not get in v0
- Job control (Ctrl-Z,
bg,fg,&background): requires realSIGCHLD/SIGTSTP. Skip; documented as out of scope. - Process groups, sessions, controlling terminals: same reason.
trapfor signals other thanEXIT: handlers stored, never fired.read -t(timeout): doable via Timer cap; defer to v1.ulimit: returns 0 / ENOSYS. Quotas are kernel-side capability ledgers, not POSIX rlimits.
Validation smoke
make run-posix-shell-smoke:
- Boot a manifest that grants
dashaTerminalSession(stdio), a read-only bootstrap-grantedDirectorycap rooted at a tiny in-rodata pseudo-fs (the resolver remainsNamespace-shaped for forward parity with the future userspaceNamespaceservice; the v0 manifest grants aDirectorybecause that is what Storage Phase 3 slice 2 ships as a kernelCapObjecttoday), aProcessSpawnernarrowed to one allowed binary (ls-shim), and aTimercap. - Pipe a heredoc into stdin:
ls; echo done. - Assert kernel log shows
doneand clean exit.
Stretch goal smoke: cat foo | grep bar end-to-end (depends on the pipe
primitive landing).
First Port: DNS Resolver
Status update (post-smoltcp). The original v0 DNS smoke (
posix-dns-resolver, Phase P1.2 Phase B) drove a hand-rolled A query through a raw kernelUdpSocketcap; that smoke is retired with the qemu-only kernel UDP owner. Name resolution now goes through a typed systemDnsResolvercapability (network-system-dnsresolver-cap-local-proof), andlibcapos-posixexposes the standard POSIX surface over it:getaddrinfo/freeaddrinfo/gai_strerror(src/netdb.rs,include/capos/posix/netdb.h) resolve one IPv4Aresult through a granteddns_resolverendpoint and map the typed resolver status ontoaddrinfo/EAI_*, with no ambient UDP fallback (a process without the cap gets a deterministicEAI_FAIL). A read-only/etc/resolv.confprojection is materialized atopen()time from the resolver status (writes fail closed withEACCES; absent without the cap). Proof:make run-posix-getaddrinfo. The candidate survey below is retained as the original design rationale; vendoreddns.cis no longer on the critical path for the resolver bridge. AAAA /sockaddr_in6,AI_*flags, and/etc/servicesremain follow-ups (each fails closed:EAI_FAMILY/EAI_BADFLAGS/EAI_SERVICE).
Candidate survey
| Library | License | Source size | Deps | Async style | Verdict |
|---|---|---|---|---|---|
musl res_query (upstream) | MIT | ~2 kSLOC for resolver core | Embedded in musl | Synchronous (parallel queries internally) | Available only if the build links musl; capOS does not. Skip. |
| c-ares (upstream) | MIT, C89 | ~30+ kSLOC, multi-file, configure-driven | POSIX sockets, optional threads | Native async (callbacks + select/poll/event loop) | Largest surface, most mature, most invasive port |
| dns.c (wahern) (upstream) | MIT | single-file C, ~10 kSLOC, no deps | None – caller provides socket I/O via three pluggable patterns (pollfd / events / timeout) | Non-blocking, no required callback shape | Recommended primary |
| GNU adns (upstream) | GPL-2.0+ | Multi-file, ~10-15 kSLOC | POSIX, no event-loop integration | Async, opaque state | License is GPL-2.0+, not BSD/MIT. Skip unless capOS accepts a GPL component in the demo path. |
| udns (upstream) | LGPL-2.1 | small | POSIX | Async stub-only | LGPL plus older project; skip unless dns.c blows up |
| SPCDNS | LGPL | small | encode/decode only, no socket | n/a | Skip – provides no resolver loop |
| trust-dns-resolver in Rust | Apache-2 / MIT | large | Tokio | async | Reject – defeats the purpose of porting C. Native Rust resolver is a separate path. |
Recommended primary: dns.c by William Ahern.
Reasons:
- Single-file, zero deps. Drops into the build with a minimal
ccrule. The build avoids configure scripts, pkg-config, optional feature matrices, and multi-file build orchestration. - No fixed I/O model. dns.c is designed around three common methods
(pollfd, events, timeout). The host adapter plugs capability-backed
socket I/O without rewriting the resolver core, replacing
socket()/sendto()/recvfrom()/poll()withlibcapos-posixwrappers that return fd-shaped results backed byUdpSocket/TcpSocketcaps. - MIT license is capOS-compatible.
- ~10 kSLOC means port review can read it end-to-end.
- C89, no threading assumption, no global state surprises (resolver handle is opaque per-instance) – fits a single-process v0 design.
Open Question §2 below records that the candidate is a recommendation, not a final decision.
Required POSIX surface (v0)
The DNS resolver port exercises a very narrow POSIX subset:
| Group | Calls | Backed by |
|---|---|---|
| Stdio (logs only) | write(2,...) | Console cap |
| Allocation | malloc/free/calloc/realloc | libcapos heap |
| Time | clock_gettime/gettimeofday | Timer cap |
| Sockets (UDP) | socket(AF_INET, SOCK_DGRAM, 0), sendto, recvfrom, bind, close, setsockopt (subset) | NetworkManager + UdpSocket cap |
| Polling | poll(fds, nfds, timeout_ms) | Synthesised: each fd carries its underlying cap; libcapos-posix uses cap_enter(min_complete=1, timeout_ns) with one CQE per ready fd. No new kernel surface needed for v0 if dns.c uses one fd per query. |
| Resolv config | One in-rodata bounded text blob inlined into libcapos-posix (single nameserver entry; v0 ships before any storage cap exists) | No open / Namespace cap required for v0 |
No pipes, no fork, no exec, no signals, no /etc/resolv.conf-by-path,
no Namespace or File caps required. The DNS resolver is strictly easier
than the shell.
The v0 surface intentionally omits TCP fallback for truncated responses
and intentionally omits any path-based config file. The optional TCP
fallback row uses socket(SOCK_STREAM), connect, send, recv
through the existing NetworkManager + TcpSocket cap, but only on a
later iteration once the v0 UDP-only smoke is green; see “What dns.c
will not get in v0” below.
Critical gaps:
UdpSocketcapability. The networking proposal Phase B implements TCP + listener only; UDP “is deferred until the userspace network stack or DNS work needs it; it is not part of the Telnet Shell Demo contract” (networking-proposal.md). The resolver port creates the UDP path; it does not consume an existing one.- The future
Resolvercap concept (inservice-architecture-proposal.md“DNS resolver – consumes aUdpSocket, exportsResolver”) is a target once the UDP path exists. The first port produces the exported shape.
What dns.c will not get in v0
- DNSSEC validation: dns.c supports it, depending on
/etc/resolv.conftrust anchor config. Defer. - TCP fallback for truncated responses: implement on a second iteration once the TCP capability path is reusable.
mDNS: out of scope.- Recursive mode (acting as a recursive resolver): out of scope; v0 ships stub-only.
Validation smoke
make run-posix-dns-smoke:
- Boot a manifest that grants the resolver process a
NetworkManager(or future narrowedUdpSocket-only authority), a Console cap, and a Timer cap. The single-nameserver resolv config is the in-rodata bounded text blob compiled intolibcapos-posix; no Namespace or File cap is needed for v0. - The resolver opens a UDP socket, sends a query for a known A record to QEMU’s user-mode 10.0.2.3 (slirp’s built-in DNS) or to an in-host test resolver.
- Resolver prints the resolved IPv4 address.
- Assert kernel log line matches.
Trade-offs and Ordering
Smallest-deps comparison
| Port | C surface needed | New capOS infrastructure required | Difficulty |
|---|---|---|---|
| DNS resolver (dns.c) | malloc, time, socket subset, write(2), open RO file, poll-equivalent | UDP socket cap + NetworkManager exposure of UDP; otherwise reuses Phase B TCP path infra | Smaller – strictly additive (UDP is missing today but the kernel-side smoltcp stack supports it) |
| POSIX shell (dash) | malloc, full stdio, file I/O, directory iteration, pipe(), fork-for-exec, exec, wait, env, time, signals (stub) | Pipe primitive (new), Namespace+File cap surface, ProcessSpawner sidecar work to honour fd-action grants, env-vector handoff | Larger – touches storage / IPC / process surfaces |
Which blocks which
- Both ports can run in parallel at the
libcapos/libcapos-posixlayer level: each pulls a disjoint subset of POSIX surfaces. - DNS resolver blocks on a new capOS surface (UDP cap exposure) but does
not block on
pipe(),fork(), orexec(). - Shell blocks on (in order of probable cost): pipe primitive,
ProcessSpawner fd-action support for stdin / stdout redirection,
Namespace+File cap availability, env vector /
LaunchParameters. - The library substrate (
libcaposstaticlib +libcapos-posixscaffold) blocks both. Once the substrate exists, the two ports proceed in parallel.
Recommended sequence
- libcapos staticlib v0 (Phase P1.1). The thin Rust
.awithcap_call,capset_get,sys_exit,sys_cap_enter, heap. Plus a “C hello world” smoke that callsconsole_write_line()(mirrors the userspace-binaries proposal “Future Phase: libcapos for C”). This phase is the prerequisite for both P1.2 and P1.3. - libcapos-posix scaffold – fd table, errno cell, stdio wrappers for
fd 0/1/2, stub signals,
_startglue that registersargv/envpfromLaunchParameters(or empty arrays if that surface has not landed), basicmalloc/freere-export. - dns.c port (Phase P1.2). The schema half of P1.2 (the
UdpSocketinterface andNetworkManager.createUdpSocketmethod) landed in Phase A and released the shared schema serial surface; Phase B (kernel UDP path,libcapos-posix,dns.cvendoring, demo) does not re-acquire the surface and so does not contend with P1.3 on the schema half. - dash port (P1.3 lays the pipe + fork-for-exec primitives;
Storage Phase 3 slices 1-3 land the kernel-side
File/Directory/Store/NamespaceCapObjects andKernelCapSourcegrant sources that back the dash v0 smoke’s read-only in-rodata pseudo-fs; the actual dash vendoring is a successor task that owns the libcapos-posix file / dir / stdio / env / printf surface and the smoke harness rather than new kernel surface). P1.4 does not touchschema/capos.capnpand so does not contend on the shared schema serial surface.
Critical path
The DNS resolver is the smaller-deps first slice only because of the
shell’s pipe / file dependencies. With P1.3 (pipe + fork-for-exec) and
Storage Phase 3 slices 1-3 (RAM-backed File / Directory / Store /
Namespace CapObjects) both landed, the shell-first prerequisite
gates are closed; the remaining P1.4 work is dash vendoring +
per-call-site patching, the multi-translation-unit C build, and the
smoke harness.
What this slice does not promise
- Not a path to running glibc-built binaries unchanged. Both ports are
sources-on-disk recompiled against
libcapos-posix. Binary compatibility with Linux ELFs is not in scope. - Not job control, not signals, not full POSIX session/pgrp model.
- Not a libc – the POSIX surface ships just enough for dash and dns.c.
printffamily lands inlibcapos-posixonly because both ports need it; this is not a<stdio.h>for general use. - Not a reason to skip the native Rust paths –
capos-shell(Rustshell/crate) remains the default capOS shell. dash is for porting validation, not as the system shell. - Not a foundation for hosted C++. C++ requires explicit ABI decisions
tracked separately in
docs/proposals/userspace-binaries-proposal.md.
Phase Decomposition
Phases are dispatch-ready. P1.1 closed 2026-05-05 13:28 UTC at merge
fe5f5208. P1.2 splits into Phase A (closed 2026-05-05 18:02 UTC,
schema additions + open questions + capos-rt typed client) and Phase B
(open, kernel UDP path + dns.c demo). P1.2 Phase B does not touch
schema/capos.capnp and so does not contend with P1.3 on the shared
schema serial surface; P1.3 still adds a Pipe interface and must
queue on the surface per docs/backlog/index.md Concurrency Notes when
selected.
Phase P1.1 – libcapos C-substrate v0 + C hello-world smoke
Closed 2026-05-05 13:28 UTC at merge fe5f5208 (initial slice
b2e09bce, transfer-record helper 81a88fab). Delivered scope:
- New crate
libcapos/withcrate-type = ["staticlib"](cargo[lib].name = "capos"so the archive lands aslibcapos.a) exposing the capos-rt syscall, ring CALL, CapSet lookup, typedConsole.writeLinewrapper, andmalloc/free/calloc/reallocheap shims throughextern "C". - Public C header at
libcapos/include/capos/capos.h. make c-hellobuilds the C smoke directly with clang + lld using the shareddemos/linker.ld, links againstlibcapos/target/.../libcapos.a, and reuses capos-rt’s_startthrough libcapos’scapos_rt_maintrampoline.- Demo
demos/c-hello/(single.cfile callingconsole_write_line). - Manifest
system-c-hello.cue. - No POSIX surface, no errno, no pthreads.
- Validation:
make run-c-helloboots; the C binary prints[c-hello] hello from c-hello(the markertools/qemu-c-hello-smoke.shgreps) and exits cleanly.
Phase P1.2 – UDP cap surface + dns.c stub resolver smoke
P1.2 splits into two dispatch waves so the kernel-side wave can
serialise behind the active DDF hostile-smoke work on
kernel/src/cap/network.rs and kernel/src/virtio.rs without holding
the schema-only wave.
Phase P1.2 Phase A – schema + open questions + capos-rt client
Closed 2026-05-05 18:02 UTC. Delivered scope:
- Open questions §2 (DNS resolver = dns.c by William Ahern), §4 (errno
via per-thread TLS cell exposed through
__errno_location()), §5 (static-array fd table inlibcapos-posix, 32-fd cap for v0), and §8 (four-method blocking UDP shape with the wait deadline owned by the ring client, not a per-methodtimeoutNsparameter) resolved in this proposal. - Schema additions to
schema/capos.capnp: newUdpSocketinterface (sendTo,recvFrom,close) plus the newNetworkManager.createUdpSocketmethod. Generated bindings refresh verified viamake generated-code-check. - New
UDP_SOCKET_INTERFACE_IDconstant incapos-config/src/lib.rs. - New typed
UdpSocketClientincapos-rt/src/client.rs, mirroring the existingTcpSocketClientshape (create/send_to/recv_from/close). - Schema serial-surface release: this slice held the surface during schema additions and released it at merge.
Phase P1.2 Phase B – kernel UDP path + dns.c + demo
Closed 2026-05-05 21:21 UTC. Delivered scope:
- Kernel: extended
kernel/src/cap/network.rswith the UDP path mirroring the existing TCP path (UdpSocketCap,handle_create_udp_socket/handle_udp_send_to/handle_udp_recv_from/handle_udp_socket_close, deferred-recv parking viaPendingUdpRecv), and added UDP runtime methods on the existing scheduler-polled smoltcp runtime inkernel/src/virtio.rs(create_udp_socket/send_udp/recv_udp/close_udp_socketover a boundedMAX_PUBLIC_UDP_SOCKETSslot table with generation-bumped handles). - New standalone Rust staticlib crate
libcapos-posix/(NOT a workspace member, mirrors the libcapos pattern) producinglibcapos_posix.a. Provides:- per-process static-array fd table (
MAX_FDS = 32), per Open Question §5; - single-thread errno cell exposed via
__errno_location(), per Open Question §4; socket(AF_INET, SOCK_DGRAM, 0)/sendto/recvfrom/close()overUdpSocketandclock_gettime(CLOCK_MONOTONIC, ...)/gettimeofday(&tv, NULL)overTimer(single-shotTimer.now()calls in v0; long retry loops handled by the consumer).- C headers under
libcapos-posix/include/capos/posix/:errno.h,sys/socket.h,time.h,unistd.h. - Reuses libcapos’s installed runtime through a renamed extern crate
libcapos_::runtime::with(...)(the underscore avoids colliding with libcapos’s C-sidecapos_*exports). libcapos was promoted tocrate-type = ["staticlib", "rlib"]to support this.
- per-process static-array fd table (
- Vendored
vendor/dns-c-wahern/(William Ahern dns.c atrel-20160808, commit4ec718a77633c5a02fb77883387d1e7604750251, MIT). Mirror-as-is; onlysrc/dns.candsrc/dns.hretained alongsideLICENSEandREADME.mdper the WASI W.1 vendoring discipline. Seevendor/dns-c-wahern/VENDORED_FROM.md. - New C smoke
demos/posix-dns-resolver/main.cthat links againstlibcapos.a+libcapos_posix.aand drives a hand-rolled DNS A query forexample.comto QEMU slirp DNS at 10.0.2.3:53. The binary uses the vendored dns.c as a reference but does NOT compile dns.c whole into the smoke. Rationale: dns.c expects a POSIX header set (signal.h,fcntl.h,poll.h,netinet/in.h,arpa/inet.h,netdb.h,sys/select.h,sys/un.h) substantially wider than the v0libcapos-posixsurface. Compiling dns.c whole would require either patching the vendored tree or shipping a much larger POSIX header surface than this slice scopes; documented as follow-on work inVENDORED_FROM.md. - New focused-proof manifest
system-posix-dns.cue(own CUE package, imports the sharedcapos.local/cue/defaultspackage per the slice-3 defaults pattern) granting the smokeconsole,network_manager, andtimer. - New Makefile target
run-posix-dns-smokeand harnesstools/qemu-posix-dns-smoke.sh. The smoke prints[posix-dns-resolver] resolved example.com -> <addr>(an arbitrary IPv4 dotted-quad slirp returns from upstream resolution) and exits cleanly. Verified at2026-05-05 21:21 UTC:make run-posix-dns-smokereturns 0 withresolved example.com -> 104.20.23.154in the kernel log;make run-netregression keeps S.11.2.7 / S.11.2.8 hostile-smoke proof lines green.
Depended on Phase P1.1 and Phase P1.2 Phase A.
Phase P1.3 – Pipe capability + fork-for-exec scaffolding
Closed 2026-05-07 09:55 UTC. make run-posix-pipe-smoke is
the load-bearing gate; it drives the dash-shaped pipeline pattern
end to end through the kernel Pipe capability and the
recording-shim fork+execve path.
What landed:
- Schema: new
Pipeinterface (read/write/close/isClosed) andProcessSpawner.createPipe(bufferBytes). The generatedtools/generated/capos_capnp.rsbaseline was refreshed through the canonical capnpc step andmake generated-code-checkpasses. - Kernel:
kernel/src/cap/pipe.rsships the bounded SPSC byte ring with EOF-on-close semantics, kept symmetric with the UDP recv ceiling (4 KiB). Each cap half stores anArc<PipeShared>plus a direction; close on one side flips the shared closed flag and the per-tick poll completes the peer.kernel/src/cap/mod.rsandkernel/src/sched.rsintegrate the new poll alongside the existing network poll. - Kernel:
kernel/src/cap/process_spawner.rsgainshandle_create_pipe, mirroring the UDP-socket result-cap transfer pattern. The existingspawnMove-grant path is reused; no changes to the spawn ABI. - Userspace runtime:
capos-rt/src/client.rsexposes typedPipeClient(read/write/close/isClosed and matching*_wait) plusProcessSpawnerClient::create_pipe / create_pipe_waitand theCreatePipeResultprojection of the two transferred halves. libcapos-posix: newpipe.rsandprocess.rsmodules. The fd table grows aFdBacking::Pipevariant;dup_for_dup2()clones theOwnedCapability<Pipe>so an aliased fd does not release the underlying cap until the last fd drops.pipe,read,write,dup,dup2,fork,execve,waitpid,_exit, andposix_inherit_stdioare exposed via C ABI.dup2andcloseinside a fork-recording window route throughprocess::maybe_record_dup2/maybe_record_closerather than mutating the parent fd table;execveconsumes the recorded actions asstdio_<N>spawn grants –Pipe/TerminalSessionforwardedMove,Console/Directory/FileforwardedRawover their Copy-transferable caps – and returns the synthetic child pid as its own return value so the user pattern becomesint spawn_pid = execve(...); if (spawn_pid < 0) /* surface error to the caller; do NOT _exit because the pseudo-child branch is still the parent process */; child = spawn_pid;(nosetjmp/longjmpinvolved – earlier iterations longjmp’d back to thefork()call site, which dropped back into a returned-and-deallocated stack frame and was undefined behaviour). After a successful spawn, eachMove-granted source fd slot is replaced with aFdBacking::Movedsentinel and the underlyingOwnedCapabilityis forgotten so the parent does not queue a stale CAP_OP_RELEASE for the moved cap_id; a subsequentclose(src)on the parent side (the dash-shaped pattern’s “I no longer hold the write end”) removes the sentinel without a kernel round trip. ARaw/Copy grant (Console/Directory/File) is non-destructive: the parent’s own fd is restored intact, since the kernel handed the child a separate alias. The child side adopts eachstdio_<N>grant back into slotNby interface id (fd::inherit_stdio_grants), wrapping an inherited directory fd throughfdopendir(); proofmake run-posix-execve-inherit-smoke.libcapos-posixsuccessor surface: directposix_spawnandposix_spawn_file_actions_init/destroy/adddup2/addclosereuse the same action-replay helper behind the recording-shimexecvepath. Recording-shimexecvenow delivers argv through the privateposix_argvPipe grant described above. Directposix_spawnstill acceptsargvandenvpfor source compatibility but does not deliver them to the child yet; direct-spawn argv/environment remain empty until a typed LaunchParameters / environment grant exists.libcapos-posixstdio successor: landed at commitaa6a56d7(2026-05-13 11:03 UTC). fd 1 and fd 2 initialize to the granted Console cap when present, but only after anystdio_<N>recording-shim grants have been adopted into their slots. fd 0 is not synthesized from Console;read(0, ...)stays closed unless a real stdin backing is granted.make run-posix-stdio-smokeprints distinct stdout/stderr markers through POSIXwriteand proves the no-stdin refusal path.- Demo:
demos/posix-pipe-shim/main.c(parent) anddemos/posix-pipe-child/main.c(child). The parent pipes, forks, the child-pseudo-context dup2()s the write end onto STDOUT_FILENO, closes both pipe fds, and execve()s the child; the child callsposix_inherit_stdio(), writes “hello via pipe” to fd 1, closes it, and exits 0; the parent drains the read end throughread()until EOF,waitpid()s, and emits[posix-pipe] read 14 bytes: hello via pipe. - New manifest
system-posix-pipe.cue(own CUE package, imports the sharedcapos.local/cue/defaultspackage). New Makefile targetrun-posix-pipe-smokeand harnesstools/qemu-posix-pipe-smoke.sh. Verified2026-05-07 09:55 UTC:make run-posix-pipe-smokereturns 0 with the proof line in the kernel log;make run-smokeandmake run-spawnregressions stay green. - Schema serial-surface coordination: held the surface for the P1.3 schema additions and released on merge.
Open Question §6 closed: Variant A (recording shim) is the
adopted answer. fork() records “next exec is the real spawn” in
TLS and returns 0; the shim translates inter-call dup2/close into
spawn-grant Move actions; and execve() performs the spawn and
returns the synthetic child pid as its own return value (the
caller forwards the pid to the parent flow’s waitpid via
int spawn_pid = execve(...); if (spawn_pid < 0) /* surface error to the parent's normal error path; the pseudo-child branch is still the parent process so do NOT _exit */ ; child = spawn_pid;).
Earlier iterations used setjmp / longjmp to
fake the fork-return-twice semantic; that approach was replaced
because the longjmp jumped back into fork()’s already-returned
(and deallocated) stack frame, which is undefined behaviour. Variant
B (patched-port posix_spawn only) is rejected for v0. Variant
A still requires a small dash-side patch – the four-line
“capture spawn_pid; bail on -1; assign back to child” snippet at
each fork-exec site – because successful execve() now returns the
synthetic pid where unmodified dash assumes execve only returns on
failure. That patch surface is much narrower than Variant B’s
“consolidate every fork+dup2+exec into a single posix_spawn call
with explicit posix_spawn_file_actions” rewrite, which is why
Variant A is the chosen v0 path. A 2026-05-13 successor exports the
direct posix_spawn() surface over the same code path. Recording-shim
execve argv now travels through a private posix_argv Pipe grant; direct
posix_spawn argv/envp remain ignored until LaunchParameters / environment
support lands.
Open Question §9 closed: kernel-allocated bounded SPSC ring
with EOF-on-close, exposed as two cap halves sharing
Arc<PipeShared>. Reader-closed surfaces bytesWritten = 0 to
the writer (the EPIPE-equivalent chosen to avoid expanding the
kernel ExceptionType vocabulary). Writer-closed surfaces eof = true to the reader after the buffered bytes drain. The shared
MemoryObject + userspace ring alternative is rejected
because EOF across process exits and bounded waiter wake
semantics need kernel-side state anyway.
Depended on Phase P1.1.
Phase P1.4 – dash vendoring + libcapos-posix file/dir/stdio/env/printf surface
Status (2026-05-23 07:52 UTC): in flight. Slice 3 (libcapos-posix
FdBacking File / Directory / Terminal variants + smoke) closed at commit
ae58f936; Slice 4 (absolute-path resolver over a bootstrap-granted root
Directory cap plus functional open()/opendir()) landed at commit
94b29177; the posix-file-directory-client-capos-rt closeout at commit
f97d9833 (2026-05-23 06:23 UTC) adds functional lseek(), lazy
readdir() over Directory.list, and the focused make run-posix-file
proof. Slice 7 adds the focused printf/string C library subset and proves it
with make run-posix-printf. Slices 8/9 add signal-registration stubs plus
Timer-backed time() / nanosleep() / sleep() and prove them with
make run-posix-signal-time. The kernel-side capability surface required for
the v0 dash smoke landed under
Storage and Naming Phase 3 slices 1-3:
RAM-backed File (kernel/src/cap/file.rs), Directory
(kernel/src/cap/directory.rs), and Store / Namespace
(kernel/src/cap/store.rs, kernel/src/cap/namespace.rs) CapObjects,
plus the matching KernelCapSource::file / directory / store /
namespace manifest grant sources, are sufficient backing for the
“read-only Namespace cap rooted at a tiny in-rodata pseudo-fs” the
smoke described in §Validation smoke needs. Earlier proposal drafts
called Phase P1.4 “blocked on the Namespace + File cap surface”;
that framing is stale – the open work has moved out of the kernel and
into the libcapos-posix userspace surface, the dash port itself, and
the smoke harness. A userspace Store / Namespace service over a
real backing store (the remaining Phase 3 item in the storage proposal)
is not a prerequisite for the v0 dash smoke; the kernel
bootstrap-grant Directory cap is the v0 backing.
The concrete checklist lives in docs/proposals/posix-adapter-proposal.md Task 4
and the long-form decomposition is in
docs/backlog/posix-adapter-dash-port.md. This proposal records the
phase shape and the substantive outstanding work groups; the backlog
file owns per-step ordering.
Current closed surfaces and outstanding work groups, all in userspace and userspace-adjacent harness surface (no further kernel cap work needed for the v0 smoke):
- dash vendoring + patch. Closed (
posix-p1-4-dash-vendor,2026-05-24 19:40 UTC). dashv0.5.13.4is vendored mirror-as-is (full upstream tree, byte-identical) undervendor/dash/withvendor/dash/VENDORED_FROM.md. The per-call-site Variant A patch (captureexecve()’s synthetic pid return value, bail on-1, assign back tochild) – the shape recorded in Open Question §6 and the Decisions §6 entry – lives undervendor/dash/patches/as two.patchfiles:0001-execve-return-synthetic-pid.patchpropagates the synthetic pid up throughtryexec()/shellexec()(theexecve()call site), and0002-vforkexec-adopt-synthetic-pid.patchadopts it at thevforkexec()fork-exec site. Cumulative diff 45 changed lines (< 50). dash’s inter-calldup2/closebetween fork and execve already records throughlibcapos-posixand needs no per-call patching. Design evidence only: nothing compiles/runs at this slice; the C-build and shell-smoke slices below prove the behavior. - C-build pipeline for vendored multi-file C sources. Landed
(
posix-p1-4-c-multifile-build). The existingc-buildhelper compiles single-filedemos/*/main.csmokes againstlibcapos.a+libcapos_posix.a. dash is a multi-translation-unit C codebase; the Makefile gained the reusablecapos-c-multitu-elfdefine(instantiated with$(eval $(call ...))) that compiles a list of vendored.cfiles each to an object and links them withlibcapos_posix.a+libcapos.ainto a userspace ELF without dragging in an external libc. Toolchain remainsclang --target=x86_64-unknown-none-elf -nostdlib -staticper Open Question §11 and the libcapos C-substrate plan. Proven by the two-TUdemos/c-multifile/demo andmake run-c-multifile, which asserts a cross-TU computed line. - dash build pipeline (autotools config.h + host table generators).
Landed (
posix-p1-4-dash-build-pipeline,2026-05-26 05:11 UTC). The generic multi-TU rule runs noconfigureand no host generators, so the dash-specific prerequisites live undervendor/dash/capos/: a pinnedconfig.h(derivation + host-table caveat invendor/dash/VENDORED_FROM.md) andgen-tables.sh, which stages a patched source copy (keepingvendor/dash/srcbyte-identical) and runs dash’s six host generators (mktokens,mksyntax,mknodes,mksignames,mkbuiltins,mkinit). The Makefiledashtarget funnelsdash_CFILES+ the five generated tables throughcapos-c-multitu-elfagainstlibcapos_posix.a+libcapos.ain the-nostdincsysroot mode, producingtarget/dash/dash.elf(static, 0 undefined symbols, both Variant A fork-exec patches compiled in).make dashproves build + link; the runtime QEMU proof is the dependent shell smoke below. - File / directory I/O surface in
libcapos-posix. TypedFileClientandDirectoryClientwrappers landed incapos-rt/src/client.rsat commit747a8611(2026-05-16 20:07 UTC);FILE_INTERFACE_ID/DIRECTORY_INTERFACE_IDconstants are already incapos-config/src/lib.rs. Slice 3 added theFdBacking::File/FdBacking::Directory/FdBacking::Terminalvariants inlibcapos-posix/src/fd.rsat commitae58f936and the matching smoke. The current surface implementsopen,close,read/write(joining the existing pipe/UDP read/write dispatch),lseek,opendir,readdir, andclosedir;make run-posix-fileproves these through a live POSIX C process. File-backed fds now store the POSIX access mode fromopen():readrejectsO_WRONLY,writerejectsO_RDONLY,ftruncaterequires a write-capable fd, andO_RDONLY | O_TRUNCis denied before the resolver can reachDirectory.open.dup/dup2preserve the stored mode, and the recording-shimexecvepath grants a privateposix_fd_rightsmetadata pipe so inherited File fds reconstruct the same attenuation in the child fd table.make run-posix-open-smokeandmake run-posix-filecarry the same-process denial checks;make run-posix-execve-inherit-smokeproves the recording-shim inheritance path preserves read-only and write-only File fd modes. - Path resolver over a root
Directorycap. A resolver inlibcapos-posix/walks a path through a bootstrap-granted rootDirectorycap and returnsFile/Directoryresult caps via existing IPC cap-transfer machinery. A v0 per-process current-working- directory string (getcwd/chdir,libcapos-posix/src/cwd.rs) plus cwd-relative resolution foropen/opendir/stat/access/unlink/mkdirlanded (make run-posix-cwd);chdirstores only the normalized path string and drops the validated cap, so cwd inheritance across spawn is still deferred...is not collapsed: escape is prevented by the kernelDirectorycap’s lack of a parent edge, not a resolver clamp. TheNamespace/Storeresolver shape remains documented for a future real filesystem service. - Remaining file metadata calls.
stat,fstat,access, andunlinkremain fail-closed stubs until a dash call site requires the stablestruct statand remove-contract shape. - Stdio over
TerminalSession.FdBacking::Terminaladopting the bootstrap-grantedTerminalSessioncap as fd 0 / fd 1 / fd 2 when the manifest supplies one. Implements Open Question §7’s decision (canonical fd 0 backing =TerminalSession). The existing pipe-backed inheritance path stays in place forposix_spawn-driven pipeline children.posix_inherit_stdio()becomes a one-shot adopter for the terminal grant too. - Env vector +
getenv/setenv/putenv. Per-process env vector inlibcapos-posix, populated at startup from manifest rodata (a bounded env grant oninitConfig.init, mirroring thewasiEnv :Textbounded grant the WASI host adapter already uses for Preview 1environ_get). The eventual typedLaunchParameterscap remains a follow-on; the v0 env source is the manifest rodata grant. - printf / string subset. Implemented in
libcapos-posix:printf/fprintf/vprintf/vfprintf/snprintf/vsnprintf;memcpy/memmove/memset/memcmp;strlen/strcmp/strncmp/strchr/strrchr/strcpy/strncpy/strcat/strncat/strdup;atoi/strtol/strtoul; and the ctype subset (isspace/isdigit/isalpha/isalnum/tolower/toupper). Formatted output is bounded to the documented v0 integer / string conversions and width/precision caps; floating-point,fopen, stream buffering, and locale stay out of scope.make run-posix-printfproves the surface from a live capOS C process.libcaposalready exportsmalloc/free/calloc/reallocfor C consumers. - Signal stubs. Implemented in
libcapos-posix:signal/sigactionvalidate and store handlers in a per-process table but never deliver them;killfails closed withEPERMbecause this POSIX surface has no targetProcessHandleauthority;raisefails closed withENOSYSbecause self-delivery is not implemented.make run-posix-signal-timeproves the documented behavior from live capOS C process output. RealSIGCHLD/SIGTSTPdelivery and job control remain out of scope. - Time additions. Implemented in
libcapos-posix:time(2),nanosleep, andsleepreuse the existingTimercap path already used byclock_gettime/gettimeofday.make run-posix-signal-timeproves monotonic-since-boottime()output, boundednanosleep(), and one-secondsleep()from live capOS C process output. - Identity stubs. Implemented:
getpidreturns the stable capos-rt bootstrap pid for the current process, while the recording-shim child pid allocator stays above the caller’s pid for thewaitpidtable;getuid/getgidreturn the hardcoded single-identity uid/gid0.make run-posix-identityproves a parent and fork/exec child observe distinct process-visible pids from live capOS C code. isatty/getppid(closed2026-05-24 08:47 UTC). Both are pure-userspace dash prerequisites over the existing fd table – no kernel, cap, IPC, or schema change.isatty(fd)returns1for anFdBacking::Terminalslot,0witherrno = ENOTTYfor any other live backing, and0witherrno = EBADFfor an empty/closed slot.getppid()returns the v0 single-identity parent constant (1); no kernel parent handoff exists yet, so it is an honest stub alongside thegetpidsingle-identity path.make run-posix-isattyprovesisatty(0/1/2)=1over bootstrap-granted TerminalSession stdio,isatty(pipe_fd)=0 errno=ENOTTY, andgetppid=1from live capOS C process output.fcntl(closed2026-05-24 09:23 UTC). A pure-userspace dash prerequisite over the existing fd table – no kernel, cap, IPC, or schema change.F_DUPFD/F_DUPFD_CLOEXECduplicate into the lowest free slot>= argover the samedup_for_dup2alias pathdup/dup2use;F_GETFD/F_SETFDround-trip a per-fdFD_CLOEXECbyte;F_GETFLreports a stable access mode (O_RDWRfor Console/Udp/Pipe/Terminal, the storedopen()mode for File,O_RDONLYfor the read-only Directory);F_SETFLfails closed withEINVALwhen the argument carriesO_NONBLOCK(the v0 ring calls block withWAIT_FOREVER, so there is no non-blocking mode to switch into), except on UDP socket fds, where it is accepted-and-ignored for the vendored dns.c snapshot whose documented contract already drives deadlines from userspace; other status bits (e.g.O_APPEND) stay accept-and-ignore. UnknowncmdyieldsEINVALand a closed/out-of-range fd yieldsEBADF. CLOEXEC is enforced at recording-shimexecvetime: the full-fd-table inheritance walk skips a slot whose flags byte carriesFD_CLOEXECunless an explicit recordeddup2named that child slot.make run-posix-fcntlproves theF_DUPFD,10relocation, theFD_CLOEXECround-trip,F_GETFL=O_RDWRfor a pipe, and theEBADF/EINVALerror paths from live capOS C process output.- Manifest + smoke harness (landed
2026-05-27 09:36 UTC).system-posix-shell.cuegrants dash aTerminalSession(stdio), a bootstrap RAMDirectory(root), aProcessSpawner, and aTimer. Newdemos/ls-shim/one-binary listing helper wraps the inherited directory fd withfdopendir()(the smoke’s only allowed spawn target).make run-posix-shell-smoke+tools/qemu-posix-shell-smoke.shfeed a heredoc into the shell’s fd 0 – the shell creates two RAM-root entries, opens the directory as fd 3 (exec 3< /), runs/ls-shim, and printsdone– and assert thealpha/betaentry lines,done, two clean-exit log lines, the scheduler halt line, and clean QEMU exit. Thels-by-bare-name vs/ls-shimPATH-stat workaround uses the slash-bearing path, which the recording-shim spawn maps to the manifest binary name by basename. Stretch: extend the smoke tocat foo | grep barend-to-end, exercising the P1.3Pipeprimitive through a shell pipeline. Stretch closed (2026-05-27,posix-dash-pipeline-exec-reconcile): dash patch0004-pipeline-evexit-recording-shim.patchreconciles theEV_EXITin-placeshellexecpath with the recording shim (everyevalpipeelement takes that path, which the original patch set had left unreconciled), andlibcapos-posixgained wildcardwaitpid(-1)/wait3reaping.make run-posix-shell-smokenow drives the pipeline (match bar herefiltered through, four clean child exits). Seedocs/backlog/posix-adapter-dash-port.mdSlice 14 andvendor/dash/VENDORED_FROM.md. readbuiltin over fd 0 (landed2026-05-31 20:35 UTC,posix-dash-read-builtin-terminal-line). Proves dash’sread VARbuiltin consuming interactive input off its fd 0TerminalSessioncooked-mode line discipline – the one stdin path every prior smoke skipped (run-posix-shell-smokefeeds no stdin). No dash patch or libcapos-posix change was needed: dash’stcgetattr(0)-derived canonical buffering takes the plainread(0, ...)branch, which theFdBacking::Terminaladapter satisfies one line at a time.make run-posix-read-builtin(system-posix-read-builtin.cue+tools/qemu-posix-read-builtin-smoke.sh) echoes back the harness-fed linesgot=[hello world]/raw=[raw\back\slash](the second underread -r, proving the no-escape path). The harness handshakes each feed on dash’s own terminal output because the kernel line discipline has no inter-read input buffer and the UART carries no EOF. Seedocs/backlog/posix-adapter-dash-port.mdSlice 18.- Open question closures (Slice 1, closed
2026-05-24 00:53 UTC). Open Question §1 (dash 0.5.13.x candidate) and §7 (fd 0 backing =TerminalSession) are promoted to final decisions in this proposal’s “## Open Questions” section ahead of vendoring.
Recommended dispatch ordering: P1.1 -> P1.2 Phase A (schema + client,
landed) -> P1.2 Phase B (kernel UDP path + dns.c, landed) and P1.3
(Pipe cap + fork-for-exec, landed) in either order, since they no
longer contend on the schema serial surface -> P1.4 dash-port
successors. P1.4 itself does not touch schema/capos.capnp and so does
not contend on the shared schema serial surface.
Trust Boundaries
| Boundary | Native capOS service | POSIX-shaped C binary on capOS |
|---|---|---|
| Authority source | Process CapSet | Process CapSet projected through libcapos-posix fd table |
| Memory isolation | Page tables | Page tables (no wasm-style sandbox; libc has no extra runtime check) |
| Code integrity | W^X + NX | W^X + NX |
| Cap forgery | Kernel-owned CapTable | Same; the fd table is per-process userspace state, not authority |
| Resource limits | Kernel quotas | Kernel quotas; ulimit is ENOSYS |
| Side channels | Hardware-level (Spectre etc.) | Same hardware level |
A POSIX binary on capOS is more constrained than on Linux, not less. The adapter provides familiar function signatures, not familiar authority.
Validation
The first ports are not complete until they have QEMU evidence:
- A POSIX binary prints through a granted Console / TerminalSession.
- The same binary cannot use
writeto a fd it was not granted, cannotopen()a path outside its preopened namespaces, and cannot call an unimplemented POSIX function without receivingENOSYS. - A missing or wrong-interface cap lookup returns the documented errno (not a host-side panic, not silent success).
- An owned result cap is released deterministically when the binary exits.
- Each demo binary exits cleanly and does not wedge the kernel.
Host tests should cover errno mapping and the per-process fd table once those pieces are pure enough to test outside QEMU. Do not claim “POSIX adapter works” from host tests alone; the useful behavior is authority- shaped POSIX execution in capOS.
Open Questions
The following design decisions are documented as open questions because the planning phase recommends an answer but has not yet committed to one.
- POSIX shell candidate. Decided (P1.4 Slice 1,
2026-05-24 00:53 UTC): dash 0.5.13.x, vendored at a pinned tag undervendor/dash/. Rationale: smallest established POSIX-strict shell (~13 kSLOC, readable in full by the porting team), no readline/termcap dependency (it talks to whatever fd 0 gives it), and a single-purpose/bin/shposture that does not accidentally validate Bash extensionslibcapos-posixdoes not implement. Rejected: busyboxash(heavier embedded framework cost), oksh (ksh-superset, larger surface than v0 needs), toysh (incomplete upstream), and a custom Rust shell (it defeats the purpose of porting a real C program; the nativeshell/capos-shellalready exists). Vendoring, the Variant A patch, the multi-TU C build, and the shell smoke are later P1.4 slices (11-14). - DNS resolver candidate. Decided (P1.2 Phase A,
2026-05-05 18:02 UTC): dns.c (William Ahern), vendored at a pinned tag undervendor/dns-c-wahern/. Rationale: single-file MIT C (~10 kSLOC.cplus header), no Cargo/CMake build system, no configure script, no required I/O model (caller plugs the socket layer), and a track record as a reusable resolver core in production software outside libc. The license is capOS-compatible and does not force a transitive libc port. Rejected: musl libresolv – tied to the rest of musl’s headers, build, and__syscallshape; pulling it in either drags musl as a transitive dependency or forces a per-symbol carve-out that defeats the “single .c plus header” cost profile. Rejected: c-ares (configure-driven, ~3x larger, more invasive port). Rejected: GNU adns (GPL-2.0+ license question). Rejected: pure-Rust trust-dns (defeats the C-port purpose). - libcapos versioning and naming. The C library is just
libcapos(mirrors the Rustcapos-rt). Open question: should the POSIX layer belibcapos-posix(current recommendation), or a different name that avoids any Rust-side framework name collision? The C-side naming is settled; the POSIX-layer name remains an open question pending confirmation that no Rust framework will reuse thelibcapos-posixidentifier. Working answer: keeplibcapos-posixfor the POSIX layer. - POSIX errno representation. Decided (P1.2 Phase A,
2026-05-05 18:02 UTC): per-threaderrnocell exposed via__errno_location()– the standard POSIX shape. Storage lives inlibcapos-posix, owned by a thread-local cell accessed through a stableextern "C" int *__errno_location(void);function so vendored ports (dns.c, dash, future C software) compile againsterrnoexactly as on Linux/musl. Rust internals keep the typedCapError/CapExceptionshape; one bidirectional mapping at the C boundary writes theintvalue into the TLS cell so internal callers cannot invent unmapped values. Rejected: per-fd error field – breaks source compatibility with every POSIX program that readserrnoafterread/recvfrom/open, requires every vendored port to be patched, and provides no isolation gain over the per-thread cell that the cap layer already exclusively writes. - File descriptor table location. Decided (P1.2 Phase A,
2026-05-05 18:02 UTC): static-array fd table inlibcapos-posixwith a small fixed cap (target: 32 open fds per process for v0). Rationale: the lookup is one bounds-check + one array index in userspace with no syscall; the kernel keeps zero knowledge of fds, so capOS authority remains exactly the per-processCapTableand is not duplicated in a parallel kernel-side fd map. The fixed cap matches the surfaces dns.c (single fd) and a v0 shell port (a handful of stdio + pipe fds) actually exercise. Rejected: capability-table-backed fd map that resolves fd numbers through the process cap table – larger blast radius (fd churn would touch the kernel cap table on everydup/close), and the cap-table object id is already a userspace-visible handle throughOwnedCapability, so a separate dense fd index in userspace is the right layer. The 32-fd cap can grow later (or migrate to a sparse representation) if a real consumer needs more, without changing the kernel surface. - Fork policy. Decided (P1.3,
2026-05-07 09:55 UTC; refined2026-05-07 10:30 UTCto dropsetjmp/longjmp): Variant A – the recording shim.fork()records “next exec is the real spawn” in TLS and returns 0 unconditionally.dup2()andclose()calls betweenfork()andexecve()route throughprocess::maybe_record_dup2 / maybe_record_closeand are not applied to the parent fd table.execve()consumes the recorded actions, dispatchesProcessSpawner.spawn()with the matching pipe halves moved into the child asstdio_<dst>grants, parks the resultingOwnedCapability<ProcessHandle>in a per-process table, and returns the synthetic child pid as its own return value (a deliberate v0 deviation from POSIX, whereexecveonly returns -1 on failure). The user pattern becomesint spawn_pid = execve(...); if (spawn_pid < 0) /* surface error to the parent's error path; do NOT _exit because the pseudo-child branch is still the parent */ ; child = spawn_pid;. After a successful Move-grant spawn the parent’s source fd slot is replaced with aFdBacking::Movedsentinel so a subsequentclose(src)(the dash-shaped pattern’s “I no longer hold the write end”) removes the sentinel without a kernel round trip. The earliersetjmp/longjmpdesign longjmp’d back tofork()’s call site afterexecve()had returned – the saved jmp_buf RSP/RIP pointed intofork()’s stack frame, which was deallocated whenfork()first returned, so the longjmp resumed inside a stale frame whose memory had already been reused bydup2/close/execve. A targeted dash patch is still required for the v0 contract:execve()returns the synthetic pid on success, where unmodified dash assumesexecve()only returns on failure (and falls into its post-exec error path). Variant A keeps that patch surface narrow – the change is the four-line “capture spawn_pid; bail on -1; assign back to child” snippet shown above per fork-exec call site, not a wholesale rewrite of the fork-dup2-exec pattern – and dash’s inter-call dup2/close still record into the spawn grants without per-call patching. Rejected: Variant B (patched-portposix_spawnonly) requires the port to consolidate every fork+dup2+exec sequence into a singleposix_spawncall with explicitposix_spawn_file_actions, a much wider patch surface. A 2026-05-13 successor now exports directposix_spawn()over the same execve-backed action replay. Recording-shimexecveargv now travels through a privateposix_argvPipe grant; directposix_spawnargv/envp remain ignored until LaunchParameters / environment support lands. - fd 0 backing for the shell. Decided (P1.4 Slice 1,
2026-05-24 00:53 UTC): the canonical fd 0 / 1 / 2 backing for the v0 dash smoke isTerminalSession– the natural mapping (read line + cooked-mode line discipline already exists in kernel and migrates to userspace at networking Phase C). For the DNS resolver fd 0 is unused and stays unmapped. The backing is realized by theFdBacking::Terminalvariant inlibcapos-posix/src/fd.rsplusposix_inherit_stdio()adopting the bootstrap-grantedTerminalSessioncap, mirroring the existing pipe-inheritance path; that implementation already shipped under P1.4 Slice 5 and is proven bymake run-posix-stdio-terminal-smoke. This slice only records the backing choice as final. - UDP cap surface scope. Decided (P1.2 Phase A,
2026-05-05 18:02 UTC): four-method blocking shape that mirrors the existing TCP cap pattern, with the wait deadline owned by the ring client (not the method parameter list). Methods:NetworkManager.createUdpSocket(localAddr :Data, localPort :UInt16) -> (socketIndex :UInt16)– bind a UDP socket to the given local(addr, port)(localAddrempty selects the configured interface;localPort = 0selects an ephemeral port). The result cap is transferred viasocketIndexin the CQE result-cap list, matchingconnectTcp.UdpSocket.sendTo(addr :Data, port :UInt16, data :Data) -> (bytesSent :UInt32).UdpSocket.recvFrom(maxLen :UInt32) -> (addr :Data, port :UInt16, data :Data)– blocking, no in-method timeout. Same CQE-on-completion shape asTcpSocket.recv: the kernel parks the SQE until a datagram arrives. The caller bounds the wait through the existingRingClient::wait(call_id, timeout_ns)mechanism; dns.c-style retry/deadline loops drive that bound from userspace. If the caller wants to abort a parkedrecvFromearly, it issuesclose()on the socket; the parked completion then returns aDisconnected-classCapException. The v0 surface deliberately does not introduce a newTimeoutexception class, since none exists today (ExceptionTypecovers onlyfailed,overloaded,disconnected,unimplemented) and inventing one for a single method would expand the kernel error surface ahead of any consumer that needs to distinguish wait-expiry from generic disconnect.UdpSocket.close() -> (). Rationale: the blocking shape maps directly onto dns.c’s existing retry/timeout loop (dns.c does its own resend and deadline tracking, then issues a bounded blocking read backed by the ring wait), so the v0 port plugs in without a separate readiness/poll surface. The shape also reuses every primitive already present for TCP – ring-sidecap_enterparking, transferred result caps, client-sideRingClient::waitdeadline – so the kernel UDP path in P1.2 Phase B is a near-mirror of the TCP path. Rejected (deferred): readiness/poll-stylerecvFrom– the cap surface decision (one-shot wait vs an event stream over anEndpoint) is itself unsettled, has no live consumer, and adding a second wait shape now would force every port to choose. Add a separate readiness method (or a genericPollablecap) when a real consumer needs it, not before. Rejected: per-methodtimeoutNsparameter – creates two competing deadlines (the in-method timeout and the ring wait) that race on the same call, would require either inventing a newTimeoutexception class or overloadingDisconnectedambiguously, and is redundant with the ring wait the client already issues.
- Pipe cap design. Decided (P1.3,
2026-05-07 09:55 UTC): kernel-allocated bounded SPSC ring (4 KiB ceiling, default to the maximum) with EOF on close. The two halves share anArc<PipeShared>and store their direction; close on one side flips the matching closed flag and the per-tick poll completes the peer. Both halves implement the samePipeinterface (read / write / close / isClosed); the kernel rejects wrong-direction calls with afailedexception. Reader-closed surfacesbytesWritten = 0to the writer (the EPIPE-equivalent chosen to avoid expanding the kernelExceptionTypevocabulary). Writer-closed surfaceseof = trueto the reader after the buffered bytes drain. **Rejected: shared MemoryObject- userspace ring** because EOF across process exits and bounded waiter wake semantics need kernel-side state anyway, and the userspace path would still need a kernel cap to coordinate close races.
- argv / envp source. This proposal assumes a future
LaunchParameterscap delivers argv / envp through a typed cap. Until that cap lands,libcapos-posixcan carry argv / envp via a fixed well-known cap or rodata blob. Confirm gate-on-LaunchParametersversus ship-stub. - Linker / toolchain for C consumers. Recommended:
clang --target=x86_64-unknown-none-elf -nostdlib -static, link againstlibcapos.a(and optionallylibcapos-posix.a), reuse the existingcapos-rtlinker script. Confirm clang vs gcc and whether the track ships a sharedcc-glueCargo crate or a Make rule invokingccdirectly. - Vendoring policy. In-tree
vendor/dash/,vendor/dns-c-wahern/versus out-of-tree submodule versus separate repo. Working answer: in-tree vendoring with pinned tags, mirroring the plannedvendor/piccolo-no_std/shape from the Lua track. - Audit / measure-mode interaction. The
libcapos-posixwrappers must not break measure mode (themeasurefeature). Most wrappers only calllibcapos, which only callscapos-rt, which is already measure-mode-clean, so this should be free; confirm whether the track adds amake run-measuresmoke for onelibcapos-posixbinary as a regression gate.
Relationship to Other Proposals
- Userspace Binaries owns the broader native-binary, language, and POSIX-adapter roadmap. This proposal supersedes Part 4: POSIX Compatibility Adapter of that proposal with the full POSIX adapter design.
- Programming Languages is the
reader-facing summary of language support. The C row records the
shipped
libcapos.a+libcapos_posix.asurface (P1.1 + P1.2 + P1.3, plus the 2026-05-13posix_spawnsuccessor and Console-backed stdio slice). The POSIX-shaped software row cross-links this proposal as the long-form design source and records the P1.4 dash-port block onNamespace+Filecaps. - Networking defines
NetworkManager,TcpListener, andTcpSocketand defers UDP. The DNS resolver port in Phase P1.2 adds theUdpSocketcap surface; the TCP cap surface is reused unchanged. - Storage and Naming defines the
Directory/File/Store/Namespacesurfaces that the shell port consumes. Phase 2/3 of that proposal gates the dash file I/O surface. - Service Architecture defines
the future
Resolvercap that the resolver port eventually exports. - Shell covers the native
capos-shell. The POSIX shell port is for porting validation and does not replacecapos-shell. - WASI Host Adapter is the parallel untrusted-portable execution path. POSIX adapter targets trusted source-recompiled C; WASI adapter targets sandboxed wasm modules. Both share the per-process fd-table and per-import authority pattern.
- Lua Scripting is the
capability-scoped trusted-script path; PUC Lua’s native build assumes
a C substrate, so it eventually consumes
libcapos.