Files
Triple-C/app/src/components/projects/MigrateContainerModal.tsx
T
shadow-testandClaude Opus 5 2de00b3c55 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>
2026-08-09 19:35:39 -07:00

428 lines
15 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { ContainerStaleness, MigrationOptions } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Toggle from "../ui/Toggle";
import { SwitchRow } from "../ui/Field";
import MigrationReportCard from "./MigrationReportCard";
import MigrationInterruptedCard from "./MigrationInterruptedCard";
import type { ContainerMigration } from "../../hooks/useContainerMigration";
import {
DATA_NOT_CARRIED,
KEPT_AUTOMATICALLY,
KEPT_WHY,
LOST_WITHOUT_REPLAY,
MID_RUN_SAFETY,
REPLAY_COST,
ROLLBACK_DISK_COST,
ROLLBACK_SCOPE,
formatDataSize,
formatSnapshotDate,
} from "./migrationCopy";
interface Props {
projectName: string;
staleness: ContainerStaleness | null;
migration: ContainerMigration;
onClose: () => void;
}
function Section({
title,
children,
control,
}: {
title: string;
children: React.ReactNode;
control?: React.ReactNode;
}) {
return (
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3.5 py-3">
{control ? (
<SwitchRow label={title} control={control} />
) : (
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">{title}</h3>
)}
<div className="mt-2 space-y-1.5">{children}</div>
</section>
);
}
function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) {
return (
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
{items.map((item) => (
<li
key={item}
className={`text-xs leading-snug text-[var(--text-secondary)] ${
mono ? "font-mono break-all" : ""
}`}
>
{item}
</li>
))}
</ul>
);
}
/**
* Pre-flight, progress and outcome for a base-image migration, in one dialog.
*
* Order matters here. The reassurance comes first — almost nothing painful is
* at risk, because the two volumes re-attach untouched — and only then the
* short list of things that genuinely have to be put back. Leading with the
* options would read as "pick which of your data to lose".
*
* Once the run starts the dialog stays **dismissible**: this takes minutes, and
* a modal that blocks the whole app for the duration is worse than no progress
* UI at all. Closing it hides a view; the work and its log live in the hook.
*/
export default function MigrateContainerModal({
projectName,
staleness,
migration,
onClose,
}: Props) {
const [replayPackages, setReplayPackages] = useState(true);
const [copyPaths, setCopyPaths] = useState(true);
const [keepRollback, setKeepRollback] = useState(true);
const logRef = useRef<HTMLDivElement>(null);
const { running, report, interrupted, log, phaseMessage, busy, probeSettled } =
migration;
const aptDelta = staleness?.apt_delta ?? [];
const npmDelta = staleness?.npm_global_delta ?? [];
const verbatim = staleness?.verbatim_paths ?? [];
const atRisk = staleness?.unpreserved_data ?? [];
const gains = staleness?.missing_features ?? [];
const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null);
// Follow the tail of the apt output, the way a terminal would.
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [log.length]);
const start = () => {
const options: MigrationOptions = {
// Deliberately *not* `&& verbatim.length > 0`. That looked like a
// harmless optimisation but read the toggle's meaning off a probe that
// may not have landed, so a null `staleness` sent `copy_paths: false`
// and the backend — which recomputes the real set but honours the flag —
// skipped files that did exist. The backend already skips the step when
// its own set comes out empty; that is the only place that knows.
replay_packages: replayPackages,
copy_paths: copyPaths,
keep_rollback: keepRollback,
};
void migration.start(options);
};
// ---- Unfinished ---------------------------------------------------------
// Ahead of the report, for the reason spelled out in MigrationInterruptedCard:
// Keep is not a legitimate action on a container that is mid-swap.
if (interrupted) {
return (
<Modal
title={`Update container base — ${projectName}`}
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Close
</Button>
}
>
<MigrationInterruptedCard
record={interrupted}
busy={busy || running}
onResume={() => void migration.resume()}
onRollback={() => void migration.rollback().then(onClose)}
/>
</Modal>
);
}
// ---- Outcome ------------------------------------------------------------
if (report) {
return (
<Modal
title={`Update container base — ${projectName}`}
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Close
</Button>
}
>
<MigrationReportCard
report={report}
busy={busy}
onKeep={() => void migration.keep().then(onClose)}
onRollback={() => void migration.rollback().then(onClose)}
onDismiss={() => void migration.dismiss().then(onClose)}
/>
</Modal>
);
}
// ---- Progress -----------------------------------------------------------
if (running) {
return (
<Modal
title={`Updating container base — ${projectName}`}
description="This keeps running if you close it. You can carry on using the app."
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Hide
</Button>
}
>
<div className="space-y-3">
<p
role="status"
aria-live="polite"
className="text-[13px] text-[var(--text-primary)]"
>
{phaseMessage ?? "Starting…"}
</p>
<div
ref={logRef}
data-testid="migration-log"
className="h-56 overflow-y-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] whitespace-pre-wrap break-all select-text"
>
{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}
</div>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{MID_RUN_SAFETY}
</p>
</div>
</Modal>
);
}
// ---- Pre-flight ---------------------------------------------------------
return (
<Modal
title={`Update container base — ${projectName}`}
description={
snapshot
? `Rebuilds this container on the current base image. It is running on a saved image from ${snapshot}.`
: "Rebuilds this container on the current base image."
}
onClose={onClose}
widthClassName="w-[36rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={!probeSettled}
onClick={start}
>
Update container base
</Button>
</>
}
>
<div className="space-y-3">
{/* 0. Until the probe lands, every list below is "not known" wearing
"empty"'s clothes. Say which one it is, and do not let the run
start on an unread delta. */}
{!probeSettled && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
role="status"
aria-live="polite"
>
<p className="text-xs text-[var(--text-primary)] leading-snug">
Still working out what this container has that the current base
does not. The lists below are not complete until it finishes, so
the update cannot start yet.
</p>
</section>
)}
{/* 1. Reassurance first. Not a choice — a statement of fact. */}
<Section title="Kept automatically">
<BulletList items={KEPT_AUTOMATICALLY} />
<p className="text-xs text-[var(--text-secondary)] leading-snug">{KEPT_WHY}</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{LOST_WITHOUT_REPLAY}
</p>
</Section>
{/* 1b. The one thing that is genuinely destroyed. Directly under the
reassurance, because a user who reads only the top of this dialog
must not come away thinking nothing is at stake. */}
<section
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
data-testid="migration-unpreserved"
>
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
{atRisk.length > 0
? `Destroyed, and not restored by this update (${atRisk.length})`
: "Not carried across"}
</h3>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{DATA_NOT_CARRIED}
</p>
{probeSettled ? (
atRisk.length > 0 ? (
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
{atRisk.map((d) => (
<li
key={d.path}
className="text-xs leading-snug text-[var(--text-primary)]"
>
<span className="font-mono break-all">{d.path}</span>
<span className="text-[var(--text-secondary)]">
{" "}
{formatDataSize(d.bytes)} in {d.file_count} file
{d.file_count === 1 ? "" : "s"}
</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-[var(--text-secondary)]">
Nothing was found under <code className="font-mono">/var</code>{" "}
on this container, so there is nothing here to lose.
</p>
)
) : (
<p className="text-xs text-[var(--text-secondary)]">
Not checked yet.
</p>
)}
</section>
{/* 2. The apt replay. */}
<Section
title={`Reinstalled from the new base's repos (${aptDelta.length})`}
control={
<Toggle
label="Reinstall system packages from the new base's repositories"
checked={replayPackages}
onChange={setReplayPackages}
/>
}
>
{aptDelta.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
{/* "None found" and "not looked yet" are different sentences.
Printing the first while the probe is still running is how a
user ends up believing a delta was empty when it was unread. */}
{probeSettled
? "No extra apt packages were found on this container."
: "Still checking which apt packages this container added."}
</p>
) : (
<BulletList items={aptDelta} mono />
)}
{npmDelta.length > 0 && (
<>
<p className="text-xs text-[var(--text-secondary)] pt-1">
Global npm packages ({npmDelta.length}):
</p>
<BulletList items={npmDelta} mono />
</>
)}
<p className="text-xs text-[var(--text-secondary)]">{REPLAY_COST}</p>
</Section>
{/* 3. Verbatim copies — usually nothing once the probe has settled, so
usually not shown at all. Shown while it has not, because a hidden
section reads as "there is nothing here". */}
{(verbatim.length > 0 || !probeSettled) && (
<Section
title={
probeSettled
? `Copied across as-is (${verbatim.length})`
: "Copied across as-is"
}
control={
<Toggle
label="Copy user-authored files across as-is"
checked={copyPaths}
onChange={setCopyPaths}
/>
}
>
<p className="text-xs text-[var(--text-secondary)]">
Content under <code className="font-mono">/usr/local</code>,{" "}
<code className="font-mono">/opt</code>,{" "}
<code className="font-mono">/srv</code> and non-bind-mounted{" "}
<code className="font-mono">/workspace</code> that belongs to no
package, so it cannot be reinstalled from a repository.
</p>
{probeSettled ? (
<BulletList items={verbatim} mono />
) : (
<p className="text-xs text-[var(--text-secondary)]">
Still checking what is there.
</p>
)}
</Section>
)}
{/* 4. The rollback image, with its real disk cost stated. */}
<Section
title="Keep a rollback image until I confirm"
control={
<Toggle
label="Keep a rollback image until I confirm"
checked={keepRollback}
onChange={setKeepRollback}
tone="caution"
/>
}
>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_DISK_COST}
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_SCOPE}
</p>
</Section>
{gains.length > 0 && (
<section className="border border-[var(--success)]/40 bg-[var(--success-muted)] rounded-[var(--radius-panel)] px-3.5 py-3">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
You will gain
</h3>
<ul className="mt-1.5 space-y-1">
{gains.map((feature) => (
<li
key={feature}
className="text-xs leading-snug text-[var(--text-secondary)]"
>
<span aria-hidden="true" className="text-[var(--success)]">
+{" "}
</span>
{feature}
</li>
))}
</ul>
{/* "A different version", not "behind" — the count measures drift
from the base, not a guarantee that each one is an upgrade. */}
{(staleness?.outdated_package_count ?? 0) > 0 && (
<p className="mt-1.5 text-xs text-[var(--text-secondary)]">
Plus {staleness?.outdated_package_count} package
{staleness?.outdated_package_count === 1 ? "" : "s"} the current
base carries at a different version, security updates among them.
</p>
)}
</section>
)}
</div>
</Modal>
);
}