ADR-0027: Backend-Neutral Disposable Primary Workspace (--clone)
September 11, 2026 · View on GitHub
Context and Problem Statement
On sbx, sbx create --clone runs the agent on a private in-container clone of
the host repository: the agent branches, commits, and experiments without
touching the host checkout, and finished work comes back through an explicit
git fetch sandbox-<name> on the host. On msb, workspaces are direct host
mounts (rw or :ro) — the agent edits the real checkout. For teams whose
workflow depends on a disposable primary, that gap is the reason they cannot
leave sbx (ADR-0011 pins it as
load-bearing).
Disposable working copies are the industry default for agent sandboxes: cloud
agents (Codex cloud, Cursor cloud agents, Google Jules) clone per task into a
fresh VM with an explicit git crossing back, and sandbox platforms bring code in
by clone or upload rather than mounting a host checkout rw. sbx's distinctive
contribution is cloning from the local host checkout (unpushed branches
included, no forge round-trip). That capability is worth neutral vocabulary
rather than remaining an sbx exclusive — the same trajectory as --image
(ADR-0022) and volumes:
(ADR-0023).
Decision Drivers
- One neutral knob —
--clone(orACQ_CLONE=1) works on both backends with identical UX: same flag, samesandbox-<name>fetch-back remote, same rm-time warning. - No upstream dependency — land without waiting for msb to grow a native clone feature; if it does, the adapter can switch behind the same flag.
- The crossing back must be inert data — recovery via
git fetchtransfers hash-verified objects only; hooks and config never cross (they are host-executed code paths). - The copy must be physical — a same-filesystem
git clonehardlinks object files by default; an agent with write access to a hardlinked scratch.gitcould modify inodes shared with the real repo's object store.
Considered Options
- Managed host-side scratch clone (msb emulation), sbx passthrough.
Chosen.
git clone --no-hardlinksinto acq-managed state, mounted rw as the primary; recovery and lifecycle mirror sbx exactly. - In-guest clone from an
:romount. Closer to sbx's placement (the working copy dies with the sandbox), but recovery is worse: the host cannot fetch from guest storage, so commits leave via push (blocked today by the msb:22egress gap, GSA-TTS/agentic-coding-quickstart#402) or via bundles copied out. Possible later refinement. - CoW overlay mount (OpenHands-style
:overlay). Perfect state fidelity including ignored files — but merge-back is diff-shaped rather than git-native, and ignored-state fidelity is exactly the contamination the git clone avoids. - Clone-from-forge in-guest (the Codex/Jules pattern). Works over HTTPS today, but loses uncommitted local branches and adds a forge round-trip — not a substitute for local-first workflows.
Decision Outcome
Chosen: Option 1. --clone becomes acq-owned neutral vocabulary, extracted
at dispatch (mirroring --image) and never forwarded raw to a backend CLI.
ACQ_CLONE=1 is the env equivalent; like --image, the option applies at
create only — a re-attach prints a note and ignores it.
Per-backend mapping
| Backend | Mechanism |
|---|---|
| sbx | re-injects the native sbx create --clone |
| msb | emulates: managed host-side scratch clone (below) |
msb emulation
At create, when --clone is requested:
- The primary workspace must be a git repository root (a
subdirectory or non-repo path fails the create before any backend call).
Secondaries are unchanged (
:roand direct mounts as before). git clone --no-hardlinksthe primary into$XDG_STATE_HOME/acq/clones/<sandbox>/<repo>(root overridable viaACQ_STATE_DIR/ACQ_MSB_CLONES_DIR).--no-hardlinksis load-bearing (see Decision Drivers).- Mount the scratch rw at the original workspace's absolute path in the
guest (verified msb 0.6.15 mounts
--volume src:dstwithsrc != dst), so the agent's starting directory, kit behavior, and docs are identical to a non-clone run. - Register a
sandbox-<name>remote in the host checkout pointing at the scratch dir;git fetch sandbox-<name>pulls agent branches back as hash-verified objects. acq rmwarns when the scratch holds commits absent from the host's object store, then deletes the scratch and removes the remote (same gone-after-remove-attempt rule as derived volumes, ADR-0023). A failedmsb createcleans up its own fresh scratch immediately.
Deliberate divergences from sbx (proposed as the better default)
A git clone carries committed state only:
- No gitignored/untracked files. sbx's
--clonecopies gitignored files, which is how host-side build state (e.g. a macOS-initialized Postgres cluster in.devenv/state) poisons sandboxes — a documented trap. Workflows that need a specific ignored file (.env) copy it in explicitly withacq cp; that doc story is now identical on both backends. - No uncommitted changes to tracked files. sbx's copy-based clone carries a
dirty working tree; the msb emulation does not. Create prints a notice when
the host tree is dirty: commit first, or
acq cpthe files in.
Unlike the two divergences above, two pieces of .git/config state are carried
on purpose, written repo-locally into the scratch:
- The source checkout's effective git identity (
user.name/user.email), resolved on the host as the user's own commits resolve it. A clone drops.git/config, and per-forge identities commonly live only there or in a gitdir-scoped include. Without the copy, the first in-sandbox commit fails with "Author identity unknown"; the guest's global tier cannot express a per-repo value. - The source checkout's origin URL (
remote.origin.url, plusremote.origin.pushurlwhen set).git clone <host path>points the scratch'soriginat the host checkout path, and the scratch is mounted at that very path in the guest, sooriginwould resolve to the scratch itself:git fetch origina no-op,git push originunable to reach the real remote, and anything that identifies the repo by its origin URL misled. The raw configured values are copied, notgit remote get-url's expansion: a hostinsteadOfrewrite is host policy that the guest never receives (onlyuser.*is synced into its global tier), and an https-to-ssh rewrite would hand the guest a transport it has no key for. A credential embedded in the URL travels with it, exactly as it does when the checkout's own.git/configis mounted in a non-clone run. Remote-tracking refs (origin/*) still reflect the host's local branches at clone time until the firstgit fetch --prune.
sbx carries both incidentally by copying .git wholesale. Propagating only
these values is the minimized form of that, without the credential helpers,
hooks, and URL rewrites that ride along with a wholesale copy.
Trade-off stated openly
The scratch clone lives on host disk (unlike sbx's in-guest clone), so agent writes land on the host — but confined to the acq-managed directory, which is disposable by construction and never executed by the host's git (a fetch transfers objects, not hooks or config).
Guest-visible markers (amended 2026-09-09)
Mounting the scratch at the original path makes the clone indistinguishable
from a passthrough mount by design, which is right for the agent but left
kits with no honest signal for "write into the clone, never into the real
checkout". Kits fell back to backend accidents (findmnt -t ext4, sbx's
WORKSPACE_DIR) that the msb emulation does not reproduce, so such a step was
a silent no-op on the default backend
(GSA-TTS/agentic-coding-quickstart#456).
Both backends therefore set two neutral guest variables at create, via their
native create-time env flag (msb create --env, sbx create --env; both
verified to reach every exec/attach session and to survive a native restart):
ACQ_WORKSPACE=<guest path of the primary>on any create with a workspace. On msb this is the canonical host path the primary mounts at; on sbx it is the logical absolute path sbx itself mounts at (sbx resolves.to the$PWDform, notrealpath). It names the primary's mount root, not the agent's cwd:ACQ_MSB_WORKSPACErelocates only the start dir, and following it would letACQ_CLONE=1sit next to a secondary passthrough's real path.ACQ_CLONE=1only when the primary is the disposable clone.
The two facts are kept separate so a non-clone kit gets a neutral workspace
path for free, and because the workspace path alone must never be read as a
clone signal: msb already records the primary's path at
/var/lib/acq/workspace (root-written, for name-only re-attach) on every
create, clone or not, and a kit that keyed on it wrote its files into a real
checkout through a passthrough mount. A marker file inside the scratch was
also rejected: it needs a .git/info/exclude entry and a post-create write on
sbx's native clone.
Consequences
- Positive: disposable-primary workflows work identically on both backends;
the last load-bearing sbx exclusive named by ADR-0011 becomes neutral
vocabulary; recovery UX (
git fetch sandbox-<name>) is backend-invariant. - Negative / trade-off: two documented state-fidelity divergences from sbx
(above), both git-native and both with the same
acq cpescape hatch; host disk holds a second physical copy of the repo per cloned sandbox. - Known limitation (accepted): the scratch existence check and its
mkdirare not atomic, so two concurrent creates with the same name can race, and the losing invocation's cleanup can delete the winner's fresh scratch. This requires the operator to race themselves with identical names in a single-operator interactive CLI, and msb's own name registration rejects the duplicate create anyway — accepted (surfaced by adversarial review) rather than complicating the claim into an atomicmkdirwith EEXIST handling. - Scope: applies at sandbox creation only;
acq stop/restart preserve the scratch and remote; onlyacq rm(or a failed create) cleans them up. - Kit contract:
ACQ_WORKSPACE/ACQ_CLONE(above) are documented indocs/BACKEND_GUIDE.mdand are the only supported way for a kit to detect the clone; mount-type or origin-URL heuristics are not.