fix(tui-clipboard): skip native safety net on OSC52-capable terminals (#20954)
Some checks failed
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Docker Build and Publish / move-latest (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (push) Waiting to run
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Build Skills Index / deploy-with-index (push) Has been cancelled

* fix(tui-clipboard): skip native safety net on OSC52-capable terminals

On terminals with first-class OSC 52 support (Ghostty, kitty, WezTerm,
Windows Terminal, VS Code), setClipboard() currently fires both OSC 52
AND a parallel native-tool write (wl-copy / xclip / pbcopy). On Wayland
+ wl-copy this corrupts the clipboard: probeLinuxCopy() runs wl-copy
with empty stdin as an existence check (destructive — wipes clipboard
to empty string), and the subsequent real wl-copy invocation races
OSC 52 plus its own daemon's previous SIGTERM.

Symptom: user on Arch + Ghostty + wl-copy (Wayland, no tmux, no SSH)
had to press Ctrl+Shift+C three times before a selection landed.
env -u WAYLAND_DISPLAY -u DISPLAY HERMES_TUI_FORCE_OSC52=1 (which
short-circuits copyNative via the DISPLAY-absent early-return) made
every copy work instantly — proving OSC 52 alone is sufficient on
Ghostty and that copyNative() is actively destructive there.

Add OSC52_CAPABLE_TERMINALS allowlist to terminal.ts (same pattern as
the existing EXTENDED_KEYS_TERMINALS), and gate copyNative() on the
terminal NOT being on it. The native safety net continues to fire on
unrecognised terminals (xterm, GNOME Terminal, Konsole, Terminal.app,
etc.) where OSC 52 is less reliable.

* fix(tui-clipboard): address Copilot review feedback

- Move OSC52_CAPABLE_TERMINALS + supportsOsc52Clipboard() from
  ink/terminal.ts to utils/env.ts. ink/terminal.ts already imports
  link from ink/termio/osc.ts; importing back into termio/osc.ts
  introduced a circular dependency. utils/env.ts has no deps on
  either file and already owns terminal detection (detectTerminal()),
  so the helper sits naturally next to it.

- Replace the inline gating (!SSH_CONNECTION && !supportsOsc52Clipboard())
  with a pure shouldUseNativeClipboard(env, terminal) helper. The old
  expression skipped native on allowlisted terminals even when
  setClipboard() wouldn't actually emit OSC 52 (e.g. inside
  TMUX/STY where we use tmux load-buffer instead, or when the user
  has set HERMES_TUI_FORCE_OSC52=0). That made the clipboard write
  a no-op in those configurations. The new helper:
    1. SSH_CONNECTION set -> false (existing behaviour)
    2. TMUX or STY set -> true (we go through load-buffer, no race)
    3. shouldEmitClipboardSequence() false -> true (native is the
       only path left when OSC 52 is suppressed)
    4. Otherwise: skip native iff terminal is allowlisted.

- Add 11 tests for shouldUseNativeClipboard covering the SSH guard,
  TMUX/STY tmux-inside-Ghostty case, HERMES_TUI_FORCE_OSC52=0
  override, allowlisted vs non-allowlisted terminals, precedence,
  and default-args smoke. Tests follow the package's existing
  parameterised-helper style (no vi.mock; helpers accept env and
  terminal as arguments).

- Update test imports to the new utils/env.js path.

* fix(tui-clipboard): address Copilot round 2 feedback

* fix(tui-clipboard): address Copilot round 3 feedback

* fix(tui-clipboard): address Copilot round 4 feedback
This commit is contained in:
Ben Barclay 2026-05-12 12:40:07 +10:00 committed by GitHub
parent e85592591e
commit 3c23b15f81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 260 additions and 8 deletions

View file

@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import { shouldEmitClipboardSequence } from './osc.js'
import { env, supportsOsc52Clipboard } from '../../utils/env.js'
import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js'
describe('shouldEmitClipboardSequence', () => {
it('suppresses local multiplexer clipboard OSC by default', () => {
@ -49,3 +51,141 @@ describe('shouldEmitClipboardSequence', () => {
).toBe(false)
})
})
describe('supportsOsc52Clipboard', () => {
// Terminals known to correctly implement OSC 52. On these, setClipboard()
// skips the native-tool safety net (wl-copy/xclip/pbcopy) to avoid racing
// the terminal's own clipboard write. Values must match what
// detectTerminal() in utils/env.ts returns — TERM=xterm-ghostty normalises
// to 'ghostty', TERM_PROGRAM=WezTerm stays 'WezTerm', etc.
it.each(['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode'])(
'returns true for allowlisted terminal %s',
terminal => {
expect(supportsOsc52Clipboard(terminal)).toBe(true)
}
)
// Intentionally conservative — iTerm2 disables OSC 52 by default; Alacritty
// and GNOME Terminal detection is unreliable; xterm/Terminal.app lack
// reliable OSC 52. These keep the existing native-safety-net behaviour.
it.each(['iTerm.app', 'alacritty', 'Apple_Terminal', 'xterm', 'tmux', 'screen', 'cursor', 'WarpTerminal', ''])(
'returns false for non-allowlisted terminal %s',
terminal => {
expect(supportsOsc52Clipboard(terminal)).toBe(false)
}
)
it('returns false when terminal is null (detection failed)', () => {
expect(supportsOsc52Clipboard(null)).toBe(false)
})
it('defaults to the module-level detected terminal when no argument is passed', () => {
// With no argument, uses env.terminal detected at module load. We don't
// know what that is in CI, but the call must return a boolean (not throw)
// and the result must match calling with env.terminal explicitly.
expect(typeof supportsOsc52Clipboard()).toBe('boolean')
expect(supportsOsc52Clipboard()).toBe(supportsOsc52Clipboard(env.terminal))
})
})
// shouldUseNativeClipboard() encodes the gating logic that setClipboard()
// uses to decide whether to fire copyNative(). Testing it directly (rather
// than mocking copyNative inside setClipboard) matches the package's
// existing style — tests pass env/terminal as arguments instead of using
// vi.mock — and gives broader coverage of the env x terminal matrix.
describe('shouldUseNativeClipboard', () => {
it('returns false over SSH (native would write to remote clipboard)', () => {
// Over SSH the user's terminal is on the local end of the pty;
// pbcopy/wl-copy/xclip on the remote machine would write to the wrong
// clipboard. OSC 52 is the right path. Existing behaviour, preserved.
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'xterm')).toBe(false)
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, null)).toBe(false)
})
it('returns true on plain local terminals (existing behaviour)', () => {
// Non-allowlisted terminals — xterm, GNOME Terminal, Apple_Terminal,
// alacritty (detection unreliable), iTerm2 (OSC 52 off by default).
// These keep the native safety net firing. This is the bulk of the
// existing user base; behaviour must not regress.
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'xterm')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'iTerm.app')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'Apple_Terminal')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'alacritty')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, null)).toBe(true)
})
it('returns false on allowlisted local terminals (the race-fix case)', () => {
// Ghostty / kitty / WezTerm / Windows Terminal / VS Code — OSC 52
// alone is reliable, native fallback racing it can corrupt the
// clipboard (the wl-copy on Wayland symptom this PR fixes).
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'kitty')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'WezTerm')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'windows-terminal')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'vscode')).toBe(false)
})
it('returns true inside tmux even on allowlisted outer terminal', () => {
// detectTerminal() prefers TERM_PROGRAM over TMUX, so a tmux session
// inside Ghostty reports terminal='ghostty'. But setClipboard() goes
// through tmux load-buffer there, not raw OSC 52 — the wl-copy race
// doesn't apply. Native is still useful since tmux's outer-terminal
// forwarding depends on `set -g set-clipboard` + `allow-passthrough`.
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true)
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'vscode')).toBe(true)
})
it('returns true inside GNU screen even on allowlisted outer terminal', () => {
// Same reasoning as TMUX — STY indicates we're inside screen, which
// has its own escape-sequence handling and we don't emit raw OSC 52.
expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
})
it('returns true when OSC 52 emission is disabled via HERMES_TUI_FORCE_OSC52=0', () => {
// If we suppress OSC 52 (user override) AND skip native, the clipboard
// write becomes a no-op. So when OSC 52 is off, native is the only
// remaining path — keep it on regardless of terminal allowlist.
expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
expect(shouldUseNativeClipboard({ HERMES_TUI_CLIPBOARD_OSC52: '0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true)
expect(shouldUseNativeClipboard({ HERMES_TUI_COPY_OSC52: 'no' } as NodeJS.ProcessEnv, 'vscode')).toBe(true)
})
it('returns true under TMUX even with HERMES_TUI_FORCE_OSC52=1 on an allowlisted terminal (tmux load-buffer path)', () => {
// FORCE_OSC52=1 is the user explicitly opting INTO OSC 52 (e.g. they
// have tmux set up for passthrough). On an allowlisted terminal the
// race-avoidance still applies.
expect(
shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')
// TMUX guard wins — native still fires because we're going through
// tmux load-buffer, not raw OSC 52 to the terminal.
).toBe(true)
})
it('SSH_CONNECTION takes precedence over allowlisted terminal', () => {
// Even on Ghostty, if we're SSH'd in we shouldn't run pbcopy on the
// remote machine — the user's clipboard is on the other end.
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
})
it('SSH_CONNECTION takes precedence over TMUX', () => {
// Combined: SSH'd in and inside tmux on the remote. SSH_CONNECTION
// gate fires first, native stays off (we use OSC 52 to reach the
// local terminal).
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'xterm')).toBe(
false
)
})
it('defaults env to process.env and terminal to the module-detected terminal when no args passed', () => {
// Smoke test: no args is a valid call shape for the convenience seam.
// shouldUseNativeClipboard() defaults `terminal` to envModule.terminal
// (the module-level detected terminal), not null. Returns a boolean
// without throwing.
expect(typeof shouldUseNativeClipboard()).toBe('boolean')
})
})

View file

@ -4,7 +4,7 @@
import { Buffer } from 'buffer'
import { env } from '../../utils/env.js'
import { env as envModule, supportsOsc52Clipboard } from '../../utils/env.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
@ -20,7 +20,7 @@ export const ST = ESC + '\\'
/** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator>
* Uses ST terminator for Kitty (avoids beeps), BEL for others */
export function osc(...parts: (string | number)[]): string {
const terminator = env.terminal === 'kitty' ? ST : BEL
const terminator = envModule.terminal === 'kitty' ? ST : BEL
return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
}
@ -102,6 +102,77 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env
return !!env['SSH_CONNECTION'] || (!env['TMUX'] && !env['STY'])
}
/**
* Decide whether setClipboard() should also fire the native clipboard tool
* (pbcopy / wl-copy / xclip / xsel / clip.exe) as a safety net alongside
* OSC 52 / tmux load-buffer.
*
* The default is "yes, native fires" it's the historical safety net for
* terminals where OSC 52 may not work (iTerm2 disables OSC 52 by default,
* Apple_Terminal / GNOME Terminal / xterm coverage is patchy). The two
* cases where we suppress it:
*
* 1. SSH session: native tools would write to the *remote* machine's
* clipboard. OSC 52 (which travels back over the pty to the user's
* local terminal) is the right path. Existing behaviour.
*
* 2. Allowlisted OSC-52-capable terminal AND we're actually going to
* emit an OSC 52 sequence AND we're not inside tmux/screen. On these
* terminals (Ghostty / kitty / WezTerm / Windows Terminal / VS Code)
* the OSC 52 write is reliable on its own, and racing it with a
* native tool is destructive wl-copy on Wayland in particular
* wipes the clipboard during its existence-probe and forks a daemon
* that races the terminal's own write (~30% empty-clipboard rate
* reported on Ghostty + Wayland; symptom: ctrl+shift+c works on the
* 3rd attempt).
*
* The TMUX/STY guard is important: detectTerminal() in utils/env.ts
* prefers TERM_PROGRAM over TMUX, so a tmux session inside Ghostty
* reports terminal='ghostty'. But inside tmux setClipboard() doesn't
* emit raw OSC 52 it goes through tmux load-buffer (which loads
* the tmux paste buffer and, with -w, asks tmux to forward an OSC 52
* to the OUTER terminal via its own emission path). The native
* safety net is still useful there because tmux load-buffer's
* outer-terminal forwarding depends on `set -g set-clipboard` and
* `allow-passthrough`, which many users don't have configured.
*
* The OSC-52-will-emit guard matters too: if the user has set
* HERMES_TUI_FORCE_OSC52=0, no OSC 52 sequence will be written. If
* we ALSO skip native, the clipboard write becomes a no-op. So skip
* native only when OSC 52 will actually carry the data.
*/
export function shouldUseNativeClipboard(
env: NodeJS.ProcessEnv = process.env,
terminal: string | null = envModule.terminal
): boolean {
// Over SSH the native tools would write to the wrong machine's clipboard.
if (env.SSH_CONNECTION) {
return false
}
// Inside tmux/screen, OSC 52 is normally suppressed and we rely on
// tmux load-buffer instead — so the wl-copy/OSC-52 race usually doesn't
// apply. Even when HERMES_TUI_FORCE_OSC52=1 forces a tmux-passthrough
// OSC 52 emission, we keep native enabled as a safety net: tmux's
// outer-terminal forwarding depends on `allow-passthrough` in the
// user's tmux config, so a forced OSC 52 may silently never reach the
// host terminal. Native (pbcopy/wl-copy/xclip) covers that gap.
if (env.TMUX || env.STY) {
return true
}
// If OSC 52 won't actually emit (user override or env state), the
// native tool is the only path left — keep it on.
if (!shouldEmitClipboardSequence(env)) {
return true
}
// OSC 52 is going to emit AND the terminal is in the allowlist of
// terminals where OSC 52 alone is reliable: skip native to avoid the
// wl-copy race documented above.
return !supportsOsc52Clipboard(terminal)
}
/**
* Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
* tmux forwards the payload to the outer terminal, bypassing its own parser.
@ -193,11 +264,27 @@ export async function setClipboard(text: string): Promise<ClipboardResult> {
// AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency
// before pbcopy even started — fast cmd+tab → paste would beat it
// (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
// Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
// forever but SSH_CONNECTION is in tmux's default update-environment and
// clears on local attach. Fire-and-forget, but `copyNativeAttempted`
// tells us whether ANY native path will be tried on this platform.
const nativeAttempted = !process.env['SSH_CONNECTION'] && copyNative(text)
// Skipped entirely on terminals with first-class OSC 52 support (see
// `shouldUseNativeClipboard()` above): running wl-copy/xclip/pbcopy in
// parallel with OSC 52 on those terminals can corrupt the clipboard.
// wl-copy on Wayland is the worst offender — `probeLinuxCopy()` runs it
// with empty stdin to check if the binary exists (which destructively
// wipes the clipboard), and the subsequent real invocation forks a
// background daemon that races the terminal's own OSC 52 write plus its
// own prior daemon's SIGTERM. On Ghostty + Wayland this produced a ~30%
// clipboard-empty rate (symptom: user had to press ctrl+shift+c three
// times before the selection landed). Native still fires inside
// tmux/screen — we primarily rely on tmux load-buffer there rather
// than raw OSC 52, so the wl-copy race usually doesn't apply, and
// native is kept as a safety net because tmux passthrough forwarding
// depends on the user's `allow-passthrough` config (note: when
// HERMES_TUI_FORCE_OSC52=1 we DO additionally emit a tmux-passthrough
// OSC 52, but it can be silently dropped without that setting).
// Native also fires when the user has disabled OSC 52 emission via
// HERMES_TUI_FORCE_OSC52=0 (otherwise the clipboard write becomes a
// complete no-op). Fire-and-forget, but `nativeAttempted` tells us
// whether ANY native path will be tried.
const nativeAttempted = shouldUseNativeClipboard(process.env, envModule.terminal) && copyNative(text)
const tmuxBufferLoaded = await tmuxLoadBuffer(text)

View file

@ -39,3 +39,28 @@ function detectTerminal(): TerminalName {
export const env = {
terminal: detectTerminal()
}
// Terminals known to correctly implement OSC 52 clipboard writes
// (ESC ] 52 ; c ; <b64> BEL/ST — osc() in ink/termio/osc.ts emits BEL
// for most terminals and ST for kitty). When detected, setClipboard() skips the
// native-tool safety net entirely — running wl-copy/xclip/pbcopy in
// parallel with OSC 52 races the terminal's own clipboard write and can
// corrupt it (e.g. wl-copy on Wayland holds the selection in a background
// daemon; stacking two writes within ~30ms triggers a SIGTERM race).
// Intentionally conservative: terminals with known flaky or disabled-by-
// default OSC 52 (iTerm2 disables OSC 52 by default; Alacritty detection
// is unreliable) are not on this list. Users on those terminals keep the
// existing behaviour (native safety net fires alongside OSC 52).
//
// Lives here in utils/env.ts (rather than ink/terminal.ts) so that
// ink/termio/osc.ts can import it without creating a circular dependency:
// ink/terminal.ts already imports `link` from ink/termio/osc.ts.
const OSC52_CAPABLE_TERMINALS = ['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode']
/** True if this terminal is known to correctly handle OSC 52 clipboard
* writes, so setClipboard() can skip the native-tool safety net.
* Accepts an optional terminal name for testability; defaults to the
* module-level `env.terminal` detected at startup. */
export function supportsOsc52Clipboard(terminal: string | null = env.terminal): boolean {
return OSC52_CAPABLE_TERMINALS.includes(terminal ?? '')
}