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
+38 -18
View File
@@ -37,6 +37,18 @@ interface Props {
/** Open it in the container's own browser instead of the host's. Omitted when
* the project has no browser to open it in. */
onOpenInContainer?: () => void;
/**
* Which action leads for a *sign-in* link (see the note below). Nothing else
* in the toast moves: both buttons are offered either way, in either order.
*
* This component does not work it out, because the answer depends on the
* project's auth bridge and on what is installed inside its container —
* neither of which a presentational component should be reaching for.
* `hooks/useSignInOpenTarget.ts` owns the rule. `"host"` is the default here
* for the same reason it is the fallback there: it is the answer that is more
* likely to work, and the one that reports its own failure.
*/
signInDefault?: "host" | "container";
onDismiss: () => void;
}
@@ -57,17 +69,20 @@ interface Props {
* text swaps with no animation, and a user reading URL A can click Open on URL
* B that arrived a second later.
*
* ## Anthropic sign-in links default to the container's browser
* ## Anthropic sign-in links get their default from the caller
*
* For an ordinary URL the host browser is the right answer and stays the
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
* is waiting on is inside the container, so a host browser completes the sign-in
* and then posts the result somewhere nothing is listening, and the terminal
* hangs until it times out. Making the host button primary there was quietly
* steering every user into that. The container-side browser closes the loop
* with no host round trip and no auth bridge, so it leads — and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
* default, unconditionally. A sign-in is the one case where it might not be:
* the callback listener the CLI is waiting on is inside the container, so a
* host browser can complete the sign-in and then post the result where nothing
* is listening, leaving the terminal to hang to its timeout.
*
* *Can*, not *does* — which is why this is no longer decided from the URL. The
* auth bridge mirrors that container listener onto the same host port, and the
* container-side alternative is Playwright's dashboard pane, which a fresh
* project has not installed. Both of those are project facts, so the owner
* passes {@link Props.signInDefault} and this only renders it: the leading
* button is filled and comes first, the other keeps its place beside it.
*
* ## Reachable without a mouse, and it does not take focus to manage it
*
@@ -96,6 +111,7 @@ export default function UrlToast({
label = "Long URL detected",
onOpen,
onOpenInContainer,
signInDefault = "host",
onDismiss,
}: Props) {
const origin = urlOrigin(url);
@@ -103,18 +119,22 @@ export default function UrlToast({
// Only when there is somewhere to send it: without `onOpenInContainer` the
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// A sign-in link the caller has decided is better completed inside the
// container. Everything below keys off this rather than off `signIn`, so the
// two orderings differ only in which of the pair leads.
const containerLeads = signIn && signInDefault === "container";
// `Button` already owns the filled/outlined variants — including the rule
// that filled uses `--accent-emphasis` and never `--accent`, which is the
// foreground/link accent and fails WCAG AA behind white text.
const hostButton = (
<Button
variant={signIn ? "secondary" : "primary"}
data-url-toast-primary={signIn ? undefined : "true"}
variant={containerLeads ? "secondary" : "primary"}
data-url-toast-primary={containerLeads ? undefined : "true"}
onClick={onOpen}
className="flex-shrink-0"
title={
signIn
containerLeads
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
@@ -128,8 +148,8 @@ export default function UrlToast({
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<Button
variant={signIn ? "primary" : "secondary"}
data-url-toast-primary={signIn ? "true" : undefined}
variant={containerLeads ? "primary" : "secondary"}
data-url-toast-primary={containerLeads ? "true" : undefined}
onClick={onOpenInContainer}
className="flex-shrink-0"
title="Open in a browser inside the container, and watch it in the Browser tab"
@@ -235,14 +255,14 @@ export default function UrlToast({
lineHeight: 1.35,
}}
>
Sign-in link the callback listener is inside the container.
Opening it there closes the loop; the host browser needs the auth
bridge.
{containerLeads
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
: "Sign-in link — the callback listener is inside the container. The auth bridge is what carries the callback back to it from your own browser."}
</div>
)}
</div>
{signIn ? (
{containerLeads ? (
<>
{containerButton}
{hostButton}