首页
/ kitty OSC 5522 剪贴板协议详解:多 MIME 数据、权限模型与 kitty 源码实现

kitty OSC 5522 剪贴板协议详解:多 MIME 数据、权限模型与 kitty 源码实现

2026-09-05 13:34:32作者:温艾琴Wonderful

本文围绕 kitty 官方的 OSC 5522 剪贴板扩展协议展开,完整覆盖 payload 的 base64 编码规则、读/写双向报文格式、错误码语义、MIME 别名、免重复授权的密码机制、粘贴事件模式以及终端复用器支持,并结合 kitty/clipboard.pykitty_tests/clipboard.pykittens/clipboard 的实际实现,帮助你在终端应用中实现与系统剪贴板进行任意数据(文本、图片、富文本文档)的双向传输,并理解 kitty 侧的权限控制链路。

1. 协议定位:从 OSC 52 到 OSC 5522

传统终端程序通过 OSC 52 转义序列读写系统剪贴板中的纯文本。kitty 在此基础上引入了更先进的协议(见 docs/clipboard.rst),它解决 OSC 52 的两个根本限制:

  • 可复制任意数据类型:包括图片、富文本文档等,而不仅是 text/plain
  • 权限协商:终端可以向用户征求剪贴板访问许可,并能显式上报"权限被拒绝"。

转义序列编号为 OSC 5522,是 OSC 52 的扩展。基本报文格式为:

<OSC>5522;metadata;payload<ST>

其中:

  • metadata 是冒号分隔的键值对列表(key=value:key=value);
  • payload 是 base64 编码的数据,用分号与 metadata 分隔;
  • OSC<ESC>]\x1b]);
  • ST 是字符串终止符 <ESC>\\x1b\\)。

在 kitty 源码中,该协议的解析入口是 ClipboardRequestManager.parse_osc_5522(见 kitty/clipboard.py),由 VT 解析器分发:kitty/vt-parser.c 中的 case 5522 触发 OSC 回调,最终经 kitty/window.py 将数据交给该窗口的 clipboard_request_manager

2. payload 的 base64 编码规则

协议对编码的要求相当严格,这也是实现中最容易踩坑的部分:

  1. 所有方向的 payload,以及 metadata 中声明为 base64 的键(mimenamepw),一律使用 RFC 4648 第 4 节的标准编码、标准字母表
  2. 填充(padding)是强制的:每个编码后的值长度必须是 4 的字节倍数;
  3. 终端应当拒绝非法 base64 数据——包括含有字母表之外字符(这包括空白和换行符)或填充不正确的数据。终端不得悄悄丢弃非法字符,因为那会把损坏数据变成"看似合法"的数据;
  4. 因非法 base64 而拒绝写请求时,终端必须以 EINVAL 状态中止该请求;读请求没有错误状态,终端拒绝读请求时直接忽略即可。

一个重要的细节:当同一 MIME 类型的数据被拆分到多个 wdata 包时,终端解码的是这些包 payload 的拼接结果。因此单个包的 payload 不必是 4 字节倍数,只有某一 MIME 类型全部 payload 的拼接整体才必须正确填充。

kitty 的实现印证了这一规则:strict_base64_decode(见 kitty/clipboard.py)使用 SIMD 实现的严格解码器,会直接拒绝字母表外字符,而不是像标准库 base64 模块那样忽略非法字符。测试 kitty_tests/clipboard.py 精确验证了这组行为:

  • b'!!!'b'SGVs!!!bG8='、payload 中夹杂换行符、填充缺失的 b'SGVsbG8' —— 全部触发 EINVAL
  • 而把一段合法 base64 按 3 字节边界甚至逐字节切分发送,只要拼接后填充正确,就以 DONE 成功结束。

3. 从系统剪贴板读取数据

3.1 读请求

读剪贴板的转义序列为:

<OSC>5522;type=read;<base64 编码的、空格分隔的待读取 MIME 类型列表><ST>

例如要读取纯文本和 PNG 数据,payload 就是把 text/plain image/png 做 base64 编码。若要读取**主选择(primary selection)**而非剪贴板,在 metadata 段追加 loc=primary

要获取剪贴板上当前可用的 MIME 类型列表,payload 只需是一个句点(.,base64 编码后为 Lg==)。

3.2 读响应

终端以一系列转义序列应答:

<OSC>5522;type=read:status=OK<ST>
<OSC>5522;type=read:status=DATA:mime=<base64 编码的 MIME 类型>;<base64 编码的数据><ST>
<OSC>5522;type=read:status=DATA:mime=<base64 编码的 MIME 类型>;<base64 编码的数据><ST>
.
.
.
<OSC>5522;type=read:status=DONE<ST>

status=DATA 包按 MIME 类型逐个交付 base64 字节。终端应当把单个类型的数据切分为不大于 4096 字节的块(注意:4096 是 base64 编码之前的大小)。同一类型的所有块必须顺序、连续发送完毕,才能开始下一个类型的块;数据结束由 status=DONE 包标识。

发生错误时,终端不发送开头的 status=OK 包,而改发 status=ERRORCODE 包,错误码包括:

错误码 含义
status=ENOSYS 请求的剪贴板类型不可用。例如某些系统不支持主选择,而请求使用了 loc=primary
status=EPERM 读剪贴板的权限被系统或用户拒绝
status=EBUSY 出现临时性故障,例如复用器中多个客户端同时访问剪贴板

关于权限:终端应当在允许读请求前向用户征求许可;但如果读请求只是想列出可用的数据类型(payload 为 .),应当免提示放行,以免用户连续面对两次授权弹窗。kitty 中这一点落在 ask_to_read_clipboard(见 kitty/clipboard.py):当 rr.mime_types == (TARGETS_MIME,)TARGETS_MIME = '.')时直接执行而不询问。

kitty 发送读响应时,分块大小常量 READ_RESPONSE_CHUNK_SIZE = 4096 就在 kitty/clipboard.py 顶部定义;fulfill_read_requestkitty/clipboard.py)在遇到 . 这一特殊 MIME 类型时,会把 get_available_mime_types_for_paste() 返回的 MIME 列表以空格拼接(并补换行)后作为单包 payload 发回。

3.3 读请求的错误码在 kitty 客户端侧的映射

kitty 自带的 clipboard kitten 把错误码翻译成人类可读信息,见 kittens/clipboard/read.goerror_from_statusENOSYS 提示"本系统无主选择"、EPERM 提示"权限被拒绝"、EBUSY 提示"临时故障,稍后重试"。

4. 向系统剪贴板写入数据

4.1 写请求包序列

终端程序发送如下包序列:

<OSC>5522;type=write<ST>
<OSC>5522;type=wdata:mime=<base64 编码的 MIME 类型>;<该类型的 base64 编码数据块><ST>
<OSC>5522;type=wdata:mime=<base64 编码的 MIME 类型>;<该类型的 base64 编码数据块><ST>
.
.
.
<OSC>5522;type=wdata<ST>

最后这个不带 mime、不带数据的 type=wdata 包表示传输结束。每个 MIME 类型的数据应切分为不大于 4096 字节的块(同样指编码前大小),同一类型的块必须全部发完再发下一类型。传输完成后,终端以单个包回复成功:

<OSC>5522;type=write:status=DONE<ST>

4.2 写错误码

出错时终端可以随时发送错误包 <OSC>5522;type=write:status=ERRORCODE<ST>

错误码 含义
status=EIO 处理数据时发生 I/O 错误
status=EINVAL 某个包非法,通常是 base64 编码非法(见第 2 节)或缺少 MIME 类型。终端必须丢弃当前写请求已接收的全部数据
status=ENOSYS 客户端通过 loc=primary 请求写主选择,但系统不支持
status=EPERM 写剪贴板的权限被系统或用户拒绝
status=EBUSY 出现临时性故障,例如复用器中多个客户端同时访问剪贴板
status=EFBIG 待写入数据超过终端愿意存储的最大剪贴板大小。终端可设此上限以防拒绝服务攻击,但至少必须接受 64MB 数据

一旦出错,终端必须忽略后续所有 OSC 5522 写相关包,直到看到新的 type=write 包。kitty 的实现完全遵循此约定:abort_write_requestkitty/clipboard.py)先把 in_flight_write_request 置空再回复错误状态;add_base64_data 开头检查 self.aborted 直接返回。测试 kitty_tests/clipboard.pytest_clipboard_write_too_much_data 验证了超限后返回 EFBIG,且被中止请求的后续包全部被丢弃。

4.3 主选择与 MIME 别名

  • 客户端可在初始 type=write 包中追加 loc=primary,把数据写入主选择而非剪贴板;
  • 客户端可以对 MIME 类型做别名,发送 type=walias 包:
<OSC>5522;type=walias:mime=<base64 编码的目标 MIME 类型>;<base64 编码的、空格分隔的别名列表><ST>

别名生效后,系统剪贴板会同时提供所有别名 MIME 类型,数据与目标 MIME 类型相同。这节省带宽:客户端只传一份数据,却在剪贴板中创建多个引用。别名包可以在初始 write 包之后、结束数据包之前的任意时刻发送。

在 kitty 侧,walias 的处理见 kitty/clipboard.py:payload 不是合法 base64/UTF-8 或缺少 mime 键时,直接以 EINVAL 中止整个写请求;提交阶段 WriteRequest.commitkitty/clipboard.py)把别名映射到目标 MIME 的同一数据区间,一次 set_mime 全部提交。写请求的数据通过 Tempfile 缓冲,超过 16MB 自动从内存文件滚动到磁盘临时文件(kitty/clipboard.py),提交时以分块器(chunker)形式懒读取,避免一次性把大文件载入内存。

5. 配置项:clipboard_control 与 clipboard_max_size

协议中"终端应当征求许可"的行为,在 kitty 中由配置项驱动。kitty 提供两个直接相关的选项(定义见 kitty/options/definition.py):

  • clipboard_control(默认 write-clipboard write-primary read-clipboard-ask read-primary-ask):控制程序读/写剪贴板的允许行为。可取值:write-clipboardread-clipboardwrite-primaryread-primaryread-clipboard-askread-primary-ask。默认允许写入剪贴板和主选择,读取时征求许可。官方明确提示:关闭读确认是安全风险——任何程序(包括经 SSH 在远端运行的程序)都能读取你的剪贴板。
  • clipboard_max_size(默认 512,单位 MB,正浮点,0 表示不限制):终端程序可写入系统剪贴板的最大数据量。超过该值时,使用本协议的程序会收到 EFBIG 错误且数据被丢弃。

解析链路为 handle_write_requestget_options().clipboard_controlkitty/clipboard.py);写请求的大小上限在 WriteRequest.__init__ 中读取(kitty/clipboard.py),超限日志与 EFBIG 中止在 write_base64_dataparse_osc_5522 中完成。

6. 避免重复的权限弹窗:密码机制

对于编辑器这类需要频繁读写剪贴板的程序,默认每次读请求都要弹窗,体验很差。为此(kitty 0.42.2 起),协议允许在 type=writetype=read 请求中携带密码人类可读名称

  • 密码与名称通过 metadata 的 pwname 键传递,值都是 base64 编码的 UTF-8 字符串
  • 终端可以询问用户"是否允许该密码之后的所有请求"。用户同意后,同一 tty 上后续携带该密码的请求将被自动放行;
  • 使用此机制的程序理想情况下应在启动时随机生成密码(如 UUID4)。终端也可以实现持久化密码,由用户为受信程序配置固定密码;
  • 只给密码不给人类可读名称,等价于没给密码——终端必须把它当作无密码请求处理。

kitty 的实现中,granted_passwords 字典(kitty/clipboard.py)缓存每个密码对应的 GrantedPermission(读/写授权、一次性标记、永久禁止标记)。request_permissionkitty/clipboard.py)弹出四选项对话框:Allow(本次允许)、Always(本会话内自动放行)、Deny(拒绝)、Ban(本会话内自动拒绝),与文档"终端可永久存储密码"的语义对应。

7. 粘贴事件模式(paste events,kitty 0.44.1 起)

如果 TUI 应用希望响应粘贴事件(用户按终端的粘贴快捷键、或从终端 UI 菜单选择粘贴),可以启用 paste events 私有模式(编号同为 5522),其规范见 kitty 文档引用的辅助规范文档。启用该模式后,每次用户触发粘贴动作,终端都会向应用发送剪贴板上可用的 MIME 类型列表,应用可从中请求任意想要的数据。

开关使用标准的 DECSET/DECRST 控制序列:

CSI ? 5522 h    # 启用模式
CSI ? 5522 l    # 禁用模式

kitty 中该模式定义为 PASTE_EVENTS (5522 << 5)(见 kitty/modes.h)。

按协议要求,终端应当随 MIME 类型列表附带一个一次性密码pw 键,base64 编码)。应用随后使用该密码请求剪贴板数据时无需权限弹窗,且此时人类名称应当设为 Paste event(base64 编码)。kitty 的 send_paste_eventkitty/clipboard.py)正是这一流程的实现:用 uuid4() 生成一次性密码,登记 GrantedPermission(read=True, one_time=True),再以 . MIME 请求走读响应路径,响应中带上 otp_for_response 编码为 pw 键(见 kitty/clipboard.py)。

8. 探测终端是否支持本协议

应用可通过标准 DECRQM 查询:

CSI ? 5522 $ p

终端以 DECRPM 响应:

CSI ? 5522 ; Ps $ y

Ps 为 0 或 4 表示不支持该模式。kitty 的 query_terminal kitten 也暴露了剪贴板相关的查询能力(见 kittens/query_terminal/main.py),例如查询终端当前的 clipboard_control 配置。

9. 终端复用器(Multiplexer)支持

由于本协议是终端与程序之间的双向通信,复用器需要知道把终端的响应发回哪个窗口。为此,metadata 中包含可选的 id 字段:

  • 若存在 id,终端必须在每个响应中原样带回
  • 合法 id 只能含 [a-zA-Z0-9-_+.] 字符集中的字符,其他字符必须被终端在转发前剔除
  • 复用器中两个不同程序互相覆盖剪贴板请求是本质不可避免的问题——系统剪贴板是单一的全局共享资源;更复杂的隐患是响应可能丢失(例如同时收到多个写请求),设计良好的复用器应保证同一时刻只有一个请求在途,并可向被中止的请求回发 EBUSY 错误码;
  • 当终端因 5522 模式启用而发送主动粘贴事件(unsolicited paste event)时,没有关联的 id,此时复用器必须把事件转发给当前活动窗口

kitty 对 id 的清洗由 sanitize_id 完成,在解析 read/write 请求时调用(见 [kitty/clipboard.py](https://gitcode.com/GitHub_Trending/ki/kitty/blob/b14ae3bf21ee5fb59e0f86f8f6050f5b3462bf4f/kitty/clipboard.py?utm_source=gitcode_repo_files#L384-L386, L420, L429));响应编码时 ReadRequest.encode_responseWriteRequest.encode_response 都会把 id 追加到 metadata 中(kitty/clipboard.py)。测试 kitty_tests/clipboard.py 验证了响应报文确实携带 :id=w1:id=w2 原样回传,且中止后 in_flight_write_request 被清空。

10. 实战参考:kitty 的 clipboard kitten

kitty 用本协议实现的 clipboard kitten 是上述协议的最佳参照客户端(协议编码器见 kittens/clipboard/read.goEncode_bytes,它严格拼接 \x1b]5522;...\x1b\\):

# 把 STDIN 文本复制到剪贴板
echo hooray | kitten clipboard

# 从剪贴板读取文本到 STDOUT(默认会弹出权限确认,受 clipboard_control 控制)
kitten clipboard --get-clipboard

# 把图片复制到剪贴板
kitten clipboard picture.png

# 同时复制图片和文本
kitten clipboard picture.jpg text.txt

# 从 STDIN 复制文本,同时复制一张图片
echo hello | kitten clipboard picture.png /dev/stdin

# 把剪贴板上任意位图导出为 PNG
kitten clipboard -g picture.png

# 图片写文件、文本写 STDOUT
kitten clipboard -g picture.png /dev/stdout

# 列出剪贴板上所有可用格式
kitten clipboard -g -m . /dev/stdout

kitten 默认按文件名猜测 MIME 类型,可用 --mime 精确指定。从 kittens/clipboard/read.go 可以看到它的读流程与协议文档完全一致:先发 payload 为 .type=read 请求获取 MIME 列表,DONE 后按别名/通配/图像可转换性等规则匹配每个输出目标(assign_mime_typekittens/clipboard/read.go),再发一次携带目标 MIME 列表的 type=read 请求拉取数据,直到 DONE 退出;支持通过 --pw/--nameopts.Password/opts.HumanName)携带密码以避免重复授权。

11. 实现要点小结

结合协议文档与 kitty 源码,实现一个合规的 OSC 5522 客户端/终端时建议遵循以下清单:

  1. 编解码:只用标准字母表的标准 base64,强制 padding;接收端严格校验、拒绝而非静默清洗非法字符;
  2. 分块:读响应与写数据均按编码前 ≤ 4096 字节切块,同一 MIME 类型的块必须连续发送;跨块拼接后校验整体填充;
  3. 状态机:写流程 write → wdata* → wdata(结束),出错后置为"已中止"并丢弃后续包直到新的 type=write
  4. 权限:读/写默认征求许可,列 MIME(payload .)免提示;用 pw+name 支持会话内免打扰,缺 name 则视同无密码;
  5. 复用器:回传 id 前清洗非法字符,保证单请求在途,冲突时回 EBUSY
  6. 大小上限:终端必须至少接受 64MB 写数据,超限回 EFBIG(kitty 默认可配至 512MB,见 clipboard_max_size)。

协议规范全文见 docs/clipboard.rst,kitty 的参考实现与测试分别位于 kitty/clipboard.pykitty_tests/clipboard.py,可直接作为客户端或终端两侧的实现对照。

登录后查看全文
热门项目推荐
相关项目推荐