在 iTerm2 中将 macOS 图片剪贴板粘贴到远端 Codex
在 iTerm2 中将 macOS 图片剪贴板粘贴到远端 Codex
Section titled “在 iTerm2 中将 macOS 图片剪贴板粘贴到远端 Codex”目标体验很简单:
在 macOS 复制图片 ↓切回 iTerm2 中的 SSH 会话 ↓在远端 Codex CLI 按 Ctrl-V ↓图片显示为 [Image #1]问题在于,Codex CLI 运行在远端 Linux 上时,Ctrl-V 读取的是远端进程可见的系统剪贴板,而不是 iTerm2 所在 Mac 的剪贴板。仅仅开启 SSH X11 转发还不够:XQuartz 自带的 Pasteboard 代理通常只在 X11 应用重新获得焦点时同步剪贴板,所以同一条命令在 XQuartz 的 xterm 中有效,在原生 iTerm2 中却可能失败。
本文记录一个经过端到端验证的方案:使用一个用户级 LaunchAgent 持续监听 macOS Pasteboard,并将图片或文本同步到 XQuartz 的 X11 CLIPBOARD。配置完成后不再需要打开或聚焦 xterm,日常操作只剩下 Ctrl-V。
| 组件 | 版本 |
|---|---|
| macOS | 15.7.7 |
| iTerm2 | 3.6.10 |
| XQuartz | 2.8.6 |
| 本机 xclip | 0.13 |
| 远端系统 | Ubuntu 22.04 |
| Codex CLI | 0.145.0 |
在 iTerm2 中 SSH 到远端服务器,启动 Codex 后按 Ctrl-V:
Failed to paste image: clipboard unavailable:Unknown error while interacting with the clipboard:X11 server connection timed out because it was unreachable开启 X11 转发后,报错可能变成:
Error: target image/png not available此时常见的误判是 X11 转发仍未生效。但如果:
echo "$DISPLAY"已经得到类似:
localhost:10.0并且远端存在 xauth,那么 SSH 转发本身通常已经正常。真正缺失的是 macOS Pasteboard 到 XQuartz CLIPBOARD 的自动同步。
完整路径包含五层:
macOS Pasteboard → XQuartz X11 CLIPBOARD → ssh -Y 转发 → 远端 DISPLAY → Codex 的 Ctrl-V 图片读取Codex 在 Linux 上通过系统剪贴板接口读取图片,最终依赖 X11 或 Wayland。SSH 不会自动把本机图片剪贴板上传给远端进程;ssh -Y 只是让远端 X11 客户端连接到本机的 XQuartz。
XQuartz 的 Pasteboard 代理能够在 macOS 和 X11 之间转换文本与图片,但它检测 macOS Pasteboard 变化的时机与 X11 激活事件相关。这解释了一个关键差异:
- 从 XQuartz
xterm运行ssh -Y:切回xterm时 XQuartz 获得焦点,剪贴板随之同步。 - 从 iTerm2 运行
ssh -Y:iTerm2 是原生 macOS 应用,不会触发 XQuartz 的激活事件,X11CLIPBOARD可能继续保留旧内容。
因此,继续使用 iTerm2 且要求只按 Ctrl-V,需要一个独立于窗口焦点的剪贴板桥接器。
安装并配置 XQuartz
Section titled “安装并配置 XQuartz”brew install --cask xquartzopen -a XQuartz打开 XQuartz 的“偏好设置 → 粘贴板”,至少启用:
- 启用同步
- 粘贴板改变时更新
CLIPBOARD CLIPBOARD改变时更新粘贴板
PRIMARY 同步与 Codex 图片粘贴无关,可按需开启。

安装 XQuartz 后建议注销并重新登录 macOS,确保 SSH 能找到 /opt/X11/bin/xauth。
安装本机 xclip
Section titled “安装本机 xclip”桥接器通过本机 xclip 直接成为 XQuartz CLIPBOARD 的 owner:
brew install xclip确认安装路径:
command -v xclipApple Silicon Homebrew 通常返回 /opt/homebrew/bin/xclip,Intel Mac 通常是 /usr/local/bin/xclip。
配置 SSH X11 转发
Section titled “配置 SSH X11 转发”编辑本机 ~/.ssh/config:
Host gpu HostName your-server.example.com User root ForwardX11 yes ForwardX11Trusted yes也可以临时使用:
ssh -Y gpu远端需要启用 X11Forwarding,并安装 xauth 和用于验证的 xclip:
# Ubuntu / Debiansudo apt-get install xauth xclip
sudo sshd -T | grep '^x11forwarding'期望结果:
x11forwarding yes创建剪贴板桥接器
Section titled “创建剪贴板桥接器”桥接器使用 NSPasteboard.changeCount 检测变化,只有剪贴板真正变化时才读取内容:
- 图片转换为 PNG,并写入 X11 的
image/pngtarget。 - 文本写入
UTF8_STRING,防止图片之后复制文本时仍粘贴上一张旧图。 - 使用 SHA-256 避免 XQuartz 双向同步造成循环。
- 动态读取
launchctl getenv DISPLAY,不固化 XQuartz 每次登录生成的 socket 地址。 - XQuartz 尚未运行时尝试在后台启动,并保留待同步内容继续重试。
创建源码目录:
mkdir -p "$HOME/.local/share/codex-x11-clipboard-bridge"mkdir -p "$HOME/.local/bin"将下面内容保存为:
~/.local/share/codex-x11-clipboard-bridge/bridge.swiftimport AppKitimport CryptoKitimport Foundation
private let xclipPath = [ "/opt/homebrew/bin/xclip", "/usr/local/bin/xclip",].first { FileManager.default.isExecutableFile(atPath: $0)} ?? ""
private let pollInterval: TimeInterval = 0.25private let retryInterval: TimeInterval = 2
struct ClipboardPayload { let target: String let data: Data let signature: String}
private let timestampFormatter = ISO8601DateFormatter()
private func log(_ message: String) { let line = "\(timestampFormatter.string(from: Date())) \(message)\n" FileHandle.standardError.write(Data(line.utf8))}
private func signature(kind: String, data: Data) -> String { let digest = SHA256.hash(data: data) return "\(kind):\(digest.map { String(format: "%02x", $0) }.joined())"}
private func pngData(from image: NSImage) -> Data? { guard let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else { return nil }
return bitmap.representation(using: .png, properties: [:])}
private func readPayload(from pasteboard: NSPasteboard) -> ClipboardPayload { if let image = pasteboard .readObjects(forClasses: [NSImage.self], options: nil)? .first as? NSImage, let data = pngData(from: image) { return ClipboardPayload( target: "image/png", data: data, signature: signature(kind: "image/png", data: data) ) }
let text = pasteboard.string(forType: .string) ?? "" let data = Data(text.utf8) return ClipboardPayload( target: "UTF8_STRING", data: data, signature: signature(kind: "UTF8_STRING", data: data) )}
private func commandOutput(_ executable: String, _ arguments: [String]) -> String? { let process = Process() let output = Pipe()
process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments process.standardOutput = output process.standardError = FileHandle.nullDevice
do { try process.run() process.waitUntilExit() } catch { return nil }
guard process.terminationStatus == 0 else { return nil }
let data = output.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines)}
private func currentDisplay() -> String? { guard let display = commandOutput("/bin/launchctl", ["getenv", "DISPLAY"]), !display.isEmpty else { return nil }
return display}
private func startXQuartz() { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") process.arguments = ["-g", "-j", "-a", "XQuartz"] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try? process.run()}
private func sync(_ payload: ClipboardPayload, display: String) -> Bool { let process = Process() let input = Pipe() let errors = Pipe() var environment = ProcessInfo.processInfo.environment environment["DISPLAY"] = display
process.executableURL = URL(fileURLWithPath: xclipPath) process.arguments = [ "-selection", "clipboard", "-target", payload.target, "-in", ] process.environment = environment process.standardInput = input process.standardOutput = FileHandle.nullDevice process.standardError = errors
do { try process.run() input.fileHandleForWriting.write(payload.data) try input.fileHandleForWriting.close() process.waitUntilExit() } catch { log("sync failed: \(error)") return false }
guard process.terminationStatus == 0 else { let data = errors.fileHandleForReading.readDataToEndOfFile() let message = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) log("xclip exited \(process.terminationStatus): \(message ?? "unknown error")") return false }
return true}
guard !xclipPath.isEmpty else { log("xclip is missing; install it with: brew install xclip") exit(1)}
let pasteboard = NSPasteboard.generalvar observedChangeCount = -1var pendingPayload: ClipboardPayload?var lastSyncedSignature: String?var nextRetryAt = Date.distantPastvar lastXQuartzStartAttempt = Date.distantPast
log("bridge started")
while true { autoreleasepool { let changeCount = pasteboard.changeCount if changeCount != observedChangeCount { observedChangeCount = changeCount let payload = readPayload(from: pasteboard) if payload.signature != lastSyncedSignature { pendingPayload = payload nextRetryAt = Date.distantPast } }
let now = Date() if let payload = pendingPayload, now >= nextRetryAt { if let display = currentDisplay() { if sync(payload, display: display) { lastSyncedSignature = payload.signature pendingPayload = nil } else { nextRetryAt = now.addingTimeInterval(retryInterval) } } else { if now.timeIntervalSince(lastXQuartzStartAttempt) >= 10 { log("XQuartz DISPLAY unavailable; starting XQuartz") startXQuartz() lastXQuartzStartAttempt = now } nextRetryAt = now.addingTimeInterval(retryInterval) } } }
Thread.sleep(forTimeInterval: pollInterval)}编译:
swiftc -warnings-as-errors -O \ "$HOME/.local/share/codex-x11-clipboard-bridge/bridge.swift" \ -o "$HOME/.local/bin/codex-x11-clipboard-bridge"添加 LaunchAgent
Section titled “添加 LaunchAgent”创建:
~/Library/LaunchAgents/com.local.codex-clipboard-x11-bridge.plistlaunchd 不会在 plist 的字符串中展开 $HOME,因此下面的 YOUR_HOME 必须替换成真实绝对路径,例如 /Users/alice。
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key> <string>com.local.codex-clipboard-x11-bridge</string>
<key>ProgramArguments</key> <array> <string>YOUR_HOME/.local/bin/codex-x11-clipboard-bridge</string> </array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>ProcessType</key> <string>Background</string>
<key>ThrottleInterval</key> <integer>10</integer>
<key>StandardErrorPath</key> <string>YOUR_HOME/Library/Logs/codex-x11-clipboard-bridge.log</string>
<key>StandardOutPath</key> <string>YOUR_HOME/Library/Logs/codex-x11-clipboard-bridge.log</string></dict></plist>校验并加载:
plutil -lint \ "$HOME/Library/LaunchAgents/com.local.codex-clipboard-x11-bridge.plist"
launchctl bootstrap "gui/$(id -u)" \ "$HOME/Library/LaunchAgents/com.local.codex-clipboard-x11-bridge.plist"
launchctl enable \ "gui/$(id -u)/com.local.codex-clipboard-x11-bridge"
launchctl kickstart -k \ "gui/$(id -u)/com.local.codex-clipboard-x11-bridge"查看运行状态:
launchctl print \ "gui/$(id -u)/com.local.codex-clipboard-x11-bridge" | grep -E 'state =|pid =|runs ='正常结果包含:
state = running日志只记录启动和错误,不记录剪贴板正文:
tail -f "$HOME/Library/Logs/codex-x11-clipboard-bridge.log"先确认 macOS 剪贴板中确实是图片。截图到剪贴板必须包含 Control:
Control + Shift + Command + 4本机检查:
osascript -e 'clipboard info'输出应包含 TIFF picture、PNGf 等图片类型,而不应只有 utf8 和 string。
不要打开或聚焦 xterm,直接从 iTerm2 连接:
ssh -Y gpu远端检查:
echo "$DISPLAY"xclip -selection clipboard -t image/png -o > /tmp/clipboard-test.pngfile /tmp/clipboard-test.png期望结果类似:
/tmp/clipboard-test.png: PNG image data, 990 x 744, 8-bit/color RGBA最后启动 Codex:
codex按 Ctrl-V 后,输入框应出现:
[Image #1]tmux 的额外注意事项
Section titled “tmux 的额外注意事项”DISPLAY 是进程启动时继承的环境变量。已经运行的 Codex 或已经存在的 tmux pane 不会自动获得新 SSH 会话中的 DISPLAY。
推荐顺序:
ssh -Y gpu
# 在带有正确 DISPLAY 的 SSH 会话中再进入 tmuxtmux attach
# 新建窗口后重新启动或恢复 Codex如果 Codex 进程是在 X11 转发配置之前启动的,必须重新启动该进程;剪贴板桥接器无法修改既有进程的环境。
服务管理与卸载
Section titled “服务管理与卸载”重启桥接器:
launchctl kickstart -k \ "gui/$(id -u)/com.local.codex-clipboard-x11-bridge"停止并卸载:
launchctl bootout \ "gui/$(id -u)/com.local.codex-clipboard-x11-bridge"卸载后再按需删除二进制、源码和 plist。
ssh -Y 是可信 X11 转发。远端 X11 客户端能够访问本机 XQuartz 暴露的资源,其中包括同步后的剪贴板内容。因此:
- 只对可信服务器设置
ForwardX11Trusted yes。 - 不要对通配的
Host *全局启用可信 X11 转发。 - 不使用时可以停止 LaunchAgent 或退出 XQuartz。
- 桥接器本身只写入本机 X11
CLIPBOARD,不会主动建立网络连接;真正跨机器的数据通道仍是 SSH X11 转发。