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:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user