fix: gate OSC 8 link activation instead of merely hinting at it
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m42s
Build App (Preview) / build-windows (pull_request) Successful in 5m3s
Build App (Preview) / build-linux (pull_request) Successful in 7m13s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m42s
Build App (Preview) / build-windows (pull_request) Successful in 5m3s
Build App (Preview) / build-linux (pull_request) Successful in 7m13s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Review of this branch found its central premise was false. The claim was that xterm cancels a mousedown before the link layer while a program holds the mouse, so only a Shift+click could reach a link. None of that holds: `cancel()` is `if (this.options.cancelEvents || force)` and `cancelEvents` defaults to false and is never set here, so it does nothing; the mouse reporting listeners bind to `.xterm` while the Linkifier is constructed on `screenElement`, a descendant, so the link layer sees the event first regardless; and `_handleMouseUp` checks neither the modifier nor the button before calling `activate`. So a plain click opened the link, and so did a right-click. That is not a missing convenience. OSC 8 lets the container wrap any clickable TUI widget -- a menu row, a "1. Yes", a file chip -- in a link to anywhere, and because the mouse report still reaches the program afterwards the widget responds too and nothing looks wrong. The hover card was the only mitigation, and it assumes a user deliberately reaching for a link. `opensOnClick` is now a real gate: primary button only, and while a program tracks the mouse the force-selection modifier is required -- the gesture the user already has for "this click is for the terminal, not the program". With nothing tracking, a bare click opens, which is what WebLinksAddon already does for plain-text URLs in the same buffer. The mode is read per click through a getter rather than captured, and `syncMouseCapture` and the gate share one expression, because a gate that disagreed with the badge would be the hole again. The gate and the hint also share one modifier predicate, and the hint is conditional on tracking, so it can never name a key that does nothing. Three more from the same review. The origin span had `flexShrink: 0`, which beats `overflowWrap` under flexbox, so an attacker-controlled 600-character origin ran off the pane and hid the registrable domain -- the same spoof as an ellipsis, without one; it now wraps and the remainder is what gives way. The card had no `pointerEvents: none`, and `xterm-hover` is inert at this placement, so a card under the pointer took `mouseleave` from screenElement and made bottom-row links flicker and refuse to activate at all. And the design doc comment had come adrift from its function. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,33 @@ const ptyOutput = vi.hoisted(() => ({
|
|||||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `TerminalView` actually handed the `Terminal` constructor, and the
|
||||||
|
* instances it built.
|
||||||
|
*
|
||||||
|
* The real xterm is kept — these tests depend on its parser, its modes and its
|
||||||
|
* DOM — and only the constructor is wrapped, because the wiring of
|
||||||
|
* `linkHandler` is otherwise unobservable from outside: xterm decides when to
|
||||||
|
* call it from cell geometry that jsdom has no layout for, so deleting the
|
||||||
|
* `linkHandler:` line changed nothing any assertion could see.
|
||||||
|
*/
|
||||||
|
const xterm = vi.hoisted(() => ({
|
||||||
|
options: null as Record<string, unknown> | null,
|
||||||
|
instances: [] as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@xterm/xterm")>();
|
||||||
|
class SpyTerminal extends actual.Terminal {
|
||||||
|
constructor(options?: ConstructorParameters<typeof actual.Terminal>[0]) {
|
||||||
|
super(options);
|
||||||
|
xterm.options = (options ?? null) as Record<string, unknown> | null;
|
||||||
|
xterm.instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...actual, Terminal: SpyTerminal };
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shift+Enter has to reach the container as ESC+CR.
|
* Shift+Enter has to reach the container as ESC+CR.
|
||||||
*
|
*
|
||||||
@@ -161,6 +188,8 @@ beforeEach(() => {
|
|||||||
useAppState.setState({ toasts: [] });
|
useAppState.setState({ toasts: [] });
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
useAppState.setState({ sessions: [] });
|
useAppState.setState({ sessions: [] });
|
||||||
|
xterm.options = null;
|
||||||
|
xterm.instances.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -1085,29 +1114,42 @@ describe("the hover hint names the key that actually works", () => {
|
|||||||
const original = navigator.platform;
|
const original = navigator.platform;
|
||||||
afterEach(() => platform(original));
|
afterEach(() => platform(original));
|
||||||
|
|
||||||
// xterm gates this on its own `isMac`; if the hint and the gate disagree the
|
const hoverHint = (tracking: boolean): string => {
|
||||||
// user is told to press a key that does nothing.
|
const host = document.createElement("div");
|
||||||
|
createOsc8LinkHandler(
|
||||||
|
() => host,
|
||||||
|
() => tracking,
|
||||||
|
).hover?.(new MouseEvent("mousemove"), "https://example.com/x", {
|
||||||
|
start: { x: 1, y: 1 },
|
||||||
|
end: { x: 1, y: 1 },
|
||||||
|
});
|
||||||
|
return host.textContent ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// The hint and the gate read one predicate; these pin that they cannot
|
||||||
|
// drift, because a hint naming a key the gate does not accept is the bug
|
||||||
|
// that was already fixed once on this branch.
|
||||||
it("says Option on a Mac, because that is xterm's force-selection modifier there", () => {
|
it("says Option on a Mac, because that is xterm's force-selection modifier there", () => {
|
||||||
platform("MacIntel");
|
platform("MacIntel");
|
||||||
const host = document.createElement("div");
|
expect(hoverHint(true)).toContain("Option+click");
|
||||||
createOsc8LinkHandler(() => host).hover?.(
|
expect(hoverHint(true)).not.toContain("Shift+click");
|
||||||
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", () => {
|
it("says Shift everywhere else", () => {
|
||||||
platform("Linux x86_64");
|
platform("Linux x86_64");
|
||||||
const host = document.createElement("div");
|
expect(hoverHint(true)).toContain("Shift+click");
|
||||||
createOsc8LinkHandler(() => host).hover?.(
|
});
|
||||||
new MouseEvent("mousemove"),
|
|
||||||
"https://example.com/x",
|
// No program holds the mouse, so no modifier is needed — and naming one
|
||||||
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
|
// would tell the user to press a key the gate ignores.
|
||||||
);
|
it("names no modifier at all while nothing is tracking the mouse", () => {
|
||||||
expect(host.textContent).toContain("Shift+click");
|
platform("Linux x86_64");
|
||||||
|
const hint = hoverHint(false);
|
||||||
|
expect(hint).toContain("Click to open");
|
||||||
|
expect(hint).not.toContain("Shift+click");
|
||||||
|
|
||||||
|
platform("MacIntel");
|
||||||
|
expect(hoverHint(false)).not.toContain("Option+click");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1130,28 +1172,35 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
|||||||
|
|
||||||
let host: HTMLDivElement;
|
let host: HTMLDivElement;
|
||||||
let handler: ReturnType<typeof createOsc8LinkHandler>;
|
let handler: ReturnType<typeof createOsc8LinkHandler>;
|
||||||
|
/** What the terminal's live mouse-tracking mode says, per test. */
|
||||||
|
let tracking: boolean;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
host = document.createElement("div");
|
host = document.createElement("div");
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
handler = createOsc8LinkHandler(() => host);
|
tracking = false;
|
||||||
|
handler = createOsc8LinkHandler(
|
||||||
|
() => host,
|
||||||
|
() => tracking,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => host.remove());
|
||||||
|
|
||||||
function hoverCard(): HTMLElement | null {
|
function hoverCard(): HTMLElement | null {
|
||||||
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
|
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const click = (init: MouseEventInit = {}) =>
|
||||||
|
new MouseEvent("click", { button: 0, ...init });
|
||||||
|
|
||||||
it("refuses a target that fails validation, without reaching the opener", () => {
|
it("refuses a target that fails validation, without reaching the opener", () => {
|
||||||
// The visible text can be anything; the parameter is what gets opened, and
|
// 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
|
// a container is free to put a scheme in it that the host must never hand
|
||||||
// to an OS-level opener.
|
// to an OS-level opener.
|
||||||
handler.activate(new MouseEvent("click"), "javascript:alert(1)", range);
|
handler.activate(click(), "javascript:alert(1)", range);
|
||||||
handler.activate(new MouseEvent("click"), "file:///etc/passwd", range);
|
handler.activate(click(), "file:///etc/passwd", range);
|
||||||
handler.activate(
|
handler.activate(click(), "https://claude.ai@evil.tld/authorize", range);
|
||||||
new MouseEvent("click"),
|
|
||||||
"https://claude.ai@evil.tld/authorize",
|
|
||||||
range,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1160,13 +1209,75 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
|||||||
const url =
|
const url =
|
||||||
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&scope=user%3Ainference";
|
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&scope=user%3Ainference";
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
handler.activate(new MouseEvent("click"), url, range);
|
handler.activate(click(), url, range);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(openUrlExternal).toHaveBeenCalledWith(url);
|
expect(openUrlExternal).toHaveBeenCalledWith(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the gate on activation", () => {
|
||||||
|
const URL = "https://example.com/x";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attack this gate exists for.
|
||||||
|
*
|
||||||
|
* xterm's mouse-reporting mousedown does *not* cancel anything —
|
||||||
|
* `cancelEvents` defaults to false — and the Linkifier is a descendant of
|
||||||
|
* the element those listeners are bound to, so the link layer sees every
|
||||||
|
* click first and `_handleMouseUp` activates with no modifier, button or
|
||||||
|
* mode check of its own. A TUI widget the user is meant to click can
|
||||||
|
* therefore be wrapped in an OSC 8 pointing anywhere, and a plain click
|
||||||
|
* opens the host browser on it while the mouse report still reaches the
|
||||||
|
* program, so nothing looks wrong. The modifier is the only thing that
|
||||||
|
* separates "I clicked the menu item" from "I asked to leave the app".
|
||||||
|
*/
|
||||||
|
it("refuses a plain click while a program is tracking the mouse", () => {
|
||||||
|
tracking = true;
|
||||||
|
|
||||||
|
handler.activate(click(), URL, range);
|
||||||
|
|
||||||
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on the force-selection modifier while tracking", async () => {
|
||||||
|
tracking = true;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
handler.activate(click({ shiftKey: true }), URL, range);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openUrlExternal).toHaveBeenCalledWith(URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on a plain click when nothing holds the mouse", async () => {
|
||||||
|
// A normal shell. This is what `WebLinksAddon` does for the plain-text
|
||||||
|
// URLs in the same buffer, and asking for a modifier here would read as
|
||||||
|
// a broken link.
|
||||||
|
tracking = false;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
handler.activate(click(), URL, range);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openUrlExternal).toHaveBeenCalledWith(URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores every button but the primary one", () => {
|
||||||
|
// Right-click is the context menu this pane already binds; middle-click
|
||||||
|
// is paste. Neither is a request to leave the app.
|
||||||
|
tracking = false;
|
||||||
|
|
||||||
|
handler.activate(click({ button: 2 }), URL, range);
|
||||||
|
handler.activate(click({ button: 1 }), URL, range);
|
||||||
|
handler.activate(click({ button: 2, shiftKey: true }), URL, range);
|
||||||
|
|
||||||
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the real origin on hover, not the text on screen", () => {
|
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
|
// The point of the affordance. OSC 8 decouples label from target: the row
|
||||||
// can read `https://claude.ai` while the parameter points anywhere.
|
// can read `https://claude.ai` while the parameter points anywhere.
|
||||||
@@ -1180,14 +1291,49 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
|||||||
expect(card).not.toBeNull();
|
expect(card).not.toBeNull();
|
||||||
const origin = card!.querySelector('[data-testid="osc8-hover-origin"]');
|
const origin = card!.querySelector('[data-testid="osc8-hover-origin"]');
|
||||||
expect(origin?.textContent).toBe("https://evil.example.com");
|
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");
|
expect(card!.textContent).not.toContain("https://claude.ai");
|
||||||
|
|
||||||
handler.leave?.(new MouseEvent("mouseout"), "https://evil.example.com/", range);
|
handler.leave?.(new MouseEvent("mouseout"), "https://evil.example.com/", range);
|
||||||
expect(hoverCard()).toBeNull();
|
expect(hoverCard()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a very long origin whole, and gives way in the remainder instead", () => {
|
||||||
|
// The attacker picks the origin's length. `https://claude.ai.<300 a's>
|
||||||
|
// .evil.tld/` parses, passes every `sanitizeRelayUrl` rule, and under a
|
||||||
|
// non-shrinking flex item runs off the right edge of the pane — which
|
||||||
|
// hides the registrable domain just as effectively as an ellipsis would.
|
||||||
|
const origin = `https://claude.ai.${"a".repeat(300)}.${"b".repeat(200)}.evil.tld`;
|
||||||
|
handler.hover?.(new MouseEvent("mousemove"), `${origin}/oauth?code=1`, range);
|
||||||
|
|
||||||
|
const originEl = hoverCard()!.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="osc8-hover-origin"]',
|
||||||
|
)!;
|
||||||
|
// Whole origin or nothing: every character is in the DOM...
|
||||||
|
expect(originEl.textContent).toBe(origin);
|
||||||
|
// ...and it is allowed to wrap rather than be clipped or pushed off-pane.
|
||||||
|
expect(originEl.style.flexShrink).not.toBe("0");
|
||||||
|
expect(originEl.style.whiteSpace).not.toBe("nowrap");
|
||||||
|
expect(originEl.style.overflowWrap).toBe("anywhere");
|
||||||
|
|
||||||
|
// The truncatable half is the remainder, and only the remainder.
|
||||||
|
const restEl = hoverCard()!.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="osc8-hover-rest"]',
|
||||||
|
)!;
|
||||||
|
expect(restEl.style.textOverflow).toBe("ellipsis");
|
||||||
|
expect(restEl.style.whiteSpace).toBe("nowrap");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot take the pointer away from the link that summoned it", () => {
|
||||||
|
// The card is appended to `Terminal.element`, a *sibling* of the
|
||||||
|
// `screenElement` the Linkifier listens on, so `xterm-hover` buys nothing
|
||||||
|
// here: a card under the pointer means `mouseleave` on screenElement, the
|
||||||
|
// card is torn down, and the mouseup that would activate the link lands on
|
||||||
|
// the card instead of the terminal.
|
||||||
|
handler.hover?.(new MouseEvent("mousemove"), "https://example.com/x", range);
|
||||||
|
|
||||||
|
expect(hoverCard()!.style.pointerEvents).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
it("says so on hover when the target would be refused", () => {
|
it("says so on hover when the target would be refused", () => {
|
||||||
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
|
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
|
||||||
|
|
||||||
@@ -1199,11 +1345,37 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
|||||||
expect(card!.textContent).not.toContain("javascript:");
|
expect(card!.textContent).not.toContain("javascript:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not call a refused web address something other than a web address", () => {
|
||||||
|
// `https://claude.ai@evil.tld/` is a perfectly good URL; it is refused
|
||||||
|
// because the userinfo makes the visible host a lie. Telling the user it
|
||||||
|
// "is not a web address" is false, and a false explanation teaches them to
|
||||||
|
// distrust the card.
|
||||||
|
handler.hover?.(
|
||||||
|
new MouseEvent("mousemove"),
|
||||||
|
"https://claude.ai@evil.tld/authorize",
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hoverCard()!.textContent).not.toContain("not a web address");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a stale card when the pane is no longer on screen", () => {
|
||||||
|
// `leave` only ever arrives from the Linkifier's `_clearCurrentLink`, and
|
||||||
|
// switching tabs from the keyboard moves no pointer: without this, the
|
||||||
|
// card is still sitting there when the user comes back.
|
||||||
|
handler.hover?.(new MouseEvent("mousemove"), "https://example.com/x", range);
|
||||||
|
expect(hoverCard()).not.toBeNull();
|
||||||
|
|
||||||
|
handler.dismiss();
|
||||||
|
|
||||||
|
expect(hoverCard()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("pushes the shared toast when the host opener fails", async () => {
|
it("pushes the shared toast when the host opener fails", async () => {
|
||||||
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
handler.activate(new MouseEvent("click"), "https://example.com/x", range);
|
handler.activate(click(), "https://example.com/x", range);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1215,3 +1387,70 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
|||||||
expect(toasts[0].dedupeKey).toBe("host-open-failed");
|
expect(toasts[0].dedupeKey).toBe("host-open-failed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the link handler is wired into the terminal, and reads its live mode", () => {
|
||||||
|
const range = {
|
||||||
|
start: { x: 1, y: 1 },
|
||||||
|
end: { x: 80, y: 1 },
|
||||||
|
} as unknown as Parameters<
|
||||||
|
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
|
||||||
|
>[2];
|
||||||
|
|
||||||
|
/** What the mounted view passed as `linkHandler`. */
|
||||||
|
function wiredHandler() {
|
||||||
|
const handler = xterm.options?.linkHandler as
|
||||||
|
| ReturnType<typeof createOsc8LinkHandler>
|
||||||
|
| undefined;
|
||||||
|
if (!handler) throw new Error("no linkHandler was passed to Terminal");
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed the terminal a DECSET the way the container would. */
|
||||||
|
async function write(data: string) {
|
||||||
|
const term = xterm.instances.at(-1) as { write(d: string, cb: () => void): void };
|
||||||
|
await act(
|
||||||
|
() => new Promise<void>((resolve) => term.write(data, resolve)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("passes one at all — without it OSC 8 links are inert", () => {
|
||||||
|
mountSession("claude");
|
||||||
|
|
||||||
|
const handler = wiredHandler();
|
||||||
|
expect(typeof handler.activate).toBe("function");
|
||||||
|
expect(typeof handler.hover).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The gate has to ask the terminal, not a boolean captured at construction:
|
||||||
|
// the mode changes whenever the container prints a DECSET, which is several
|
||||||
|
// times a second in Claude Code.
|
||||||
|
it("refuses a plain click once the container turns mouse tracking on", async () => {
|
||||||
|
mountSession("claude");
|
||||||
|
await write("\x1b[?1002h");
|
||||||
|
|
||||||
|
wiredHandler().activate(
|
||||||
|
new MouseEvent("click", { button: 0 }),
|
||||||
|
"https://example.com/x",
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens again once the container gives the mouse back", async () => {
|
||||||
|
mountSession("claude");
|
||||||
|
await write("\x1b[?1002h");
|
||||||
|
await write("\x1b[?1002l");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wiredHandler().activate(
|
||||||
|
new MouseEvent("click", { button: 0 }),
|
||||||
|
"https://example.com/x",
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openUrlExternal).toHaveBeenCalledWith("https://example.com/x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -108,12 +108,14 @@ export function supersedes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class xterm requires on an element that floats over the terminal.
|
* Marks the hover card, for xterm's stylesheet and for the tests.
|
||||||
*
|
*
|
||||||
* Not decoration: `ILinkHandler.hover`'s contract is that the hover element
|
* It does *not* make xterm route pointer events around the card. xterm only
|
||||||
* lives inside `Terminal.element` and carries this class, otherwise xterm's
|
* consults this class inside `Linkifier._handleMouseMove`, which is registered
|
||||||
* own hit-testing does not know to stop at it and mouse events fall through to
|
* on `screenElement`; the card is appended to `Terminal.element`, a *sibling*
|
||||||
* whatever link is underneath.
|
* of that node, so the check never sees it. What keeps the card out of the way
|
||||||
|
* is `pointerEvents: "none"` on the card itself — see `hover` below for what
|
||||||
|
* goes wrong without it.
|
||||||
*/
|
*/
|
||||||
export const OSC8_HOVER_CLASS = "xterm-hover";
|
export const OSC8_HOVER_CLASS = "xterm-hover";
|
||||||
|
|
||||||
@@ -135,6 +137,91 @@ function reportOpenFailure(e: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ILinkHandler`, plus the one thing xterm never asks for.
|
||||||
|
*
|
||||||
|
* `leave` is only ever reached through `Linkifier._clearCurrentLink`, i.e. a
|
||||||
|
* pointer that moved. Switching tabs from the keyboard moves no pointer and
|
||||||
|
* the Linkifier's dispose path does not clear either, so the card outlives the
|
||||||
|
* pane and is still there when the user comes back. {@link dismiss} is how the
|
||||||
|
* view says "this pane is gone" without pretending to be a mouse event.
|
||||||
|
*/
|
||||||
|
export type Osc8LinkHandler = ILinkHandler & { dismiss(): void };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a program holding the mouse?
|
||||||
|
*
|
||||||
|
* One expression, two readers that must never disagree: the status-bar badge
|
||||||
|
* (`syncMouseCapture`) and the gate on opening a link ({@link opensOnClick}).
|
||||||
|
* A gate that thought tracking was off while the badge said it was on would be
|
||||||
|
* the whole security hole back again.
|
||||||
|
*/
|
||||||
|
function terminalTracksMouse(term: Terminal): boolean {
|
||||||
|
return term.modes.mouseTrackingMode !== "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** xterm's `isMac` verbatim (`common/Platform.ts`), so we split where it does. */
|
||||||
|
function isMacPlatform(): boolean {
|
||||||
|
const platform = typeof navigator === "undefined" ? "" : navigator.platform;
|
||||||
|
return ["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xterm's `SelectionService.shouldForceSelection`, mirrored.
|
||||||
|
*
|
||||||
|
* The modifier is not our choice and it must not drift: while a program holds
|
||||||
|
* the mouse, this is the one gesture the user already has for "this click is
|
||||||
|
* for the terminal, not for the program", so it is the gesture that may open a
|
||||||
|
* link. xterm's rule is
|
||||||
|
* `isMac ? e.altKey && macOptionClickForcesSelection : e.shiftKey`, and this
|
||||||
|
* view sets `macOptionClickForcesSelection`, so the Mac branch is live rather
|
||||||
|
* than theoretical.
|
||||||
|
*
|
||||||
|
* The gate and the hint below both call this. A hint that names a key the gate
|
||||||
|
* does not accept is worse than no hint — the user concludes the link is
|
||||||
|
* broken — and that is a bug this branch has already shipped once, so the two
|
||||||
|
* are not allowed separate answers.
|
||||||
|
*/
|
||||||
|
function forcesSelection(event: { altKey: boolean; shiftKey: boolean }): boolean {
|
||||||
|
return isMacPlatform() ? event.altKey : event.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the card tells the user to do, for the state the terminal is in *now*.
|
||||||
|
*
|
||||||
|
* Conditional because the gesture is: with no program tracking the mouse a
|
||||||
|
* plain click opens the link, and naming a modifier then would send the user
|
||||||
|
* hunting for a key that changes nothing.
|
||||||
|
*/
|
||||||
|
function openHintLabel(mouseTracking: boolean): string {
|
||||||
|
if (!mouseTracking) return "Click to open";
|
||||||
|
return isMacPlatform() ? "Option+click to open" : "Shift+click to open";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this click is a request to leave the app for the host browser.
|
||||||
|
*
|
||||||
|
* Two refusals, for two different mistakes:
|
||||||
|
*
|
||||||
|
* - Anything but the primary button. xterm's `Linkifier._handleMouseUp`
|
||||||
|
* checks neither the button nor the mouse mode, so without this a
|
||||||
|
* *right*-click activates the link as well as opening this pane's context
|
||||||
|
* menu, and a middle-click paste opens it too.
|
||||||
|
* - A plain click while a program is tracking the mouse. That is the
|
||||||
|
* dangerous one: OSC 8 lets the container wrap any clickable TUI widget —
|
||||||
|
* a menu row, a "1. Yes", a file chip — in a link to anywhere, and because
|
||||||
|
* the mouse report still reaches the program the widget also responds, so
|
||||||
|
* nothing looks wrong. Requiring the force-selection modifier there makes
|
||||||
|
* the two intents distinguishable.
|
||||||
|
*
|
||||||
|
* With nothing tracking the mouse a bare click is correct and expected: it is
|
||||||
|
* what `WebLinksAddon` does for the plain-text URLs in the same buffer.
|
||||||
|
*/
|
||||||
|
function opensOnClick(event: MouseEvent, mouseTracking: boolean): boolean {
|
||||||
|
if (event.button !== 0) return false;
|
||||||
|
return !mouseTracking || forcesSelection(event);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes OSC 8 hyperlinks clickable, and shows where they actually go.
|
* Makes OSC 8 hyperlinks clickable, and shows where they actually go.
|
||||||
*
|
*
|
||||||
@@ -152,15 +239,21 @@ function reportOpenFailure(e: unknown) {
|
|||||||
* stays, because it covers the plain-text URLs in ordinary shell output that
|
* stays, because it covers the plain-text URLs in ordinary shell output that
|
||||||
* carry no OSC 8 at all.
|
* carry no OSC 8 at all.
|
||||||
*
|
*
|
||||||
* ## The gesture is Shift+click (Option+click on macOS)
|
* ## xterm applies no gate of its own, so this one does
|
||||||
*
|
*
|
||||||
* Claude holds mouse tracking (`?1000`/`?1002`/`?1003`), and xterm's mousedown
|
* There is a tempting story in which xterm's mouse-reporting mousedown cancels
|
||||||
* handler cancels the event before the link layer whenever tracking is on —
|
* the event before the link layer sees it, leaving only the force-selection
|
||||||
* *unless* `shouldForceSelection(e)` is true, which is `e.shiftKey`, or
|
* modifier a way through. It is false in both halves. That branch calls
|
||||||
* `e.altKey` on macOS with `macOptionClickForcesSelection` set (this view sets
|
* `cancel(e)`, which is a no-op unless `cancelEvents` is set and it defaults to
|
||||||
* it). So the modifier that already exists for selecting text is the one that
|
* false; and the mouse-reporting listeners are bound on `Terminal.element`
|
||||||
* reaches a link, and no new key handling is involved. A plain click keeps
|
* while the Linkifier is bound on `screenElement`, a descendant, so bubbling
|
||||||
* going to the program, which is what a TUI needs.
|
* reaches the link first no matter what. `Linkifier._handleMouseUp` then
|
||||||
|
* activates the link with no check on the button, the modifier or the mouse
|
||||||
|
* mode.
|
||||||
|
*
|
||||||
|
* So the gate is {@link opensOnClick}, applied in `activate`, and the mouse
|
||||||
|
* mode it asks about is read from the terminal on every click rather than
|
||||||
|
* captured — the container changes it whenever it likes.
|
||||||
*
|
*
|
||||||
* ## The hover card is the security half, not a nicety
|
* ## The hover card is the security half, not a nicety
|
||||||
*
|
*
|
||||||
@@ -179,31 +272,14 @@ function reportOpenFailure(e: unknown) {
|
|||||||
*
|
*
|
||||||
* @param getHost returns `Terminal.element`, which does not exist until
|
* @param getHost returns `Terminal.element`, which does not exist until
|
||||||
* `term.open()` has run — hence a getter rather than the element.
|
* `term.open()` has run — hence a getter rather than the element.
|
||||||
|
* @param isMouseTracking answers "is a program holding the mouse right now?"
|
||||||
|
* — a getter for the same reason, and the *only* reason: the answer
|
||||||
|
* changes under us.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* The modifier that opens an OSC 8 link, named the way the user's platform
|
|
||||||
* names it.
|
|
||||||
*
|
|
||||||
* This is not our choice and it must not drift: xterm only lets a click reach
|
|
||||||
* the link layer while a program holds the mouse when its own
|
|
||||||
* `shouldForceSelection` says so, and that is
|
|
||||||
* `isMac ? e.altKey && macOptionClickForcesSelection : e.shiftKey`. The list
|
|
||||||
* below is xterm's `isMac` verbatim (`common/Platform.ts`) so the hint cannot
|
|
||||||
* disagree with the behaviour it describes — a hint that names the wrong key
|
|
||||||
* is worse than no hint, because the user concludes the link is broken.
|
|
||||||
*
|
|
||||||
* `macOptionClickForcesSelection` is set on the terminal, so the Mac branch is
|
|
||||||
* live rather than theoretical.
|
|
||||||
*/
|
|
||||||
function openModifierLabel(): string {
|
|
||||||
const platform = typeof navigator === "undefined" ? "" : navigator.platform;
|
|
||||||
const isMac = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(platform);
|
|
||||||
return isMac ? "Option+click to open" : "Shift+click to open";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOsc8LinkHandler(
|
export function createOsc8LinkHandler(
|
||||||
getHost: () => HTMLElement | null,
|
getHost: () => HTMLElement | null,
|
||||||
): ILinkHandler {
|
isMouseTracking: () => boolean,
|
||||||
|
): Osc8LinkHandler {
|
||||||
let card: HTMLDivElement | null = null;
|
let card: HTMLDivElement | null = null;
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
@@ -219,7 +295,10 @@ export function createOsc8LinkHandler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activate(_event, text) {
|
activate(event, text) {
|
||||||
|
// Opening the host browser is the one thing in this pane the container
|
||||||
|
// must not be able to provoke on its own. See `opensOnClick`.
|
||||||
|
if (!opensOnClick(event, isMouseTracking())) return;
|
||||||
// Same sink and same rule as the WebLinksAddon branch: this came off the
|
// Same sink and same rule as the WebLinksAddon branch: this came off the
|
||||||
// container's output, so it is validated before it reaches the OS
|
// container's output, so it is validated before it reaches the OS
|
||||||
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
|
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
|
||||||
@@ -244,7 +323,16 @@ export function createOsc8LinkHandler(
|
|||||||
left: "8px",
|
left: "8px",
|
||||||
bottom: "8px",
|
bottom: "8px",
|
||||||
maxWidth: "calc(100% - 16px)",
|
maxWidth: "calc(100% - 16px)",
|
||||||
|
boxSizing: "border-box",
|
||||||
zIndex: "30",
|
zIndex: "30",
|
||||||
|
// The card lands under the pointer for a link in the bottom rows, and
|
||||||
|
// it is not a sibling the Linkifier hit-tests around (see
|
||||||
|
// `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave`
|
||||||
|
// the moment the card appears — card removed, pointer back on the
|
||||||
|
// link, card back: a flicker loop — and worse, the `mouseup` that
|
||||||
|
// activates the link lands on the card, so the link cannot be opened
|
||||||
|
// at all. Nothing here is interactive, so nothing is lost.
|
||||||
|
pointerEvents: "none",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "baseline",
|
alignItems: "baseline",
|
||||||
gap: "6px",
|
gap: "6px",
|
||||||
@@ -253,6 +341,9 @@ export function createOsc8LinkHandler(
|
|||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
background: "var(--bg-secondary)",
|
background: "var(--bg-secondary)",
|
||||||
border: "1px solid var(--border-color)",
|
border: "1px solid var(--border-color)",
|
||||||
|
// The origin wraps, so the card grows downward rather than sideways;
|
||||||
|
// this is the backstop for anything that still cannot fit.
|
||||||
|
overflow: "hidden",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
boxShadow: "var(--shadow-overlay)",
|
boxShadow: "var(--shadow-overlay)",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
@@ -263,9 +354,12 @@ export function createOsc8LinkHandler(
|
|||||||
if (!safe || !origin) {
|
if (!safe || !origin) {
|
||||||
// Nothing of the rejected target is echoed into the DOM — it is
|
// Nothing of the rejected target is echoed into the DOM — it is
|
||||||
// untrusted text, and the only useful thing to say is that the click
|
// untrusted text, and the only useful thing to say is that the click
|
||||||
// will not do anything.
|
// will not do anything. Deliberately not "it is not a web address":
|
||||||
|
// `https://claude.ai@evil.tld/` and an over-length URL both are one,
|
||||||
|
// and a card that explains a refusal wrongly teaches the user to
|
||||||
|
// distrust the card.
|
||||||
card.appendChild(
|
card.appendChild(
|
||||||
span("This link will not be opened — it is not a web address", {
|
span("This link will not be opened — it failed the URL safety check", {
|
||||||
color: "var(--text-secondary)",
|
color: "var(--text-secondary)",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -273,10 +367,19 @@ export function createOsc8LinkHandler(
|
|||||||
const rest = safe.startsWith(origin) ? safe.slice(origin.length) : safe;
|
const rest = safe.startsWith(origin) ? safe.slice(origin.length) : safe;
|
||||||
const originEl = span(origin, {
|
const originEl = span(origin, {
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
// The part that decides where the credentials go. Never truncated:
|
// The part that decides where the credentials go, so all of it is
|
||||||
// truncating it *is* the spoof.
|
// shown: truncating it *is* the spoof, and so is pushing its tail
|
||||||
flexShrink: "0",
|
// off the right edge of the pane. The attacker picks the length —
|
||||||
|
// `https://claude.ai.<300 chars>.evil.tld` parses and passes every
|
||||||
|
// `sanitizeRelayUrl` rule — so "do not shrink" is not enough:
|
||||||
|
// `flex-shrink: 0` pins a flex item at its max-content width and the
|
||||||
|
// text never wraps, it just overflows. It wraps instead, onto as
|
||||||
|
// many lines as it needs, and the truncatable remainder below is the
|
||||||
|
// thing that gives way.
|
||||||
|
flexShrink: "1",
|
||||||
|
minWidth: "0",
|
||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
|
whiteSpace: "normal",
|
||||||
});
|
});
|
||||||
originEl.dataset.testid = "osc8-hover-origin";
|
originEl.dataset.testid = "osc8-hover-origin";
|
||||||
card.appendChild(originEl);
|
card.appendChild(originEl);
|
||||||
@@ -291,7 +394,7 @@ export function createOsc8LinkHandler(
|
|||||||
restEl.dataset.testid = "osc8-hover-rest";
|
restEl.dataset.testid = "osc8-hover-rest";
|
||||||
card.appendChild(restEl);
|
card.appendChild(restEl);
|
||||||
|
|
||||||
const hint = span(openModifierLabel(), {
|
const hint = span(openHintLabel(isMouseTracking()), {
|
||||||
color: "var(--text-secondary)",
|
color: "var(--text-secondary)",
|
||||||
flexShrink: "0",
|
flexShrink: "0",
|
||||||
marginLeft: "4px",
|
marginLeft: "4px",
|
||||||
@@ -303,6 +406,8 @@ export function createOsc8LinkHandler(
|
|||||||
},
|
},
|
||||||
|
|
||||||
leave: clear,
|
leave: clear,
|
||||||
|
|
||||||
|
dismiss: clear,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +417,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
|
// Held only so the hover card can be taken down when this pane leaves the
|
||||||
|
// screen — see `Osc8LinkHandler.dismiss`.
|
||||||
|
const osc8LinkHandlerRef = useRef<Osc8LinkHandler | null>(null);
|
||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||||
@@ -578,7 +686,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const syncMouseCapture = useCallback(() => {
|
const syncMouseCapture = useCallback(() => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
const captured = term.modes.mouseTrackingMode !== "none";
|
const captured = terminalTracksMouse(term);
|
||||||
if (captured === mouseCapturedRef.current) return;
|
if (captured === mouseCapturedRef.current) return;
|
||||||
mouseCapturedRef.current = captured;
|
mouseCapturedRef.current = captured;
|
||||||
setMouseCaptured(captured);
|
setMouseCaptured(captured);
|
||||||
@@ -625,9 +733,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
macOptionClickForcesSelection: true,
|
macOptionClickForcesSelection: true,
|
||||||
// OSC 8 hyperlinks — the form Claude Code prints its links in, and the
|
// OSC 8 hyperlinks — the form Claude Code prints its links in, and the
|
||||||
// one `WebLinksAddon` structurally cannot match. See
|
// one `WebLinksAddon` structurally cannot match. See
|
||||||
// `createOsc8LinkHandler`, including why the gesture is the same
|
// `createOsc8LinkHandler`, including why opening one while a program
|
||||||
// Shift/Option the line above is about.
|
// holds the mouse needs the same Shift/Option the line above is about.
|
||||||
linkHandler: createOsc8LinkHandler(() => term.element ?? null),
|
// Both arguments are getters because neither answer exists yet: the
|
||||||
|
// element arrives with `term.open()`, and the mouse mode changes
|
||||||
|
// whenever the container prints a DECSET.
|
||||||
|
linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler(
|
||||||
|
() => term.element ?? null,
|
||||||
|
() => terminalTracksMouse(term),
|
||||||
|
)),
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0d1117",
|
background: "#0d1117",
|
||||||
@@ -968,9 +1082,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
webglRef.current = null;
|
webglRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
|
osc8LinkHandlerRef.current = null;
|
||||||
};
|
};
|
||||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// A hover card only ever clears on a *pointer* leaving the link, so switching
|
||||||
|
// tabs from the keyboard leaves one hanging over a pane nobody is looking at,
|
||||||
|
// to be found still there on the way back. Hiding the wrapper does not fire
|
||||||
|
// `mouseleave`, so nothing else would.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) return;
|
||||||
|
osc8LinkHandlerRef.current?.dismiss();
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
||||||
// Only the active terminal holds a WebGL context to avoid exhausting
|
// Only the active terminal holds a WebGL context to avoid exhausting
|
||||||
// the browser's limited pool (~8-16 contexts).
|
// the browser's limited pool (~8-16 contexts).
|
||||||
|
|||||||
Reference in New Issue
Block a user