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>
274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
import { useState } from "react";
|
|
import Button from "../ui/Button";
|
|
import Modal from "../ui/Modal";
|
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
|
import { selectClass } from "../ui/Field";
|
|
import ClaudeAuthModal from "./ClaudeAuthModal";
|
|
import { clearClaudeToken } from "../../lib/tauri-commands";
|
|
import { useProjects } from "../../hooks/useProjects";
|
|
import { useAppState } from "../../store/appState";
|
|
import { authErrorMessage, useClaudeTokenStatus } from "../../hooks/useClaudeAuth";
|
|
|
|
const STATUS_DISPLAY: Record<
|
|
string,
|
|
{ tone: StatusTone; label: string; detail: string }
|
|
> = {
|
|
checking: {
|
|
tone: "unknown",
|
|
label: "Checking",
|
|
detail: "Looking for a stored token in the OS keychain.",
|
|
},
|
|
stored: {
|
|
tone: "ok",
|
|
label: "Authenticated",
|
|
detail:
|
|
"A shared token is stored. Anthropic-backend projects use it from their next container start.",
|
|
},
|
|
absent: {
|
|
tone: "off",
|
|
label: "Not authenticated",
|
|
detail:
|
|
"No shared token yet, so each Anthropic-backend project still needs its own `claude login`.",
|
|
},
|
|
unavailable: {
|
|
tone: "error",
|
|
label: "Unknown",
|
|
detail: "The OS keychain could not be read.",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Host-level control for the one long-lived Claude Code token shared by every
|
|
* project. Acquisition needs a running container to run the CLI in, so the
|
|
* user picks which project lends one.
|
|
*/
|
|
export default function SharedAuthSettings() {
|
|
const { projects } = useProjects();
|
|
const pushToast = useAppState((s) => s.pushToast);
|
|
const { status, error, refresh } = useClaudeTokenStatus();
|
|
|
|
const [pickedId, setPickedId] = useState<string | null>(null);
|
|
const [authOpen, setAuthOpen] = useState(false);
|
|
const [confirmRevoke, setConfirmRevoke] = useState(false);
|
|
const [revoking, setRevoking] = useState(false);
|
|
|
|
// `claude setup-token` runs inside a container, so only running projects can
|
|
// host the flow.
|
|
const runnable = projects.filter(
|
|
(p) => p.status === "running" && p.container_id !== null,
|
|
);
|
|
const host = runnable.find((p) => p.id === pickedId) ?? runnable[0] ?? null;
|
|
|
|
const display = STATUS_DISPLAY[status];
|
|
|
|
const handleRevoke = async () => {
|
|
setRevoking(true);
|
|
try {
|
|
const outcome = await clearClaudeToken();
|
|
setConfirmRevoke(false);
|
|
await refresh();
|
|
|
|
// 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",
|
|
message: "Could not remove the shared Claude token.",
|
|
detail: authErrorMessage(
|
|
e,
|
|
"The OS keychain rejected the delete. The token may still be stored.",
|
|
),
|
|
});
|
|
} finally {
|
|
setRevoking(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
|
Shared Claude authentication
|
|
</span>
|
|
<StatusIndicator
|
|
tone={display.tone}
|
|
label={display.label}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-snug">
|
|
Authenticate once and every project on the Anthropic backend signs in with
|
|
that token, instead of each container running its own{" "}
|
|
<code className="font-mono">claude login</code>. The token is held in your OS
|
|
keychain and injected into containers as an environment variable.
|
|
</p>
|
|
<p
|
|
data-testid="shared-auth-detail"
|
|
className="mt-1 text-xs text-[var(--text-secondary)] leading-snug"
|
|
>
|
|
{display.detail}
|
|
</p>
|
|
{error && <p className="mt-1 text-xs text-[var(--error)]">{error}</p>}
|
|
</div>
|
|
|
|
{runnable.length > 1 && (
|
|
<div>
|
|
<label
|
|
htmlFor="shared-auth-host"
|
|
className="block text-xs text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Run the sign-in in
|
|
</label>
|
|
<select
|
|
id="shared-auth-host"
|
|
value={host?.id ?? ""}
|
|
onChange={(e) => setPickedId(e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
{runnable.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="md"
|
|
variant="primary"
|
|
disabled={!host}
|
|
onClick={() => setAuthOpen(true)}
|
|
>
|
|
{status === "stored" ? "Re-authenticate" : "Authenticate"}
|
|
</Button>
|
|
{status === "stored" && (
|
|
<Button
|
|
size="md"
|
|
variant="danger"
|
|
disabled={revoking}
|
|
onClick={() => setConfirmRevoke(true)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{!host && (
|
|
<p
|
|
data-testid="shared-auth-no-container"
|
|
className="text-xs text-[var(--warning)] leading-snug"
|
|
>
|
|
No project is running. Signing in runs{" "}
|
|
<code className="font-mono">claude setup-token</code> inside a container, so
|
|
start a project first — any one will do, it only lends its container.
|
|
</p>
|
|
)}
|
|
|
|
{host && (
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
|
The sign-in runs in{" "}
|
|
<strong className="text-[var(--text-primary)]">{host.name}</strong>’s
|
|
container, but the resulting token is shared by all projects.
|
|
</p>
|
|
)}
|
|
|
|
{authOpen && host && (
|
|
<ClaudeAuthModal
|
|
projectId={host.id}
|
|
projectName={host.name}
|
|
onClose={() => setAuthOpen(false)}
|
|
onAuthenticated={() => {
|
|
void refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{confirmRevoke && (
|
|
<Modal
|
|
title="Revoke shared Claude token"
|
|
widthClassName="w-[26rem]"
|
|
onClose={() => setConfirmRevoke(false)}
|
|
footer={
|
|
<>
|
|
<Button
|
|
size="md"
|
|
variant="ghost"
|
|
onClick={() => setConfirmRevoke(false)}
|
|
disabled={revoking}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="md"
|
|
variant="danger"
|
|
disabled={revoking}
|
|
onClick={() => void handleRevoke()}
|
|
>
|
|
{revoking ? "Revoking…" : "Revoke token"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<p className="text-[13px] text-[var(--text-secondary)] leading-snug">
|
|
This deletes the shared token from your OS keychain. Anthropic-backend
|
|
projects fall back to their own{" "}
|
|
<code className="font-mono">claude login</code> the next time their
|
|
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’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>
|
|
);
|
|
}
|