Migrate a project onto a new base image without losing its volumes

Projects were pinned to the image they were first created from. Both
create paths preferred triple-c-snapshot-<id>:latest whenever it
existed, and container_needs_recreation compared the container's live
image against the triple-c.image label — which create_container wrote
from the same image it created from. A tautology that could never fire.
The only escape was Reset, which calls remove_project_volumes and
destroys the login, skills and transcripts.

Measured consequences on this host: real projects are missing socat (so
the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not
work), plus Mission Control and triple-c-sso-refresh, and sit 61
packages behind the base including ca-certificates, openssl and curl.

Detection. create_container now writes triple-c.base-image-id (the image
ID, not RepoDigests, which local-built and custom images do not have)
and triple-c.create-image. container_needs_recreation takes the expected
create-image and compares against the latter, so the check means
something. base-image-id is deliberately NOT compared: a base bump would
otherwise silently recreate from the snapshot, consuming the "you should
migrate" signal without migrating. Staleness is surfaced, never acted on
automatically.

Migration keeps the volumes. /home/claude and ~/.claude are volumes and
the image's copy is seed-only — permanently masked after first mount —
so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH
keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only
root-level state is rebuilt: apt packages are replayed against the new
base rather than copied, so no stale libc is dragged forward, and
/usr/local, /opt and the non-bind-mounted parts of /workspace are copied
verbatim with tar --skip-old-files so they can never clobber a newer
base binary.

docker diff is not used: on a snapshot-derived container it reports only
changes since the last commit. Raw image-vs-image diffing is filtered
through dpkg ownership because it otherwise lies — 8,677 raw path
differences on a real project reduced to 2 genuinely user-authored
files, both loose /workspace-root files.

Crash safety. snapshot:latest keeps pointing at the old image until the
final commit, so any crash before it self-heals on next start. Later
crashes are caught by reconcile_project_statuses. The rollback pin is a
docker tag: 0.057s and 0 bytes. Rollback restores the system layer only
— volumes are never touched — and the UI says so rather than implying a
time machine.

Fixes an infinite recreation loop shipped with the MCP removal. docker
commit propagates labels to the image, so a container created from a
snapshot inherited its non-empty triple-c.mcp-fingerprint and the
one-shot shim recreated it again on every start, forever. Lineage labels
are now always written explicitly.

Documents the second, separate bug this uncovered: Dockerfile changes
under /home/claude never reach an existing project, migration or not,
because the volume masks them. Anything that must stay upgradable
belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh.

145 Rust tests, 227 frontend tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 18:19:12 -07:00
co-authored by Claude Opus 5
parent cc5f691677
commit d42b741337
26 changed files with 5704 additions and 58 deletions
@@ -0,0 +1,80 @@
/**
* 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.";
export const LOST_WITHOUT_REPLAY =
"What a new base does not carry over is the root-level system packages you installed with apt. Those are the only thing this update has to put back.";
/**
* 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.812.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 12 minutes.";
/** `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");
}