qwen-code/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
Edenman 07bd5c41cb
fix(mcp): make the OAuth authorization URL clickable when wrapped (#3489)
* fix(mcp): render OAuth URL as OSC 8 hyperlink so it stays clickable when wrapped

Closes #3470.

The MCP OAuth flow previously pushed the authorization URL through the
generic display-message list, where Ink rendered it as plain text. When
the URL exceeded the terminal width it got hard-wrapped into the message
buffer, and most terminals could no longer detect it as a single
hyperlink (cmd/ctrl+click did nothing, selecting it pulled in extra
whitespace).

Render the URL as an OSC 8 hyperlink in AuthenticateStep instead, and
stop duplicating it through the display-message stream when an event
emitter is available. Terminals that support OSC 8 (iTerm2, WezTerm,
Kitty, Windows Terminal, VS Code, GNOME Terminal, …) now treat the URL
as a single clickable link even when it visually wraps; terminals
without OSC 8 support ignore the escapes and fall back to the existing
"press c to copy" affordance.

* fix(mcp): pre-split OAuth URL so every wrapped line stays clickable

Wrapping the whole URL in a single OSC 8 hyperlink and letting Ink /
wrap-ansi break the line produced two bugs observed in iTerm2 etc.:
only the first visible segment was a hyperlink (wrap-ansi re-emits SGR
codes across wraps but does not re-open OSC 8 links), and the remaining
URL characters overflowed past the dialog border because wrap-ansi was
unable to break the unbroken URL token within the container width.

Manually slice the URL into chunks of `columns - 8` characters
(MCPManagementDialog's container width) and render each chunk as its
own OSC 8 hyperlink with `wrap="truncate"`. Every visible line now
carries a complete hyperlink pointing at the same URL, and no line
exceeds the container width.

* fix(mcp): terminate OSC 8 hyperlinks with BEL so Ink preserves them

Ink's renderer tokenizes text through @alcalzone/ansi-tokenize, which
only recognizes OSC 8 hyperlink escapes terminated with BEL (\x07).
The ST terminator (ESC \\) we were using is valid per the OSC 8 spec
but the tokenizer falls through and treats the escape bytes as regular
characters. That explains the two symptoms seen after the previous fix:

  - Only the first URL segment rendered as a clickable hyperlink. The
    rest of the lines had their opening \\x1b]8;; bytes tokenized as
    chars, so their hyperlink wrap was lost.
  - The dialog's right border disappeared because the mangled escape
    bytes consumed grid cells, pushing the container width past
    `columns - 8` and shoving the border off-screen.

Switch the helper to the BEL-terminated form. Ink now sees each line's
OSC 8 wrap as a proper zero-width code, every wrapped line stays
clickable, and the border is no longer displaced.

* fix(mcp): render OAuth URL via <Static> as a single unwrapped line

The per-line OSC 8 approach didn't make lines past the first clickable
in real terminals. Root cause: Ink's renderer runs text through
@alcalzone/ansi-tokenize, which:

  - Only accepts OSC 8 sequences with empty params (`\x1b]8;;URL\x07`).
    Any `id=` form is parsed as a bogus SGR code and the remainder
    leaks out as visible characters. Without an `id=` grouping
    parameter, terminals like iTerm2 don't reliably stitch adjacent
    OSC 8 escapes together as one hyperlink.
  - Re-emits styles per Ink row via styledCharsToString, so even when
    each slice carried a self-contained OSC 8 wrap, terminals still
    treated each visual line as an independent hyperlink that only
    the first row reliably activated.

Emit the URL through Ink's `<Static>` component instead, inside a
`<Box width={url.length}>`. Ink sees a single logical line that
doesn't need wrapping, so it hands the terminal one OSC 8 open, the
whole URL, and one close. The terminal then soft-wraps that line
visually, and the OSC 8 hyperlink state is carried across every
wrap — every visible line is clickable.

`<Static>` writes once above the dynamic dialog (scrollback-safe) and
isn't touched by re-renders, which also avoids the flicker we'd get
from repeatedly re-emitting the escape sequence inside the live tree.

* fix(mcp): render OAuth URL as live row so it clears on dialog dismissal

The previous <Static> emission made the URL stay permanently in the
scrollback after the OAuth flow finished — e.g. after the dialog was
dismissed the URL was still sitting above the prompt.

Switch to a normal (live) Ink row: a Box sized to the URL length
holding a single OSC 8 wrapped Text. Ink doesn't wrap the row
(maxWidth == content width), so it hands log-update one long line;
log-update's wrap-ansi pass then wraps it at terminal width and
re-emits the OSC 8 escape at every wrap boundary, so every visible
wrapped line is clickable. Because this is a regular child of the
dialog, log-update tracks its height and erases it cleanly when the
AuthenticateStep unmounts (auth succeeds / user backs out / dialog
closes).

* fix(mcp): pre-split OAuth URL so the live row clears cleanly

The wide-Box live approach left dialog fragments in the scrollback: Ink
ships its own log-update.js (packages/cli/.../ink/build/log-update.js)
which counts erase height with output.split('\n').length and does NOT
run wrap-ansi. A single Ink row that exceeds terminal width wraps
visually but the erase still covers only one terminal line, so
authState transitions (auth success, Esc-to-back, dialog dismiss) leave
the top rows of the previous frame behind.

Go back to pre-slicing the URL into chunks sized to the dialog content
width (columns - 8) and rendering each chunk as its own Ink row with
its own OSC 8 wrap. Log-update's row count then matches the visible
row count, so erase is clean on every transition. Terminals that group
adjacent OSC 8 sequences will still treat the whole URL as clickable;
those that don't at least keep the first slice clickable, and the
existing "press c to copy" affordance covers the rest.

* fix(mcp): commit to Static-rendered URL outside the dialog

Stop flip-flopping between in-box and out-of-box URL rendering. Every
in-box attempt hit one of two walls:

  - Per-slice OSC 8 rows: each Ink row is its own self-contained
    hyperlink, but some terminals (seen with the reporter's) only
    register the first adjacent OSC 8 without an id= parameter as
    clickable. Ink's @alcalzone/ansi-tokenize rejects OSC 8 with
    params, so id= grouping is not deliverable.
  - Wide-Box overflow rows: the single OSC 8 wrap keeps every wrapped
    line clickable because the hyperlink state persists across the
    terminal's soft-wraps, but Ink ships its own log-update.js that
    counts erase height by output.split('\n').length and never runs
    wrap-ansi. When the row visually wraps but Ink counts it as 1
    row, transitions (auth success / Esc / dismiss) erase too few
    lines and leave dialog fragments in the scrollback.

Render the URL through <Static> above the dialog: it writes once,
outside log-update's tracking, so the terminal soft-wraps a single
OSC 8 hyperlink and every visible line stays clickable. The trade-off
is that the URL stays in the scrollback after the dialog dismisses
(Static is append-only); that is acceptable given the URL is no
longer sensitive once auth has completed, and it avoids the
click-failure and residue problems of the other approaches.

* fix(mcp): print OAuth URL via useStdout, erase on unmount

Drop <Static> (which persisted the URL in the scrollback forever) and
print the authorization URL directly with Ink's `write` (useStdout)
instead. Ink's writeToStdout clears the live frame, writes our bytes
into the scrollback, and re-renders the frame below, so the URL goes
out in a single OSC 8 hyperlink sequence and the terminal's soft-wrap
preserves the hyperlink state across every wrapped row — every visible
line stays clickable.

On unmount (auth success, Esc, dialog dismiss) we use the same `write`
path to push a cursor-up + eraseLines sequence that removes the URL
rows (plus the leading/trailing blank separators) before log-update
redraws the now-smaller live frame. Net effect: URL shows above the
dialog while authenticating, disappears cleanly when the dialog goes
away, and every wrapped line is clickable throughout.

* fix(mcp): period-terminate prompt and restore wrap warning

Now that the OAuth URL renders above the dialog (outside the message
list), the in-dialog prompt no longer leads into the URL on the next
line — rename the i18n key from "…into your browser:" to "…into your
browser." and re-add the "Make sure to copy the COMPLETE URL — it may
wrap across multiple lines." warning that was dropped when the URL
was first moved out of displayMessage. Translations in de/en/fr/ja/pt/
ru/zh are updated to match and to point at the URL "above" rather
than "following".

* fix(mcp): correct OAuth URL erase count on unmount

The previous logic wrote the URL as `\n${URL}\n` (leading + trailing
newlines) and erased `urlVisualLines + 2` rows on unmount, but the
leading blank and the trailing "\n" don't both occupy their own rows
— the trailing newline just moves the cursor to where the dynamic UI
is re-rendered. For a typical URL whose length isn't an exact multiple
of the terminal width this left the erase off-by-one and wiped a row
above the dialog (e.g. a piece of the command prompt).

Drop the leading `\n` (no real visual benefit) and compute the erase
count as `urlVisualLines + (autoWrapOverflow ? 1 : 0)`. The overflow
term handles the aligned edge case where the terminal auto-wraps past
the last URL char, leaving a blank row between URL and re-rendered
dynamic UI that also needs erasing.

Also drop the stale comment about Ink's ansi-tokenize restricting
OSC 8 terminator choice — we now bypass Ink's tokenizer via
useStdout, so BEL is just the more compatible terminator.

* fix(mcp): pass OAuth URL hyperlink through multiplexer wrapper

Inside tmux or GNU screen the raw OSC 8 hyperlink escape is intercepted
by the multiplexer and never reaches the host terminal — users see
the URL as plain text, exactly the bug this PR is trying to fix. The
existing `wrapForMultiplexer` helper (already used for OSC 52 clipboard
writes) wraps the sequence in a DCS passthrough envelope that tmux /
screen forward to the host.

Apply the same helper to `osc8Hyperlink` so tmux / screen users get
clickable links for every wrapped line as well. Outside a multiplexer
the helper is a no-op, so native terminals are unchanged.

Also note in a comment that the captured `stdout.columns` goes stale
if the terminal is resized during the OAuth flow; this is acceptable
for a sub-minute flow on ASCII-only authorization URLs.

* docs(mcp): note tmux 3.3+ allow-passthrough requirement

* fix(mcp): render OAuth URL inside dialog box

Replace the useStdout().write + cursor-up/eraseLines scrollback
approach with an in-dialog <Box><Text>{osc8Hyperlink(url)}</Text></Box>.
Removes the Ink dynamic-frame interleave and the column-width erase
bookkeeping; the URL is owned by the dialog, so it disappears with it.

* refactor(mcp): drop redundant Fragment around single Text

* revert(mcp): restore original OAuth prompt wording

URL now renders inside the dialog box, so the "copy and paste this URL
into your browser:" prompt no longer needs the period-terminated /
"URL above" rewording. Revert the i18n keys and localized strings;
keep the event-driven dispatch so the URL isn't also pushed through
displayMessage (which would double-render in the UI).

* fix(mcp): sanitize URL/label before embedding in OSC 8 sequence

An unescaped \x07 (BEL) or \x1b (ESC) in the URL or label would
terminate the OSC 8 envelope early and let the tail bytes through
as interpretable terminal escapes. authUrl is normally built via
URL.toString() which percent-encodes controls, but the authorization
endpoint itself comes from server-controlled OAuth discovery, so
treat the input as untrusted and strip C0 + DEL before splicing.
2026-04-21 16:44:23 +08:00

334 lines
10 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { AuthenticateStepProps } from '../types.js';
import { useConfig } from '../../../contexts/ConfigContext.js';
import {
MCPOAuthProvider,
MCPOAuthTokenStorage,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../../../utils/events.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
* Terminal, xterm.js-based emulators) without spawning a subprocess.
* Returns true if the sequence was written to a TTY; false otherwise.
* A return of true does not guarantee the terminal accepted the write —
* some terminals disable OSC 52 by default.
*/
function copyToClipboardViaOsc52(text: string): boolean {
const base64 = Buffer.from(text, 'utf8').toString('base64');
const seq = wrapForMultiplexer(`\x1b]52;c;${base64}\x07`);
const stream = process.stderr.isTTY
? process.stderr
: process.stdout.isTTY
? process.stdout
: null;
if (!stream) return false;
try {
stream.write(seq);
return true;
} catch {
return false;
}
}
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
server,
onBack,
}) => {
const config = useConfig();
const [authState, setAuthState] = useState<AuthState>('idle');
const [messages, setMessages] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [copyState, setCopyState] = useState<
{ status: 'idle' } | { status: 'copied' | 'unsupported'; nonce: number }
>({ status: 'idle' });
const isRunning = useRef(false);
const runAuthentication = useCallback(async () => {
if (!server || !config || isRunning.current) return;
isRunning.current = true;
setAuthState('authenticating');
setMessages([]);
setErrorMessage(null);
try {
setMessages([
t("Starting OAuth authentication for MCP server '{{name}}'...", {
name: server.name,
}),
]);
let oauthConfig = server.config.oauth;
if (!oauthConfig) {
oauthConfig = { enabled: false };
}
const mcpServerUrl = server.config.httpUrl || server.config.url;
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(
server.name,
oauthConfig,
mcpServerUrl,
appEvents,
);
setMessages((prev) => [
...prev,
t("Successfully authenticated and refreshed tools for '{{name}}'.", {
name: server.name,
}),
]);
// Trigger tool re-discovery to pick up authenticated server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
setMessages((prev) => [
...prev,
t("Re-discovering tools from '{{name}}'...", {
name: server.name,
}),
]);
await toolRegistry.discoverToolsForServer(server.name);
// Show discovered tool count
const discoveredTools = toolRegistry.getToolsByServer(server.name);
setMessages((prev) => [
...prev,
t("Discovered {{count}} tool(s) from '{{name}}'.", {
count: String(discoveredTools.length),
name: server.name,
}),
]);
}
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
setMessages((prev) => [
...prev,
t('Authentication complete. Returning to server details...'),
]);
setAuthState('success');
} catch (error) {
setErrorMessage(getErrorMessage(error));
setAuthState('error');
} finally {
isRunning.current = false;
}
}, [server, config]);
// Subscribe to OAuth events for the lifetime of this component. Keeping
// the subscription tied to mount/unmount (rather than to runAuthentication's
// async flow) ensures listeners are released immediately on unmount even if
// the authentication promise is still pending.
useEffect(() => {
const displayListener = (message: OAuthDisplayPayload) => {
const text =
typeof message === 'string' ? message : t(message.key, message.params);
setMessages((prev) => [...prev, text]);
};
const authUrlListener = (url: string) => {
setAuthUrl(url);
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
appEvents.on(AppEvent.OauthAuthUrl, authUrlListener);
return () => {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
appEvents.removeListener(AppEvent.OauthAuthUrl, authUrlListener);
};
}, []);
useEffect(() => {
runAuthentication();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-navigate back after authentication succeeds
useEffect(() => {
if (authState !== 'success') return;
const timer = setTimeout(() => {
onBack();
}, AUTO_BACK_DELAY_MS);
return () => clearTimeout(timer);
}, [authState, onBack]);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
return;
}
if (
key.name === 'c' &&
!key.ctrl &&
!key.meta &&
!key.paste &&
authUrl &&
authState === 'authenticating'
) {
const ok = copyToClipboardViaOsc52(authUrl);
setCopyState({
status: ok ? 'copied' : 'unsupported',
nonce: Date.now(),
});
}
},
{ isActive: true },
);
useEffect(() => {
if (copyState.status === 'idle') return;
const timer = setTimeout(
() => setCopyState({ status: 'idle' }),
COPY_FEEDBACK_MS,
);
return () => clearTimeout(timer);
// Depend on the nonce so repeated presses reset the timer.
}, [copyState]);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* Server info */}
<Box>
<Text color={theme.text.secondary}>
{t('Server:')} {server.name}
</Text>
</Box>
{/* Progress messages */}
{messages.length > 0 && (
<Box flexDirection="column">
{messages.map((msg, i) => (
<Text key={i} color={theme.text.secondary}>
{msg}
</Text>
))}
</Box>
)}
{/* Error message */}
{authState === 'error' && errorMessage && (
<Box>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{authUrl && (
<Box>
<Text color={theme.text.accent}>{osc8Hyperlink(authUrl)}</Text>
</Box>
)}
{/* Action hints */}
<Box flexDirection="column">
{authState === 'authenticating' && (
<Text color={theme.text.secondary}>
{t('Authenticating... Please complete the login in your browser.')}
</Text>
)}
{authState === 'authenticating' && authUrl && (
<Text
bold={copyState.status === 'idle'}
color={
copyState.status === 'copied'
? theme.status.success
: copyState.status === 'unsupported'
? theme.status.warning
: theme.text.accent
}
>
{copyState.status === 'copied'
? t(
'Copy request sent to your terminal. If paste is empty, copy the URL above manually.',
)
: copyState.status === 'unsupported'
? t('Cannot write to terminal — copy the URL above manually.')
: t('Press c to copy the authorization URL to your clipboard.')}
</Text>
)}
{authState === 'success' && (
<Text color={theme.status.success}>
{t('Authentication successful.')}
</Text>
)}
</Box>
</Box>
);
};