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
@@ -23,6 +23,7 @@ import {
setBrowserViewMatchWindow, setBrowserViewMatchWindow,
setBrowserViewPopoutAlwaysOnTop, setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands"; } from "../../../lib/tauri-commands";
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
import { useAppState } from "../../../store/appState"; import { useAppState } from "../../../store/appState";
import OpenPageDialog from "./OpenPageDialog"; import OpenPageDialog from "./OpenPageDialog";
import AccordionSection from "../../ui/AccordionSection"; import AccordionSection from "../../ui/AccordionSection";
@@ -338,7 +339,7 @@ export default function BrowserTab({ project, active }: Props) {
// Prefer the probe: it is the fresher of the two, and it is the one that // Prefer the probe: it is the fresher of the two, and it is the one that
// reflects an install that just finished. // reflects an install that just finished.
const probed = detection ?? status.detection; const probed = detection ?? status.detection;
const ready = isUsable(probed); const ready = isBrowserViewUsable(probed);
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an // Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
// apt package, so it never shows up in `browsers`, and a container that has // apt package, so it never shows up in `browsers`, and a container that has
// it is not missing a browser. // it is not missing a browser.
@@ -539,11 +540,6 @@ export default function BrowserTab({ project, active }: Props) {
); );
} }
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
function isUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/** /**
* Mirrors Rust `PlaywrightDetection::revision_skew`. * Mirrors Rust `PlaywrightDetection::revision_skew`.
* *
@@ -627,7 +623,7 @@ function Setup({
onInstall: (which: Exclude<SetupJob, null>) => void; onInstall: (which: Exclude<SetupJob, null>) => void;
}) { }) {
const busy = job !== null; const busy = job !== null;
const havePackages = isUsable(detection); const havePackages = isBrowserViewUsable(detection);
const missing = missingParts(detection); const missing = missingParts(detection);
const browsers = detection?.browsers ?? []; const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null; const chrome = detection?.chrome_channel ?? null;
@@ -3,6 +3,12 @@ import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView"; import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands"; import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
chooseSignInTarget,
resetBrowserSupportCache,
} from "../../hooks/useSignInOpenTarget";
import type { AuthBridgeStatus, PlaywrightDetection } from "../../lib/types";
import { URL_TOAST_SELECTOR } from "./UrlToast"; import { URL_TOAST_SELECTOR } from "./UrlToast";
/** /**
@@ -16,6 +22,18 @@ const dragDrop = vi.hoisted(() => ({
handler: null as null | ((event: unknown) => unknown), handler: null as null | ((event: unknown) => unknown),
})); }));
/**
* What the project's container answers about itself.
*
* `TerminalView` asks two questions on mount — is the auth bridge live, and is
* there a browser inside to open a page in — because together they decide which
* of the URL toast's two buttons leads for a sign-in link.
*/
const containerEnv = vi.hoisted(() => ({
bridge: { enabled: false, active_ports: [], conflicts: [] } as unknown,
detection: null as unknown,
}));
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */ /** The `terminal-output-{id}` listeners, so a test can be the PTY. */
const ptyOutput = vi.hoisted(() => ({ const ptyOutput = vi.hoisted(() => ({
listeners: new Map<string, (e: { payload: number[] }) => void>(), listeners: new Map<string, (e: { payload: number[] }) => void>(),
@@ -45,6 +63,8 @@ vi.mock("../../lib/tauri-commands", () => ({
awsSsoRefresh: vi.fn(async () => {}), awsSsoRefresh: vi.fn(async () => {}),
openPageInContainerBrowser: vi.fn(async () => ({ error: null })), openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
uploadHostFileToTerminal: vi.fn(async () => ""), uploadHostFileToTerminal: vi.fn(async () => ""),
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
})); }));
vi.mock("@tauri-apps/api/event", () => ({ vi.mock("@tauri-apps/api/event", () => ({
@@ -128,6 +148,14 @@ beforeEach(() => {
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt"); vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
dragDrop.handler = null; dragDrop.handler = null;
ptyOutput.listeners.clear(); ptyOutput.listeners.clear();
vi.mocked(openUrl).mockReset();
vi.mocked(openUrl).mockResolvedValue(undefined);
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
containerEnv.detection = null;
// The Playwright probe is memoized across mounts (it is a container exec), so
// a case that changes the answer has to drop what an earlier one cached.
resetBrowserSupportCache();
useAppState.setState({ toasts: [] });
document.body.innerHTML = ""; document.body.innerHTML = "";
useAppState.setState({ sessions: [] }); useAppState.setState({ sessions: [] });
}); });
@@ -559,6 +587,214 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
}); });
}); });
/**
* A container with Playwright *and* a browser in the cache — i.e. one where
* "In container" would actually open something.
*/
function usableDetection(
over: Partial<PlaywrightDetection> = {},
): PlaywrightDetection {
return {
node_version: "v22.11.0",
playwright_version: "1.56.0",
playwright_path: "/workspace/node_modules/playwright",
playwright_cli: "/workspace/node_modules/playwright/cli.js",
has_bind: true,
cli_version: "1.56.0",
cli_entry: "/workspace/node_modules/@playwright/cli/index.js",
browsers: ["chromium-1200"],
chrome_channel: null,
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1200/chrome",
chromium_executable_exists: true,
script_playwright_version: "1.56.0",
script_chromium_executable: null,
script_chromium_executable_exists: false,
searched: [],
...over,
};
}
const LIVE_BRIDGE: AuthBridgeStatus = {
enabled: true,
active_ports: [],
conflicts: [],
};
describe("chooseSignInTarget — which action leads for a sign-in link", () => {
// The rule this replaced was "container, always", justified by the callback
// listener living inside the container. Both halves of that justification
// stopped being true: the auth bridge mirrors that listener onto the host,
// and the container-side target is Playwright's pane, whose browsers are not
// in the image.
it("prefers the host browser whenever the bridge is live", () => {
expect(chooseSignInTarget(LIVE_BRIDGE, usableDetection())).toBe("host");
});
it("does not call a bridge live while it is holding a port conflict", () => {
// Enabled and unable to catch the callback anyway — the one state where
// "on" must not read as "will work".
const conflicted: AuthBridgeStatus = {
enabled: true,
active_ports: [],
conflicts: [{ port: 54545, reason: "already in use on the host" }],
};
expect(chooseSignInTarget(conflicted, usableDetection())).toBe("container");
});
it("does not wait for a bridged port before trusting an enabled bridge", () => {
// There is nothing to bridge until the CLI binds its listener, and that
// races the URL reaching the transcript. Requiring a port would make the
// default flip between two identical sign-ins.
expect(chooseSignInTarget(LIVE_BRIDGE, null)).toBe("host");
});
it("falls to the container only when it has a browser to open", () => {
const off: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
expect(chooseSignInTarget(off, usableDetection())).toBe("container");
expect(chooseSignInTarget(off, null)).toBe("host");
// Packages installed, cache empty — the fresh-project state, and the one
// that used to be the silent default.
expect(
chooseSignInTarget(
off,
usableDetection({ browsers: [], chromium_executable_exists: false }),
),
).toBe("host");
// Playwright too old to bind: the pane cannot show it either.
expect(chooseSignInTarget(off, usableDetection({ has_bind: false }))).toBe("host");
});
it("answers host when nothing is known at all", () => {
expect(chooseSignInTarget(null, null)).toBe("host");
});
});
describe("TerminalView — the sign-in default follows the project", () => {
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
function relaySequence(url: string): number[] {
return Array.from(
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
);
}
async function mountWithPrompt() {
const view = mountSession("claude");
await act(async () => {});
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: relaySequence(SIGN_IN) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
return view;
}
function primaryLabel(): string | null {
return document.querySelector<HTMLElement>(
'[data-url-toast-primary="true"]',
)?.textContent ?? null;
}
function actionOrder(): (string | null)[] {
return Array.from(document.querySelectorAll("button"))
.map((b) => b.textContent)
.filter((t) => t === "Open" || t === "In container");
}
it("leads with the host browser when the auth bridge is on", async () => {
containerEnv.bridge = LIVE_BRIDGE;
containerEnv.detection = usableDetection();
await mountWithPrompt();
expect(primaryLabel()).toBe("Open");
// Both are still offered — this changes which leads, never which exist.
expect(actionOrder()).toEqual(["Open", "In container"]);
});
it("leads with the container when the bridge is off and a browser is there", async () => {
containerEnv.detection = usableDetection();
await mountWithPrompt();
expect(primaryLabel()).toBe("In container");
expect(actionOrder()).toEqual(["In container", "Open"]);
});
it("leads with the host on a fresh project, where neither is set up", async () => {
// Playwright is deliberately not baked into the image, so this is what a
// project looks like until someone presses install — and pointing the
// default at it failed on every platform, silently.
await mountWithPrompt();
expect(primaryLabel()).toBe("Open");
});
});
describe("TerminalView — a host open that fails says so", () => {
const URL = "https://github.com/login/device?code=ABCD-EFGH";
function relaySequence(url: string): number[] {
return Array.from(
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
);
}
async function mountWithPrompt() {
const view = mountSession("claude");
await act(async () => {});
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: relaySequence(URL) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
return view;
}
function openButton(): HTMLElement {
const el = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Open",
);
if (!el) throw new Error("Open button not found");
return el as HTMLElement;
}
it("pushes a toast instead of a console line nobody reads", async () => {
vi.mocked(openUrl).mockRejectedValueOnce(new Error("no opener"));
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
await Promise.resolve();
});
const toasts = useAppState.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].kind).toBe("error");
expect(toasts[0].detail).toContain("no opener");
});
it("keeps the prompt on screen, so the other route is still one click away", async () => {
// Dismissing first is what this replaced: the toast vanished, nothing
// opened, and the URL only existed in the container's transcript.
vi.mocked(openUrl).mockRejectedValueOnce(new Error("no opener"));
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
await Promise.resolve();
});
expect(document.querySelector(URL_TOAST_SELECTOR)).not.toBeNull();
});
it("dismisses the prompt once the handoff actually succeeded", async () => {
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
await Promise.resolve();
});
expect(openUrl).toHaveBeenCalledWith(URL);
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
});
});
describe("TerminalView — focus on request", () => { describe("TerminalView — focus on request", () => {
/** Mount, then deliberately give focus away, so what the assertions below /** Mount, then deliberately give focus away, so what the assertions below
* observe is the *request* taking effect and never the focus `active` * observe is the *request* taking effect and never the focus `active`
+54 -3
View File
@@ -23,6 +23,7 @@ import {
sanitizeRelayUrl, sanitizeRelayUrl,
} from "../../lib/urlRelay"; } from "../../lib/urlRelay";
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget"; import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
import UrlToast, { import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR, URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR, URL_TOAST_SELECTOR,
@@ -409,7 +410,18 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("Refusing to open a link that failed validation"); console.warn("Refusing to open a link that failed validation");
return; return;
} }
openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); // Same failure reporting as the toast's Open button — see the long note
// on `handleOpenUrl`, including what this catch does *not* catch on
// Linux. A click that appears to do nothing is the complaint either way.
openUrl(safe).catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open that link in your browser",
detail: String(e),
// A dead opener fails for every link in the buffer. One card.
dedupeKey: "host-open-failed",
}),
);
}, { urlRegex }); }, { urlRegex });
term.loadAddon(webLinksAddon); term.loadAddon(webLinksAddon);
@@ -786,20 +798,58 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [imagePasteMsg]); }, [imagePasteMsg]);
/**
* Hand the prompted URL to the host's browser.
*
* Two things here are ordering, not decoration:
*
* - **The toast is dismissed on success only.** It used to go first, so a
* failed open left the user with an empty screen and no way back to a URL
* that only exists in the container's transcript. Now a failure keeps the
* prompt exactly where it was, which also leaves "In container" one click
* away — the fallback this failure is the argument for.
* - **The failure is a toast, not a `console.error`.** Same `pushToast` the
* container-browser branch below uses, because from the user's side the
* two actions fail identically: nothing happens.
*
* What this does *not* cover, and must not be described as covering: on Linux
* `xdg-open` routinely exits 0 having done nothing useful, so the most common
* Linux failure resolves this promise and reports success. Stripping the
* leaked AppImage environment before the browser is spawned is what addresses
* that; this is the complement that catches everything which does report.
*/
const handleOpenUrl = useCallback(() => { const handleOpenUrl = useCallback(() => {
if (!urlPrompt) return; if (!urlPrompt) return;
// Validated again at the sink. `promptUrl` is the only writer and already // Validated again at the sink. `promptUrl` is the only writer and already
// sanitizes, so this can only fail if that invariant is broken — which is // sanitizes, so this can only fail if that invariant is broken — which is
// precisely when it matters that the last thing before `openUrl` checks. // precisely when it matters that the last thing before `openUrl` checks.
const safe = sanitizeRelayUrl(urlPrompt.url); const safe = sanitizeRelayUrl(urlPrompt.url);
dismissUrlPrompt();
if (!safe) { if (!safe) {
console.warn("Refusing to open a URL that failed validation"); console.warn("Refusing to open a URL that failed validation");
dismissUrlPrompt();
return; return;
} }
openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); openUrl(safe)
.then(() => dismissUrlPrompt())
.catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open it in your browser",
detail: String(e),
dedupeKey: "host-open-failed",
}),
);
}, [urlPrompt, dismissUrlPrompt]); }, [urlPrompt, dismissUrlPrompt]);
/**
* Which action leads when the prompt is holding an Anthropic sign-in link.
*
* Resolved per project, not per URL — see `useSignInOpenTarget`. The toast
* offers both regardless; this is only which one is filled in and reachable
* with {@link URL_TOAST_SHORTCUT}.
*/
const signInDefault = useSignInOpenTarget(projectId);
/** /**
* Open the prompted URL in the container's own browser instead of the host's. * Open the prompted URL in the container's own browser instead of the host's.
* *
@@ -896,6 +946,7 @@ export default function TerminalView({ sessionId, active }: Props) {
label={urlPrompt.label} label={urlPrompt.label}
onOpen={handleOpenUrl} onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer} onOpenInContainer={handleOpenUrlInContainer}
signInDefault={signInDefault}
onDismiss={dismissUrlPrompt} onDismiss={dismissUrlPrompt}
/> />
)} )}
+50 -6
View File
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
url={SIGN_IN} url={SIGN_IN}
onOpen={noop} onOpen={noop}
onOpenInContainer={noop} onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop} onDismiss={noop}
/>, />,
); );
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
url={SIGN_IN} url={SIGN_IN}
onOpen={noop} onOpen={noop}
onOpenInContainer={noop} onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop} onDismiss={noop}
/>, />,
); );
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
describe("Anthropic sign-in links", () => { describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the // The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in // container, so a sign-in is the one case where the host browser may be the
// and then posts the result where nothing is listening, and the terminal // wrong lead. Whether it actually is depends on the project — a live auth
// hangs to its timeout — so for these, and only these, the container-side // bridge carries the callback back, and the container-side alternative is
// browser leads. // not installed on a fresh project — so the owner decides and passes
// `signInDefault`. This component only renders the decision.
const SIGN_IN = const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code"; "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"); .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( render(
<UrlToast <UrlToast
url={SIGN_IN} url={SIGN_IN}
onOpen={noop} onOpen={noop}
onOpenInContainer={noop} onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop} 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", () => { it("keeps the host browser available as a fallback", () => {
const onOpen = vi.fn(); const onOpen = vi.fn();
render( render(
@@ -202,6 +242,7 @@ describe("UrlToast", () => {
url={SIGN_IN} url={SIGN_IN}
onOpen={onOpen} onOpen={onOpen}
onOpenInContainer={noop} onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop} onDismiss={noop}
/>, />,
); );
@@ -211,12 +252,14 @@ describe("UrlToast", () => {
it("leaves an ordinary URL alone", () => { it("leaves an ordinary URL alone", () => {
// A `gh auth login` device code, a docs page, a preview build — the host // 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( render(
<UrlToast <UrlToast
url="https://github.com/login/device?code=ABCD-EFGH" url="https://github.com/login/device?code=ABCD-EFGH"
onOpen={noop} onOpen={noop}
onOpenInContainer={noop} onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop} onDismiss={noop}
/>, />,
); );
@@ -232,6 +275,7 @@ describe("UrlToast", () => {
url="https://claude.ai.evil.tld/oauth/authorize?x=1" url="https://claude.ai.evil.tld/oauth/authorize?x=1"
onOpen={noop} onOpen={noop}
onOpenInContainer={noop} onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop} onDismiss={noop}
/>, />,
); );
+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 /** Open it in the container's own browser instead of the host's. Omitted when
* the project has no browser to open it in. */ * the project has no browser to open it in. */
onOpenInContainer?: () => void; 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; onDismiss: () => void;
} }
@@ -57,17 +69,20 @@ interface Props {
* text swaps with no animation, and a user reading URL A can click Open on URL * text swaps with no animation, and a user reading URL A can click Open on URL
* B that arrived a second later. * 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 * 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 * default, unconditionally. A sign-in is the one case where it might not be:
* is waiting on is inside the container, so a host browser completes the sign-in * the callback listener the CLI is waiting on is inside the container, so a
* and then posts the result somewhere nothing is listening, and the terminal * host browser can complete the sign-in and then post the result where nothing
* hangs until it times out. Making the host button primary there was quietly * is listening, leaving the terminal to hang to its timeout.
* 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 * *Can*, not *does* — which is why this is no longer decided from the URL. The
* stays, because a user who has the auth bridge on, or who wants their existing * auth bridge mirrors that container listener onto the same host port, and the
* browser session, still needs it. * 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 * ## 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", label = "Long URL detected",
onOpen, onOpen,
onOpenInContainer, onOpenInContainer,
signInDefault = "host",
onDismiss, onDismiss,
}: Props) { }: Props) {
const origin = urlOrigin(url); const origin = urlOrigin(url);
@@ -103,18 +119,22 @@ export default function UrlToast({
// Only when there is somewhere to send it: without `onOpenInContainer` the // Only when there is somewhere to send it: without `onOpenInContainer` the
// host button is the only action there is, so it stays primary. // host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url); 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 // `Button` already owns the filled/outlined variants — including the rule
// that filled uses `--accent-emphasis` and never `--accent`, which is the // that filled uses `--accent-emphasis` and never `--accent`, which is the
// foreground/link accent and fails WCAG AA behind white text. // foreground/link accent and fails WCAG AA behind white text.
const hostButton = ( const hostButton = (
<Button <Button
variant={signIn ? "secondary" : "primary"} variant={containerLeads ? "secondary" : "primary"}
data-url-toast-primary={signIn ? undefined : "true"} data-url-toast-primary={containerLeads ? undefined : "true"}
onClick={onOpen} onClick={onOpen}
className="flex-shrink-0" className="flex-shrink-0"
title={ title={
signIn containerLeads
? "Open in your own browser instead — the callback then has to reach the container by some other route" ? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined : undefined
} }
@@ -128,8 +148,8 @@ export default function UrlToast({
// the container's own loopback, which is where the tool waiting for it is // the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge. // listening — no host round trip, no auth bridge.
<Button <Button
variant={signIn ? "primary" : "secondary"} variant={containerLeads ? "primary" : "secondary"}
data-url-toast-primary={signIn ? "true" : undefined} data-url-toast-primary={containerLeads ? "true" : undefined}
onClick={onOpenInContainer} onClick={onOpenInContainer}
className="flex-shrink-0" className="flex-shrink-0"
title="Open in a browser inside the container, and watch it in the Browser tab" 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, lineHeight: 1.35,
}} }}
> >
Sign-in link the callback listener is inside the container. {containerLeads
Opening it there closes the loop; the host browser needs the auth ? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
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>
)} )}
</div> </div>
{signIn ? ( {containerLeads ? (
<> <>
{containerButton} {containerButton}
{hostButton} {hostButton}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
checkBrowserViewSupport,
getAuthBridgeStatus,
} from "../lib/tauri-commands";
import { canOpenPageInContainerBrowser } from "../lib/browserViewSupport";
import type {
AuthBridgeChangedEvent,
AuthBridgeStatus,
PlaywrightDetection,
} from "../lib/types";
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
/** Which of the URL toast's two buttons should lead for a sign-in link. */
export type SignInOpenTarget = "host" | "container";
/**
* Whether the auth bridge can be relied on to catch a callback for this
* project.
*
* Deliberately **not** gated on `active_ports` being non-empty. There is only
* something to bridge once the CLI has bound its callback listener, and the
* order in which that happens against the URL landing in the transcript is not
* ours to control — requiring a port here would make the answer depend on a
* race and flip the default button between two otherwise identical sign-ins.
* `enabled` is the durable fact: the poller is watching, and it will mirror the
* port the moment it appears.
*
* A conflict is the exception, because it is the one state where the bridge is
* on and nevertheless *cannot* catch the callback — the host port it needed was
* already taken. That is precisely when the container-side browser is the
* better default, so it must not read as live.
*/
export function authBridgeIsLive(status: AuthBridgeStatus | null): boolean {
if (!status || !status.enabled) return false;
return status.conflicts.length === 0;
}
/**
* The rule, as a pure function of the two things it depends on.
*
* Both fallbacks land on the host, for different reasons:
*
* - With the bridge live, the host browser is strictly better — it is the
* user's own signed-in profile, and the callback still reaches the container.
* - With neither available, the host is the *more likely to work* of two
* imperfect answers, and it is the one that reports its own failure (see
* `handleOpenUrl` in `TerminalView`). The container-side target is
* Playwright's dashboard pane, and Playwright's browsers are not baked into
* the image, so on a fresh project pointing there fails on every platform
* after a several-second wait.
*
* Whichever way it goes, both buttons stay in the toast. This chooses which one
* leads, never which ones exist.
*/
export function chooseSignInTarget(
bridge: AuthBridgeStatus | null,
detection: PlaywrightDetection | null,
): SignInOpenTarget {
if (authBridgeIsLive(bridge)) return "host";
if (canOpenPageInContainerBrowser(detection)) return "container";
return "host";
}
/**
* How long a Playwright probe is reused for.
*
* `check_browser_view_support` is a `docker exec` running a Node probe, and
* every terminal tab of a project would otherwise run its own on mount. Five
* minutes is long enough that opening a handful of tabs costs one exec, and
* short enough that pressing "Set up Playwright" in the Browser tab is
* reflected in the default before the user has finished reading the result.
*/
const DETECTION_TTL_MS = 5 * 60_000;
const detectionCache = new Map<
string,
{ at: number; probe: Promise<PlaywrightDetection | null> }
>();
/** The shared, rate-limited probe. Never rejects — "didn't answer" is `null`. */
function probeBrowserSupport(projectId: string): Promise<PlaywrightDetection | null> {
const hit = detectionCache.get(projectId);
if (hit && Date.now() - hit.at < DETECTION_TTL_MS) return hit.probe;
const probe = checkBrowserViewSupport(projectId).catch(() => {
// A failure is usually a stopped container, which is a state the user
// leaves — so it is not worth remembering for five minutes.
detectionCache.delete(projectId);
return null;
});
detectionCache.set(projectId, { at: Date.now(), probe });
return probe;
}
/** Test seam: drops the memoized probes so a case starts from nothing. */
export function resetBrowserSupportCache(): void {
detectionCache.clear();
}
/**
* Resolve the default action for Anthropic sign-in links in this project.
*
* Resolved at mount rather than when a URL arrives, on purpose: the toast has
* two buttons side by side, and a default that settles a second after the
* toast appears moves them under a mouse that is already travelling.
*
* The expensive half is only paid when it can change the answer. The bridge
* status is host-side and cheap; the Playwright probe is a container exec, and
* a live bridge decides the question before it is ever asked — which, with the
* bridge now on by default, is the ordinary case.
*/
export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTarget {
const [target, setTarget] = useState<SignInOpenTarget>("host");
useEffect(() => {
if (!projectId) {
setTarget("host");
return;
}
let cancelled = false;
let bridge: AuthBridgeStatus | null = null;
let detection: PlaywrightDetection | null = null;
const settle = () => {
if (!cancelled) setTarget(chooseSignInTarget(bridge, detection));
};
const consider = (next: AuthBridgeStatus) => {
bridge = next;
settle();
// Only now is the container's side of it worth an exec.
if (authBridgeIsLive(bridge)) return;
probeBrowserSupport(projectId).then((d) => {
if (cancelled) return;
detection = d;
settle();
});
};
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) consider(s);
})
// Nothing to say to the user here: this only picks which button is
// filled in, and the fallback is the one that reports its own failures.
.catch(() => {
if (!cancelled) consider({ enabled: false, active_ports: [], conflicts: [] });
});
// The switch can be flipped *while a login is hanging* — that is the whole
// reason `set_auth_bridge_enabled` exists outside the Config tab's save —
// so the default has to follow it rather than reflect whatever was true
// when this terminal was opened.
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
consider(event.payload.status);
})
.then((un) => {
if (cancelled) un();
else unlisten = un;
})
.catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
}, [projectId]);
return target;
}
+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, MAX_RELAY_URL_LENGTH,
RelayRateLimiter, RelayRateLimiter,
URL_RELAY_OSC, URL_RELAY_OSC,
isAnthropicSignInUrl,
parseUrlRelayOsc, parseUrlRelayOsc,
sanitizeRelayUrl, sanitizeRelayUrl,
urlOrigin, urlOrigin,
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
expect(rl.allow("https://c.example/", 10_200)).toBe(true); 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. * Whether this is a URL that signs the user in to Anthropic.
* *
* Used to decide *presentation*, not permission — the toast makes the * Classification only. It answers "is this a sign-in link", never "where should
* container-side browser the default action for these, because the OAuth * it be opened" — that decision moved out to `hooks/useSignInOpenTarget.ts`,
* callback listener is inside the container and the host has nothing to catch * because it depends on things this module has no business knowing: whether the
* it with. It is deliberately the same host allowlist the sign-in flow itself * project's auth bridge is live, and whether a browser is actually installed in
* uses, so the two cannot disagree about what a sign-in link is. * 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 { export function isAnthropicSignInUrl(url: string): boolean {
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }); const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });