From 4a648128200be04b8230d92028eecd495c23550c Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Mon, 10 Aug 2026 16:35:49 +0100 Subject: [PATCH 1/3] Refused clone-mount sandbox creation on a shallow repository with an actionable message instead of a silent empty workspace (FR-058) --- CLAUDE.md | 4 +- docs/Architecture.md | 34 ++++- docs/Features.md | 30 +++++ .../completed/015 - Clone Mount Preflight.md | 118 ++++++++++++++++++ src/git.ts | 35 ++++++ src/ops.ts | 54 +++++++- src/script.ts | 11 +- 7 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 docs/specs/completed/015 - Clone Mount Preflight.md create mode 100644 src/git.ts diff --git a/CLAUDE.md b/CLAUDE.md index c75b9f2..23a4dca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,8 @@ secret cache: DPAPI blobs in `~/.sbx`, shared with the generated CLI — FR-051) executed by the extension), `sandbox.ts` (recipe→refs + naming + lifecycle), `ops.ts` (per-sandbox create/attach/rebuild/shell shared by palette + Explorer; owns the progress spinners, the one-operation-per-sandbox guard FR-054 and cancellation FR-056), +`git.ts` (read-only host git probes: `isShallowRepository` for the clone-mount preflight, +FR-058 — never mutates a repo), `log.ts` (operation log FR-055: the `Sandbox Console` channel + the spawn runner every sbx/docker call streams through — process plumbing only, no CLI strings), `names.ts` (per-working-copy `workspaceState` record of sbx names that can no longer be created, @@ -93,7 +95,7 @@ FR-057), `terminal.ts` (native terminals driving sbx), `form.ts` (webview Configure form), `tree.ts` (Sandbox Explorer view + per-node commands), `agents.ts`/`services.ts` (registries + discovery). Dependency direction: `extension → {ops, form, tree, sandbox, config, identity, agents, names, script, secrets, sbx, log}`; -`ops → {images, secrets, sandbox, terminal, names, sbx, log}`; +`ops → {images, secrets, sandbox, terminal, names, git, sbx, log}`; `git → log`; `tree → {ops, form, sandbox, config, identity, agents, sbx, log}`; `form → {ops, secrets, sandbox, config, identity, agents, names, script, sbx}`; `secrets → {blobs, sandbox, services, sbx}`; `script → config`; diff --git a/docs/Architecture.md b/docs/Architecture.md index 771d01c..41fcdc8 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -75,6 +75,7 @@ All source is in `src/`. The extension bundles to `dist/extension.js` via esbuil | `blobs.ts` | The per-project secret cache store (FR-051, §8): `~/.sbx/..dpapi` blobs, encrypted/decrypted via a PowerShell child process (DPAPI; value over stdin/stdout pipes only). Shared on disk with the generated CLI. | | `script.ts` | Renders and maintains the generated project CLI `.sandbox/scripts/sbx.sh` (FR-052, §13): version+hash header, silent refresh of unmodified copies, never overwrites manual edits silently. | | `ops.ts` | Per-sandbox create/attach/stop/rebuild/destroy/shell, shared by the palette and the Explorer so the two never drift. Owns the progress spinners, the single-flight guard (FR-054) and cancellation at stage boundaries (FR-056) — §12. | +| `git.ts` | Read-only host git probes (FR-058): `isShallowRepository` (`git rev-parse --is-shallow-repository`, resolved with `-C` so a workspace inside a repo works). Own module for the same reason `sbx.ts` is one — one place per external CLI's argv. Never mutates a repository. | | `log.ts` | The operation log (FR-055): the `Sandbox Console` output channel plus the `spawn`-based runner every `sbx`/`docker` call goes through — streams child output to the channel and to the progress notification, and kills the child on cancel. Process plumbing only: it knows no CLI strings. | | `names.ts` | The per-working-copy record of sbx names that can no longer be created (FR-057, §14): `workspaceState`-backed, written when a create fails with the leaked-state error, read by key derivation. Local by construction — it never reaches the committed recipe. | | `terminal.ts` | Native VS Code terminals whose `shellPath` is `sbx` — assembles the interactive `run`/`exec` shellArgs and pools agent terminals per sandbox (§10, §12). This is where the agent actually attaches. | @@ -86,10 +87,10 @@ Dependency direction (verified against imports): `extension → {ops, form, tree, sandbox, config, identity, agents, names, script, secrets, sbx, log}`; `tree → {ops, form, sandbox, config, identity, agents, sbx, log}`; `form → {ops, secrets, sandbox, config, identity, agents, names, script, sbx}`; -`ops → {images, secrets, sandbox, terminal, names, sbx, log}`; +`ops → {images, secrets, sandbox, terminal, names, git, sbx, log}`; `terminal → {sandbox, agents, sbx}`; `secrets → {blobs, sandbox, services, sbx}`; `sandbox → {config, identity, sbx, log}`; `images → {config, sbx, log}`; -`sbx → log`; `script → config`; `identity → config`. +`sbx → log`; `git → log`; `script → config`; `identity → config`. Nothing depends on `extension`; `config`, `agents`, `services`, `blobs`, `names`, and `log` are leaves. @@ -119,6 +120,13 @@ The UI labels the attach action **Connect** (the underlying sbx operation is sti attach via `sbx run`). Create-vs-attach is disambiguated by checking `sbx ls --json` first, then choosing the create form (`… `) or the attach form (`… `). +**Preflight before the first sbx call.** Every create path (Connect, Shell, Rebuild) +first checks that the workspace can serve the sandbox's mount mode: `hostToSandboxPath` +rejects UNC/`\\wsl$` paths (FR-040), and `mount: clone` additionally requires a +non-shallow repository (FR-058, §9). Rebuild runs both **before** its removal stage, so a +refusal leaves the existing sandbox intact rather than deleting it and then declining to +recreate it. + There is **no implicit default sandbox**, and startup is **always quiet and read-only** — opening a workspace never raises notifications and **never writes into `.sandbox/`** (spec 009). Discovery feeds the status bar (state icon + display name; `+ New Sandbox` @@ -340,7 +348,11 @@ Provided by `sbx`, surfaced (not reimplemented) by the extension: - **Network policy** (Features §12) — host proxy enforces an allow-list; outbound hosts are logged/allowed/blocked. - **Filesystem policy** (Features §12) — *direct* mount (read-write workspace) vs - `--clone` (private in-container clone, host repo mounted read-only). + `--clone` (private in-container clone, host repo mounted read-only at + `/run/sandbox/source`). The clone is made by sbx at every start with + `git clone --reference ` plus a `git daemon` + serving it back as the host remote `sandbox-`; `--reference` is why the mode + needs a **non-shallow** repository, checked before the create (FR-058, §5, §14). - **Workspace mount path** — with direct mount on Windows, each host drive is mounted in the sandbox at `/` (e.g. `D:\Repositories\app` → `/d/Repositories/app`), read-write and bidirectional. `sbx run` drops the agent there; `Shell` reaches it via @@ -534,6 +546,10 @@ re-encoding these rules as prose and calls subcommands instead same logic runs with `createIfMissing: false` — an existing script is still refreshed on upgrade, but a missing one is **not** created merely by opening a project (spec 009; FR-002 read-only discovery). A sibling `.gitattributes` pins LF. +- **Same preconditions as the UI.** The create path refuses `mount: clone` on a shallow + repository, with the same message and the same fail-open behaviour as `ops.ts` + (FR-058) — parity here is what keeps a script-driven create from producing the empty + workspace the UI now prevents. - **Runners.** `runner-create ` instantiates the recipe's `default: true` entry as an **ephemeral** clone-mode instance `---p` (agent/image/secret names/caps from the recipe; defaults `-m 8g --cpus 4`) — never written back into the @@ -584,5 +600,17 @@ re-encoding these rules as prose and calls subcommands instead asked whether a name is claimed — `sbx ls` does not list it and `sbx rm` reports "not found". The record is local (`workspaceState`), stores names only, and becomes irrelevant after a `sbx reset`. +- **A clone-mode create can still fail silently for reasons the host cannot see.** The + in-sandbox clone runs on every start, guarded by `[ ! -d "$TARGET/.git" ]`, and the + script pre-creates `$TARGET` before cloning into it. Anything that makes that one + `git clone --reference` fail therefore leaves an **empty workspace directory** the agent + is then dropped into, with the error only in sbx's start-up output (the agent terminal), + since `sbx create` itself exited 0. It never self-heals: as soon as the agent writes into + that directory, later starts fail on + `fatal: destination path … already exists and is not an empty directory` and the original + cause is lost. Shallow sources are the reachable case and are refused up front (FR-058); + the general case would need a post-create probe (`sbx exec … test -d /.git`), which + costs a started sandbox per create and reports only after the fact — deliberately not + built. Recovery for an already-wedged sandbox is **Rebuild** once the cause is removed. - **Deferred scope**: filesystem/network policy UIs (Features §12), MCP endpoints (Features §13), kit injection (§7). diff --git a/docs/Features.md b/docs/Features.md index 2c3bee9..387beff 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -214,6 +214,9 @@ After creation: * Agent launches automatically * Terminal opens automatically +A create is refused up front when the workspace cannot serve the sandbox's mount mode — +network/WSL paths (FR-040) and a shallow repository under `mount: clone` (FR-058). + --- ## FR-004 Sandbox Start @@ -510,6 +513,31 @@ Examples: --- +## FR-058 Clone Mount Preflight + +A sandbox shall never be created into a workspace its mount mode cannot serve. + +* **`mount: clone` requires an unshallowed repository.** sbx copies the repo into the + sandbox with `git clone --reference `, and git refuses a shallow + source (`--depth` clone or fetch). The create itself still succeeds, so the failure is + invisible from the extension: it happens inside the sandbox at start-up and leaves the + agent in an **empty workspace**. +* Creating such a sandbox is therefore **refused before the first sbx call** — on Connect, + Shell and Rebuild alike — with a message that names the cause, the fix + (`git fetch --unshallow`, and what running it does to the working copy) and the + alternative (`mount: direct`, which clones nothing). +* The extension does not run the fix: fetching the missing history changes what the user's + repository contains, so it is theirs to run (the same rule that keeps discovery + non-mutating, FR-002). +* The check fails open — no git on the host, or a workspace outside any repository, does + not block a create. sbx rejects a non-repository workspace itself, before creating + anything; shallowness is the one precondition it does not check. +* `mount: direct` is unaffected — nothing is cloned. An existing sandbox already created + this way is not repaired automatically: unshallow, then **Rebuild**. +* The generated project CLI (FR-052) refuses the same case in its create path. + +--- + # 10. Sandbox Explorer A dedicated VS Code sidebar shall be available, **scoped to the current repo**. @@ -616,6 +644,8 @@ subcommands instead of re-encoding naming/lifecycle/secret rules as prose passive open never adds it (FR-002). * The extension **never executes** the generated script — generation is one-way (a committed script is repo-controlled input). +* Preconditions mirror the UI's: the create path refuses `mount: clone` on a shallow + repository with the same message and the same fail-open rule (FR-058). ## FR-053 Fresh Image Rebuild diff --git a/docs/specs/completed/015 - Clone Mount Preflight.md b/docs/specs/completed/015 - Clone Mount Preflight.md new file mode 100644 index 0000000..7028443 --- /dev/null +++ b/docs/specs/completed/015 - Clone Mount Preflight.md @@ -0,0 +1,118 @@ +# 015 — Clone Mount Preflight + +> **Iteration spec — immutable history.** Describes what changed in this iteration and +> why. The current truth lives in [`../../Architecture.md`](../../Architecture.md) and +> [`../../Features.md`](../../Features.md); where this spec disagrees with them, they win. +> +> **Period:** 2026-08-07 · **Base:** `main` after spec 014 +> +> **Status: shipped with this iteration.** A clone-mount sandbox on a shallow repository +> is refused before creation, with the fix named in the message; Features FR-058 and +> Architecture §4/§5/§9/§13/§14 carry the current truth. + +## What & why + +A sandbox with `mount: clone` (FR-009, Architecture §9) on a **shallow** host repository +is created successfully, starts, opens a terminal — and hands the agent an **empty +workspace**. Nothing in the extension says a word. + +The mechanics, confirmed against sbx v0.31.3 by reproducing it on a probe sandbox: the +in-sandbox clone is performed by a script sbx runs on every start, whose relevant part is + +```sh +if [ ! -d "$TARGET/.git" ]; then + mkdir -p "$TARGET"; chown agent:agent "$TARGET" + su -m -s /bin/sh agent -c 'git -c safe.directory="$SRC" clone --reference "$SRC" "$SRC" "$TARGET"' +fi +# … then: git daemon --base-path="$PARENT" "$TARGET" +``` + +`--reference` makes the clone borrow objects from the read-only host mount +(`/run/sandbox/source`) instead of copying them — and git refuses that when the reference +is shallow, because "shallow" is a property of the repository (`.git/shallow`), not of the +object store, and it cannot travel through `objects/info/alternates`: + +``` +fatal: reference repository '/run/sandbox/source' is shallow +``` + +The script has already created `$TARGET`, so the failed clone leaves an **empty directory** +that the agent is then dropped into. Three consequences, all of them silent from the user's +side: + +1. The failure text goes to sbx's own stderr on start, i.e. into the agent terminal, where + it scrolls past under the agent's banner. The extension neither sees nor reports it — + `sbx create` itself exited 0. +2. The state does not self-heal. Once the agent session writes anything into that empty + directory (Claude Code creating `.claude/` is enough), every later start fails the + *other* way — `fatal: destination path … already exists and is not an empty directory` — + and the original cause is gone from view. +3. `mount: direct` is unaffected: nothing is cloned, so a shallow repository is fine. + +FR-040 already establishes the pattern this iteration follows: a workspace the mount cannot +support (UNC / `\\wsl$`) is rejected **before any sbx mutation**, with an actionable error. +A shallow repository under `mount: clone` is the same class of precondition — cheap to +check on the host, impossible to recover from afterwards without a recreate. + +sbx guards the neighbouring precondition itself, which is what scopes this iteration: a +workspace that is not in a repository at all is refused up front, before anything is +created — verified on v0.31.3 — + +``` +ERROR: --clone requires a Git repository, but C:\…\nogit is not in a Git repository +``` + +so "no repository" needs nothing from the extension. Shallowness is the one precondition +sbx does not check. Note the wording: *not in a Git repository* — a workspace **inside** a +repository is accepted, so the extension's probe must resolve the containing repository the +same way (run git with `cwd` = the workspace and let it walk up), not test the folder for a +`.git` entry. + +## What changed + +- **New FR-058 (Clone Mount Preflight).** Creating a `mount: clone` sandbox on a shallow + repository is refused before the first sbx call, with an error that names the cause, the + fix (`git fetch --unshallow`, and what running it does to the working copy) and the + alternative (`mount: direct`). +- **`src/git.ts` — a new module for read-only host git probes.** One export, + `isShallowRepository(dir)`, running `git -C rev-parse --is-shallow-repository` + through the `log.ts` spawn runner (quiet; the log keeps it when it fails, FR-055). + `-C` rather than a spawn cwd so the probe resolves the *containing* repository, matching + sbx's own "not **in** a Git repository" semantics. Dependency direction: `ops → git → log`; + nothing else depends on it. Git gets its own module for the same reason sbx has one — + argv for an external CLI lives in exactly one place. +- **The guard sits beside the existing UNC fail-fast in `ops.ts`.** `assertMountUsable` + runs on every path that can *create* a sandbox (`createOrAttach`, `rebuildRef`, + `shellRef`), gated on `ref.spec.mount === "clone"`; attaching to an existing sandbox does + not run it. +- **Rebuild checks the workspace before it destroys anything.** Its workspace resolution, + the UNC translation and the new preflight moved from just-before-recreate to the top of + the operation — previously a workspace problem surfaced *after* `docker build` and + `sbx rm`, i.e. after the old instance was already gone. A refusal now leaves the sandbox + untouched. +- **The generated project CLI got the same guard (FR-052 parity).** `script.ts` renders the + check into `.sandbox/scripts/sbx.sh`'s `create_instance`, so a script-driven create + refuses the same case with the same fix instead of producing the same empty workspace. + +## Decisions + +- **Preflight only, no post-create verification.** A shallow reference is the one cause the + extension can detect on the host, for free, before it mutates anything. A general "did the + clone actually materialise" probe would mean an `sbx exec` into a freshly started sandbox + on every clone-mode create, and would report the failure only after the sandbox exists. + Deferred; recorded in Architecture §14 with the failure it would catch. +- **Refuse, don't fix — and say what the fix does.** The message explains the cause in one + line (the repository has a truncated history; sbx's clone borrows objects from it and git + refuses a shallow source), then names `git fetch --unshallow` and what running it means: + it downloads the missing history into this repository, and it is the user's call because + it changes what their repository contains. No button, no extension-run git: the same + reason discovery never writes (spec 009), applied to git state. +- **Fail open when the probe cannot run.** If `git` is absent from the host, or the folder + is not in a repository, the create proceeds. A false refusal would be worse than the + failure it prevents, and nothing is lost: sbx refuses a non-repository workspace itself, + before creating anything (`--clone requires a Git repository, but is not in a Git + repository`). The extension's probe therefore covers exactly one case sbx does not — + shallow — and when it cannot run, the outcome is today's behaviour, not a worse one. +- **Nothing is done for sandboxes already wedged this way.** An existing clone-mount sandbox + whose workspace is empty stays broken: the fix is `git fetch --unshallow` on the host, then + **Rebuild** (recreate). Detecting it would require the post-create probe above. diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..e9835c4 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,35 @@ +import * as log from "./log"; + +/** + * Read-only host git probes (FR-058). Git gets its own module for the same reason `sbx` + * has one: the argv for an external CLI lives in exactly one place. Nothing here mutates + * a repository — the extension never runs a git command that changes what the user's + * working copy contains (spec 015, "refuse, don't fix"). + */ + +/** + * FR-058: is the repository containing `dir` shallow — i.e. cloned or fetched with + * `--depth`, so its history is truncated at the commits listed in `.git/shallow`? + * + * Matters for `mount: clone` only: sbx builds the sandbox's copy with + * `git clone --reference `, and git refuses a shallow reference + * (shallowness lives in the repository, not in the object store it would borrow). + * + * `-C` rather than a spawn cwd, so the probe resolves the *containing* repository — + * matching sbx's own precondition, which accepts a workspace **inside** a repository + * ("--clone requires a Git repository, but is not in a Git repository"). + * + * Fails open (`false`) on every failure — no git on the host, no repository, an ancient + * git without `--is-shallow-repository`. A false refusal would be worse than the failure + * it prevents, and nothing is lost: sbx rejects a non-repository workspace itself, before + * creating anything. Quiet: this runs on a create path, and the log already brackets the + * sbx calls around it (FR-055 still records it if it fails). + */ +export async function isShallowRepository(dir: string): Promise { + const { stdout, code } = await log.run( + "git", + ["-C", dir, "rev-parse", "--is-shallow-repository"], + { quiet: true } + ); + return code === 0 && stdout.trim() === "true"; +} diff --git a/src/ops.ts b/src/ops.ts index be6a9ce..da0b44a 100644 --- a/src/ops.ts +++ b/src/ops.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import * as git from "./git"; import * as images from "./images"; import * as log from "./log"; import * as names from "./names"; @@ -216,6 +217,43 @@ async function rollbackCreate( }); } +/** + * FR-058: the precondition `mount: clone` puts on the workspace, checked before the first + * sbx call — like the UNC translation above, and for the same reason: what it prevents + * cannot be repaired afterwards. + * + * sbx builds the sandbox's copy with `git clone --reference `, which + * git refuses when the source is shallow. The create still succeeds, so nothing here would + * notice: the failure happens inside the sandbox at start-up, scrolls past in the agent + * terminal, and leaves the agent in an empty workspace directory. Worse, it does not + * self-heal — once the agent writes anything into that directory, every later start fails + * on "already exists and is not an empty directory" instead, and the real cause is gone. + * + * Refuse, don't fix (spec 015): `git fetch --unshallow` changes what the user's working + * copy contains, so it is named, explained, and left to them. `mount: direct` is untouched + * — it clones nothing. + */ +async function assertMountUsable( + ref: sandbox.SandboxRef, + workspace: string +): Promise { + if (ref.spec.mount !== "clone") { + return; + } + if (!(await git.isShallowRepository(workspace))) { + return; + } + throw new Error( + `${ref.name} uses mount: clone, but this repository is shallow — its history was ` + + "truncated by a `--depth` clone or fetch. Docker Sandboxes copies the repo into " + + "the sandbox with `git clone --reference`, and git refuses a shallow source, so " + + "the sandbox would start with an empty workspace. Run `git fetch --unshallow` in " + + "the repository — it downloads the missing history into your working copy, which " + + "is why it is left to you — then try again. Or set this sandbox to mount: direct, " + + "which needs no clone." + ); +} + /** * Create the sandbox (non-attaching) behind a progress spinner. Cancellable, with the * caveat that matters: the CLI is not killed (see `sbx.run`), so cancelling waits for the @@ -326,6 +364,7 @@ export async function createOrAttach( throw new Error("No workspace open."); } sbx.hostToSandboxPath(workspace); // fail fast on UNC/WSL paths, before any sbx mutation + await assertMountUsable(ref, workspace); // FR-058: clone mount needs full history await ensureImageForRef(ref, root.fsPath); await createSandbox(ref, workspace); if (ref.spec.secrets.length > 0) { @@ -392,6 +431,15 @@ export async function rebuildRef( // What cancelling from the current stage leaves behind; undefined = nothing touched. let leftBehind: string | undefined; try { + // Both workspace checks run before the first destructive step, not next to the + // recreate they guard: a rebuild refused here leaves the existing sandbox alone, + // where the same refusal after the removal stage would leave the user with nothing. + const workspace = sandbox.workspacePath(); + if (!workspace) { + throw new Error("No workspace open."); + } + sbx.hostToSandboxPath(workspace); // fail fast on UNC/WSL paths, before any sbx mutation + await assertMountUsable(ref, workspace); // FR-058: clone mount needs full history if (ref.spec.image && ref.spec.dockerfile) { await ensureImageForRef(ref, root.fsPath, true); // docker build --pull (FR-053) } @@ -421,11 +469,6 @@ export async function rebuildRef( ); } } - const workspace = sandbox.workspacePath(); - if (!workspace) { - throw new Error("No workspace open."); - } - sbx.hostToSandboxPath(workspace); // fail fast on UNC/WSL paths, before any sbx mutation leftBehind = "Connect recreates the sandbox."; // Same create-then-attach as createOrAttach: `sbx create` honours --name where the // one-shot run create-form does not (see createOrAttach). @@ -463,6 +506,7 @@ export async function shellRef( // Translate before any sbx mutation: throws a friendly error for UNC/WSL paths. const workspaceInside = sbx.hostToSandboxPath(workspace); if ((await sandbox.state(ref)) === "absent") { + await assertMountUsable(ref, workspace); // FR-058: clone mount needs full history await ensureImageForRef(ref, root.fsPath); await createSandbox(ref, workspace); } diff --git a/src/script.ts b/src/script.ts index 9d2d023..a5d1c4e 100644 --- a/src/script.ts +++ b/src/script.ts @@ -317,7 +317,16 @@ create_instance() { # agent=$(cfg_field "$key" agent); [ -n "$agent" ] || die "sandboxes.$key.agent missing" img=$(cfg_field "$key" image) local args=(create --name "$inst") - [ "$(cfg_field "$key" mount)" = "clone" ] && args+=(--clone) + if [ "$(cfg_field "$key" mount)" = "clone" ]; then + # FR-058: sbx copies the repo into the sandbox with "git clone --reference", which git + # refuses when the source is shallow — the sandbox would come up with an empty + # workspace, and the retry on every later start fails the other way ("not an empty + # directory"). Same preflight as ops.ts, same fix, and it fails open the same way. + if [ "$(git -C "$ROOT" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then + die "sandboxes.$key uses mount: clone, but this repository is shallow (a --depth clone or fetch): git refuses to clone from it and the sandbox would start with an empty workspace. Run 'git fetch --unshallow' here — it downloads the missing history into this working copy — or set mount: direct" + fi + args+=(--clone) + fi [ -n "$img" ] && args+=(-t "$img") "$SBX" "\${args[@]}" "$agent" "$ROOT" } From ffb381791eb3192c2e805369b1cb03097851a341 Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Mon, 10 Aug 2026 16:45:17 +0100 Subject: [PATCH 2/3] Turned the shallow-repository refusal into a modal explanation with a terminal hand-off for git fetch --unshallow (FR-058) --- CLAUDE.md | 3 +- docs/Architecture.md | 9 ++-- docs/Features.md | 8 ++-- .../completed/015 - Clone Mount Preflight.md | 21 ++++++---- src/extension.ts | 3 ++ src/form.ts | 8 ++-- src/ops.ts | 42 ++++++++++++++----- src/terminal.ts | 19 +++++++++ src/tree.ts | 3 ++ 9 files changed, 89 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 23a4dca..eec7205 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,8 @@ FR-058 — never mutates a repo), sbx/docker call streams through — process plumbing only, no CLI strings), `names.ts` (per-working-copy `workspaceState` record of sbx names that can no longer be created, FR-057), `terminal.ts` -(native terminals driving sbx), `form.ts` (webview Configure form), `tree.ts` (Sandbox +(native terminals driving sbx + the one host terminal, `openHostCommandTerminal`, that +types the FR-058 fix without running it), `form.ts` (webview Configure form), `tree.ts` (Sandbox Explorer view + per-node commands), `agents.ts`/`services.ts` (registries + discovery). Dependency direction: `extension → {ops, form, tree, sandbox, config, identity, agents, names, script, secrets, sbx, log}`; `ops → {images, secrets, sandbox, terminal, names, git, sbx, log}`; `git → log`; diff --git a/docs/Architecture.md b/docs/Architecture.md index 41fcdc8..3479f25 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -74,11 +74,11 @@ All source is in `src/`. The extension bundles to `dist/extension.js` via esbuil | `secrets.ts` | Provisions missing service secrets — cached-entry picker / prompt → `sbx secret set` over stdin (FR-032 + FR-051, §8) — and the `Manage Cached Secrets` command. | | `blobs.ts` | The per-project secret cache store (FR-051, §8): `~/.sbx/..dpapi` blobs, encrypted/decrypted via a PowerShell child process (DPAPI; value over stdin/stdout pipes only). Shared on disk with the generated CLI. | | `script.ts` | Renders and maintains the generated project CLI `.sandbox/scripts/sbx.sh` (FR-052, §13): version+hash header, silent refresh of unmodified copies, never overwrites manual edits silently. | -| `ops.ts` | Per-sandbox create/attach/stop/rebuild/destroy/shell, shared by the palette and the Explorer so the two never drift. Owns the progress spinners, the single-flight guard (FR-054) and cancellation at stage boundaries (FR-056) — §12. | +| `ops.ts` | Per-sandbox create/attach/stop/rebuild/destroy/shell, shared by the palette and the Explorer so the two never drift. Owns the progress spinners, the single-flight guard (FR-054), cancellation at stage boundaries (FR-056) — §12 — and the mount preflight (FR-058, §5) whose modal refusal it shows itself, raising the shared `HandledError` so no surface reports it twice. | | `git.ts` | Read-only host git probes (FR-058): `isShallowRepository` (`git rev-parse --is-shallow-repository`, resolved with `-C` so a workspace inside a repo works). Own module for the same reason `sbx.ts` is one — one place per external CLI's argv. Never mutates a repository. | | `log.ts` | The operation log (FR-055): the `Sandbox Console` output channel plus the `spawn`-based runner every `sbx`/`docker` call goes through — streams child output to the channel and to the progress notification, and kills the child on cancel. Process plumbing only: it knows no CLI strings. | | `names.ts` | The per-working-copy record of sbx names that can no longer be created (FR-057, §14): `workspaceState`-backed, written when a create fails with the leaked-state error, read by key derivation. Local by construction — it never reaches the committed recipe. | -| `terminal.ts` | Native VS Code terminals whose `shellPath` is `sbx` — assembles the interactive `run`/`exec` shellArgs and pools agent terminals per sandbox (§10, §12). This is where the agent actually attaches. | +| `terminal.ts` | Native VS Code terminals whose `shellPath` is `sbx` — assembles the interactive `run`/`exec` shellArgs and pools agent terminals per sandbox (§10, §12). This is where the agent actually attaches. Also opens the one **host** terminal the extension needs (`openHostCommandTerminal`): the FR-058 hand-off, which types `git fetch --unshallow` and leaves the Enter to the user. | | `form.ts` | The New/Edit webview (§7, §12): persists to the recipe AND applies to the instance. | | `tree.ts` | Sandbox Explorer view + per-node commands (§12). | | `agents.ts` / `services.ts` | Static agent/secret-service registries (labels + fallback) backing the live discovery in `sbx.ts`. | @@ -125,7 +125,10 @@ first checks that the workspace can serve the sandbox's mount mode: `hostToSandb rejects UNC/`\\wsl$` paths (FR-040), and `mount: clone` additionally requires a non-shallow repository (FR-058, §9). Rebuild runs both **before** its removal stage, so a refusal leaves the existing sandbox intact rather than deleting it and then declining to -recreate it. +recreate it. The shallow refusal is a modal dialog raised in `ops.ts` (one place for all +three surfaces) offering **Open Terminal** — `git fetch --unshallow` typed into a host +terminal, never executed — and then throws `ops.HandledError`, which every surface's +error reporter skips so the dialog is not followed by a redundant toast. There is **no implicit default sandbox**, and startup is **always quiet and read-only** — opening a workspace never raises notifications and **never writes into `.sandbox/`** diff --git a/docs/Features.md b/docs/Features.md index 387beff..00f4a09 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -523,12 +523,14 @@ A sandbox shall never be created into a workspace its mount mode cannot serve. invisible from the extension: it happens inside the sandbox at start-up and leaves the agent in an **empty workspace**. * Creating such a sandbox is therefore **refused before the first sbx call** — on Connect, - Shell and Rebuild alike — with a message that names the cause, the fix + Shell and Rebuild alike — in a **modal dialog** that names the cause, the fix (`git fetch --unshallow`, and what running it does to the working copy) and the - alternative (`mount: direct`, which clones nothing). + alternative (`mount: direct`, which clones nothing). Modal because it ends the action the + user just asked for, and the explanation does not fit a notification's one line. * The extension does not run the fix: fetching the missing history changes what the user's repository contains, so it is theirs to run (the same rule that keeps discovery - non-mutating, FR-002). + non-mutating, FR-002). **Open Terminal** opens a host terminal in the repository with the + command typed in and *not* executed — the Enter stays with the user. * The check fails open — no git on the host, or a workspace outside any repository, does not block a create. sbx rejects a non-repository workspace itself, before creating anything; shallowness is the one precondition it does not check. diff --git a/docs/specs/completed/015 - Clone Mount Preflight.md b/docs/specs/completed/015 - Clone Mount Preflight.md index 7028443..1c72d4f 100644 --- a/docs/specs/completed/015 - Clone Mount Preflight.md +++ b/docs/specs/completed/015 - Clone Mount Preflight.md @@ -84,7 +84,10 @@ same way (run git with `cwd` = the workspace and let it walk up), not test the f - **The guard sits beside the existing UNC fail-fast in `ops.ts`.** `assertMountUsable` runs on every path that can *create* a sandbox (`createOrAttach`, `rebuildRef`, `shellRef`), gated on `ref.spec.mount === "clone"`; attaching to an existing sandbox does - not run it. + not run it. It shows its own modal and raises `ops.HandledError`; `extension.ts`, + `tree.ts` and `form.ts` skip that sentinel in their error reporters (form's local + `HandledError` now extends it, so one check covers both), so the dialog is not chased by + a duplicate toast. `terminal.ts` gained `openHostCommandTerminal` for the hand-off. - **Rebuild checks the workspace before it destroys anything.** Its workspace resolution, the UNC translation and the new preflight moved from just-before-recreate to the top of the operation — previously a workspace problem surfaced *after* `docker build` and @@ -101,12 +104,16 @@ same way (run git with `cwd` = the workspace and let it walk up), not test the f clone actually materialise" probe would mean an `sbx exec` into a freshly started sandbox on every clone-mode create, and would report the failure only after the sandbox exists. Deferred; recorded in Architecture §14 with the failure it would catch. -- **Refuse, don't fix — and say what the fix does.** The message explains the cause in one - line (the repository has a truncated history; sbx's clone borrows objects from it and git - refuses a shallow source), then names `git fetch --unshallow` and what running it means: - it downloads the missing history into this repository, and it is the user's call because - it changes what their repository contains. No button, no extension-run git: the same - reason discovery never writes (spec 009), applied to git state. +- **Refuse, don't fix — and say what the fix does.** The refusal is a **modal** dialog: it + ends the action the user just asked for, and the explanation (truncated history; sbx's + clone borrows objects from it; git refuses a shallow source) does not survive a + notification's one-line clamp. It names `git fetch --unshallow` and what running it + means — it downloads the missing history into this repository, which is why it is the + user's call. **Open Terminal** opens a host terminal in the repository with the command + *typed but not executed*; the extension never runs git itself, the same reason discovery + never writes (spec 009), applied to git state. Showing the dialog inside `ops.ts` (and + raising the shared `HandledError` the surfaces skip) keeps the palette, the Explorer and + the form from each rendering their own version of it. - **Fail open when the probe cannot run.** If `git` is absent from the host, or the folder is not in a repository, the create proceeds. A false refusal would be worse than the failure it prevents, and nothing is lost: sbx refuses a non-repository workspace itself, diff --git a/src/extension.ts b/src/extension.ts index f04b4f3..1c12e8e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -130,6 +130,9 @@ export function deactivate(): void { } function fail(action: string, err: unknown): void { + if (err instanceof ops.HandledError) { + return; // already explained in its own dialog (FR-058) — a second toast adds nothing + } const msg = err instanceof Error ? err.message : String(err); // Only suggest "sbx login" when the CLI output actually points at auth — for build // or config failures the hint would send users down the wrong path. diff --git a/src/form.ts b/src/form.ts index 6409e9f..2b7d14f 100644 --- a/src/form.ts +++ b/src/form.ts @@ -154,7 +154,7 @@ export async function openForm( // The panel stays open on failure — hand the form back to the user so the // corrected values can be re-submitted (FR-054). void panel.webview.postMessage({ type: "idle" }); - if (!(err instanceof HandledError)) { + if (!(err instanceof ops.HandledError)) { vscode.window.showErrorMessage( `Sandbox Console: ${ err instanceof Error ? err.message : String(err) @@ -378,8 +378,10 @@ function info(message: string): void { vscode.window.showInformationMessage(message); } -/** Thrown after the user was already shown a notification (the panel stays open). */ -class HandledError extends Error {} +/** Thrown after the user was already shown a notification (the panel stays open). + * Extends the shared sentinel so one `instanceof` covers this and the refusals `ops.ts` + * reports in a dialog of their own (FR-058). */ +class HandledError extends ops.HandledError {} /** Recipe keys and Dockerfile names become files under `.sandbox/` and docker-tag * components. No path separators and no leading "." — that is what makes traversal diff --git a/src/ops.ts b/src/ops.ts index da0b44a..c91d2ea 100644 --- a/src/ops.ts +++ b/src/ops.ts @@ -9,6 +9,7 @@ import * as secrets from "./secrets"; import { disposeSandboxTerminals, openAgentAttach, + openHostCommandTerminal, openShell, } from "./terminal"; @@ -217,6 +218,16 @@ async function rollbackCreate( }); } +/** + * Thrown after the user has already been shown what went wrong, so the command surfaces + * (palette, Explorer, form) must not report it a second time. The refusal below needs a + * dialog rather than the one-line failure notification those surfaces render, and it is + * reached from all three — showing it here is what keeps the three from drifting. + */ +export class HandledError extends Error {} + +const UNSHALLOW = "git fetch --unshallow"; + /** * FR-058: the precondition `mount: clone` puts on the workspace, checked before the first * sbx call — like the UNC translation above, and for the same reason: what it prevents @@ -230,8 +241,10 @@ async function rollbackCreate( * on "already exists and is not an empty directory" instead, and the real cause is gone. * * Refuse, don't fix (spec 015): `git fetch --unshallow` changes what the user's working - * copy contains, so it is named, explained, and left to them. `mount: direct` is untouched - * — it clones nothing. + * copy contains, so the extension never runs it. "Open terminal" types it into a host + * terminal at the repository and stops there — the Enter is the user's. Modal, because + * this ends the action the user just asked for and the explanation does not survive a + * notification's one-line clamp. `mount: direct` is untouched — it clones nothing. */ async function assertMountUsable( ref: sandbox.SandboxRef, @@ -243,15 +256,24 @@ async function assertMountUsable( if (!(await git.isShallowRepository(workspace))) { return; } - throw new Error( - `${ref.name} uses mount: clone, but this repository is shallow — its history was ` + - "truncated by a `--depth` clone or fetch. Docker Sandboxes copies the repo into " + - "the sandbox with `git clone --reference`, and git refuses a shallow source, so " + - "the sandbox would start with an empty workspace. Run `git fetch --unshallow` in " + - "the repository — it downloads the missing history into your working copy, which " + - "is why it is left to you — then try again. Or set this sandbox to mount: direct, " + - "which needs no clone." + const choice = await vscode.window.showErrorMessage( + `Cannot create ${ref.name}: this repository is shallow.`, + { + modal: true, + detail: + "This sandbox uses mount: clone, and Docker Sandboxes copies the repository into " + + "the sandbox with `git clone --reference`. Git refuses a shallow source (a " + + "--depth clone or fetch), so the sandbox would start with an empty workspace.\n\n" + + `Run "${UNSHALLOW}" in this repository to download the missing history — it ` + + "changes what your working copy contains, so it is left to you. Or set this " + + "sandbox to mount: direct, which needs no clone.", + }, + "Open Terminal" ); + if (choice === "Open Terminal") { + openHostCommandTerminal(workspace, UNSHALLOW, "git fetch --unshallow"); + } + throw new HandledError(`${ref.name}: shallow repository, mount: clone refused`); } /** diff --git a/src/terminal.ts b/src/terminal.ts index c009107..80f05d1 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -168,6 +168,25 @@ export function disposeSandboxTerminals(name: string): Promise { }); } +/** + * A plain **host** terminal at `cwd` with `command` typed in but NOT executed — the user + * presses Enter. Used by the FR-058 refusal to hand over `git fetch --unshallow` without + * running it: the extension never changes what a user's repository contains, and typing it + * for them removes the only friction that leaves. + * + * Deliberately not an sbx terminal: this runs on the host, in the user's own shell, so it + * carries no `shellPath` and is not pooled. + */ +export function openHostCommandTerminal( + cwd: string, + command: string, + name: string +): void { + const terminal = vscode.window.createTerminal({ name, cwd }); + terminal.show(); + terminal.sendText(command, false); // false = type it, leave the Enter to the user +} + /** * Attach to an existing sandbox: `sbx run ` resumes the sandbox if stopped. Reuses a * live terminal (the same session) when present; otherwise launches the agent fresh. We do diff --git a/src/tree.ts b/src/tree.ts index d49162b..3f934e1 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -239,6 +239,9 @@ class SandboxExplorer implements vscode.TreeDataProvider { } function reportError(action: string, err: unknown): void { + if (err instanceof ops.HandledError) { + return; // already explained in its own dialog (FR-058) — a second toast adds nothing + } const msg = err instanceof Error ? err.message : String(err); // FR-055: the full CLI output that produced this message is one click away. void vscode.window From f6fb514a339236197f06c019fc69abce89c95c79 Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Mon, 10 Aug 2026 17:10:12 +0100 Subject: [PATCH 3/3] Documented why the generated CLI's shallow probe is not affected by set -e (FR-058) --- src/script.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/script.ts b/src/script.ts index a5d1c4e..c3a1000 100644 --- a/src/script.ts +++ b/src/script.ts @@ -322,6 +322,9 @@ create_instance() { # # refuses when the source is shallow — the sandbox would come up with an empty # workspace, and the retry on every later start fails the other way ("not an empty # directory"). Same preflight as ops.ts, same fix, and it fails open the same way. + # No "|| true" needed for that: this substitution sits in an if-condition, which set -e + # exempts, and its status is discarded by the enclosing test — a missing git or a + # non-repository yields "" and proceeds, exactly like the cfg_field test above. if [ "$(git -C "$ROOT" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then die "sandboxes.$key uses mount: clone, but this repository is shallow (a --depth clone or fetch): git refuses to clone from it and the sandbox would start with an empty workspace. Run 'git fetch --unshallow' here — it downloads the missing history into this working copy — or set mount: direct" fi