Files
Triple-C/app/src/components/projects/home/ContainerMigrationBanner.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

240 lines
8.8 KiB
TypeScript

import type { ContainerMigration } from "../../../hooks/useContainerMigration";
import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator";
import MigrationReportCard from "../MigrationReportCard";
import MigrationInterruptedCard from "../MigrationInterruptedCard";
import { formatSnapshotDate, joinFeatures } from "../migrationCopy";
interface Props {
migration: ContainerMigration;
/** Migration mirrors Reset's gate: the container has to be stopped. */
canMigrate: boolean;
onOpen: () => void;
}
const SHELL =
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
/**
* The Overview answer to "why is this container behaving oddly?".
*
* It leads with the *features* that are missing, not image digests: a user does
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
* that host-browser opening and the auth bridge do not work. Digests are the
* evidence, not the message.
*
* It also has to survive the run: an in-flight migration, an interrupted one,
* and the report are all shown here, because the modal is dismissable and the
* outcome must not vanish with it.
*/
export default function ContainerMigrationBanner({
migration,
canMigrate,
onOpen,
}: Props) {
const {
staleness,
probing,
probeSettled,
running,
recovered,
interrupted,
report,
phaseMessage,
busy,
} = migration;
// An unfinished migration outranks its own report. The report's action row
// offers Keep, and Keep on a mid-swap container drops the rollback image
// while `:latest` still points at the old lineage — the backend's message on
// the very same record says to resume. Resume is the only honest primary
// action here, so the report card is not rendered at all.
if (interrupted) {
return (
<section
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
aria-label="Container base update was interrupted"
>
<MigrationInterruptedCard
record={interrupted}
busy={busy || running}
onResume={() => void migration.resume()}
onRollback={() => void migration.rollback()}
/>
</section>
);
}
// The report outranks staleness: after a run, the outcome is the news.
if (report) {
return (
<section
className={`${SHELL} ${
report.phase === "partial" || report.phase === "failed"
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
}`}
aria-label="Container base update result"
>
<MigrationReportCard
report={report}
busy={busy}
onKeep={() => void migration.keep()}
onRollback={() => void migration.rollback()}
onDismiss={() => void migration.dismiss()}
/>
</section>
);
}
if (running) {
return (
<section
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
aria-label="Container base update in progress"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<StatusIndicator
tone="busy"
label={
recovered
? "A container base update was already running"
: "Updating container base"
}
className="text-[13px] font-semibold"
/>
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
{phaseMessage ?? "Starting…"}
</p>
{recovered && (
<p className="mt-1 text-xs text-[var(--text-secondary)]">
It was still in progress when the app last closed. Picking it back up.
</p>
)}
</div>
<Button size="md" onClick={onOpen}>
Show progress
</Button>
</div>
</section>
);
}
if (!staleness) return null;
// `stale` is deliberately false whenever `known` is false — an unestablished
// lineage is not a claim of staleness. But a container with no base-image
// label is exactly the old container most likely to be missing things, and
// the probe says so directly. So the probe's own findings are grounds to
// speak up even though the version comparison never happened.
const probeFoundGaps =
!staleness.known &&
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
if (!staleness.stale && !probeFoundGaps) return null;
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
const features = joinFeatures(staleness.missing_features);
return (
<section
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
aria-label="Container base is out of date"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<StatusIndicator
tone="error"
label={
staleness.known
? "Container base is out of date"
: "Container is missing things the current base ships"
}
className="text-[13px] font-semibold"
/>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{staleness.known
? snapshot
? `Running on a saved image from ${snapshot}.`
: "Running on a saved image older than the current base."
: "This container predates base-image tracking, so it was probed directly."}
</p>
{staleness.missing_features.length > 0 && (
<p className="text-xs leading-snug text-[var(--text-primary)]">
{staleness.known ? "Missing: " : "The probe found these missing: "}
<span className="text-[var(--text-secondary)]">{features}.</span>
</p>
)}
{staleness.missing_features.length === 0 &&
staleness.missing_paths.length > 0 && (
<p className="text-xs leading-snug text-[var(--text-primary)]">
{staleness.known ? "Missing: " : "The probe found these missing: "}
<span className="font-mono text-[var(--text-secondary)]">
{staleness.missing_paths.join(", ")}
</span>
</p>
)}
{/* Deliberately "differ" rather than "behind": the count is a drift
measure, not a promise that every one of them is newer. */}
{staleness.outdated_package_count > 0 && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{staleness.outdated_package_count} package
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
versions on the current base, where security updates land.
</p>
)}
{staleness.probe_error && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Some checks did not complete: {staleness.probe_error}
</p>
)}
{/* An out-of-date container that also has data under /var is the one
case where updating can cost something, so it is said here and not
only behind the button. */}
{staleness.unpreserved_data.length > 0 && (
<p className="text-xs text-[var(--text-primary)] leading-snug">
Not carried across:{" "}
<span className="font-mono text-[var(--text-secondary)]">
{staleness.unpreserved_data.map((d) => d.path).join(", ")}
</span>
<span className="text-[var(--text-secondary)]">
{" "}
— back this up before updating.
</span>
</p>
)}
{!canMigrate && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{/* Distinguishing these matters: "stop the container" on a
container that is already stopped, because the probe has not
landed, reads as a bug. */}
{!probeSettled
? probing
? "Checking what this container has that the current base does not…"
: "That check did not complete, so what would be carried across is not known. Updating stays disabled until it does — try again once the container can be inspected."
: "Stop the container to update its base."}
</p>
)}
</div>
<Button
size="md"
variant="primary"
disabled={!canMigrate}
onClick={onOpen}
className="flex-shrink-0"
>
Update container base…
</Button>
</div>
</section>
);
}