From 8aecb37211a0036f46fb20003deff39aba4b96e7 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Tue, 7 Jul 2026 15:27:22 +0800 Subject: [PATCH] fix(core): emit auth URL as OSC 8 hyperlink instead of hard-wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Qwen OAuth device flow falls back to the non-interactive message (--no-browser or non-TUI contexts), the auth URL was hard-wrapped at character boundaries to fit a fixed-width ASCII box. This broke the URL across multiple lines, making it impossible to click or copy-paste as a single link — especially over SSH. Add supportsOsc8Hyperlinks() to detect terminal OSC 8 support (iTerm2, WezTerm, Kitty, VS Code, Windows Terminal, VTE, Alacritty, etc.) and osc8Hyperlink() to wrap URLs in OSC 8 escape sequences. When supported, the URL is emitted as a single clickable hyperlink; otherwise the existing ASCII box with hard-wrapping is preserved as a fallback. Fixes #6428 Co-authored-by: Qwen-Coder --- packages/core/src/qwen/qwenOAuth2.test.ts | 170 ++++++++++++++++++++++ packages/core/src/qwen/qwenOAuth2.ts | 82 ++++++++++- 2 files changed, 246 insertions(+), 6 deletions(-) diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 034cb0dd9a..4fa914ce07 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -18,6 +18,7 @@ import { qwenOAuth2Events, QwenOAuth2Event, QwenOAuth2Client, + showFallbackMessage, type DeviceAuthorizationResponse, type DeviceTokenResponse, type ErrorData, @@ -2387,3 +2388,172 @@ describe('Constants and Configuration', () => { ); }); }); + +describe('showFallbackMessage', () => { + const ORIGINAL_ENV = { ...process.env }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let stderrWriteSpy: any; + let originalIsTTY: boolean; + + beforeEach(() => { + originalIsTTY = process.stderr.isTTY ?? false; + stderrWriteSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + // Reset env + process.env = { ...ORIGINAL_ENV }; + }); + + afterEach(() => { + stderrWriteSpy.mockRestore(); + process.env = ORIGINAL_ENV; + // Restore isTTY + if (originalIsTTY) { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + } else { + Object.defineProperty(process.stderr, 'isTTY', { + value: false, + configurable: true, + }); + } + }); + + function getOutput(): string { + return stderrWriteSpy.mock.calls.map((c: [string]) => c[0]).join(''); + } + + it('emits OSC 8 hyperlink when terminal supports it (iTerm.app)', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).toContain( + '\x1b]8;;https://chat.qwen.ai/device?code=ABC123\x07', + ); + expect(output).toContain('\x1b]8;;\x07'); + // URL appears as a single line within the OSC 8 envelope + expect(output).toContain( + 'https://chat.qwen.ai/device?code=ABC123\x1b]8;;\x07', + ); + }); + + it('hard-wraps URL when terminal does not support OSC 8 (non-TTY)', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: false, + configurable: true, + }); + delete process.env['TERM_PROGRAM']; + delete process.env['FORCE_HYPERLINK']; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).not.toContain('\x1b]8;;'); + // URL is present as plain text + expect(output).toContain('https://chat.qwen.ai/device?code=ABC123'); + }); + + it('does not emit OSC 8 when QWEN_DISABLE_HYPERLINKS=1', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + process.env['QWEN_DISABLE_HYPERLINKS'] = '1'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).not.toContain('\x1b]8;;'); + }); + + it('respects FORCE_HYPERLINK=1 even in tmux', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TMUX'] = '/tmp/tmux-1000/default,1234,0'; + process.env['FORCE_HYPERLINK'] = '1'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).toContain('\x1b]8;;'); + }); + + it('refuses OSC 8 in tmux without FORCE_HYPERLINK', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TMUX'] = '/tmp/tmux-1000/default,1234,0'; + delete process.env['FORCE_HYPERLINK']; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).not.toContain('\x1b]8;;'); + }); + + it('detects Windows Terminal via WT_SESSION', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['WT_SESSION'] = 'xxx'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).toContain('\x1b]8;;'); + }); + + it('detects Kitty via TERM=xterm-kitty', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TERM'] = 'xterm-kitty'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).toContain('\x1b]8;;'); + }); + + it('detects VS Code via TERM_PROGRAM=vscode', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TERM_PROGRAM'] = 'vscode'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).toContain('\x1b]8;;'); + }); + + it('still renders the ASCII box with title and instructions', () => { + Object.defineProperty(process.stderr, 'isTTY', { + value: true, + configurable: true, + }); + process.env['TERM_PROGRAM'] = 'iTerm.app'; + + showFallbackMessage('https://chat.qwen.ai/device?code=ABC123'); + + const output = getOutput(); + expect(output).toContain('Qwen OAuth Device Authorization'); + expect(output).toContain('Please visit the following URL'); + expect(output).toContain('Waiting for authorization'); + }); +}); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 9e533b4cb2..365034a644 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -701,16 +701,81 @@ export async function getQwenOAuthClient( } } +/** + * Check whether stderr's terminal supports OSC 8 hyperlinks. A minimal but + * safe subset of the detection logic in `packages/cli/src/ui/utils/osc8.ts` — + * enough to cover the common terminals (iTerm2, WezTerm, Kitty, VS Code, + * Windows Terminal, GNOME Terminal/VTE) while refusing for multiplexers + * (tmux/screen) and non-TTY contexts unless `FORCE_HYPERLINK` is set. + */ +function supportsOsc8Hyperlinks(): boolean { + const env = process.env; + if (env['QWEN_DISABLE_HYPERLINKS'] === '1') return false; + if (!process.stderr?.isTTY) return false; + const force = env['FORCE_HYPERLINK']; + if (force !== undefined) return force !== '0'; + if (env['CI']) return false; + if (env['TMUX'] || env['STY']) return false; + if (env['WT_SESSION']) return true; + if (env['KITTY_WINDOW_ID'] || env['TERM'] === 'xterm-kitty') return true; + if (env['DOMTERM']) return true; + if (env['GHOSTTY_RESOURCES_DIR'] || env['TERM'] === 'xterm-ghostty') { + return true; + } + if (env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm') return true; + if (env['TERM_PROGRAM']) { + switch (env['TERM_PROGRAM']) { + case 'iTerm.app': + case 'WezTerm': + case 'vscode': + case 'ghostty': + case 'mintty': + return true; + default: + break; + } + } + if (env['VTE_VERSION']) { + const v = parseInt(env['VTE_VERSION'], 10); + if (Number.isFinite(v) && v >= 5000 && v !== 5000) return true; + } + if ( + env['TERM'] === 'alacritty' || + env['ALACRITTY_LOG'] !== undefined || + env['ALACRITTY_WINDOW_ID'] !== undefined + ) { + return true; + } + return false; +} + +/** + * Wrap a URL in an OSC 8 hyperlink escape sequence. BEL (\x07) terminates + * the OSC — more broadly supported than ST (ESC \\). Control characters + * are stripped from the URL to prevent breaking the OSC envelope. + */ +function osc8Hyperlink(url: string): string { + // eslint-disable-next-line no-control-regex + const safeUrl = url.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, ''); + return `\x1b]8;;${safeUrl}\x07${safeUrl}\x1b]8;;\x07`; +} + /** * Displays a formatted box with OAuth device authorization URL. * Uses process.stderr.write() to ensure the auth URL is always visible to users, * especially in non-interactive mode. Using stderr prevents corruption of * structured JSON output (which goes to stdout) and follows the standard Unix * convention of user-facing messages to stderr. + * + * When the terminal supports OSC 8 hyperlinks, the URL is emitted as a single + * clickable link instead of being hard-wrapped across multiple lines. This is + * especially important over SSH, where wrapped URLs are impossible to + * copy-paste as a single link. */ -function showFallbackMessage(verificationUriComplete: string): void { +export function showFallbackMessage(verificationUriComplete: string): void { const title = 'Qwen OAuth Device Authorization'; const url = verificationUriComplete; + const useOsc8 = supportsOsc8Hyperlinks(); const minWidth = 70; const maxWidth = 80; const boxWidth = Math.min(Math.max(title.length + 4, minWidth), maxWidth); @@ -786,11 +851,16 @@ function showFallbackMessage(verificationUriComplete: string): void { process.stderr.write(emptyLine + '\n'); - // Write URL - for (const line of urlLines) { - process.stderr.write( - '| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n', - ); + // Write URL — as a single OSC 8 clickable hyperlink when supported, + // or hard-wrapped across lines as a fallback. + if (useOsc8) { + process.stderr.write('| ' + osc8Hyperlink(url) + ' |\n'); + } else { + for (const line of urlLines) { + process.stderr.write( + '| ' + line + ' '.repeat(contentWidth - line.length) + ' |\n', + ); + } } process.stderr.write(emptyLine + '\n');