https://github.com/shangxiang0907/codex-wsl-clipboard-bridge

https://shangxiang0907.github.io/codex-wsl-clipboard-bridge/

在这里插入图片描述

文章目录

让 WSL 里的 Codex CLI 正确粘贴 Windows 截图:一次跨 Wayland/X11 剪贴板排障实录

环境记录日期:2026-08-24
实测环境:Windows + WSL2/WSLg + Ubuntu 26.04 开发版 + Git Bash 终端窗口 + Codex CLI 0.149.0
最终方案:Wayland BMP → PNG → X11 CLIPBOARD 的事件驱动型用户服务

摘要

Codex CLI 明明提示可以按 Ctrl+V 粘贴图片,Windows 剪贴板里也确实有截图,但运行在 WSL Ubuntu 中的 Codex 始终报:

Failed to paste image: no image on clipboard: The clipboard contents were not
available in the requested format or the clipboard is empty.

问题最终不是 Codex 不支持图片,也不是 Windows 剪贴板为空,更不是关闭了 WSL 的 appendWindowsPath。真正原因是一次跨系统、跨显示协议、跨 MIME 格式的错位:

Windows 截图剪贴板
        │
        │ WSLg 同步
        ▼
Wayland clipboard: image/bmp
        │
        │ Codex 所用 Linux 剪贴板后端在本环境中读取不到这个表示
        ▼
X11 CLIPBOARD: 缺少 Codex 能取得的 image/png

手工执行下面这条命令后,Codex 立刻能显示 [Image #1],由此锁定了问题边界:

xclip -selection clipboard -t image/png -i /tmp/clipboard.png

最终我们实现了一个后台用户服务:用 XFixes 事件监听 X11 剪贴板所有者变化,从 WSLg 的 Wayland 剪贴板读取 image/bmp,经 ImageMagick 转成 image/png,再通过 xclip 发布到 X11。服务空闲时不轮询,实测 3 秒和 5 秒空闲窗口 CPU 增量均为 0。

OpenAI 官方 Codex CLI 文档也明确写到,codex --image 可以把视觉上下文随首个 prompt 传入,交互式 composer 支持直接粘贴图片。因此本文处理的是特定 WSL 剪贴板链路的兼容问题,而不是为 Codex 增加原本不存在的能力:Codex CLI 官方文档


一、问题是怎么出现的

1. 终端里的 Codex 支持图片,但按 Ctrl+V 没有成功

Codex CLI 启动界面显示:

Tip: Paste an image with Ctrl+V to attach it to your next message.

在 VS Code/Codex 桌面界面中,图片可以直接粘贴和预览;但在终端运行的 Codex CLI 中,Ctrl+V 触发的是 Codex composer 的“读取图片剪贴板”动作。它不是把某个 PNG 文件路径当作普通文本粘进去,而是要求 Codex 进程通过 Linux 剪贴板 API 取得图片数据。

最开始,在 VS Code 终端按键几乎没有可见反馈;换到 Git Bash 窗口后,Codex 至少显示了明确错误:

Failed to paste image: no image on clipboard

这说明按键确实已经交给 Codex,失败发生在“从剪贴板读取图片”阶段。

2. Windows 明明能看到剪贴板图片

在 Windows 侧用 STA PowerShell 检查:

powershell.exe -NoProfile -STA -Command \
  "Add-Type -AssemblyName System.Windows.Forms; \
   [Windows.Forms.Clipboard]::ContainsImage()"

这个 PowerShell 命令的作用是:检查当前 Windows 剪贴板中是否包含图像数据。

返回:

True

因此不能再用“可能没有复制成功”解释问题。

3. 剪贴板不是一个固定文件

剪贴板更像操作系统管理的一组“数据对象/数据提供者”,而不是一个固定路径的文件。一次截图可以同时宣告多种格式,例如位图、PNG、HTML 或应用私有格式;消费方会请求自己支持的格式。有些系统还采用延迟渲染:只有消费方请求某种格式时,当前剪贴板所有者才真正提供字节。

跨入 WSL 后又多了一层协议转换:

  • Windows 有自己的剪贴板机制;
  • WSLg 将它同步到 Linux GUI 环境;
  • Linux 侧同时可能存在 Wayland selection 和 X11 selection;
  • 同一张图在不同链路上暴露的 MIME 类型不一定相同;
  • Codex 使用的剪贴板库必须刚好连到正确的显示协议并请求到支持的图片类型。

所以“Windows 返回 ContainsImage=True”与“WSL 进程能否按期望格式读取图片”是两个不同问题。


二、逐层排查过程

1. 先排除 WSLg 环境变量和 socket 断开

在 WSL 中检查:

env | grep -E '^(DISPLAY|WAYLAND_DISPLAY|XDG_RUNTIME_DIR|WSL_INTEROP)='

当时得到的关键值是:

DISPLAY=:0
WAYLAND_DISPLAY=wayland-0
XDG_RUNTIME_DIR=/run/user/1000
WSL_INTEROP=/run/WSL/..._interop

再检查 Wayland socket:

ls -l "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"

结果指向 WSLg:

/run/user/1000/wayland-0 -> /mnt/wslg/runtime-dir/wayland-0

这说明 WSLg 的 GUI/Wayland 通道存在。appendWindowsPath=false 只控制是否把 Windows PATH 自动附加到 WSL 的 PATH,不控制剪贴板、Wayland、X11 或 WSLg socket,因此与本故障无关。

2. 用 wl-clipboard 直接查看 Wayland 剪贴板类型

安装并检查:

sudo apt install wl-clipboard
wl-paste --list-types

Windows 截图在 WSLg Wayland 侧只暴露为:

image/bmp

这一步非常关键:WSL 并非完全访问不到图片,它能访问,只是拿到的是 BMP。

3. 把 BMP 真正读出来

wl-paste --type image/bmp > /tmp/clipboard.bmp
file /tmp/clipboard.bmp

实测输出:

/tmp/clipboard.bmp: PC bitmap, Windows 3.x format, 228 x 207 x 24

因此可以排除:

  • Windows 剪贴板为空;
  • WSLg 完全没同步;
  • Wayland socket 损坏;
  • WSL 权限完全阻止了剪贴板读取。

4. 转成 PNG 后写回 Wayland,Codex 仍失败

安装 ImageMagick 并转换:

sudo apt install imagemagick
convert /tmp/clipboard.bmp /tmp/clipboard.png
wl-copy --type image/png < /tmp/clipboard.png
wl-paste --list-types

Wayland 已能看到 image/png,但 Codex 仍然报告没有图片。

这个结果进一步缩小范围:问题不仅是 BMP 与 PNG 的格式差异,还涉及 Wayland 与 X11 两套 selection。

5. 改用 xclip 发布 X11 PNG,Codex 立即成功

sudo apt install xclip
xclip -selection clipboard -t image/png -i /tmp/clipboard.png

这个命令的作用是将指定的 PNG 图片文件复制到Linux/X11 环境下的系统剪贴板中

随后在 Codex CLI 中按 Ctrl+V,composer 出现:

[Image #1]

在这里插入图片描述

这就是决定性证据。对当前 Codex CLI 0.149.0、当前终端启动方式和当前 WSLg 环境而言,Codex 的 Linux 剪贴板读取链路最终能从 X11 CLIPBOARDimage/png 成功取图,但没能直接消费 WSLg Wayland 上的 image/bmp

这是一条基于实测得出的环境结论,不应外推为“所有 Codex 版本永远只读 X11”。以后 Codex 或底层 clipboard 库升级后,行为可能改变。

不过,Codex 的 Linux 剪贴板读取链路是否能直接从 X11 CLIPBOARD 的 image/bmp 成功取图,这个倒没测,如果可以,我们可以省去转换格式那一步

6. 为什么 wl-copy 方案没有解决,而 xclip 解决了

两者写入的不是同一个协议面:

  • wl-copy 操作 Wayland selection;
  • xclip 操作 X11 selection;
  • WSLg 会在 Windows、Wayland 和 X11 之间进行一定程度的同步,但格式和同步时机不完全对称;
  • 这次 Codex 恰好能从 X11 PNG 成功取图。

因此最终桥接器不是简单“BMP 转 PNG”,而是:

Wayland image/bmp  --转换-->  PNG 字节  --发布-->  X11 CLIPBOARD image/png

三、为什么没有直接用 wl-paste --watch

wl-paste--watch 模式,看起来很适合做事件驱动服务。但在 WSLg 中实测:

Watch mode requires a compositor that supports the wlroots data-control protocol

WSLg 的 compositor 不提供该 wlroots data-control 协议,所以无法依靠 wl-paste --watch

最初做过一个每 0.5 秒执行一次 wl-paste --list-types 的轮询版本。它能工作,但审计发现:

  • 约 20 分 55 秒累计消耗 58.961 秒 CPU,约等于持续占用单核 4.7%;
  • 每轮都会启动多个外部进程;
  • 转换或 xclip 失败后仍可能记录源哈希,导致同一张图不再重试;
  • 管道没有完整的 pipefail 失败语义;
  • 错误输出被压掉,日志不足;
  • 没有输入大小、像素数、转换时间和服务资源限制;
  • 安装器没有依赖预检、备份、回滚和卸载能力。

所以最终没有继续修补轮询,而是用 XFixes 监听 X11 CLIPBOARD 所有者变化。

系统已有 libX11.so.6libXfixes.so.3 运行库,但没有 libx11-devlibxfixes-dev 头文件。为了不引入整套编译依赖,我们用 Python ctypes 直接调用运行库。监听器阻塞在 XNextEvent,没有事件时不会轮询。


四、最终方案的工作原理

1. 组件关系

Windows 截图
    │
    ▼
WSLg / Wayland selection
    │  MIME: image/bmp
    │
    │ X11 selection owner change(由 WSLg 同步触发)
    ▼
x11-clipboard-watch.py
    │  XFixesSelectSelectionInput + XNextEvent
    │  向 FIFO 输出 changed
    ▼
codex-clipboard-bridge (Bash)
    ├─ wl-paste --list-types
    ├─ wl-paste --type image/bmp
    ├─ ImageMagick: BMP → PNG32
    ├─ 规范化 RGBA 像素哈希,防止回环/重复
    └─ xclip -selection clipboard -t image/png
             │
             ▼
       X11 CLIPBOARD image/png
             │
             ▼
       Codex CLI Ctrl+V → [Image #1]

2. 事件驱动

Python 监听器连接 DISPLAY=:0,查询 XFixes 扩展,对 CLIPBOARD atom 注册 XFixesSetSelectionOwnerNotifyMask。之后阻塞等待事件,每次 selection owner 变化便向 FIFO 写一行 changed

Bash 主进程读取 FIFO;收到事件后等待 80 ms 并合并短时间内的重复事件,再处理一次剪贴板。这样既避免轮询,也避免 WSLg 一次同步产生的事件突发。

原子注册——
在 X11 协议中,Atom 是一个全局唯一的整数 ID,用来代表一个字符串名称。
为什么需要它? X11 是一个客户端-服务器架构,如果每次通信都传递 “CLIPBOARD”、“_NET_WM_NAME” 这样的长字符串,网络开销和内存消耗会非常大。因此,X11 设计了一个全局字符串表,每个字符串只存储一次,并分配一个 32 位整数作为句柄。这个整数就是 Atom。
在这段话中: CLIPBOARD atom 指的就是代表剪贴板的那个特定整数 ID。当程序说“我要操作 CLIPBOARD”时,实际上是在说“我要操作 ID 为 xxx 的那个选择区”。
类比: 就像数据库中的主键索引,或者编程中的符号表。你用数字 42 来代替字符串 “CLIPBOARD” 进行高效通信。

等待 80ms 并合并重复事件(防抖/Debounce): 这是为了解决 WSLg 的特殊问题。当你在 Windows 侧复制内容时,WSLg 可能会在极短时间内触发多次 Selection Owner 变更事件(例如先清空再写入,或内部同步机制导致)。如果不做合并,Bash 就会处理多次相同的剪贴板内容,造成浪费甚至逻辑错误。80ms 的窗口期足以将这些突发脉冲合并为一次有效操作。

3. 格式转换与回环保护

处理步骤如下:

  1. 查询 Wayland 剪贴板类型;
  2. 空剪贴板的 Nothing is copied 被视为正常状态;
  3. 仅当存在 image/bmp 时继续;
  4. 将 BMP 写入用户私有的 /run/user/1000/codex-clipboard-bridge/ 临时文件;
  5. 校验非空、最大 128 MiB、最大 1 亿像素;
  6. ImageMagick 在内存、映射、磁盘和时间限制内生成 PNG32;
  7. 对规范化 RGBA 像素计算 SHA-256,而不只比较容器文件字节;
  8. 与上次像素哈希相同则不再写回,避免 X11 ↔ WSLg 反射循环;
  9. 通过 xclip 声明 X11 CLIPBOARDimage/png
  10. 只有所有必要步骤成功后才提交哈希状态,临时失败会在后续事件中重试。

每个事件最多尝试 3 次。临时 BMP/PNG 在成功、退出或信号中都会清理。

“对规范化 RGBA 像素计算 SHA-256,而不只比较容器文件字节”
这是什么意思?
不要直接比较图片文件(如 BMP 或 PNG)的二进制内容,而是先把图片解码成最原始的像素数据(RGBA格式),再对这些纯像素数据计算哈希值。
为什么要这样做?
* 容器差异: 同一张图,从 BMP 转成 PNG,或者经过 ImageMagick 重新保存,文件的元数据、压缩算法、头部信息都会变。如果直接比对文件字节,哪怕像素完全一样,哈希值也会不同。
* 避免误判: 只有比对“肉眼看到的实际画面内容”(即规范化后的 RGBA 像素),才能准确判断两张图是否真的相同。这确保了后续的去重逻辑是基于视觉内容而非文件格式

“与上次像素哈希相同则不再写回,避免 X11 ↔ WSLg 反射循环”
这是什么意思?
如果新计算出的像素哈希值和上一次成功处理过的哈希值一模一样,就停止操作,不要把数据写入 X11 剪贴板。
为什么要这样做?(核心痛点)
在 WSLg 中,Wayland 和 X11 的剪贴板是互通的。如果没有这个保护,会发生以下死循环
1. Wayland 有张图 → 桥接程序把它转到 X11;
2. X11 剪贴板更新了 → WSLg 自动把 X11 的内容又反射回 Wayland;
3. Wayland 剪贴板又更新了 → 桥接程序检测到变化,再次转到 X11;
4. ……无限循环,导致 CPU 飙升、系统卡死。
通过像素哈希去重,当第3步发现“这图和刚才那张是一样的”,就直接丢弃,从而切断循环。

“只有所有必要步骤成功后才提交哈希状态,临时失败会在后续事件中重试”
这是什么意思?
更新“已处理的哈希值”这个动作,必须放在整个流程的最后一步。只有当 BMP 读取、校验、转换 PNG、xclip 写入等所有环节都 100% 成功了,才记录这个哈希。如果中间任何一步失败了,不要更新哈希。
为什么要这样做?
* 防止丢失数据: 假设转换成功了,但 xclip 写入 X11 失败了。如果你此时更新了哈希,下一次事件到来时,程序会发现“哈希没变”,于是跳过处理。结果就是:这张图永远无法被同步到 X11。
* 保证最终一致性: 不更新哈希意味着下次事件触发时,程序会认为“这是一张新图/未处理的图”,从而重新尝试整个流程,直到成功为止。配合“每个事件最多尝试3次”的限制,既保证了可靠性,又防止了因永久性错误导致的无限重试。

4. 为什么服务里一直有一个 xclip 进程

X11 selection 通常不是把所有数据永久复制进 X server;selection owner 需要在其他客户端请求目标格式时提供数据。xclip 读取 PNG 后会保留一个子进程来持有并服务该 selection。

因此下面这样的进程是正常设计,不是泄漏:

xclip -selection clipboard -t image/png -i /run/user/1000/.../candidate....png

路径对应的临时文件可以已经删除,因为 xclip 已经把内容读入内存。服务使用 KillMode=control-group,停止服务时会连同该 selection owner 一起结束。

1. 为什么剪贴板需要一个进程一直挂着?

在 Windows 或 macOS 中,当你复制内容时,系统会把数据完整地拷贝一份到系统的剪贴板内存里,然后原程序就可以退出了。

但在 Linux X11 环境下,机制完全不同:

  • 不自动存储: X Server(显示服务器)不会主动把复制的内容存下来。
  • 按需供给: X Server 只记录“谁拥有当前剪贴板内容”。当有其他程序(比如微信、编辑器)想要粘贴时,它们会向这个“拥有者”发起请求。
  • 拥有者必须在场: “拥有者”必须保持运行状态,随时准备响应请求并把数据传过去。如果拥有者进程退出了,剪贴板里的内容就立刻失效了。
2. xclip 在这里扮演什么角色?

文中提到的命令 xclip -selection clipboard -t image/png -i ... 的作用是读取一张图片并放入剪贴板。

  • 因为 X11 的上述机制,xclip 读完文件后不能退出
  • 它会保留一个子进程驻留在后台,充当这张图片的 “临时服务器”
  • 每当有程序尝试粘贴这张图片时,这个驻留的 xclip 进程就会把内存中的图片数据发送给对方。
3. 关于临时文件和内存

“路径对应的临时文件可以已经删除,因为 xclip 已经把内容读入内存。”

这意味着 xclip 在启动时已经把图片加载到了 RAM 中。即使原始的 .png 临时文件被删除了,只要 xclip 进程还活着,剪贴板里的图片就是安全的、可粘贴的。

5. systemd 管理和安全边界

服务是普通用户服务,不需要 root:

  • Restart=on-failure
  • 私有运行目录,权限 0700
  • UMask=0077
  • NoNewPrivileges=true
  • 只允许 AF_UNIX socket;
  • 禁止 namespace 创建、实时调度、SUID/SGID 创建、ABI personality 修改和可写可执行内存;
  • 阻断时钟、调试、模块、挂载、过时、特权、raw I/O、重启、swap 等 syscall 组;
  • CPUQuota=50%
  • MemoryHigh=512MMemoryMax=768M
  • TasksMax=32
  • 文件描述符和单文件大小限制。

曾尝试过 CapabilityBoundingSet=PrivateDevices=ProtectKernel*=ProtectSystem= 等更强规则,但 WSL 用户级 systemd 无法执行对应的 capability/namespace 操作,服务以 218/CAPABILITIES 失败。安装器成功自动回滚。后来又发现 LimitNPROC=64 是按整个用户计算,而不是按该服务计算,导致 fork: Resource temporarily unavailable;最终改用按 cgroup 生效的 TasksMax=32

systemd-analyze --user security 最终从约 9.8 EXPOSED 降到 6.0 MEDIUM。该评分仍把用户服务误判成“以 root 运行”并要求隔离 /tmp、根目录等;但 X11 socket 位于 /tmp/.X11-unix,盲目追求评分会破坏功能。这里选择的是 WSL 能真正执行的安全控制,而不是加入会被忽略或导致启动失败的配置。


五、前置依赖

系统条件

  • Windows 11 或具备 WSLg 的 WSL2 环境;
  • WSLg 的 Wayland 和 X11 通道可用;
  • Ubuntu 用户级 systemd 正常运行;
  • DISPLAY=:0
  • WAYLAND_DISPLAY=wayland-0
  • XDG_RUNTIME_DIR=/run/user/<uid>
  • libX11.so.6libXfixes.so.3 运行库;
  • Bash、Python 3、coreutils、util-linux、systemd。

当前机器 /etc/wsl.conf 为:

[boot]
systemd=true

[user]
default=user

[interop]
appendWindowsPath=false

这些内容是已有配置,桥接器没有修改 /etc/wsl.confappendWindowsPath=false 不影响剪贴板,只意味着 Windows 可执行文件不会自动出现在 WSL 的 PATH 中。

需要安装的直接软件包

sudo apt update
sudo apt install wl-clipboard imagemagick xclip

当前机器的版本:

wl-clipboard  2.2.1-2build1
imagemagick   8:7.1.2.18+dfsg1-1
xclip         0.13-4build1
libx11-6      2:1.8.13-1
libxfixes3    1:6.0.0-2build2
python3       3.14.3-0ubuntu2(/usr/bin/python3.14 运行时报告 3.14.4)
systemd       259.5-0ubuntu3.4

桥接器同时兼容 ImageMagick 7 的 magick 命令和旧式 convert/identify 命令。


六、目录结构和所有文件位置

Windows 工作区中的源码

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\
├── BLOG.zh-CN.md
├── README.md
├── codex-clipboard-bridge
├── x11-clipboard-watch.py
├── codex-clipboard-bridge.service
├── install.sh
├── uninstall.sh
├── verify.sh
└── integration-test.sh

WSL 中对应路径:

/mnt/c/Users/User/Documents/Codex/2026-08-22/wo/work/codex-clipboard-bridge/

源码目录当前约 40 KiB。

安装到 WSL 用户目录的文件

/home/user/.local/bin/codex-clipboard-bridge                         0755
/home/user/.local/libexec/codex-clipboard-bridge/x11-clipboard-watch.py 0755
/home/user/.config/systemd/user/codex-clipboard-bridge.service      0644

启用服务会创建链接:

/home/user/.config/systemd/user/default.target.wants/
└── codex-clipboard-bridge.service
    -> /home/user/.config/systemd/user/codex-clipboard-bridge.service

运行时目录

服务运行时由 systemd 管理:

/run/user/1000/codex-clipboard-bridge/           0700
├── bridge.lock                                  0600
├── events.fifo                                  0600
├── last-source.sha256                           0600(按状态可能不存在)
├── last-pixels.sha256                           0600(按状态可能不存在)
└── candidate.*.bmp / candidate.*.png            临时,处理后删除

当前审计只看到锁文件和 FIFO,没有残留截图文件。

备份目录

每次安装/升级都先保存旧版本:

/home/user/.local/share/codex-clipboard-bridge/backups/<UTC时间>-<PID>/

本次多轮部署和自动回滚留下 6 个备份目录,总计约 200 KiB:

20260823T180701Z-54193
20260823T180753Z-55032
20260823T180832Z-55671
20260823T180902Z-55812
20260823T180953Z-56064
20260823T181225Z-56720

前几个目录包含最初的轮询版本,后几个包含逐步加固的事件版本。


七、部署、验证、运行与卸载

部署或升级

从 WSL 执行:

cd /mnt/c/Users/User/Documents/Codex/2026-08-22/wo/work/codex-clipboard-bridge
bash ./install.sh

安装器会:

  1. 检查命令依赖;
  2. 执行 Bash 语法检查;
  3. 实连 X11/XFixes;
  4. 检查 WSLg Wayland socket;
  5. 检查用户级 systemd;
  6. 备份现有脚本、监听器和 unit;
  7. .new 文件加 mv 原子替换;
  8. daemon-reload、enable、restart;
  9. 检查服务 active;
  10. 检查至少存在 Bash 主进程和 Python 监听器两个任务;
  11. 失败时打印日志并自动恢复旧文件和旧运行状态。

静态与事件验证

bash ./verify.sh

它检查 Bash、X11/XFixes、systemd unit 和 XFixes 事件投递。如果当前 X11 剪贴板有 PNG,会原样重新声明同一图片以测试事件,不改变图像内容;没有 PNG 时跳过事件投递测试。

端到端验证

bash ./integration-test.sh /path/to/fixture.bmp

它会把指定 BMP 写进 Wayland 剪贴板,等待服务自动桥接,再从 X11 读取 PNG。注意:该测试会有意替换当前剪贴板内容。

本次实测:

PNG image data, 228 x 207, 8-bit/color RGBA, non-interlaced
End-to-end Wayland BMP -> X11 PNG: OK

日常管理

systemctl --user status codex-clipboard-bridge.service
systemctl --user restart codex-clipboard-bridge.service
journalctl --user -u codex-clipboard-bridge.service -f

当前实测状态:

ActiveState=active
SubState=running
NRestarts=0
TasksCurrent=3       # Bash + Python watcher + 当前 xclip selection owner
MemoryCurrent≈11-12 MiB

事件转换日志示例:

codex-clipboard-bridge: bridged 228x207 image (141642 byte BMP) to X11 as PNG

卸载服务

cd /mnt/c/Users/User/Documents/Codex/2026-08-22/wo/work/codex-clipboard-bridge
bash ./uninstall.sh

卸载脚本会:

  • disable 并停止用户服务;
  • 删除已安装的 Bash 脚本;
  • 删除已安装的 Python 监听器;
  • 删除 systemd unit;
  • 重新加载用户 systemd;
  • 清除 unit 的 failed 状态。

它不会删除:

  • Windows 工作区源码;
  • ~/.local/share/codex-clipboard-bridge/backups/ 备份;
  • apt 安装的软件包;
  • 排障阶段手工生成的 /tmp/clipboard.bmp/tmp/clipboard.png

如确认不再需要软件包,可另行执行:

sudo apt remove wl-clipboard imagemagick xclip

不要未经检查就执行 apt autoremove,因为自动依赖可能已被其他应用使用。备份若确认不再需要,可在核对目标路径后删除:

rm -r -- "$HOME/.local/share/codex-clipboard-bridge"

该操作不可由卸载脚本恢复。


八、对 WSL Ubuntu 的全部已知影响

1. apt 直接安装

apt 历史记录显示用户在排障中执行了:

2026-08-24 00:01:19  apt install wl-clipboard
2026-08-24 00:06:51  apt install imagemagick
2026-08-24 00:15:58  apt install xclip

三个包目前都被标记为手工安装。

2. apt 自动安装的依赖/推荐包

本机 apt install wl-clipboard 还安装了以下包(具体集合会随 Ubuntu 版本和 apt recommends 策略变化):

alacritty
libauthen-sasl-perl
libclone-perl
libcrypt-urandom-perl
libdata-dump-perl
libencode-locale-perl
libfile-basedir-perl
libfile-desktopentry-perl
libfile-listing-perl
libfile-mimeinfo-perl
libfont-afm-perl
libhtml-format-perl
libhtml-form-perl
libhtml-parser-perl
libhtml-tagset-perl
libhtml-tree-perl
libhttp-cookies-perl
libhttp-daemon-perl
libhttp-date-perl
libhttp-message-perl
libhttp-negotiate-perl
libio-html-perl
libio-socket-ssl-perl
libio-stringy-perl
libipc-system-simple-perl
liblwp-mediatypes-perl
liblwp-protocol-https-perl
libmailtools-perl
libnet-dbus-perl
libnet-http-perl
libnet-smtp-ssl-perl
libnet-ssleay-perl
libtie-ixhash-perl
libtimedate-perl
libtry-tiny-perl
liburi-perl
libwww-perl
libwww-robotrules-perl
libx11-protocol-perl
libxcb-shape0
libxft2
libxml-parser-perl
libxml-twig-perl
libxml-xpathengine-perl
libxv1
libxxf86dga1
luit
ncurses-term
perl-openssl-defaults
wl-clipboard
x11-utils
x11-xserver-utils
xdg-utils

apt install imagemagick 安装了:

fonts-droid-fallback
fonts-noto-mono
fonts-urw-base35
ghostscript
imagemagick
imagemagick-7-common
imagemagick-7.q16
libdjvulibre-text
libdjvulibre21
libgs-common
libgs10
libgs10-common
libidn12
libijs-0.35
libjbig2dec0
libjxr-tools
libjxr0t64
liblqr-1-0
libltdl7
libmagickcore-7.q16-10
libmagickcore-7.q16-10-extra
libmagickwand-7.q16-10
libnetpbm11t64
libpaper-utils
libpaper2
libraw23t64
libwmflite-0.2-7
netpbm
poppler-data

apt install xclip 只新增了 xclip 本身。

3. 用户目录新增文件

最终服务新增或管理:

~/.local/bin/codex-clipboard-bridge
~/.local/libexec/codex-clipboard-bridge/x11-clipboard-watch.py
~/.config/systemd/user/codex-clipboard-bridge.service
~/.config/systemd/user/default.target.wants/codex-clipboard-bridge.service
~/.local/share/codex-clipboard-bridge/backups/

4. 运行时和进程影响

  • 用户登录且 WSL 用户级 systemd 活跃时,服务自动运行;
  • 当前 Linger=no,用户会话结束/WSL 关闭后不会靠该服务永久保持运行;
  • 通常有 Bash 主进程、Python XFixes 监听器和一个 xclip selection owner;
  • 运行时目录位于 /run/user/1000,重启或停止服务后不作为持久数据保留;
  • 图片转换期间会短暂使用 CPU、内存和运行时临时文件;
  • 空闲时事件监听阻塞,实测 CPU 不增长;
  • xclip 会把当前桥接后的 PNG 保存在进程内存中,直到 selection 被替换或服务停止;
  • journal 记录启动、停止、失败和成功转换的尺寸/字节数,不记录图片内容。

5. 排障临时文件

当前仍存在:

/tmp/clipboard.bmp  141642 bytes, 228x207, PC bitmap
/tmp/clipboard.png  578 bytes, 228x207, grayscale PNG

它们不是服务运行所需文件,服务也不会读取固定 /tmp/clipboard.* 路径;只是排障和集成测试留下的样本。

6. 未修改的内容

本方案没有:

  • 修改 /etc/wsl.conf
  • 修改 shell 的 .bashrc.profile
  • 修改 Windows 注册表、Windows 服务或系统剪贴板设置;
  • 启用 systemd --user linger;
  • 安装 libx11-devlibxfixes-dev
  • 开放网络端口;
  • 使用 root 运行桥接服务;
  • 修改 Codex CLI 本身。

九、局限性和适用边界

  1. 这是环境兼容层,不是 Codex 官方组件。 它针对本机 Codex CLI 0.149.0 + WSLg 的实测行为;未来 Codex/WSLg 改善后可能不再需要。
  2. 当前只主动处理 Wayland image/bmp 如果 Windows/WSLg 以后只提供 JPEG、WebP、TIFF 或其他类型,脚本会忽略;可以扩展 MIME 分支。
  3. 事件源依赖 X11 owner change。 纯 Wayland selection 必须由 WSLg 同步成一次 X11 变化才能唤醒监听器;在本机 WSLg 已端到端验证,但其他 compositor 不保证。
    在 Linux 图形系统和 Wayland 的语境下,Compositor(合成器) 指的是负责管理屏幕显示、窗口合成以及输入事件分发的核心程序。WSLg 是一个特定的 Compositor 实现:它内置了 RDP 服务端和 XWayland,专门做了 X11 ↔ Wayland 的双向剪贴板桥接。所以在本机 WSLg 上验证通过。
    其他 Compositor 行为不一致:GNOME (Mutter)、KDE (KWin)、Sway、Hyprland 等各自独立实现了 Wayland 协议。对于 X11 Owner Change 事件 与 Wayland Selection 通知 之间的同步机制,Wayland 协议本身并没有强制规定必须触发 X11 侧的事件。因此,依赖 X11 owner change 来感知 Wayland 剪贴板变化的做法,在非 WSLg 环境下是不可靠的。
  4. 不能使用 wl-paste --watch WSLg 当前缺少 wlroots data-control 协议,因此才采用 XFixes。
  5. 会重新声明剪贴板。 原始 Windows 多格式图像最终在 X11 侧被发布为 PNG;某些依赖原始 BMP/专有格式的应用可能观察到格式变化。
  6. X11 selection 需要持有者。 一个 xclip 子进程会长期存在并在内存里持有 PNG,这是 X11 机制决定的。
  7. 存在转换开销。 事件发生时 ImageMagick 会解码、转码并再次规范化像素;大图会产生短时 CPU/内存占用。
  8. 主动拒绝超大输入。 默认 BMP 最大 128 MiB、最大 1 亿像素;超过限制的截图不会桥接。
  9. systemd 加固受 WSL 限制。 一些在原生 Linux 系统服务中常见的 capability、设备和 mount namespace 防护在 WSL 用户服务中不可执行。
  10. 安全评分不是绝对值。 systemd-analyze --user security 的 6.0 包含把用户服务当 root 等误判,也有因 X11 /tmp socket 而无法启用的隔离项。
  11. 服务不会让 WSL 永久驻留。 当前 Linger=no;若用户会话未启动,服务也未运行。第一次进入 WSL 后用户 manager 会按 enabled unit 启动它。
  12. 测试可能修改剪贴板。 integration-test.sh 明确会替换当前剪贴板,使用前应保存重要内容。
  13. 私密图片仍经过 Linux 进程内存。 临时文件位于 0700 运行目录并及时删除,但处理中的图片会短暂存在磁盘页缓存、ImageMagick/xclip 内存中。
  14. 不是远程 UI。 它只修复本机终端 Codex 对图片剪贴板的读取,不会把终端 Codex 会话变成网页聊天,也不提供 Claude Code Remote 类能力。

如果只偶尔需要一张图片,更简单、边界更清晰的替代方式仍然是保存图片后使用 Codex 官方提供的 codex --image /path/to/image.png;后台桥接适合频繁截图、希望直接 Ctrl+V 的场景。


十、完整源码

下面代码与最终部署版本一致。

1. codex-clipboard-bridge

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\codex-clipboard-bridge

安装位置:

/home/user/.local/bin/codex-clipboard-bridge
#!/usr/bin/env bash

set -Eeuo pipefail
umask 077

readonly program_name="codex-clipboard-bridge"
readonly max_input_bytes="${CODEX_CLIPBOARD_MAX_INPUT_BYTES:-134217728}"
readonly max_pixels="${CODEX_CLIPBOARD_MAX_PIXELS:-100000000}"

export DISPLAY="${DISPLAY:-:0}"
export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export MAGICK_MEMORY_LIMIT="${MAGICK_MEMORY_LIMIT:-256MiB}"
export MAGICK_MAP_LIMIT="${MAGICK_MAP_LIMIT:-512MiB}"
export MAGICK_DISK_LIMIT="${MAGICK_DISK_LIMIT:-256MiB}"
export MAGICK_TIME_LIMIT="${MAGICK_TIME_LIMIT:-20}"
export LC_ALL=C

readonly state_dir="${XDG_RUNTIME_DIR}/${program_name}"
readonly watcher="${CODEX_CLIPBOARD_WATCHER:-${HOME}/.local/libexec/${program_name}/x11-clipboard-watch.py}"
readonly source_hash_file="${state_dir}/last-source.sha256"
readonly pixel_hash_file="${state_dir}/last-pixels.sha256"
readonly event_fifo="${state_dir}/events.fifo"

watcher_pid=""
candidate_bmp=""
candidate_png=""
declare -a convert_command
declare -a inspect_command

log() {
    printf '%s: %s\n' "${program_name}" "$*" >&2
}

cleanup_candidates() {
    [[ -z "${candidate_bmp}" ]] || rm -f -- "${candidate_bmp}"
    [[ -z "${candidate_png}" ]] || rm -f -- "${candidate_png}"
    candidate_bmp=""
    candidate_png=""
}

cleanup() {
    local exit_code=$?

    trap - EXIT INT TERM HUP
    cleanup_candidates
    rm -f -- "${event_fifo}"

    if [[ -n "${watcher_pid}" ]] && kill -0 "${watcher_pid}" 2>/dev/null; then
        kill "${watcher_pid}" 2>/dev/null || true
        wait "${watcher_pid}" 2>/dev/null || true
    fi

    exit "${exit_code}"
}

write_state() {
    local value=$1
    local destination=$2
    local temporary

    temporary="$(mktemp --tmpdir="${state_dir}" state.XXXXXX)"
    printf '%s\n' "${value}" >"${temporary}"
    mv -f -- "${temporary}" "${destination}"
}

read_state() {
    local source=$1

    if [[ -r "${source}" ]]; then
        IFS= read -r REPLY <"${source}" || true
        printf '%s' "${REPLY:-}"
    fi
}

clear_loop_state() {
    rm -f -- "${source_hash_file}" "${pixel_hash_file}"
}

process_clipboard() {
    local clipboard_types
    local source_hash
    local previous_source_hash
    local pixel_hash
    local previous_pixel_hash
    local dimensions
    local width
    local height
    local input_size
    local types_error
    local types_error_file

    cleanup_candidates

    types_error_file="$(mktemp --tmpdir="${state_dir}" types-error.XXXXXX)"
    if ! clipboard_types="$(timeout 3s wl-paste --list-types 2>"${types_error_file}")"; then
        types_error="$(<"${types_error_file}")"
        rm -f -- "${types_error_file}"
        if [[ "${types_error}" == "Nothing is copied" ]]; then
            clear_loop_state
            return 0
        fi
        log "cannot query the Wayland clipboard"
        return 20
    fi
    rm -f -- "${types_error_file}"

    if ! grep -Fxq 'image/bmp' <<<"${clipboard_types}"; then
        if grep -Fxq 'image/png' <<<"${clipboard_types}"; then
            rm -f -- "${source_hash_file}"
        else
            clear_loop_state
        fi
        return 0
    fi

    candidate_bmp="$(mktemp --tmpdir="${state_dir}" candidate.XXXXXX.bmp)"
    if ! timeout 5s wl-paste --type image/bmp >"${candidate_bmp}"; then
        log "failed to read image/bmp from the Wayland clipboard"
        return 21
    fi

    input_size="$(stat -c '%s' -- "${candidate_bmp}")"
    if ((input_size == 0)); then
        log "ignored an empty BMP clipboard payload"
        return 22
    fi
    if ((input_size > max_input_bytes)); then
        log "rejected BMP payload (${input_size} bytes; limit ${max_input_bytes})"
        return 23
    fi

    source_hash="$(sha256sum -- "${candidate_bmp}")"
    source_hash="${source_hash%% *}"
    previous_source_hash="$(read_state "${source_hash_file}")"
    if [[ "${source_hash}" == "${previous_source_hash}" ]]; then
        return 0
    fi

    if ! dimensions="$(timeout 5s "${inspect_command[@]}" -format '%w %h' -- "${candidate_bmp}")"; then
        log "ImageMagick could not inspect the BMP clipboard payload"
        return 24
    fi
    read -r width height <<<"${dimensions}"
    if [[ ! "${width}" =~ ^[0-9]+$ || ! "${height}" =~ ^[0-9]+$ ]] ||
        ((width == 0 || height == 0 || width > max_pixels / height)); then
        log "rejected invalid or oversized image dimensions (${dimensions})"
        return 25
    fi

    candidate_png="$(mktemp --tmpdir="${state_dir}" candidate.XXXXXX.png)"
    if ! timeout 25s "${convert_command[@]}" \
        -limit memory "${MAGICK_MEMORY_LIMIT}" \
        -limit map "${MAGICK_MAP_LIMIT}" \
        -limit disk "${MAGICK_DISK_LIMIT}" \
        -limit time "${MAGICK_TIME_LIMIT}" \
        "${candidate_bmp}" -strip "PNG32:${candidate_png}"; then
        log "ImageMagick failed to convert the clipboard image"
        return 26
    fi

    if ! pixel_hash="$(
        timeout 25s "${convert_command[@]}" \
            -limit memory "${MAGICK_MEMORY_LIMIT}" \
            -limit map "${MAGICK_MAP_LIMIT}" \
            -limit disk "${MAGICK_DISK_LIMIT}" \
            -limit time "${MAGICK_TIME_LIMIT}" \
            "${candidate_png}" -alpha on -depth 8 rgba:- |
            sha256sum
    )"; then
        log "failed to calculate the normalized image hash"
        return 27
    fi
    pixel_hash="${pixel_hash%% *}"
    previous_pixel_hash="$(read_state "${pixel_hash_file}")"

    if [[ "${pixel_hash}" != "${previous_pixel_hash}" ]]; then
        if ! timeout 10s xclip -selection clipboard -t image/png -i "${candidate_png}"; then
            log "failed to publish image/png to the X11 clipboard"
            return 28
        fi
        write_state "${pixel_hash}" "${pixel_hash_file}"
        log "bridged ${width}x${height} image (${input_size} byte BMP) to X11 as PNG"
    fi

    write_state "${source_hash}" "${source_hash_file}"
    cleanup_candidates
    return 0
}

process_with_retry() {
    local attempt
    local result=0

    for attempt in 1 2 3; do
        if process_clipboard; then
            return 0
        else
            result=$?
        fi
        ((attempt == 3)) || sleep "0.${attempt}"
    done

    log "clipboard event failed after 3 attempts (status ${result})"
    return 0
}

main() {
    local required

    for required in flock grep mktemp python3 sha256sum stat timeout wl-paste xclip; do
        command -v "${required}" >/dev/null 2>&1 || {
            log "required command is missing: ${required}"
            return 69
        }
    done
    if command -v magick >/dev/null 2>&1; then
        convert_command=(magick)
        inspect_command=(magick identify)
    elif command -v convert >/dev/null 2>&1 && command -v identify >/dev/null 2>&1; then
        convert_command=(convert)
        inspect_command=(identify)
    else
        log "ImageMagick is required (magick, or convert plus identify)"
        return 69
    fi
    [[ -r "${watcher}" ]] || {
        log "X11 watcher is missing: ${watcher}"
        return 69
    }

    mkdir -p -- "${state_dir}"
    chmod 700 -- "${state_dir}"
    exec 9>"${state_dir}/bridge.lock"
    if ! flock -n 9; then
        log "another bridge instance is already running"
        return 0
    fi

    trap cleanup EXIT INT TERM HUP
    rm -f -- "${event_fifo}"
    mkfifo -m 600 -- "${event_fifo}"

    python3 -B "${watcher}" >"${event_fifo}" &
    watcher_pid=$!
    exec 8<"${event_fifo}"

    process_with_retry
    while IFS= read -r _ <&8; do
        sleep 0.08
        while IFS= read -r -t 0.01 _ <&8; do :; done
        process_with_retry
    done

    wait "${watcher_pid}" || true
    log "X11 event watcher exited unexpectedly"
    return 70
}

main "$@"

2. x11-clipboard-watch.py

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\x11-clipboard-watch.py

安装位置:

/home/user/.local/libexec/codex-clipboard-bridge/x11-clipboard-watch.py
#!/usr/bin/env python3
"""Block on XFixes CLIPBOARD-owner events and emit one line per change."""

from __future__ import annotations

import argparse
import ctypes
import ctypes.util
import os
import signal
import sys


class XEvent(ctypes.Union):
    _fields_ = [("type", ctypes.c_int), ("padding", ctypes.c_long * 24)]


class X11Watcher:
    XFIXES_SET_SELECTION_OWNER_NOTIFY_MASK = 1
    XFIXES_SELECTION_NOTIFY_OFFSET = 0

    def __init__(self) -> None:
        x11_name = ctypes.util.find_library("X11") or "libX11.so.6"
        xfixes_name = ctypes.util.find_library("Xfixes") or "libXfixes.so.3"
        self.x11 = ctypes.CDLL(x11_name)
        self.xfixes = ctypes.CDLL(xfixes_name)

        self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
        self.x11.XOpenDisplay.restype = ctypes.c_void_p
        self.x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
        self.x11.XCloseDisplay.restype = ctypes.c_int
        self.x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
        self.x11.XDefaultRootWindow.restype = ctypes.c_ulong
        self.x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
        self.x11.XInternAtom.restype = ctypes.c_ulong
        self.x11.XNextEvent.argtypes = [ctypes.c_void_p, ctypes.POINTER(XEvent)]
        self.x11.XNextEvent.restype = ctypes.c_int
        self.x11.XFlush.argtypes = [ctypes.c_void_p]
        self.x11.XFlush.restype = ctypes.c_int

        self.xfixes.XFixesQueryExtension.argtypes = [
            ctypes.c_void_p,
            ctypes.POINTER(ctypes.c_int),
            ctypes.POINTER(ctypes.c_int),
        ]
        self.xfixes.XFixesQueryExtension.restype = ctypes.c_int
        self.xfixes.XFixesSelectSelectionInput.argtypes = [
            ctypes.c_void_p,
            ctypes.c_ulong,
            ctypes.c_ulong,
            ctypes.c_ulong,
        ]
        self.xfixes.XFixesSelectSelectionInput.restype = None

        display_name = os.environ.get("DISPLAY", ":0").encode()
        self.display = self.x11.XOpenDisplay(display_name)
        if not self.display:
            raise RuntimeError(f"cannot open X11 display {display_name.decode()!r}")

        event_base = ctypes.c_int()
        error_base = ctypes.c_int()
        if not self.xfixes.XFixesQueryExtension(
            self.display, ctypes.byref(event_base), ctypes.byref(error_base)
        ):
            self.close()
            raise RuntimeError("the X11 server does not provide XFixes")

        root = self.x11.XDefaultRootWindow(self.display)
        clipboard = self.x11.XInternAtom(self.display, b"CLIPBOARD", 0)
        self.xfixes.XFixesSelectSelectionInput(
            self.display,
            root,
            clipboard,
            self.XFIXES_SET_SELECTION_OWNER_NOTIFY_MASK,
        )
        self.x11.XFlush(self.display)
        self.selection_event_type = (
            event_base.value + self.XFIXES_SELECTION_NOTIFY_OFFSET
        )

    def close(self) -> None:
        if getattr(self, "display", None):
            self.x11.XCloseDisplay(self.display)
            self.display = None

    def run(self) -> None:
        event = XEvent()
        while True:
            self.x11.XNextEvent(self.display, ctypes.byref(event))
            if event.type == self.selection_event_type:
                print("changed", flush=True)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--check", action="store_true", help="validate X11/XFixes and exit"
    )
    args = parser.parse_args()

    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
    watcher = X11Watcher()
    try:
        if not args.check:
            watcher.run()
    finally:
        watcher.close()
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, RuntimeError) as exc:
        print(f"x11-clipboard-watch: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

3. codex-clipboard-bridge.service

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\codex-clipboard-bridge.service

安装位置:

/home/user/.config/systemd/user/codex-clipboard-bridge.service
[Unit]
Description=Bridge WSLg image clipboard to X11 for Codex
Documentation=https://github.com/shangxiang0907/codex-wsl-clipboard-bridge
StartLimitIntervalSec=30
StartLimitBurst=5

[Service]
Type=simple
Environment=DISPLAY=:0
Environment=WAYLAND_DISPLAY=wayland-0
Environment=XDG_RUNTIME_DIR=%t
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=%h/.local/bin/codex-clipboard-bridge
Restart=on-failure
RestartSec=2
TimeoutStopSec=5
KillMode=control-group
RuntimeDirectory=codex-clipboard-bridge
RuntimeDirectoryMode=0700
UMask=0077

NoNewPrivileges=true
KeyringMode=private
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @privileged @raw-io @reboot @swap
SystemCallErrorNumber=EPERM

Nice=10
CPUQuota=50%
MemoryHigh=512M
MemoryMax=768M
TasksMax=32
LimitNOFILE=256
LimitFSIZE=256M

[Install]
WantedBy=default.target

4. install.sh

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\install.sh
#!/usr/bin/env bash

set -Eeuo pipefail
umask 077

readonly service_name="codex-clipboard-bridge.service"
installer_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly installer_dir
readonly bin_target="${HOME}/.local/bin/codex-clipboard-bridge"
readonly watcher_target="${HOME}/.local/libexec/codex-clipboard-bridge/x11-clipboard-watch.py"
readonly unit_target="${HOME}/.config/systemd/user/${service_name}"
readonly data_dir="${HOME}/.local/share/codex-clipboard-bridge"
backup_dir="${data_dir}/backups/$(date -u +'%Y%m%dT%H%M%SZ')-$$"
readonly backup_dir

rollback_armed=0

log() {
    printf 'installer: %s\n' "$*" >&2
}

require_command() {
    command -v "$1" >/dev/null 2>&1 || {
        log "missing required command: $1"
        return 69
    }
}

backup_file() {
    local source=$1
    local backup_name=$2

    if [[ -e "${source}" ]]; then
        install -Dm600 -- "${source}" "${backup_dir}/${backup_name}"
        printf 'present\n' >"${backup_dir}/${backup_name}.state"
    else
        printf 'absent\n' >"${backup_dir}/${backup_name}.state"
    fi
}

restore_file() {
    local destination=$1
    local backup_name=$2
    local state

    state="$(<"${backup_dir}/${backup_name}.state")"
    if [[ "${state}" == "present" ]]; then
        install -Dm755 -- "${backup_dir}/${backup_name}" "${destination}.rollback"
        [[ "${backup_name}" == *.service ]] && chmod 644 "${destination}.rollback"
        mv -f -- "${destination}.rollback" "${destination}"
    else
        rm -f -- "${destination}"
    fi
}

rollback() {
    local exit_code=$?

    if ((rollback_armed)); then
        trap - ERR
        journalctl --user -u "${service_name}" --no-pager -n 30 >&2 || true
        log "deployment failed; restoring the previous version from ${backup_dir}"
        systemctl --user stop "${service_name}" 2>/dev/null || true
        restore_file "${bin_target}" codex-clipboard-bridge
        restore_file "${watcher_target}" x11-clipboard-watch.py
        restore_file "${unit_target}" "${service_name}"
        systemctl --user daemon-reload || true
        if [[ "$(<"${backup_dir}/service-active.state")" == "active" ]]; then
            systemctl --user start "${service_name}" || true
        fi
    fi

    exit "${exit_code}"
}

atomic_install() {
    local mode=$1
    local source=$2
    local destination=$3

    install -Dm"${mode}" -- "${source}" "${destination}.new"
    mv -f -- "${destination}.new" "${destination}"
}

main() {
    local command_name
    local tasks_current

    for command_name in bash date flock grep install mktemp python3 sha256sum stat systemctl timeout wl-paste xclip; do
        require_command "${command_name}"
    done
    if ! command -v magick >/dev/null 2>&1 &&
        (! command -v convert >/dev/null 2>&1 || ! command -v identify >/dev/null 2>&1); then
        log "ImageMagick is required (install the imagemagick package)"
        return 69
    fi

    bash -n "${installer_dir}/codex-clipboard-bridge"
    bash -n "${installer_dir}/install.sh"
    bash -n "${installer_dir}/uninstall.sh"

    export DISPLAY="${DISPLAY:-:0}"
    export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}"
    export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
    python3 -B "${installer_dir}/x11-clipboard-watch.py" --check
    [[ -S "${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ]] || {
        log "WSLg Wayland socket is unavailable: ${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}"
        return 69
    }
    systemctl --user is-system-running >/dev/null

    mkdir -p -- "${backup_dir}"
    chmod 700 -- "${data_dir}" "${data_dir}/backups" "${backup_dir}"
    backup_file "${bin_target}" codex-clipboard-bridge
    backup_file "${watcher_target}" x11-clipboard-watch.py
    backup_file "${unit_target}" "${service_name}"
    if systemctl --user is-active --quiet "${service_name}"; then
        printf 'active\n' >"${backup_dir}/service-active.state"
    else
        printf 'inactive\n' >"${backup_dir}/service-active.state"
    fi

    rollback_armed=1
    trap rollback ERR

    atomic_install 755 "${installer_dir}/codex-clipboard-bridge" "${bin_target}"
    atomic_install 755 "${installer_dir}/x11-clipboard-watch.py" "${watcher_target}"
    atomic_install 644 "${installer_dir}/${service_name}" "${unit_target}"

    systemctl --user daemon-reload
    systemctl --user enable "${service_name}" >/dev/null
    systemctl --user restart "${service_name}"

    for _ in 1 2 3 4 5; do
        systemctl --user is-active --quiet "${service_name}" && break
        sleep 0.2
    done
    systemctl --user is-active --quiet "${service_name}"
    sleep 1
    systemctl --user is-active --quiet "${service_name}"
    tasks_current="$(systemctl --user show "${service_name}" --property=TasksCurrent --value)"
    if [[ ! "${tasks_current}" =~ ^[0-9]+$ ]] || ((tasks_current < 2)); then
        log "health check failed: expected the bridge and watcher, found ${tasks_current} task(s)"
        return 70
    fi

    rollback_armed=0
    trap - ERR
    log "installed successfully; backup: ${backup_dir}"
    systemctl --user --no-pager --full status "${service_name}"
}

main "$@"

5. uninstall.sh

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\uninstall.sh
#!/usr/bin/env bash

set -Eeuo pipefail
umask 077

readonly service_name="codex-clipboard-bridge.service"
readonly bin_target="${HOME}/.local/bin/codex-clipboard-bridge"
readonly watcher_dir="${HOME}/.local/libexec/codex-clipboard-bridge"
readonly unit_target="${HOME}/.config/systemd/user/${service_name}"
readonly data_dir="${HOME}/.local/share/codex-clipboard-bridge"

systemctl --user disable --now "${service_name}" 2>/dev/null || true
rm -f -- "${bin_target}" "${watcher_dir}/x11-clipboard-watch.py" "${unit_target}"
rmdir -- "${watcher_dir}" 2>/dev/null || true
systemctl --user daemon-reload
systemctl --user reset-failed "${service_name}" 2>/dev/null || true

printf 'Removed the bridge. Backups were preserved under %s.\n' "${data_dir}/backups"

6. verify.sh

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\verify.sh
#!/usr/bin/env bash

set -Eeuo pipefail
umask 077

project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly project_dir
event_file="$(mktemp /tmp/codex-xfixes-events.XXXXXX)"
image_file="$(mktemp /tmp/codex-xfixes-image.XXXXXX.png)"
watcher_pid=""

cleanup() {
    [[ -z "${watcher_pid}" ]] || kill "${watcher_pid}" 2>/dev/null || true
    rm -f -- "${event_file}" "${image_file}"
}
trap cleanup EXIT INT TERM HUP

bash -n "${project_dir}/codex-clipboard-bridge"
bash -n "${project_dir}/install.sh"
bash -n "${project_dir}/uninstall.sh"
DISPLAY="${DISPLAY:-:0}" python3 -B "${project_dir}/x11-clipboard-watch.py" --check
systemd-analyze --user verify "${project_dir}/codex-clipboard-bridge.service"

if DISPLAY="${DISPLAY:-:0}" xclip -selection clipboard -t image/png -o >"${image_file}" 2>/dev/null &&
    [[ -s "${image_file}" ]]; then
    DISPLAY="${DISPLAY:-:0}" timeout 3s \
        python3 -B "${project_dir}/x11-clipboard-watch.py" >"${event_file}" &
    watcher_pid=$!
    sleep 0.2
    DISPLAY="${DISPLAY:-:0}" xclip -selection clipboard -t image/png -i "${image_file}"
    wait "${watcher_pid}" || [[ $? -eq 124 ]]
    watcher_pid=""
    grep -Fxq changed "${event_file}"
    printf 'XFixes event delivery: OK\n'
else
    printf 'XFixes event delivery: SKIPPED (X11 clipboard has no PNG)\n'
fi

printf 'Static and runtime verification: OK\n'

7. integration-test.sh

源码位置:

C:\Users\User\Documents\Codex\2026-08-22\wo\work\codex-clipboard-bridge\integration-test.sh
#!/usr/bin/env bash

set -Eeuo pipefail
umask 077

readonly fixture="${1:-/tmp/clipboard.bmp}"
output="$(mktemp /tmp/codex-clipboard-test.XXXXXX.png)"

cleanup() {
    # shellcheck disable=SC2317  # Invoked indirectly by trap.
    rm -f -- "${output}"
}
trap cleanup EXIT INT TERM HUP

[[ -s "${fixture}" ]] || {
    printf 'BMP fixture does not exist or is empty: %s\n' "${fixture}" >&2
    exit 66
}
systemctl --user is-active --quiet codex-clipboard-bridge.service

wl-copy --type image/bmp <"${fixture}"

for _ in {1..30}; do
    if xclip -selection clipboard -t image/png -o >"${output}" 2>/dev/null &&
        [[ -s "${output}" ]]; then
        file -- "${output}"
        printf 'End-to-end Wayland BMP -> X11 PNG: OK\n'
        exit 0
    fi
    sleep 0.1
done

printf 'The bridge did not publish an X11 PNG within 3 seconds.\n' >&2
exit 1

十一、最终结论

这次排障最重要的不是记住一条 xclip 命令,而是学会把“剪贴板粘贴失败”拆成四层来验证:

  1. 源系统是否真的持有图片;
  2. WSLg 是否把它暴露给 Linux;
  3. 暴露在哪个显示协议、哪些 MIME 类型上;
  4. 目标程序实际从哪个协议面、以什么格式读取。

只有 Windows 侧 ContainsImage=True 远远不够;只有 wl-paste 能读 BMP 也不代表 Codex 能读;甚至 Wayland 上有 PNG,也不代表使用 X11 后端的程序能看到它。

通过逐层验证,我们把模糊的“Ctrl+V 不工作”收敛成了一个明确、可重复的协议桥接问题,并把一次性手工命令演进成了可安装、可观察、可回滚、可卸载、空闲零轮询的用户服务。

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。

更多推荐