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
+53
View File
@@ -0,0 +1,53 @@
/**
* What a container has to have before anything can be opened *inside* it.
*
* The Browser tab asks this to decide what to offer; the terminal's URL toast
* asks it to decide which of its two buttons should lead. Both need the same
* answer, so the predicates live here rather than beside either caller — the
* failure this avoids is the toast steering a user at a container-side browser
* that the Browser tab is, on the very same screen, offering to install.
*
* The important thing to know about `PlaywrightDetection` is that browsers are
* deliberately **not** baked into the image: the libraries they link against
* are, the binaries are a user-pressed install. So "Playwright is present" and
* "a page can actually be opened" are two different questions, and a fresh
* project answers yes to neither.
*/
import type { PlaywrightDetection } from "./types";
/**
* Mirrors Rust `PlaywrightDetection::is_usable` — the packages the live
* dashboard needs. Says nothing about whether a browser exists to show in it.
*/
export function isBrowserViewUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Whether `openPageInContainerBrowser` has a browser to launch.
*
* Stricter than {@link isBrowserViewUsable} on purpose: the packages can be
* installed with `~/.cache/ms-playwright` still empty, which is exactly the
* state a `playwright install` step exists to leave behind, and launching into
* it fails several seconds after the click.
*
* Unknown reads as "no". A probe that could not run (stopped container, an
* image predating these fields) leaves the executable fields absent, and the
* caller's fallback — the host browser — is the one that at least reports its
* own failure. Over-refusing costs a user one extra click on a button that is
* still right there; over-accepting costs them a sign-in that goes nowhere.
*/
export function canOpenPageInContainerBrowser(d: PlaywrightDetection | null): boolean {
if (!isBrowserViewUsable(d) || !d) return false;
// The viewer's own Chromium, confirmed on disk by the probe.
if (d.chromium_executable_exists) return true;
// Google Chrome is an apt package, so it is never in `browsers` and has no
// revision to skew against.
if (d.chrome_channel !== null) return true;
// `== null`, not `=== null`: a probe from a container predating the
// executable fields omits them entirely, and `undefined` there means "didn't
// answer", not "missing". In that case a non-empty bundle list is the only
// evidence available, and it is better than nothing.
return d.chromium_executable == null && d.browsers.length > 0;
}
+51
View File
@@ -4,6 +4,7 @@ import {
MAX_RELAY_URL_LENGTH,
RelayRateLimiter,
URL_RELAY_OSC,
isAnthropicSignInUrl,
parseUrlRelayOsc,
sanitizeRelayUrl,
urlOrigin,
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
});
});
describe("isAnthropicSignInUrl", () => {
// Classification only. Where a sign-in link should be opened is decided by
// `hooks/useSignInOpenTarget.ts`, from facts about the project — this answers
// the narrower question of whether it is a sign-in link at all, and it does
// so through the same allowlist the sign-in flow itself uses.
it("recognises the links `claude setup-token` and `claude login` print", () => {
expect(
isAnthropicSignInUrl(
"https://claude.ai/oauth/authorize?code=true&client_id=abc",
),
).toBe(true);
expect(
isAnthropicSignInUrl("https://platform.claude.com/oauth/code/callback?x=1"),
).toBe(true);
expect(isAnthropicSignInUrl("https://console.anthropic.com/login?x=1")).toBe(
true,
);
});
it("is not fooled by a host that merely contains an allowed domain", () => {
// The thing the allowlist exists for: `claude.ai.evil.tld` ends with
// neither `claude.ai` nor `.claude.ai`.
expect(isAnthropicSignInUrl("https://claude.ai.evil.tld/oauth/authorize")).toBe(
false,
);
expect(isAnthropicSignInUrl("https://notclaude.ai/login")).toBe(false);
});
it("holds the full validator, not just the host test", () => {
// It runs `sanitizeRelayUrl`, so everything that cannot be opened at all
// is not a sign-in link either — no separate, weaker copy of the rules.
expect(isAnthropicSignInUrl("javascript:claude.ai/login")).toBe(false);
expect(isAnthropicSignInUrl("https://claude.ai@evil.tld/login")).toBe(false);
expect(isAnthropicSignInUrl("https://claude\nai/login")).toBe(false);
});
it("does not claim every allowlisted URL is a sign-in", () => {
expect(isAnthropicSignInUrl("https://claude.ai/chat/abc")).toBe(false);
expect(isAnthropicSignInUrl("https://www.anthropic.com/news")).toBe(false);
});
it("leaves an ordinary link alone, whatever it says in its path", () => {
// A `gh auth login` device code is the common one, and sending it to a
// container-side browser would be actively wrong.
expect(isAnthropicSignInUrl("https://github.com/login/device?code=A")).toBe(
false,
);
});
});
+16 -5
View File
@@ -182,11 +182,22 @@ export function extendsUrl(next: string, current: string): boolean {
/**
* Whether this is a URL that signs the user in to Anthropic.
*
* Used to decide *presentation*, not permission — the toast makes the
* container-side browser the default action for these, because the OAuth
* callback listener is inside the container and the host has nothing to catch
* it with. It is deliberately the same host allowlist the sign-in flow itself
* uses, so the two cannot disagree about what a sign-in link is.
* Classification only. It answers "is this a sign-in link", never "where should
* it be opened" — that decision moved out to `hooks/useSignInOpenTarget.ts`,
* because it depends on things this module has no business knowing: whether the
* project's auth bridge is live, and whether a browser is actually installed in
* the container. This function stays here because the *rule* it encodes is a
* URL rule, and it is deliberately the same host allowlist the sign-in flow
* itself uses, so the two cannot disagree about what a sign-in link is.
*
* It used to carry the default with it — container-side always, on the grounds
* that "the OAuth callback listener is inside the container and the host has
* nothing to catch it with". Both halves of that are now wrong. The host does
* have something to catch it with (the auth bridge mirrors the container's
* loopback listener onto the same host port), and the container-side target is
* not a general browser but Playwright's dashboard pane, whose browsers are
* deliberately not baked into the image — so on a fresh project the default
* pointed at something that was not installed, on every platform.
*/
export function isAnthropicSignInUrl(url: string): boolean {
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });