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
@@ -9,6 +9,11 @@ import {
authErrorMessage,
useClaudeTokenAcquisition,
} from "../../hooks/useClaudeAuth";
import {
ANTHROPIC_SIGN_IN_HOSTS,
sanitizeRelayUrl,
urlOrigin,
} from "../../lib/urlRelay";
interface Props {
/** Project whose running container is borrowed to run the CLI. */
@@ -85,11 +90,30 @@ export default function ClaudeAuthModal({
? PHASE_STATUS.finishing
: PHASE_STATUS.waiting;
// Split for display only. `flow.signInUrl` has already passed the host
// allowlist; this decides which half of it an ellipsis is allowed to eat.
const signInOrigin = flow.signInUrl ? (urlOrigin(flow.signInUrl) ?? "") : "";
const signInPath = flow.signInUrl
? flow.signInUrl.slice(signInOrigin.length)
: "";
const handleOpen = async () => {
if (!flow.signInUrl) return;
setLinkError(null);
// Re-validated at the sink. `extractSignInUrl` already applies the host
// allowlist, so a failure here means that invariant broke — which is the
// one moment it matters that the last step before the OS opener checks.
const target = sanitizeRelayUrl(flow.signInUrl, {
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
});
if (!target) {
setLinkError(
"That link is not an Anthropic sign-in address and was not opened. Start authentication again.",
);
return;
}
try {
await openUrl(flow.signInUrl);
await openUrl(target);
} catch (e) {
setLinkError(
authErrorMessage(
@@ -103,8 +127,19 @@ export default function ClaudeAuthModal({
const handleCopy = async () => {
if (!flow.signInUrl) return;
setLinkError(null);
// Copying is the manual route to the same browser, so it gets the same
// check — a link too dangerous to open is too dangerous to hand over.
const target = sanitizeRelayUrl(flow.signInUrl, {
allowHosts: ANTHROPIC_SIGN_IN_HOSTS,
});
if (!target) {
setLinkError(
"That link is not an Anthropic sign-in address and was not copied. Start authentication again.",
);
return;
}
try {
await navigator.clipboard.writeText(flow.signInUrl);
await navigator.clipboard.writeText(target);
setCopied(true);
} catch (e) {
setLinkError(
@@ -185,16 +220,32 @@ export default function ClaudeAuthModal({
{flow.signInUrl ? (
<div className="mt-1 space-y-1.5">
<div className="flex items-center gap-1.5">
{/* The origin is rendered at full length and the path is the
only part allowed to truncate. A single `truncate` element
showing the whole URL is a spoofing primitive: pad the
front and the ellipsis eats the half that decides where the
user's Anthropic password goes. */}
<a
href={flow.signInUrl}
onClick={(e) => {
e.preventDefault();
void handleOpen();
}}
className="min-w-0 flex-1 truncate px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
className="flex min-w-0 flex-1 items-baseline px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
title={flow.signInUrl}
>
{flow.signInUrl}
<span
data-testid="claude-auth-url-origin"
className="shrink-0 font-semibold [overflow-wrap:anywhere]"
>
{signInOrigin}
</span>
<span
data-testid="claude-auth-url-path"
className="min-w-0 truncate text-[var(--text-secondary)]"
>
{signInPath}
</span>
</a>
<Button size="md" onClick={() => void handleOpen()}>
Open
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import GatewaySettings from "./GatewaySettings";
import type { AppSettings, GatewayStatus } from "../../lib/types";
const getGatewayStatus = vi.fn();
const stopGateway = vi.fn();
const startGateway = vi.fn();
const checkGatewayHealth = vi.fn();
const saveSettings = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
getGatewayStatus: () => getGatewayStatus(),
startGateway: () => startGateway(),
stopGateway: () => stopGateway(),
checkGatewayHealth: () => checkGatewayHealth(),
pullGatewayImage: vi.fn(),
buildGatewayImage: vi.fn(),
setGatewayApiKey: vi.fn(),
clearGatewayApiKey: vi.fn(),
getGatewayAuthToken: vi.fn(),
regenerateGatewayAuthToken: vi.fn(),
}));
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
let appSettings: AppSettings | null = null;
vi.mock("../../hooks/useSettings", () => ({
useSettings: () => ({ appSettings, saveSettings }),
}));
const settingsWithGateway = (enabled: boolean): AppSettings =>
({
gateway: { enabled, port: 4000, provider: "openai", api_base: null, models: [] },
}) as unknown as AppSettings;
const status = (over: Partial<GatewayStatus> = {}): GatewayStatus => ({
container_exists: true,
running: true,
port: 4000,
image_exists: true,
model_count: 0,
has_api_key: false,
base_url: "http://host.docker.internal:4000",
...over,
});
describe("GatewaySettings", () => {
beforeEach(() => {
vi.clearAllMocks();
appSettings = settingsWithGateway(false);
getGatewayStatus.mockResolvedValue(status());
checkGatewayHealth.mockResolvedValue(true);
saveSettings.mockImplementation(async (s: AppSettings) => s);
stopGateway.mockResolvedValue(undefined);
});
it("keeps a working Stop button when the gateway is disabled but its container exists", async () => {
render(<GatewaySettings />);
const stop = await screen.findByRole("button", { name: "Stop" });
// The configuration UI stays hidden — only the container row survives.
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
/gateway container is still present/i,
);
// Status is a word, not just a colour.
expect(screen.getByTestId("gateway-leftover-container")).toHaveTextContent(
/Running on port 4000/,
);
fireEvent.click(stop);
await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1));
// Stopping re-reads status: once on mount, once after the action.
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
});
it("shows nothing extra when the gateway is disabled and no container exists", async () => {
getGatewayStatus.mockResolvedValue(status({ container_exists: false, running: false }));
render(<GatewaySettings />);
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalled());
expect(screen.queryByTestId("gateway-leftover-container")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
});
it("re-reads container status after toggling the gateway off", async () => {
appSettings = settingsWithGateway(true);
render(<GatewaySettings />);
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(1));
// The backend stops the container as part of update_settings, so the UI has
// to re-read rather than trust the status it already has.
getGatewayStatus.mockResolvedValue(status({ running: false }));
fireEvent.click(screen.getByRole("switch", { name: "Model gateway" }));
await waitFor(() =>
expect(saveSettings).toHaveBeenCalledWith(
expect.objectContaining({ gateway: expect.objectContaining({ enabled: false }) }),
),
);
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
});
});
@@ -79,9 +79,17 @@ export default function GatewaySettings() {
refreshStatus();
}, [refreshStatus]);
/**
* Persist a gateway settings change, then re-read the container status.
*
* `update_settings` reconciles the container itself — it stops the gateway
* when `enabled` goes false and recreates it on a port change — so the status
* we are holding is stale the moment the save returns.
*/
const patch = async (changes: Partial<GatewaySettingsType>) => {
if (!appSettings) return;
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
await refreshStatus();
};
const savePort = async () => {
@@ -190,6 +198,13 @@ export default function GatewaySettings() {
? "Stopped"
: "Image ready";
// Rendered in whichever branch is live — only one of them ever mounts.
const errorLine = error ? (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
) : null;
return (
<div>
<label className="block text-sm font-medium mb-1">Model Gateway</label>
@@ -213,6 +228,27 @@ export default function GatewaySettings() {
}
/>
{/*
Turning the gateway off hides its configuration, but a container that
already exists must stay reachable — otherwise a leftover container
keeps its port bound with no UI left to stop it.
*/}
{!gateway.enabled && status?.container_exists && (
<div className="space-y-2" data-testid="gateway-leftover-container">
<div className="flex items-center gap-3 flex-wrap">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
<Button variant="danger" disabled={loading} onClick={() => run(stopGateway)}>
{loading ? "Working…" : "Stop"}
</Button>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
The gateway container is still present. Stop it here if it is still running; it
will not be started again while the gateway is off.
</p>
{errorLine}
</div>
)}
{gateway.enabled && (
<>
{/* ── Container ─────────────────────────────────────────────── */}
@@ -247,11 +283,7 @@ export default function GatewaySettings() {
</pre>
)}
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{errorLine}
{/* ── Provider ──────────────────────────────────────────────── */}
<Field
@@ -395,10 +427,10 @@ export default function GatewaySettings() {
</div>
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">
Set a project's backend to <strong>OpenAI Compatible</strong> and use these
values. On native Linux Docker, where{" "}
<code className="font-mono">host.docker.internal</code> is not injected into
containers, use <code className="font-mono">http://172.17.0.1:{gateway.port}</code>{" "}
instead.
values. The base URL below is the one your Docker engine actually needs {" "}
<code className="font-mono">host.docker.internal</code> on Docker Desktop, the
bridge gateway address on native Linux, where that name is not injected into
containers.
</p>
</div>
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import SharedAuthSettings from "./SharedAuthSettings";
import type { Project } from "../../lib/types";
import { useAppState } from "../../store/appState";
import type { ClearTokenOutcome, Project } from "../../lib/types";
const hasClaudeToken = vi.fn();
const clearClaudeToken = vi.fn();
@@ -63,8 +64,29 @@ describe("SharedAuthSettings", () => {
vi.clearAllMocks();
projects = [];
hasClaudeToken.mockResolvedValue(false);
useAppState.setState({ toasts: [] });
});
/** Open the confirmation and go through with it. */
async function revoke(outcome: Partial<ClearTokenOutcome>) {
projects = [running()];
hasClaudeToken.mockResolvedValue(true);
clearClaudeToken.mockResolvedValue({
snapshots_scrubbed: [],
snapshots_failed: [],
snapshots_superseded: [],
docker_unavailable: null,
...outcome,
});
render(<SharedAuthSettings />);
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
await waitFor(() =>
expect(useAppState.getState().toasts.length).toBeGreaterThan(0),
);
return useAppState.getState().toasts[0];
}
it("disables Authenticate and says why when nothing is running", async () => {
projects = [baseProject];
render(<SharedAuthSettings />);
@@ -123,4 +145,48 @@ describe("SharedAuthSettings", () => {
await screen.findByText("keyring backend unavailable");
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
});
// ── Revoking has to tell the truth ──────────────────────────────────────
// Deleting the keychain entry is only part of it. `docker commit` copies the
// token into each project's snapshot image, and an image outlives every
// container built from it — so a "removed" message while a snapshot still
// holds a live ~1-year credential is the wrong thing to say.
it("says so plainly when snapshot images were cleared too", async () => {
const toast = await revoke({
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
});
expect(toast.kind).toBe("success");
expect(toast.message).toMatch(/1 snapshot image/);
});
it("reports an error, not success, when an image still holds the token", async () => {
const toast = await revoke({
snapshots_failed: ["triple-c-snapshot-p1:latest: image has child images"],
});
expect(toast.kind).toBe("error");
expect(toast.message).toMatch(/still in some images/i);
expect(toast.detail).toMatch(/triple-c-snapshot-p1/);
});
it("does not claim the images are clean when Docker could not be reached", async () => {
const toast = await revoke({ docker_unavailable: "Docker is not running" });
expect(toast.kind).toBe("error");
expect(toast.detail).toMatch(/Docker could not be reached/);
});
it("mentions a retained image layer without calling the revoke a failure", async () => {
const toast = await revoke({
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
snapshots_superseded: ["triple-c-snapshot-p1:latest"],
});
expect(toast.kind).toBe("success");
expect(toast.detail).toMatch(/still on disk because a container is running/);
});
it("still succeeds plainly when there was nothing to scrub", async () => {
const toast = await revoke({});
expect(toast.kind).toBe("success");
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
});
});
@@ -64,13 +64,52 @@ export default function SharedAuthSettings() {
const handleRevoke = async () => {
setRevoking(true);
try {
await clearClaudeToken();
const outcome = await clearClaudeToken();
setConfirmRevoke(false);
await refresh();
pushToast({
kind: "success",
message: "Shared Claude token removed from the keychain.",
});
// The keychain entry is gone either way. What matters here is the copy of
// the token that `docker commit` baked into each project's snapshot
// image: that one outlives every container, and `docker image inspect`
// will keep printing it until the image is rewritten. If that could not
// be done, the revocation is incomplete and saying "removed" would be a
// lie.
if (outcome.docker_unavailable) {
pushToast({
kind: "error",
message: "Token removed from the keychain, but snapshots were not checked.",
detail:
`Docker could not be reached (${outcome.docker_unavailable}), so any snapshot image ` +
"built before this version may still contain the token in its environment. " +
"Start Docker and revoke again to clear them.",
});
} else if (outcome.snapshots_failed.length > 0) {
pushToast({
kind: "error",
message: "Token removed from the keychain, but it is still in some images.",
detail:
`${outcome.snapshots_failed.length} snapshot image(s) could not be rewritten and ` +
"still contain the token, readable via `docker image inspect`. Reset those " +
`projects to remove the images. Details: ${outcome.snapshots_failed.join("; ")}`,
});
} else if (outcome.snapshots_scrubbed.length > 0) {
pushToast({
kind: "success",
message: `Shared Claude token removed, and cleared from ${outcome.snapshots_scrubbed.length} snapshot image(s).`,
detail:
outcome.snapshots_superseded.length > 0
? "The pre-rewrite image layer for " +
`${outcome.snapshots_superseded.join(", ")} is still on disk because a ` +
"container is running from it. It goes away once that project is restarted " +
"(which recreates the container) and Docker prunes the leftover."
: undefined,
});
} else {
pushToast({
kind: "success",
message: "Shared Claude token removed from the keychain.",
});
}
} catch (e) {
pushToast({
kind: "error",
@@ -220,6 +259,13 @@ export default function SharedAuthSettings() {
container starts. Existing running containers keep working until they are
restarted.
</p>
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
Each project&rsquo;s snapshot image is also rewritten, because{" "}
<code className="font-mono">docker commit</code> copies the token into it
and an image outlives every container built from it. If any image
cannot be rewritten you will be told which, and the token stays readable
in it until that project is Reset.
</p>
</Modal>
)}
</div>