Workspaces acceptance scenarios
Black-box scenarios for workspaces, with section references written as sN. Harness: tests/acceptance/workspaces_uat.py, run by make regress ONLY=workspaces (or ONLY=W01-plain-bash) against ~/.marsh-dev (--prefix). There are no unit tests or mocks. Callers are host bash, marsh -c sessions, the host CLI, and registered fixture Kit commands. Inside Kit jobs the caller is the shipped marsh shim, driven by the fixture's argv-only pipeline SEP ARGV [SEP ARGV]... mode. That mode turns %%% into ::: one level at a time, because a job argument cannot be the literal :::. An older fixture without pipeline cannot run W17–W21. Use a current immutable fixture reference for release qualification; a local source fixture is an authoring option.
The harness observes only stdout/stderr bytes, exit statuses, PIPESTATUS, the user's tree, and the user's .git: index bytes and mtime, HEAD, refs, packed-refs, object list, config, and hooks. It also reads out/ artifacts, public JSON (splits, status, jobs show), and stock sbx exec <owned vm> docker inspect. Each run is isolated in its own MARSH_HOME and control home. Cleanup removes only VMs in its own ownership map whose stable IDs it has seen (harness.py IsolatedScope). Every subprocess has a timeout and is killed by process group. A preflight checks marsh --help, which needs no VM, then splits --json, split --help, and join --help. If the CLI is missing, it marks every scenario blocked before any VM work. Without the preflight, an unknown marsh split runs as a Brush script and boots a shell VM. Use --no-preflight to skip it, --only ID (repeatable) to select scenarios, and --fail-fast to stop at the first failure. Evidence goes to <evidence>/workspaces.json.
| ID | Spec claim | Setup / action | Pass criteria |
|---|---|---|---|
| W01 plain-bash | s2 primary CLI; spool; argv verbatim; SPLIT_*; release | host bash: printf 'in\0put\n' | marsh split -b sh=cat ::: k fixture streams 'a b' '$x' '' '*' | marsh join -- sh -c 'copy out/' | PIPESTATUS 0 23 0; exact out/{sh,k}/{stdout,stderr,status} bytes; SPLIT_ID/DIR/MANIFEST/OBJECTS, with SPLIT_DIR=<root>/.marsh/split/<id>/out; manifest v2 with placement shell-vm and kit:*; split removed (CMD 0); user state unchanged; Kit container verified deleted |
| W02 lease-kept-joinable | s6 lease; s2 two-step | session: h=$(marsh split ... </dev/null); sleep 68 s; marsh join -- CMD <<<"$h" | splits --json state kept after the lease; join works and CMD sees A\ta.txt; consumed(0) removes |
| W03 snapshot-fidelity | s1 snapshot S/H/I; s8 gitfile; ignores | main repo with staged, unstaged, deleted, untracked, and ignored edits; -b g='git status; git diff --cached; rev-parse; ls -A' | fork status, cached diff, and HEAD equal the user's; ignored secret.log and .marsh absent; .git is a file; user index bytes and mtime, refs, objects, and config untouched |
| W04 snapshot-consistency | s8 M2 frozen base | branch waits on a gate; the user then edits u.txt, adds late.txt, and stages | branch reads one and no late.txt; no diff.patch or files; the user's edits and index survive |
| W05 patch-roundtrip | s5 layout; s8 patch writer (literal binary, renames as D+A) | shell branch: binary append, chmod +x, symlink retarget, file to symlink, delete, add binary, add in new dir, no-EOL, non-ASCII path with space; Kit chmod and Kit symlink; join -- git apply each diff.patch | user tree equals the expected bytes, exec bits, and link targets; files sets exact (M, T, D, A); user .git/index unchanged; the rendering shows binary file changed: PATH (N bytes), never GIT binary patch |
| W06 non-git | s1 (S without H/I); s8 confinement | plain dir with shell edits, a Kit write, a Kit absolute write; join -- git apply | patches apply, including binary; escape file absent; no .git created; no .marsh/.gitignore |
| W07 status-pipefail | s2 declaration order; pipefail | -b a='sleep 2; exit 3' -b b='exit 5' with join -- true / join -- exit 4, with and without pipefail | p1=3 3 0, p2=4 3 4, p3=0 3 0; join -- true tolerates CMD not reading stdin |
| W08 join-after-failure | s2 CMD runs after failure; s2 sugar | -b bad='...; exit 4' then join -- CMD; Brush printf in | split { a: cat; b: exit 3 } | join | {...} | CMD gets the rendering with statuses and out/bad/{status,stderr}; CMD 0 removes the split; sugar PIPESTATUS 0 3 N 0; $SPLIT_DIR/a/stdout; SPLIT_ID unset afterwards |
| W09 retention | s2 release; s6; s5 status counts | four splits: bare/ok, bare/failed, --keep, -- false | first one removed; the other three are kept in splits --json; status --json kept count ≥ 3; splits rm removes each |
| W10 environment | s2 env; s2 no tty | host: credential-shaped names (*_KEY*, *TOKEN, *PASSWORD, *_PAT, *SECRET, *CREDENTIALS, AWS_*) never reach a branch; exported, unexported, MARSH_*, DOCKER_HOST, SBX_*; session: function, unexported var, set -f | only the exported value arrives; /dev/tty fails; no function or set option inherited |
| W11 kit-confinement | s8 mounts (NoWriteToUserTree, SnapshotImmutable, AdminReadOnly); s4 no sockets; s5 lineage | Kit branches write absolute <proj>/x, ../../../../x, .git/x, .git/objects/x, ../store.git/x; plus ok, authority, identity | escapes fail and nothing appears; ok = A\tinside.txt; no sockets or authority env; cwd = fork; each receipt's mounts ⊇ the s8 table (admin dir read-write, its config/HEAD/info/packed-refs and the gitfile read-only) and no other user-tree path; store and user objects read-only; receipts carry lineage; containers deleted |
| W12 admin-tamper | s8 capture verification; s11 bullet 4 | Kit writes ../.admin/lk/COMMIT_EDITMSG (must succeed: Git state stays writable) and writes ../.admin/L/{config,HEAD,info/exclude} (fsmonitor and hooksPath to real host scripts) and replaces the .git gitfile; join --keep; host git -C <fork> status and commit | each branch is rejected: or failed with EROFS/EBUSY/EACCES (not ENOENT); fork config has no core.fsmonitor/hooksPath; marker files absent. A control repo first proves host git runs fsmonitor |
| W13 nested-dotgit | s8 no nested .git at any depth (case-insensitive APFS) | Kit writes src/.git and src/.GiT gitfiles that point at a repo with an fsmonitor marker | both rejected: and fork removed; sibling branch exited 0; marker absent |
| W14 hostile-user-repo | s8 metadata read as data, no host Git execution; s11 bullet 5 | user repo config: fsmonitor, required clean/smudge filter, diff.external, hooksPath, include.path; dirty file | split and join succeed; no marker from any of them |
| W15 label-and-argv-refusal | s2 labels; s2 registered CMD; s7 all-or-nothing | ../x, a/b, A+a, duplicate, out, Base, .admin, ::: ../y, ::: k /bin/sh -c, ::: k bash -c | each exits 2 with a diagnostic; marker absent; no new split dir; no Kit job |
| W16 caps-and-pool | s4 fan-out cap 16; pool of 8 (BudgetBound) | 17 branches; then 9 × sleep 6 with join -- CMD | 17 gives 2 and nothing runs; 9 gives exactly one failed: capacity and 8 exited 0, both visible in the rendering, and CMD runs |
| W17 nested-from-kit | s2 shim; s3 creator-only join; s1 sibling forks; s5 lineage | session: ::: p fixture pipeline | marsh split %%% c ... | tee /dev/stderr | marsh join --keep; then the session joins the child handle | child created and kept; the ancestor's join exits nonzero and its CMD never runs; child lineage names the parent; child out/ is a sibling under .marsh/split/; c.txt absent from the user tree; receipts verified deleted |
| W18 depth-cap | s4 depth 3 | d1 -> d2 -> d3 nested through pipeline; d3 attempts the depth-4 split | root exits 2 and out/d1/status = exited 2; "depth" visible; exactly 3 Kit jobs (d4 never runs) |
| W19 interrupt-subtree | s2 signals; s7 cancel | host split: shell sleep 15; touch late + Kit hold + nested Kit hold; SIGINT to the process group | exits 130 within 25 s with no handle; a later join refuses and its CMD does not run; late never appears; all 3 containers verified deleted; the confirmed-cancelled split directories are removed |
| W20 creator-exit-cascade | s4 revoke on exit; s7 | p: pipeline | --exit-after 20 marsh split %%% c fixture hold 600 | p = exited 7; child split cancelled or done; both containers deleted; finishes in under 120 s |
| W21 daemon-restart | s6 restart; NoReplay; s7 quarantine | shell + Kit hold running; authenticated daemon shutdown; next command restarts the daemon | client exits nonzero; split uncertain, forks flagged untrusted and retained; no Kit job is created after the restart; workers reset fixture succeeds |
| W22 git-clean-replaced | s6 (dev, ino) check; fd-based removal | branch gated; user runs git clean -xfd; recreates <id>/w/sentinel | split nonzero; workspace replaced reported; sentinel survives join and splits rm |
| W23 non-creator-join | s2/s3 creator only | session A creates; session B runs join -- touch m | B nonzero; marker absent; split not released |
| W25 planted-symlinks | s13 descriptor-only host I/O | split running; an ordinary Kit job tries project-symlink out/a/stdout -> <host file>; the host plants out/a/{stdout,status} symlinks | the Kit job fails (.marsh is read-only to it); the host file is unchanged after capture and splits rm; tampered is recorded |
| W26 nested-repository | s8 nested .git pruned at snapshot | user tree holds a nested repository vendor/lib | split exits 0; the fork lists no .git there; files = A\ta.txt; the user's nested repository is intact |
| W27 sugar-interrupt | s2 signals for the Brush sugar | split { a: ...; sleep 30; touch late } | join | touch joined, SIGINT | exits within 25 s with 130; neither late nor joined appears; the split directory is removed |
| W28 quarantine-retire | s7 quarantine; operator retire by recorded UUID | (1) stop the Kit VM under fixture hold; workers reset fixture. (2) a shell that ran fixture loses its VM; workers reset all, then reset. (3) Kit quarantine; reset. (4) shell loss; stop | every command exits 0, prints what it removed (or the cause), and the VMs are gone from sbx ls |
| W29 doc-examples | public documentation examples as written | every doc-test-marked example in README.md, docs/*.md (except agents.md, which P31 covers), docs/guides/*.md, the man page source, and the site's LLM introduction (tests/acceptance/doc_examples.py), from host bash and/or marsh -c, in a fresh repo or plain dir; an example with a control= marker runs again with that text replaced by true | statuses and output as marked; every control run fails (the docs/split.md three-way merge recipe fails without GIT_ALTERNATE_OBJECT_DIRECTORIES="$SPLIT_OBJECTS", because the split's starting version is not in the original object store); status text shows attached shells and split counts; tee /dev/stderr works in a session; --help lists no flight |
| W30 capture-ignored | s8 capture of ignored output, rendering, phase times | Git repo with .gitignore = __pycache__/, *.log; a shell branch runs python3 -c 'import x', writes build/__pycache__/y.pyc and run.log, adds notes.txt to .gitignore, edits notes.txt, appends to blob.bin; join --timing -- CMD | the branch made a .pyc; files = M .gitignore, M notes.txt, M blob.bin; no diff --git names a .pyc or run.log; the rendering shows binary file changed: blob.bin (5 bytes; full patch in $SPLIT_DIR/py/diff.patch); stderr has timing: snapshot …; forks …; run …; capture py …; consumer … |
| W24 performance | proposed budget (spec states none) | 10k-file committed repo, warm; median of 3 for split -b a=true | join vs marsh -c true | overhead ≤ 1.5 s |
Specified details
Creator identity (host CLI callers share the host creator; W23 checks two distinct marsh -c session creators), JSON shapes, root depth 1, and the cancelled state are specified in workspaces.md, section 12.