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
+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,
);
});
});