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
TerminalSessioncap for stdio, - a read-only bootstrap-granted
Directorycap rooted at a tiny in-rodata pseudo-fs (the resolver remainsNamespace-shaped for forward parity; the v0 manifest grants aDirectorybecause that is what Storage Phase 3 slice 2 ships as a kernelCapObject), - a
ProcessSpawnernarrowed to one allowed binary (ls-shim), - and a
Timercap.
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 ofcapos-rt,_startshim, fixed heap,malloc/free/calloc/realloc,console_write_line. - P1.2 UDP + DNS resolver smoke (
2026-05-05 21:21 UTC):libcapos-posixerrno TLS cell,clock_gettime/gettimeofdayoverTimer, fd-table dispatch shape,__errno_location(). - P1.3 Pipe + recording-shim fork-for-exec (
2026-05-07 09:55 UTC, fix-slice through05b528732026-05-07 21:07 UTC): kernelPipecap,ProcessSpawner.createPipe, fd-tableFdBacking::Pipe, recording-shimfork/execve/waitpid/_exit, directposix_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
d06dff6bat2026-05-14 19:31 UTC, slice 2b11ec9e4at2026-05-14 22:30 UTC, slice 3804a3f41at2026-05-14 23:23 UTC): RAM-backedFile/Directory/Store/NamespaceCapObjects withKernelCapSource::file/directory/store/namespacegrant 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 oninitConfig.init(wasiEnv :Text). The dash port mirrors this for its env vector. setjmp/longjmpprecursor (libc-setjmp-longjmp,2026-05-25 21:11 UTC): the x86_64 SysVsetjmp/longjmpC-ABI primitive plusjmp_bufand a<setjmp.h>header. This was absent from the original P1.4 surface table, but dash’s exception/interpreter control flow is built onsetjmp/longjmpover a realjmp_buf(pervasive inerror.h/main.c/eval.c/parser.c/trap.c/ …), so it is a hard precursor for the dash build pipeline and shell smoke. Implemented inlibcapos/src/setjmp.rs(global_asm), exposed throughlibcapos-posix/include/capos/posix/setjmp.h, and proven in QEMU viamake run-posix-setjmp(direct call returns 0, alongjmpfrom a deep recursion resumessetjmpwith the passed value, andlongjmp(env, 0)returns 1). Nosigsetjmp/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/shposture. - §7: confirm
TerminalSessionas the canonical fd 0 / 1 / 2 backing for the v0 smoke. AnFdBacking::Terminalvariant inlibcapos-posix/src/fd.rsplusposix_inherit_stdio()adoption is the implementation shape.
Promotion = strike the “Working answer” phrasing in the proposal,
replace with “Decided (P1.4 Slice 1,
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,closeover theFileinterface methods.DirectoryClient:open,list,mkdir,remove,subover theDirectoryinterface methods, returning typedFileClient/DirectoryClientprojections 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 kernelFilecap (the schema-level read/write take an explicit offset).FdBacking::Directory { client: DirectoryClient, iter: ... }– iteration state forreaddir.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
Directorycap. - Walk
Directory.sub()for each prefix segment; mint a leafFile/Directorycap directly withDirectory.open()/Directory.sub(). - A v0 per-process cwd string landed (
libcapos-posix/src/cwd.rs,make run-posix-cwd):getcwd/chdirplus cwd-relative resolution foropen/opendir/stat/access/unlink/mkdir.chdirvalidates 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 kernelDirectorycap’s lack of a parent edge, not a resolver clamp. - Returns typed
File/Directoryresult 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 oninitConfig.initinschema/capos.capnp. This is the only P1.4 schema touch; queue on the shared schema serial surface perdocs/backlog/index.mdConcurrency Notes when selected. Regenerate the checked-in capnp bindings;make generated-code-check` must pass.
- Add a bounded
- 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. LaunchParametersremains 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.hsubset:printf,fprintf(fd 1 / fd 2 only),vprintf,vfprintf,snprintf,vsnprintf,putchar,puts,fputs,fputc. Nofopen/FILE *– those route through the fd-table surface.string.hsubset:memcpy,memmove,memset,memcmp,strlen,strcmp,strncmp,strchr,strrchr,strcpy,strncpy,strcat,strncat,strdup.stdlib.hsubset:atoi,strtol,strtoul. Process termination still uses the existing libcapos_exitpath;exit/abortstay outside this focused printf/string slice.ctype.hsubset: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. Addvendor/dash/VENDORED_FROM.mdrecording the upstream URL, commit, tag, and refresh procedure (mirror the existingvendor/dns-c-wahern/VENDORED_FROM.mdshape). - 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 tochild. Patches live undervendor/dash/patches/with one.patchper call site; the cumulative diff against upstream is < 50 lines. - Inter-call
dup2/closebetween fork and execve already records throughlibcapos-posixand needs no per-call patching. - Carried into Slices 12-13: patch
0001de-noreturnsshellexec(), so the two no-fork exec-replace callers (src/eval.cevalcommand()EV_EXITpath andexeccmd(), each with/* NOTREACHED */) now fall through under the recording shim. A single non-interactive command (dash -c '/bin/echo hi') takes theEV_EXITpath, notvforkexec(). Slice 12/13 must disable theEV_EXITin-place-exec optimization under the recording shim (fork-exec-then-exit) or add an exec-replace-then-exit patch before the binary runs. Details invendor/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
.cfiles, - 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_CREATE0) 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, needstoken.hon its compile include path),mknodes(nodes.c/nodes.h),mksignames(signames.c),mkbuiltinsover a preprocessedbuiltins.def(builtins.c/builtins.h), andmkinitover the 27-file TU list (init.c).mkinitandmkbuiltinstake 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 thecapos/posix/*source of truth. Consumed with-nostdinc- four
-isystemroots (clang freestanding builtins, the sysroot, and the two capOS namespaces) via the existingcapos-c-multitu-elfrule (CAPOS_C_SYSROOT_INCLUDEin the Makefile). The focused proof ismake run-c-libc-surface(qsort / strerror / umask / strtoll / strstr /S_IS*, all through bare includes).
- four
- Surface. The inventory plus several items the original table understated:
the C/POSIX-locale multibyte layer
expand.cneeds unconditionally (mbrtowc/mbrlen/mbsrtowcs/wcschr/wctype/iswctype+ theisw*family,<wchar.h>/<wctype.h>),strpbrk,lstat,getgroups,wait3,vfork,htonl/htons/ntohl/ntohs(used bybltin/printf.c), theS_IS*file-type macros,DT_LNK/…,environ, and thesys_siglistarray dash’s ownstrsignalreads.
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 withopendir/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, importscapos.local/cue/defaults) grantingTerminalSession, a read-onlyDirectoryover an in-rodata pseudo- fs containing exactly the entries the heredoc references, aProcessSpawnernarrowed tols-shim, and aTimer.Makefilevendor-dash,libcapos-posix-shell,manifest-posix-shell.bin,capos-posix-shell.iso, andrun-posix-shell-smoketargets.tools/qemu-posix-shell-smoke.shhost harness: pipels; echo doneheredoc into fd 0, assertdone, 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 containsbar).demos/grep-shim/main.c: reads stdin line by line, writes lines containingargv[1]to stdout. The initial Slice 14 proof bakedbaras a compile-time fallback because child argv did not yet cross the recording-shimexecveboundary; Slice 20 below now seeds grep-shim through the privateposix_argvpipe, and the fallback no longer matches the corpus. The shell smoke therefore fails if/grep-shim bardoes not deliverbaras argv.system-posix-shell.cue: both shims asProcessSpawnertargets.tools/qemu-posix-shell-smoke.sh: assertsmatch bar herereaches 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()’sEV_EXITin-placeshellexec()stashes the synthetic pid (capos_exec_pid) andbreaks instead of faulting throughcase CMDBUILTIN.evaltree()suppresses itsEV_EXITexraise(EXEND)while that pid is pending.evalpipe()’s child arm callsevaltree()(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-exportedforkparent(), re-suppressing interrupts to balance the child-armINTON.forkshell()setsvforkedaroundfork()/forkchild()so the recording-shim “child” does notfreejob()the pipeline job. The cumulative dash patch budget was raised past 50 lines for this; seevendor/dash/VENDORED_FROM.mdfor the decision and the remaining residuals (the single trailing external command residual is closed by Slice 16 below, and theexec foobuiltin residual by Slice 17; compound pipeline elements remain unsupported under the recording shim).libcapos-posixwildcardwaitpid(-1)/wait3. dash reaps pipeline children withwait3->waitpid(-1); the v0 surface now reaps any tracked child (blocking) and honorsWNOHANGas “no child ready”, which is allwaitforjob->dowaitneeds.
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-terminatedchar **built frominitConfig.init.posixArgs(aCueValuetext list), read off the grantedbootBootPackage cap. It mirrorscapos-wasm/src/payload.rs::read_wasi_argsand reuses theposixEnvblob-streaming/CueValuehelpers (Slice 6, promoted topub(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 Cmain(argc, argv)trampoline inlibcapos/src/entry.rsis untouched (still0, NULL), so bare-name demos are unaffected.- dash patch
0003: whenargv == 0, pullposix_args()and use it forprocargs()when non-empty, keeping the{"sh", 0}fallback. A manifest seedingposixArgs: ["dash", "-c", "CMD"]now reachesevalstring(minusc, ...), sodash -cis invokable. - Proof:
make run-posix-args-smoke(manifest seeds["posix-args-smoke", "alpha", "beta"]; the C process printsargc=3and eachargv[i]). Regression:make run-posix-shell-smoke(theargv==0fallback 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’scapos_exec_pidguard left the trailing child orphaned-but-spawned. In fact dash’sEV_EXIToptimization execs in place without forking, and the recording shim only spawns from anexecve()inside afork()-opened record window — so an unforked top-level in-placeshellexec()returnsENOSYS(process.rs::execvewithrecording.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): acapos_pipe_armcounter, raised byevalpipe()around its child-armevaltree()call, gates the in-placeEV_EXITshellexec()to pipeline arms only (capos_pipe_arm > 0, whereforkshell()already opened the window). A top-levelEV_EXITcommand (capos_pipe_arm == 0) takes the forkingvforkexec()path, whose existingwaitforjob()blocks for the child and returns its status;evaltree()thenexraise(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 manifestposixArgsdash -cscript (granted thebootBootPackage 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-shimthat 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
0005did not cover it.execcmd()(eval.c) runs as aCMDBUILTIN(EXECCMD) throughevalbltin(), which is dispatched fromevalcommand()’scase CMDBUILTIN— bypassing thedefault:-caseEV_EXITfork gate0005added. So0005’scapos_pipe_armgate never sees theexecbuiltin; its in-placeshellexec()->execve()has nofork()-opened record window and returnsENOSYS, andexeccmd()ignores it andreturn 0s, continuing the script. - dash patch
0006-execcmd-fork-replace-wait.patch(eval.c): theargc > 1form ofexeccmd()now forks viavforkexec(NULL, argv + 1, pathval(), 0)(which opens the record window),waitforjob()s for the replacement child, setssavestatusto its status, andexraise(EXEXIT)s — the exact shell-exit channel theexitbuiltin (exitcmd) uses, soEXITRESETcopiessavestatusintoexitstatusand the shell exits with the replacement command’s status.n == NULLis safe (forkchild()early-returns undervforked;forkparent()readsnonly whenjobctlis set, never in a non-interactivedash -c). The no-command form (exec 3< /,argc == 1) keeps itsreturn 0andpopredir()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-shimwithexec /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: gammamarkers 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 bufferedpgetc()->preadbuffer()->preadfd()->read(0, buf, BUFSIZ)path.input_init()keys the buffering mode offtcgetattr(0), which libcapos-posix synthesizes as canonical (c_lflag & ICANON), sostdin_bufferable()is true and dash takes the plainread()branch (not the Linuxtee()/splicehistory path, which would otherwise return a non-EINVALerror and be misread as EOF). libcapos-posixread()overFdBacking::Terminalreturns exactly one line plus a synthesized\nper call — the canonical-tty contract dash expects — so the two sequentialreads each consume one line. system-posix-read-builtin.cue: a focused manifest grantingterminal(TerminalSession),timer, andboot(forposixArgs), with thedash -cscriptprintf 'rb-ready\n'; read NAME; printf 'got=[%s]\n' "$NAME"; read -r RAW; printf 'raw=[%s]\n' "$RAW".printf %s(notecho) 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: thedrivestep handshakes rather than blind-sleeps. The kernel line discipline has no inter-read input buffer (it consumes UART bytes only while areadLineis pending) and the UART carries no EOF, so a line fed before userspace is draining is lost and the blockedreadhangs to the QEMU timeout.drivetails the terminal-UART log: feed line 1 after therb-readybanner, feed line 2 after thegot=[echo (so the two distinct lines never collide in the single shared UART FIFO), then hold stdin open until theraw=[echo. A byte arriving just before itsreadLineis posted is still caught byhandle_read_line()’s synchronous FIFO drain, so the banner/echo gates are a sufficient ordering guarantee.- Proof:
make run-posix-read-builtin— observedgot=[hello world]and the byte-preservedraw=[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(theexec/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.cfilstat()callsstat64(nm, &s)/lstat64(nm, &s)(capos/config.hmapsstat64->stat,lstat64->lstat) and switches onS_ISREG/S_ISDIR/FILEXIST;testcmd()registers bothtestand[(src/builtins.def.in).HAVE_FACCESSATis unset incapos/config.h, so-r/-w/-xroute throughfilstat()->test_access()(a dash-internal check on the stat result, no extra libc call); under capOS’s single-identity euid=0test_access(R_OK)short-circuits to true on any successful stat (before thest_moderead), sor=yesproves-rreached a real stat of/alpha, distinct from the absent-path miss, not the mode-bit comparison itself. libcapos-posixstat()(src/file.rs) resolves the path against the bootstrap rootDirectory, fillingS_IFREG|0644for files (write_file_stat) orS_IFDIR|0755for directories (write_dir_stat, incl. the root viais_root_path);lstat()andaccess()are landed too, and theS_IS*macros ship fromlibc-dash-sysroot-surface. system-posix-test-builtin.cue: a focused manifest grantingterminal(TerminalSession),timer,boot(posixArgs), androot(source: {kernel: "directory"}). Noprocess_spawner—test/[are in-process builtins, no fork/exec; the> /alpharedirect is dash’s own open(O_CREAT) over the root Directory (already proven bysystem-posix-shell.cue). Thedash -cscript creates/alpha, then runs the six predicates withprintfmarkers, using the&& ... || ...form for-d /alphaand-e /nopeso 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 asrun-posix-shell-smoke). Theassertchecks all six markers, the absence of the true-branchd=alpha-dir(a blanket-truetestwould 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=yeson 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; thed=alpha-notdir/absent=yeselse-branches and the absentd=alpha-dirprove 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 kernelPipe, and grants only the read end to the child asposix_argv. The existing full-fd-table inheritance path is unchanged:stdio_<N>grants still carry inherited fd backings, and directposix_spawn()continues to accept but ignore argv/envp until a broader LaunchParameters design lands.libcapos-posix/src/args.rs:posix_args()first looks for theposix_argvpipe grant and decodes it into the same process-lifetimechar **store used by manifestposixArgs; when the grant is absent it falls back to the manifestbootBootPackage path. The recording-shim payload is capped by the existing 4 KiB Pipe transport, so it is narrower than the manifest 8 KiB-totalposixArgschannel 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 withE2BIGbefore the recordeddup2mutates the parent fd table, and the successful child prints inheritedargv[0..2]before listing the inherited Directory entries.demos/grep-shim/main.c+run-posix-shell-smoke: grep-shim now callsposix_args()and usesargv[1]as the filter pattern. Its fallback does not match the corpus, so the existingcat foo | grep barshell proof now depends on/grep-shim barcrossing the recording-shimexecveboundary.
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/Namespaceservice 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-smokeexits cleanly under QEMU. A real dash runs a manifestdash -cscript that drives the directory listing, thecat | greppipeline, and anexec /ls-shimwhose 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 aVENDORED_FROM.mdand apatches/directory whose cumulative diff vs upstream is < 50 lines. libcapos-posixexposes the file / dir / stdio / env / printf / string / signal / time / identity surface listed above; the surface ships from headers underlibcapos-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, andmake run-posix-pipe-smokeall remain green.- The proposal stamps the phase closeout with merge SHA and a minute-precision timestamp.