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

POSIX Adapter Phase P1.4: Running dash

Long-form decomposition for the POSIX adapter Phase P1.4 dash port. Root task records under docs/tasks/ select dispatchable POSIX work and link here; the executable per-step checklist is in docs/proposals/posix-adapter-proposal.md Task 4; the design rationale and validation smoke contract are in docs/proposals/posix-adapter-proposal.md Phase P1.4 and Open Questions §1 (shell candidate) + §7 (fd 0 backing). Open Question §6 (fork policy = Variant A recording shim) is already a final decision in the proposal and does not gate P1.4.

What “Running dash” Means in v0

The validation smoke is make run-posix-shell-smoke. It boots a focused manifest that grants:

  • a TerminalSession cap for stdio,
  • a read-only bootstrap-granted Directory cap rooted at a tiny in-rodata pseudo-fs (the resolver remains Namespace-shaped for forward parity; the v0 manifest grants a Directory because that is what Storage Phase 3 slice 2 ships as a kernel CapObject),
  • a ProcessSpawner narrowed to one allowed binary (ls-shim),
  • and a Timer cap.

tools/qemu-posix-shell-smoke.sh pipes the heredoc ls; echo done into the shell’s fd 0, asserts done on the kernel log, asserts two clean-exit log entries (shell + ls-shim), and asserts clean QEMU exit. Stretch goal: cat foo | grep bar end-to-end against demos/cat-shim/ and demos/grep-shim/, exercising the P1.3 Pipe primitive through a shell pipeline.

This is intentionally narrow: no job control, no signal delivery, no real filesystem persistence, no ulimit (a v0 chdir / cwd string with cwd-relative resolution has since landed – see Slice 4 below). The point is to prove that a real POSIX C program (not a capOS-native shell) boots, parses scripts, dispatches subprocesses through fork+execve, reads stdin, writes stdout, and exits cleanly under QEMU.

Prerequisites Already Landed

  • P1.1 libcapos C substrate (fe5f5208, 2026-05-05 13:28 UTC): Rust staticlib mirror of capos-rt, _start shim, fixed heap, malloc / free / calloc / realloc, console_write_line.
  • P1.2 UDP + DNS resolver smoke (2026-05-05 21:21 UTC): libcapos-posix errno TLS cell, clock_gettime / gettimeofday over Timer, fd-table dispatch shape, __errno_location().
  • P1.3 Pipe + recording-shim fork-for-exec (2026-05-07 09:55 UTC, fix-slice through 05b52873 2026-05-07 21:07 UTC): kernel Pipe cap, ProcessSpawner.createPipe, fd-table FdBacking::Pipe, recording-shim fork / execve / waitpid / _exit, direct posix_spawn / posix_spawn_file_actions_*. The Variant A contract: execve() returns the synthetic child pid on success.
  • Storage Phase 3 slices 1-3 (slice 1 d06dff6b at 2026-05-14 19:31 UTC, slice 2 b11ec9e4 at 2026-05-14 22:30 UTC, slice 3 804a3f41 at 2026-05-14 23:23 UTC): RAM-backed File / Directory / Store / Namespace CapObjects with KernelCapSource::file / directory / store / namespace grant sources. These are the v0 backing for the dash smoke’s read-only in-rodata pseudo-fs.
  • WASI bounded env grant (5f5028e7, 2026-05-13 11:05 UTC): reference shape for a bounded text env grant on initConfig.init (wasiEnv :Text). The dash port mirrors this for its env vector.
  • setjmp / longjmp precursor (libc-setjmp-longjmp, 2026-05-25 21:11 UTC): the x86_64 SysV setjmp / longjmp C-ABI primitive plus jmp_buf and a <setjmp.h> header. This was absent from the original P1.4 surface table, but 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 and shell smoke. Implemented in libcapos/src/setjmp.rs (global_asm), exposed through libcapos-posix/include/capos/posix/setjmp.h, and proven in QEMU via make run-posix-setjmp (direct call returns 0, a longjmp from a deep recursion resumes setjmp with the passed value, and longjmp(env, 0) returns 1). No sigsetjmp / siglongjmp: dash uses only the plain primitive and the v0 signal layer has no asynchronous delivery.

The Phase 2 Open Question §1 (dash candidate) and §7 (fd 0 backing = TerminalSession) are now promoted from working answers to final decisions (docs/proposals/posix-adapter-proposal.md “## Open Questions” §1/§7, Decided (P1.4 Slice 1, 2026-05-24 00:53 UTC)); that promotion was the first dispatch slice of P1.4 (Slice 1 below).

Decomposition

Slice 1: open-question closures (docs-only)

Status: closed (P1.4 Slice 1, 2026-05-24 00:53 UTC). Open Questions §1 (dash 0.5.13.x) and §7 (fd 0 backing = TerminalSession) are now Decided in docs/proposals/posix-adapter-proposal.md; the §1 candidate-survey cross-reference and the Phase P1.4 “Open question closures” bullet are reconciled to match.

Two open questions in docs/proposals/posix-adapter-proposal.md must become final decisions before any code lands:

  • §1: confirm dash 0.5.13.x as the v0 candidate. Alternatives surveyed: busybox ash, oksh, toysh, custom Rust shell. dash wins on size, POSIX strictness, and single-purpose /bin/sh posture.
  • §7: confirm TerminalSession as the canonical fd 0 / 1 / 2 backing for the v0 smoke. An FdBacking::Terminal variant in libcapos-posix/src/fd.rs plus posix_inherit_stdio() adoption is the implementation shape.

Promotion = strike the “Working answer” phrasing in the proposal, replace with “Decided (P1.4 Slice 1, )” and the rationale.

Slice 2: typed clients in capos-rt

Status: closed. The typed clients and interface-ID re-exports are available from capos-rt; make run-posix-file now exercises them through libcapos-posix.

TerminalSessionClient and the TERMINAL_SESSION_INTERFACE_ID re-export already ship from capos-rt/src/client.rs and capos-rt/src/lib.rs; no work there. The net-new wrappers, mirroring the existing PipeClient / UdpSocketClient shape, are:

  • FileClient: read, write, stat, truncate, sync, close over the File interface methods.
  • DirectoryClient: open, list, mkdir, remove, sub over the Directory interface methods, returning typed FileClient / DirectoryClient projections of the transferred result caps.

Add re-exports for the existing FILE_INTERFACE_ID / DIRECTORY_INTERFACE_ID constants (already defined in capos-config/src/lib.rs) from the pub use capos_config::{...} block in capos-rt/src/lib.rs.

Slice 3: fd backing for File / Directory / Terminal

Status: closed. FdBacking::File, FdBacking::Directory, and FdBacking::Terminal are present; read, write, close, lseek, opendir, readdir, and closedir are wired for the RAM-backed root Directory path. The stat / fstat / access / unlink metadata/remove follow-up is closed by the posix-p1-4-file-metadata slice: stat / fstat fill a struct stat (sys/stat.h) from File.stat, access is an existence check (single-identity v0, mode ignored), and unlink resolves the parent Directory and calls Directory.remove. Proven by the extended make run-posix-file smoke. The file-resize follow-up is partially closed by the posix-ftruncate-truncate-file-resize slice: ftruncate(fd, length) and truncate(path, length) drive File.truncate @3 over the RAM-backed root and are proven by the same make run-posix-file smoke (ftruncate shrink ok / truncate by-path ok markers). The fsync(2) / fdatasync(2) C shims over FileClient::sync_wait are implemented in libcapos-posix/src/file.rs; writable-disk (writable_fs) truncate beyond the RAM-backed root remains open.

Extend libcapos-posix/src/fd.rs with three new FdBacking variants:

  • FdBacking::File { client: FileClient, pos: u64 } – the seek position lives in the fd table, not the kernel File cap (the schema-level read/write take an explicit offset).
  • FdBacking::Directory { client: DirectoryClient, iter: ... } – iteration state for readdir.
  • FdBacking::Terminal { client: TerminalSessionClient }.

Route the existing read / write / close C entry points through these variants. Add file-path-only C entry points (open, lseek, stat, fstat, access, unlink, opendir, readdir, closedir) in libcapos-posix/src/file.rs and libcapos-posix/src/directory.rs.

Slice 4: path resolver over root Directory

Status: closed for the bootstrap root Directory shape. A read-only absolute-path resolver in libcapos-posix/src/path.rs:

  • Input: an absolute UTF-8 path and a bootstrap-granted root Directory cap.
  • Walk Directory.sub() for each prefix segment; mint a leaf File / Directory cap directly with Directory.open() / Directory.sub().
  • A v0 per-process cwd string landed (libcapos-posix/src/cwd.rs, make run-posix-cwd): getcwd / chdir plus cwd-relative resolution for open / opendir / stat / access / unlink / mkdir. chdir validates the target directory through the resolver, stores the normalized absolute string, and drops the cap; cwd inheritance across spawn is still deferred. No .. collapsing – escape is prevented by the kernel Directory cap’s lack of a parent edge, not a resolver clamp.
  • Returns typed File / Directory result caps that flow into the fd-table backing.

The future Namespace.resolve + Store.get shape remains planned for a real filesystem service; the v0 dash smoke uses the bootstrap-granted root Directory, no Store / content-addressed hashes.

Slice 5: stdio over TerminalSession

Status: closed. posix_inherit_stdio() adopts a bootstrap-granted TerminalSession cap as fds 0 / 1 / 2 (FdBacking::Terminal), with the pipe-backed inheritance path retained for posix_spawn-driven pipeline children; proven by make run-posix-stdio-terminal-smoke.

posix_inherit_stdio() already adopts pipe-backed fds 0 / 1 / 2 from the recording-shim execve path. Extend it to also adopt a bootstrap-granted TerminalSession cap as fd 0 / 1 / 2 when the manifest supplies one (the posix-pipe pipeline children stay on the existing pipe path). The shell binary calls posix_inherit_stdio() once from main() before reading the heredoc.

Slice 6: env vector + getenv / setenv / putenv

Mirror the WASI host adapter’s wasiEnv :Text shape:

  • Add a bounded posixEnv :Text (or per-key `posixEnvEntries
    List(Text)) grant on initConfig.initinschema/capos.capnp. This is the only P1.4 schema touch; queue on the shared schema serial surface per docs/backlog/index.mdConcurrency Notes when selected. Regenerate the checked-in capnp bindings;make generated-code-check` must pass.
  • Read the grant from the bootstrap CapSet at startup; populate a per-process env vector in libcapos-posix/src/env.rs.
  • C entry points: getenv, setenv, putenv, unsetenv.
  • LaunchParameters remains a follow-on for non-v0 callers.

Slice 7: printf / string subset

Status: closed. The focused C library subset now ships from libcapos-posix, and make run-posix-printf proves formatted output plus string/mem, numeric conversion, and ctype behavior from a live capOS C process.

A focused C library subset shipped from libcapos-posix (not a full libc, not a musl port):

  • stdio.h subset: printf, fprintf (fd 1 / fd 2 only), vprintf, vfprintf, snprintf, vsnprintf, putchar, puts, fputs, fputc. No fopen / FILE * – those route through the fd-table surface.
  • string.h subset: memcpy, memmove, memset, memcmp, strlen, strcmp, strncmp, strchr, strrchr, strcpy, strncpy, strcat, strncat, strdup.
  • stdlib.h subset: atoi, strtol, strtoul. Process termination still uses the existing libcapos _exit path; exit / abort stay outside this focused printf/string slice.
  • ctype.h subset: isspace, isdigit, isalpha, isalnum, isupper, islower, tolower, toupper.

malloc / free / calloc / realloc already ship from libcapos.

Slice 8: signal stubs

Status: closed for the v0 dash-port surface. signal() and sigaction() validate and store handlers in a per-process table, but handlers are never delivered. kill() fails closed with EPERM because this POSIX layer has no target ProcessHandle authority, and raise() fails closed with ENOSYS because self-delivery is not implemented. make run-posix-signal-time proves the documented behavior from a live capOS C process.

Header-and-stub-only signal, kill, sigaction, plus a TLS-stored handler table that accepts handler registration but never delivers a signal. dash registers a SIGCHLD handler at startup; the stub records the handler pointer and returns 0. Documented out of scope: real SIGCHLD / SIGTSTP delivery, job control, controlling terminals.

Slice 9: time additions

Status: closed. 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 a live capOS C process.

time(2), nanosleep, sleep over the existing Timer cap; clock_gettime / gettimeofday already landed under P1.2 Phase B.

Slice 10: identity stubs

Status: closed for the ready-task surface (getpid, getuid, getgid) at commit 1a8a9896 (2026-05-23 06:51 UTC). getpid returns the stable capos-rt bootstrap pid for the current process, and the recording-shim child-pid allocator avoids colliding with the caller’s pid. getuid / getgid return the hardcoded single-identity uid/gid 0. The geteuid / getegid alias follow-up is closed (task posix-geteuid-getegid): both delegate to getuid / getgid since the effective ids equal the real ids under the v0 single-identity model, declared in unistd.h, and asserted by run-posix-identity via the printed euid=0 egid=0 fields.

Slice 11: dash vendoring + Variant A patch

Status: closed (posix-p1-4-dash-vendor, 2026-05-24 19:40 UTC). dash v0.5.13.4 is vendored mirror-as-is under vendor/dash/ (full upstream tree, byte-identical) with vendor/dash/VENDORED_FROM.md. The Variant A fork-exec patch set lives under vendor/dash/patches/ as two .patch files (0001-execve-return-synthetic-pid.patch over src/exec.c/src/exec.h; 0002-vforkexec-adopt-synthetic-pid.patch over src/jobs.c), cumulative diff 45 changed lines (< 50). Design evidence only – nothing compiles or runs at this slice; the C-build slice (posix-p1-4-c-multifile-build) and shell smoke (posix-p1-4-dash-shell-smoke) prove the behavior end-to-end.

  • Vendor dash 0.5.13.x under vendor/dash/ at a pinned tag, mirror-as-is. Add vendor/dash/VENDORED_FROM.md recording the upstream URL, commit, tag, and refresh procedure (mirror the existing vendor/dns-c-wahern/VENDORED_FROM.md shape).
  • Apply the Variant A per-call-site patch: at each fork-exec site, capture execve()’s synthetic pid return value, bail on -1, and assign back to child. Patches live under vendor/dash/patches/ with one .patch per call site; the cumulative diff against upstream is < 50 lines.
  • Inter-call dup2 / close between fork and execve already records through libcapos-posix and needs no per-call patching.
  • Carried into Slices 12-13: patch 0001 de-noreturns shellexec(), so the two no-fork exec-replace callers (src/eval.c evalcommand() EV_EXIT path and execcmd(), each with /* NOTREACHED */) now fall through under the recording shim. A single non-interactive command (dash -c '/bin/echo hi') takes the EV_EXIT path, not vforkexec(). Slice 12/13 must disable the EV_EXIT in-place-exec optimization under the recording shim (fork-exec-then-exit) or add an exec-replace-then-exit patch before the binary runs. Details in vendor/dash/VENDORED_FROM.md.

Slice 12: C-build pipeline for vendored multi-file C sources — CLOSED

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 (main.c, eval.c, exec.c, expand.c, input.c, jobs.c, mail.c, memalloc.c, miscbltin.c, mystring.c, nodes.c, options.c, output.c, parser.c, redir.c, show.c, trap.c, var.c, plus generated tables).

Closed by posix-p1-4-c-multifile-build: the Makefile gained the reusable capos-c-multitu-elf define (instantiated via $(eval $(call ...))) that

  • accepts a list of .c files,
  • compiles each to an object with clang --target=x86_64-unknown-none-elf -nostdlib -static -I libcapos/include -I libcapos-posix/include,
  • links the objects with libcapos_posix.a + libcapos.a,
  • produces a userspace ELF without dragging in an external libc.

The proof demo demos/c-multifile/ (main.c + greet.c + greet.h) builds through the rule; greet.c uses libcapos-posix strlen/memcpy, so the link resolves symbols from both archives. make run-c-multifile boots the two-TU ELF and asserts the greet=/checksum= line computed in the helper TU, proving the cross-TU call executed. The rule is reusable for future C ports (busybox utilities, dash).

Slice 12.5: dash build pipeline — LANDED

Status: landed (2026-05-26 05:11 UTC, task posix-p1-4-dash-build-pipeline). The sysroot/libc precursor landed first (2026-05-25 22:23 UTC, task libc-dash-sysroot-surface).

The build pipeline lives under vendor/dash/capos/ (outside the mirror-as-is src/): config.h (pinned autotools config) and gen-tables.sh (stages a patched source copy under target/dash/src and runs dash’s six host generators into target/dash/gen). 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 include mode, producing target/dash/dash.elf (statically linked, 0 undefined symbols, _start from capos-rt, the two Variant A fork-exec patches compiled in). A clean tree (rm -rf target/dash && make dash) regenerates deterministically; the mksignames signal-name table is the one host-<signal.h>-derived table (cosmetic on capOS v0). Runtime behavior (including the EV_EXIT residual) is the dependent posix-p1-4-dash-shell-smoke. Config derivation + host-table caveat: vendor/dash/VENDORED_FROM.md.

Original precursor notes (posix-p1-4-dash-build-pipeline is now ready): The build-pipeline mechanics were validated by a -nostdinc compile/link probe over the full vendored dash TU set (branch posix-p1-4-dash-build-pipeline, gitignored target/dash-probe/):

  • A pinned capOS config.h (SMALL=1, JOBS=0, HAVE_* mostly undefined, _PATH_* literals, PRIdMAX "lld", USE_TEE/USE_MEMFD_CREATE 0) drives the preprocessor and gates.
  • All six generators run deterministically and emit the tables: mktokens (token.h/token_vars.h), mksyntax (syntax.c/syntax.h, needs token.h on its compile include path), mknodes (nodes.c/nodes.h), mksignames (signames.c), mkbuiltins over a preprocessed builtins.def (builtins.c/builtins.h), and mkinit over the 27-file TU list (init.c). mkinit and mkbuiltins take their inputs as separate arguments — mind shell word-splitting in the Makefile recipe.

The blocker was that dash includes bare POSIX headers (<unistd.h>, <fcntl.h>, <signal.h>, …), which resolved to the host /usr/include under the existing flags, plus a broad missing libc surface. libc-dash-sysroot-surface closed both:

  • Sysroot. libcapos-posix/sysroot/include/ holds bare-name headers (stdio.h, unistd.h, sys/types.h, termios.h, wchar.h, …) that forward to the capos/posix/* source of truth. Consumed with -nostdinc
    • four -isystem roots (clang freestanding builtins, the sysroot, and the two capOS namespaces) via the existing capos-c-multitu-elf rule (CAPOS_C_SYSROOT_INCLUDE in the Makefile). The focused proof is make run-c-libc-surface (qsort / strerror / umask / strtoll / strstr / S_IS*, all through bare includes).
  • Surface. The inventory plus several items the original table understated: the C/POSIX-locale multibyte layer expand.c needs unconditionally (mbrtowc/mbrlen/mbsrtowcs/wcschr/wctype/iswctype + the isw* family, <wchar.h>/<wctype.h>), strpbrk, lstat, getgroups, wait3, vfork, htonl/htons/ntohl/ntohs (used by bltin/printf.c), the S_IS* file-type macros, DT_LNK/…, environ, and the sys_siglist array dash’s own strsignal reads.

A -nostdinc compile of the full vendored TU set (27 hand-written + 3 bltin + 5 generated, using the probe’s generated headers) against the real sysroot now reports 0 errors, and a symbol audit shows 0 unresolved libc symbols once the dash objects and the two capOS archives are combined (evidence: ~/capos-evidence/libc-dash-sysroot-surface/).

Config.h the pipeline slice must pin (these are autotools/feature flags, not libc surface — they belong to posix-p1-4-dash-build-pipeline): the probe set already documented (SMALL=1, JOBS=0, _PATH_*, PRIdMAX "lld", USE_TEE/USE_MEMFD_CREATE 0) plus HAVE_ALLOCA_H 1 (so <alloca.h> is included; the sysroot provides it as a __builtin_alloca alias), HAVE_WAIT3 1 (dash’s #else branch is a non-compiling 4-arg waitpid; with the flag it uses the wait3 symbol the surface provides), HAVE_ISALPHA 1 (capOS <ctype.h> declares the classifiers, so dash uses them directly instead of its _isXXX rename shims), and the stat64/lstat64/fstat64/open64/readdir64/ dirent64/glob64* → unsuffixed #define fallbacks. Do not define HAVE_STRSIGNAL or HAVE_SYSCONF — dash provides those itself (and consumes sys_siglist/its noreturn sysconf).

Slice 13: ls-shim + manifest + smoke harness

Status: closed (2026-05-27 09:36 UTC) by make run-posix-shell-smoke: a real vendored dash boots as PID 1, reads the heredoc off its fd 0 TerminalSession, creates two entries in its bootstrap RAM root Directory (> /alpha, > /beta), opens that directory as fd 3 (exec 3< /), dispatches /ls-shim through fork/execve, prints done, and exits; ls-shim lists the inherited directory (alpha, beta) over the shared terminal and both processes exit cleanly. The earlier block (2026-05-27 00:46 UTC) was the fd-inheritance premise conflict (vanilla dash forwarded no capability to ls-shim); it was resolved by the posix-recording-shim-full-fd-inherit + posix-terminal-session-forwardable + posix-open-directory-fd precursors, so assembly needed only three minimal additions: a vendor/dash/patches/ runtime bootstrap (synthesize argv[0] + posix_inherit_stdio(); the runtime entry passes argv=NULL and wires no POSIX stdio), a basename map in the recording-shim spawn (the kernel matches the manifest binary name, so /ls-shim resolves to ls-shim; a no-op for the bare-name smokes), and the ls-shim / manifest / harness assembly. No dash EV_EXIT patch was needed: the heredoc never makes an external command the last command, so /ls-shim takes vforkexec(). Full historical analysis: see the completed task record docs/tasks/done/2026-05-27/posix-p1-4-dash-shell-smoke.md.

Finding 1 (vanilla dash forwards no cap) resolved 2026-05-27 by posix-recording-shim-full-fd-inherit (done): the recording shim now inherits the parent’s full live fd table by default (POSIX fork+execve), so dash’s open stdio flows to ls-shim with no dup2, and a held read-only Directory fd inherits as the child’s cwd source. Its kernel precursor posix-terminal-session-forwardable (done) lets the terminal forward non-destructively (Raw), so dash keeps its own terminal. Close-on-exec is enforced and an aliased non-destructive backing Copy-shares; proof make run-posix-fd-inherit-default. The remaining Slice 13 items are the secondary gaps: posix-open-directory-fd (open(dir, O_RDONLY) -> FdBacking::Directory, done 2026-05-27, proof make run-posix-open-dir-fd; needed only if a N</ redirection is used – a dirfd(opendir()) forward also works), the slash-bearing /ls-shim PATH-stat workaround, and the dash EV_EXIT in-place exec-replace residual (see Slice 11).

  • demos/ls-shim/main.c: open a hardcoded in-rodata directory path, iterate with opendir / readdir / closedir, print each entry name, exit cleanly. This is the only allowed spawn target in the smoke.
  • system-posix-shell.cue: a focused-proof manifest (own CUE package, imports capos.local/cue/defaults) granting TerminalSession, a read-only Directory over an in-rodata pseudo- fs containing exactly the entries the heredoc references, a ProcessSpawner narrowed to ls-shim, and a Timer.
  • Makefile vendor-dash, libcapos-posix-shell, manifest-posix-shell.bin, capos-posix-shell.iso, and run-posix-shell-smoke targets.
  • tools/qemu-posix-shell-smoke.sh host harness: pipe ls; echo done heredoc into fd 0, assert done, two clean-exit log entries (shell + ls-shim), the scheduler halt line, and QEMU exit status 1 (isa-debug-exit).

Slice 14 (stretch): cat | grep pipeline — DONE (2026-05-27)

Drives cat foo | grep bar end-to-end through dash’s pipeline parser:

  • demos/cat-shim/main.c: writes an in-rodata three-line corpus to stdout (only the middle line contains bar).
  • demos/grep-shim/main.c: reads stdin line by line, writes lines containing argv[1] to stdout. The initial Slice 14 proof baked bar as a compile-time fallback because child argv did not yet cross the recording-shim execve boundary; Slice 20 below now seeds grep-shim through the private posix_argv pipe, and the fallback no longer matches the corpus. The shell smoke therefore fails if /grep-shim bar does not deliver bar as argv.
  • system-posix-shell.cue: both shims as ProcessSpawner targets.
  • tools/qemu-posix-shell-smoke.sh: asserts match bar here reaches the terminal, the two non-matching corpus lines do not, and ≥4 clean child exits (dash + ls-shim + cat-shim + grep-shim).

This proves the P1.3 Pipe primitive end-to-end through dash’s own pipeline parser, not just the recording-shim posix_spawn_file_actions path.

Reconciliation (posix-dash-pipeline-exec-reconcile, DONE 2026-05-27). The premise conflict the blocked attempt found – a real cat foo | grep bar page-faulted dash after the first element because evalpipe sets EV_EXIT on every element and the recording-shim patch set had only reconciled vforkexec – is resolved by dash patch 0004-pipeline-evexit-recording-shim.patch plus a libcapos-posix wildcard reap:

  • 0004 (eval.c/jobs.c/jobs.h). evalcommand()’s EV_EXIT in-place shellexec() stashes the synthetic pid (capos_exec_pid) and breaks instead of faulting through case CMDBUILTIN. evaltree() suppresses its EV_EXIT exraise(EXEND) while that pid is pending. evalpipe()’s child arm calls evaltree() (not the __noreturn__ evaltreenr()) so it returns under the no-separate-address-space recording fork, then adopts the pid into the pipeline job via the now-exported forkparent(), re-suppressing interrupts to balance the child-arm INTON. forkshell() sets vforked around fork()/forkchild() so the recording-shim “child” does not freejob() the pipeline job. The cumulative dash patch budget was raised past 50 lines for this; see vendor/dash/VENDORED_FROM.md for the decision and the remaining residuals (the single trailing external command residual is closed by Slice 16 below, and the exec foo builtin residual by Slice 17; compound pipeline elements remain unsupported under the recording shim).
  • libcapos-posix wildcard waitpid(-1)/wait3. dash reaps pipeline children with wait3 -> waitpid(-1); the v0 surface now reaps any tracked child (blocking) and honors WNOHANG as “no child ready”, which is all waitforjob -> dowait needs.

Proof: make run-posix-shell-smoke (extended with the pipeline line). Regression-clean: make run-posix-pipe-smoke, make run-posix-execve-inherit-smoke.

Slice 15: PID-1 argv channel (posixArgs) — DONE (2026-05-30)

Closes the “capOS delivers no argv” gap for the manifest-launched binary, without a schema or kernel change:

  • libcapos-posix/src/args.rs: posix_args(int *argc) returns a process-lifetime, NUL-terminated char ** built from initConfig.init.posixArgs (a CueValue text list), read off the granted boot BootPackage cap. It mirrors capos-wasm/src/payload.rs::read_wasi_args and reuses the posixEnv blob-streaming/CueValue helpers (Slice 6, promoted to pub(crate)). Bounded by 32 entries / 4096 bytes-per-entry / 8192 bytes-total, fail-open to empty argv on any malformed or absent grant. Delivery is opt-in: the C main(argc, argv) trampoline in libcapos/src/entry.rs is untouched (still 0, NULL), so bare-name demos are unaffected.
  • dash patch 0003: when argv == 0, pull posix_args() and use it for procargs() when non-empty, keeping the {"sh", 0} fallback. A manifest seeding posixArgs: ["dash", "-c", "CMD"] now reaches evalstring(minusc, ...), so dash -c is invokable.
  • Proof: make run-posix-args-smoke (manifest seeds ["posix-args-smoke", "alpha", "beta"]; the C process prints argc=3 and each argv[i]). Regression: make run-posix-shell-smoke (the argv==0 fallback path is unchanged).

Follow-up closed by Slice 20 below: cross-execve argv inheritance to recording-shim children, needed before a spawned grep-shim could receive argv[1].

Slice 16: trailing top-level external command waits and exits (sh -c) — DONE (2026-05-30)

Closes the largest posix-dash-pipeline-exec-reconcile runtime residual: a single trailing top-level external command (the sh -c 'cmd' shape), unblocked by the Slice 15 argv channel.

  • Premise correction. The blocked task assumed 0004’s capos_exec_pid guard left the trailing child orphaned-but-spawned. In fact dash’s EV_EXIT optimization execs in place without forking, and the recording shim only spawns from an execve() inside a fork()-opened record window — so an unforked top-level in-place shellexec() returns ENOSYS (process.rs::execve with recording.active == false) and dash exits 126, never spawning the child. The fix is therefore to fork, not to wait on a pid that was never produced.
  • dash patch 0005-evexit-trailing-extcmd-wait.patch (eval.c): a capos_pipe_arm counter, raised by evalpipe() around its child-arm evaltree() call, gates the in-place EV_EXIT shellexec() to pipeline arms only (capos_pipe_arm > 0, where forkshell() already opened the window). A top-level EV_EXIT command (capos_pipe_arm == 0) takes the forking vforkexec() path, whose existing waitforjob() blocks for the child and returns its status; evaltree() then exraise(EXEND)s and exits with it. Substantive change: four lines; the rest are explanatory comments.
  • system-posix-shell.cue + tools/qemu-posix-shell-smoke.sh: the proof moves off the fd-0 heredoc onto a manifest posixArgs dash -c script (granted the boot BootPackage cap). The script keeps the directory-setup, pipeline-parser (/cat-shim foo | /grep-shim bar), and successful-listing (/ls-shim 3< /) proofs, then ends with a trailing /ls-shim that lacks fd 3 and exits 31. dash (the last process to exit, after waiting for that child) exits 31 — a value it has no other code path to produce, so it is a non-tautological wait-and-exit discriminator (an orphan-and-continue would have exited 0).
  • Proof: make run-posix-shell-smoke. Regression-clean: make run-posix-args-smoke, make run-posix-pipe-smoke, make run-posix-execve-inherit-smoke.

Remaining residual at the time: the execcmd() / exec foo command (process-image replace) path, closed by Slice 17 below. The later cross-execve argv inheritance gap is closed by Slice 20 below.

Slice 17: exec foo command builtin forks, waits, and exits (execcmd) — DONE (2026-05-31)

Closes the last posix-dash-pipeline-exec-reconcile in-place-shellexec() residual: the exec builtin’s command form (exec foo command), which must replace the shell with foo and exit with foo’s status.

  • Why 0005 did not cover it. execcmd() (eval.c) runs as a CMDBUILTIN (EXECCMD) through evalbltin(), which is dispatched from evalcommand()’s case CMDBUILTINbypassing the default:-case EV_EXIT fork gate 0005 added. So 0005’s capos_pipe_arm gate never sees the exec builtin; its in-place shellexec()->execve() has no fork()-opened record window and returns ENOSYS, and execcmd() ignores it and return 0s, continuing the script.
  • dash patch 0006-execcmd-fork-replace-wait.patch (eval.c): the argc > 1 form of execcmd() now forks via vforkexec(NULL, argv + 1, pathval(), 0) (which opens the record window), waitforjob()s for the replacement child, sets savestatus to its status, and exraise(EXEXIT)s — the exact shell-exit channel the exit builtin (exitcmd) uses, so EXITRESET copies savestatus into exitstatus and the shell exits with the replacement command’s status. n == NULL is safe (forkchild() early-returns under vforked; forkparent() reads n only when jobctl is set, never in a non-interactive dash -c). The no-command form (exec 3< /, argc == 1) keeps its return 0 and popredir() redirection-permanence path unchanged. Substantive change: five lines; the rest are explanatory comments.
  • system-posix-shell.cue + tools/qemu-posix-shell-smoke.sh: the proof replaces the trailing bare /ls-shim with exec /ls-shim (no fd 3, exits 31) followed by a poison tail > /gamma; /ls-shim 3< /. Correct exec-replace exits dash with 31 and the poison tail never runs (its [ls-shim] listed 3 entries / entry: gamma markers are absent); a buggy ignore-and-continue would instead create /gamma, list 3 entries, and exit 0. The directory-setup, pipeline-parser, and successful-listing proofs are kept intact.
  • Proof: make run-posix-shell-smoke. Regression-clean: make run-posix-args-smoke, make run-posix-pipe-smoke, make run-posix-execve-inherit-smoke.

Remaining residual closed by Slice 20 below: cross-execve argv inheritance.

Slice 18: read VAR builtin reads a line off fd 0 TerminalSession — DONE (2026-05-31)

Closes the one interactive-stdin path every prior P1.4 smoke skipped: dash’s read builtin (miscbltin.c readcmd()) consuming a line off its fd 0 TerminalSession cooked-mode line discipline and binding it to a shell variable. run-posix-shell-smoke drives a dash -c script and feeds no stdin (its harness only sleeps); the fd-0 -> TerminalSession.readLine read path was fully wired but never exercised through dash’s own read.

  • No dash patch and no libcapos-posix change needed. readcmd() reads via the buffered pgetc() -> preadbuffer() -> preadfd() -> read(0, buf, BUFSIZ) path. input_init() keys the buffering mode off tcgetattr(0), which libcapos-posix synthesizes as canonical (c_lflag & ICANON), so stdin_bufferable() is true and dash takes the plain read() branch (not the Linux tee()/splice history path, which would otherwise return a non-EINVAL error and be misread as EOF). libcapos-posix read() over FdBacking::Terminal returns exactly one line plus a synthesized \n per call — the canonical-tty contract dash expects — so the two sequential reads each consume one line.
  • system-posix-read-builtin.cue: a focused manifest granting terminal (TerminalSession), timer, and boot (for posixArgs), with the dash -c script printf 'rb-ready\n'; read NAME; printf 'got=[%s]\n' "$NAME"; read -r RAW; printf 'raw=[%s]\n' "$RAW". printf %s (not echo) echoes the bound values so the asserted bytes bypass echo’s backslash-escape interpretation.
  • tools/qemu-posix-read-builtin-smoke.sh + run-posix-read-builtin: the drive step handshakes rather than blind-sleeps. The kernel line discipline has no inter-read input buffer (it consumes UART bytes only while a readLine is pending) and the UART carries no EOF, so a line fed before userspace is draining is lost and the blocked read hangs to the QEMU timeout. drive tails the terminal-UART log: feed line 1 after the rb-ready banner, feed line 2 after the got=[ echo (so the two distinct lines never collide in the single shared UART FIFO), then hold stdin open until the raw=[ echo. A byte arriving just before its readLine is posted is still caught by handle_read_line()’s synchronous FIFO drain, so the banner/echo gates are a sufficient ordering guarantee.
  • Proof: make run-posix-read-builtin — observed got=[hello world] and the byte-preserved raw=[raw\back\slash] on the terminal UART, dash exit code 0, scheduler halt. Non-tautological: the echoed values are the harness-fed fd-0 bytes, which the script has no other source for. Regression-clean: make run-posix-shell-smoke (the exec/pipeline/$? paths are untouched; it still feeds no stdin).

Remaining residual closed by Slice 20 below: cross-execve argv inheritance.

Slice 19: test/[ file-test builtin stats the root Directory — DONE (2026-05-31)

Closes the last unexercised reachable-cap dash builtin path: test -e/-f/-d/-r FILE (and [ ... ]), the single most common shell file-predicate, reaching libcapos-posix stat/lstat over the bootstrap root Directory and discriminating file vs directory vs absent. Every prior P1.4 smoke exercises stdio, pipelines, exec-replace, argv, or interactive read, but none drives the test/[ builtin against the filesystem.

  • No dash patch and no libcapos-posix change needed. dash’s src/bltin/test.c filstat() calls stat64(nm, &s) / lstat64(nm, &s) (capos/config.h maps stat64->stat, lstat64->lstat) and switches on S_ISREG/S_ISDIR/FILEXIST; testcmd() registers both test and [ (src/builtins.def.in). HAVE_FACCESSAT is unset in capos/config.h, so -r/-w/-x route through filstat()->test_access() (a dash-internal check on the stat result, no extra libc call); under capOS’s single-identity euid=0 test_access(R_OK) short-circuits to true on any successful stat (before the st_mode read), so r=yes proves -r reached a real stat of /alpha, distinct from the absent-path miss, not the mode-bit comparison itself. libcapos-posix stat() (src/file.rs) resolves the path against the bootstrap root Directory, filling S_IFREG|0644 for files (write_file_stat) or S_IFDIR|0755 for directories (write_dir_stat, incl. the root via is_root_path); lstat() and access() are landed too, and the S_IS* macros ship from libc-dash-sysroot-surface.
  • system-posix-test-builtin.cue: a focused manifest granting terminal (TerminalSession), timer, boot (posixArgs), and root (source: {kernel: "directory"}). No process_spawnertest/[ are in-process builtins, no fork/exec; the > /alpha redirect is dash’s own open(O_CREAT) over the root Directory (already proven by system-posix-shell.cue). The dash -c script creates /alpha, then runs the six predicates with printf markers, using the && ... || ... form for -d /alpha and -e /nope so the negative-branch markers prove real discrimination.
  • tools/qemu-posix-test-builtin-smoke.sh + run-posix-test-builtin: a blind boot+sleep harness (dash feeds no fd 0, so no stdin handshake — same shape as run-posix-shell-smoke). The assert checks all six markers, the absence of the true-branch d=alpha-dir (a blanket-true test would emit it), the clean exit, and the scheduler halt.
  • Proof: make run-posix-test-builtine=yes, f=yes, d=alpha-notdir, root=dir, absent=yes, r=yes on the terminal UART, dash exit 0, scheduler halt. Non-tautological: each marker gates on a distinct stat result the script has no other source for; the d=alpha-notdir / absent=yes else-branches and the absent d=alpha-dir prove file/dir/absent discrimination, not a blanket-true builtin. Regression-clean: make run-posix-shell-smoke.

Remaining residual closed by Slice 20 below: cross-execve argv inheritance.

Slice 20: recording-shim execve argv inheritance — DONE (2026-06-07)

Closes the remaining recording-shim child-argv gap without changing the generated ProcessSpawner.spawn(name, binaryName, grants) surface:

  • libcapos-posix/src/process.rs: execve(path, argv, envp) snapshots the C argv vector before consuming the fork-recording window, rejects over-budget or malformed vectors before fd-action replay, writes a bounded binary argv record into a private kernel Pipe, and grants only the read end to the child as posix_argv. The existing full-fd-table inheritance path is unchanged: stdio_<N> grants still carry inherited fd backings, and direct posix_spawn() continues to accept but ignore argv/envp until a broader LaunchParameters design lands.
  • libcapos-posix/src/args.rs: posix_args() first looks for the posix_argv pipe grant and decodes it into the same process-lifetime char ** store used by manifest posixArgs; when the grant is absent it falls back to the manifest boot BootPackage path. The recording-shim payload is capped by the existing 4 KiB Pipe transport, so it is narrower than the manifest 8 KiB-total posixArgs channel but still uses the same 32-entry / bounded C-string shape.
  • demos/posix-execve-inherit-* + tools/qemu-posix-execve-inherit-smoke.sh: the focused smoke now proves both sides: an over-budget argv vector is rejected with E2BIG before the recorded dup2 mutates the parent fd table, and the successful child prints inherited argv[0..2] before listing the inherited Directory entries.
  • demos/grep-shim/main.c + run-posix-shell-smoke: grep-shim now calls posix_args() and uses argv[1] as the filter pattern. Its fallback does not match the corpus, so the existing cat foo | grep bar shell proof now depends on /grep-shim bar crossing the recording-shim execve boundary.

Proofs: cargo build --features qemu, make run-posix-execve-inherit-smoke, make run-posix-shell-smoke.

Conflict Surface Coordination

P1.4 does not touch kernel/src/cap/, kernel/src/sched.rs, or any device-driver foundation file. The schema half is limited to the optional posixEnv bounded text grant on initConfig.init (Slice 6); queue on the shared schema serial surface per docs/backlog/index.md Concurrency Notes when that slice dispatches. Every other slice is parallel-safe with the current selected milestone and DDF follow-up kernel surfaces because it avoids the kernel-core device-driver files.

Out of Scope for P1.4

  • Job control, real signal delivery, controlling terminals.
  • ulimit.
  • A userspace Store / Namespace service over a real backing store – that remains the next Phase 3 item in the storage proposal and is not required for the v0 dash smoke.
  • Real filesystem persistence (block device, virtio-blk, FAT).
  • A POSIX terminal line discipline owned by libcapos-posix – cooked-mode line discipline still lives kernel-side until networking proposal Phase C.
  • Hosted C++. Tracked separately in docs/proposals/userspace-binaries-proposal.md.

Success Criteria

  • make run-posix-shell-smoke exits cleanly under QEMU. A real dash runs a manifest dash -c script that drives the directory listing, the cat | grep pipeline, and an exec /ls-shim whose status (31) dash replaces-and-waits with (the poison tail after it never runs); the harness asserts the listing, the pipeline filter, dash’s exec-replace-and-wait status, the absent poison-tail markers, the clean-exit children, and the scheduler halt line.
  • The vendored dash source under vendor/dash/ is mirror-as-is at a pinned tag with a VENDORED_FROM.md and a patches/ directory whose cumulative diff vs upstream is < 50 lines.
  • libcapos-posix exposes the file / dir / stdio / env / printf / string / signal / time / identity surface listed above; the surface ships from headers under libcapos-posix/include/capos/posix/ with no dependency on an external libc.
  • make workflow-check, make fmt-check, make generated-code-check, cargo test-config, cargo test-lib, cargo build-demos-capos, make capos-rt-check, make run-smoke, make run-c-hello, make run-posix-dns-smoke, and make run-posix-pipe-smoke all remain green.
  • The proposal stamps the phase closeout with merge SHA and a minute-precision timestamp.