qwen-code/docs
Edenman 533daac316
feat(cli): wrap markdown links in OSC 8 so wrapped URLs stay clickable (#4037)
* 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

* fix(cli): harden OSC 8 markdown wrapping after multi-round audit

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.

* feat(cli): detect Alacritty / Konsole / Warp / JetBrains / mintty for OSC 8

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.

* chore(cli): polish OSC 8 detector after another audit round

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
  (`<https://example.com>`) 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.

* fix(cli): tighten OSC 8 gating per PR review

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.

* fix(cli): stop auto-detecting Warp Terminal as OSC 8 capable

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.

* feat(cli): drop visible (url) suffix when OSC 8 wrapping is active

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.

* 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.

* fix(cli): harden OSC 8 link sanitization and tighten gating

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.

* fix(cli): keep `(url)` visible when an OSC 8 label looks like a different URL

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.

* fix(cli): catch bare-host label deception in OSC 8 wrapping

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.

* fix(cli): handle mailto: target in labelMayDeceive

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.

* fix(cli): catch IPv4-literal label deception in OSC 8 wrapping

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).

* fix(cli): sanitize URL when rendered as visible text in OSC 8 path

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/a‮evil)`
     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/a‮evil)` round-trips through the
renderer with the RLO stripped from both the OSC target and the visible
URL fallback.
2026-05-13 11:37:27 +08:00
..
design refactor(cli): remove legacy qwen auth CLI subcommand, redirect to /auth TUI dialog (#3959) 2026-05-11 16:44:09 +08:00
developers docs(telemetry): align config and docs semantics for target, outfile, and CLI flags (#4066) 2026-05-13 08:27:41 +08:00
plans feat(vscode-ide-companion): add agent execution tool display (#2590) 2026-04-18 23:39:26 +08:00
users feat(cli): wrap markdown links in OSC 8 so wrapped URLs stay clickable (#4037) 2026-05-13 11:37:27 +08:00
_meta.ts feat: refactor docs 2025-12-05 10:51:57 +08:00
index.md fix: lint issues 2025-12-19 15:52:11 +08:00