From 0f8376d1109560018d777d7f74b6e639cc87d8df Mon Sep 17 00:00:00 2001 From: Dmitrij Zykovic Date: Wed, 5 Aug 2026 00:27:30 +0100 Subject: [PATCH] Made the recipe state truthful: the Explorer and status bar follow config.yaml edits on disk, new-sandbox keys derive from the title, and names claimed by leaked sbx state are remembered and skipped (FR-009, FR-057) --- CLAUDE.md | 9 +- docs/Architecture.md | 51 ++++++++--- docs/Features.md | 34 +++++++ .../completed/014 - Truthful Recipe State.md | 90 +++++++++++++++++++ src/extension.ts | 57 +++++++++++- src/form.ts | 73 ++++++++++++--- src/names.ts | 46 ++++++++++ src/ops.ts | 7 ++ src/sbx.ts | 30 ++++--- 9 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 docs/specs/completed/014 - Truthful Recipe State.md create mode 100644 src/names.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5fe5fd9..c75b9f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,12 +87,15 @@ executed by the extension), `sandbox.ts` (recipe→refs + naming + lifecycle), ` (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), `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), `terminal.ts` +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 Explorer view + per-node commands), `agents.ts`/`services.ts` (registries + discovery). -Dependency direction: `extension → {ops, form, tree, sandbox, config, identity, agents, script, secrets, sbx, log}`; -`ops → {images, secrets, sandbox, terminal, sbx, log}`; +Dependency direction: `extension → {ops, form, tree, sandbox, config, identity, agents, names, script, secrets, sbx, log}`; +`ops → {images, secrets, sandbox, terminal, names, sbx, 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`; `sandbox → {config, identity, sbx, log}`; `images → {config, sbx, log}`; `sbx → log`; nothing depends on `extension`. diff --git a/docs/Architecture.md b/docs/Architecture.md index dd6c5b3..771d01c 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -76,21 +76,22 @@ All source is in `src/`. The extension bundles to `dist/extension.js` via esbuil | `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. | | `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. | | `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`. | Dependency direction (verified against imports): -`extension → {ops, form, tree, sandbox, config, identity, agents, script, secrets, sbx, log}`; +`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, script, sbx}`; -`ops → {images, secrets, sandbox, terminal, sbx, log}`; +`form → {ops, secrets, sandbox, config, identity, agents, names, script, sbx}`; +`ops → {images, secrets, sandbox, terminal, names, 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`. -Nothing depends on `extension`; `config`, `agents`, `services`, `blobs`, and `log` are -leaves. +Nothing depends on `extension`; `config`, `agents`, `services`, `blobs`, `names`, and +`log` are leaves. `sbx.ts` shapes all child-process `sbx` invocations; `terminal.ts` additionally builds the interactive `run`/`exec` argument vectors used as terminal `shellArgs` — CLI strings @@ -193,6 +194,23 @@ digits, `.`, `+`, `-`), must start with a letter/digit, and fall back to `sandbo nothing usable remains (e.g. a fully non-ASCII folder name) — so any folder name yields a valid sandbox name. +**Key derivation (FR-057).** A new sandbox's `key` is seeded from the title entered in the +form (`Backend API (v2)` → `backend-api-v2`, capped at 40 chars), falling back to the agent +id when nothing usable remains, and then **frozen** — the form locks it in edit mode, so a +later rename never moves a sandbox name, Dockerfile, or image tag. The candidate is skipped +if the key is already in the recipe or if its sbx name is recorded unusable (`names.ts`, +§14); the suffix (`-2`, `-3`, …) resolves genuine collisions. Because the default Dockerfile +name follows the key, two sandboxes on one agent no longer share `claude.Dockerfile` by +accident — sharing is expressed by typing the same file name in both. + +**The recipe is watched, not snapshotted (FR-009).** `config.yaml` is committed and edited +by hand, by a git pull, or by another window, while the tree and the status bar resolve +their refs at render time. `extension.ts` therefore holds a `FileSystemWatcher` over +`.sandbox/*.yaml` that (debounced) refreshes the status bar and fires +`sandboxConsole.refresh`, dropping the tree's cached refs. Watching keeps discovery +event-driven and silent (FR-002; a timer would reintroduce background `sbx ls` traffic for +a file that changes rarely), and the watcher only observes — it never writes. + ## 7. Custom images: templates & kits `sbx` has **no native Dockerfile build**; custom environments come from two official, @@ -233,8 +251,9 @@ only; kit injection (`sbx kit add`) is the natural follow-up for live environmen **Instance-first New/Edit (the user edits a sandbox, not a file).** The Explorer drives a webview: **New Sandbox** (`sandboxConsole.newSandbox`, the `+` in the view title) creates -one; **Edit** on a node opens *that* sandbox prefilled. Fields: **Title** (display label -only — the key derives from the agent id, so a rename never touches names/images), +one; **Edit** on a node opens *that* sandbox prefilled. Fields: **Title** (display label; it +seeds the key once at creation and never tracks it afterwards, so a rename never touches +names/images — FR-057, §6), **Agent**, **Group** (organises the tree into folders), **Credentials** (checkboxes — names only; values prompted on apply), and **Advanced** (Environment: Default / Custom Dockerfile / Custom image; published ports as @@ -475,10 +494,11 @@ child runs to completion after the click (§11), so a node still reading *connec a minutes-long `--clone` create reads as a hang and contradicts the box beside it. **Title & Group (organising).** A spec may carry `title` (Explorer/status-bar label — -display-only, **never** part of the key, sbx name, file names, or image tags, so it is -safe to rename at any time) and `group` (folders the tree). New-sandbox keys derive from -the agent id (`claude`, `claude-2`, …). Groups are organisational today and the natural -hook for per-group governance (`sbx --profile`) later. +display-only: it is read **once**, to seed a new sandbox's key, and never tracks it +afterwards, so renaming a title is always safe for the sbx name, file names, and image +tags) and `group` (folders the tree). New-sandbox keys derive from the title, falling back +to the agent id (FR-057, §6). Groups are organisational today and the natural hook for +per-group governance (`sbx --profile`) later. **Status bar & the active sandbox (FR-050).** The status bar item shows the *active* sandbox by display name (`title || key`) with its state icon; the agent is in the @@ -557,7 +577,12 @@ re-encoding these rules as prose and calls subcommands instead machine), the alternative being bbolt surgery on three internal DBs (#129). **This is why cancel never kills an sbx child** (§11): interrupting sbx mid-cleanup is exactly the trigger. It is reachable without this extension too — a `sbx stop` that - outruns the CLI's own 120s timeout does it. `sbx.explainCreateFailure` recognises the - error and tells the user to change the sandbox's `key` rather than leaving a raw 500. + outruns the CLI's own 120s timeout does it. `sbx.ts` recognises the error, raises it as + a typed `NameClaimedError` explaining the way out rather than a raw 500, and `ops.ts` + records the dead name in `names.ts` so key derivation can never hand it out again + (FR-057). Remembering the observed failure is the only option available: sbx cannot be + 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`. - **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 59ed5ce..2c3bee9 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -306,6 +306,13 @@ sandboxes: The recipe declares one or more sandboxes per repo (multi-agent, e.g. `claude` + `shell`). Identity (the local id) is kept separately and gitignored (FR-001). +**The UI follows the recipe.** `.sandbox/config.yaml` is a file users are told to edit by +hand, and it also changes under a git pull or in another VS Code window. The extension +watches it (and `identity.yaml`): a change refreshes the Sandboxes view and the status bar +and invalidates the sandbox refs resolved from it, so an edited key takes effect +immediately instead of Connect going out under the previous sandbox name. Watching keeps +discovery event-driven and read-only (FR-002) — the watcher observes, it never writes. + --- # 6. Terminal Requirements @@ -707,6 +714,33 @@ Long operations shall be cancellable from their progress notification. --- +## FR-057 Truthful Sandbox Names + +Sandbox names shall say what the sandbox is, and a name that cannot be created shall +never be derived again. + +* **New-sandbox keys derive from the title.** At **creation only**, the key is seeded from + the entered title, sanitised to the key charset (`Backend API (v2)` → `backend-api-v2`), + falling back to the agent id when the title is empty or sanitises away (e.g. a fully + non-ASCII title). The uniqueness suffix (`-2`, `-3`, …) stays for genuine collisions. +* **The key is then frozen forever.** The form locks it in edit mode, so renaming a title + never touches the sandbox name, the generated Dockerfile, or the image tag: the rule is + that the key must not *track* the title, and seeding it once does not. +* Consequence, accepted deliberately: the default Dockerfile name follows the key + (`.Dockerfile`), so two sandboxes on the same agent no longer *default* to one + shared Dockerfile and one shared image. Sharing stays available — typing the same file + name in both — and is now explicit rather than accidental. +* **Names that cannot be created are remembered.** When `sbx create` fails because the + name is still claimed by leaked sbx runtime state (a known upstream defect with no + released fix — Architecture §14), the name is recorded **per working copy** and key + derivation skips any candidate that would produce it. Previously a key freed by a rename + or a removal was handed straight to the next new sandbox, which walked the user back + into the same permanently failing name. +* The record is local, never committed, stores names only (it never removes sandboxes), + and `sbx reset` makes it irrelevant. + +--- + # 12. Security Requirements ## Isolation diff --git a/docs/specs/completed/014 - Truthful Recipe State.md b/docs/specs/completed/014 - Truthful Recipe State.md new file mode 100644 index 0000000..49701e7 --- /dev/null +++ b/docs/specs/completed/014 - Truthful Recipe State.md @@ -0,0 +1,90 @@ +# 014 — Truthful Recipe State + +> **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-07-29 · **Base:** `main` after spec 013 +> +> **Status: shipped with this iteration.** Features FR-009/FR-057 and Architecture +> §4/§6/§12/§14 carry the current truth. + +## What & why + +Spec 013 made *operations* honest — what is running, how to stop it, that it runs once. +Acceptance for it then produced three failures that share a different root: the recipe and +the names derived from it go stale or collide, and the UI keeps acting on the old value. +All three were observed in one session on a real project. + +**The Explorer does not notice a recipe edited on disk.** `tree.ts` resolves a node's +`SandboxRef` — and therefore its `key` — when the tree renders, and only refreshes on +focus change, terminal events, and lifecycle completion. Editing `.sandbox/config.yaml` +outside VS Code (or in another window) leaves nodes carrying the previous `key`, so +Connect goes out under the *old* sandbox name until something happens to refresh. Observed +consequence: a sandbox key was renamed to escape an unusable name, Connect was clicked +immediately, and it failed under the old name — which read as "renaming does not help" and +cost several wrong conclusions before the stale node was found. The recipe is a committed +file the docs actively tell people to edit by hand (FR-009), so editing it outside the form +is a supported path, not an edge case. + +**New-sandbox keys recycle.** The form derives a new key from the agent id and takes the +first free one — `claude`, then `claude-2`, `claude-3`. The rule keeps the key independent +of the mutable `title`, which is right, but the *source* being the agent id has two costs. +Sandbox names read `tomis-next-claude-2-37ab1` rather than anything about the sandbox; and +because the search is a dense counter over a tiny namespace, **a key freed by a rename or a +removal is immediately handed to the next new sandbox**. In the session above that reissued +a key whose sbx name was permanently unusable (Architecture §14 / upstream +[#129](https://github.com/docker/sbx-releases/issues/129)) moments after the rename that +escaped it — the fix walked straight back into the fault. + +**Nothing remembers an unusable name.** When `sbx create` fails because the name is claimed +by leaked runtime state, spec 013 explains the failure well (`explainCreateFailure`) but the +extension forgets it immediately. The same name can be derived again, and will be. + +The through-line: the recipe is treated as a snapshot taken once and as a namespace with no +memory. Both assumptions are cheap to hold and both were observed producing wrong actions. + +## What changed + +**FR-009 (extended): the Explorer follows the recipe.** A `FileSystemWatcher` over +`.sandbox/*.yaml` (the recipe and `identity.yaml`), held by `extension.ts` and debounced +300 ms, refreshes the status bar and fires `sandboxConsole.refresh` — which drops the +tree's cached refs — so a recipe edited on disk takes effect without a manual Refresh. +Read-only discovery is preserved (FR-002): the watcher observes, it never writes. + +**FR-057: new-sandbox keys derive from the title.** At *creation only*, the key is derived +from the entered title, sanitised to the key charset (`^[A-Za-z0-9][A-Za-z0-9._-]*$`, capped +at 40 chars) — `Backend API (v2)` → `backend-api-v2` — falling back to the agent id when the +title is empty or sanitises away (e.g. a fully non-ASCII title). The uniqueness suffix stays +for genuine collisions. The key remains **frozen** afterwards: the form already locks it in +edit mode, so renaming a title never touches the sandbox name, generated Dockerfile, or +image tag. The invariant was only ever "the key must not *track* the title"; seeding it +once does not violate that, and the previous wording conflated the two. + +Consequence to accept deliberately: the default Dockerfile name follows the key +(`.Dockerfile`), so two Claude sandboxes no longer *default* to one shared +`claude.Dockerfile` and one shared image. Sharing stays available — typing the same file +name in both is still how it is expressed, and is now explicit rather than accidental. + +**FR-057: unusable names are remembered.** `sbx.ts` now raises the leaked-state 500 as a +typed `NameClaimedError` (so no caller re-parses CLI output), `ops.ts` records the name in +the new `names.ts` — per working copy, `workspaceState` — when a create fails with it, and +key derivation skips any candidate that would produce a recorded name. This is a workaround +for an upstream defect with no released fix, so it is scoped, local, and self-limiting: it +stores names (capped at the last 50), never removes sandboxes, and a `sbx reset` simply +makes the list irrelevant. + +## Decisions + +- **Watch the recipe, do not poll it.** Discovery is already event-driven and silent + (FR-002, spec 009); a watcher fits that model, a timer would reintroduce background + `sbx ls` traffic for a file that changes rarely. +- **Derive the key at creation, freeze it forever.** The alternative — keeping the key in + sync with the title — would rename sandbox names, Dockerfiles and image tags under a live + instance, which is exactly what the original rule existed to prevent. +- **Remember unusable names rather than probing for them.** There is no way to ask sbx + whether a name is claimed by leaked state: `sbx ls` does not list it and `sbx rm` reports + "not found" (Architecture §14). Observed failure is the only available signal. +- **Local, not committed.** The record is a property of one machine's sbx runtime, not of + the project, so it belongs in `workspaceState` beside the identity — never in the + committed recipe. diff --git a/src/extension.ts b/src/extension.ts index 81f8d91..f04b4f3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,10 @@ import * as vscode from "vscode"; import { agentLabel } from "./agents"; -import { readConfig, SandboxConfig, SandboxSpec } from "./config"; +import { CONFIG_DIR, readConfig, SandboxConfig, SandboxSpec } from "./config"; import { openForm, showInvalidConfig } from "./form"; import { ensureIdentity, readIdentity } from "./identity"; import * as log from "./log"; +import * as names from "./names"; import * as ops from "./ops"; import * as sandbox from "./sandbox"; import * as sbx from "./sbx"; @@ -21,6 +22,8 @@ let extCtx: vscode.ExtensionContext; export function activate(context: vscode.ExtensionContext): void { extCtx = context; + // FR-057: the record of sbx names this machine can no longer create is per working copy. + names.init(context.workspaceState); statusItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, 100 @@ -62,6 +65,7 @@ export function activate(context: vscode.ExtensionContext): void { ); registerExplorer(context); + watchRecipe(context); // FR-002: startup discovery is SILENT — it only feeds the status bar and the // Explorer. Opening a workspace never raises notifications; Connect lives one // click away in the status bar. @@ -70,6 +74,57 @@ export function activate(context: vscode.ExtensionContext): void { void refreshProjectScript(); } +/** + * FR-009: follow the recipe on disk. `.sandbox/config.yaml` is a committed file the docs + * actively tell people to edit by hand, and it also changes under a git pull or a second + * VS Code window — but the status bar and the Explorer resolve their keys (and therefore + * sbx names) when they render. Without this, editing the recipe left both surfaces acting + * on the previous key until something unrelated refreshed them, so Connect went out under + * the old sandbox name — a rename appeared not to work. + * + * Watching, not polling: discovery stays event-driven and silent (FR-002), and the watcher + * only observes — it never writes into `.sandbox/`. + */ +function watchRecipe(context: vscode.ExtensionContext): void { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + return; + } + // `.sandbox/*.yaml` = the recipe plus identity.yaml (a regenerated id changes every + // sandbox name too). Scoped to the workspace folder so a nested repo cannot fire it. + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(folder, `${CONFIG_DIR}/*.yaml`) + ); + let pending: NodeJS.Timeout | undefined; + const changed = (): void => { + // A single save can emit several events; refreshing per event would run one `sbx ls` + // each. Coalesce them — the tree and the status bar re-read the file when they reload. + if (pending) { + clearTimeout(pending); + } + pending = setTimeout(() => { + pending = undefined; + void refreshStatus(); + void vscode.commands + .executeCommand("sandboxConsole.refresh") + .then(undefined, () => undefined); + }, 300); + }; + context.subscriptions.push( + watcher, + watcher.onDidChange(changed), + watcher.onDidCreate(changed), + watcher.onDidDelete(changed), + { + dispose: () => { + if (pending) { + clearTimeout(pending); + } + }, + } + ); +} + export function deactivate(): void { // Sandboxes are long-lived and intentionally outlive the extension host. } diff --git a/src/form.ts b/src/form.ts index 2c075e5..6409e9f 100644 --- a/src/form.ts +++ b/src/form.ts @@ -10,6 +10,7 @@ import { writeConfig, } from "./config"; import { ensureIdentity } from "./identity"; +import * as names from "./names"; import * as ops from "./ops"; import * as sandbox from "./sandbox"; import * as sbx from "./sbx"; @@ -203,20 +204,38 @@ async function apply( if (mode.kind === "edit") { key = mode.key; agent = oldSpec?.agent ?? payload.agent; - } else if (pinned.key && config.sandboxes.some((s) => s.key === pinned.key)) { + } else if ( + pinned.key && + config.sandboxes.some((s) => s.key === pinned.key) && + // FR-057: unless the previous attempt is what taught us the name is unusable — reusing + // it would fail identically, which is the loop this requirement exists to break. + !names.isUnusable(sandbox.sandboxName(projectName, pinned.key, identity.id)) + ) { // Retry after a failed create: the entry is already persisted — update it // in place instead of appending a duplicate "-2". key = pinned.key; agent = payload.agent; } else { - // The key is the stable technical id (sbx name, file names, image tags). It is - // deliberately NOT derived from the title — the title is a display label the - // user may rename at any time without touching instances or images. + // FR-057: the key is the stable technical id (sbx name, file names, image tags), so it + // is seeded ONCE — from the title the user just typed, falling back to the agent id + // when the title is empty or sanitises away — and frozen from then on: the form locks + // it in edit mode, so renaming a title never touches a sandbox name, Dockerfile or + // image tag. The invariant is that the key must not *track* the title; seeding it once + // does not violate that, and it stops sandbox names reading `-claude-2-`. agent = payload.agent; - key = agent; + const base = keyFromTitle(payload.title) ?? agent; const used = new Set(config.sandboxes.map((s) => s.key)); - for (let n = 2; used.has(key); n++) { - key = `${agent}-${n}`; + key = base; + // Skip keys already in the recipe AND keys whose sbx name is known to be unusable + // (FR-057): a name freed by a rename/removal used to be handed straight back to the + // next sandbox, which is how a create walked back into a permanently claimed name. + for ( + let n = 2; + used.has(key) || + names.isUnusable(sandbox.sandboxName(projectName, key, identity.id)); + n++ + ) { + key = `${base}-${n}`; } } @@ -281,10 +300,20 @@ async function apply( // argv allowlists must fail the save, not poison the committed recipe. const ref = sandbox.ref(projectName, spec, identity.id); - const exists = config.sandboxes.some((s) => s.key === key); + // FR-057: a retry that had to abandon its pinned key (its sbx name turned out to be + // permanently claimed) drops the dead entry — nothing was ever created under it, so + // keeping it would only leave the recipe carrying a definition that cannot be built. + const abandoned = + mode.kind === "new" && pinned.key && pinned.key !== key + ? pinned.key + : undefined; + const entries = abandoned + ? config.sandboxes.filter((s) => s.key !== abandoned) + : config.sandboxes; + const exists = entries.some((s) => s.key === key); let sandboxes = exists - ? config.sandboxes.map((s) => (s.key === key ? spec : s)) - : [...config.sandboxes, spec]; + ? entries.map((s) => (s.key === key ? spec : s)) + : [...entries, spec]; if (spec.default) { // Single default per recipe (FR-050): the other entries lose the flag. sandboxes = sandboxes.map((s) => @@ -357,6 +386,28 @@ class HandledError extends Error {} * impossible; uppercase/underscore are fine (image tags are tagSafe()d separately). */ const KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +/** A key derived from a title stays readable in a sandbox name; a pasted sentence does not. */ +const KEY_MAX = 40; + +/** + * FR-057: seed a new sandbox's key from its title — `Backend API (v2)` → `backend-api-v2`. + * The result must satisfy KEY_RE (it names files under `.sandbox/` and feeds image tags + * and the sbx name), so it is lowercased, disallowed runs become "-", and separators are + * collapsed/trimmed. Returns undefined when nothing usable is left (empty title, or a + * fully non-ASCII one) — the caller then falls back to the agent id, as before. + */ +function keyFromTitle(title: string): string | undefined { + const safe = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/[._-]{2,}/g, "-") + .replace(/^[^a-z0-9]+/, "") + .slice(0, KEY_MAX) + .replace(/[._-]+$/, ""); + return KEY_RE.test(safe) ? safe : undefined; +} + /** * One component of a derived docker image tag. Docker repository names need [a-z0-9] * runs joined by single separators; project/folder names often aren't (spaces, "+", …): @@ -614,7 +665,7 @@ function getHtml(data: InitData, nonce: string): string {
- +
diff --git a/src/names.ts b/src/names.ts new file mode 100644 index 0000000..2d55c6a --- /dev/null +++ b/src/names.ts @@ -0,0 +1,46 @@ +import * as vscode from "vscode"; + +/** + * FR-057: the sbx names this machine can no longer create. + * + * When `sbx create` fails because the name is still claimed by leaked runtime state + * (Architecture §14, upstream docker/sbx-releases#129), that name is dead until + * `sbx reset` — and nothing in the CLI can report it: `sbx ls` does not list it and + * `sbx rm` answers "not found". The observed failure is the only signal there is, so it + * is recorded here and key derivation skips any candidate that would produce a recorded + * name (form.ts) instead of walking straight back into the fault. + * + * State is per working copy (`workspaceState`) because it describes THIS machine's sbx + * runtime, not the project: it must never reach the committed recipe. The record is + * self-limiting — it only stores names, never removes sandboxes, and a `sbx reset` simply + * makes it irrelevant. + */ + +const KEY = "sandboxConsole.unusableNames"; + +/** Keep the record bounded; the useful entries are the recent ones. */ +const LIMIT = 50; + +let store: vscode.Memento | undefined; + +/** Wire the workspace store (extension activation). Before this every query is empty. */ +export function init(memento: vscode.Memento): void { + store = memento; +} + +function all(): string[] { + return store?.get(KEY) ?? []; +} + +/** True when creating this sbx name is known to fail on this machine. */ +export function isUnusable(name: string): boolean { + return all().includes(name); +} + +/** Record a name whose `sbx create` failed with the leaked-state error. */ +export async function remember(name: string): Promise { + if (!store || all().includes(name)) { + return; + } + await store.update(KEY, [...all(), name].slice(-LIMIT)); +} diff --git a/src/ops.ts b/src/ops.ts index 3f21ddb..be6a9ce 100644 --- a/src/ops.ts +++ b/src/ops.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import * as images from "./images"; import * as log from "./log"; +import * as names from "./names"; import * as sandbox from "./sandbox"; import * as sbx from "./sbx"; import * as secrets from "./secrets"; @@ -238,6 +239,12 @@ async function createSandbox( } ); } catch (err) { + if (err instanceof sbx.NameClaimedError) { + // FR-057: the name is claimed by leaked runtime state and will fail forever. Record + // it here — where the failure is observed — so the next new sandbox cannot be handed + // the same name (the extension has no other way to learn this; see names.ts). + await names.remember(ref.name); + } throw err instanceof Cancelled ? new Cancelled(await rollbackCreate(ref)) : err; } } diff --git a/src/sbx.ts b/src/sbx.ts index bf1b3fd..006c708 100644 --- a/src/sbx.ts +++ b/src/sbx.ts @@ -175,10 +175,20 @@ export async function create( const { stderr, code } = await run(args, ctx); if (code !== 0) { const message = stderr.trim() || `sbx create failed for ${opts.name}`; - throw new Error(explainCreateFailure(opts.name, message)); + throw createFailure(opts.name, message); } } +/** + * FR-057: a create that failed because the sbx runtime still holds the name. Typed rather + * than text-matched at the call site so callers can react to the condition (ops.ts records + * the name as unusable) without re-parsing CLI output outside this module. + */ +export class NameClaimedError extends Error {} + +/** The leaked-state 500 (see NameClaimedError / createFailure). */ +const NAME_CLAIMED_RE = /failed to create (network|container).*already exists/i; + /** * Turn one sbx failure that is otherwise a dead end into something actionable. When a * cleanup is interrupted after sbx deletes its runtime entry but before the container / @@ -189,17 +199,17 @@ export async function create( * recovery is `sbx reset`, which destroys every sandbox on the machine. Say so, and point * at the cheap way out first. */ -function explainCreateFailure(name: string, message: string): string { - if (!/failed to create (network|container).*already exists/i.test(message)) { - return message; +function createFailure(name: string, message: string): Error { + if (!NAME_CLAIMED_RE.test(message)) { + return new Error(message); } - return ( + return new NameClaimedError( `${message}\n\nThe sandbox name "${name}" is still claimed inside the sbx runtime by ` + - "leaked state — a known sbx bug (docker/sbx-releases#129) with no released fix. The " + - "name cannot be reused: give this sandbox a new, distinctive `key` in " + - ".sandbox/config.yaml — not a bare agent name like `claude`, because new sandboxes " + - "take those and would land on this same name again. Or run `sbx reset` to clear all " + - "sbx state (this destroys every sandbox on the machine)." + "leaked state — a known sbx bug (docker/sbx-releases#129) with no released fix. The " + + "name cannot be reused: give this sandbox a new, distinctive `key` in " + + ".sandbox/config.yaml. New sandboxes will not land on it again — the name is " + + "remembered locally and skipped (FR-057). Or run `sbx reset` to clear all sbx state " + + "(this destroys every sandbox on the machine)." ); }