fix: route sign-in links by what can actually catch the callback

`isAnthropicSignInUrl` made the container the default action for every
Anthropic sign-in link, justified by "the host has nothing to catch it
with". That was wrong in both directions. The host does have something --
the auth bridge -- and the container side is not a general browser at all
but Playwright's dashboard, whose packages and chromium are deliberately
not baked into the image. So the default pointed at the one path that is
uninstalled on a fresh project, on every platform, while the path that
works sat behind a switch.

The decision now lives in `useSignInOpenTarget`: a live auth bridge picks
the host, otherwise a container that can actually launch a browser picks
the container, otherwise the host. It resolves at mount rather than when a
URL arrives, so the buttons do not swap under a moving mouse, and it
re-decides on `auth-bridge-changed` so flipping the switch during a
hanging login takes effect. A bridge with port conflicts reads as not
live; an empty `active_ports` does not, since there is nothing to bridge
until the CLI binds its listener and that races the URL.

Both buttons still render either way -- this changes which one leads.
`sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded copy in
web_terminal/terminal.html needs no matching edit.

The host "Open" path also failed silently: `dismissUrlPrompt()` ran before
`openUrl`, so the toast vanished and a rejected promise reached only the
devtools console. Dismissal now happens on success only, leaving "In
container" one click away after a failure, and the error surfaces through
the same toast the container path already used. On Linux this catch will
not fire for the common case -- `xdg-open` routinely exits 0 having done
nothing -- so it complements the AppImage environment fix rather than
replacing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:07:50 -07:00
co-authored by Claude Opus 5
parent 90b7e4ccb2
commit bf8094dbc4
9 changed files with 677 additions and 39 deletions
+54 -3
View File
@@ -23,6 +23,7 @@ import {
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
@@ -409,7 +410,18 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("Refusing to open a link that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
// Same failure reporting as the toast's Open button — see the long note
// on `handleOpenUrl`, including what this catch does *not* catch on
// Linux. A click that appears to do nothing is the complaint either way.
openUrl(safe).catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open that link in your browser",
detail: String(e),
// A dead opener fails for every link in the buffer. One card.
dedupeKey: "host-open-failed",
}),
);
}, { urlRegex });
term.loadAddon(webLinksAddon);
@@ -786,20 +798,58 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => clearTimeout(timer);
}, [imagePasteMsg]);
/**
* Hand the prompted URL to the host's browser.
*
* Two things here are ordering, not decoration:
*
* - **The toast is dismissed on success only.** It used to go first, so a
* failed open left the user with an empty screen and no way back to a URL
* that only exists in the container's transcript. Now a failure keeps the
* prompt exactly where it was, which also leaves "In container" one click
* away — the fallback this failure is the argument for.
* - **The failure is a toast, not a `console.error`.** Same `pushToast` the
* container-browser branch below uses, because from the user's side the
* two actions fail identically: nothing happens.
*
* What this does *not* cover, and must not be described as covering: on Linux
* `xdg-open` routinely exits 0 having done nothing useful, so the most common
* Linux failure resolves this promise and reports success. Stripping the
* leaked AppImage environment before the browser is spawned is what addresses
* that; this is the complement that catches everything which does report.
*/
const handleOpenUrl = useCallback(() => {
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);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
dismissUrlPrompt();
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
openUrl(safe)
.then(() => dismissUrlPrompt())
.catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open it in your browser",
detail: String(e),
dedupeKey: "host-open-failed",
}),
);
}, [urlPrompt, dismissUrlPrompt]);
/**
* Which action leads when the prompt is holding an Anthropic sign-in link.
*
* Resolved per project, not per URL — see `useSignInOpenTarget`. The toast
* offers both regardless; this is only which one is filled in and reachable
* with {@link URL_TOAST_SHORTCUT}.
*/
const signInDefault = useSignInOpenTarget(projectId);
/**
* Open the prompted URL in the container's own browser instead of the host's.
*
@@ -896,6 +946,7 @@ export default function TerminalView({ sessionId, active }: Props) {
label={urlPrompt.label}
onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
signInDefault={signInDefault}
onDismiss={dismissUrlPrompt}
/>
)}