Make in-container OAuth logins actually complete #57
@@ -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`
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
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
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user