diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index d09999e8788..dfe29313396 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -583,6 +583,8 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | | `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | | `NO_COLOR` | Set to any value to disable all color output in the CLI. | | +| `FORCE_HYPERLINK` | Override the OSC 8 clickable-link detection in the markdown renderer. Set to `1` (or any non-zero value, or empty string) to force-enable, `0` to force-disable. Honors `NO_COLOR` / `QWEN_DISABLE_HYPERLINKS` opt-outs above it. | Use this to opt into OSC 8 inside `tmux` / GNU `screen` (auto-detection refuses by default because the host terminal's capabilities are hidden behind the multiplexer). Requires `set -g allow-passthrough on` on tmux 3.3+. Also enables Hyper, which isn't auto-detected. | +| `QWEN_DISABLE_HYPERLINKS` | Set to `1` to hard-disable OSC 8 clickable hyperlinks in the markdown renderer even on terminals that auto-detect as capable. | Useful when a terminal advertises support but breaks on long URLs, or when piping output through an intermediary that mangles escape sequences. The renderer falls back to plain `label (url)` rendering. | | `CLI_TITLE` | Set to a string to customize the title of the CLI. | | | `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | | `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index 0977752d7cb..7dbf0b29df6 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -18,57 +18,17 @@ import { } from '@qwen-code/qwen-code-core'; import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core'; import { appEvents, AppEvent } from '../../../../utils/events.js'; +import { + osc8Hyperlink, + supportsHyperlinks, + wrapForMultiplexer, +} from '../../../utils/osc8.js'; type AuthState = 'idle' | 'authenticating' | 'success' | 'error'; const AUTO_BACK_DELAY_MS = 2000; const COPY_FEEDBACK_MS = 2000; -/** - * Wrap an OSC sequence for terminal multiplexers so the host terminal - * receives it. tmux requires a DCS passthrough with inner ESCs doubled; - * GNU screen uses a plain DCS envelope. Note: tmux 3.3+ defaults - * `allow-passthrough` to off — users on default configs will not see - * the hyperlink until they set `set -g allow-passthrough on`. - */ -function wrapForMultiplexer(osc: string): string { - if (process.env['TMUX']) { - return `\x1bPtmux;${osc.split('\x1b').join('\x1b\x1b')}\x1b\\`; - } - if (process.env['STY']) { - return `\x1bP${osc}\x1b\\`; - } - return osc; -} - -/** - * Strip C0 control characters and DEL so an untrusted string can be safely - * embedded inside an OSC escape. Without this a `\x07` (BEL) or `\x1b` (ESC) - * in the input would prematurely terminate the OSC sequence and leak the - * tail bytes to the terminal as interpretable escape codes. - */ -function sanitizeForOsc(s: string): string { - // eslint-disable-next-line no-control-regex - return s.replace(/[\x00-\x1f\x7f]/g, ''); -} - -/** - * Wrap a URL in an OSC 8 hyperlink escape sequence. Supported terminals - * (iTerm2, WezTerm, Kitty, Windows Terminal, VS Code, GNOME Terminal, …) - * render it as a clickable link; terminals without OSC 8 support ignore - * the escapes and print the raw text. BEL (\x07) terminates the OSC - * sequence — more broadly supported than ST (ESC \\). - * - * Inside tmux / screen the OSC sequence is wrapped in a DCS passthrough - * envelope (see `wrapForMultiplexer`) so the multiplexer forwards it to - * the host terminal instead of eating it. - */ -function osc8Hyperlink(url: string, label = url): string { - const safeUrl = sanitizeForOsc(url); - const safeLabel = sanitizeForOsc(label); - return wrapForMultiplexer(`\x1b]8;;${safeUrl}\x07${safeLabel}\x1b]8;;\x07`); -} - /** * Copy a string to the user's clipboard using the OSC 52 terminal escape * sequence. Works through SSH and most web terminals (iTerm2, Windows @@ -292,7 +252,9 @@ export const AuthenticateStep: React.FC = ({ {authUrl && ( - {osc8Hyperlink(authUrl)} + + {supportsHyperlinks() ? osc8Hyperlink(authUrl) : authUrl} + )} diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx index b33d2cc885c..b4477998b7d 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -4,11 +4,42 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; +import { HYPERLINK_ENV_KEYS } from './osc8.js'; describe('', () => { + const savedEnv = { ...process.env }; + const savedIsTTY = process.stdout.isTTY; + const savedPlatform = process.platform; + + beforeEach(() => { + process.env = { ...savedEnv }; + // Force unsupported by default so the pre-existing assertions in this + // file (math, dollar variables, plain-text fast path) don't accidentally + // pick up OSC 8 bytes from a developer's iTerm2 session. + for (const key of HYPERLINK_ENV_KEYS) { + delete process.env[key]; + } + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: false, + }); + }); + + afterEach(() => { + process.env = { ...savedEnv }; + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: savedIsTTY, + }); + Object.defineProperty(process, 'platform', { + configurable: true, + value: savedPlatform, + }); + }); + it('leaves shell-style dollar variables untouched by default', () => { const { lastFrame } = renderWithProviders( , @@ -33,4 +64,322 @@ describe('', () => { expect(lastFrame()).toContain('cost is $5 and $10 later'); }); + + describe('markdown link OSC 8 wrapping', () => { + function enableHyperlinks() { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + } + + it('wraps a safe http(s) link and shows only the label (no `(url)` suffix)', () => { + enableHyperlinks(); + const url = 'https://very.long.example.com/path/to/thing?with=params'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + // Envelope is present, pointing at the URL. + expect(out).toContain(`\x1b]8;;${url}\x07`); + expect(out).toContain('\x1b]8;;\x07'); + // Visible label is rendered… + expect(out).toContain('here'); + // …and the long URL is NOT repeated as plain text — capable terminals + // expose the target via hover / copy-link instead. + expect(out).not.toContain(`(${url})`); + }); + + it('falls back to showing the URL as label when [](url) has empty label', () => { + enableHyperlinks(); + const url = 'https://example.com/x'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${url}\x07`); + // Empty label would render an invisible link, so show the URL itself. + expect(out).toContain(url); + }); + + it('does not wrap dangerous schemes (javascript:, data:, file:, …)', () => { + enableHyperlinks(); + for (const url of [ + 'javascript:alert', + 'data:text/html', + 'file:///etc/passwd', + 'vbscript:msgbox', + ]) { + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out, `scheme should not wrap: ${url}`).not.toContain('\x1b]8;;'); + // The URL stays visible so the user can read what they would click. + // Strip any Ink-inserted soft wraps before checking for the URL. + expect(out.replace(/\s+/g, ' ')).toContain(url); + } + }); + + it('falls back to plain "label (url)" rendering on unsupported terminals', () => { + // Default: hyperlinks disabled (isTTY=false from beforeEach). + const url = 'https://example.com/page'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + expect(out).not.toContain('\x1b]8;;'); + expect(out).toContain('docs'); + expect(out).toContain(`(${url})`); + }); + + it('wraps bare URLs in an OSC 8 envelope when supported', () => { + enableHyperlinks(); + const url = 'https://example.com/very/long/url'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${url}\x07`); + expect(out).toContain(url); + expect(out).toContain('\x1b]8;;\x07'); + }); + + it('trims trailing sentence punctuation from the OSC 8 target only', () => { + enableHyperlinks(); + const url = 'https://example.com/page'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + // Visible bytes retain the period (no regression). + expect(out).toContain(`${url}.`); + // OSC 8 target is the URL without the trailing period. + expect(out).toContain(`\x1b]8;;${url}\x07`); + expect(out).not.toContain(`\x1b]8;;${url}.\x07`); + }); + + it('leaves bare URLs unwrapped when unsupported', () => { + const url = 'https://example.com/plain'; + const { lastFrame } = renderWithProviders( + , + ); + + const out = lastFrame() ?? ''; + expect(out).not.toContain('\x1b]8;;'); + expect(out).toContain(url); + }); + + it('refuses to emit OSC 8 inside tmux without FORCE_HYPERLINK', () => { + enableHyperlinks(); + process.env['TMUX'] = '/tmp/tmux-1000/default,1,0'; + const url = 'https://example.com/page'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).not.toContain('\x1b]8;;'); + }); + + it('preserves balanced parens inside the link URL (Wikipedia-style)', () => { + enableHyperlinks(); + const url = 'https://en.wikipedia.org/wiki/Foo_(bar)'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + // Envelope target must be the full URL including the inner `)` — even + // though the URL isn't shown as visible text in wrap mode, it has to + // be byte-correct in the envelope so clicking resolves. + expect(out).toContain(`\x1b]8;;${url}\x07`); + // Visible bytes are just the label. + expect(out).toContain('wiki'); + expect(out).not.toContain(`(${url})`); + }); + + it('does not wrap a URL that contains whitespace', () => { + // The link regex accepts `[^()]*` inside the URL group, which includes + // whitespace. Every terminal rejects/truncates an OSC 8 target with + // embedded whitespace, so we must NOT wrap — falling through preserves + // the legacy "broken URL is at least visible" behavior. + enableHyperlinks(); + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).not.toContain('\x1b]8;;'); + expect(out.replace(/\s+/g, ' ')).toContain('https://x.com path'); + }); + + it('does not wrap a URL containing NBSP / Unicode whitespace', () => { + // `/\s/` in JavaScript matches U+00A0 NBSP and other Unicode spaces, + // so model output that smuggles them into the URL still falls through + // to the legacy rendering. + enableHyperlinks(); + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).not.toContain('\x1b]8;;'); + }); + + it('trims a trailing `>` from a CommonMark autolink URL target', () => { + // `` in the markdown source surfaces as the bare URL + // `https://x.com>` after the regex matches; the trim function strips + // the `>` from the OSC 8 target while the visible text keeps it. + enableHyperlinks(); + const url = 'https://example.com/auto'; + const { lastFrame } = renderWithProviders( + ok`} />, + ); + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${url}\x07`); + expect(out).not.toContain(`\x1b]8;;${url}>\x07`); + }); + + it('mid-stream unclosed-link state emits a well-formed envelope, not a half-envelope', () => { + // Streaming chunks arrive faster than the human eye; MarkdownDisplay + // re-renders the whole line on each tick. While a chunk is in flight + // and the closing `)` hasn't arrived, the link branch can't match, so + // the bare-URL alternative wraps the partial URL. That's acceptable: + // the next tick produces the full link. What we MUST guarantee is + // that the envelope is always balanced — never a half-open OSC 8. + enableHyperlinks(); + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + // Same count of opens (`\x1b]8;;…\x07`) and closes (`\x1b]8;;\x07`). + // eslint-disable-next-line no-control-regex + const opens = (out.match(/\x1b\]8;;[^\x07]+\x07/g) ?? []).length; + // eslint-disable-next-line no-control-regex + const closes = (out.match(/\x1b\]8;;\x07/g) ?? []).length; + expect(opens).toBe(closes); + }); + + it('chunked stream finalizes to a single full link envelope', () => { + enableHyperlinks(); + const url = 'https://example.com/page'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + // Final-state assertion: one envelope pointing at the URL, label only + // in the visible bytes. + expect(out).toContain(`\x1b]8;;${url}\x07`); + expect(out).toContain('foo'); + expect(out).not.toContain(`(${url})`); + }); + + it('sanitizes bidi controls in the URL when used as visible text', () => { + // The OSC 8 target inside `osc8Open` is sanitized, but a model that + // emits `[](https://example.com/a‮evil)` (empty label) would + // otherwise render the raw URL — including the RLO — as visible text + // via the `safeLabel || url` fallback. Same risk for the deceptive + // `(url)` suffix. Both must render the sanitized URL. + enableHyperlinks(); + const dirtyUrl = 'https://example.com/a\u202eevil'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + // The OSC target is sanitized (RLO byte stripped). + expect(out).toContain('\x1b]8;;https://example.com/aevil\x07'); + // The visible URL fallback also has the RLO stripped. + expect(out).not.toContain('\u202e'); + }); + + it('sanitizes bidi controls in the visible label (anti-spoof)', () => { + // U+202E (RLO) injected into a label would visually reverse the + // trailing bytes, letting a "click [safe.com](https://evil.com)" + // render as a different host than the URL — a spoofing vector that + // OSC 8's clickable region makes more dangerous than the legacy + // `label (url)` rendering, because the user no longer sees the + // URL in plain text. + enableHyperlinks(); + const url = 'https://example.com/page'; + const spoofLabel = 'safe.com\u202emoc.live'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${url}\x07`); + // The RLO byte must NOT survive into the rendered visible label. + expect(out).not.toContain('\u202e'); + }); + + it('keeps the `(url)` suffix when the label looks like a mismatched URL', () => { + // Anti-spoof: if the model emits `[https://google.com](https://evil.com)` + // the OSC 8 branch must NOT hide the actual target, or the user sees + // a clickable "google.com" that resolves to evil.com. + enableHyperlinks(); + const target = 'https://attacker.com/phish'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + // Envelope is still emitted so the label is clickable. + expect(out).toContain(`\x1b]8;;${target}\x07`); + // Visible label remains. + expect(out).toContain('https://google.com/auth'); + // The real target stays visible right next to the link. + expect(out).toContain(`(${target})`); + }); + + it('keeps the `(url)` suffix for bare-host labels (e.g. `google.com`)', () => { + // The most natural click-deception form: the model writes a bare + // hostname as the label that doesn't match the URL's host. + enableHyperlinks(); + const target = 'https://attacker.com/phish'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${target}\x07`); + expect(out).toContain('google.com'); + expect(out).toContain(`(${target})`); + }); + + it('elides `(url)` when label==url (no deception risk)', () => { + // The model echoing a URL as both label and target is fine — the user + // sees the URL either way, no deception. Keep the existing elision. + enableHyperlinks(); + const url = 'https://example.com/page'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + expect(out).toContain(`\x1b]8;;${url}\x07`); + // No duplicated `(url)` suffix — the label already shows the URL. + expect(out).not.toContain(`(${url})`); + }); + + it('non-TTY fallback is byte-identical to the legacy `label (url)` form', () => { + // Pin the contract from the PR: when the terminal does not advertise + // OSC 8 support, output must contain no OSC 8 envelope bytes and the + // visible payload must be the legacy `label (url)` form. A regression + // that adds a stray escape on the off-path would slip past the + // `not.toContain('\x1b]8;;')` checks elsewhere if accompanied by + // other escapes, so anchor a stricter substring assertion here too. + // (isTTY=false from the suite-wide beforeEach disables hyperlinks.) + const url = 'https://example.com/page'; + const { lastFrame } = renderWithProviders( + , + ); + const out = lastFrame() ?? ''; + // No OSC 8 envelope, no related escape introducer. + expect(out).not.toContain('\x1b]8'); + // Exactly one occurrence of the legacy form, with the URL fully present. + expect(out).toContain(`docs`); + expect(out).toContain(`(${url})`); + }); + }); }); diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index e0b853ef222..e6c0a949e00 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -10,6 +10,18 @@ import { theme } from '../semantic-colors.js'; import stringWidth from 'string-width'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { renderInlineLatex } from './latexRenderer.js'; +import { + MD_LINK_CAPTURE, + MD_LINK_PATTERN, + isSafeOscScheme, + labelMayDeceive, + osc8Close, + osc8Open, + sanitizeForOsc, + shouldWrapMarkdownLink, + supportsHyperlinks, + trimTrailingUrlPunctuation, +} from './osc8.js'; // Constants for Markdown parsing const BOLD_MARKER_LENGTH = 2; // For "**" @@ -24,10 +36,13 @@ const INLINE_MATH_PATTERN = new RegExp( String.raw`(?.*?<\/u>|https?:\/\/\S+)/g; +const INLINE_MARKDOWN_REGEX = new RegExp( + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + + String.raw`\`+.+?\`+|.*?<\/u>|https?:\/\/\S+)`, + 'g', +); const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` + + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + String.raw`\`+.+?\`+|(?.*?<\/u>|https?:\/\/\S+)`, 'g', ); @@ -55,6 +70,9 @@ const RenderInlineInternal: React.FC = ({ const nodes: React.ReactNode[] = []; let lastIndex = 0; + // Capability is stable for the duration of a single render — read it once + // here so each matched link/URL doesn't re-walk the env-var table. + const canHyperlink = supportsHyperlinks(); const inlineRegex = enableInlineMath ? INLINE_MARKDOWN_WITH_MATH_REGEX : INLINE_MARKDOWN_REGEX; @@ -134,11 +152,47 @@ const RenderInlineInternal: React.FC = ({ fullMatch.includes('](') && fullMatch.endsWith(')') ) { - const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + const linkMatch = fullMatch.match(MD_LINK_CAPTURE); if (linkMatch) { - const linkText = linkMatch[1]; - const url = linkMatch[2]; - renderedNode = ( + const linkText = linkMatch[1] ?? ''; + const url = linkMatch[2] ?? ''; + const wrapOsc8 = shouldWrapMarkdownLink(url, canHyperlink); + // When OSC 8 is active, render ONLY the markdown label — the + // clickable target lives in the envelope, so repeating a long URL + // in plain text would just clutter the output. Empty labels + // (`[](url)`) fall back to showing the URL so the link stays + // discoverable. + // + // When OSC 8 is NOT active (unsupported terminal, unsafe scheme, + // whitespace in URL) we emit byte-identical legacy `label (url)` + // rendering so the user can still read and copy the target. + // The label is rendered inside the clickable region, so any bidi / + // C0 / C1 byte the model embedded would still spoof the visible + // text even though the OSC target is sanitized. Run the same + // sanitizer over both the visible label AND any URL bytes that + // end up as visible text (empty-label fallback, anti-deception + // `(url)` suffix) when OSC 8 is active. The legacy `label (url)` + // branch leaves both intact so today's unsupported-terminal + // output stays byte-identical. + const safeLabel = wrapOsc8 ? sanitizeForOsc(linkText) : linkText; + const safeUrl = wrapOsc8 ? sanitizeForOsc(url) : url; + // Keep the `(url)` suffix visible when the label itself looks + // like a (mismatched) URL — pre-OSC-8 rendering always showed the + // target; eliding it now would let `[https://google.com](https://attacker.com)` + // present a clickable "google.com" that resolves elsewhere. + const showUrlSuffix = wrapOsc8 && labelMayDeceive(safeLabel, safeUrl); + renderedNode = wrapOsc8 ? ( + + + {osc8Open(url)} + {safeLabel || safeUrl} + {osc8Close()} + + {showUrlSuffix ? ( + ({safeUrl}) + ) : null} + + ) : ( {linkText} ({url}) @@ -176,9 +230,21 @@ const RenderInlineInternal: React.FC = ({ ); } else if (fullMatch.match(/^https?:\/\//)) { + // The bare-URL regex greedily eats trailing punctuation (`.`, `)`, + // `,`, …). Trim that off the OSC 8 *target* so the clickable link + // resolves correctly, while leaving the visible bytes unchanged so + // unsupported terminals see today's output exactly. The bare-URL + // alternative is anchored on `https?://`, so `isSafeOscScheme` is + // redundant but kept as a cheap defense-in-depth assertion. + const trimmedUrl = canHyperlink + ? trimTrailingUrlPunctuation(fullMatch) + : fullMatch; + const wrapOsc8 = canHyperlink && isSafeOscScheme(trimmedUrl); renderedNode = ( + {wrapOsc8 ? osc8Open(trimmedUrl) : null} {fullMatch} + {wrapOsc8 ? osc8Close() : null} ); } diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index f2514d98463..4b1b10cdc93 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -4,13 +4,40 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; import stripAnsi from 'strip-ansi'; import stringWidth from 'string-width'; import { renderWithProviders } from '../../test-utils/render.js'; import { TableRenderer, type ColumnAlign } from './TableRenderer.js'; +import { HYPERLINK_ENV_KEYS } from './osc8.js'; describe('', () => { + // Force OSC 8 detection off for every test in this file so cell rendering + // is deterministic regardless of the developer's terminal. Without this, + // running the suite from iTerm2 / WezTerm / Kitty leaks escape bytes into + // table output and any future strict assertion would flake. + const savedEnv = { ...process.env }; + const savedIsTTY = process.stdout.isTTY; + + beforeEach(() => { + process.env = { ...savedEnv }; + for (const key of HYPERLINK_ENV_KEYS) { + delete process.env[key]; + } + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: false, + }); + }); + + afterEach(() => { + process.env = { ...savedEnv }; + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: savedIsTTY, + }); + }); + const renderTable = ( headers: string[], rows: string[][], @@ -600,6 +627,86 @@ describe('', () => { expect(output).toContain('很长的值一'); }); + describe('OSC 8 markdown links in cells', () => { + function enableHyperlinks() { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + } + + it('wraps a markdown link in a cell with an OSC 8 envelope', () => { + enableHyperlinks(); + const url = 'https://example.com/long/path'; + const output = renderTable( + ['Name', 'Link'], + [['Docs', `[here](${url})`]], + 80, + ); + expect(output).toContain(`\x1b]8;;${url}\x07`); + expect(output).toContain('\x1b]8;;\x07'); + expect(output).toContain('here'); + // Long URL must NOT be repeated as visible text inside the cell. + expect(output).not.toContain(`(${url})`); + // Column width math must strip the OSC 8 envelope (otherwise alignment + // breaks); the rendered table should still have uniform line widths. + expectAllLinesToHaveSameVisibleWidth(output); + }); + + it('falls back to legacy `label (url)` in cells on unsupported terminals', () => { + // isTTY=false from the suite-wide beforeEach disables hyperlinks. + const url = 'https://example.com/page'; + const output = renderTable( + ['Name', 'Link'], + [['Docs', `[here](${url})`]], + 80, + ); + expect(output).not.toContain('\x1b]8'); + expect(output).toContain('here'); + expect(output).toContain(`(${url})`); + }); + + it('does not wrap dangerous schemes in cells', () => { + enableHyperlinks(); + const url = 'javascript:alert(1)'; + const output = renderTable( + ['Name', 'Link'], + [['Bad', `[click](${url})`]], + 80, + ); + expect(output).not.toContain('\x1b]8'); + // The unsafe URL stays visible so the user can read what they would click. + expect(stripAnsi(output).replace(/\s+/g, ' ')).toContain(url); + }); + + it('keeps `(url)` suffix in cells when label looks like a mismatched URL', () => { + enableHyperlinks(); + const target = 'https://attacker.com/phish'; + const output = renderTable( + ['Name', 'Link'], + [['x', `[https://google.com](${target})`]], + 80, + ); + expect(output).toContain(`\x1b]8;;${target}\x07`); + // Real target stays visible next to the clickable label. + expect(stripAnsi(output)).toContain(`(${target})`); + }); + + it('sanitizes bidi controls in a cell label', () => { + enableHyperlinks(); + const url = 'https://example.com/page'; + const output = renderTable( + ['Name', 'Link'], + [['x', `[safe.com\u202emoc.live](${url})`]], + 80, + ); + expect(output).toContain(`\x1b]8;;${url}\x07`); + expect(output).not.toContain('\u202e'); + }); + }); + // ─── Narrow-terminal vertical fallback ─── describe('horizontal/vertical mode threshold', () => { it('uses horizontal mode at ample width (60 cols, 2 short cols)', () => { diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 1ad5323f0a1..661062fec90 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -11,6 +11,18 @@ import stripAnsi from 'strip-ansi'; import { getCachedStringWidth } from './textUtils.js'; import { theme } from '../semantic-colors.js'; import { renderInlineLatex } from './latexRenderer.js'; +import { + MD_LINK_CAPTURE, + MD_LINK_PATTERN, + isSafeOscScheme, + labelMayDeceive, + osc8Close, + osc8Open, + sanitizeForOsc, + shouldWrapMarkdownLink, + supportsHyperlinks, + trimTrailingUrlPunctuation, +} from './osc8.js'; /** Minimum column width to prevent degenerate layouts */ const MIN_COLUMN_WIDTH = 3; @@ -31,10 +43,13 @@ const SAFETY_MARGIN = 4; const INLINE_MATH_MAX_CHARS = 1024; const INLINE_MATH_PATTERN = String.raw`(?.*?<\/u>|https?:\/\/\S+)/g; +const INLINE_MARKDOWN_REGEX = new RegExp( + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + + String.raw`\`+.+?\`+|.*?<\/u>|https?:\/\/\S+)`, + 'g', +); const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` + + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + String.raw`\`+.+?\`+|${INLINE_MATH_PATTERN}|.*?<\/u>|https?:\/\/\S+)`, 'g', ); @@ -225,6 +240,10 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { : INLINE_MARKDOWN_REGEX; inlineRegex.lastIndex = 0; + // Capability is stable for the duration of one cell render — read it once + // here instead of per matched token. + const canHyperlink = supportsHyperlinks(); + let result = ''; let lastIndex = 0; let match; @@ -274,9 +293,38 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { fullMatch.includes('](') && fullMatch.endsWith(')') ) { - const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + const linkMatch = fullMatch.match(MD_LINK_CAPTURE); if (linkMatch) { - rendered = `${linkMatch[1]} ${applyColor(`(${linkMatch[2]})`, theme.text.link)}`; + const labelText = linkMatch[1] ?? ''; + const url = linkMatch[2] ?? ''; + // When OSC 8 wraps, show only the label — long URLs in narrow + // table cells were the worst offender for layout cluttering, so + // this matters especially here. Fall back to the legacy + // `label (url)` rendering when wrapping is off so the cell is + // byte-identical to today on unsupported terminals / unsafe + // schemes / whitespace URLs. + if (shouldWrapMarkdownLink(url, canHyperlink)) { + // Strip bidi / C0 / C1 from BOTH the visible label and any URL + // bytes that end up as visible text (empty-label fallback, + // deceptive-label `(url)` suffix). The OSC target inside + // `osc8Open` is already sanitized, but raw `url` reaching the + // visible region would let U+202E etc. spoof the rendered text. + const safeLabel = sanitizeForOsc(labelText); + const safeUrl = sanitizeForOsc(url); + const visibleLabel = applyColor( + safeLabel || safeUrl, + theme.text.link, + ); + const envelope = `${osc8Open(url)}${visibleLabel}${osc8Close()}`; + // When the label looks like a (mismatched) URL, keep the `(url)` + // suffix so the user can see where the click actually goes — same + // mitigation as the React renderer. + rendered = labelMayDeceive(safeLabel, safeUrl) + ? `${envelope} ${applyColor(`(${safeUrl})`, theme.text.link)}` + : envelope; + } else { + rendered = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`; + } } } else if ( enableInlineMath && @@ -295,7 +343,15 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { ) { rendered = ansiFmt.underline(fullMatch.slice(3, -4)); } else if (/^https?:\/\//.test(fullMatch)) { - rendered = applyColor(fullMatch, theme.text.link); + const visible = applyColor(fullMatch, theme.text.link); + if (canHyperlink) { + const trimmedUrl = trimTrailingUrlPunctuation(fullMatch); + rendered = isSafeOscScheme(trimmedUrl) + ? `${osc8Open(trimmedUrl)}${visible}${osc8Close()}` + : visible; + } else { + rendered = visible; + } } result += rendered ?? fullMatch; diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts new file mode 100644 index 00000000000..66527c03e85 --- /dev/null +++ b/packages/cli/src/ui/utils/osc8.test.ts @@ -0,0 +1,577 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + HYPERLINK_ENV_KEYS, + isSafeOscScheme, + labelMayDeceive, + osc8Close, + osc8Hyperlink, + osc8Open, + sanitizeForOsc, + supportsHyperlinks, + trimTrailingUrlPunctuation, +} from './osc8.js'; + +const ESC = '\x1b'; +const BEL = '\x07'; + +function clearHyperlinkEnv() { + for (const key of HYPERLINK_ENV_KEYS) { + delete process.env[key]; + } +} + +describe('osc8 helpers', () => { + const savedEnv = { ...process.env }; + const savedIsTTY = process.stdout.isTTY; + const savedPlatform = process.platform; + + beforeEach(() => { + // Start every test from a known baseline. supportsHyperlinks() has no + // memoization so a fresh env is enough. + process.env = { ...savedEnv }; + clearHyperlinkEnv(); + // Reset platform too so a prior test that flipped to win32 can't leak. + Object.defineProperty(process, 'platform', { + configurable: true, + value: savedPlatform, + }); + // Symmetric isTTY reset — the early describes (sanitizer, scheme, trim) + // don't call setTTY() themselves, so without this they would inherit + // whatever the previous test left behind. + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: savedIsTTY, + }); + }); + + afterEach(() => { + process.env = { ...savedEnv }; + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: savedIsTTY, + }); + Object.defineProperty(process, 'platform', { + configurable: true, + value: savedPlatform, + }); + }); + + describe('sanitizeForOsc', () => { + it('strips C0 control bytes and DEL', () => { + expect(sanitizeForOsc('a\x00b\x07c\x1bd\x7fe')).toBe('abcde'); + }); + + it('strips C1 control bytes (\\x80-\\x9f)', () => { + // \x9c is 8-bit ST and \x9d is 8-bit OSC — terminals that honor C1 + // controls treat these as sequence boundaries, so they must not + // survive inside an OSC 8 target. + expect(sanitizeForOsc('a\x80b\x9cc\x9dd\x9fe')).toBe('abcde'); + }); + + it('strips Unicode bidi controls (label spoofing)', () => { + // U+202E (RLO) in a link label would visually reverse the trailing + // bytes, letting `safe.com` render as a different host even though + // the OSC target is sanitized. + expect(sanitizeForOsc('safe.com\u202emoc.live')).toBe('safe.commoc.live'); + // Every bidi control covered by the regex. + expect( + sanitizeForOsc( + 'a\u200eb\u200fc\u202ad\u202be\u202cf\u202dg\u202eh\u2066i\u2067j\u2068k\u2069l', + ), + ).toBe('abcdefghijkl'); + }); + + it('strips line / paragraph separators (envelope fracture)', () => { + // U+2028 / U+2029 are treated as line breaks inside an OSC payload + // by some terminals, fracturing the envelope. + expect(sanitizeForOsc('a\u2028b\u2029c')).toBe('abc'); + }); + + it('keeps printable ASCII and unicode intact', () => { + expect(sanitizeForOsc('https://example.com/路径?q=v')).toBe( + 'https://example.com/路径?q=v', + ); + }); + }); + + describe('osc8Hyperlink', () => { + it('emits the canonical OSC 8 envelope with BEL terminators', () => { + expect(osc8Hyperlink('https://example.com', 'click me')).toBe( + `${ESC}]8;;https://example.com${BEL}click me${ESC}]8;;${BEL}`, + ); + }); + + it('defaults the label to the url', () => { + expect(osc8Hyperlink('https://example.com')).toBe( + `${ESC}]8;;https://example.com${BEL}https://example.com${ESC}]8;;${BEL}`, + ); + }); + + it('strips embedded escapes so they cannot break out of the envelope', () => { + const malicious = `https://example.com${BEL}${ESC}]8;;evil${BEL}`; + const out = osc8Hyperlink(malicious, `lbl${ESC}[31m`); + // Exactly two ESC and two BEL bytes — the envelope's own terminators. + // eslint-disable-next-line no-control-regex + expect((out.match(/\x1b/g) ?? []).length).toBe(2); + // eslint-disable-next-line no-control-regex + expect((out.match(/\x07/g) ?? []).length).toBe(2); + expect(out).toContain('https://example.com]8;;evil'); + expect(out).toContain('lbl[31m'); + }); + + it('produces an envelope that composes from osc8Open + osc8Close', () => { + expect(osc8Open('https://x.test') + 'label' + osc8Close()).toBe( + osc8Hyperlink('https://x.test', 'label'), + ); + }); + }); + + describe('isSafeOscScheme', () => { + it.each([ + 'http://example.com', + 'https://example.com/path', + 'HTTPS://example.com', // case-insensitive scheme + 'mailto:user@example.com', + 'ftp://example.com/file', + 'ftps://example.com', + 'sftp://host/path', + 'ssh://host', + ])('allows %s', (url) => { + expect(isSafeOscScheme(url)).toBe(true); + }); + + it.each([ + 'javascript:alert(1)', + 'JavaScript:alert(1)', + 'data:text/html;base64,PHNjcmlwdD4=', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'chrome://settings', + 'about:blank', + // Relative / fragment / empty — no scheme at all. + '', + '#anchor', + '/relative/path', + './doc.html', + 'just text', + ])('rejects %s', (url) => { + expect(isSafeOscScheme(url)).toBe(false); + }); + }); + + describe('labelMayDeceive', () => { + it('flags labels containing a different URL than the target', () => { + // The classic spoof: label looks like one host, target is another. + expect( + labelMayDeceive('https://google.com', 'https://attacker.com'), + ).toBe(true); + expect( + labelMayDeceive('Visit https://safe.com now', 'https://evil.com/x'), + ).toBe(true); + // Scheme-only label still trips the heuristic — defensively + // permissive: a bare `mailto:` label that doesn't equal the URL is + // suspicious enough to keep the `(url)` suffix visible. + expect(labelMayDeceive('mailto:friend', 'mailto:other')).toBe(true); + }); + + it('does NOT flag when the label equals the target', () => { + const url = 'https://example.com/x'; + expect(labelMayDeceive(url, url)).toBe(false); + }); + + it('flags bare-host labels whose host differs from the target', () => { + // The single most common click-deception shape in markdown — and one + // a careful attacker would prefer over a full `https://…` label, + // since it looks more natural. Must NOT escape the heuristic. + expect(labelMayDeceive('google.com', 'https://attacker.com')).toBe(true); + expect( + labelMayDeceive('paypal.com', 'https://phish.example.org/login'), + ).toBe(true); + // Punycode IDN attack — label `google.com`, target is the lookalike + // punycode host. Label host does not equal target host → flag. + expect(labelMayDeceive('google.com', 'https://xn--googl-fsa.com')).toBe( + true, + ); + }); + + it('does NOT flag bare-host labels that match the target', () => { + // Honest rendering: label is the same host the URL points to. + expect(labelMayDeceive('google.com', 'https://google.com/page')).toBe( + false, + ); + expect( + labelMayDeceive('docs.example.com', 'https://docs.example.com/x'), + ).toBe(false); + }); + + it('flags IPv4 literal labels whose host differs from the target', () => { + // `[1.1.1.1](https://attacker.com)` — common shape (Cloudflare DNS, + // Google DNS) the model might emit. Same click-deception class as a + // bare hostname but the alphabetic-TLD regex skips it, so a separate + // dotted-quad rule has to catch it. + expect(labelMayDeceive('1.1.1.1', 'https://attacker.com')).toBe(true); + expect(labelMayDeceive('192.168.1.1', 'https://evil.com')).toBe(true); + expect(labelMayDeceive('Click 8.8.8.8 here', 'https://evil.com')).toBe( + true, + ); + }); + + it('does NOT flag IPv4 literal labels that match the target', () => { + expect(labelMayDeceive('1.1.1.1', 'https://1.1.1.1/dns')).toBe(false); + }); + + it('does NOT flag email-style labels matching the mailto target', () => { + // `new URL('mailto:x@y').hostname` is empty; the implementation has to + // pull the host from after the `@` or every mailto link with a + // bare-email label would be falsely flagged. + expect( + labelMayDeceive('support@example.com', 'mailto:support@example.com'), + ).toBe(false); + // …but a mismatched mailto domain IS deceptive. + expect( + labelMayDeceive('support@example.com', 'mailto:abuse@evil.com'), + ).toBe(true); + }); + + it('flags same-host different-path labels', () => { + // `[https://google.com/safe](https://google.com/evil)` — same host but + // the label hides which path. The `://` pattern fires here and the + // user sees the real path in the `(url)` suffix. + expect( + labelMayDeceive('https://google.com/safe', 'https://google.com/evil'), + ).toBe(true); + }); + + it('does NOT flag plain-text or numeric labels', () => { + expect(labelMayDeceive('click here', 'https://example.com')).toBe(false); + expect(labelMayDeceive('docs', 'https://example.com')).toBe(false); + expect(labelMayDeceive('', 'https://example.com')).toBe(false); + // Version strings like `v1.2.3` are not host-like (last segment is + // digits, not alphabetic) so they don't trip the heuristic. + expect(labelMayDeceive('v1.2.3', 'https://example.com')).toBe(false); + }); + }); + + describe('trimTrailingUrlPunctuation', () => { + it.each([ + ['https://example.com.', 'https://example.com'], + ['https://example.com,', 'https://example.com'], + ['https://example.com!', 'https://example.com'], + ['https://example.com?q=1)', 'https://example.com?q=1'], + ['https://example.com:::', 'https://example.com'], + ['https://example.com).', 'https://example.com'], + ])('trims sentence punctuation: %s -> %s', (input, expected) => { + expect(trimTrailingUrlPunctuation(input)).toBe(expected); + }); + + it('preserves a trailing close-paren when balanced inside the URL', () => { + const url = 'https://en.wikipedia.org/wiki/Foo_(bar)'; + expect(trimTrailingUrlPunctuation(url)).toBe(url); + }); + + it('trims a trailing `>` (CommonMark autolink delimiter)', () => { + expect(trimTrailingUrlPunctuation('https://example.com>')).toBe( + 'https://example.com', + ); + }); + + it('trims an unbalanced trailing close-paren', () => { + expect(trimTrailingUrlPunctuation('https://example.com/x)')).toBe( + 'https://example.com/x', + ); + }); + + it('returns the input unchanged when there is no trailing punctuation', () => { + expect(trimTrailingUrlPunctuation('https://example.com/x')).toBe( + 'https://example.com/x', + ); + }); + }); + + describe('supportsHyperlinks', () => { + function setTTY(value: boolean) { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }); + } + function setPlatform(value: NodeJS.Platform) { + Object.defineProperty(process, 'platform', { + configurable: true, + value, + }); + } + + it('returns false when stdout is not a TTY', () => { + setTTY(false); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns false when NO_COLOR is set', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + process.env['NO_COLOR'] = '1'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns false when FORCE_COLOR=0', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + process.env['FORCE_COLOR'] = '0'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns false in CI', () => { + setTTY(true); + process.env['CI'] = 'true'; + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns false inside tmux even on a capable terminal', () => { + setTTY(true); + process.env['TMUX'] = '/tmp/tmux-1000/default,1,0'; + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns false inside GNU screen', () => { + setTTY(true); + process.env['STY'] = '1234.host'; + process.env['WT_SESSION'] = '00000000-0000-0000-0000-000000000000'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('lets a detected TERM_PROGRAM win even on win32', () => { + setTTY(true); + setPlatform('win32'); + process.env['TERM_PROGRAM'] = 'vscode'; + process.env['TERM_PROGRAM_VERSION'] = '1.80.0'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('returns false on bare win32 (no detected terminal)', () => { + setTTY(true); + setPlatform('win32'); + delete process.env['TERM_PROGRAM']; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns true for Windows Terminal even on win32', () => { + setTTY(true); + setPlatform('win32'); + process.env['WT_SESSION'] = '00000000-0000-0000-0000-000000000000'; + expect(supportsHyperlinks()).toBe(true); + }); + + describe('version-gated TERM_PROGRAMs', () => { + it('iTerm.app >= 3.1 is enabled, < 3.1 is not', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + expect(supportsHyperlinks()).toBe(true); + process.env['TERM_PROGRAM_VERSION'] = '3.1.0'; + expect(supportsHyperlinks()).toBe(true); + process.env['TERM_PROGRAM_VERSION'] = '3.0.15'; + expect(supportsHyperlinks()).toBe(false); + process.env['TERM_PROGRAM_VERSION'] = '2.9.20140903'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('vscode >= 1.72 is enabled, < 1.72 is not', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'vscode'; + process.env['TERM_PROGRAM_VERSION'] = '1.72.0'; + expect(supportsHyperlinks()).toBe(true); + process.env['TERM_PROGRAM_VERSION'] = '1.71.2'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('WezTerm requires the dated build >= 20200620', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'WezTerm'; + process.env['TERM_PROGRAM_VERSION'] = '20200620-000000'; + expect(supportsHyperlinks()).toBe(true); + process.env['TERM_PROGRAM_VERSION'] = '20191123-000000'; + expect(supportsHyperlinks()).toBe(false); + }); + }); + + it('VTE 0.50.0 is blocked due to known segfault; >= 0.50 otherwise enabled', () => { + setTTY(true); + process.env['VTE_VERSION'] = '0.50.0'; + expect(supportsHyperlinks()).toBe(false); + process.env['VTE_VERSION'] = '0.52.0'; + expect(supportsHyperlinks()).toBe(true); + process.env['VTE_VERSION'] = '0.48.0'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('VTE 0.50.0 reported in packed form (5000) is also blocked', () => { + // VTE historically reports VTE_VERSION as a packed integer; the + // string-compare path would miss this case and let the segfault fire. + setTTY(true); + process.env['VTE_VERSION'] = '5000'; + expect(supportsHyperlinks()).toBe(false); + process.env['VTE_VERSION'] = '5002'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Kitty is enabled via KITTY_WINDOW_ID or TERM=xterm-kitty', () => { + setTTY(true); + process.env['KITTY_WINDOW_ID'] = '1'; + expect(supportsHyperlinks()).toBe(true); + delete process.env['KITTY_WINDOW_ID']; + process.env['TERM'] = 'xterm-kitty'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Ghostty is enabled via GHOSTTY_RESOURCES_DIR or TERM=xterm-ghostty', () => { + setTTY(true); + process.env['GHOSTTY_RESOURCES_DIR'] = '/path'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Konsole ≥ 21.04 is enabled via KONSOLE_VERSION', () => { + setTTY(true); + process.env['KONSOLE_VERSION'] = '230400'; + expect(supportsHyperlinks()).toBe(true); + process.env['KONSOLE_VERSION'] = '210400'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Konsole < 21.04 falls through (no OSC 8 in 20.x)', () => { + // KONSOLE_VERSION is set by every Konsole release, including ones + // that pre-date OSC 8 support. Without a version gate those older + // sessions would receive escape bytes they can't render. + setTTY(true); + process.env['KONSOLE_VERSION'] = '201200'; // Konsole 20.12 + expect(supportsHyperlinks()).toBe(false); + process.env['KONSOLE_VERSION'] = '210399'; // one patch below the gate + expect(supportsHyperlinks()).toBe(false); + }); + + it('Alacritty is enabled via TERM=alacritty', () => { + setTTY(true); + process.env['TERM'] = 'alacritty'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Alacritty fallback: ALACRITTY_LOG/WINDOW_ID/SOCKET when terminfo missing', () => { + setTTY(true); + // Simulate Alacritty falling back to TERM=xterm-256color because the + // alacritty terminfo isn't installed on this host. + process.env['TERM'] = 'xterm-256color'; + process.env['ALACRITTY_LOG'] = '/tmp/Alacritty-12345.log'; + expect(supportsHyperlinks()).toBe(true); + delete process.env['ALACRITTY_LOG']; + process.env['ALACRITTY_WINDOW_ID'] = '12345'; + expect(supportsHyperlinks()).toBe(true); + delete process.env['ALACRITTY_WINDOW_ID']; + process.env['ALACRITTY_SOCKET'] = '/tmp/alacritty.sock'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('JetBrains JediTerm is enabled via TERMINAL_EMULATOR', () => { + setTTY(true); + process.env['TERMINAL_EMULATOR'] = 'JetBrains-JediTerm'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Warp Terminal is intentionally NOT auto-detected (no OSC 8 support yet)', () => { + // Warp's current rendering engine doesn't honor OSC 8 — it prints the + // envelope as visible garbage. Falls through to the final return false + // until Warp ships support. Users on a Warp build that does support + // OSC 8 can opt in via FORCE_HYPERLINK=1. + setTTY(true); + process.env['TERM_PROGRAM'] = 'WarpTerminal'; + expect(supportsHyperlinks()).toBe(false); + process.env['FORCE_HYPERLINK'] = '1'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('mintty is enabled via TERM_PROGRAM=mintty', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'mintty'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Hyper is intentionally not auto-detected (requires FORCE_HYPERLINK)', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'Hyper'; + expect(supportsHyperlinks()).toBe(false); + process.env['FORCE_HYPERLINK'] = '1'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('Apple Terminal is not auto-detected (no OSC 8 support)', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'Apple_Terminal'; + process.env['TERM_PROGRAM_VERSION'] = '447'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('honors FORCE_HYPERLINK=1 inside tmux on a TTY', () => { + setTTY(true); + process.env['TMUX'] = '/tmp/x,1,0'; + process.env['FORCE_HYPERLINK'] = '1'; + expect(supportsHyperlinks()).toBe(true); + }); + + it('FORCE_HYPERLINK=1 does NOT override non-TTY suppression', () => { + // A user with `FORCE_HYPERLINK=1` in their shell profile (to enable + // OSC 8 inside tmux interactively) must still get a clean pipe when + // running `qwen | cat` — escape bytes never go into a file/pipe. + setTTY(false); + process.env['FORCE_HYPERLINK'] = '1'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('FORCE_HYPERLINK=0 disables even on capable terminals', () => { + setTTY(true); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['TERM_PROGRAM_VERSION'] = '3.5.0'; + process.env['FORCE_HYPERLINK'] = '0'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('hard opt-outs (NO_COLOR/QWEN_DISABLE_HYPERLINKS) win over FORCE_HYPERLINK', () => { + setTTY(true); + process.env['FORCE_HYPERLINK'] = '1'; + process.env['NO_COLOR'] = '1'; + expect(supportsHyperlinks()).toBe(false); + delete process.env['NO_COLOR']; + process.env['QWEN_DISABLE_HYPERLINKS'] = '1'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('returns false for an unknown terminal even on a TTY', () => { + setTTY(true); + process.env['TERM'] = 'dumb'; + expect(supportsHyperlinks()).toBe(false); + }); + + it('accepts a stream argument so non-stdout writers can be probed', () => { + const fakeStream = { isTTY: true } as NodeJS.WriteStream; + process.env['WT_SESSION'] = '00000000-0000-0000-0000-000000000000'; + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: false, + }); + expect(supportsHyperlinks(fakeStream)).toBe(true); + expect(supportsHyperlinks(process.stdout)).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts new file mode 100644 index 00000000000..a3a13328917 --- /dev/null +++ b/packages/cli/src/ui/utils/osc8.ts @@ -0,0 +1,481 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OSC 8 hyperlink helpers. + * + * Supported terminals (iTerm2 ≥ 3.1, WezTerm ≥ 20200620, Kitty, Ghostty, + * Windows Terminal, VS Code ≥ 1.72, GNOME Terminal / VTE ≥ 0.50, …) render + * an OSC 8 envelope as a clickable link that survives line wrapping. + * Terminals without OSC 8 support ignore the escapes and print the visible + * label as-is. + */ + +import { wrapForMultiplexer } from '../../utils/osc.js'; +// Re-export so MCP `AuthenticateStep` (the one remaining inline caller) can +// pick the helper up from a single OSC-8-aware namespace. +export { wrapForMultiplexer }; + +/** + * Strip C0 + DEL + C1 control characters AND Unicode bidi / line-separator + * controls so an untrusted string can be safely embedded inside an OSC + * escape and rendered without spoofing the visible label. + * + * Bytes removed: + * - C0 + DEL (`\x00-\x1f\x7f`): a stray BEL (`\x07`) or ESC (`\x1b`) would + * prematurely terminate the OSC sequence and leak the tail bytes as + * interpretable escape codes. + * - C1 (`\x80-\x9f`): includes 8-bit ST and 8-bit OSC introducers, which + * terminals that honor C1 controls treat the same as their two-byte ESC + * counterparts. + * - Bidi controls (`U+200E`, `U+200F`, `U+202A`-`U+202E`, `U+2066`-`U+2069`): + * a model-emitted `U+202E` (RLO) in a link label visually reverses the + * trailing text, letting a label like `safe.com` actually read as a + * different host after rendering. The scheme allowlist guards the *target*; + * stripping bidi controls guards the visible *label* from the same class + * of click-deception attack. + * - Line / paragraph separators (`U+2028`, `U+2029`): some terminals treat + * these as line breaks inside an OSC payload, fracturing the envelope. + */ +export function sanitizeForOsc(s: string): string { + return s.replace( + // eslint-disable-next-line no-control-regex + /[\x00-\x1f\x7f\x80-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g, + '', + ); +} + +/** + * Wrap a URL in an OSC 8 hyperlink escape sequence. BEL (\x07) terminates + * the OSC — more broadly supported than ST (ESC \\). + */ +export function osc8Hyperlink(url: string, label = url): string { + const safeUrl = sanitizeForOsc(url); + const safeLabel = sanitizeForOsc(label); + return wrapForMultiplexer(`\x1b]8;;${safeUrl}\x07${safeLabel}\x1b]8;;\x07`); +} + +/** + * Open half of an OSC 8 hyperlink envelope. Pair with `osc8Close()` to wrap + * a styled label without losing the surrounding SGR resets — OSC 8 and SGR + * are orthogonal so nested color styling is preserved by terminals that + * honor the hyperlink sequence. + */ +export function osc8Open(url: string): string { + return wrapForMultiplexer(`\x1b]8;;${sanitizeForOsc(url)}\x07`); +} + +/** Close half of an OSC 8 hyperlink envelope. */ +export function osc8Close(): string { + return wrapForMultiplexer(`\x1b]8;;\x07`); +} + +/** + * Schemes safe to embed in an OSC 8 target. Restricting to network and mail + * schemes prevents prompt-injection attacks from producing a one-click + * `javascript:` / `data:` / `file:` trap whose target is hidden behind the + * link label. Anything outside this set falls back to legacy `label (url)` + * rendering so the user sees the suspicious URL before any click. + * + * When OSC 8 wrapping IS active the renderer drops the parenthesized URL + * suffix and shows only the label — long URLs would otherwise clutter the + * stream. Capable terminals expose the target via hover / status bar / + * right-click "copy link", so the URL is still inspectable without + * polluting the visible bytes. The scheme allowlist remains the front-line + * defense against the click-deception case. + */ +const SAFE_OSC8_SCHEMES = new Set([ + 'http:', + 'https:', + 'mailto:', + 'ftp:', + 'ftps:', + 'sftp:', + 'ssh:', +]); + +/** + * Return true if `url` carries an explicit allowlisted scheme. URLs without + * a scheme (relative paths, `#anchor`, empty) are rejected — terminals can't + * resolve them anyway, and rejecting them avoids creating un-clickable links. + */ +export function isSafeOscScheme(url: string): boolean { + const match = url.match(/^([a-z][a-z0-9+.-]*:)/i); + if (!match) return false; + return SAFE_OSC8_SCHEMES.has(match[1]!.toLowerCase()); +} + +interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +function parseVersion(versionString: string | undefined): ParsedVersion { + if (!versionString) return { major: 0, minor: 0, patch: 0 }; + // VTE historically reports `VTE_VERSION` as a packed integer (e.g. `7800` + // for 0.78.0, `5000` for 0.50.0) rather than dot-separated. Mirror the + // `supports-hyperlinks` package's heuristic for this case so we extract + // the right minor for the >=0.50 gate below. + if (/^\d{3,4}$/.test(versionString)) { + const m = /(\d{1,2})(\d{2})/.exec(versionString)!; + return { major: 0, minor: parseInt(m[1]!, 10), patch: parseInt(m[2]!, 10) }; + } + const parts = versionString.split('.').map((n) => parseInt(n, 10) || 0); + return { major: parts[0] ?? 0, minor: parts[1] ?? 0, patch: parts[2] ?? 0 }; +} + +/** + * Detect whether the given writable stream's host terminal can render OSC 8 + * hyperlinks. Mirrors the version-gated detection used by the + * `supports-hyperlinks` npm package — see https://github.com/jamestalmage/node-supports-hyperlinks — + * with two intentional deviations: + * + * 1. Inside `tmux` or GNU `screen` we refuse by default. The multiplexer + * hides the actual host terminal's capabilities, so even when we DCS- + * passthrough the sequence the host may print visible garbage on + * terminals that don't understand OSC 8. Power users who know their + * host supports OSC 8 and have `allow-passthrough on` (tmux 3.3+) can + * opt in with `FORCE_HYPERLINK=1`. + * + * 2. `QWEN_DISABLE_HYPERLINKS=1` is a hard opt-out (e.g. for users whose + * terminal advertises support but breaks on long URLs). + * + * The detector deliberately allocates nothing and reads env vars on every + * call — env state can change at runtime (`/theme` toggles, NO_COLOR set + * mid-session) and memoizing would freeze a stale answer. + */ +export function supportsHyperlinks( + stream: NodeJS.WriteStream | undefined = process.stdout, +): boolean { + const env = process.env; + + // Hard opt-outs win unconditionally. + if (env['QWEN_DISABLE_HYPERLINKS'] === '1') return false; + if (env['NO_COLOR'] !== undefined && env['NO_COLOR'] !== '') return false; + if (env['FORCE_COLOR'] === '0' || env['FORCE_COLOR'] === 'false') { + return false; + } + + // Embedded escapes must never end up in a file or another process. This + // guard sits above `FORCE_HYPERLINK` on purpose: a user who has + // `FORCE_HYPERLINK=1` in their shell profile (to enable OSC 8 inside + // tmux/Hyper interactively) still shouldn't see escape bytes when they + // run `qwen | cat` or `qwen > out.txt`. + if (!stream || !stream.isTTY) return false; + + // Explicit force overrides every heuristic below — but not the opt-outs + // above nor the non-TTY guard. Mirrors the `FORCE_HYPERLINK` contract + // from supports-hyperlinks: any non-zero numeric value (or empty string) + // enables, `0` disables. + const force = env['FORCE_HYPERLINK']; + if (force !== undefined) { + if (force.length === 0) return true; + return parseInt(force, 10) !== 0; + } + + if (env['CI']) return false; + if (env['TEAMCITY_VERSION']) return false; + + // Multiplexers hide the host terminal's identity — bail unless the user + // opted in via FORCE_HYPERLINK above. + if (env['TMUX'] || env['STY']) return false; + + // Modern terminals identified by their own env vars (no version probe + // needed — these have shipped OSC 8 since their first OSC-8-aware release + // and their env var is only set by versions new enough to support it). + if (env['WT_SESSION']) return true; // Windows Terminal + if (env['KITTY_WINDOW_ID'] || env['TERM'] === 'xterm-kitty') return true; + if (env['DOMTERM']) return true; + if (env['GHOSTTY_RESOURCES_DIR'] || env['TERM'] === 'xterm-ghostty') { + return true; + } + // Konsole sets KONSOLE_VERSION on every session as a packed integer + // (e.g. 21.04 → 210400, 23.08.5 → 230805). OSC 8 support landed in + // Konsole 21.04, so version-gate against `>= 210400` and let older + // releases fall through to the final `return false` so we don't emit + // escapes on a host that won't render them. + if (env['KONSOLE_VERSION']) { + const konsoleVersion = parseInt(env['KONSOLE_VERSION'], 10); + if (Number.isFinite(konsoleVersion) && konsoleVersion >= 210400) { + return true; + } + } + // Alacritty ≥ 0.11 supports OSC 8. Identify it via TERM=alacritty (set + // when the alacritty terminfo is installed) or the ALACRITTY_LOG / + // ALACRITTY_WINDOW_ID env vars that Alacritty 0.12+ sets unconditionally. + // Note: on hosts without alacritty terminfo Alacritty falls back to + // TERM=xterm-256color and the TERM heuristic alone won't fire — the + // env-var fallbacks catch those cases. + if ( + env['TERM'] === 'alacritty' || + env['ALACRITTY_LOG'] !== undefined || + env['ALACRITTY_WINDOW_ID'] !== undefined || + env['ALACRITTY_SOCKET'] !== undefined + ) { + return true; + } + // JetBrains IDEs set TERMINAL_EMULATOR on their integrated terminal; the + // JediTerm backend has supported OSC 8 since 2022.3. + if (env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm') return true; + + if (env['TERM_PROGRAM']) { + const version = parseVersion(env['TERM_PROGRAM_VERSION']); + switch (env['TERM_PROGRAM']) { + case 'iTerm.app': + if (version.major === 3) return version.minor >= 1; + return version.major > 3; + case 'WezTerm': + return version.major >= 20200620; + case 'vscode': + return ( + version.major > 1 || (version.major === 1 && version.minor >= 72) + ); + case 'ghostty': + return true; + case 'mintty': + // mintty ≥ 3.3 supports OSC 8; older installs are extremely rare + // and still degrade safely (terminal just prints the visible bytes). + return true; + // Warp (TERM_PROGRAM=WarpTerminal) does NOT yet support OSC 8 — its + // rendering engine ignores the envelope and prints visible garbage, + // so we deliberately fall through to the legacy `label (url)` path. + // Re-enable when Warp ships OSC 8 support. + // + // Hyper exposes OSC 8 in recent versions but plugin chains have a + // history of breaking escape passthrough — gate on FORCE_HYPERLINK + // so users who know their setup works can opt in explicitly. + default: + break; + } + } + + if (env['VTE_VERSION']) { + // VTE 0.50.0 advertises OSC 8 but segfaults when it actually fires. + // Compare against the parsed version so the packed form (`'5000'`) is + // recognized too — the raw string compare against `'0.50.0'` would miss + // it and let the segfault through. + const version = parseVersion(env['VTE_VERSION']); + if (version.major === 0 && version.minor === 50 && version.patch === 0) { + return false; + } + if (version.major > 0 || version.minor >= 50) return true; + return false; + } + + // Legacy Windows console (cmd.exe, conhost) — no OSC support outside WT. + if (process.platform === 'win32') return false; + + return false; +} + +/** + * Trim trailing sentence punctuation off a bare URL run before it becomes + * an OSC 8 target. Models routinely produce `see https://example.com.` and + * the inline regex greedily swallows the period; clicking the wrapped link + * then opens a 404. The trailing characters stay in the visible text — only + * the OSC 8 *target* is trimmed, so byte-output for unsupported terminals + * is unchanged. + * + * The set of trimmable trailing characters matches GitHub / GitLab linkifier + * behavior. We additionally rebalance a trailing `)` against opening `(` in + * the URL so URLs that legitimately end with `)` (Wikipedia disambiguation, + * MSDN) aren't truncated. + */ +export function trimTrailingUrlPunctuation(url: string): string { + // Count `( [ {` opens once up-front; we then decrement running `)`/`]`/`}` + // close counts as we trim, keeping the whole trim O(n) instead of O(n²) + // for adversarial inputs like `https://x.com))))…`. + let openParen = 0; + let openBracket = 0; + let openBrace = 0; + let closeParen = 0; + let closeBracket = 0; + let closeBrace = 0; + for (let i = 0; i < url.length; i++) { + const cc = url.charCodeAt(i); + if (cc === 0x28) openParen++; + else if (cc === 0x5b) openBracket++; + else if (cc === 0x7b) openBrace++; + else if (cc === 0x29) closeParen++; + else if (cc === 0x5d) closeBracket++; + else if (cc === 0x7d) closeBrace++; + } + + let end = url.length; + while (end > 0) { + const c = url.charCodeAt(end - 1); + // .,;:!?'"`> — `>` covers CommonMark autolinks (``) + // where the inline regex greedily eats the trailing `>` into `\S+`. + if ( + c === 0x2e || + c === 0x2c || + c === 0x3b || + c === 0x3a || + c === 0x21 || + c === 0x3f || + c === 0x27 || + c === 0x22 || + c === 0x60 || + c === 0x3e + ) { + end--; + continue; + } + // Trailing `)`/`]`/`}` only when unbalanced against opens in the prefix. + if (c === 0x29 && closeParen > openParen) { + closeParen--; + end--; + continue; + } + if (c === 0x5d && closeBracket > openBracket) { + closeBracket--; + end--; + continue; + } + if (c === 0x7d && closeBrace > openBrace) { + closeBrace--; + end--; + continue; + } + break; + } + return url.slice(0, end); +} + +// ── Markdown link regex shared between the React and ANSI renderers ────── + +/** + * Inline link pattern allowing one level of balanced parens in the URL + * group so `[wiki](https://en.wikipedia.org/wiki/Foo_(bar))` isn't truncated + * at the inner `)`. Mirrors CommonMark's cap. Exposed for both the React + * markdown renderer and the ANSI table renderer to keep them in lockstep. + */ +export const MD_LINK_PATTERN = String.raw`\[.*?\]\((?:[^()]|\([^()]*\))*\)`; + +/** + * Capture the label and URL out of a single matched link token. Anchored + * with `^...$` because callers pass the whole match string. + */ +export const MD_LINK_CAPTURE = /^\[(.*?)\]\(((?:[^()]|\([^()]*\))*)\)$/; + +/** + * Should the markdown renderers wrap a `[label](url)` token in an OSC 8 + * envelope? Returns true only when (a) the host terminal advertises OSC 8, + * (b) the URL uses an allowlisted network/mail scheme, and (c) the URL + * contains no whitespace — every terminal rejects or silently truncates a + * whitespace-bearing OSC 8 target, which would turn the whole region into + * an un-clickable trap on capable terminals. + * + * Centralizing the predicate keeps the React renderer and the ANSI table + * renderer in lockstep; if a future scheme is allowlisted, both pick it up. + */ +export function shouldWrapMarkdownLink( + url: string, + canHyperlink: boolean, +): boolean { + return canHyperlink && isSafeOscScheme(url) && !/\s/.test(url); +} + +/** + * True if the visible label could deceive the user about where the link + * actually points. The OSC 8 branch hides the URL target behind a clickable + * label, so a model-emitted `[https://google.com](https://attacker.com)` + * shows a label that *looks* like a different host than the click resolves + * to — pre-OSC-8 rendering always kept `(url)` visible, so the deception + * couldn't land. The fix is: when the label contains a URL-shaped substring + * AND it doesn't equal the actual target, keep the `(url)` suffix visible + * even though OSC 8 wrapping is otherwise active. The label is still + * clickable (envelope is still emitted), but the user sees the real target. + * + * Three patterns trip the heuristic: + * 1. Label contains `scheme://…` — covers `[https://google.com](https://evil.com)`. + * 2. Label *starts* with a `scheme:` — covers `[mailto:x](mailto:y)`. + * 3. Label contains a bare host token (`name.tld`) that doesn't equal the + * URL's hostname — covers the most common spoof shape an attacker + * would actually use: `[google.com](https://attacker.com)`. + * + * Heuristic is intentionally permissive: false positives just append a + * harmless `(url)` suffix to niche labels (e.g. Python attrs like + * `os.path` happen to look like a host); false negatives let a real spoof + * through. ASCII-only hostname matching means an IDN-homograph attack + * (Cyrillic `о` in `gооgle.com`) escapes the bare-host check, but the + * fully-qualified-URL form of that same attack is still caught by pattern 1. + */ +const HOST_LIKE_RE = + /\b[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)*\.[a-z]{2,}\b/gi; + +// Dotted-quad IPv4 in a label: `[1.1.1.1](https://attacker.com)` is the +// same class of click-deception as a bare hostname but `HOST_LIKE_RE`'s +// alphabetic-TLD anchor skips it. Each octet is loosely bounded to 1-3 +// digits; over-permissive (e.g. `999.999.999.999`) is fine — false +// positives just keep an extra `(url)` suffix. +const IPV4_LIKE_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; + +function targetHostname(url: string): string | undefined { + try { + const u = new URL(url); + // `mailto:` URLs report an empty `hostname` — pull the domain out of + // the email address after the `@` so labels like `[support@example.com] + // (mailto:support@example.com)` don't trip the bare-host check. + if (u.protocol === 'mailto:') { + const at = u.pathname.lastIndexOf('@'); + return at >= 0 + ? u.pathname.slice(at + 1).toLowerCase() || undefined + : undefined; + } + return u.hostname.toLowerCase() || undefined; + } catch { + return undefined; + } +} + +export function labelMayDeceive(label: string, url: string): boolean { + if (label === url) return false; + if (/:\/\//.test(label) || /^[a-z][a-z0-9+.-]*:/i.test(label.trim())) { + return true; + } + const lower = label.toLowerCase(); + const labelHosts = [ + ...(lower.match(HOST_LIKE_RE) ?? []), + ...(lower.match(IPV4_LIKE_RE) ?? []), + ]; + if (labelHosts.length === 0) return false; + const target = targetHostname(url); + if (!target) return true; + return labelHosts.some((h) => h !== target); +} + +// ── Test helpers ───────────────────────────────────────────────────────── + +/** + * Every env var `supportsHyperlinks()` reads. Test files clear these in + * `beforeEach` so a developer's iTerm2 session doesn't leak into snapshot + * output. Exported so tests stay in lockstep with the detector. + */ +export const HYPERLINK_ENV_KEYS = [ + 'NO_COLOR', + 'FORCE_COLOR', + 'CI', + 'TMUX', + 'STY', + 'TERM_PROGRAM', + 'TERM_PROGRAM_VERSION', + 'WT_SESSION', + 'KITTY_WINDOW_ID', + 'VTE_VERSION', + 'DOMTERM', + 'GHOSTTY_RESOURCES_DIR', + 'KONSOLE_VERSION', + 'TERMINAL_EMULATOR', + 'ALACRITTY_LOG', + 'ALACRITTY_WINDOW_ID', + 'ALACRITTY_SOCKET', + 'TERM', + 'TEAMCITY_VERSION', + 'FORCE_HYPERLINK', + 'QWEN_DISABLE_HYPERLINKS', +] as const;