Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
51 changes: 38 additions & 13 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
34 changes: 34 additions & 0 deletions docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
(`<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
Expand Down
90 changes: 90 additions & 0 deletions docs/specs/completed/014 - Truthful Recipe State.md
Original file line number Diff line number Diff line change
@@ -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
(`<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.
Loading