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:
@@ -23,6 +23,7 @@ import {
|
||||
setBrowserViewMatchWindow,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import OpenPageDialog from "./OpenPageDialog";
|
||||
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
|
||||
// reflects an install that just finished.
|
||||
const probed = detection ?? status.detection;
|
||||
const ready = isUsable(probed);
|
||||
const ready = isBrowserViewUsable(probed);
|
||||
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
|
||||
// apt package, so it never shows up in `browsers`, and a container that has
|
||||
// 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`.
|
||||
*
|
||||
@@ -627,7 +623,7 @@ function Setup({
|
||||
onInstall: (which: Exclude<SetupJob, null>) => void;
|
||||
}) {
|
||||
const busy = job !== null;
|
||||
const havePackages = isUsable(detection);
|
||||
const havePackages = isBrowserViewUsable(detection);
|
||||
const missing = missingParts(detection);
|
||||
const browsers = detection?.browsers ?? [];
|
||||
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 { useAppState } from "../../store/appState";
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -16,6 +22,18 @@ const dragDrop = vi.hoisted(() => ({
|
||||
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. */
|
||||
const ptyOutput = vi.hoisted(() => ({
|
||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||
@@ -45,6 +63,8 @@ vi.mock("../../lib/tauri-commands", () => ({
|
||||
awsSsoRefresh: vi.fn(async () => {}),
|
||||
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
|
||||
uploadHostFileToTerminal: vi.fn(async () => ""),
|
||||
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
||||
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -128,6 +148,14 @@ beforeEach(() => {
|
||||
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
||||
dragDrop.handler = null;
|
||||
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 = "";
|
||||
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", () => {
|
||||
/** Mount, then deliberately give focus away, so what the assertions below
|
||||
* observe is the *request* taking effect and never the focus `active`
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
sanitizeRelayUrl,
|
||||
} from "../../lib/urlRelay";
|
||||
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
||||
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||
import UrlToast, {
|
||||
URL_TOAST_PRIMARY_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");
|
||||
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 });
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
@@ -786,20 +798,58 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [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(() => {
|
||||
if (!urlPrompt) return;
|
||||
// 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
|
||||
// precisely when it matters that the last thing before `openUrl` checks.
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
dismissUrlPrompt();
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
dismissUrlPrompt();
|
||||
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]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -896,6 +946,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
signInDefault={signInDefault}
|
||||
onDismiss={dismissUrlPrompt}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
|
||||
|
||||
describe("Anthropic sign-in links", () => {
|
||||
// The callback listener a `claude login` is waiting on is *inside* the
|
||||
// container. Sending the user to their host browser completes the sign-in
|
||||
// and then posts the result where nothing is listening, and the terminal
|
||||
// hangs to its timeout — so for these, and only these, the container-side
|
||||
// browser leads.
|
||||
// container, so a sign-in is the one case where the host browser may be the
|
||||
// wrong lead. Whether it actually is depends on the project — a live auth
|
||||
// bridge carries the callback back, and the container-side alternative is
|
||||
// not installed on a fresh project — so the owner decides and passes
|
||||
// `signInDefault`. This component only renders the decision.
|
||||
const SIGN_IN =
|
||||
"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");
|
||||
}
|
||||
|
||||
it("puts the container browser first", () => {
|
||||
it("puts the container browser first when the caller asks for it", () => {
|
||||
render(
|
||||
<UrlToast
|
||||
url={SIGN_IN}
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
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", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
@@ -202,6 +242,7 @@ describe("UrlToast", () => {
|
||||
url={SIGN_IN}
|
||||
onOpen={onOpen}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -211,12 +252,14 @@ describe("UrlToast", () => {
|
||||
|
||||
it("leaves an ordinary URL alone", () => {
|
||||
// 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(
|
||||
<UrlToast
|
||||
url="https://github.com/login/device?code=ABCD-EFGH"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -232,6 +275,7 @@ describe("UrlToast", () => {
|
||||
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
||||
onOpen={noop}
|
||||
onOpenInContainer={noop}
|
||||
signInDefault="container"
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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