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 (or ACQ_CLONE=1) works on both backends with identical UX: same flag, same sandbox-<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 fetch transfers hash-verified objects only; hooks and config never cross (they are host-executed code paths).
  • The copy must be physical — a same-filesystem git clone hardlinks object files by default; an agent with write access to a hardlinked scratch .git could modify inodes shared with the real repo's object store.

Considered Options

  1. Managed host-side scratch clone (msb emulation), sbx passthrough. Chosen. git clone --no-hardlinks into acq-managed state, mounted rw as the primary; recovery and lifecycle mirror sbx exactly.
  2. In-guest clone from an :ro mount. 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 :22 egress gap, GSA-TTS/agentic-coding-quickstart#402) or via bundles copied out. Possible later refinement.
  3. 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.
  4. 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

BackendMechanism
sbxre-injects the native sbx create --clone
msbemulates: managed host-side scratch clone (below)

msb emulation

At create, when --clone is requested:

  1. 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 (:ro and direct mounts as before).
  2. git clone --no-hardlinks the primary into $XDG_STATE_HOME/acq/clones/<sandbox>/<repo> (root overridable via ACQ_STATE_DIR / ACQ_MSB_CLONES_DIR). --no-hardlinks is load-bearing (see Decision Drivers).
  3. Mount the scratch rw at the original workspace's absolute path in the guest (verified msb 0.6.15 mounts --volume src:dst with src != dst), so the agent's starting directory, kit behavior, and docs are identical to a non-clone run.
  4. 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.
  5. acq rm warns 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 failed msb create cleans 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 --clone copies 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 with acq 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 cp the 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, plus remote.origin.pushurl when set). git clone <host path> points the scratch's origin at the host checkout path, and the scratch is mounted at that very path in the guest, so origin would resolve to the scratch itself: git fetch origin a no-op, git push origin unable to reach the real remote, and anything that identifies the repo by its origin URL misled. The raw configured values are copied, not git remote get-url's expansion: a host insteadOf rewrite is host policy that the guest never receives (only user.* 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/config is mounted in a non-clone run. Remote-tracking refs (origin/*) still reflect the host's local branches at clone time until the first git 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 $PWD form, not realpath). It names the primary's mount root, not the agent's cwd: ACQ_MSB_WORKSPACE relocates only the start dir, and following it would let ACQ_CLONE=1 sit next to a secondary passthrough's real path.
  • ACQ_CLONE=1 only 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 cp escape hatch; host disk holds a second physical copy of the repo per cloned sandbox.
  • Known limitation (accepted): the scratch existence check and its mkdir are 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 atomic mkdir with EEXIST handling.
  • Scope: applies at sandbox creation only; acq stop/restart preserve the scratch and remote; only acq rm (or a failed create) cleans them up.
  • Kit contract: ACQ_WORKSPACE / ACQ_CLONE (above) are documented in docs/BACKEND_GUIDE.md and are the only supported way for a kit to detect the clone; mount-type or origin-URL heuristics are not.