# 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` `CapObject`s 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, <timestamp>)" 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.init` in
  `schema/capos.capnp`. This is the only P1.4 schema touch; queue on
  the shared schema serial surface per
  `docs/backlog/index.md` Concurrency 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-`noreturn`s `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 `break`s
  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 CMDBUILTIN` — *bypassing* 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 0`s, 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 `read`s
  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_spawner` — `test`/`[` 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-builtin` — `e=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.
