Fix review findings: secrets in snapshots, URL spoofing, migration data loss

Adversarial review of the branch produced findings across four areas.
This addresses them, plus the Windows CI environment.

Secrets. commit_container_snapshot baked the container's full env into
the per-project snapshot image, so the shared OAuth token — and the AWS
keys, git token and gateway master key — outlived revocation and were
readable via docker inspect. Verified against Engine 29.6 that a commit
body's config merges over the container's: keys cannot be dropped but
can be overwritten, so all of them now commit as KEY=. clear_claude_token
additionally rewrites images from earlier builds and reports honestly
when a tag could not be rewritten.

The recommendation to move the token out of env entirely was not taken,
with reasoning: apiKeyHelper is a different auth method that outranks
CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no
file-based delivery exists. The durable exposure — the image — is what
is closed here. Separately noted, not fixed: entrypoint.sh captures the
token into the scheduler's .env inside the persisted volume.

URL spoofing. Three call sites reached openUrl with container-controlled
strings, one of which the review missed (the WebLinksAddon handler).
The sign-in URL was scraped from container output with a longest-match
tie-break and no userinfo check, so claude.ai@evil.tld rendered as
"claude.ai…" in a truncating element. There is now one sanitizer in
front of every sink — scheme allowlist, no userinfo, C0/C1 and quote
rejection, host allowlist for the sign-in case, first-match — and the
origin renders un-truncated. The toast is keyed so a changed URL
remounts, closing a bait-and-switch where the user read one URL and
clicked another.

Migration. The rollback pin was best-effort: a tag failure was logged
and the migration continued past remove_container, after which the
final commit overwrote the only copy of the old system layer. It now
aborts before anything destructive and reads the tag back. /var was
destroyed while the ordinary recreate path preserves it — making the
"safe" alternative to Reset more destructive than Reset's alternative;
data-bearing subtrees are now detected and disclosed in the pre-flight
rather than copied, since tarring a live database onto a different
base's packages is a corruption risk. resume_migration now verifies the
migration-state label instead of reporting success for a container that
never swapped. dismiss actually resolves the record rather than leaving
the feature permanently refusing to migrate. Start and Reset are guarded
while a migration is live.

Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and
advertised URL are derived together so they cannot drift. Disabling it
now stops it. App exit runs teardown concurrently under a budget with a
visible shutting-down state instead of blocking for minutes. Auto-starts
retry when Docker is not up yet, and the polling-recovery path now
reconciles, so interrupted migrations are still recovered. Auth-bridge
forwards are capped, closing a container-driven fd exhaustion.

Windows CI. build-windows failed on this branch with "linker link.exe
not found". The runner had no MSVC build tools and the workflow assumed
a hand-provisioned machine, so a bare runner registers, accepts jobs and
fails at link time after downloading the whole crate graph. The job now
installs the VC++ workload when vswhere cannot find it, matching how it
already conditionally installs Rust and Node.

192 Rust tests, 274 frontend tests, both builds clean, zero warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 19:35:39 -07:00
co-authored by Claude Opus 5
parent eb1324cb16
commit 2de00b3c55
43 changed files with 4348 additions and 346 deletions
+26 -3
View File
@@ -112,9 +112,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `image.rs` — Image build/pull with progress streaming
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
Binds `0.0.0.0` — unlike STT — because *project containers*, not the host process, consume
it; it therefore **always** sets a LiteLLM `master_key`, since LiteLLM without one accepts
any key.
Its bind address is **detected, never `0.0.0.0`** — unlike STT, *project containers* consume
it, so loopback alone is not always enough: Docker Desktop gets `127.0.0.1` (containers reach
it via `host.docker.internal`), native Linux gets the default bridge gateway (`172.17.0.1`).
`GatewayBinding` derives the bind address and the advertised `base_url` together so they
cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host firewalls —
in front of a container config holding a billed provider key. It also **always** sets a
LiteLLM `master_key`, since LiteLLM without one accepts any key.
- `migration.rs` — Base-image migration: manifest capture via throwaway containers, the pure
delta computation (dpkg-ownership filter, bind-mount exclusion, verbatim-copy set), and the
crash-recovery state machine. See "Base-image migration" below.
@@ -180,6 +184,25 @@ security update. Migration is the non-destructive way out; Reset is the destruct
label plus the persisted state file let `reconcile_project_statuses` offer resume or rollback.
- **Rollback restores the system layer only.** The volumes are never touched at any point, so work
done in `$HOME` during a migrated session survives a rollback. Say so in any UI copy.
- **`/var` is never copied either, and that is the one way migration is *more* destructive than
the ordinary recreate.** A recreate builds from the project's snapshot, so `/var/lib/postgresql`
rides along; a migration builds from the base and the apt replay hands back an empty cluster.
Copying a live database's files onto a different base's version of the same package is a
corruption risk, not a fix — so the answer is disclosure. `unpreserved_data()` reports
first-level directories under `/var/lib` and `/var/www` that the base does not ship *and* that
hold non-dpkg-owned files (which is what keeps `/var/lib/apt` and `/var/lib/dpkg` out of it),
and the pre-flight, the banner and the finished report all name them. Do not make this silent.
- **The rollback pin is not best-effort.** After `commit_container_snapshot` the commit is the only
copy of the old system layer, so a `docker tag` that fails — or succeeds without the reference
resolving — aborts the migration before `remove_container`. Same rule in reverse for
`rollback_migration`: the image is confirmed to exist before the container is destroyed.
- **`resume` must check the container's `triple-c.migration-state` label**, exactly as
`reconcile_migration` does. Without it a record left behind by a failed commit "resumes" into
the *old, unmigrated* container and commits it as migrated.
- **Anything that stops, removes or recreates a project's container consults
`migration_commands::is_migrating`.** The window between `remove_container` and the create that
follows looks exactly like "no container" to Start, and Reset would delete the volumes out from
under a live run.
- **`/etc` is never copied**, only reported: the snapshot lineage has
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
having both breaks every `apt-get update` on a duplicate source. Verified, not theoretical.