Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Proposal: 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) and libcapos-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-posix with no hidden ambient authority.
  • A first DNS resolver port that builds against libcapos-posix with 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). Only fork() followed promptly by execve() is supported, via a posix_spawn-shaped shim.
  • Real signal delivery. signal()/sigaction() accept the call, store the handler, never invoke it. kill(2) requires a future ProcessHandle cap.
  • 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.a surface, 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 root Directory cap 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-rt surface that libcapos mirrors for C consumers.
  • Networking defines NetworkManager, TcpListener, and TcpSocket and explicitly defers UdpSocket until 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, and Store cap shape; these gate the shell port’s filesystem surface (Phase 2/3 of that proposal).
  • Service Architecture frames the future Resolver cap 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); toybox toysh. Source repositories cited inline in the candidate comparison table.
  • DNS resolver candidates surveyed: dns.c by William Ahern (single-file MIT, ~10 kSLOC, no dependencies); c-ares; GNU adns; udns; SPCDNS; musl’s embedded res_query; trust-dns-resolver. Source repositories cited inline in the candidate comparison table.
  • libcapos prior art: this proposal builds on the libcapos shape sketched in Userspace Binaries “Future: C via libcapos” / “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 / CapException and 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/sh since 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

  1. POSIX is not a kernel feature. The kernel sees ordinary userspace processes with a CapSet and a capability ring. libcapos and libcapos-posix are static libraries linked into those processes.
  2. Two layers, one C ABI per layer. libcapos is the C-ABI mirror of capos-rt: capability ring, CapSet, raw syscalls, heap. It has no errno, no fd table, no open/read/write. libcapos-posix builds the POSIX shape on top. Programs that do not need POSIX semantics may link only libcapos.
  3. 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.
  4. 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.
  5. Fail closed. Any unimplemented POSIX call returns ENOSYS and sets errno. Any cap lookup that fails returns the documented errno. Programs cannot probe absent caps for ambient behaviour.
  6. No fork without exec. Only fork() followed by execve() is supported. The shim turns the pair into posix_spawn(). Bare fork() used to clone state in-process fails on the next non-trivial syscall.
  7. No real signals. Handlers are accepted and stored, never delivered. kill(2) requires a future ProcessHandle cap and even then is limited to SIGKILL. Programs that depend on SIGCHLD job control are out of scope.
  8. The C substrate is Rust. libcapos and libcapos-posix are Rust crates with crate-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: stdin cap from CapSet (TerminalSession, Console, or future StdinReader-shaped cap, whichever is granted).
  • fd 1: stdout Console cap.
  • fd 2: stderr Console 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):

OptionWhat it providesCostWorking answer
Emulate fork() as posix_spawn with inherited cap-set, recording inter-call dup2/close as posix_spawn file actionsExisting fork+exec and fork+dup2+exec pipeline patterns work with one patch siteDaemonisation and arbitrary COW state inheritance between fork and exec still breakRecommended 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()HonestEvery POSIX program that uses fork must be patchedRecommended safety net when fork-for-exec is misused
Process-shadow: a “POSIX process” wraps a capOS processGeneralLarge kernel + runtime change; doubles process accountingRecommended 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-posix exposes fork() and execve() as a coupled shim that:
    1. fork() records “next exec is the real spawn” in TLS and returns 0 unconditionally. Only the if (pid == 0) branch ever executes; the legacy else branch is unreachable because pid is 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 via child = execve(...); near the end of the if-body. Pictorially:
      pid_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, ...);
      
      There is no else branch in the v0 contract, only the post-if parent flow.
    2. dup2() / close() calls between fork() and execve() are recorded as posix_spawn file actions on the pending spawn rather than mutating the parent’s fd table.
    3. execve(path, argv, envp) consumes the recorded intent, calls ProcessSpawner.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:
      int 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) */
      
      On failure execve() returns -1 with errno set; 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.
    4. Any fork() not followed by execve() before a syscall outside the recorded-action allowlist (e.g. setsid) returns -1 / ENOSYS on that downstream call.
  • Variant B – patched-port shim. libcapos-posix exposes only posix_spawn() with explicit file actions, plus stub fork() / execve() that return -1 / ENOSYS. Each port (dash and successors) is patched to translate its fork+dup2+exec sequence into a single posix_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 a ProcessHandle cap for the target – and even then the only signal honoured would be SIGKILL, which maps to a future ProcessHandle.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 / sigismember are real bit operations on the caller’s sigset_t (a uint64_t). sigprocmask keeps a per-process blocked mask so ports can save and restore it during job control, honours SIG_BLOCK / SIG_UNBLOCK / SIG_SETMASK, and force-clears SIGKILL / SIGSTOP per POSIX – but the mask is stored, never enforced, because there is no delivery to block. sigpending always reports an empty set for the same reason.
  • pause() / sigsuspend() / sigwait() block forever (or with timeout) via sys_cap_enter(0, timeout); they never wake from a signal.
  • SIGPIPE is 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 / CapExceptionPOSIX errno
CapError::NotFoundENOENT
CapError::PermissionDeniedEACCES
CapError::DisconnectedECONNRESET
CapError::TimeoutETIMEDOUT
CapError::ResourceExhaustedENOMEM / EMFILE (context dependent)
CapError::InvalidArgumentEINVAL
CapError::WouldBlockEAGAIN
(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 via cap_enter).
  • pthread_self -> TLS slot or ThreadControl.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

ShellLicenseSizeDepsPOSIX coverageVerdict
dash (upstream)BSD~13 kSLOC, ~134 KBtiny libc subset; no readline; no termcapStrict POSIX, no extensionsRecommended primary
busybox ash (upstream)GPL-2.0~8 kSLOC of shell/ash.c + busybox infraDesigned for embedded, modularPOSIX + selectable extensionsHeavier framework cost; useful later when capOS wants a coreutils set
toybox toysh (upstream)0BSDcurrently incompleteDesigned for self-contained ELFPOSIX + Bash compat target, not finishedSkip – explicitly described upstream as still under development
oksh (upstream)Public domain~308 KB binary, 0 depsOptional ncurses for clear-screen onlyKorn-shell superset of POSIXBigger surface than v0 needs to validate libcapos-posix
Custom Rust shelln/an/an/an/aReject – defeats the purpose of porting C. Native shell already exists at shell/ (capos-shell).

Recommended primary: dash.

Reasons:

  1. Smallest established POSIX-strict shell. ~13 kSLOC is small enough for the porting team to read the entire codebase.
  2. No readline / termcap dependency. The shell talks to whatever fd 0 gives it. This is exactly what libcapos-posix provides through TerminalSession or Console.
  3. Strict POSIX means the port does not accidentally validate Bash extensions that libcapos-posix does not implement.
  4. Already proven as a porting target on Linux from Scratch, OpenWrt, and Alpine. Patterns for replacing the libc layer (__syscall, stubbed sigaction) are well documented.
  5. Debian uses it as /bin/sh since 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:

GroupCalls (minimum set)Backed by
Process startup_start shim, argv/envp parsing, exitlibcapos _start, sys_exit
Stdioread(0,...), write(1,...), write(2,...)Console / TerminalSession cap
Allocationmalloc/free/calloc/realloclibcapos heap
String/formatprintf/fprintf/memcpy/strlen/strcmp/strchr/strncpy/…libcapos-posix string/printf subset
File I/Oopen/close/read/write/lseek/stat/fstat/access/unlinkNamespace + File caps
Directoryopendir/readdir/closedirDirectory cap
Pipespipe(), dup2(), close() on fdsNEW Pipe capability (P1.3)
Processfork+execve (fork-for-exec only), posix_spawn, wait/waitpidProcessSpawner + ProcessHandle.wait
Envgetenv/setenv/putenvPer-process env vector in libcapos-posix; populated from a future LaunchParameters cap when one lands
Signalssignal/kill/sigaction (stubs)TLS-stored handlers, never delivered
Timetime/gettimeofday/nanosleepTimer cap
Control flowsetjmp/longjmp over jmp_buflibcapos x86_64 SysV global_asm (<setjmp.h>); no sigsetjmp
Miscgetpid/getuid/getgidgetpid 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 real SIGCHLD/SIGTSTP. Skip; documented as out of scope.
  • Process groups, sessions, controlling terminals: same reason.
  • trap for signals other than EXIT: 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:

  1. Boot a manifest that grants dash a TerminalSession (stdio), a read-only bootstrap-granted Directory cap rooted at a tiny in-rodata pseudo-fs (the resolver remains Namespace-shaped for forward parity with the future userspace Namespace service; the v0 manifest grants a Directory because that is what Storage Phase 3 slice 2 ships as a kernel CapObject today), a ProcessSpawner narrowed to one allowed binary (ls-shim), and a Timer cap.
  2. Pipe a heredoc into stdin: ls; echo done.
  3. Assert kernel log shows done and 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 kernel UdpSocket cap; that smoke is retired with the qemu-only kernel UDP owner. Name resolution now goes through a typed system DnsResolver capability (network-system-dnsresolver-cap-local-proof), and libcapos-posix exposes the standard POSIX surface over it: getaddrinfo / freeaddrinfo / gai_strerror (src/netdb.rs, include/capos/posix/netdb.h) resolve one IPv4 A result through a granted dns_resolver endpoint and map the typed resolver status onto addrinfo / EAI_*, with no ambient UDP fallback (a process without the cap gets a deterministic EAI_FAIL). A read-only /etc/resolv.conf projection is materialized at open() time from the resolver status (writes fail closed with EACCES; absent without the cap). Proof: make run-posix-getaddrinfo. The candidate survey below is retained as the original design rationale; vendored dns.c is no longer on the critical path for the resolver bridge. AAAA / sockaddr_in6, AI_* flags, and /etc/services remain follow-ups (each fails closed: EAI_FAMILY / EAI_BADFLAGS / EAI_SERVICE).

Candidate survey

LibraryLicenseSource sizeDepsAsync styleVerdict
musl res_query (upstream)MIT~2 kSLOC for resolver coreEmbedded in muslSynchronous (parallel queries internally)Available only if the build links musl; capOS does not. Skip.
c-ares (upstream)MIT, C89~30+ kSLOC, multi-file, configure-drivenPOSIX sockets, optional threadsNative async (callbacks + select/poll/event loop)Largest surface, most mature, most invasive port
dns.c (wahern) (upstream)MITsingle-file C, ~10 kSLOC, no depsNone – caller provides socket I/O via three pluggable patterns (pollfd / events / timeout)Non-blocking, no required callback shapeRecommended primary
GNU adns (upstream)GPL-2.0+Multi-file, ~10-15 kSLOCPOSIX, no event-loop integrationAsync, opaque stateLicense is GPL-2.0+, not BSD/MIT. Skip unless capOS accepts a GPL component in the demo path.
udns (upstream)LGPL-2.1smallPOSIXAsync stub-onlyLGPL plus older project; skip unless dns.c blows up
SPCDNSLGPLsmallencode/decode only, no socketn/aSkip – provides no resolver loop
trust-dns-resolver in RustApache-2 / MITlargeTokioasyncReject – defeats the purpose of porting C. Native Rust resolver is a separate path.

Recommended primary: dns.c by William Ahern.

Reasons:

  1. Single-file, zero deps. Drops into the build with a minimal cc rule. The build avoids configure scripts, pkg-config, optional feature matrices, and multi-file build orchestration.
  2. 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() with libcapos-posix wrappers that return fd-shaped results backed by UdpSocket / TcpSocket caps.
  3. MIT license is capOS-compatible.
  4. ~10 kSLOC means port review can read it end-to-end.
  5. 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:

GroupCallsBacked by
Stdio (logs only)write(2,...)Console cap
Allocationmalloc/free/calloc/realloclibcapos heap
Timeclock_gettime/gettimeofdayTimer cap
Sockets (UDP)socket(AF_INET, SOCK_DGRAM, 0), sendto, recvfrom, bind, close, setsockopt (subset)NetworkManager + UdpSocket cap
Pollingpoll(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 configOne 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:

  • UdpSocket capability. 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 Resolver cap concept (in service-architecture-proposal.md “DNS resolver – consumes a UdpSocket, exports Resolver”) 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.conf trust 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:

  1. Boot a manifest that grants the resolver process a NetworkManager (or future narrowed UdpSocket-only authority), a Console cap, and a Timer cap. The single-nameserver resolv config is the in-rodata bounded text blob compiled into libcapos-posix; no Namespace or File cap is needed for v0.
  2. 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.
  3. Resolver prints the resolved IPv4 address.
  4. Assert kernel log line matches.

Trade-offs and Ordering

Smallest-deps comparison

PortC surface neededNew capOS infrastructure requiredDifficulty
DNS resolver (dns.c)malloc, time, socket subset, write(2), open RO file, poll-equivalentUDP socket cap + NetworkManager exposure of UDP; otherwise reuses Phase B TCP path infraSmaller – 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 handoffLarger – touches storage / IPC / process surfaces

Which blocks which

  • Both ports can run in parallel at the libcapos / libcapos-posix layer 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(), or exec().
  • 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 (libcapos staticlib + libcapos-posix scaffold) blocks both. Once the substrate exists, the two ports proceed in parallel.
  1. libcapos staticlib v0 (Phase P1.1). The thin Rust .a with cap_call, capset_get, sys_exit, sys_cap_enter, heap. Plus a “C hello world” smoke that calls console_write_line() (mirrors the userspace-binaries proposal “Future Phase: libcapos for C”). This phase is the prerequisite for both P1.2 and P1.3.
  2. libcapos-posix scaffold – fd table, errno cell, stdio wrappers for fd 0/1/2, stub signals, _start glue that registers argv / envp from LaunchParameters (or empty arrays if that surface has not landed), basic malloc/free re-export.
  3. dns.c port (Phase P1.2). The schema half of P1.2 (the UdpSocket interface and NetworkManager.createUdpSocket method) landed in Phase A and released the shared schema serial surface; Phase B (kernel UDP path, libcapos-posix, dns.c vendoring, demo) does not re-acquire the surface and so does not contend with P1.3 on the schema half.
  4. dash port (P1.3 lays the pipe + fork-for-exec primitives; Storage Phase 3 slices 1-3 land the kernel-side File / Directory / Store / Namespace CapObjects and KernelCapSource grant 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 touch schema/capos.capnp and 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. printf family lands in libcapos-posix only 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 (Rust shell/ 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/ with crate-type = ["staticlib"] (cargo [lib].name = "capos" so the archive lands as libcapos.a) exposing the capos-rt syscall, ring CALL, CapSet lookup, typed Console.writeLine wrapper, and malloc/free/calloc/realloc heap shims through extern "C".
  • Public C header at libcapos/include/capos/capos.h.
  • make c-hello builds the C smoke directly with clang + lld using the shared demos/linker.ld, links against libcapos/target/.../libcapos.a, and reuses capos-rt’s _start through libcapos’s capos_rt_main trampoline.
  • Demo demos/c-hello/ (single .c file calling console_write_line).
  • Manifest system-c-hello.cue.
  • No POSIX surface, no errno, no pthreads.
  • Validation: make run-c-hello boots; the C binary prints [c-hello] hello from c-hello (the marker tools/qemu-c-hello-smoke.sh greps) 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 in libcapos-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-method timeoutNs parameter) resolved in this proposal.
  • Schema additions to schema/capos.capnp: new UdpSocket interface (sendTo, recvFrom, close) plus the new NetworkManager.createUdpSocket method. Generated bindings refresh verified via make generated-code-check.
  • New UDP_SOCKET_INTERFACE_ID constant in capos-config/src/lib.rs.
  • New typed UdpSocketClient in capos-rt/src/client.rs, mirroring the existing TcpSocketClient shape (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.rs with 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 via PendingUdpRecv), and added UDP runtime methods on the existing scheduler-polled smoltcp runtime in kernel/src/virtio.rs (create_udp_socket/send_udp/recv_udp/close_udp_socket over a bounded MAX_PUBLIC_UDP_SOCKETS slot table with generation-bumped handles).
  • New standalone Rust staticlib crate libcapos-posix/ (NOT a workspace member, mirrors the libcapos pattern) producing libcapos_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() over UdpSocket and clock_gettime(CLOCK_MONOTONIC, ...) / gettimeofday(&tv, NULL) over Timer (single-shot Timer.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-side capos_* exports). libcapos was promoted to crate-type = ["staticlib", "rlib"] to support this.
  • Vendored vendor/dns-c-wahern/ (William Ahern dns.c at rel-20160808, commit 4ec718a77633c5a02fb77883387d1e7604750251, MIT). Mirror-as-is; only src/dns.c and src/dns.h retained alongside LICENSE and README.md per the WASI W.1 vendoring discipline. See vendor/dns-c-wahern/VENDORED_FROM.md.
  • New C smoke demos/posix-dns-resolver/main.c that links against libcapos.a + libcapos_posix.a and drives a hand-rolled DNS A query for example.com to 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 v0 libcapos-posix surface. 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 in VENDORED_FROM.md.
  • New focused-proof manifest system-posix-dns.cue (own CUE package, imports the shared capos.local/cue/defaults package per the slice-3 defaults pattern) granting the smoke console, network_manager, and timer.
  • New Makefile target run-posix-dns-smoke and harness tools/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 at 2026-05-05 21:21 UTC: make run-posix-dns-smoke returns 0 with resolved example.com -> 104.20.23.154 in the kernel log; make run-net regression 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 Pipe interface (read / write / close / isClosed) and ProcessSpawner.createPipe(bufferBytes). The generated tools/generated/capos_capnp.rs baseline was refreshed through the canonical capnpc step and make generated-code-check passes.
  • Kernel: kernel/src/cap/pipe.rs ships the bounded SPSC byte ring with EOF-on-close semantics, kept symmetric with the UDP recv ceiling (4 KiB). Each cap half stores an Arc<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.rs and kernel/src/sched.rs integrate the new poll alongside the existing network poll.
  • Kernel: kernel/src/cap/process_spawner.rs gains handle_create_pipe, mirroring the UDP-socket result-cap transfer pattern. The existing spawn Move-grant path is reused; no changes to the spawn ABI.
  • Userspace runtime: capos-rt/src/client.rs exposes typed PipeClient (read/write/close/isClosed and matching *_wait) plus ProcessSpawnerClient::create_pipe / create_pipe_wait and the CreatePipeResult projection of the two transferred halves.
  • libcapos-posix: new pipe.rs and process.rs modules. The fd table grows a FdBacking::Pipe variant; dup_for_dup2() clones the OwnedCapability<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, and posix_inherit_stdio are exposed via C ABI. dup2 and close inside a fork-recording window route through process::maybe_record_dup2 / maybe_record_close rather than mutating the parent fd table; execve consumes the recorded actions as stdio_<N> spawn grants – Pipe/TerminalSession forwarded Move, Console/Directory/File forwarded Raw over their Copy-transferable caps – and returns the synthetic child pid as its own return value so the user pattern becomes int 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; (no setjmp / longjmp involved – earlier iterations longjmp’d back to the fork() call site, which dropped back into a returned-and-deallocated stack frame and was undefined behaviour). After a successful spawn, each Move-granted source fd slot is replaced with a FdBacking::Moved sentinel and the underlying OwnedCapability is forgotten so the parent does not queue a stale CAP_OP_RELEASE for the moved cap_id; a subsequent close(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. A Raw/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 each stdio_<N> grant back into slot N by interface id (fd::inherit_stdio_grants), wrapping an inherited directory fd through fdopendir(); proof make run-posix-execve-inherit-smoke.
  • libcapos-posix successor surface: direct posix_spawn and posix_spawn_file_actions_init / destroy / adddup2 / addclose reuse the same action-replay helper behind the recording-shim execve path. Recording-shim execve now delivers argv through the private posix_argv Pipe grant described above. Direct posix_spawn still accepts argv and envp for 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-posix stdio successor: landed at commit aa6a56d7 (2026-05-13 11:03 UTC). fd 1 and fd 2 initialize to the granted Console cap when present, but only after any stdio_<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-smoke prints distinct stdout/stderr markers through POSIX write and proves the no-stdin refusal path.
  • Demo: demos/posix-pipe-shim/main.c (parent) and demos/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 calls posix_inherit_stdio(), writes “hello via pipe” to fd 1, closes it, and exits 0; the parent drains the read end through read() 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 shared capos.local/cue/defaults package). New Makefile target run-posix-pipe-smoke and harness tools/qemu-posix-pipe-smoke.sh. Verified 2026-05-07 09:55 UTC: make run-posix-pipe-smoke returns 0 with the proof line in the kernel log; make run-smoke and make run-spawn regressions 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). dash v0.5.13.4 is vendored mirror-as-is (full upstream tree, byte-identical) under vendor/dash/ with vendor/dash/VENDORED_FROM.md. The per-call-site Variant A patch (capture execve()’s synthetic pid return value, bail on -1, assign back to child) – the shape recorded in Open Question §6 and the Decisions §6 entry – lives under vendor/dash/patches/ as two .patch files: 0001-execve-return-synthetic-pid.patch propagates the synthetic pid up through tryexec()/shellexec() (the execve() call site), and 0002-vforkexec-adopt-synthetic-pid.patch adopts it at the vforkexec() fork-exec site. Cumulative diff 45 changed lines (< 50). dash’s inter-call dup2 / close between fork and execve already records through libcapos-posix and 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 existing c-build helper compiles single-file demos/*/main.c smokes against libcapos.a + libcapos_posix.a. dash is a multi-translation-unit C codebase; the Makefile gained the reusable capos-c-multitu-elf define (instantiated with $(eval $(call ...))) that compiles a list of vendored .c files each to an object and links them with libcapos_posix.a + libcapos.a into a userspace ELF without dragging in an external libc. Toolchain remains clang --target=x86_64-unknown-none-elf -nostdlib -static per Open Question §11 and the libcapos C-substrate plan. Proven by the two-TU demos/c-multifile/ demo and make 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 no configure and no host generators, so the dash-specific prerequisites live under vendor/dash/capos/: a pinned config.h (derivation + host-table caveat in vendor/dash/VENDORED_FROM.md) and gen-tables.sh, which stages a patched source copy (keeping vendor/dash/src byte-identical) and runs dash’s six host generators (mktokens, mksyntax, mknodes, mksignames, mkbuiltins, mkinit). The Makefile dash target funnels dash_CFILES + the five generated tables through capos-c-multitu-elf against libcapos_posix.a + libcapos.a in the -nostdinc sysroot mode, producing target/dash/dash.elf (static, 0 undefined symbols, both Variant A fork-exec patches compiled in). make dash proves build + link; the runtime QEMU proof is the dependent shell smoke below.
  • File / directory I/O surface in libcapos-posix. Typed FileClient and DirectoryClient wrappers landed in capos-rt/src/client.rs at commit 747a8611 (2026-05-16 20:07 UTC); FILE_INTERFACE_ID / DIRECTORY_INTERFACE_ID constants are already in capos-config/src/lib.rs. Slice 3 added the FdBacking::File / FdBacking::Directory / FdBacking::Terminal variants in libcapos-posix/src/fd.rs at commit ae58f936 and the matching smoke. The current surface implements open, close, read / write (joining the existing pipe/UDP read/write dispatch), lseek, opendir, readdir, and closedir; make run-posix-file proves these through a live POSIX C process. File-backed fds now store the POSIX access mode from open(): read rejects O_WRONLY, write rejects O_RDONLY, ftruncate requires a write-capable fd, and O_RDONLY | O_TRUNC is denied before the resolver can reach Directory.open. dup / dup2 preserve the stored mode, and the recording-shim execve path grants a private posix_fd_rights metadata pipe so inherited File fds reconstruct the same attenuation in the child fd table. make run-posix-open-smoke and make run-posix-file carry the same-process denial checks; make run-posix-execve-inherit-smoke proves the recording-shim inheritance path preserves read-only and write-only File fd modes.
  • Path resolver over a root Directory cap. A resolver in libcapos-posix/ walks a path through a bootstrap-granted root Directory cap and returns File / Directory result 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 for open / opendir / stat / access / unlink / mkdir landed (make run-posix-cwd); chdir stores 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 kernel Directory cap’s lack of a parent edge, not a resolver clamp. The Namespace / Store resolver shape remains documented for a future real filesystem service.
  • Remaining file metadata calls. stat, fstat, access, and unlink remain fail-closed stubs until a dash call site requires the stable struct stat and remove-contract shape.
  • Stdio over TerminalSession. FdBacking::Terminal adopting the bootstrap-granted TerminalSession cap 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 for posix_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 in libcapos-posix, populated at startup from manifest rodata (a bounded env grant on initConfig.init, mirroring the wasiEnv :Text bounded grant the WASI host adapter already uses for Preview 1 environ_get). The eventual typed LaunchParameters cap 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-printf proves the surface from a live capOS C process. libcapos already exports malloc / free / calloc / realloc for C consumers.
  • Signal stubs. Implemented in libcapos-posix: signal / sigaction validate and store handlers in a per-process table but never deliver them; kill fails closed with EPERM because this POSIX surface has no target ProcessHandle authority; raise fails closed with ENOSYS because self-delivery is not implemented. make run-posix-signal-time proves the documented behavior from live capOS C process output. Real SIGCHLD / SIGTSTP delivery and job control remain out of scope.
  • Time additions. Implemented in libcapos-posix: time(2), nanosleep, and sleep reuse the existing Timer cap path already used by clock_gettime / gettimeofday. make run-posix-signal-time proves monotonic-since-boot time() output, bounded nanosleep(), and one-second sleep() from live capOS C process output.
  • Identity stubs. Implemented: getpid returns the stable capos-rt bootstrap pid for the current process, while the recording-shim child pid allocator stays above the caller’s pid for the waitpid table; getuid / getgid return the hardcoded single-identity uid/gid 0. make run-posix-identity proves a parent and fork/exec child observe distinct process-visible pids from live capOS C code.
  • isatty / getppid (closed 2026-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) returns 1 for an FdBacking::Terminal slot, 0 with errno = ENOTTY for any other live backing, and 0 with errno = EBADF for 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 the getpid single-identity path. make run-posix-isatty proves isatty(0/1/2)=1 over bootstrap-granted TerminalSession stdio, isatty(pipe_fd)=0 errno=ENOTTY, and getppid=1 from live capOS C process output.
  • fcntl (closed 2026-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_CLOEXEC duplicate into the lowest free slot >= arg over the same dup_for_dup2 alias path dup/dup2 use; F_GETFD/F_SETFD round-trip a per-fd FD_CLOEXEC byte; F_GETFL reports a stable access mode (O_RDWR for Console/Udp/Pipe/Terminal, the stored open() mode for File, O_RDONLY for the read-only Directory); F_SETFL fails closed with EINVAL when the argument carries O_NONBLOCK (the v0 ring calls block with WAIT_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. Unknown cmd yields EINVAL and a closed/out-of-range fd yields EBADF. CLOEXEC is enforced at recording-shim execve time: the full-fd-table inheritance walk skips a slot whose flags byte carries FD_CLOEXEC unless an explicit recorded dup2 named that child slot. make run-posix-fcntl proves the F_DUPFD,10 relocation, the FD_CLOEXEC round-trip, F_GETFL=O_RDWR for a pipe, and the EBADF/EINVAL error paths from live capOS C process output.
  • Manifest + smoke harness (landed 2026-05-27 09:36 UTC). system-posix-shell.cue grants dash a TerminalSession (stdio), a bootstrap RAM Directory (root), a ProcessSpawner, and a Timer. New demos/ls-shim/ one-binary listing helper wraps the inherited directory fd with fdopendir() (the smoke’s only allowed spawn target). make run-posix-shell-smoke + tools/qemu-posix-shell-smoke.sh feed 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 prints done – and assert the alpha/beta entry lines, done, two clean-exit log lines, the scheduler halt line, and clean QEMU exit. The ls-by-bare-name vs /ls-shim PATH-stat workaround uses the slash-bearing path, which the recording-shim spawn maps to the manifest binary name by basename. Stretch: extend the smoke to cat foo | grep bar end-to-end, exercising the P1.3 Pipe primitive through a shell pipeline. Stretch closed (2026-05-27, posix-dash-pipeline-exec-reconcile): dash patch 0004-pipeline-evexit-recording-shim.patch reconciles the EV_EXIT in-place shellexec path with the recording shim (every evalpipe element takes that path, which the original patch set had left unreconciled), and libcapos-posix gained wildcard waitpid(-1)/wait3 reaping. make run-posix-shell-smoke now drives the pipeline (match bar here filtered through, four clean child exits). See docs/backlog/posix-adapter-dash-port.md Slice 14 and vendor/dash/VENDORED_FROM.md.
  • read builtin over fd 0 (landed 2026-05-31 20:35 UTC, posix-dash-read-builtin-terminal-line). Proves dash’s read VAR builtin consuming interactive input off its fd 0 TerminalSession cooked-mode line discipline – the one stdin path every prior smoke skipped (run-posix-shell-smoke feeds no stdin). No dash patch or libcapos-posix change was needed: dash’s tcgetattr(0)-derived canonical buffering takes the plain read(0, ...) branch, which the FdBacking::Terminal adapter 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 lines got=[hello world] / raw=[raw\back\slash] (the second under read -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. See docs/backlog/posix-adapter-dash-port.md Slice 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

BoundaryNative capOS servicePOSIX-shaped C binary on capOS
Authority sourceProcess CapSetProcess CapSet projected through libcapos-posix fd table
Memory isolationPage tablesPage tables (no wasm-style sandbox; libc has no extra runtime check)
Code integrityW^X + NXW^X + NX
Cap forgeryKernel-owned CapTableSame; the fd table is per-process userspace state, not authority
Resource limitsKernel quotasKernel quotas; ulimit is ENOSYS
Side channelsHardware-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 write to a fd it was not granted, cannot open() a path outside its preopened namespaces, and cannot call an unimplemented POSIX function without receiving ENOSYS.
  • 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.

  1. POSIX shell candidate. Decided (P1.4 Slice 1, 2026-05-24 00:53 UTC): dash 0.5.13.x, vendored at a pinned tag under vendor/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/sh posture that does not accidentally validate Bash extensions libcapos-posix does not implement. Rejected: busybox ash (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 native shell/ capos-shell already exists). Vendoring, the Variant A patch, the multi-TU C build, and the shell smoke are later P1.4 slices (11-14).
  2. DNS resolver candidate. Decided (P1.2 Phase A, 2026-05-05 18:02 UTC): dns.c (William Ahern), vendored at a pinned tag under vendor/dns-c-wahern/. Rationale: single-file MIT C (~10 kSLOC .c plus 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 __syscall shape; 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).
  3. libcapos versioning and naming. The C library is just libcapos (mirrors the Rust capos-rt). Open question: should the POSIX layer be libcapos-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 the libcapos-posix identifier. Working answer: keep libcapos-posix for the POSIX layer.
  4. POSIX errno representation. Decided (P1.2 Phase A, 2026-05-05 18:02 UTC): per-thread errno cell exposed via __errno_location() – the standard POSIX shape. Storage lives in libcapos-posix, owned by a thread-local cell accessed through a stable extern "C" int *__errno_location(void); function so vendored ports (dns.c, dash, future C software) compile against errno exactly as on Linux/musl. Rust internals keep the typed CapError/CapException shape; one bidirectional mapping at the C boundary writes the int value into the TLS cell so internal callers cannot invent unmapped values. Rejected: per-fd error field – breaks source compatibility with every POSIX program that reads errno after read/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.
  5. File descriptor table location. Decided (P1.2 Phase A, 2026-05-05 18:02 UTC): static-array fd table in libcapos-posix with 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-process CapTable and 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 every dup/close), and the cap-table object id is already a userspace-visible handle through OwnedCapability, 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.
  6. Fork policy. Decided (P1.3, 2026-05-07 09:55 UTC; refined 2026-05-07 10:30 UTC to drop setjmp/longjmp): Variant A – the recording shim. fork() records “next exec is the real spawn” in TLS and returns 0 unconditionally. dup2() and close() calls between fork() and execve() route through process::maybe_record_dup2 / maybe_record_close and are not applied to the parent fd table. execve() consumes the recorded actions, dispatches ProcessSpawner.spawn() with the matching pipe halves moved into the child as stdio_<dst> grants, parks the resulting OwnedCapability<ProcessHandle> in a per-process table, and returns the synthetic child pid as its own return value (a deliberate v0 deviation from POSIX, where execve only returns -1 on failure). The user pattern becomes int 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 a FdBacking::Moved sentinel so a subsequent close(src) (the dash-shaped pattern’s “I no longer hold the write end”) removes the sentinel without a kernel round trip. The earlier setjmp/longjmp design longjmp’d back to fork()’s call site after execve() had returned – the saved jmp_buf RSP/RIP pointed into fork()’s stack frame, which was deallocated when fork() first returned, so the longjmp resumed inside a stale frame whose memory had already been reused by dup2/close/execve. A targeted dash patch is still required for the v0 contract: execve() returns the synthetic pid on success, where unmodified dash assumes execve() 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-port posix_spawn only) requires the port to consolidate every fork+dup2+exec sequence into a single posix_spawn call with explicit posix_spawn_file_actions, a much wider patch surface. A 2026-05-13 successor now exports direct posix_spawn() over the same execve-backed action replay. 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.
  7. 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 is TerminalSession – 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 the FdBacking::Terminal variant in libcapos-posix/src/fd.rs plus posix_inherit_stdio() adopting the bootstrap-granted TerminalSession cap, mirroring the existing pipe-inheritance path; that implementation already shipped under P1.4 Slice 5 and is proven by make run-posix-stdio-terminal-smoke. This slice only records the backing choice as final.
  8. 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) (localAddr empty selects the configured interface; localPort = 0 selects an ephemeral port). The result cap is transferred via socketIndex in the CQE result-cap list, matching connectTcp.
    • 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 as TcpSocket.recv: the kernel parks the SQE until a datagram arrives. The caller bounds the wait through the existing RingClient::wait(call_id, timeout_ns) mechanism; dns.c-style retry/deadline loops drive that bound from userspace. If the caller wants to abort a parked recvFrom early, it issues close() on the socket; the parked completion then returns a Disconnected-class CapException. The v0 surface deliberately does not introduce a new Timeout exception class, since none exists today (ExceptionType covers only failed, 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-side cap_enter parking, transferred result caps, client-side RingClient::wait deadline – so the kernel UDP path in P1.2 Phase B is a near-mirror of the TCP path. Rejected (deferred): readiness/poll-style recvFrom – the cap surface decision (one-shot wait vs an event stream over an Endpoint) 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 generic Pollable cap) when a real consumer needs it, not before. Rejected: per-method timeoutNs parameter – creates two competing deadlines (the in-method timeout and the ring wait) that race on the same call, would require either inventing a new Timeout exception class or overloading Disconnected ambiguously, and is redundant with the ring wait the client already issues.
  9. 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 an Arc<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 same Pipe interface (read / write / close / isClosed); the kernel rejects wrong-direction calls with a failed exception. 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. **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.
  10. argv / envp source. This proposal assumes a future LaunchParameters cap delivers argv / envp through a typed cap. Until that cap lands, libcapos-posix can carry argv / envp via a fixed well-known cap or rodata blob. Confirm gate-on-LaunchParameters versus ship-stub.
  11. Linker / toolchain for C consumers. Recommended: clang --target=x86_64-unknown-none-elf -nostdlib -static, link against libcapos.a (and optionally libcapos-posix.a), reuse the existing capos-rt linker script. Confirm clang vs gcc and whether the track ships a shared cc-glue Cargo crate or a Make rule invoking cc directly.
  12. 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 planned vendor/piccolo-no_std/ shape from the Lua track.
  13. Audit / measure-mode interaction. The libcapos-posix wrappers must not break measure mode (the measure feature). Most wrappers only call libcapos, which only calls capos-rt, which is already measure-mode-clean, so this should be free; confirm whether the track adds a make run-measure smoke for one libcapos-posix binary 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.a surface (P1.1 + P1.2 + P1.3, plus the 2026-05-13 posix_spawn successor 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 on Namespace + File caps.
  • Networking defines NetworkManager, TcpListener, and TcpSocket and defers UDP. The DNS resolver port in Phase P1.2 adds the UdpSocket cap surface; the TCP cap surface is reused unchanged.
  • Storage and Naming defines the Directory / File / Store / Namespace surfaces that the shell port consumes. Phase 2/3 of that proposal gates the dash file I/O surface.
  • Service Architecture defines the future Resolver cap that the resolver port eventually exports.
  • Shell covers the native capos-shell. The POSIX shell port is for porting validation and does not replace capos-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.