feat: make links in Claude's output clickable
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m47s
Build App (Preview) / build-linux (pull_request) Successful in 8m8s
Build App (Preview) / build-windows (pull_request) Failing after 13m30s
Build App (Preview) / prune-previews (pull_request) Skipped
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m47s
Build App (Preview) / build-linux (pull_request) Successful in 8m8s
Build App (Preview) / build-windows (pull_request) Failing after 13m30s
Build App (Preview) / prune-previews (pull_request) Skipped
Claude Code prints links as OSC 8 hyperlinks whose visible text is hard-wrapped into terminal-width pieces -- urlDetector's header records a 346-character sign-in URL arriving as five emissions, each carrying the whole URL in its parameter and about 80 characters on screen. WebLinksAddon regex-matches the painted characters row by row, so against Claude it matches a fragment or nothing, which is why the URL toast exists. xterm 5.5 hands over the exact parameter through `linkHandler`, so the slicing stops mattering. WebLinksAddon stays for plain-text URLs in ordinary shell output; the two cover different cases and neither replaces the other. Both now share one failure reporter and one validator. No new key handling was needed. xterm's mousedown handler is `if (areMouseEventsActive && !shouldForceSelection(e)) return cancel(e)`, so holding the force-selection modifier lets the event reach the link layer while Claude still holds the mouse -- Shift+click, or Option+click on macOS, which this terminal already enables for text selection. The hover card is the security half rather than decoration. OSC 8 decouples the label from the target completely: a container can print `https://claude.ai` and link it anywhere, which is strictly worse than the userinfo spoofing already guarded against and which invalidated the justification for opening a click without confirmation ("a deliberate act on visible text"). Hovering now shows the real origin, in full and never truncated, because truncating it is the spoof. A target that fails validation says so and deliberately echoes nothing of itself. The hint names the modifier for the platform, from xterm's own `isMac` list, so it cannot tell a Mac user to press a key that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import TerminalView, { supersedes } from "./TerminalView";
|
||||
import TerminalView, {
|
||||
OSC8_HOVER_CLASS,
|
||||
createOsc8LinkHandler,
|
||||
supersedes,
|
||||
} from "./TerminalView";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import {
|
||||
uploadHostFileToTerminal,
|
||||
@@ -1074,3 +1078,140 @@ describe("TerminalView — releasing a captured mouse", () => {
|
||||
expect(terminalInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hover hint names the key that actually works", () => {
|
||||
const platform = (value: string) =>
|
||||
Object.defineProperty(navigator, "platform", { value, configurable: true });
|
||||
const original = navigator.platform;
|
||||
afterEach(() => platform(original));
|
||||
|
||||
// xterm gates this on its own `isMac`; if the hint and the gate disagree the
|
||||
// user is told to press a key that does nothing.
|
||||
it("says Option on a Mac, because that is xterm's force-selection modifier there", () => {
|
||||
platform("MacIntel");
|
||||
const host = document.createElement("div");
|
||||
createOsc8LinkHandler(() => host).hover?.(
|
||||
new MouseEvent("mousemove"),
|
||||
"https://example.com/x",
|
||||
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
|
||||
);
|
||||
expect(host.textContent).toContain("Option+click");
|
||||
expect(host.textContent).not.toContain("Shift+click");
|
||||
});
|
||||
|
||||
it("says Shift everywhere else", () => {
|
||||
platform("Linux x86_64");
|
||||
const host = document.createElement("div");
|
||||
createOsc8LinkHandler(() => host).hover?.(
|
||||
new MouseEvent("mousemove"),
|
||||
"https://example.com/x",
|
||||
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
|
||||
);
|
||||
expect(host.textContent).toContain("Shift+click");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOsc8LinkHandler — clicking a link Claude Code printed", () => {
|
||||
/**
|
||||
* The handler is exercised directly rather than through a rendered terminal.
|
||||
*
|
||||
* xterm decides *when* to call it from cell geometry, and jsdom gives every
|
||||
* element a zero-sized box — so a test driving the mouse over the pane would
|
||||
* be asserting that jsdom's layout engine exists, not that this app validates
|
||||
* what it opens. What xterm hands over is the OSC 8 parameter verbatim, which
|
||||
* is exactly what these arguments are.
|
||||
*/
|
||||
const range = {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 80, y: 1 },
|
||||
} as unknown as Parameters<
|
||||
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
|
||||
>[2];
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let handler: ReturnType<typeof createOsc8LinkHandler>;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
handler = createOsc8LinkHandler(() => host);
|
||||
});
|
||||
|
||||
function hoverCard(): HTMLElement | null {
|
||||
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
|
||||
}
|
||||
|
||||
it("refuses a target that fails validation, without reaching the opener", () => {
|
||||
// The visible text can be anything; the parameter is what gets opened, and
|
||||
// a container is free to put a scheme in it that the host must never hand
|
||||
// to an OS-level opener.
|
||||
handler.activate(new MouseEvent("click"), "javascript:alert(1)", range);
|
||||
handler.activate(new MouseEvent("click"), "file:///etc/passwd", range);
|
||||
handler.activate(
|
||||
new MouseEvent("click"),
|
||||
"https://claude.ai@evil.tld/authorize",
|
||||
range,
|
||||
);
|
||||
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a valid target through the one sink", async () => {
|
||||
const url =
|
||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&scope=user%3Ainference";
|
||||
await act(async () => {
|
||||
handler.activate(new MouseEvent("click"), url, range);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openUrlExternal).toHaveBeenCalledWith(url);
|
||||
});
|
||||
|
||||
it("shows the real origin on hover, not the text on screen", () => {
|
||||
// The point of the affordance. OSC 8 decouples label from target: the row
|
||||
// can read `https://claude.ai` while the parameter points anywhere.
|
||||
handler.hover?.(
|
||||
new MouseEvent("mousemove"),
|
||||
"https://evil.example.com/claude.ai/oauth/authorize?code=true",
|
||||
range,
|
||||
);
|
||||
|
||||
const card = hoverCard();
|
||||
expect(card).not.toBeNull();
|
||||
const origin = card!.querySelector('[data-testid="osc8-hover-origin"]');
|
||||
expect(origin?.textContent).toBe("https://evil.example.com");
|
||||
// Whole origin or nothing — a truncated one is the spoof this prevents.
|
||||
expect(origin?.textContent).not.toContain("…");
|
||||
expect(card!.textContent).not.toContain("https://claude.ai");
|
||||
|
||||
handler.leave?.(new MouseEvent("mouseout"), "https://evil.example.com/", range);
|
||||
expect(hoverCard()).toBeNull();
|
||||
});
|
||||
|
||||
it("says so on hover when the target would be refused", () => {
|
||||
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
|
||||
|
||||
const card = hoverCard();
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
|
||||
// Never echo the rejected target: it is untrusted text on its way to a DOM
|
||||
// node, and the only thing worth saying is that clicking does nothing.
|
||||
expect(card!.textContent).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("pushes the shared toast when the host opener fails", async () => {
|
||||
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
||||
|
||||
await act(async () => {
|
||||
handler.activate(new MouseEvent("click"), "https://example.com/x", range);
|
||||
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");
|
||||
// Same card as every other dead-opener report in this view.
|
||||
expect(toasts[0].dedupeKey).toBe("host-open-failed");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user