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:
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user