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
+58 -12
View File
@@ -14,6 +14,7 @@ import {
RelayRateLimiter,
URL_RELAY_OSC,
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection";
@@ -45,10 +46,38 @@ export default function TerminalView({ sessionId, active }: Props) {
// One toast slot, two producers: the heuristic long-URL detector and the
// container's explicit "open this in the host browser" relay (OSC 7777).
// Sharing the slot keeps them from stacking on top of each other.
const [urlPrompt, setUrlPrompt] = useState<{ url: string; label: string } | null>(
null,
);
//
// Both producers read the container's PTY output, so both are untrusted, and
// both must go through `sanitizeRelayUrl` before anything is stored here —
// see `promptUrl` below, which is the only writer.
//
// `seq` exists because the slot is shared and long-lived: a second prompt
// replacing a first would otherwise mutate the toast in place, swapping the
// text under a user who is mid-read and mid-click. Keying the toast on it
// remounts the component, so a new URL is unmistakably a new prompt.
const [urlPrompt, setUrlPrompt] = useState<{
url: string;
label: string;
seq: number;
} | null>(null);
const promptSeqRef = useRef(0);
const relayLimiterRef = useRef(new RelayRateLimiter());
/**
* The only writer of the prompt slot. Re-validates whatever the caller
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
* but the heuristic detector branch has been through nothing at all, and a
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
*/
const promptUrl = useCallback((raw: string, label: string) => {
const url = sanitizeRelayUrl(raw);
if (!url) {
console.warn("Refusing to prompt for a URL that failed validation");
return;
}
promptSeqRef.current += 1;
setUrlPrompt({ url, label, seq: promptSeqRef.current });
}, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -162,9 +191,19 @@ export default function TerminalView({ sessionId, active }: Props) {
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
// that matches URLs even if they lack trailing path segments (the default regex
// misses OAuth URLs that end mid-line).
const urlRegex = /https?:\/\/[^\s'"\x07]+/;
// eslint-disable-next-line no-control-regex
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
const webLinksAddon = new WebLinksAddon((_event, uri) => {
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
// Same sink, same rule: what xterm matched came off the container's
// output, so it is validated before it reaches the OS opener. A click
// here is a deliberate act on visible text, but "visible" is exactly
// what a userinfo-spoofed URL subverts.
const safe = sanitizeRelayUrl(uri);
if (!safe) {
console.warn("Refusing to open a link that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, { urlRegex });
term.loadAddon(webLinksAddon);
@@ -244,7 +283,7 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("URL relay: rate-limited", url);
return true;
}
setUrlPrompt({ url, label: "Container asked to open a URL" });
promptUrl(url, "Container asked to open a URL");
return true;
});
@@ -332,7 +371,7 @@ export default function TerminalView({ sessionId, active }: Props) {
let aborted = false;
const detector = new UrlDetector((url) =>
setUrlPrompt({ url, label: "Long URL detected" }),
promptUrl(url, "Long URL detected"),
);
detectorRef.current = detector;
@@ -477,12 +516,17 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
if (urlPrompt) {
openUrl(urlPrompt.url).catch((e) =>
console.error("Failed to open URL:", e),
);
setUrlPrompt(null);
if (!urlPrompt) return;
// Validated again at the sink. `promptUrl` is the only writer and already
// sanitizes, so this can only fail if that invariant is broken — which is
// precisely when it matters that the last thing before `openUrl` checks.
const safe = sanitizeRelayUrl(urlPrompt.url);
setUrlPrompt(null);
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, [urlPrompt]);
const handleScrollToBottom = useCallback(() => {
@@ -557,6 +601,8 @@ export default function TerminalView({ sessionId, active }: Props) {
>
{urlPrompt && (
<UrlToast
// A different URL is a different prompt, not an edit of this one.
key={urlPrompt.seq}
url={urlPrompt.url}
label={urlPrompt.label}
onOpen={handleOpenUrl}