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
+50 -6
View File
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
// and then posts the result where nothing is listening, and the terminal
// hangs to its timeout — so for these, and only these, the container-side
// browser leads.
// container, so a sign-in is the one case where the host browser may be the
// wrong lead. Whether it actually is depends on the project — a live auth
// bridge carries the callback back, and the container-side alternative is
// not installed on a fresh project — so the owner decides and passes
// `signInDefault`. This component only renders the decision.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
@@ -180,12 +183,13 @@ describe("UrlToast", () => {
.filter((t) => t === "Open" || t === "In container");
}
it("puts the container browser first", () => {
it("puts the container browser first when the caller asks for it", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -195,6 +199,42 @@ describe("UrlToast", () => {
);
});
it("leads with the host when the caller says so, without hiding the other", () => {
// A live auth bridge, or a container with no browser installed. The pair
// is unchanged; only the order and which one is filled.
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="host"
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("Open");
// Still recognised as a sign-in, so the explanation stays.
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/auth bridge/i,
);
});
it("defaults to the host when the caller passes nothing", () => {
// The safe fallback: the answer more likely to work, and the one that
// reports its own failure.
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
});
it("keeps the host browser available as a fallback", () => {
const onOpen = vi.fn();
render(
@@ -202,6 +242,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={onOpen}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -211,12 +252,14 @@ describe("UrlToast", () => {
it("leaves an ordinary URL alone", () => {
// A `gh auth login` device code, a docs page, a preview build — the host
// browser is the right answer for all of them and stays the default.
// browser is the right answer for all of them and stays the default,
// whatever the project's sign-in preference happens to be.
render(
<UrlToast
url="https://github.com/login/device?code=ABCD-EFGH"
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -232,6 +275,7 @@ describe("UrlToast", () => {
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);