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>
108 lines
4.7 KiB
TypeScript
108 lines
4.7 KiB
TypeScript
/**
|
||
* Shared wording for container base-image migration.
|
||
*
|
||
* The banner, the pre-flight modal and the report all have to make the same
|
||
* promise about what survives, or the feature reads as another Reset. It is
|
||
* written once here so the three surfaces cannot drift apart.
|
||
*/
|
||
|
||
import type { PackageFailure } from "../../lib/types";
|
||
|
||
/**
|
||
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||
* — they live on the two Docker volumes, which the new container mounts as-is.
|
||
*/
|
||
export const KEPT_AUTOMATICALLY = [
|
||
"Your claude login and ~/.claude.json — no signing in again",
|
||
"Skills, agents, commands, hooks, plugins and MCP config",
|
||
"Every saved session transcript, so past sessions still resume",
|
||
"Scheduler tasks and their logs",
|
||
"SSH keys, git config and shell history",
|
||
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
|
||
];
|
||
|
||
export const KEPT_WHY =
|
||
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
|
||
|
||
/**
|
||
* The honest list of what the writable layer holds, because the modal's own
|
||
* sections name more than one thing and copy that says "the only thing" while
|
||
* the section below it offers to copy files is copy the user cannot trust.
|
||
*/
|
||
export const LOST_WITHOUT_REPLAY =
|
||
"What a new base does not carry over is what lives in the container itself: system packages you installed with apt, global npm packages, and files under /usr/local, /opt, /srv or loose in /workspace. This update puts those back.";
|
||
|
||
/**
|
||
* The exception, and it is not a small one — so it gets its own line wherever
|
||
* the update is offered. Reinstalling `postgresql` gets the package back and an
|
||
* empty cluster with it; the ordinary Reset-free recreate keeps /var because it
|
||
* builds from the project's own saved image, so this is the one way in which
|
||
* updating the base is more destructive than leaving it alone.
|
||
*/
|
||
export const DATA_NOT_CARRIED =
|
||
"Data written under /var is not carried across and reinstalling the package does not bring it back — a database in /var/lib, a site in /var/www. Back it up from inside the container before you update.";
|
||
|
||
/**
|
||
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
|
||
* it swaps the system layer back and leaves both volumes exactly where the
|
||
* migrated session left them.
|
||
*/
|
||
export const ROLLBACK_SCOPE =
|
||
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
|
||
|
||
export const ROLLBACK_DISK_COST =
|
||
"A rollback image is close to a full second copy of the container — snapshots here run 3.8–12.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
|
||
|
||
/** Shown mid-run, where rollback is not a button but is still the safety net. */
|
||
export const MID_RUN_SAFETY =
|
||
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
|
||
|
||
export const REPLAY_COST =
|
||
"Needs network access and usually takes 1–2 minutes.";
|
||
|
||
/** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */
|
||
export function formatDataSize(bytes: number): string {
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let value = bytes;
|
||
let unit = 0;
|
||
while (value >= 1000 && unit < units.length - 1) {
|
||
value /= 1000;
|
||
unit += 1;
|
||
}
|
||
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
|
||
}
|
||
|
||
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||
export function formatSnapshotDate(iso: string | null): string | null {
|
||
if (!iso) return null;
|
||
const ms = Date.parse(iso);
|
||
if (Number.isNaN(ms)) return null;
|
||
return new Date(ms).toLocaleDateString(undefined, {
|
||
day: "numeric",
|
||
month: "short",
|
||
});
|
||
}
|
||
|
||
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
|
||
export function joinFeatures(features: string[]): string {
|
||
if (features.length === 0) return "";
|
||
if (features.length === 1) return features[0];
|
||
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
|
||
}
|
||
|
||
/** The exact line to paste into a shell to finish a partial migration by hand. */
|
||
export function aptRetryCommand(failures: PackageFailure[]): string {
|
||
return `sudo apt-get install -y ${failures.map((f) => f.name).join(" ")}`;
|
||
}
|
||
|
||
/** Plain-text form of a partial report, for the copy button. */
|
||
export function failureReportText(failures: PackageFailure[]): string {
|
||
const lines = failures.map((f) => `${f.name}: ${f.reason}`);
|
||
return [
|
||
"Packages that could not be reinstalled:",
|
||
...lines,
|
||
"",
|
||
aptRetryCommand(failures),
|
||
].join("\n");
|
||
}
|