hermes-agent/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts
Ben Barclay 3c23b15f81
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 (#20954)
* 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
2026-05-11 19:40:07 -07:00

191 lines
9.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { env, supportsOsc52Clipboard } from '../../utils/env.js'
import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js'
describe('shouldEmitClipboardSequence', () => {
it('suppresses local multiplexer clipboard OSC by default', () => {
expect(shouldEmitClipboardSequence({ TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe(false)
expect(shouldEmitClipboardSequence({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv)).toBe(false)
})
it('keeps OSC enabled for remote or plain local terminals', () => {
expect(
shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)
).toBe(true)
expect(shouldEmitClipboardSequence({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
it('honors explicit env override', () => {
expect(
shouldEmitClipboardSequence({
HERMES_TUI_CLIPBOARD_OSC52: '1',
TMUX: '/tmp/tmux-1/default,1,0'
} as NodeJS.ProcessEnv)
).toBe(true)
expect(
shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)
).toBe(false)
})
it('HERMES_TUI_FORCE_OSC52 takes precedence over TMUX suppression', () => {
// Without the override, local-in-tmux suppresses the OSC 52 sequence
// so the terminal multiplexer path wins. FORCE_OSC52=1 flips that
// back on for users whose tmux config supports passthrough.
expect(shouldEmitClipboardSequence({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv)).toBe(false)
expect(
shouldEmitClipboardSequence({
HERMES_TUI_FORCE_OSC52: '1',
TMUX: '/tmp/t,1,0'
} as NodeJS.ProcessEnv)
).toBe(true)
})
it('HERMES_TUI_FORCE_OSC52=0 suppresses OSC 52 even for remote or plain terminals', () => {
expect(
shouldEmitClipboardSequence({
HERMES_TUI_FORCE_OSC52: '0',
SSH_CONNECTION: '1'
} as NodeJS.ProcessEnv)
).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')
})
})