Respect tmux clipboard policy and preserve empty reads

This commit is contained in:
Ryan Hughes committed 2026-09-13 19:48:41 -04:00
1 parent 3bfc327f8f
commit 0022e278c6
4 files changed
+191 -6

No files matched your search

+2 -1
View File
@@ -39,10 +39,11 @@ jobs:
-w /workspace \
archlinux:base-devel bash -lc '
set -euo pipefail
pacman -Syu --noconfirm git jq neovim
pacman -Syu --noconfirm git jq neovim tmux python
./bin/sync-upstream self-test
./bin/sync-rebuilds --self-test
./bin/omarchy-pkgs self-test
./bin/omarchy-release self-test
./tests/neovim-remote-clipboard.sh
python tests/neovim-clipboard-tmux.py
'
@@ -56,6 +56,11 @@ function M.setup()
local has_tmux = in_tmux and vim.fn.executable("tmux") == 1
local last_copy = {}
local function tmux_export_enabled()
local setting = vim.fn.systemlist({ "tmux", "show-options", "-sv", "set-clipboard" })
return vim.v.shell_error == 0 and (setting[1] == "on" or setting[1] == "external")
end
local function copy(register)
local emit = osc52.copy(register)
@@ -72,8 +77,21 @@ function M.setup()
if vim.g.omarchy_remote_clipboard_osc52 ~= false then
if has_tmux and register == "+" then
-- tmux ignores zero-byte loads. Record the current buffer's identity
-- instead: all Neovim instances see empty until a new buffer arrives,
-- without deleting the user's shared tmux clipboard history.
if #lines == 0 or (#lines == 1 and lines[1] == "") then
vim.fn.system({ "tmux", "set-option", "-sF", "@omarchy-nvim-cleared-buffer", "#{buffer_name}" })
return
end
-- Let tmux emit OSC 52, avoiding its input parser's payload size limit.
vim.fn.system({ "tmux", "load-buffer", "-w", "-" }, lines)
-- Explicit -w bypasses set-clipboard, so honor the user's policy first.
local cmd = { "tmux", "load-buffer" }
if tmux_export_enabled() then
cmd[#cmd + 1] = "-w"
end
cmd[#cmd + 1] = "-"
vim.fn.system(cmd, lines)
if vim.v.shell_error == 0 then
return
end
@@ -86,11 +104,21 @@ function M.setup()
local function paste(register)
return function()
local cmd
-- tmux's buffer is shared between Neovim instances in the session. Over
-- tmux's buffer is shared between Neovim instances on the server. Over
-- SSH, prefer it to the remote machine's unrelated graphical clipboard.
if has_tmux and register == "+" and (in_ssh or not has_wayland)
and vim.g.omarchy_remote_clipboard_osc52 ~= false then
cmd = { "tmux", "save-buffer", "-" }
local buffers = vim.fn.systemlist({ "tmux", "display-message", "-p",
"#{buffer_name}\n#{@omarchy-nvim-cleared-buffer}" })
if vim.v.shell_error ~= 0 then
return vim.deepcopy(last_copy[register] or { {}, "v" })
end
if not buffers[1] or buffers[1] == "" or buffers[1] == buffers[2] then
return { {}, "v" }
end
-- Pin the buffer selected above: a concurrent copy must not change
-- which payload we read after checking its identity against the marker.
cmd = { "tmux", "save-buffer", "-b", buffers[1], "-" }
elseif has_wayland then
cmd = { "wl-paste", "--no-newline" }
if register == "*" then
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Exercise the provider against real, isolated tmux buffers and fake terminals."""
import base64
import fcntl
import json
import os
from pathlib import Path
import pty
import select
import struct
import subprocess
import tempfile
import termios
import time
ROOT = Path(__file__).resolve().parents[1]
PROVIDER = ROOT / "pkgbuilds/omarchy-nvim/lua/config/remote_clipboard.lua"
def drain(fd, seconds=0.1):
data = b""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]:
try:
data += os.read(fd, 65536)
except OSError:
break
return data
with tempfile.TemporaryDirectory(prefix="nvim-tmux-test-") as directory:
root = Path(directory)
command = ["tmux", "-S", str(root / "socket"), "-f", "/dev/null"]
clients = []
def tmux(*args):
return subprocess.check_output(command + list(args), text=True)
driver = root / "driver.lua"
driver.write_text(r'''
local request = vim.json.decode(table.concat(vim.fn.readfile(vim.env.TEST_REQUEST), "\n"))
dofile(vim.env.TEST_PROVIDER).setup()
if request.operation == "copy" then
vim.fn.setreg("+", request.lines, request.regtype or "v")
else
local value = vim.g.clipboard.paste["+"]()
local lines = type(value[1]) == "table" and value[1] or value
io.write(vim.json.encode(lines))
end
''')
def nvim(operation, lines=None, regtype="v"):
request = root / "request.json"
request.write_text(json.dumps(dict(operation=operation, lines=lines, regtype=regtype)))
env = dict(os.environ)
for key in ("WAYLAND_DISPLAY", "DISPLAY", "HERDR_PANE_ID", "SSH_TTY"):
env.pop(key, None)
env.update(
TMUX=tmux("display-message", "-p", "#{socket_path},#{pid},0").strip(),
TMUX_PANE=tmux("display-message", "-p", "-t", "A:0.0", "#{pane_id}").strip(),
SSH_CONNECTION="test", TEST_PROVIDER=str(PROVIDER),
TEST_REQUEST=str(request),
)
result = subprocess.run(
["nvim", "--clean", "-n", "--headless", "-i", "NONE", "-l", str(driver)],
env=env, cwd=root, capture_output=True, text=True, timeout=15,
)
assert result.returncode == 0, result.stderr
if operation == "paste":
return json.loads(result.stdout)
try:
tmux("new-session", "-d", "-s", "A")
tmux("new-session", "-d", "-s", "B")
tmux("set-option", "-s", "terminal-features", "xterm*:clipboard")
tmux("set-option", "-g", "set-clipboard", "off")
# Every nvim() call is a separate instance: clearing must be shared.
nvim("copy", [""])
assert nvim("paste") == [], "empty server clipboard did not stay empty"
tmux("set-buffer", "older history")
nvim("copy", ["private previous value"])
assert nvim("paste") == ["private previous value"]
history = tmux("list-buffers")
for _ in range(2):
nvim("copy", [""])
assert nvim("paste") == [], "clear returned stale clipboard text"
assert tmux("list-buffers") == history, "clear destroyed tmux history"
tmux("set-buffer", "external copy")
assert nvim("paste") == ["external copy"], "new copy did not supersede clear"
nvim("copy", [""])
nvim("copy", ["new Neovim copy"])
assert nvim("paste") == ["new Neovim copy"]
print("ok - shared empty clipboard, preserved history, and subsequent copies")
for lines, regtype in [(["line"], "V"), (["a", "b"], "v"),
(["x" * (1024 * 1024 + 1)], "v"),
(["$(touch NEVER) `id`; quotes ' \"", "λ\u001b\n"], "v")]:
nvim("copy", lines, regtype)
# Neovim represents embedded NUL bytes as newline inside a list item.
expected = list(lines)
if regtype == "V":
expected.append("")
actual = nvim("paste")
assert actual == expected, ("transport changed clipboard bytes", repr(actual)[:200], repr(expected)[:200])
assert not (root / "NEVER").exists(), "clipboard content executed as shell code"
print("ok - real transport: linewise, multiline, large and literal/control payloads")
# Attach three PTYs, never the developer's terminal or clipboard.
for session in ("A", "A", "B"):
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
def controlling_tty():
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
env = dict(os.environ, TERM="xterm-256color")
env.pop("TMUX", None)
env.pop("TMUX_PANE", None)
process = subprocess.Popen(
command + ["attach-session", "-t", session], stdin=slave,
stdout=slave, stderr=slave, env=env, preexec_fn=controlling_tty,
)
os.close(slave)
clients.append((master, process))
drain(master)
for policy in ("off", "on", "external", "off"):
tmux("set-option", "-s", "set-clipboard", policy)
for fd, _ in clients:
drain(fd)
payload = "synthetic-export-" + policy
nvim("copy", [payload])
outputs = [drain(fd, 0.3) for fd, _ in clients]
exports = [b"\x1b]52;" in output for output in outputs]
assert not exports[2], "clipboard escaped into an unrelated session"
if policy == "off":
assert not any(exports), "set-clipboard off leaked OSC 52"
else:
assert sum(exports[:2]) == 1, "expected one selected tmux client"
assert any(base64.b64encode(payload.encode()) in output for output in outputs[:2])
assert nvim("paste") == [payload], "export policy broke internal paste"
print("ok - real clients: export policy, policy changes, selected-client isolation")
finally:
subprocess.run(command + ["kill-server"], capture_output=True)
for fd, process in clients:
process.wait(timeout=5)
os.close(fd)
+10 -2
View File
@@ -28,6 +28,14 @@ local function setup(env, options)
return ""
end
vim.fn.systemlist = function(cmd)
if cmd[2] == "show-options" then
real_system({ "sh", "-c", "exit 0" })
return { "on" }
end
if cmd[2] == "display-message" then
real_system({ "sh", "-c", failed and "exit 1" or "exit 0" })
return { "buffer-test", "" }
end
reads[#reads + 1] = cmd
real_system({ "sh", "-c", failed and "exit 1" or "exit 0" })
return vim.deepcopy(read_result or {})
@@ -88,7 +96,7 @@ equal(writes[1], { { "tmux", "load-buffer", "-w", "-" }, large })
assert(#emitted == 0)
read_result = { "external buffer" }
equal(vim.g.clipboard.paste["+"](), read_result)
equal(reads[1], { "tmux", "save-buffer", "-" })
equal(reads[1], { "tmux", "save-buffer", "-b", "buffer-test", "-" })
failed = true
equal(vim.g.clipboard.paste["+"](), { large, "v" })
write_failed = true
@@ -119,5 +127,5 @@ vim.g.clipboard.copy["+"]({ "both" }, "v")
assert(#writes == 2)
read_result = { "both" }
equal(vim.g.clipboard.paste["+"](), { { "both" }, "v" })
equal(reads[1], { "tmux", "save-buffer", "-" })
equal(reads[1], { "tmux", "save-buffer", "-b", "buffer-test", "-" })
print("ok - Wayland primary, external, empty and failed reads; SSH prefers tmux")