Develop marsh from marsh
Status: implemented. self-dev is the end-to-end regress suite; its checks are described below. Run it against the candidate being qualified.
In a dev session at the checkout's Mac path, you can edit marsh, build a Linux candidate, and run it. The candidate's stock-SBX calls pass through a host-daemon broker restricted to the dev shell's grant.
make dev # Mac: build everything, install ~/.marsh-dev
~/.marsh-dev/bin/marsh --dev # Mac, in the checkout
DEV_KIT=<fixture-ref> make dev-inner # in the dev shell
make dev-inner-run CMD="fixture identity" # inner candidate via the broker
To work on marsh with Pi, after make dev, from the Mac checkout:
~/.marsh-dev/bin/marsh --dev -c pi-dev
make dev incrementally builds the host binaries, Linux arm64 guest, shell template with dev tools, and packaged source Kits in parallel. The Kit staging script (scripts/stage-kits.py) combines each Kit's Git files with the canonical image-base/, dhi-notices/, and collect-notices.mjs inputs used by the publisher, so stock SBX can build the staged sources. Installation writes bin/ and libexec/marsh/ under DEV_PREFIX (default ~/.marsh-dev).
The install then runs marshd --prebuild-kit-images DEV_PREFIX/libexec/marsh. The daemon's Buildx path builds each packaged Kit's job image into the owner-only cache at ~/Library/Caches/marsh/kit-images (MARSH_KIT_IMAGE_CACHE overrides), which is never guest-mounted. Builds are keyed by source fingerprint and skipped when unchanged; DEV_KIT_IMAGES=0 opts out. Any daemon for the account can load a cached archive into a cold Kit VM, checking it against stock SBX's outer image digest and the loaded image ID. A mismatch evicts the entry. Cache writes use atomic rename, and retention keeps the 24 most recently used entries, with none unused for 14 days.
Published OCI-digest Kit images share the cache, keyed by digest. After a cold VM pulls one, the daemon saves it in the background with docker image save, under a marsh-<digest> tag in the same repository so it loads with a name. The next cold VM loads the archive instead of pulling, accepting it only when the pinned repository@sha256: reference resolves to that digest in the VM's content-addressed image store. A mismatch evicts the entry and pulls the image.
Stock SBX's own Kit builder cache is not warmed because its only public entry is a real sbx create. The install must be outside the checkout: the daemon refuses guest mounts that overlap its artifacts, and make dev rejects a DEV_PREFIX inside the checkout.
libexec/marsh/dev-enabled (an empty marker make dev writes) is what turns --dev on: a daemon whose install carries it admits dev grants. make install ships no such file, so a production daemon still needs MARSH_ENABLE_DEV_SCOPES=1. The gate is install-time rather than per-launch: the dev install is a separate prefix the operator built for development, and the grant is still opt-in per session (--dev). --dev only enables the broker: there is no separate dev image.
Dev shell
marsh --dev attaches to one warm dev shell VM per daemon (vm_name(Shell, "dev"), kept apart from the ordinary shell VM because it holds the broker grant) made from the one shell template (libexec/marsh/shell-image, built from packaging/shell): the DHI Debian base with gcc/make/git/python3/node/npm and Docker Engine installed, plus Rust 1.95 with the aarch64 gnu and musl targets, plus pi-dev (see Pi in the dev shell). Ordinary shells have the same tools. Its VM-local cargo target survives sessions. Kit jobs (ACP agents included) still get no daemon socket or relay. Grants exist only on a dev install's daemon or one started with MARSH_ENABLE_DEV_SCOPES=1; --dev is not tied to a particular home. The trust domain is the shell VM. A process in that VM can exercise the grant; a registered Kit job has only its job capability socket and cannot use the dev relay.
Reaching the host: the existing relay + an sbx shim
PublicRequest::DevSbx { argv, pty, cwd },RequestAuthority::RelayDevelopment. The session is the relay token's owner, never a payload field. After admission the connection streams stdin (credited), stdout, stderr, and exit frames (dev_broker/stream.rs).cwdlets the daemon'ssbx mount VM .(mount from inside the admitted source) resolve the same way on the host.- At attach the host copies the outer build's guest
marshtoS/tmp/bin/sbxand writesS/tmp/bin/sbx-relay.json(relay socket and token paths). Invoked assbx, the binary is the shim (crates/marsh/src/sbx_shim.rs). The host owns the protocol, so a candidate cannot break its own broker hop. The sidecar matters because daemons runsbxwith a cleared environment. - The shell gets
PATH=S/tmp/bin:…,MARSH_DEV_SCRATCH,MARSH_DEV_DEPTH,MARSH_VM_PREFIX,MARSH_DEV_SHELL_TEMPLATE(the host shell template). - The inner candidate runs with
MARSH_SBX=S/tmp/bin/sbxthrough the unmodifiedStockSbxSystemCommandRunnerpath.MARSH_VM_PREFIXmakes it name VMs${prefix}{s,k}-<8>.
The grant
Created at attach (DevBroker::create_grant), bound to the relay session, persisted in the ownership map file under grants: {session, prefix, roots, scratch, max_vms=8, revoked, cleanup_uncertain, names: name -> uuid | null(intent)}.
- Names. Prefix
${MARSH_VM_PREFIX:-marsh-}x<5 base36>-.create --name Nandrun --name Nneed^prefix(x[0-9a-z]{5}-){0,2}[skr]-[0-9a-z]{8}$; N is persisted as an intent before the call and adopted by name on the next inventory (also instore_view), or dropped. Arunwithout--nameis given${prefix}r-<8>by the broker, so stock never picks its<agent>-<workdir>default or reattaches to a host VM. - Existing VMs. An op needs N in this grant's
nameswith its recorded UUID in the cached inventory (one refresh on mismatch). Host names, other grants' names, and foreign VMs are refused before any stock call. - Roots. The pinned project and
S = ~/Library/Caches/marsh/dev/<sha256(project)[..16]>/(MARSH_DEV_CACHE_ROOToverrides). Leaveshome control tmp artifacts cacheare mounted at the same paths. One live grant per project. Sources must be normalized descendants of a root, resolve to themselves (no symlink), with identity checked before and after the stock call; a mismatch unmounts and revokes. The reopen insidesbx mountstays model A4's gap.
| verb | allowed (own VM unless noted) | refused |
|---|---|---|
version, --help, create --help | pass-through | |
ls [--json] | rows filtered to this grant | other rows |
create | name rule; --quiet --pull --skills off|readonly --cpus -m --memory --deny-network, -e K=V; --template = the host shell template (tag or tag@digest; a local template reaches stock as its tag, and the created VM's image digest must match or the VM is removed and the call fails; after a run session, the grant is revoked); agent shell, oci://…@sha256:…, or a Kit dir under roots; workspaces under roots | --env-file, bare -e K, --kit, -p, --clone, --cloud, --skills readwrite, built-in agents (use run), other options, over max_vms |
exec | -i -t -u -w --detach-keys, -e K=V | --env-file, bare -e K, --privileged, other options |
mount, umount | HOST[:CTR[:ro|rw]], HOST (relative to cwd) under roots | anything else |
cp | host → own VM, SRC under roots | VM → host, -L |
run | new child (name rule, counts toward max_vms) or --name of an own VM (reattach); -d --pull --skills off|readonly --cpus -m --memory --deny-network, -e K=V, -t/--template = host template; agent = a stock built-in (claude codex shell …), oci://…@sha256:…, or a Kit dir under roots; workspaces (:ro/:rw) under roots; no workspace = the caller's cwd, which must be a host path under roots (normally the natural project path); everything after -- passes to the agent | --cloud, --clone, --kit*, --env-file, bare -e K, --profile, -p, --static-mcp, --skills readwrite, cloud-only flags, bare non-agent names (host sandbox names, registry refs, git URLs), other options |
stop, rm [--force], inspect [--json] | own VM | template mcp policy secret ports … |
Terminals. The shim sends the caller's terminal size when its stdin and stdout are terminals. The host gives the stock process a PTY for exec -t and an attached run (not -d), and says so in the start frame; the shim then puts its terminal in raw mode (restored on exit) and forwards size changes. Keys such as Ctrl-C reach the agent through the PTY. A built-in agent runs with stock's own proxy-managed credentials, as in any sbx run; none enter the grant.
Revocation (revoke_session at the end of every --dev shell, including relay loss; revoke_all at marsh stop/marsh reset and at daemon start): persist revoked → fence and wait for in-flight calls (killed and reaped by the stream supervisor) → fresh inventory (adopts sent intents) → rm --force names whose recorded UUID is present → verify absence → delete S/{home,control,tmp} → drop the record. Failure sets cleanup_uncertain and is retried at the next start. Nothing is replayed. S/artifacts and S/cache persist.
Nesting. The inner daemon runs the same broker for its own --dev child (prefix ${prefix}x<5>-, scratch under S/tmp/dev), and its shim reaches stock through the outer shim. The host checks only the outer grant: the name pattern caps depth, roots admit descendants, max_vms counts the subtree. Daemons refuse --dev at MARSH_DEV_DEPTH ≥ 3. Streams nest one hop per level; mind the relay's 16-connection cap.
Inner build/run
make dev-inner(Linux, inside--dev): release gnu build ofmarsh marsh-local marsh-relay marshdand static muslmarsh-worker marsh-byte-execwith a VM-localCARGO_TARGET_DIR, installed toS/artifacts/{bin,libexec/marsh}withcommands.json(plusfixture=DEV_KITwhen set),agents.json,kits/, andshell-image= the host template. The Mactarget/is never written.make dev-inner-run CMD=…runs the inner client withMARSH_HOME=S/home,MARSH_CONTROL_HOME=S/control,TMPDIR=S/tmp,MARSH_SBX=S/tmp/bin/sbx, relay variables unset.DEV_INNER_FLAGS=--devopens a depth-2 dev child.make dev-inner-stopstops it.
End-to-end checks
python3 tests/acceptance/self_dev.py --sbx $(command -v sbx) --kit <fixture-ref> [--prefix ~/.marsh-dev] [--depth2|--depth3] [--relay-kill|--only-relay-kill]: runs the make dev install with a dedicated home/control/cache (no dev variables), records an sbx ls baseline, runs marsh --dev -c 'make dev-inner && make dev-inner-run CMD="fixture identity"' plus refusal probes (exec into the dev shell VM and a foreign VM, mount ~/.ssh, symlink escape under S/tmp, create --env-file, a non-prefixed name, template, cp VM → host, run --cloud, run with ~/.ssh, run --name of a host VM) and sbx run -d shell without --name. It asserts the r- child under the prefix and that sbx exec in it sees the project at its natural path, the identity output, receipts in S/control, prefixed child VMs, then after exit: no prefixed VM, no grant record, no S/{home,control,tmp}, artifacts kept, baseline VMs unchanged, and no new VM after the outer marsh stop.
--depth3 adds, inside the depth-2 shell, a depth-3 --dev child opened by the depth-2 inner daemon: MARSH_DEV_DEPTH=3, sbx ls, a child VM created/exec'd/removed three broker hops from stock, a foreign exec refused, a third make dev-inner, and its inner daemon (MARSH_DEV_DEPTH=3) refusing a depth-4 --dev. --relay-kill opens a second session that kills its relay (pkill -x marsh-relay) during a brokered sbx exec: the call fails visibly (125, "dev broker stream lost"), later sbx calls fail, session end revokes the grant and removes its child VM, no host sbx exec is left running, and the shell VM is cleanup-uncertain until marsh reset, after which a new --dev session works.
Pi in the dev shell
The shell image installs the Pi coding agent from the exact npm lock in packaging/shell/pi (@earendil-works/pi-coding-agent; regenerate the lock with npm install --package-lock-only --min-release-age=0 if a local npm enforces a minimum release age, and keep resolved URLs on registry.npmjs.org) and a pi-dev launcher. Pi runs in the dev shell VM, so its bash tool runs there too, with the session's PATH (the sbx shim, registered-command shims, Rust) and environment: make dev-inner, make dev-inner-run and the broker behave as they do for the operator. Pi's tool shell is the VM's /bin/bash (Pi's own choice), not Brush. In the session pi is the registered pi Kit command (a job container that cannot reach the broker); use pi-dev.
- Credentials. Stock SBX gives every shell VM proxy-managed sentinels for the stored global service secrets (
sbx secret set -g anthropic;SBX_CRED_ANTHROPIC_MODE=apikey) and injects the real key at its proxy. No credential enters the image, the scratch, or marsh. - State.
PI_CODING_AGENT_DIR=S/cache/pi-dev/agent(survives--devsessions; outside--dev,$HOME/.pi-dev/agent). The first run seedssettings.json(Anthropicclaude-opus-5-5, install telemetry off); after that it is the operator's file. - Not imported. No host agent profile, extensions, themes or skills. A personal agent built on Pi belongs in its own Kit (plan).