Shift+Enter newline, OAuth URL truncation, and the auth bridge toggle

Three fixes that all land on the same journey: sign in, paste a prompt,
and have the terminal behave the way every other Claude Code host does.

Shift+Enter inserts a newline
-----------------------------
xterm.js does not consult `shiftKey` for Enter (`Keyboard.ts`, case 13),
so Shift+Enter was byte-identical to Enter and submitted the prompt.
Both terminals now send `\x1b\r` (ESC+CR) instead, which Claude Code
parses as return+meta — the same bytes its own `/terminal-setup` writes
into the VS Code, Cursor, Alacritty and Zed keymaps, so this is in-band
rather than a guess. Not `\n`: Claude Code accepts it, but a shell would
run the line, so the two session types would diverge. Bound in Claude
sessions only for that reason.

`entrypoint.sh` sets `shiftEnterKeyBindingInstalled` in `~/.claude.json`
so the CLI stops printing its "run /terminal-setup" tip. Purely
cosmetic — the decoding is unconditional either way.

Alt+Enter has always done the same thing (xterm ESC-prefixes on altKey)
and was simply never documented. It is now, along with the rest.

OAuth login URL truncation
--------------------------
Two producers wrote one toast slot, last-writer-wins. The OSC 7777 relay
delivers the URL base64-encoded and therefore exact; ~300 ms later the
screen-scraper's debounce fired and overwrote it with a truncated guess
at the same link — a URL that parses, points at the right host, and
authorises nothing. The user is the one who has to notice.

Why the scraper truncated: `ANSI_RE` strips OSC sequences wholesale,
including the OSC 8 hyperlink whose parameter carries the complete URL.
Claude Code slices the *visible* text of that hyperlink to the terminal
width while every emission carries the whole URL in its parameter. The
backend already knew this (`commands/auth_token_commands.rs`); the
frontend did not.

- `urlDetector` now reads OSC 8 targets out of the raw buffer before
  stripping, filtered by a port of `usable_sign_in_link`, and tags every
  candidate with its provenance.
- The prompt slot gained `supersedes`: better provenance always wins,
  worse never does, and between equals only a candidate that *extends*
  what is showing may replace it. That last rule is `extendsUrl`,
  factored out of `pickSignInUrl` rather than copied — same rule, same
  reason, one implementation.
- `flatten` splits on a bare `\r` as well as on `\r?\n`, so a
  `\r`-repainted TUI frame no longer inflates a line past the width and
  suppresses a join that should have happened; and the width is now
  sampled at `feed()` rather than read at `scan()`, so a resize inside
  the 300 ms debounce cannot reassemble 80-column text against a
  120-column rule.

Also corrects the comment claiming `acquire_claude_token` enables the
auth bridge. It deliberately does not, and the module comment in
`auth_token_commands.rs` explains at length why not.

The auth bridge toggle
----------------------
`setAuthBridgeEnabled` and `getAuthBridgeStatus` had zero call sites:
the Rust was complete, the IPC wrapper shipped, and there was nowhere to
click — so the docs told users to "enable the Auth Bridge" for a switch
that did not exist. `AuthBridgeRow` is that switch, in Config → Runtime.
It deliberately does not go through the tab's stopped-only save: the
dedicated command exists so the bridge can be flipped while a login is
hanging in a running container, which is the only moment anyone reaches
for it.

It also subscribes to `auth-bridge-changed`, which the poller has been
emitting to nobody — so a host port the bridge could not take was a
completely silent failure, indistinguishable from a login that hung.

`tunnel.rs` promotes the best-effort `::1` bind failure from debug to a
warning recorded on the port. Half-bound is the failure mode that looks
like success: the status says bridged, and a client that resolves
`localhost` to `::1` without falling back is still refused.

Finally, for a recognised Anthropic sign-in URL the toast now leads with
"In container" and demotes the host "Open". The callback listener is
inside the container, so the container-side browser closes the loop with
no host round trip and no auth bridge; the host button stays as the
fallback. Ordinary URLs are unchanged.

Tests: 402 frontend (was 359), 285 Rust (unchanged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 08:31:39 -07:00
co-authored by Claude Opus 5
parent 75cace7dde
commit 22d142c70d
21 changed files with 1529 additions and 98 deletions
+13
View File
@@ -23,6 +23,11 @@ export default function StatusBar({ stt }: Props) {
}))
);
const running = projects.filter((p) => p.status === "running").length;
// Only in a Claude tab: the chord is bound there and nowhere else, and a hint
// for a key that does nothing is worse than no hint.
const inClaudeSession = sessions.some(
(s) => s.id === activeSessionId && s.sessionType === "claude",
);
return (
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
@@ -45,6 +50,14 @@ export default function StatusBar({ stt }: Props) {
</span>
</>
)}
{!terminalHasSelection && inClaudeSession && (
<>
<span className="mx-2">|</span>
<span title="Sends ESC+CR — the sequence Claude Code's own /terminal-setup installs. Alt+Enter does the same.">
Shift+Enter: newline
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import AuthBridgeRow, { bridgeIndicator } from "./AuthBridgeRow";
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
/**
* The bridge shipped with a working backend, a typed IPC wrapper, and no way to
* reach either: `setAuthBridgeEnabled` had zero call sites, and the
* `auth-bridge-changed` event had no listener — so a host port the bridge could
* not take was a silent failure that presented as a login that simply hung.
* These tests hold both halves down.
*/
const getAuthBridgeStatus = vi.fn<() => Promise<AuthBridgeStatus>>();
const setAuthBridgeEnabled = vi.fn<(id: string, on: boolean) => Promise<AuthBridgeStatus>>();
vi.mock("../../../../lib/tauri-commands", () => ({
getAuthBridgeStatus: () => getAuthBridgeStatus(),
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
}));
/** Captured so a test can push an `auth-bridge-changed` payload by hand. */
let emit: ((payload: unknown) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (_name: string, handler: (e: { payload: unknown }) => void) => {
emit = (payload) => handler({ payload });
return () => {
emit = null;
};
}),
}));
const OFF: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const project = {
id: "p1",
name: "api",
status: "running",
auth_bridge_enabled: false,
} as unknown as Project;
beforeEach(() => {
vi.clearAllMocks();
getAuthBridgeStatus.mockResolvedValue(OFF);
setAuthBridgeEnabled.mockResolvedValue({ ...OFF, enabled: true });
});
describe("AuthBridgeRow", () => {
it("turns the bridge on through its own command, not the project save", async () => {
// The dedicated command exists so this can be flipped while the container
// runs — which is exactly when a user discovers they need it. Routing it
// through the Config tab's stopped-only save would make it unreachable at
// the only moment it matters.
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
await waitFor(() =>
expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true),
);
});
it("stays usable while the container is running", async () => {
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled();
});
it("reports a port conflict the poller emitted", async () => {
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(emit).not.toBeNull());
emit!({
project_id: "p1",
status: {
enabled: true,
active_ports: [],
conflicts: [
{ port: 54545, reason: "Host port 54545 is already in use (…); not bridged." },
],
},
});
expect(await screen.findByText(/Port 54545/)).toBeInTheDocument();
expect(screen.getByText("Port conflict")).toBeInTheDocument();
});
it("ignores an event for a different project", async () => {
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(emit).not.toBeNull());
emit!({
project_id: "other",
status: { enabled: true, active_ports: [], conflicts: [{ port: 1, reason: "nope" }] },
});
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
});
it("puts the switch back if the command rejects", async () => {
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
expect(await screen.findByText(/not found/)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeChecked();
});
});
describe("bridgeIndicator", () => {
// Every branch is a glyph plus a word — status is never colour alone.
it("says nothing is on when it is off", () => {
expect(bridgeIndicator(OFF, true)).toEqual({ tone: "off", label: "Off" });
});
it("puts a conflict ahead of everything else", () => {
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
],
conflicts: [{ port: 2, reason: "taken" }],
},
true,
).tone,
).toBe("error");
});
it("flags a port that only took the IPv4 half", () => {
// Node resolves `localhost` to IPv6 first on Linux, so a v4-only listener
// is a callback that never arrives in front of a bridge reporting healthy.
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v6", bridged_at: "", ipv6_warning: "no ::1" },
],
conflicts: [],
},
true,
).label,
).toBe("IPv4 only");
});
it("counts the ports it is holding", () => {
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
{ port: 2, family: "v4", bridged_at: "", ipv6_warning: null },
],
conflicts: [],
},
true,
).label,
).toBe("Bridging 2 ports");
});
it("says it is waiting when the container is not running", () => {
// Enabled and holding nothing is normal; enabled with no container is a
// different thing, and saying so stops it reading as a failure.
expect(bridgeIndicator({ ...OFF, enabled: true }, false).label).toBe(
"Waiting for the container",
);
expect(bridgeIndicator({ ...OFF, enabled: true }, true).label).toBe("Watching");
});
});
@@ -0,0 +1,198 @@
import { useCallback, useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
getAuthBridgeStatus,
setAuthBridgeEnabled,
} from "../../../../lib/tauri-commands";
import type {
AuthBridgeChangedEvent,
AuthBridgeStatus,
Project,
} from "../../../../lib/types";
import { SwitchRow } from "../../../ui/Field";
import StatusIndicator, { type StatusTone } from "../../../ui/StatusIndicator";
import Toggle from "../../../ui/Toggle";
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
const LABEL = "Auth bridge";
/**
* What the indicator beside the switch says.
*
* Split out so the interesting part — that a conflict is a *visible* failure —
* can be tested without a container. Every branch pairs a glyph with a word;
* none of them are distinguished by colour alone.
*/
export function bridgeIndicator(
status: AuthBridgeStatus | null,
containerRunning: boolean,
): { tone: StatusTone; label: string } {
if (!status) return { tone: "unknown", label: "Checking" };
if (!status.enabled) return { tone: "off", label: "Off" };
// A conflict means a login is in progress and its port could not be taken —
// the one state where doing nothing is the wrong answer, and until now the
// one state nothing in the app reported at all.
if (status.conflicts.length > 0) {
return { tone: "error", label: "Port conflict" };
}
if (status.active_ports.some((p) => p.ipv6_warning)) {
return { tone: "busy", label: "IPv4 only" };
}
if (status.active_ports.length > 0) {
const n = status.active_ports.length;
return { tone: "running", label: `Bridging ${n} port${n === 1 ? "" : "s"}` };
}
// Enabled but holding nothing. Normal: there is only something to bridge
// while a login is actually waiting for a callback.
if (!containerRunning) {
return { tone: "stopped", label: "Waiting for the container" };
}
return { tone: "ok", label: "Watching" };
}
/**
* The switch for `auth_bridge_enabled`, and the only place it can be changed.
*
* Two things here are deliberate and easy to undo by accident:
*
* - **It does not go through the Config tab's `save`.** That path is gated on
* a stopped container, because almost everything else in the tab is baked
* into the container at creation. This is not: the bridge is entirely
* host-side, and `set_auth_bridge_enabled` exists precisely so it can be
* flipped *while a login is hanging*, which is when the user finds out they
* need it. Routing it through the generic save would make it unreachable at
* the only moment it matters.
* - **It subscribes to `auth-bridge-changed`.** The poller already emits the
* bridged-port and conflict sets on every change and, before this, nothing
* listened — so a host port the bridge could not take was a completely
* silent failure, indistinguishable from a login that simply hung.
*/
export default function AuthBridgeRow({ project }: { project: Project }) {
const projectId = project.id;
const containerRunning = project.status === "running";
const [status, setStatus] = useState<AuthBridgeStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setStatus(null);
setError(null);
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
setStatus(event.payload.status);
})
.then((un) => {
if (cancelled) un();
else unlisten = un;
})
.catch((e) => console.error("Auth bridge event subscription failed:", e));
return () => {
cancelled = true;
unlisten?.();
};
}, [projectId]);
const toggle = useCallback(
async (next: boolean) => {
setBusy(true);
setError(null);
// Optimistic, so the switch responds even though enabling has to await a
// container probe. The command's return value replaces it either way.
setStatus((s) => (s ? { ...s, enabled: next } : s));
try {
setStatus(await setAuthBridgeEnabled(projectId, next));
} catch (e) {
setStatus((s) => (s ? { ...s, enabled: !next } : s));
setError(String(e));
} finally {
setBusy(false);
}
},
[projectId],
);
// Fall back to the persisted flag until the first status arrives, so the
// switch never renders in the wrong position.
const enabled = status?.enabled ?? project.auth_bridge_enabled;
const indicator = bridgeIndicator(status, containerRunning);
return (
<SwitchRow
label={LABEL}
hint={
<>
Mirrors a port a program inside the container is listening on onto the
host's <code>127.0.0.1</code>, so a browser OAuth callback can reach
the listener waiting inside the container {" "}
<code>claude login</code>, <code>aws sso login</code> and{" "}
<code>gh auth login</code> all work this way, and without it the
browser calls back into nothing and the login hangs. Host-side only:
it never recreates the container, and it can be switched on while one
is running. A bridged port is unauthenticated and reachable by any
local process for as long as the in-container listener exists, so
leave it off unless you need it.
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusIndicator tone={indicator.tone} label={indicator.label} />
{status?.active_ports.map((p) => (
<span
key={p.port}
className="font-mono text-[var(--text-secondary)]"
title={p.ipv6_warning ?? `Bound on host 127.0.0.1:${p.port}`}
>
127.0.0.1:{p.port}
{p.ipv6_warning ? " (IPv4 only)" : ""}
</span>
))}
</span>
{status?.conflicts.map((c) => (
<span
key={c.port}
className="mt-1 block text-[var(--error)]"
role="status"
>
Port {c.port}: {c.reason}
</span>
))}
{status?.active_ports
.filter((p) => p.ipv6_warning)
.map((p) => (
<span key={p.port} className="mt-1 block text-[var(--warning)]">
Port {p.port}: {p.ipv6_warning}
</span>
))}
{error && (
<span className="mt-1 block text-[var(--error)]">{error}</span>
)}
</>
}
control={
<Toggle
label={LABEL}
checked={enabled}
// Never gated on the container being stopped — see the note above.
disabled={busy}
onChange={toggle}
/>
}
/>
);
}
@@ -1,7 +1,21 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import RuntimeSection from "./RuntimeSection";
import type { Project } from "../../../../lib/types";
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
// The auth-bridge row owns its own IPC — see `AuthBridgeRow.tsx` for why it
// does not go through `save`.
const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const setAuthBridgeEnabled = vi.fn(async () => ({ ...OFF_BRIDGE, enabled: true }));
vi.mock("../../../../lib/tauri-commands", () => ({
getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE),
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
const baseProject: Project = {
id: "p1",
@@ -92,3 +106,24 @@ describe("RuntimeSection — VPN support toggle", () => {
).toBeInTheDocument();
});
});
describe("RuntimeSection — auth bridge toggle", () => {
beforeEach(() => vi.clearAllMocks());
it("is reachable while the container is running", async () => {
// The rest of the tab is gated on a stopped container because those
// settings are baked in at creation. This one is host-side and has its own
// command, and the moment a user needs it is the moment a login is hanging
// in a *running* container — so the tab's `disabled` must not reach it.
renderSection({ status: "running" }, true);
const toggle = screen.getByRole("switch", { name: "Auth bridge" });
await waitFor(() => expect(toggle).not.toBeDisabled());
fireEvent.click(toggle);
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
// And never through the generic project save, which would drop it on the
// floor while the container runs.
expect(save).not.toHaveBeenCalled();
});
});
@@ -4,6 +4,7 @@ import { ConfigGroup, SwitchRow } from "../../../ui/Field";
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
import AuthBridgeRow from "./AuthBridgeRow";
interface Props {
project: Project;
@@ -70,6 +71,12 @@ export default function RuntimeSection({
}
/>
{/* Not gated on `disabled`: the bridge is host-side and has its own
command, so it can be switched on while a login is hanging — which
is the only moment anyone reaches for it. It owns its state rather
than going through `save`. */}
<AuthBridgeRow project={project} />
<SwitchRow
label="Mission Control"
hint="A web dashboard for monitoring and managing Claude sessions remotely."
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
/**
* Shift+Enter has to reach the container as ESC+CR.
*
* xterm.js does not consult `shiftKey` for Enter, so Shift+Enter is
* byte-identical to Enter unless `attachCustomKeyEventHandler` intervenes —
* which means the interesting assertion is not just "ESC+CR was sent" but
* "and a bare CR was not", i.e. that the handler returned false and xterm
* stopped. A test that only checked the first half would pass on a version
* that submits the prompt *and* inserts a newline.
*/
const terminalInput = vi.fn(async () => {});
vi.mock("../../lib/tauri-commands", () => ({
terminalInput: (sessionId: string, bytes: number[]) =>
terminalInput(sessionId, bytes),
terminalResize: vi.fn(async () => {}),
pasteImageToTerminal: vi.fn(async () => ""),
openTerminalSession: vi.fn(async () => {}),
closeTerminalSession: vi.fn(async () => {}),
updateProject: vi.fn(async () => ({})),
awsSsoRefresh: vi.fn(async () => {}),
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
uploadHostFileToTerminal: vi.fn(async () => ""),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: vi.fn(async () => () => {}) }),
}));
/** jsdom has no ResizeObserver, and the mount effect installs one. */
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
/** What `sendInput` put on the wire, decoded back to a string. */
function sent(): string[] {
return terminalInput.mock.calls.map((call) =>
new TextDecoder().decode(new Uint8Array((call as unknown as [string, number[]])[1])),
);
}
function mountSession(sessionType: "claude" | "bash") {
useAppState.setState({
sessions: [
{
id: "s1",
projectId: "p1",
projectName: "api",
sessionType,
sessionName: null,
},
],
});
return render(<TerminalView sessionId="s1" active />);
}
/** The hidden textarea xterm binds its keyboard handling to. */
function helperTextarea(container: HTMLElement): HTMLTextAreaElement {
const el = container.querySelector<HTMLTextAreaElement>(
"textarea.xterm-helper-textarea",
);
if (!el) throw new Error("xterm helper textarea not found");
return el;
}
beforeEach(() => {
vi.stubGlobal("ResizeObserver", NoopResizeObserver);
// xterm's renderer asks the window for its device pixel ratio on open.
vi.stubGlobal(
"matchMedia",
(query: string) => ({
matches: false,
media: query,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
onchange: null,
dispatchEvent: () => false,
}),
);
terminalInput.mockClear();
useAppState.setState({ sessions: [] });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("TerminalView — Shift+Enter", () => {
it("sends ESC+CR and nothing else in a Claude session", () => {
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
shiftKey: true,
});
// The bytes `/terminal-setup` installs for every other editor.
expect(sent()).toEqual(["\x1b\r"]);
// And specifically not the bare CR that would have submitted the prompt.
expect(sent()).not.toContain("\r");
});
it("leaves a plain Enter alone", () => {
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), { key: "Enter", keyCode: 13 });
expect(sent()).toEqual(["\r"]);
});
it("does not bind it in a bash session", () => {
// `bash -l` runs readline, which has no binding for `\e\r`: it would answer
// with a bell and swallow the Enter the user actually pressed.
const { container } = mountSession("bash");
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
shiftKey: true,
});
expect(sent()).toEqual(["\r"]);
});
it("leaves a modified Shift+Enter to xterm", () => {
// Adding Ctrl is not the chord this binds; whatever xterm does with it is
// xterm's business.
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
shiftKey: true,
ctrlKey: true,
});
expect(sent()).not.toContain("\x1b\r");
});
it("Alt+Enter already produced ESC+CR without any handler", () => {
// Pinned because it is the reason Shift+Enter was the only gap: xterm
// ESC-prefixes on `altKey` by itself, so Alt+Enter has always inserted a
// newline in Claude Code. It was simply undocumented.
const { container } = mountSession("bash"); // no custom branch involved
fireEvent.keyDown(helperTextarea(container), {
key: "Enter",
keyCode: 13,
altKey: true,
});
expect(sent()).toEqual(["\x1b\r"]);
});
});
describe("supersedes — who owns the prompt slot", () => {
const relay = (url: string) => ({ url, source: "relay" as const });
const osc8 = (url: string) => ({ url, source: "osc8" as const });
const guess = (url: string) => ({ url, source: "heuristic" as const });
const COMPLETE =
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference";
// What the screen-scraper reconstructs from the visible text: parses, points
// at the right host, authorises nothing.
const TRUNCATED = COMPLETE.slice(0, 80);
it("fills an empty slot from anywhere", () => {
expect(supersedes(guess(TRUNCATED), null)).toBe(true);
});
it("refuses to let a truncated guess replace the exact copy", () => {
// The whole bug: the relay lands first with the complete URL, and 300 ms
// later the detector's debounce fires with a prefix of it.
expect(supersedes(guess(TRUNCATED), relay(COMPLETE))).toBe(false);
expect(supersedes(guess(TRUNCATED), osc8(COMPLETE))).toBe(false);
});
it("lets a better source take over from a worse one", () => {
expect(supersedes(osc8(COMPLETE), guess(TRUNCATED))).toBe(true);
expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true);
});
it("lets a scraped candidate grow into the complete link", () => {
// A repaint can land the truncated copy first. Extending it is safe: a
// longer string with the same prefix has the same origin.
expect(supersedes(guess(COMPLETE), guess(TRUNCATED))).toBe(true);
});
it("does not let an unrelated scrape displace what is on screen", () => {
// Longest-wins without the prefix test hands the choice to whoever pads
// their URL the most.
expect(
supersedes(guess("https://evil.tld/" + "a".repeat(400)), guess(COMPLETE)),
).toBe(false);
});
it("lets a second explicit relay request through", () => {
// Each OSC 7777 is a fresh deliberate ask, not another view of the last
// one — a second `gh auth login` must be able to replace the first.
expect(
supersedes(relay("https://github.com/login/device"), relay(COMPLETE)),
).toBe(true);
});
});
+129 -19
View File
@@ -13,10 +13,11 @@ import {
uploadHostFileToTerminal,
} from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector";
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
import {
RelayRateLimiter,
URL_RELAY_OSC,
extendsUrl,
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
@@ -29,6 +30,58 @@ interface Props {
active: boolean;
}
/**
* Where a prompted URL came from.
*
* `relay` is the container asking explicitly, over OSC 7777, with the URL
* base64-encoded — exact by construction. `osc8` is lifted verbatim out of a
* hyperlink parameter — also exact, but nobody asked for it. `heuristic` was
* reassembled from painted text and is the only one that can be a *truncated
* guess* at the link it is showing.
*/
export type PromptSource = "relay" | UrlSource;
/** Higher wins. Provenance, not recency. */
const SOURCE_RANK: Record<PromptSource, number> = {
heuristic: 0,
osc8: 1,
relay: 2,
};
/**
* Whether `next` may take over the prompt slot from `current`.
*
* The bug this exists for: `claude login` relays its OAuth URL over OSC 7777,
* base64-encoded and therefore complete; the screen-scraper's 300 ms debounce
* then fires, finds the same link cut into terminal-width pieces, and — under
* the old last-writer-wins slot — replaced the good URL with a truncated one
* that still parses, still points at the right host, and cannot authorise
* anything. The user is the one who has to notice.
*
* Two rules, in order:
*
* - Better provenance always wins, worse provenance never does. A scraped
* guess cannot displace an exact copy.
* - Between equals, only an *extension* of what is showing may replace it.
* That is {@link extendsUrl}, the same rule and the same reasoning as
* `pickSignInUrl` in `hooks/useClaudeAuth.ts`: a repaint can land a
* truncated copy before the complete one, and a longer string sharing a
* prefix cannot move the origin. The relay is exempt because each OSC 7777
* is a fresh deliberate request rather than another view of the last one —
* a second `gh auth login` must be able to replace the first.
*/
export function supersedes(
next: { url: string; source: PromptSource },
current: { url: string; source: PromptSource } | null,
): boolean {
if (!current) return true;
if (SOURCE_RANK[next.source] !== SOURCE_RANK[current.source]) {
return SOURCE_RANK[next.source] > SOURCE_RANK[current.source];
}
if (next.source === "relay") return true;
return extendsUrl(next.url, current.url);
}
export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
@@ -47,13 +100,24 @@ export default function TerminalView({ sessionId, active }: Props) {
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
);
// One toast slot, two producers: the heuristic long-URL detector and the
// container's explicit "open this in the host browser" relay (OSC 7777).
// Sharing the slot keeps them from stacking on top of each other.
// Which program is on the other end of the PTY. Read through a ref because
// the key handler is registered once, in the mount effect keyed on
// `sessionId`, and a value captured there would go stale if the session
// record arrived after the first render.
const sessionType = useAppState(
(s) => s.sessions.find((sess) => sess.id === sessionId)?.sessionType
);
const sessionTypeRef = useRef(sessionType);
sessionTypeRef.current = sessionType;
// One toast slot, three producers: the container's explicit "open this in the
// host browser" relay (OSC 7777), OSC 8 hyperlink targets, and the heuristic
// long-URL detector. Sharing the slot keeps them from stacking on top of each
// other.
//
// Both producers read the container's PTY output, so both are untrusted, and
// both must go through `sanitizeRelayUrl` before anything is stored here
// see `promptUrl` below, which is the only writer.
// All three read the container's PTY output, so all three are untrusted, and
// all three must go through `sanitizeRelayUrl` before anything is stored here
// see `promptUrl` below, which is the only writer.
//
// `seq` exists because the slot is shared and long-lived: a second prompt
// replacing a first would otherwise mutate the toast in place, swapping the
@@ -62,6 +126,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const [urlPrompt, setUrlPrompt] = useState<{
url: string;
label: string;
source: PromptSource;
seq: number;
} | null>(null);
const promptSeqRef = useRef(0);
@@ -72,16 +137,27 @@ export default function TerminalView({ sessionId, active }: Props) {
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
* but the heuristic detector branch has been through nothing at all, and a
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
*
* Last-writer-wins is what this used to be, and it lost the OAuth URL every
* time: the relay delivers the link base64-encoded and therefore exact, and
* ~300 ms later the screen-scraper's debounce fired and overwrote it with a
* truncated guess at the same link. `supersedes` is the fix — see there.
*/
const promptUrl = useCallback((raw: string, label: string) => {
const url = sanitizeRelayUrl(raw);
if (!url) {
console.warn("Refusing to prompt for a URL that failed validation");
return;
}
promptSeqRef.current += 1;
setUrlPrompt({ url, label, seq: promptSeqRef.current });
}, []);
const promptUrl = useCallback(
(raw: string, label: string, source: PromptSource) => {
const url = sanitizeRelayUrl(raw);
if (!url) {
console.warn("Refusing to prompt for a URL that failed validation");
return;
}
setUrlPrompt((current) => {
if (!supersedes({ url, source }, current)) return current;
promptSeqRef.current += 1;
return { url, label, source, seq: promptSeqRef.current };
});
},
[],
);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -234,6 +310,34 @@ export default function TerminalView({ sessionId, active }: Props) {
useAppState.getState().sttToggle();
return false;
}
// Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
// byte-identical to Enter and submits.
//
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
// meta, and it is exactly what its own `/terminal-setup` writes into the
// VS Code, Cursor, Alacritty and Zed keymaps. These are the in-band
// bytes, not a guess, which is why this must NOT be "simplified" to
// `\n`: Claude Code accepts `\n` too, but a shell would *run* the line,
// so the two session types would quietly diverge.
//
// Scoped to Claude sessions for the same reason. A bash tab runs
// `bash -l`, where readline has no binding for `\e\r` and answers with a
// bell — harmless, but there is nothing to gain from sending it.
if (
event.type === "keydown" &&
event.key === "Enter" &&
event.shiftKey &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!event.isComposing &&
sessionTypeRef.current === "claude"
) {
sendInput(sessionId, "\x1b\r");
return false; // xterm must not also send a bare CR, which submits
}
return true;
});
@@ -287,7 +391,7 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("URL relay: rate-limited", url);
return true;
}
promptUrl(url, "Container asked to open a URL");
promptUrl(url, "Container asked to open a URL", "relay");
return true;
});
@@ -374,11 +478,17 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle backend output -> terminal
let aborted = false;
// The width is read per scan, not captured: only a break the terminal
// The detector samples this getter on every `feed`, so what it reassembles
// with is the width the bytes were *printed* at — only a break the terminal
// itself inserted may be deleted, and where that is moves with every
// resize.
const detector = new UrlDetector(
(url) => promptUrl(url, "Long URL detected"),
(url, source) =>
promptUrl(
url,
source === "osc8" ? "Link detected" : "Long URL detected",
source,
),
() => termRef.current?.cols ?? 0,
);
detectorRef.current = detector;
@@ -58,4 +58,79 @@ describe("UrlToast", () => {
screen.getByRole("button", { name: "Open" }).click();
expect(onOpen).toHaveBeenCalledTimes(1);
});
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
// and then posts the result where nothing is listening, and the terminal
// hangs to its timeout — so for these, and only these, the container-side
// browser leads.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
function actions() {
return screen
.getAllByRole("button")
.map((b) => b.textContent)
.filter((t) => t === "Open" || t === "In container");
}
it("puts the container browser first", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["In container", "Open"]);
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/callback listener is inside the container/i,
);
});
it("keeps the host browser available as a fallback", () => {
const onOpen = vi.fn();
render(
<UrlToast
url={SIGN_IN}
onOpen={onOpen}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
screen.getByRole("button", { name: "Open" }).click();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("leaves an ordinary URL alone", () => {
// A `gh auth login` device code, a docs page, a preview build — the host
// browser is the right answer for all of them and stays the default.
render(
<UrlToast
url="https://github.com/login/device?code=ABCD-EFGH"
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
expect(screen.queryByTestId("url-toast-signin-hint")).not.toBeInTheDocument();
});
it("is not fooled by a lookalike host", () => {
// `isAnthropicSignInUrl` uses the same allowlist the sign-in flow does,
// so a URL that merely says "claude.ai" somewhere is not one.
render(
<UrlToast
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
});
});
});
+114 -47
View File
@@ -1,4 +1,5 @@
import { urlOrigin } from "../../lib/urlRelay";
import type { CSSProperties, MouseEvent } from "react";
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
interface Props {
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
@@ -28,6 +29,18 @@ interface Props {
* is shared and long-lived, so without one React mutates the node in place: the
* text swaps with no animation, and a user reading URL A can click Open on URL
* B that arrived a second later.
*
* ## Anthropic sign-in links default to the container's browser
*
* For an ordinary URL the host browser is the right answer and stays the
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
* is waiting on is inside the container, so a host browser completes the sign-in
* and then posts the result somewhere nothing is listening, and the terminal
* hangs until it times out. Making the host button primary there was quietly
* steering every user into that. The container-side browser closes the loop
* with no host round trip and no auth bridge, so it leads — and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
*/
export default function UrlToast({
url,
@@ -38,6 +51,81 @@ export default function UrlToast({
}: Props) {
const origin = urlOrigin(url);
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
// Only when there is somewhere to send it: without `onOpenInContainer` the
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// Filled uses `--accent-emphasis`, never `--accent` — the latter is the
// foreground/link accent and fails WCAG AA behind white text.
const primaryStyle: CSSProperties = {
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent-emphasis)",
border: "1px solid transparent",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
const secondaryStyle: CSSProperties = {
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
/** Hover feedback for whichever button is currently the filled one. */
const hover = (primary: boolean) =>
primary
? {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis-hover)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis)"),
}
: {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--bg-tertiary)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "transparent"),
};
const hostButton = (
<button
onClick={onOpen}
title={
signIn
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
style={signIn ? secondaryStyle : primaryStyle}
{...hover(!signIn)}
>
Open
</button>
);
const containerButton = onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback on
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<button
onClick={onOpenInContainer}
title="Open in a browser inside the container, and watch it in the Browser tab"
style={signIn ? primaryStyle : secondaryStyle}
{...hover(signIn)}
>
In container
</button>
);
return (
<div
@@ -109,54 +197,33 @@ export default function UrlToast({
{rest}
</span>
</div>
{signIn && (
<div
data-testid="url-toast-signin-hint"
style={{
marginTop: 3,
fontSize: 11,
color: "var(--text-secondary)",
lineHeight: 1.35,
}}
>
Sign-in link the callback listener is inside the container.
Opening it there closes the loop; the host browser needs the auth
bridge.
</div>
)}
</div>
<button
onClick={onOpen}
style={{
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent)",
border: "none",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--accent)")
}
>
Open
</button>
{onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback
// on the container's own loopback, which is where the tool waiting for
// it is listening — no host round trip, no auth bridge.
<button
onClick={onOpenInContainer}
title="Open in a browser inside the container, and watch it in the Browser tab"
style={{
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
In container
</button>
{signIn ? (
<>
{containerButton}
{hostButton}
</>
) : (
<>
{hostButton}
{containerButton}
</>
)}
<button
+4 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands";
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
import { ANTHROPIC_SIGN_IN_HOSTS, extendsUrl, sanitizeRelayUrl } from "../lib/urlRelay";
import type {
ClaudeTokenCodeRejectedEvent,
ClaudeTokenLinkEvent,
@@ -67,7 +67,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
* starts with it. That is the case longest-wins existed for — a repainting
* TUI can land a truncated copy of the same link in the transcript before
* the complete one — and it cannot swap the origin, because a longer string
* with the same prefix has the same host.
* with the same prefix has the same host. {@link extendsUrl} is that rule;
* the terminal's URL prompt slot shares it.
*/
export function pickSignInUrl(candidates: readonly string[]): string | null {
const cleaned = candidates
@@ -79,7 +80,7 @@ export function pickSignInUrl(candidates: readonly string[]): string | null {
let best: string | null = null;
for (const url of pool) {
if (best === null || url.startsWith(best)) best = url;
if (best === null || extendsUrl(url, best)) best = url;
}
return best;
}
+7 -2
View File
@@ -277,8 +277,13 @@ export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolea
// lives in the OS keychain and is injected as a container env var.
//
// `acquireClaudeToken` borrows the given project's running container to run the
// login (temporarily enabling its auth bridge), and streams progress on the
// `claude-token-progress` and `claude-token-output` events. It resolves only
// login and streams progress on the `claude-token-progress` and
// `claude-token-output` events. It deliberately does *not* touch the project's
// auth bridge: `setup-token` finishes on an Anthropic-hosted page and pastes a
// code back, so there is no loopback callback for a bridge to carry — and an
// earlier version that enabled it "just in case" persisted that flag to
// projects.json and left it latched on whenever the flow was killed. See the
// module comment in `commands/auth_token_commands.rs`. It resolves only
// once the whole flow finishes, so call it without awaiting the UI on it.
//
// Partway through, `claude setup-token` prints a sign-in URL and then waits at
+5
View File
@@ -437,6 +437,11 @@ export interface BridgedPort {
family: AuthBridgePortFamily;
/** RFC 3339 timestamp of when the host listener was bound. */
bridged_at: string;
/** Set when only the IPv4 half of the host listener could be bound. The port
* is carrying traffic, but a client that resolves `localhost` to `::1` and
* does not fall back will still be refused — which otherwise presents as a
* login that hangs while the bridge reports itself healthy. */
ipv6_warning: string | null;
}
/** A discovered loopback listener that could not be bridged (host port taken). */
+193 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { UrlDetector, flatten } from "./urlDetector";
import { UrlDetector, flatten, osc8Targets, usableLink } from "./urlDetector";
import type { UrlSource } from "./urlDetector";
const COLS = 80;
const enc = new TextEncoder();
@@ -17,6 +18,34 @@ function ptyWrap(text: string, cols = COLS): string {
return lines.join("\r\n");
}
/**
* One OSC 8 hyperlink emission: the whole URL in the parameter, a slice of it
* as visible text.
*
* This is what `claude setup-token` actually prints — measured against 2.1.226,
* a 346-character URL arrives as five of these, each carrying the complete URL
* and 80 characters of it on screen.
*/
function osc8(uri: string, visible: string): string {
return `\x1b]8;;${uri}\x07${visible}\x1b]8;;\x07`;
}
/** Slice `uri` into `width`-character visible pieces, each a full hyperlink. */
function slicedHyperlink(uri: string, width = COLS): string {
const parts: string[] = [];
for (let i = 0; i < uri.length; i += width) {
parts.push(osc8(uri, uri.slice(i, i + width)));
}
return parts.join("\r\n");
}
/** The URL Claude Code prints, at the length it really is. */
const SIGN_IN_URL =
"https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e" +
"&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback" +
"&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=" +
"vJ8Kq2mN4pR7sT9wX1zA3bC5dE6fG8hJ0kL2mN4pQ6r&code_challenge_method=S256&state=aB3dE5gH7jK9";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
@@ -116,3 +145,166 @@ describe("UrlDetector", () => {
expect(seen).toEqual([url]);
});
});
describe("usableLink", () => {
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
// between any of this and `openUrl`.
it("accepts an ordinary authorize URL", () => {
expect(usableLink(SIGN_IN_URL)).toBe(true);
});
it("rejects a scheme that is not http(s)", () => {
expect(usableLink("file:///etc/passwd")).toBe(false);
expect(usableLink("javascript:alert(1)")).toBe(false);
});
it("rejects anything outside printable ASCII", () => {
// A control character is how a URL is smuggled past a display, and
// `new URL()` strips some of them silently.
expect(usableLink("https://example.com/\u0000x")).toBe(false);
expect(usableLink("https://exa\u200bmple.com/x")).toBe(false);
expect(usableLink("https://example.com/a b")).toBe(false);
});
});
describe("osc8Targets", () => {
it("lifts the whole URL out of a sliced emission", () => {
const raw = slicedHyperlink(SIGN_IN_URL);
// Every piece carries the complete URL, however little of it is on screen.
expect(new Set(osc8Targets(raw))).toEqual(new Set([SIGN_IN_URL]));
});
it("ignores the closing half of a hyperlink", () => {
expect(osc8Targets("\x1b]8;;\x07")).toEqual([]);
});
it("ignores other OSCs, including the URL relay's own", () => {
expect(osc8Targets("\x1b]0;a window title\x07")).toEqual([]);
expect(osc8Targets("\x1b]7777;open;aHR0cHM6Ly9leGFtcGxlLmNvbQ==\x07")).toEqual([]);
});
it("reads a hyperlink terminated by ST as well as by BEL", () => {
expect(osc8Targets(`\x1b]8;id=1;${SIGN_IN_URL}\x1b\\text`)).toEqual([SIGN_IN_URL]);
});
});
describe("UrlDetector — OSC 8", () => {
it("recovers the complete URL from a sliced hyperlink", () => {
// The bug this branch exists for. `ANSI_RE` strips OSC sequences wholesale,
// so the scraper never saw the parameter and reassembled the *visible*
// pieces instead — a URL that parses, points at claude.ai, and cannot
// authorise anything.
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, src) => seen.push([u, src]), () => COLS);
feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n");
expect(seen[0]).toEqual([SIGN_IN_URL, "osc8"]);
});
it("does not emit the same hyperlink again when it is repainted", () => {
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
expect(seen.filter(([u]) => u === SIGN_IN_URL)).toHaveLength(1);
});
it("marks a scraped candidate as a guess, so the slot can refuse it", () => {
// Nothing here decides precedence — that is `supersedes` in TerminalView —
// but it is what makes the decision possible.
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
const url = "https://example.com/" + "z".repeat(120);
feed(d, url + "\r\nnext\r\n");
expect(seen).toEqual([[url, "heuristic"]]);
});
it("ignores a short hyperlink", () => {
// `ls --hyperlink` decorates every filename; none of that is a prompt.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(d, osc8("https://example.com/a", "a") + "\r\nnext\r\n");
expect(seen).toEqual([]);
});
});
describe("UrlDetector — repaints and resizes", () => {
it("treats a bare CR as a line break", () => {
// A TUI repaints by returning to column 0 without a line feed. Splitting on
// `\r?\n` alone leaves a whole frame on one "line", which is then longer
// than the width — so the `===` test says "not wrapped" and a break the
// terminal really did insert is never rejoined.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "q".repeat(100);
feed(d, "spinner frame one\rspinner frame two\r" + ptyWrap(url) + "\r\ndone\r\n");
expect(seen).toEqual([url]);
});
it("does not glue two repainted frames into one token", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(
d,
"https://example.com/" + "a".repeat(90) + "\rhttps://evil.tld/" + "b".repeat(90) + "\r\n\r\ndone\r\n",
);
for (const url of seen) expect(new URL(url).host).not.toBe("example.comhttps");
});
it("reassembles with the width the bytes were printed at, not the current one", () => {
// The scan runs 300 ms after the print. A resize inside that window used to
// change every join decision retroactively: text wrapped at 80 columns,
// rejoined against a width of 120, comes back as separate lines glued with
// spaces — or, the other way round, as a URL nobody printed.
const seen: string[] = [];
let cols = COLS;
const d = new UrlDetector((u) => seen.push(u), () => cols);
const url =
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678";
d.feed(new TextEncoder().encode(ptyWrap(url) + "\r\nWaiting…\r\n"));
cols = 120; // the user drags the window wider before the debounce fires
vi.advanceTimersByTime(2000);
expect(seen).toEqual([url]);
});
it("drops text buffered at a width that no longer applies", () => {
// Half printed at 80, half at 120: no single width reassembles both, so the
// older half goes rather than being joined by a rule that is wrong for it.
const seen: string[] = [];
let cols = COLS;
const d = new UrlDetector((u) => seen.push(u), () => cols);
const url = "https://example.com/" + "m".repeat(120);
d.feed(new TextEncoder().encode(ptyWrap(url.slice(0, 100))));
cols = 120;
feed(d, url.slice(100) + "\r\ndone\r\n");
// Whatever survives, it is never a URL that was not printed.
for (const u of seen) expect(url.startsWith(u) || u.startsWith(url)).toBe(true);
});
});
describe("flatten — bare CR", () => {
it("splits on a lone CR as well as on LF", () => {
expect(flatten("abcde\rfghij", 5)).toBe("abcdefghij");
expect(flatten("abc\rdef", 5)).toBe("abc def");
});
it("counts CRLF as one break, not two", () => {
expect(flatten("abcde\r\nfghij", 5)).toBe("abcdefghij");
});
});
+157 -5
View File
@@ -24,17 +24,109 @@
* When a URL match extends to the end of the flattened buffer, emission is
* deferred (more chunks may still be arriving). A confirmation timer emits
* the pending URL if no further data arrives within 500 ms.
*
* ## OSC 8 comes first, and the scraping is the fallback
*
* Everything above is guesswork over what a terminal *painted*. When the
* program emits an **OSC 8 hyperlink** there is no guesswork to do: the
* complete URL is in the sequence's parameter, contiguous and exact, however
* the visible text was sliced.
*
* That distinction is the whole reason this file grew a second branch. Claude
* Code prints its sign-in link as an OSC 8 hyperlink whose *visible* text is
* cut into terminal-width pieces on separate lines — measured against 2.1.226,
* a 346-character URL arrives as five emissions, each carrying the whole URL in
* its parameter and 80 characters of it on screen. `ANSI_RE` strips OSC
* sequences wholesale, so the scraper never saw the parameter and reassembled
* the visible pieces instead: a URL that parses, that points at claude.com, and
* that cannot authorise anything. The backend hit this first and solved it the
* same way — see `commands/auth_token_commands.rs`, whose `osc8_target` and
* `usable_sign_in_link` this mirrors.
*
* So each emitted candidate is tagged with where it came from, and the consumer
* refuses to let a `heuristic` candidate displace an `osc8` one.
*/
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
/**
* OSC 8 hyperlink: `ESC ] 8 ; <params> ; <uri> (BEL | ESC \\)`.
*
* The params field is `key=value` pairs separated by `:`, never `;`, so the
* first `;` after the `8;` ends it — the same split `osc8_target` makes in
* Rust. The closing half of a hyperlink is `8;;` with an empty uri.
*/
// eslint-disable-next-line no-control-regex
const OSC8_RE = /\x1b\]8;([^;\x07\x1b]*);([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
const DEBOUNCE_MS = 300;
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
const MIN_URL_LENGTH = 100;
export type UrlCallback = (url: string) => void;
/** Mirrors `MAX_LINK_LENGTH` in `commands/auth_token_commands.rs`. */
const MAX_LINK_LENGTH = 8192;
/** Bound on remembered OSC 8 targets, so a program printing a fresh hyperlink
* every frame cannot grow this without limit. */
const MAX_REMEMBERED_LINKS = 32;
/**
* Where a candidate came from, which is the same thing as how much it can be
* trusted to be *complete*.
*
* `osc8` is lifted verbatim out of a hyperlink parameter; `heuristic` was
* reassembled from painted text and may be a truncated guess. The consumer
* uses this to decide precedence — see `promptUrl` in `TerminalView.tsx`.
*/
export type UrlSource = "osc8" | "heuristic";
export type UrlCallback = (url: string, source: UrlSource) => void;
/**
* Whether an OSC 8 target is worth offering as a candidate at all.
*
* A direct port of `usable_sign_in_link` in
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
* only thing standing between any of this and `openUrl`, and duplicating its
* rules here would be a second place for them to go stale.
*
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
* exists because the backend's link path bypasses `SecretRedactor`, and there
* is no redactor on this side to bypass.
*/
export function usableLink(uri: string): boolean {
if (!uri.startsWith("https://") && !uri.startsWith("http://")) return false;
if (uri.length > MAX_LINK_LENGTH) return false;
// Printable ASCII only. Control characters and whitespace are exactly how a
// URL is smuggled past a display, and `new URL()` strips some of them
// silently; a real authorize URL is percent-encoded anyway.
for (let i = 0; i < uri.length; i++) {
const code = uri.charCodeAt(i);
if (code < 0x21 || code > 0x7e) return false;
}
return true;
}
/**
* Every usable OSC 8 hyperlink target in `raw`, in the order they were emitted.
*
* Takes the *unstripped* stream: `ANSI_RE` deletes OSC sequences wholesale, so
* by the time the buffer is clean the parameter this reads is already gone.
*/
export function osc8Targets(raw: string): string[] {
const out: string[] = [];
OSC8_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = OSC8_RE.exec(raw)) !== null) {
const uri = m[2];
// `8;;` closes a hyperlink and carries no target.
if (uri && usableLink(uri)) out.push(uri);
}
return out;
}
/**
* How wide the terminal is right now.
@@ -61,7 +153,13 @@ export type ColumnsGetter = () => number;
* confirmed by the user before anything opens.
*/
export function flatten(clean: string, columns: number): string {
const lines = clean.split(/\r?\n/);
// A bare `\r` is a break too. A TUI repaints a frame by returning to column
// 0 without a line feed, so splitting on `\r?\n` alone leaves a whole
// frame's worth of text on one "line" — which is then far longer than
// `columns`, so the `===` test below says "not wrapped" and a URL the
// terminal really did cut is never rejoined. Splitting here is also what the
// backend does with a lone CR (`strip_ansi_prefix`), for the same reason.
const lines = clean.split(/\r\n|\r|\n/);
let out = "";
for (let i = 0; i < lines.length; i++) {
out += lines[i];
@@ -84,6 +182,20 @@ export class UrlDetector {
private pendingUrl: string | null = null;
private callback: UrlCallback;
private columns: ColumnsGetter;
/**
* The width in effect when the buffered bytes were *printed*, sampled in
* `feed`.
*
* Not read in `scan`, which is where it used to be read: the scan happens
* 300 ms after the print, and a resize inside that window would reassemble
* text wrapped at 80 columns using a width of 120 — every join decision
* wrong, and a fabricated URL out the other end. `-1` means "nothing fed
* yet".
*/
private feedColumns = -1;
/** OSC 8 targets already offered, so a hyperlink repainted every frame does
* not re-prompt. Bounded by {@link MAX_REMEMBERED_LINKS}. */
private emittedLinks = new Set<string>();
constructor(callback: UrlCallback, columns: ColumnsGetter) {
this.callback = callback;
@@ -92,6 +204,18 @@ export class UrlDetector {
/** Feed raw PTY output chunks. */
feed(data: Uint8Array): void {
const columns = this.columns();
if (this.feedColumns !== -1 && columns !== this.feedColumns) {
// The buffered text was wrapped at a width that no longer applies, and
// the new text will be wrapped at this one. There is no single width
// that reassembles both, so the older half is dropped rather than
// joined by a rule that is now wrong for it. Costs a URL that was
// mid-print across a resize; never invents one.
this.buffer = "";
this.pendingUrl = null;
}
this.feedColumns = columns;
this.buffer += this.decoder.decode(data, { stream: true });
// Cap buffer to avoid unbounded growth
@@ -114,11 +238,19 @@ export class UrlDetector {
}
private scan(): void {
// 0. The exact copy first. An OSC 8 parameter needs no reassembly, so
// anything found here beats whatever the steps below reconstruct — and
// it has to be read from the raw buffer, because step 1 deletes the
// sequence that carries it.
this.scanLinks();
// 1. Strip ANSI escape sequences
const clean = this.buffer.replace(ANSI_RE, "");
// 2. Flatten the buffer: rejoin hard wraps, terminate on everything else.
const flat = flatten(clean, this.columns());
// The width is the one that was in effect when these bytes were
// printed, not the one the terminal happens to have now.
const flat = flatten(clean, this.feedColumns);
if (!flat) return;
@@ -155,7 +287,7 @@ export class UrlDetector {
this.pendingUrl = null;
if (url !== this.lastEmitted) {
this.lastEmitted = url;
this.callback(url);
this.callback(url, "heuristic");
}
}
@@ -166,10 +298,30 @@ export class UrlDetector {
}
}
/**
* Offer every OSC 8 target in the buffer that has not been offered before.
*
* `lastEmitted` is moved along with them so an identical string arriving on
* the heuristic path a moment later is recognised as the same candidate
* rather than fired a second time.
*/
private scanLinks(): void {
for (const uri of osc8Targets(this.buffer)) {
if (uri.length < MIN_URL_LENGTH) continue;
if (this.emittedLinks.has(uri)) continue;
if (this.emittedLinks.size >= MAX_REMEMBERED_LINKS) {
this.emittedLinks.clear();
}
this.emittedLinks.add(uri);
this.lastEmitted = uri;
this.callback(uri, "osc8");
}
}
private emitPending(): void {
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
this.lastEmitted = this.pendingUrl;
this.callback(this.pendingUrl);
this.callback(this.pendingUrl, "heuristic");
}
this.pendingUrl = null;
}
+36
View File
@@ -158,6 +158,42 @@ export function sanitizeRelayUrl(
return normalized;
}
/**
* Whether `next` is the same link as `current`, only longer.
*
* The single rule that lets a later candidate displace an earlier one when both
* were scraped from the same untrusted stream. A repainting TUI lands a
* truncated copy of a link in the transcript before the complete one, and this
* is what joins them back up — safely, because a longer string sharing a prefix
* with the current pick necessarily has the same scheme, host and port, so an
* attacker cannot use it to move the origin.
*
* Longest-wins without the prefix test is what this replaced, and it handed the
* choice to the attacker: pad a hostile URL and it displaces the real one.
*
* Used by `pickSignInUrl` (`hooks/useClaudeAuth.ts`) and by the terminal's URL
* prompt slot (`components/terminal/TerminalView.tsx`). One implementation, on
* purpose.
*/
export function extendsUrl(next: string, current: string): boolean {
return next.length > current.length && next.startsWith(current);
}
/**
* Whether this is a URL that signs the user in to Anthropic.
*
* Used to decide *presentation*, not permission — the toast makes the
* container-side browser the default action for these, because the OAuth
* callback listener is inside the container and the host has nothing to catch
* it with. It is deliberately the same host allowlist the sign-in flow itself
* uses, so the two cannot disagree about what a sign-in link is.
*/
export function isAnthropicSignInUrl(url: string): boolean {
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });
if (!safe) return false;
return /oauth|authorize|login|sign-?in/i.test(safe);
}
/**
* The origin of an already-sanitized URL, for display.
*