Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions packages/core/src/qwen/qwenOAuth2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
qwenOAuth2Events,
QwenOAuth2Event,
QwenOAuth2Client,
showFallbackMessage,
type DeviceAuthorizationResponse,
type DeviceTokenResponse,
type ErrorData,
Expand Down Expand Up @@ -2387,3 +2388,201 @@ 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 — clear CI and other terminal-detection vars so
// supportsOsc8Hyperlinks() doesn't short-circuit on CI runners.
process.env = { ...ORIGINAL_ENV };
delete process.env['CI'];
delete process.env['TMUX'];
delete process.env['STY'];
delete process.env['TERM_PROGRAM'];
delete process.env['TERM_PROGRAM_VERSION'];
delete process.env['WT_SESSION'];
delete process.env['KITTY_WINDOW_ID'];
delete process.env['VTE_VERSION'];
delete process.env['DOMTERM'];
delete process.env['GHOSTTY_RESOURCES_DIR'];
delete process.env['KONSOLE_VERSION'];
delete process.env['TERMINAL_EMULATOR'];
delete process.env['ALACRITTY_LOG'];
delete process.env['ALACRITTY_WINDOW_ID'];
delete process.env['ALACRITTY_SOCKET'];
delete process.env['FORCE_HYPERLINK'];
delete process.env['QWEN_DISABLE_HYPERLINKS'];
delete process.env['TEAMCITY_VERSION'];
// Keep TERM but clear known OSC 8 terminal names
if (
['xterm-kitty', 'xterm-ghostty', 'alacritty'].includes(
process.env['TERM'] ?? '',
)
) {
delete process.env['TERM'];
}
});

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';
process.env['TERM_PROGRAM_VERSION'] = '3.5.0';

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';
process.env['TERM_PROGRAM_VERSION'] = '1.85.0';

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');
});
});
82 changes: 76 additions & 6 deletions packages/core/src/qwen/qwenOAuth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Missing URL scheme validation in osc8Hyperlink(). The function embeds the URL directly into an OSC 8 envelope without checking if the URL scheme is safe. The canonical osc8Hyperlink() in packages/cli/src/ui/utils/osc8.ts uses isSafeOscScheme() to restrict targets to http:, https:, mailto:, ftp:, ftps:, sftp:, ssh:. A compromised OAuth endpoint returning a javascript: URL as verification_uri_complete would be rendered as a clickable OSC 8 hyperlink.

Suggested change
const safeUrl = url.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '');
function osc8Hyperlink(url: string): string {
// eslint-disable-next-line no-control-regex
const safeUrl = url.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '');
const scheme = safeUrl.match(/^[a-z][a-z0-9+.-]*:/)?.[0]?.toLowerCase();
const safeSchemes = new Set(['http:', 'https:', 'ftp:', 'ftps:', 'sftp:', 'ssh:', 'mailto:']);
if (scheme && !safeSchemes.has(scheme)) {
return safeUrl; // fall back to plain text for unsafe schemes
}
return `\x1b]8;;${safeUrl}\x07${safeUrl}\x1b]8;;\x07`;
}

— qwen3.7-max via Qwen Code /review

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);
Expand Down Expand Up @@ -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');
Expand Down
Loading