From dad0037d836b5b2347ad2209914ff0fa4fd8f022 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 10:21:15 +0800
Subject: [PATCH 01/14] feat(cli): wrap markdown links in OSC 8 so wrapped URLs
stay clickable
Long URLs the model emits inside `[label](url)` or as bare `https://...`
get line-wrapped by the terminal, which prevents most emulators from
detecting them as a single clickable region. OSC 8 hyperlinks decouple
the link target from the visible label so the entire label remains one
clickable target regardless of where it wraps.
- Extract the existing OSC 8 helpers from AuthenticateStep into a shared
packages/cli/src/ui/utils/osc8.ts util, plus a dependency-free
capability detector that honors NO_COLOR / FORCE_COLOR=0 / CI /
non-TTY stdout, with FORCE_HYPERLINK=1 and QWEN_DISABLE_HYPERLINKS=1
overrides for explicit opt-in / opt-out.
- Wire InlineMarkdownRenderer to wrap markdown link labels and bare
autolinks in an OSC 8 envelope when supported. Wrapping happens after
the inline link token has been fully matched, so streamed partial
chunks cannot split an envelope across flushes.
- Fall back to the legacy `label (url)` rendering byte-for-byte when
the host terminal does not advertise OSC 8 support.
Closes #3954
---
.../components/mcp/steps/AuthenticateStep.tsx | 46 +---
.../ui/utils/InlineMarkdownRenderer.test.tsx | 107 ++++++++-
.../src/ui/utils/InlineMarkdownRenderer.tsx | 23 +-
packages/cli/src/ui/utils/osc8.test.ts | 213 ++++++++++++++++++
packages/cli/src/ui/utils/osc8.ts | 147 ++++++++++++
5 files changed, 488 insertions(+), 48 deletions(-)
create mode 100644 packages/cli/src/ui/utils/osc8.test.ts
create mode 100644 packages/cli/src/ui/utils/osc8.ts
diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
index 0977752d7cb..e978d2feed6 100644
--- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
+++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
@@ -18,57 +18,13 @@ 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, 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
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index b33d2cc885c..8cfc14e4f75 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -4,9 +4,10 @@
* 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 { resetSupportsHyperlinksCache } from './osc8.js';
describe('', () => {
it('leaves shell-style dollar variables untouched by default', () => {
@@ -33,4 +34,108 @@ describe('', () => {
expect(lastFrame()).toContain('cost is $5 and $10 later');
});
+
+ describe('markdown link OSC 8 wrapping', () => {
+ const savedEnv = { ...process.env };
+ const savedIsTTY = process.stdout.isTTY;
+
+ beforeEach(() => {
+ resetSupportsHyperlinksCache();
+ });
+
+ afterEach(() => {
+ process.env = { ...savedEnv };
+ Object.defineProperty(process.stdout, 'isTTY', {
+ configurable: true,
+ value: savedIsTTY,
+ });
+ resetSupportsHyperlinksCache();
+ });
+
+ function setEnvForHyperlinkSupport(supported: boolean) {
+ for (const key of [
+ 'NO_COLOR',
+ 'FORCE_COLOR',
+ 'CI',
+ 'TMUX',
+ 'STY',
+ 'TERM_PROGRAM',
+ 'WT_SESSION',
+ 'KITTY_WINDOW_ID',
+ 'VTE_VERSION',
+ 'DOMTERM',
+ 'JEDITERM_SOURCE_ARGS',
+ 'TERMINAL_EMULATOR',
+ 'COLORTERM',
+ 'TERM',
+ 'FORCE_HYPERLINK',
+ 'QWEN_DISABLE_HYPERLINKS',
+ ]) {
+ delete process.env[key];
+ }
+ Object.defineProperty(process.stdout, 'isTTY', {
+ configurable: true,
+ value: supported,
+ });
+ if (supported) {
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ } else {
+ process.env['NO_COLOR'] = '1';
+ }
+ }
+
+ it('emits an OSC 8 envelope around the label when supported', () => {
+ setEnvForHyperlinkSupport(true);
+ const url = 'https://very.long.example.com/path/to/thing?with=params';
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+
+ const out = lastFrame() ?? '';
+ expect(out).toContain(`\x1b]8;;${url}\x07`);
+ expect(out).toContain('here');
+ expect(out).toContain('\x1b]8;;\x07');
+ // The legacy `(url)` suffix should not be shown when the terminal
+ // supports OSC 8 — the clickable label is the visible affordance.
+ expect(out).not.toContain(`(${url})`);
+ });
+
+ it('falls back to legacy "label (url)" rendering when unsupported', () => {
+ setEnvForHyperlinkSupport(false);
+ 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', () => {
+ setEnvForHyperlinkSupport(true);
+ 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('leaves bare URLs unwrapped when unsupported', () => {
+ setEnvForHyperlinkSupport(false);
+ const url = 'https://example.com/plain';
+ const { lastFrame } = renderWithProviders(
+ ,
+ );
+
+ const out = lastFrame() ?? '';
+ expect(out).not.toContain('\x1b]8;;');
+ expect(out).toContain(url);
+ });
+ });
});
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
index e0b853ef222..85c9390c870 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
@@ -10,6 +10,7 @@ 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 { osc8Close, osc8Open, supportsHyperlinks } from './osc8.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
@@ -138,7 +139,19 @@ const RenderInlineInternal: React.FC = ({
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
- renderedNode = (
+ // When the host terminal supports OSC 8, wrap just the label so the
+ // entire visible region is one clickable target even if the URL is
+ // long enough to wrap across lines. The visible URL suffix is
+ // dropped because clicking the label resolves to the same target.
+ // Otherwise fall back to the legacy `label (url)` rendering so
+ // output remains byte-identical for terminals without support.
+ renderedNode = supportsHyperlinks() ? (
+
+ {osc8Open(url)}
+ {linkText}
+ {osc8Close()}
+
+ ) : (
{linkText}
({url})
@@ -176,7 +189,13 @@ const RenderInlineInternal: React.FC = ({
);
} else if (fullMatch.match(/^https?:\/\//)) {
- renderedNode = (
+ renderedNode = supportsHyperlinks() ? (
+
+ {osc8Open(fullMatch)}
+ {fullMatch}
+ {osc8Close()}
+
+ ) : (
{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..34ef63df642
--- /dev/null
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -0,0 +1,213 @@
+/**
+ * @license
+ * Copyright 2025 Qwen
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ osc8Close,
+ osc8Hyperlink,
+ osc8Open,
+ resetSupportsHyperlinksCache,
+ sanitizeForOsc,
+ supportsHyperlinks,
+ wrapForMultiplexer,
+} from './osc8.js';
+
+const ESC = '\x1b';
+const BEL = '\x07';
+
+describe('osc8 helpers', () => {
+ const savedEnv = { ...process.env };
+ const savedIsTTY = process.stdout.isTTY;
+
+ beforeEach(() => {
+ resetSupportsHyperlinksCache();
+ });
+
+ afterEach(() => {
+ process.env = { ...savedEnv };
+ Object.defineProperty(process.stdout, 'isTTY', {
+ configurable: true,
+ value: savedIsTTY,
+ });
+ resetSupportsHyperlinksCache();
+ });
+
+ describe('sanitizeForOsc', () => {
+ it('strips C0 control bytes and DEL', () => {
+ expect(sanitizeForOsc('a\x00b\x07c\x1bd\x7fe')).toBe('abcde');
+ });
+
+ 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`);
+ // The only ESC bytes in the output must be the two that introduce the
+ // OSC 8 open and close sequences — no user-supplied ESC leaks through.
+ // eslint-disable-next-line no-control-regex
+ const escCount = (out.match(/\x1b/g) ?? []).length;
+ expect(escCount).toBe(2);
+ // Same idea for BEL — exactly two terminators, no extras from the
+ // sanitized user input.
+ // eslint-disable-next-line no-control-regex
+ const belCount = (out.match(/\x07/g) ?? []).length;
+ expect(belCount).toBe(2);
+ // After sanitization the surviving textual fragments stay inline.
+ 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('wrapForMultiplexer', () => {
+ it('returns the OSC unchanged when not inside tmux or screen', () => {
+ delete process.env['TMUX'];
+ delete process.env['STY'];
+ const seq = `${ESC}]8;;https://x${BEL}`;
+ expect(wrapForMultiplexer(seq)).toBe(seq);
+ });
+
+ it('wraps in a tmux DCS passthrough envelope with doubled ESCs', () => {
+ process.env['TMUX'] = '/tmp/tmux-1000/default,1234,0';
+ delete process.env['STY'];
+ const seq = `${ESC}]8;;https://x${BEL}`;
+ expect(wrapForMultiplexer(seq)).toBe(
+ `${ESC}Ptmux;${ESC}${ESC}]8;;https://x${BEL}${ESC}\\`,
+ );
+ });
+
+ it('wraps in a plain screen DCS envelope', () => {
+ delete process.env['TMUX'];
+ process.env['STY'] = '1234.host';
+ const seq = `${ESC}]8;;https://x${BEL}`;
+ expect(wrapForMultiplexer(seq)).toBe(
+ `${ESC}P${ESC}]8;;https://x${BEL}${ESC}\\`,
+ );
+ });
+ });
+
+ describe('supportsHyperlinks', () => {
+ function setTTY(value: boolean) {
+ Object.defineProperty(process.stdout, 'isTTY', {
+ configurable: true,
+ value,
+ });
+ }
+
+ function clearTerminalHints() {
+ for (const key of [
+ 'TERM_PROGRAM',
+ 'WT_SESSION',
+ 'KITTY_WINDOW_ID',
+ 'VTE_VERSION',
+ 'DOMTERM',
+ 'JEDITERM_SOURCE_ARGS',
+ 'TERMINAL_EMULATOR',
+ 'COLORTERM',
+ 'TERM',
+ 'FORCE_HYPERLINK',
+ 'FORCE_COLOR',
+ 'NO_COLOR',
+ 'CI',
+ 'TMUX',
+ 'STY',
+ 'QWEN_DISABLE_HYPERLINKS',
+ ]) {
+ delete process.env[key];
+ }
+ }
+
+ it('returns false when stdout is not a TTY', () => {
+ clearTerminalHints();
+ setTTY(false);
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ expect(supportsHyperlinks()).toBe(false);
+ });
+
+ it('returns false when NO_COLOR is set', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ process.env['NO_COLOR'] = '1';
+ expect(supportsHyperlinks()).toBe(false);
+ });
+
+ it('returns false when FORCE_COLOR=0', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ process.env['FORCE_COLOR'] = '0';
+ expect(supportsHyperlinks()).toBe(false);
+ });
+
+ it('returns false in CI by default', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['CI'] = 'true';
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ expect(supportsHyperlinks()).toBe(false);
+ });
+
+ it('returns true for iTerm2 on a TTY', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ expect(supportsHyperlinks()).toBe(true);
+ });
+
+ it('returns true for Windows Terminal via WT_SESSION', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['WT_SESSION'] = '00000000-0000-0000-0000-000000000000';
+ expect(supportsHyperlinks()).toBe(true);
+ });
+
+ it('honors FORCE_HYPERLINK=1 even without TTY heuristics matching', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['FORCE_HYPERLINK'] = '1';
+ expect(supportsHyperlinks()).toBe(true);
+ });
+
+ it('respects QWEN_DISABLE_HYPERLINKS=1 as a hard opt-out', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ process.env['QWEN_DISABLE_HYPERLINKS'] = '1';
+ expect(supportsHyperlinks()).toBe(false);
+ });
+
+ it('returns false for an unknown terminal even on a TTY', () => {
+ clearTerminalHints();
+ setTTY(true);
+ process.env['TERM'] = 'dumb';
+ expect(supportsHyperlinks()).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..1a4da5003a9
--- /dev/null
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -0,0 +1,147 @@
+/**
+ * @license
+ * Copyright 2025 Qwen
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * OSC 8 hyperlink helpers.
+ *
+ * Supported terminals (iTerm2, WezTerm, Kitty, Windows Terminal, VS Code,
+ * GNOME Terminal/VTE, Alacritty ≥ 0.11, Ghostty, …) 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.
+ */
+
+/**
+ * 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`.
+ */
+export 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.
+ */
+export 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. BEL (\x07) terminates
+ * the OSC — more broadly supported than ST (ESC \\). Inside tmux / screen
+ * the sequence is wrapped in a DCS passthrough envelope so the multiplexer
+ * forwards it to the host terminal instead of eating it.
+ */
+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 (e.g. an `` `` element) 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`);
+}
+
+/**
+ * Cheap, dependency-free OSC 8 capability detection. Mirrors the env-var
+ * checks used by the `supports-hyperlinks` npm package without adding a
+ * direct dependency. The result is memoized after the first call because
+ * env vars don't change over the lifetime of a CLI session.
+ *
+ * Honors `NO_COLOR` and `FORCE_COLOR=0` and bails on non-TTY stdout so
+ * piping to a file or another process doesn't embed escape bytes.
+ */
+let cachedSupport: boolean | undefined;
+
+export function supportsHyperlinks(): boolean {
+ if (cachedSupport !== undefined) return cachedSupport;
+ cachedSupport = detectSupportsHyperlinks();
+ return cachedSupport;
+}
+
+/** Reset the cached capability check. Intended for tests only. */
+export function resetSupportsHyperlinksCache(): void {
+ cachedSupport = undefined;
+}
+
+function detectSupportsHyperlinks(): boolean {
+ const env = process.env;
+ if (env['NO_COLOR'] !== undefined && env['NO_COLOR'] !== '') return false;
+ if (env['FORCE_COLOR'] === '0' || env['FORCE_COLOR'] === 'false') {
+ return false;
+ }
+ if (env['QWEN_DISABLE_HYPERLINKS'] === '1') return false;
+
+ // `FORCE_HYPERLINK=1` is the canonical override used by supports-hyperlinks.
+ if (
+ env['FORCE_HYPERLINK'] !== undefined &&
+ env['FORCE_HYPERLINK'] !== '0' &&
+ env['FORCE_HYPERLINK'] !== 'false'
+ ) {
+ return true;
+ }
+
+ // CI is detected as non-interactive; opt out by default to keep build logs
+ // clean of escape sequences when piped to capture systems.
+ if (env['CI']) return false;
+
+ // stdout must be a real TTY; piping to a file/process must not embed escapes.
+ const stdout = process.stdout as NodeJS.WriteStream | undefined;
+ if (!stdout || !stdout.isTTY) return false;
+
+ // Trust common modern terminals via their advertised env vars.
+ const termProgram = env['TERM_PROGRAM'];
+ if (
+ termProgram === 'iTerm.app' ||
+ termProgram === 'WezTerm' ||
+ termProgram === 'vscode' ||
+ termProgram === 'ghostty' ||
+ termProgram === 'Hyper' ||
+ termProgram === 'Tabby' ||
+ termProgram === 'mintty'
+ ) {
+ return true;
+ }
+ if (env['WT_SESSION']) return true; // Windows Terminal
+ if (env['KITTY_WINDOW_ID']) return true; // Kitty
+ if (env['VTE_VERSION']) return true; // GNOME Terminal, Tilix, …
+ if (env['DOMTERM']) return true;
+ if (env['JEDITERM_SOURCE_ARGS'] !== undefined) return true; // JetBrains
+ if (env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm') return true;
+
+ if (env['TERM'] === 'xterm-kitty') return true;
+ if (env['TERM']?.startsWith('alacritty')) return true;
+ if (env['COLORTERM'] === 'truecolor' && env['TERM']?.startsWith('xterm')) {
+ // Heuristic for modern xterm-compatible emulators that don't advertise
+ // themselves via TERM_PROGRAM. Conservative — only when truecolor too.
+ return true;
+ }
+
+ return false;
+}
From f0151111238fe4a0ed17f13b323cce52032bb4c5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 11:28:15 +0800
Subject: [PATCH 02/14] fix(cli): harden OSC 8 markdown wrapping after
multi-round audit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address findings from a multi-round design and code audit of the OSC 8
hyperlink feature:
Design fixes:
- Keep the visible `(url)` suffix in supported terminals too — preserves
copy-paste UX and lets users preview suspicious URLs before clicking.
OSC 8 is now purely additive (byte-identical unsupported output, plus
envelope on supported terminals).
- Restrict OSC 8 wrapping to http/https/mailto/ftp/sftp/ssh schemes;
javascript:/data:/file:/vbscript: fall through unwrapped so the user
can read the target. Prompt-injection defense for LLM output.
- Reject URLs with whitespace — every terminal treats whitespace in an
OSC 8 target as truncation/rejection, which would turn the whole
region into an un-clickable trap.
- Block OSC 8 inside tmux/screen by default; require `FORCE_HYPERLINK=1`
opt-in. The multiplexer hides the host terminal's capabilities, so
emitting passthrough escapes on a host without OSC 8 prints garbage.
- Version-gate `supportsHyperlinks()` (iTerm ≥3.1, vscode ≥1.72, WezTerm
≥20200620, VTE ≥0.50 with 0.50.0 segfault carve-out), block CI /
TEAMCITY / win32 (modulo WT_SESSION/Kitty/Ghostty/DOMTERM), mirror
`supports-hyperlinks` semantics.
- Extend the link regex to allow one level of balanced parens in the
URL group so `[wiki](https://en.wikipedia.org/wiki/Foo_(bar))` isn't
truncated at the inner `)`.
- Trim trailing sentence punctuation off the OSC 8 *target* for bare
URLs (`.`, `,`, `;`, `:`, `!`, `?`, `'`, `"`, `` ` ``) and unbalanced
trailing `)]}` so the clickable URL resolves to a real page.
- Catch VTE 0.50.0 reported in packed form (`'5000'`) — the original
string compare missed it and let the segfault through.
Code fixes:
- Consolidate `wrapForMultiplexer` with the pre-existing
`packages/cli/src/utils/osc.ts` — no more duplicate helpers.
- Drop the `supportsHyperlinks` memoization cache so runtime env changes
(NO_COLOR / theme toggles) take effect immediately.
- Extract `MD_LINK_PATTERN`, `MD_LINK_CAPTURE`, `shouldWrapMarkdownLink`,
and `HYPERLINK_ENV_KEYS` into `osc8.ts` so the React and ANSI
renderers stay in lockstep.
- Hoist `supportsHyperlinks()` once per render (both renderers).
- Apply the same OSC 8 treatment to `TableRenderer` so markdown links
inside tables are clickable too.
- Rewrite `trimTrailingUrlPunctuation` to O(n) by pre-counting opens.
Tests cover: balanced parens in URL, dangerous-scheme rejection,
whitespace-URL rejection, trailing-punctuation trimming, tmux blocking,
version gating (iTerm/WezTerm/vscode/VTE incl. packed form), platform
fallbacks, mid-stream chunk balance, byte-identical legacy fallback.
---
.../ui/utils/InlineMarkdownRenderer.test.tsx | 203 +++++++---
.../src/ui/utils/InlineMarkdownRenderer.tsx | 73 ++--
packages/cli/src/ui/utils/TableRenderer.tsx | 44 ++-
packages/cli/src/ui/utils/osc8.test.ts | 277 ++++++++++----
packages/cli/src/ui/utils/osc8.ts | 346 ++++++++++++++----
5 files changed, 710 insertions(+), 233 deletions(-)
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index 8cfc14e4f75..f611c0911f5 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -7,9 +7,39 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { RenderInline } from './InlineMarkdownRenderer.js';
-import { resetSupportsHyperlinksCache } from './osc8.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(
,
@@ -36,72 +66,53 @@ describe('', () => {
});
describe('markdown link OSC 8 wrapping', () => {
- const savedEnv = { ...process.env };
- const savedIsTTY = process.stdout.isTTY;
-
- beforeEach(() => {
- resetSupportsHyperlinksCache();
- });
-
- afterEach(() => {
- process.env = { ...savedEnv };
- Object.defineProperty(process.stdout, 'isTTY', {
- configurable: true,
- value: savedIsTTY,
- });
- resetSupportsHyperlinksCache();
- });
-
- function setEnvForHyperlinkSupport(supported: boolean) {
- for (const key of [
- 'NO_COLOR',
- 'FORCE_COLOR',
- 'CI',
- 'TMUX',
- 'STY',
- 'TERM_PROGRAM',
- 'WT_SESSION',
- 'KITTY_WINDOW_ID',
- 'VTE_VERSION',
- 'DOMTERM',
- 'JEDITERM_SOURCE_ARGS',
- 'TERMINAL_EMULATOR',
- 'COLORTERM',
- 'TERM',
- 'FORCE_HYPERLINK',
- 'QWEN_DISABLE_HYPERLINKS',
- ]) {
- delete process.env[key];
- }
+ function enableHyperlinks() {
Object.defineProperty(process.stdout, 'isTTY', {
configurable: true,
- value: supported,
+ value: true,
});
- if (supported) {
- process.env['TERM_PROGRAM'] = 'iTerm.app';
- } else {
- process.env['NO_COLOR'] = '1';
- }
+ process.env['TERM_PROGRAM'] = 'iTerm.app';
+ process.env['TERM_PROGRAM_VERSION'] = '3.5.0';
}
- it('emits an OSC 8 envelope around the label when supported', () => {
- setEnvForHyperlinkSupport(true);
+ it('wraps a safe http(s) URL in an OSC 8 envelope and keeps the visible (url) suffix', () => {
+ enableHyperlinks();
const url = 'https://very.long.example.com/path/to/thing?with=params';
const { lastFrame } = renderWithProviders(
,
);
const out = lastFrame() ?? '';
+ // Envelope is present and frames the visible region.
expect(out).toContain(`\x1b]8;;${url}\x07`);
- expect(out).toContain('here');
expect(out).toContain('\x1b]8;;\x07');
- // The legacy `(url)` suffix should not be shown when the terminal
- // supports OSC 8 — the clickable label is the visible affordance.
- expect(out).not.toContain(`(${url})`);
+ // Visible bytes are unchanged from legacy rendering — both label and
+ // the parenthesized URL remain on screen for copy-paste fallback.
+ expect(out).toContain('here');
+ 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 legacy "label (url)" rendering when unsupported', () => {
- setEnvForHyperlinkSupport(false);
+ 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(
,
@@ -114,7 +125,7 @@ describe('', () => {
});
it('wraps bare URLs in an OSC 8 envelope when supported', () => {
- setEnvForHyperlinkSupport(true);
+ enableHyperlinks();
const url = 'https://example.com/very/long/url';
const { lastFrame } = renderWithProviders(
,
@@ -126,8 +137,22 @@ describe('', () => {
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', () => {
- setEnvForHyperlinkSupport(false);
const url = 'https://example.com/plain';
const { lastFrame } = renderWithProviders(
,
@@ -137,5 +162,75 @@ describe('', () => {
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 `)`.
+ expect(out).toContain(`\x1b]8;;${url}\x07`);
+ // Visible bytes show the full URL too — no truncation at the inner `)`.
+ expect(out).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('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: exactly one envelope, pointing at the URL.
+ expect(out).toContain(`\x1b]8;;${url}\x07`);
+ expect(out).toContain(`(${url})`);
+ });
});
});
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
index 85c9390c870..e7e5ce5a50b 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
@@ -10,7 +10,16 @@ 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 { osc8Close, osc8Open, supportsHyperlinks } from './osc8.js';
+import {
+ MD_LINK_CAPTURE,
+ MD_LINK_PATTERN,
+ isSafeOscScheme,
+ osc8Close,
+ osc8Open,
+ shouldWrapMarkdownLink,
+ supportsHyperlinks,
+ trimTrailingUrlPunctuation,
+} from './osc8.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
@@ -25,10 +34,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',
);
@@ -56,6 +68,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;
@@ -135,26 +150,24 @@ 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];
- // When the host terminal supports OSC 8, wrap just the label so the
- // entire visible region is one clickable target even if the URL is
- // long enough to wrap across lines. The visible URL suffix is
- // dropped because clicking the label resolves to the same target.
- // Otherwise fall back to the legacy `label (url)` rendering so
- // output remains byte-identical for terminals without support.
- renderedNode = supportsHyperlinks() ? (
-
- {osc8Open(url)}
- {linkText}
- {osc8Close()}
-
- ) : (
+ const linkText = linkMatch[1] ?? '';
+ const url = linkMatch[2] ?? '';
+ // The visible bytes (`label (url)`) stay identical to the legacy
+ // rendering so unsupported terminals see exactly today's output
+ // and copy-paste of the URL still works on supported terminals.
+ // OSC 8 wrapping is purely additive — gated on the shared
+ // `shouldWrapMarkdownLink` predicate (capability + safe scheme +
+ // no whitespace) so the React renderer and the ANSI table
+ // renderer stay in lockstep.
+ const wrapOsc8 = shouldWrapMarkdownLink(url, canHyperlink);
+ renderedNode = (
+ {wrapOsc8 ? osc8Open(url) : null}
{linkText}
({url})
+ {wrapOsc8 ? osc8Close() : null}
);
}
@@ -189,15 +202,21 @@ const RenderInlineInternal: React.FC = ({
);
} else if (fullMatch.match(/^https?:\/\//)) {
- renderedNode = supportsHyperlinks() ? (
-
- {osc8Open(fullMatch)}
- {fullMatch}
- {osc8Close()}
-
- ) : (
+ // 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.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
index 1ad5323f0a1..0bb2612b5b9 100644
--- a/packages/cli/src/ui/utils/TableRenderer.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -11,6 +11,16 @@ 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,
+ osc8Close,
+ osc8Open,
+ shouldWrapMarkdownLink,
+ supportsHyperlinks,
+ trimTrailingUrlPunctuation,
+} from './osc8.js';
/** Minimum column width to prevent degenerate layouts */
const MIN_COLUMN_WIDTH = 3;
@@ -31,10 +41,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 +238,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 +291,16 @@ 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] ?? '';
+ const visible = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`;
+ // Same gating as the React renderer — `shouldWrapMarkdownLink`
+ // centralizes the predicate so both renderers stay in sync.
+ rendered = shouldWrapMarkdownLink(url, canHyperlink)
+ ? `${osc8Open(url)}${visible}${osc8Close()}`
+ : visible;
}
} else if (
enableInlineMath &&
@@ -295,7 +319,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
index 34ef63df642..9d3bca499a7 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -6,24 +6,40 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
+ HYPERLINK_ENV_KEYS,
+ isSafeOscScheme,
osc8Close,
osc8Hyperlink,
osc8Open,
- resetSupportsHyperlinksCache,
sanitizeForOsc,
supportsHyperlinks,
- wrapForMultiplexer,
+ 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(() => {
- resetSupportsHyperlinksCache();
+ // 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,
+ });
});
afterEach(() => {
@@ -32,7 +48,10 @@ describe('osc8 helpers', () => {
configurable: true,
value: savedIsTTY,
});
- resetSupportsHyperlinksCache();
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: savedPlatform,
+ });
});
describe('sanitizeForOsc', () => {
@@ -63,17 +82,11 @@ describe('osc8 helpers', () => {
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`);
- // The only ESC bytes in the output must be the two that introduce the
- // OSC 8 open and close sequences — no user-supplied ESC leaks through.
+ // Exactly two ESC and two BEL bytes — the envelope's own terminators.
// eslint-disable-next-line no-control-regex
- const escCount = (out.match(/\x1b/g) ?? []).length;
- expect(escCount).toBe(2);
- // Same idea for BEL — exactly two terminators, no extras from the
- // sanitized user input.
+ expect((out.match(/\x1b/g) ?? []).length).toBe(2);
// eslint-disable-next-line no-control-regex
- const belCount = (out.match(/\x07/g) ?? []).length;
- expect(belCount).toBe(2);
- // After sanitization the surviving textual fragments stay inline.
+ expect((out.match(/\x07/g) ?? []).length).toBe(2);
expect(out).toContain('https://example.com]8;;evil');
expect(out).toContain('lbl[31m');
});
@@ -85,29 +98,65 @@ describe('osc8 helpers', () => {
});
});
- describe('wrapForMultiplexer', () => {
- it('returns the OSC unchanged when not inside tmux or screen', () => {
- delete process.env['TMUX'];
- delete process.env['STY'];
- const seq = `${ESC}]8;;https://x${BEL}`;
- expect(wrapForMultiplexer(seq)).toBe(seq);
+ 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('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('wraps in a tmux DCS passthrough envelope with doubled ESCs', () => {
- process.env['TMUX'] = '/tmp/tmux-1000/default,1234,0';
- delete process.env['STY'];
- const seq = `${ESC}]8;;https://x${BEL}`;
- expect(wrapForMultiplexer(seq)).toBe(
- `${ESC}Ptmux;${ESC}${ESC}]8;;https://x${BEL}${ESC}\\`,
+ 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 an unbalanced trailing close-paren', () => {
+ expect(trimTrailingUrlPunctuation('https://example.com/x)')).toBe(
+ 'https://example.com/x',
);
});
- it('wraps in a plain screen DCS envelope', () => {
- delete process.env['TMUX'];
- process.env['STY'] = '1234.host';
- const seq = `${ESC}]8;;https://x${BEL}`;
- expect(wrapForMultiplexer(seq)).toBe(
- `${ESC}P${ESC}]8;;https://x${BEL}${ESC}\\`,
+ it('returns the input unchanged when there is no trailing punctuation', () => {
+ expect(trimTrailingUrlPunctuation('https://example.com/x')).toBe(
+ 'https://example.com/x',
);
});
});
@@ -119,95 +168,189 @@ describe('osc8 helpers', () => {
value,
});
}
-
- function clearTerminalHints() {
- for (const key of [
- 'TERM_PROGRAM',
- 'WT_SESSION',
- 'KITTY_WINDOW_ID',
- 'VTE_VERSION',
- 'DOMTERM',
- 'JEDITERM_SOURCE_ARGS',
- 'TERMINAL_EMULATOR',
- 'COLORTERM',
- 'TERM',
- 'FORCE_HYPERLINK',
- 'FORCE_COLOR',
- 'NO_COLOR',
- 'CI',
- 'TMUX',
- 'STY',
- 'QWEN_DISABLE_HYPERLINKS',
- ]) {
- delete process.env[key];
- }
+ function setPlatform(value: NodeJS.Platform) {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value,
+ });
}
it('returns false when stdout is not a TTY', () => {
- clearTerminalHints();
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', () => {
- clearTerminalHints();
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', () => {
- clearTerminalHints();
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 by default', () => {
- clearTerminalHints();
+ 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 true for iTerm2 on a TTY', () => {
- clearTerminalHints();
+ 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 true for Windows Terminal via WT_SESSION', () => {
- clearTerminalHints();
+ 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);
});
- it('honors FORCE_HYPERLINK=1 even without TTY heuristics matching', () => {
- clearTerminalHints();
+ 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('honors FORCE_HYPERLINK=1 even inside tmux and even when isTTY=false', () => {
+ setTTY(false);
+ process.env['TMUX'] = '/tmp/x,1,0';
process.env['FORCE_HYPERLINK'] = '1';
expect(supportsHyperlinks()).toBe(true);
});
- it('respects QWEN_DISABLE_HYPERLINKS=1 as a hard opt-out', () => {
- clearTerminalHints();
+ 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', () => {
- clearTerminalHints();
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
index 1a4da5003a9..ca6c576c1c6 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -7,28 +7,15 @@
/**
* OSC 8 hyperlink helpers.
*
- * Supported terminals (iTerm2, WezTerm, Kitty, Windows Terminal, VS Code,
- * GNOME Terminal/VTE, Alacritty ≥ 0.11, Ghostty, …) 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.
+ * 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.
*/
-/**
- * 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`.
- */
-export 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;
-}
+import { wrapForMultiplexer } from '../../utils/osc.js';
+export { wrapForMultiplexer } from '../../utils/osc.js';
/**
* Strip C0 control characters and DEL so an untrusted string can be safely
@@ -43,9 +30,7 @@ export function sanitizeForOsc(s: string): string {
/**
* Wrap a URL in an OSC 8 hyperlink escape sequence. BEL (\x07) terminates
- * the OSC — more broadly supported than ST (ESC \\). Inside tmux / screen
- * the sequence is wrapped in a DCS passthrough envelope so the multiplexer
- * forwards it to the host terminal instead of eating it.
+ * the OSC — more broadly supported than ST (ESC \\).
*/
export function osc8Hyperlink(url: string, label = url): string {
const safeUrl = sanitizeForOsc(url);
@@ -55,9 +40,9 @@ export function osc8Hyperlink(url: string, label = url): string {
/**
* Open half of an OSC 8 hyperlink envelope. Pair with `osc8Close()` to wrap
- * a styled label (e.g. an `` `` element) 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.
+ * 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`);
@@ -69,79 +54,282 @@ export function osc8Close(): string {
}
/**
- * Cheap, dependency-free OSC 8 capability detection. Mirrors the env-var
- * checks used by the `supports-hyperlinks` npm package without adding a
- * direct dependency. The result is memoized after the first call because
- * env vars don't change over the lifetime of a CLI session.
- *
- * Honors `NO_COLOR` and `FORCE_COLOR=0` and bails on non-TTY stdout so
- * piping to a file or another process doesn't embed escape bytes.
+ * 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 rendering so
+ * the user sees the suspicious URL before any click.
+ */
+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.
*/
-let cachedSupport: boolean | undefined;
+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());
+}
-export function supportsHyperlinks(): boolean {
- if (cachedSupport !== undefined) return cachedSupport;
- cachedSupport = detectSupportsHyperlinks();
- return cachedSupport;
+interface ParsedVersion {
+ major: number;
+ minor: number;
+ patch: number;
}
-/** Reset the cached capability check. Intended for tests only. */
-export function resetSupportsHyperlinksCache(): void {
- cachedSupport = undefined;
+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 };
}
-function detectSupportsHyperlinks(): boolean {
+/**
+ * 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;
}
- if (env['QWEN_DISABLE_HYPERLINKS'] === '1') return false;
- // `FORCE_HYPERLINK=1` is the canonical override used by supports-hyperlinks.
- if (
- env['FORCE_HYPERLINK'] !== undefined &&
- env['FORCE_HYPERLINK'] !== '0' &&
- env['FORCE_HYPERLINK'] !== 'false'
- ) {
- return true;
+ // Explicit force overrides every heuristic below — but not the opt-outs
+ // above. 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;
}
- // CI is detected as non-interactive; opt out by default to keep build logs
- // clean of escape sequences when piped to capture systems.
+ // Embedded escapes must never end up in a file or another process.
+ if (!stream || !stream.isTTY) return false;
+
if (env['CI']) return false;
+ if (env['TEAMCITY_VERSION']) return false;
- // stdout must be a real TTY; piping to a file/process must not embed escapes.
- const stdout = process.stdout as NodeJS.WriteStream | undefined;
- if (!stdout || !stdout.isTTY) return false;
-
- // Trust common modern terminals via their advertised env vars.
- const termProgram = env['TERM_PROGRAM'];
- if (
- termProgram === 'iTerm.app' ||
- termProgram === 'WezTerm' ||
- termProgram === 'vscode' ||
- termProgram === 'ghostty' ||
- termProgram === 'Hyper' ||
- termProgram === 'Tabby' ||
- termProgram === 'mintty'
- ) {
- return true;
- }
+ // 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']) return true; // Kitty
- if (env['VTE_VERSION']) return true; // GNOME Terminal, Tilix, …
+ if (env['KITTY_WINDOW_ID'] || env['TERM'] === 'xterm-kitty') return true;
if (env['DOMTERM']) return true;
- if (env['JEDITERM_SOURCE_ARGS'] !== undefined) return true; // JetBrains
- if (env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm') return true;
-
- if (env['TERM'] === 'xterm-kitty') return true;
- if (env['TERM']?.startsWith('alacritty')) return true;
- if (env['COLORTERM'] === 'truecolor' && env['TERM']?.startsWith('xterm')) {
- // Heuristic for modern xterm-compatible emulators that don't advertise
- // themselves via TERM_PROGRAM. Conservative — only when truecolor too.
+ if (env['GHOSTTY_RESOURCES_DIR'] || env['TERM'] === 'xterm-ghostty') {
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;
+ // Hyper historically advertised OSC 8 but rendered it inconsistently;
+ // require an explicit FORCE_HYPERLINK opt-in.
+ 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);
+ // .,;:!?'"`
+ if (
+ c === 0x2e ||
+ c === 0x2c ||
+ c === 0x3b ||
+ c === 0x3a ||
+ c === 0x21 ||
+ c === 0x3f ||
+ c === 0x27 ||
+ c === 0x22 ||
+ c === 0x60
+ ) {
+ 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);
+}
+
+// ── 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',
+ 'TERM',
+ 'TEAMCITY_VERSION',
+ 'FORCE_HYPERLINK',
+ 'QWEN_DISABLE_HYPERLINKS',
+] as const;
From fbfef7446faaba90ba743cb57bb0a7559616dcb6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 11:52:42 +0800
Subject: [PATCH 03/14] feat(cli): detect Alacritty / Konsole / Warp /
JetBrains / mintty for OSC 8
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expand supportsHyperlinks() to recognize five more capable terminals
that the original detector silently treated as unsupported:
- Alacritty ≥ 0.11 via TERM=alacritty (the issue explicitly calls this
one out)
- Konsole ≥ 21.04 via KONSOLE_VERSION
- WarpTerminal via TERM_PROGRAM=WarpTerminal
- JetBrains JediTerm (IDE integrated terminals) via TERMINAL_EMULATOR
- mintty (Git Bash on Windows, etc.) via TERM_PROGRAM=mintty
Hyper stays auto-detection-off (FORCE_HYPERLINK=1 override) because
plugin chains have a long history of breaking escape passthrough.
Apple_Terminal stays off because it has no OSC 8 support at all.
KONSOLE_VERSION and TERMINAL_EMULATOR added to HYPERLINK_ENV_KEYS so
the test isolation list stays in sync.
---
packages/cli/src/ui/utils/osc8.test.ts | 47 ++++++++++++++++++++++++++
packages/cli/src/ui/utils/osc8.ts | 22 ++++++++++--
2 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 9d3bca499a7..7f32420bcea 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -311,6 +311,53 @@ describe('osc8 helpers', () => {
expect(supportsHyperlinks()).toBe(true);
});
+ it('Konsole ≥ 21.04 is enabled via KONSOLE_VERSION', () => {
+ setTTY(true);
+ process.env['KONSOLE_VERSION'] = '230400';
+ expect(supportsHyperlinks()).toBe(true);
+ });
+
+ it('Alacritty is enabled via TERM=alacritty', () => {
+ setTTY(true);
+ process.env['TERM'] = 'alacritty';
+ 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 enabled via TERM_PROGRAM=WarpTerminal', () => {
+ setTTY(true);
+ process.env['TERM_PROGRAM'] = 'WarpTerminal';
+ // Warp's TERM_PROGRAM_VERSION is set but not consulted — Warp has
+ // supported OSC 8 since launch.
+ 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 even inside tmux and even when isTTY=false', () => {
setTTY(false);
process.env['TMUX'] = '/tmp/x,1,0';
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index ca6c576c1c6..82d09c7725c 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -161,6 +161,14 @@ export function supportsHyperlinks(
if (env['GHOSTTY_RESOURCES_DIR'] || env['TERM'] === 'xterm-ghostty') {
return true;
}
+ // Konsole ≥ 21.04 ships OSC 8 and sets KONSOLE_VERSION on every session.
+ if (env['KONSOLE_VERSION']) return true;
+ // Alacritty ≥ 0.11 supports OSC 8. It doesn't set a distinctive env var,
+ // so identify it through the terminfo entry instead.
+ if (env['TERM'] === 'alacritty') 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']);
@@ -176,8 +184,16 @@ export function supportsHyperlinks(
);
case 'ghostty':
return true;
- // Hyper historically advertised OSC 8 but rendered it inconsistently;
- // require an explicit FORCE_HYPERLINK opt-in.
+ case 'WarpTerminal':
+ // Warp has supported OSC 8 since its public launch.
+ 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;
+ // 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;
}
@@ -328,6 +344,8 @@ export const HYPERLINK_ENV_KEYS = [
'VTE_VERSION',
'DOMTERM',
'GHOSTTY_RESOURCES_DIR',
+ 'KONSOLE_VERSION',
+ 'TERMINAL_EMULATOR',
'TERM',
'TEAMCITY_VERSION',
'FORCE_HYPERLINK',
From 3ec9a498dae4e6568957588e454ee6c3d6e45a0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 12:43:55 +0800
Subject: [PATCH 04/14] chore(cli): polish OSC 8 detector after another audit
round
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address findings from the final multi-round audit pass:
- Document `FORCE_HYPERLINK` and `QWEN_DISABLE_HYPERLINKS` in the
user-facing env-vars table at docs/users/configuration/settings.md so
the new opt-in / opt-out surface is discoverable without grepping
source.
- Detect Alacritty even when the alacritty terminfo entry isn't
installed (a common Linux distro scenario where Alacritty falls back
to TERM=xterm-256color). Fall back to ALACRITTY_LOG /
ALACRITTY_WINDOW_ID / ALACRITTY_SOCKET — Alacritty sets at least one
of these unconditionally since 0.12.
- Trim a trailing `>` off the OSC 8 target so CommonMark autolinks
(``) produce a clickable target that actually
resolves instead of 404-ing because of the captured delimiter.
- Add OSC 8 / hyperlink env isolation to TableRenderer.test.tsx so a
developer running the suite from iTerm2 / WezTerm / Kitty can't leak
escape bytes into table output.
- Symmetric `isTTY` reset in osc8.test.ts `beforeEach` so the early
describes (sanitizer, scheme, trim) don't inherit residual TTY state
from a prior test.
- Document the deliberate security property of keeping the visible
`(url)` suffix in OSC 8 mode (user always reads the destination
before clicking) in the SAFE_OSC8_SCHEMES comment.
- Collapse the `wrapForMultiplexer` import + re-export to a single
`export { wrapForMultiplexer }` after the local import.
- Add ALACRITTY_* keys to HYPERLINK_ENV_KEYS so test isolation lists
stay complete.
Tests cover the new autolink `>` trim, the Alacritty env-var
fallbacks, and NBSP / Unicode-whitespace URL rejection.
---
docs/users/configuration/settings.md | 2 ++
.../ui/utils/InlineMarkdownRenderer.test.tsx | 26 ++++++++++++++
.../cli/src/ui/utils/TableRenderer.test.tsx | 29 ++++++++++++++-
packages/cli/src/ui/utils/osc8.test.ts | 28 +++++++++++++++
packages/cli/src/ui/utils/osc8.ts | 36 +++++++++++++++----
5 files changed, 114 insertions(+), 7 deletions(-)
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/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index f611c0911f5..2c124f2a10d 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -201,6 +201,32 @@ describe('', () => {
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
diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx
index f2514d98463..13c82504643 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[][],
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 7f32420bcea..3e53d16d109 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -40,6 +40,13 @@ describe('osc8 helpers', () => {
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(() => {
@@ -148,6 +155,12 @@ describe('osc8 helpers', () => {
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',
@@ -323,6 +336,21 @@ describe('osc8 helpers', () => {
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';
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 82d09c7725c..8ce3789bf85 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -15,7 +15,9 @@
*/
import { wrapForMultiplexer } from '../../utils/osc.js';
-export { 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 control characters and DEL so an untrusted string can be safely
@@ -59,6 +61,12 @@ export function osc8Close(): string {
* `javascript:` / `data:` / `file:` trap whose target is hidden behind the
* link label. Anything outside this set falls back to legacy rendering so
* the user sees the suspicious URL before any click.
+ *
+ * Note: the renderer keeps the visible `(url)` suffix on every link, even
+ * on capable terminals — this is a deliberate security property. The user
+ * can always read the destination before clicking, so even an allowlisted
+ * `https://attacker.example.com` link that masquerades as something else
+ * is still visible in the rendered output.
*/
const SAFE_OSC8_SCHEMES = new Set([
'http:',
@@ -163,9 +171,20 @@ export function supportsHyperlinks(
}
// Konsole ≥ 21.04 ships OSC 8 and sets KONSOLE_VERSION on every session.
if (env['KONSOLE_VERSION']) return true;
- // Alacritty ≥ 0.11 supports OSC 8. It doesn't set a distinctive env var,
- // so identify it through the terminfo entry instead.
- if (env['TERM'] === 'alacritty') 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;
@@ -254,7 +273,8 @@ export function trimTrailingUrlPunctuation(url: string): string {
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 ||
@@ -264,7 +284,8 @@ export function trimTrailingUrlPunctuation(url: string): string {
c === 0x3f ||
c === 0x27 ||
c === 0x22 ||
- c === 0x60
+ c === 0x60 ||
+ c === 0x3e
) {
end--;
continue;
@@ -346,6 +367,9 @@ export const HYPERLINK_ENV_KEYS = [
'GHOSTTY_RESOURCES_DIR',
'KONSOLE_VERSION',
'TERMINAL_EMULATOR',
+ 'ALACRITTY_LOG',
+ 'ALACRITTY_WINDOW_ID',
+ 'ALACRITTY_SOCKET',
'TERM',
'TEAMCITY_VERSION',
'FORCE_HYPERLINK',
From 9b207484d02136d171552ad15ea8f486566cdbad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 14:03:03 +0800
Subject: [PATCH 05/14] fix(cli): tighten OSC 8 gating per PR review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two fixes from chiga0's review on PR #4037:
1. Move the non-TTY check above `FORCE_HYPERLINK` so a user with
`FORCE_HYPERLINK=1` in their shell profile still gets a clean pipe
when they run `qwen | cat` or `qwen > out.txt`. The "non-TTY stdout
must suppress escapes" acceptance criterion now holds even under
forced enable.
2. Version-gate the Konsole detection at `>= 21.04`. KONSOLE_VERSION
is set by every Konsole release including ones that pre-date OSC 8
support, so the existence check alone false-positives on Konsole
20.x. Parse the packed integer (21.04 → 210400) and let older
releases fall through to the legacy fallback.
Updates the docs row for FORCE_HYPERLINK to make the non-TTY caveat
explicit. Splits the prior "FORCE_HYPERLINK + isTTY=false" test into
two — one verifying force works on a TTY, one asserting it never
escapes the non-TTY guard. Adds a Konsole < 21.04 regression test.
---
packages/cli/src/ui/utils/osc8.test.ts | 26 ++++++++++++++++++++++--
packages/cli/src/ui/utils/osc8.ts | 28 +++++++++++++++++++-------
2 files changed, 45 insertions(+), 9 deletions(-)
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 3e53d16d109..fa8a0610e0f 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -328,6 +328,19 @@ describe('osc8 helpers', () => {
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', () => {
@@ -386,13 +399,22 @@ describe('osc8 helpers', () => {
expect(supportsHyperlinks()).toBe(false);
});
- it('honors FORCE_HYPERLINK=1 even inside tmux and even when isTTY=false', () => {
- setTTY(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';
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 8ce3789bf85..cbf365bf175 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -141,18 +141,23 @@ export function supportsHyperlinks(
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. Mirrors the `FORCE_HYPERLINK` contract from supports-hyperlinks:
- // any non-zero numeric value (or empty string) enables, `0` disables.
+ // 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;
}
- // Embedded escapes must never end up in a file or another process.
- if (!stream || !stream.isTTY) return false;
-
if (env['CI']) return false;
if (env['TEAMCITY_VERSION']) return false;
@@ -169,8 +174,17 @@ export function supportsHyperlinks(
if (env['GHOSTTY_RESOURCES_DIR'] || env['TERM'] === 'xterm-ghostty') {
return true;
}
- // Konsole ≥ 21.04 ships OSC 8 and sets KONSOLE_VERSION on every session.
- if (env['KONSOLE_VERSION']) 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.
From e76c41179974d2055091c44de3c80ae3275fdb03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 14:58:47 +0800
Subject: [PATCH 06/14] fix(cli): stop auto-detecting Warp Terminal as OSC 8
capable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Warp's current rendering engine doesn't honor OSC 8 envelopes — the
escape sequence is printed as visible garbage rather than recognized
as a clickable hyperlink. Falling through to the legacy `label (url)`
rendering avoids the regression on Warp.
Users on a Warp build that ever ships OSC 8 support can opt in with
`FORCE_HYPERLINK=1`; the case will be reinstated in the switch when
Warp lands real support upstream.
Test flipped from "enabled" to "not auto-detected, FORCE_HYPERLINK
opts in" to lock the new behavior.
---
packages/cli/src/ui/utils/osc8.test.ts | 10 +++++++---
packages/cli/src/ui/utils/osc8.ts | 8 +++++---
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index fa8a0610e0f..8c4303b01be 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -370,11 +370,15 @@ describe('osc8 helpers', () => {
expect(supportsHyperlinks()).toBe(true);
});
- it('Warp Terminal is enabled via TERM_PROGRAM=WarpTerminal', () => {
+ 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';
- // Warp's TERM_PROGRAM_VERSION is set but not consulted — Warp has
- // supported OSC 8 since launch.
+ expect(supportsHyperlinks()).toBe(false);
+ process.env['FORCE_HYPERLINK'] = '1';
expect(supportsHyperlinks()).toBe(true);
});
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index cbf365bf175..483c7a73e7e 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -217,13 +217,15 @@ export function supportsHyperlinks(
);
case 'ghostty':
return true;
- case 'WarpTerminal':
- // Warp has supported OSC 8 since its public launch.
- 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.
From e3d63778113139615fb48dcdf2c9b0f3da6d69da Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Mon, 11 May 2026 15:13:31 +0800
Subject: [PATCH 07/14] feat(cli): drop visible (url) suffix when OSC 8
wrapping is active
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In the originally shipped renderer, `[label](url)` was rendered as
`label (url)` even when OSC 8 wrapped the region. With long URLs that's
clutter for no benefit — capable terminals already expose the target
via hover / status bar / right-click "copy link" without needing the
URL in the visible stream.
When `shouldWrapMarkdownLink(url, canHyperlink)` returns true, the
React renderer and the ANSI table renderer now emit only the markdown
label (link-colored), with the OSC 8 envelope pointing at the full URL.
Empty labels (`[](url)`) fall back to using the URL as the visible
label so the link stays discoverable.
When the predicate returns false (unsupported terminal, unsafe scheme,
whitespace URL) the legacy `label (url)` rendering is preserved
byte-for-byte — the scheme allowlist still guarantees the user sees
the destination before any click on a `javascript:` / `data:` / etc.
link.
Tests updated to assert label-only visible bytes in wrap mode and an
empty-label fallback case added. Comment block in `osc8.ts` updated to
reflect the new visibility contract.
---
.../ui/utils/InlineMarkdownRenderer.test.tsx | 38 ++++++++++++++-----
.../src/ui/utils/InlineMarkdownRenderer.tsx | 26 ++++++++-----
packages/cli/src/ui/utils/TableRenderer.tsx | 18 ++++++---
packages/cli/src/ui/utils/osc8.ts | 15 ++++----
4 files changed, 64 insertions(+), 33 deletions(-)
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index 2c124f2a10d..17b9dcc598a 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -75,7 +75,7 @@ describe('', () => {
process.env['TERM_PROGRAM_VERSION'] = '3.5.0';
}
- it('wraps a safe http(s) URL in an OSC 8 envelope and keeps the visible (url) suffix', () => {
+ 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(
@@ -83,13 +83,26 @@ describe('', () => {
);
const out = lastFrame() ?? '';
- // Envelope is present and frames the visible region.
+ // Envelope is present, pointing at the URL.
expect(out).toContain(`\x1b]8;;${url}\x07`);
expect(out).toContain('\x1b]8;;\x07');
- // Visible bytes are unchanged from legacy rendering — both label and
- // the parenthesized URL remain on screen for copy-paste fallback.
+ // Visible label is rendered…
expect(out).toContain('here');
- expect(out).toContain(`(${url})`);
+ // …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:, …)', () => {
@@ -181,10 +194,13 @@ describe('', () => {
,
);
const out = lastFrame() ?? '';
- // Envelope target must be the full URL including the inner `)`.
+ // 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 show the full URL too — no truncation at the inner `)`.
- expect(out).toContain(`(${url})`);
+ // Visible bytes are just the label.
+ expect(out).toContain('wiki');
+ expect(out).not.toContain(`(${url})`);
});
it('does not wrap a URL that contains whitespace', () => {
@@ -254,9 +270,11 @@ describe('', () => {
,
);
const out = lastFrame() ?? '';
- // Final-state assertion: exactly one envelope, pointing at the URL.
+ // 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(`(${url})`);
+ expect(out).toContain('foo');
+ expect(out).not.toContain(`(${url})`);
});
});
});
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
index e7e5ce5a50b..3138cd04f5e 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
@@ -154,20 +154,26 @@ const RenderInlineInternal: React.FC = ({
if (linkMatch) {
const linkText = linkMatch[1] ?? '';
const url = linkMatch[2] ?? '';
- // The visible bytes (`label (url)`) stay identical to the legacy
- // rendering so unsupported terminals see exactly today's output
- // and copy-paste of the URL still works on supported terminals.
- // OSC 8 wrapping is purely additive — gated on the shared
- // `shouldWrapMarkdownLink` predicate (capability + safe scheme +
- // no whitespace) so the React renderer and the ANSI table
- // renderer stay in lockstep.
const wrapOsc8 = shouldWrapMarkdownLink(url, canHyperlink);
- renderedNode = (
+ // 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.
+ renderedNode = wrapOsc8 ? (
+
+ {osc8Open(url)}
+ {linkText || url}
+ {osc8Close()}
+
+ ) : (
- {wrapOsc8 ? osc8Open(url) : null}
{linkText}
({url})
- {wrapOsc8 ? osc8Close() : null}
);
}
diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
index 0bb2612b5b9..71a4a2b9646 100644
--- a/packages/cli/src/ui/utils/TableRenderer.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -295,12 +295,18 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string {
if (linkMatch) {
const labelText = linkMatch[1] ?? '';
const url = linkMatch[2] ?? '';
- const visible = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`;
- // Same gating as the React renderer — `shouldWrapMarkdownLink`
- // centralizes the predicate so both renderers stay in sync.
- rendered = shouldWrapMarkdownLink(url, canHyperlink)
- ? `${osc8Open(url)}${visible}${osc8Close()}`
- : visible;
+ // 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)) {
+ const visibleLabel = applyColor(labelText || url, theme.text.link);
+ rendered = `${osc8Open(url)}${visibleLabel}${osc8Close()}`;
+ } else {
+ rendered = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`;
+ }
}
} else if (
enableInlineMath &&
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 483c7a73e7e..b09a4d87949 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -59,14 +59,15 @@ export function osc8Close(): string {
* 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 rendering so
- * the user sees the suspicious URL before any click.
+ * link label. Anything outside this set falls back to legacy `label (url)`
+ * rendering so the user sees the suspicious URL before any click.
*
- * Note: the renderer keeps the visible `(url)` suffix on every link, even
- * on capable terminals — this is a deliberate security property. The user
- * can always read the destination before clicking, so even an allowlisted
- * `https://attacker.example.com` link that masquerades as something else
- * is still visible in the rendered output.
+ * 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:',
From 431a3174f85c4163a65be3056372bd0a1efb8176 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Tue, 12 May 2026 16:24:02 +0800
Subject: [PATCH 08/14] fix(cli): strip C1 controls in OSC 8 sanitizer
sanitizeForOsc() only removed C0 + DEL, so 8-bit ST (\x9c) and 8-bit
OSC (\x9d) bytes could still survive inside an OSC 8 target. On
terminals that honor C1 controls, those bytes act as the same sequence
boundaries as their two-byte ESC counterparts, which defeats the
escape-injection hardening this helper is meant to provide. Extend
the regex to also strip \x80-\x9f and cover the case with a test.
---
packages/cli/src/ui/utils/osc8.test.ts | 7 +++++++
packages/cli/src/ui/utils/osc8.ts | 8 +++++---
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 8c4303b01be..5ed84ccad26 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -66,6 +66,13 @@ describe('osc8 helpers', () => {
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('keeps printable ASCII and unicode intact', () => {
expect(sanitizeForOsc('https://example.com/路径?q=v')).toBe(
'https://example.com/路径?q=v',
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index b09a4d87949..0f1e972394d 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -20,14 +20,16 @@ import { wrapForMultiplexer } from '../../utils/osc.js';
export { wrapForMultiplexer };
/**
- * Strip C0 control characters and DEL so an untrusted string can be safely
+ * Strip C0 + DEL + C1 control characters 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.
+ * tail bytes to the terminal as interpretable escape codes. C1 bytes
+ * (`\x80-\x9f`) include the 8-bit ST and OSC introducers, which terminals
+ * that honor C1 controls treat the same as their two-byte ESC counterparts.
*/
export function sanitizeForOsc(s: string): string {
// eslint-disable-next-line no-control-regex
- return s.replace(/[\x00-\x1f\x7f]/g, '');
+ return s.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '');
}
/**
From 3a9afec72f9f4e6447b843c269392b62377e04c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Tue, 12 May 2026 16:36:34 +0800
Subject: [PATCH 09/14] fix(cli): harden OSC 8 link sanitization and tighten
gating
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three independent issues found while auditing the markdown OSC 8 path:
1. sanitizeForOsc() previously left Unicode bidi controls (U+200E/F,
U+202A-E, U+2066-9) and line/paragraph separators (U+2028/9) intact.
A model-emitted RLO in a link label visually reverses trailing bytes,
spoofing the host the user thinks they're clicking — exactly the
click-deception attack the scheme allowlist is meant to block, just
moved from the URL into the visible label. Extend the regex to strip
those bytes too.
2. The visible label rendered inside the OSC 8 envelope went straight
to the terminal without sanitization, so even with (1) the spoof
would still land. Wire sanitizeForOsc() over the linkText in both
InlineMarkdownRenderer and TableRenderer's OSC 8 branches. The
legacy `label (url)` branches stay untouched so today's
unsupported-terminal output remains byte-identical.
3. AuthenticateStep emitted osc8Hyperlink(authUrl) unconditionally,
leaking escape bytes into pipes / non-OSC-8 terminals — inconsistent
with the suppression contract documented for the rest of the PR.
Gate it on supportsHyperlinks() so it falls back to the bare URL.
Test coverage added:
- sanitizeForOsc bidi/line-separator strip
- bidi spoof in the rendered markdown label
- byte-equality fallback on unsupported terminals
- TableRenderer markdown link → OSC 8 (positive, fallback, unsafe
scheme, bidi-spoof) — the table renderer had zero OSC 8 coverage
before this.
---
.../components/mcp/steps/AuthenticateStep.tsx | 10 ++-
.../ui/utils/InlineMarkdownRenderer.test.tsx | 39 +++++++++++
.../src/ui/utils/InlineMarkdownRenderer.tsx | 10 ++-
.../cli/src/ui/utils/TableRenderer.test.tsx | 67 +++++++++++++++++++
packages/cli/src/ui/utils/TableRenderer.tsx | 7 +-
packages/cli/src/ui/utils/osc8.test.ts | 19 ++++++
packages/cli/src/ui/utils/osc8.ts | 32 ++++++---
7 files changed, 172 insertions(+), 12 deletions(-)
diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
index e978d2feed6..7dbf0b29df6 100644
--- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
+++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
@@ -18,7 +18,11 @@ 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, wrapForMultiplexer } from '../../../utils/osc8.js';
+import {
+ osc8Hyperlink,
+ supportsHyperlinks,
+ wrapForMultiplexer,
+} from '../../../utils/osc8.js';
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
@@ -248,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 17b9dcc598a..787f327aeed 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -276,5 +276,44 @@ describe('', () => {
expect(out).toContain('foo');
expect(out).not.toContain(`(${url})`);
});
+
+ 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('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 3138cd04f5e..96f469d7558 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
@@ -16,6 +16,7 @@ import {
isSafeOscScheme,
osc8Close,
osc8Open,
+ sanitizeForOsc,
shouldWrapMarkdownLink,
supportsHyperlinks,
trimTrailingUrlPunctuation,
@@ -164,10 +165,17 @@ const RenderInlineInternal: React.FC = ({
// 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 the visible label when OSC 8 is active. The
+ // legacy `label (url)` branch leaves the label intact so today's
+ // unsupported-terminal output stays byte-identical.
+ const safeLabel = wrapOsc8 ? sanitizeForOsc(linkText) : linkText;
renderedNode = wrapOsc8 ? (
{osc8Open(url)}
- {linkText || url}
+ {safeLabel || url}
{osc8Close()}
) : (
diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx
index 13c82504643..541804a58d3 100644
--- a/packages/cli/src/ui/utils/TableRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx
@@ -627,6 +627,73 @@ 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('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 71a4a2b9646..882e43bb65a 100644
--- a/packages/cli/src/ui/utils/TableRenderer.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -17,6 +17,7 @@ import {
isSafeOscScheme,
osc8Close,
osc8Open,
+ sanitizeForOsc,
shouldWrapMarkdownLink,
supportsHyperlinks,
trimTrailingUrlPunctuation,
@@ -302,7 +303,11 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string {
// byte-identical to today on unsupported terminals / unsafe
// schemes / whitespace URLs.
if (shouldWrapMarkdownLink(url, canHyperlink)) {
- const visibleLabel = applyColor(labelText || url, theme.text.link);
+ // Strip bidi / C0 / C1 from the visible label too, not just the
+ // OSC target — otherwise a model-emitted U+202E in the label
+ // would still spoof the rendered text inside the clickable region.
+ const safeLabel = sanitizeForOsc(labelText);
+ const visibleLabel = applyColor(safeLabel || url, theme.text.link);
rendered = `${osc8Open(url)}${visibleLabel}${osc8Close()}`;
} else {
rendered = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`;
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 5ed84ccad26..6eb65bb61a8 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -73,6 +73,25 @@ describe('osc8 helpers', () => {
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',
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 0f1e972394d..10a5cce1b88 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -20,16 +20,32 @@ import { wrapForMultiplexer } from '../../utils/osc.js';
export { wrapForMultiplexer };
/**
- * Strip C0 + DEL + C1 control characters 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. C1 bytes
- * (`\x80-\x9f`) include the 8-bit ST and OSC introducers, which terminals
- * that honor C1 controls treat the same as their two-byte ESC counterparts.
+ * 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 {
- // eslint-disable-next-line no-control-regex
- return s.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '');
+ return s.replace(
+ // eslint-disable-next-line no-control-regex
+ /[\x00-\x1f\x7f\x80-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g,
+ '',
+ );
}
/**
From 9ceeeaf0a8d53a0a942eecb156275b2c96e05bf0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Tue, 12 May 2026 16:42:39 +0800
Subject: [PATCH 10/14] fix(cli): keep `(url)` visible when an OSC 8 label
looks like a different URL
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adversarial round-2 audit identified a label-as-URL deception attack:
when the OSC 8 branch elides the `(url)` suffix and shows only the
clickable label, a model-emitted `[https://google.com](https://attacker.com)`
renders a "google.com" link that resolves to attacker.com. Pre-OSC-8
rendering kept `(url)` visible so the user could see the real target;
hiding it makes the click-deception case land.
Mitigation: a new `labelMayDeceive(label, url)` predicate. When the
label contains a URL-shaped substring AND it doesn't equal the actual
target, both renderers keep the legacy `(url)` suffix while still
emitting the OSC 8 envelope — the link stays clickable, the user
still sees where the click goes.
Heuristic is permissive on purpose: false positives are harmless
(redundant `(url)` on niche labels), false negatives let a real spoof
through.
Tests: positive (mismatched URL labels), negative (label == url, plain
text labels), in both InlineMarkdownRenderer and TableRenderer.
---
.../ui/utils/InlineMarkdownRenderer.test.tsx | 34 +++++++++++++++++++
.../src/ui/utils/InlineMarkdownRenderer.tsx | 19 ++++++++---
.../cli/src/ui/utils/TableRenderer.test.tsx | 13 +++++++
packages/cli/src/ui/utils/TableRenderer.tsx | 9 ++++-
packages/cli/src/ui/utils/osc8.test.ts | 28 +++++++++++++++
packages/cli/src/ui/utils/osc8.ts | 20 +++++++++++
6 files changed, 118 insertions(+), 5 deletions(-)
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index 787f327aeed..f86764ec4ed 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -296,6 +296,40 @@ describe('', () => {
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('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
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
index 96f469d7558..749c5c3650e 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
@@ -14,6 +14,7 @@ import {
MD_LINK_CAPTURE,
MD_LINK_PATTERN,
isSafeOscScheme,
+ labelMayDeceive,
osc8Close,
osc8Open,
sanitizeForOsc,
@@ -172,11 +173,21 @@ const RenderInlineInternal: React.FC = ({
// legacy `label (url)` branch leaves the label intact so today's
// unsupported-terminal output stays byte-identical.
const safeLabel = wrapOsc8 ? sanitizeForOsc(linkText) : linkText;
+ // 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, url);
renderedNode = wrapOsc8 ? (
-
- {osc8Open(url)}
- {safeLabel || url}
- {osc8Close()}
+
+
+ {osc8Open(url)}
+ {safeLabel || url}
+ {osc8Close()}
+
+ {showUrlSuffix ? (
+ ({url})
+ ) : null}
) : (
diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx
index 541804a58d3..4b1b10cdc93 100644
--- a/packages/cli/src/ui/utils/TableRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx
@@ -681,6 +681,19 @@ describe('', () => {
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';
diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
index 882e43bb65a..9ffa00a3f70 100644
--- a/packages/cli/src/ui/utils/TableRenderer.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -15,6 +15,7 @@ import {
MD_LINK_CAPTURE,
MD_LINK_PATTERN,
isSafeOscScheme,
+ labelMayDeceive,
osc8Close,
osc8Open,
sanitizeForOsc,
@@ -308,7 +309,13 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string {
// would still spoof the rendered text inside the clickable region.
const safeLabel = sanitizeForOsc(labelText);
const visibleLabel = applyColor(safeLabel || url, theme.text.link);
- rendered = `${osc8Open(url)}${visibleLabel}${osc8Close()}`;
+ 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, url)
+ ? `${envelope} ${applyColor(`(${url})`, theme.text.link)}`
+ : envelope;
} else {
rendered = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`;
}
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 6eb65bb61a8..3cbe75cd451 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
HYPERLINK_ENV_KEYS,
isSafeOscScheme,
+ labelMayDeceive,
osc8Close,
osc8Hyperlink,
osc8Open,
@@ -164,6 +165,33 @@ describe('osc8 helpers', () => {
});
});
+ 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('does NOT flag plain-text 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);
+ });
+ });
+
describe('trimTrailingUrlPunctuation', () => {
it.each([
['https://example.com.', 'https://example.com'],
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 10a5cce1b88..31ae9498532 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -380,6 +380,26 @@ export function shouldWrapMarkdownLink(
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.
+ *
+ * Heuristic is intentionally permissive (`://` substring) — false positives
+ * just mean an extra `(url)` suffix on niche labels like `mailto://x`, which
+ * is harmless. False negatives let a real spoof through.
+ */
+export function labelMayDeceive(label: string, url: string): boolean {
+ if (label === url) return false;
+ return /:\/\//.test(label) || /^[a-z][a-z0-9+.-]*:/i.test(label.trim());
+}
+
// ── Test helpers ─────────────────────────────────────────────────────────
/**
From 9672c8a939bc5576709a7c945abe434808715828 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Tue, 12 May 2026 16:48:04 +0800
Subject: [PATCH 11/14] fix(cli): catch bare-host label deception in OSC 8
wrapping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-3 audit caught a false-negative in labelMayDeceive: the
`://` substring check only flagged labels with a fully-qualified URL
shape. The most natural markdown spoof — `[google.com](https://evil.com)`
— uses a bare host as the label and slipped past, so the OSC 8 branch
elided the `(url)` suffix and rendered a clickable "google.com" that
resolved to evil.com.
Add a third detection pattern: extract host-like tokens from the
label (`name.tld` with an alphabetic 2+ char TLD), and flag the link
when any of them doesn't equal the URL's parsed hostname. Plain
labels like `docs` / `click here` don't match the regex, version
strings like `1.2.3` are skipped (last segment is numeric), and
`[google.com](https://google.com)` is honest rendering — none of these
get flagged.
ASCII-only matching means an IDN-homograph attack on a bare-host label
(Cyrillic `о`) still escapes this layer; the fully-qualified form of
the same attack is still caught by the existing `://` rule, which is
the only form an LLM is realistically likely to emit.
Tests cover: bare-host mismatch, punycode IDN target, same-host /
different-path, label==target negative, plain-text labels, version
strings.
---
.../ui/utils/InlineMarkdownRenderer.test.tsx | 14 +++++++
packages/cli/src/ui/utils/osc8.test.ts | 39 +++++++++++++++++-
packages/cli/src/ui/utils/osc8.ts | 40 +++++++++++++++++--
3 files changed, 88 insertions(+), 5 deletions(-)
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index f86764ec4ed..1fde889330a 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -316,6 +316,20 @@ describe('', () => {
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.
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 3cbe75cd451..f21f40e0cb7 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -185,10 +185,47 @@ describe('osc8 helpers', () => {
expect(labelMayDeceive(url, url)).toBe(false);
});
- it('does NOT flag plain-text labels', () => {
+ 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 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);
});
});
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 31ae9498532..2b32b39a27f 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -391,13 +391,45 @@ export function shouldWrapMarkdownLink(
* even though OSC 8 wrapping is otherwise active. The label is still
* clickable (envelope is still emitted), but the user sees the real target.
*
- * Heuristic is intentionally permissive (`://` substring) — false positives
- * just mean an extra `(url)` suffix on niche labels like `mailto://x`, which
- * is harmless. False negatives let a real spoof through.
+ * 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;
+
+function targetHostname(url: string): string | undefined {
+ try {
+ const h = new URL(url).hostname.toLowerCase();
+ return h || undefined;
+ } catch {
+ return undefined;
+ }
+}
+
export function labelMayDeceive(label: string, url: string): boolean {
if (label === url) return false;
- return /:\/\//.test(label) || /^[a-z][a-z0-9+.-]*:/i.test(label.trim());
+ if (/:\/\//.test(label) || /^[a-z][a-z0-9+.-]*:/i.test(label.trim())) {
+ return true;
+ }
+ // Re-set lastIndex defensively — exported regex literals reuse state
+ // across calls when matched with `.exec` elsewhere.
+ HOST_LIKE_RE.lastIndex = 0;
+ const labelHosts = label.toLowerCase().match(HOST_LIKE_RE);
+ if (!labelHosts || labelHosts.length === 0) return false;
+ const target = targetHostname(url);
+ if (!target) return true;
+ return labelHosts.some((h) => h !== target);
}
// ── Test helpers ─────────────────────────────────────────────────────────
From a3b5fe189cd5c2ff327201e15fd41ea853f12213 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Tue, 12 May 2026 16:51:59 +0800
Subject: [PATCH 12/14] fix(cli): handle mailto: target in labelMayDeceive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-4 audit caught a false positive: `new URL('mailto:x@y').hostname`
is empty, so targetHostname() returned undefined and the defensive
`return true` branch fired any time a mailto label contained an
email-shaped string. A perfectly honest
`[support@example.com](mailto:support@example.com)` was being flagged
as deceptive and getting a redundant `(url)` suffix on capable
terminals.
Special-case mailto: by pulling the domain from after the `@` in the
URL pathname, matching what the user would compare against.
A mismatched mailto (e.g. `[support@example.com](mailto:abuse@evil.com)`)
still flags correctly.
Also drop a dead `HOST_LIKE_RE.lastIndex = 0` reset — `.match()` doesn't
consult lastIndex, so the line was a no-op.
---
packages/cli/src/ui/utils/osc8.test.ts | 13 +++++++++++++
packages/cli/src/ui/utils/osc8.ts | 16 +++++++++++-----
2 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index f21f40e0cb7..22665f487ac 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -210,6 +210,19 @@ describe('osc8 helpers', () => {
).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
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index 2b32b39a27f..ca805a9865f 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -410,8 +410,17 @@ const HOST_LIKE_RE =
function targetHostname(url: string): string | undefined {
try {
- const h = new URL(url).hostname.toLowerCase();
- return h || undefined;
+ 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;
}
@@ -422,9 +431,6 @@ export function labelMayDeceive(label: string, url: string): boolean {
if (/:\/\//.test(label) || /^[a-z][a-z0-9+.-]*:/i.test(label.trim())) {
return true;
}
- // Re-set lastIndex defensively — exported regex literals reuse state
- // across calls when matched with `.exec` elsewhere.
- HOST_LIKE_RE.lastIndex = 0;
const labelHosts = label.toLowerCase().match(HOST_LIKE_RE);
if (!labelHosts || labelHosts.length === 0) return false;
const target = targetHostname(url);
From 9460f8ef22c1b1e2458168d56e1a49412abe5aec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Tue, 12 May 2026 16:55:17 +0800
Subject: [PATCH 13/14] fix(cli): catch IPv4-literal label deception in OSC 8
wrapping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-5 audit found another bare-host bypass: a label like
`[1.1.1.1](https://attacker.com)` (or any other dotted-quad such as
`[192.168.1.1]` / `[8.8.8.8]`) escaped labelMayDeceive because the
existing host regex anchors on a 2+ alphabetic TLD. The user would
see a clickable "1.1.1.1" that resolves to attacker.com with no
visible target.
Add a separate dotted-quad pattern and combine it with the host-token
list before comparing against the URL's hostname. False-positive
surface is small (over-permissive on octet ranges is harmless — worst
case is an extra `(url)` suffix on a label like `999.999.999.999`).
Tests cover mismatched IPv4, IPv4 spelled inside surrounding text,
and label-equals-target IPv4 (which must NOT flag).
---
packages/cli/src/ui/utils/osc8.test.ts | 16 ++++++++++++++++
packages/cli/src/ui/utils/osc8.ts | 15 +++++++++++++--
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/packages/cli/src/ui/utils/osc8.test.ts b/packages/cli/src/ui/utils/osc8.test.ts
index 22665f487ac..66527c03e85 100644
--- a/packages/cli/src/ui/utils/osc8.test.ts
+++ b/packages/cli/src/ui/utils/osc8.test.ts
@@ -210,6 +210,22 @@ describe('osc8 helpers', () => {
).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
diff --git a/packages/cli/src/ui/utils/osc8.ts b/packages/cli/src/ui/utils/osc8.ts
index ca805a9865f..a3a13328917 100644
--- a/packages/cli/src/ui/utils/osc8.ts
+++ b/packages/cli/src/ui/utils/osc8.ts
@@ -408,6 +408,13 @@ export function shouldWrapMarkdownLink(
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);
@@ -431,8 +438,12 @@ export function labelMayDeceive(label: string, url: string): boolean {
if (/:\/\//.test(label) || /^[a-z][a-z0-9+.-]*:/i.test(label.trim())) {
return true;
}
- const labelHosts = label.toLowerCase().match(HOST_LIKE_RE);
- if (!labelHosts || labelHosts.length === 0) return false;
+ 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);
From 5c14c53e2110624d6c9bb6403877f48dbd8b4758 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com>
Date: Wed, 13 May 2026 10:10:08 +0800
Subject: [PATCH 14/14] fix(cli): sanitize URL when rendered as visible text in
OSC 8 path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two PR review findings:
1. config-utils.ts dropped the `resolvePath(...)` call (and its import)
that origin/main introduced in #4045 for tilde / relative `cwd` paths
in channel configs. The auto-merge silently reverted it the same way
it did `packages/channels/base/src/index.ts`. Restore main's content.
2. Anti-spoof sanitization was only applied to `linkText`, but the
OSC 8 render path emits the URL as visible text in two places that
bypassed it:
- empty-label fallback `safeLabel || url` — `[](https://x/aevil)`
would print the URL with RLO intact even though the OSC target was
sanitized.
- deceptive-label `(url)` suffix.
Compute `safeUrl = sanitizeForOsc(url)` once in the OSC 8 branch and
use it for both visible-URL renderings. The OSC target inside
`osc8Open` keeps the raw URL (sanitization happens inside the helper
anyway). Same fix mirrored in `TableRenderer.tsx`. The legacy
`label (url)` branch on unsupported terminals stays untouched so its
byte-identical-fallback contract holds.
Test added: `[](https://example.com/aevil)` round-trips through the
renderer with the RLO stripped from both the OSC target and the visible
URL fallback.
---
.../ui/utils/InlineMarkdownRenderer.test.tsx | 18 ++++++++++++++++++
.../src/ui/utils/InlineMarkdownRenderer.tsx | 15 +++++++++------
packages/cli/src/ui/utils/TableRenderer.tsx | 18 ++++++++++++------
3 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
index 1fde889330a..b4477998b7d 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -277,6 +277,24 @@ describe('', () => {
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/aevil)` (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)"
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
index 749c5c3650e..e6c0a949e00 100644
--- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
@@ -169,24 +169,27 @@ const RenderInlineInternal: React.FC = ({
// 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 the visible label when OSC 8 is active. The
- // legacy `label (url)` branch leaves the label intact so today's
- // unsupported-terminal output stays byte-identical.
+ // 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, url);
+ const showUrlSuffix = wrapOsc8 && labelMayDeceive(safeLabel, safeUrl);
renderedNode = wrapOsc8 ? (
{osc8Open(url)}
- {safeLabel || url}
+ {safeLabel || safeUrl}
{osc8Close()}
{showUrlSuffix ? (
- ({url})
+ ({safeUrl})
) : null}
) : (
diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
index 9ffa00a3f70..661062fec90 100644
--- a/packages/cli/src/ui/utils/TableRenderer.tsx
+++ b/packages/cli/src/ui/utils/TableRenderer.tsx
@@ -304,17 +304,23 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string {
// byte-identical to today on unsupported terminals / unsafe
// schemes / whitespace URLs.
if (shouldWrapMarkdownLink(url, canHyperlink)) {
- // Strip bidi / C0 / C1 from the visible label too, not just the
- // OSC target — otherwise a model-emitted U+202E in the label
- // would still spoof the rendered text inside the clickable region.
+ // 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 visibleLabel = applyColor(safeLabel || url, theme.text.link);
+ 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, url)
- ? `${envelope} ${applyColor(`(${url})`, theme.text.link)}`
+ rendered = labelMayDeceive(safeLabel, safeUrl)
+ ? `${envelope} ${applyColor(`(${safeUrl})`, theme.text.link)}`
: envelope;
} else {
rendered = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`;