kitty 文件传输协议(FTC):基于 OSC 5113 转义序列的 TTY 文件传输规范全解析
本文以 kitty 仓库中的协议规范文档 file-transfer-protocol.rst 为主体,完整讲解这套“文件传输协议(File Transmission Code,FTC)”的设计理念、会话流程、元数据规范、符号链接/硬链接保留、rsync 二进制增量、zlib 压缩与免交互鉴权机制,并结合 kitty 源码给出 协议实现 的关键佐证。读完后,你将能够:理解为什么 TTY 能作为唯一管道完成跨系统文件传输、手写或调试基于 OSC 5113 的传输客户端、以及读懂 kitty 端协议处理器的实现细节。
一、协议定位:为什么需要“走 TTY 的文件传输”
规范开宗明义:在某些场景下,TTY 是两台互联系统之间唯一方便的管道——嵌套的 SSH 会话、串口(serial line)等。此时没有网络文件传输通道可用,只能把文件数据编码成终端输出流来回传递。
该协议提供以下能力:
- 传输普通文件、目录以及符号链接与硬链接,并保留大部分元数据(路径、权限、修改时间);
- 可选 zlib 压缩,以及基于 rsync 算法的二进制差量(delta)传输,加速“只改了少量内容的重复传输”;
- 所有数据均以 base64 编码后走 TTY,因此规范明确承认:它永远无法与更直接的文件传输机制竞争性能——它的价值在于“能用”,而非“快”。
二、整体设计:以“传输会话”为中心
协议的核心抽象是传输会话(session)。设计动机是安全:不可信软件不应能读写另一台机器的文件系统,因此任何实际数据传输之前,会话必须先在终端模拟器一侧得到用户批准——除非提供了预共享密码(见后文 bypass 机制)。
关键设计规则:
- 会话分为**发送(send)与接收(receive)**两种:send 会话中文件从远端客户端流向运行终端模拟器的机器,receive 会话则相反;
- 每个会话都是“先发元数据、再发实际数据”的命令序列;
- 每条命令都携带
id(会话 ID,应是一个随机的、近似唯一的标识符,用于避免冲突)和一个action字段(指明该命令的作用),其余字段随命令类型而变; - 会话是双向的:命令既发给终端模拟器,终端模拟器也会回发命令。
2.1 发送文件到终端模拟器一侧
客户端首先发起 start send 命令,然后必须等待终端的 status 应答(允许或拒绝);在收到应答之前不得再发送该会话的任何命令。终端模拟器若在发出 OK 之前就收到该会话的其他命令,应直接丢弃该会话:
→ action=send id=someid
← action=status id=someid status=OK
# 或被拒绝时:
← action=status id=someid status=EPERM:User refused the transfer
获批后,客户端逐条发送 file 命令携带文件元数据:
→ action=file id=someid file_id=f1 name=/path/to/destination
→ action=file id=someid file_id=f2 name=/path/to/destination2 ftype=directory
终端对目录回 OK,对普通文件回 STARTED;若目标位置不可写则回错误:
← action=status id=someid file_id=f1 status=STARTED
← action=status id=someid file_id=f2 status=OK
← action=status id=someid file_id=f1 status=EPERM:No permission # 出错示例
数据用 data 命令逐块发送,无需等待 STARTED(终端必须丢弃未 STARTED 文件的数据)。单块上限 4096 字节,以 end_data 收尾:
→ action=data id=someid file_id=f1 data=chunk of bytes
→ action=data id=someid file_id=f1 data=chunk of bytes
...
→ action=end_data id=someid file_id=f1 data=chunk of bytes
每收到一个数据包,终端回一次确认;end_data 之后回最终确认;写盘出错则回错误码并忽略该文件的后续命令:
← action=status id=someid file_id=f1 status=PROGRESS size=bytes written
← action=status id=someid file_id=f1 status=OK size=bytes written
← action=status id=someid file_id=f1 status=EIO:Failed to write to file # 出错示例
文件全部传完后,客户端以 finish 结束会话。此时终端**提交(commit)**会话——应用文件元数据、创建链接等;若有任何错误则回错误消息:
→ action=finish id=someid
← action=status id=someid status=Some error occurred
2.2 从终端模拟器一侧接收文件
客户端先声明要接收的路径数量,随后逐条列出路径,然后必须等待终端的 OK,在此之前发送任何命令都是协议错误:
→ action=receive id=someid size=num_of_paths
→ action=file id=someid file_id=f1 name=/some/path
→ action=file id=someid file_id=f2 name=/some/path2
...
← action=status id=someid status=OK
# 或
← action=status id=someid status=EPERM:User refused the transfer
获批后终端发送所有请求文件的元数据;若是目录则递归遍历列出全部文件,且符号链接绝不能被跟随,只能作为 symlink 本身列出:
← action=file id=someid file_id=f1 mtime=XXX permissions=XXX name=/absolute/path status=file_id1 size=size_in_bytes file_type=type parent=file_id of parent
这里有两个字段值得细读:file_id 沿用客户端发送的值,而 status 字段存放该文件在终端侧的真实 file_id——因为客户端查询的一个路径(目录)会展开成多个实际文件。parent 字段是包含该文件的目录的真实 file_id,让客户端可以无歧义地重建文件树。
所有文件列完之后,终端回一个带 home 目录路径的 OK(供客户端解析 ~):
← action=status id=someid status=OK name=/path/to/home
# 列出文件失败时:
← action=status id=someid file_id=f1 status=ENOENT:Does not exist
接着客户端按终端发来的 file_id 请求数据(不得请求目录和绝对路径符号链接的数据),终端每次只发一个文件的数据,每块 ≤ 4096 字节,以 end_data 收尾;读取出错则回错误:
→ action=file id=someid file_id=f1 name=/some/path
← action=data id=someid file_id=f1 data=chunk of bytes
...
← action=end_data id=someid file_id=f1 data=chunk of bytes
← action=status id=someid file_id=f1 status=EIO:Could not read # 出错示例
读完后客户端发送 finished 结束会话(注意发送会话的结束命令是 finish,接收会话是 finished,规范原文如此):
→ action=finished id=someid
2.3 取消会话
客户端可以在任意时刻(例如用户按下 Ctrl-C)取消会话。发送 cancel 后终端丢弃会话并回 CANCELED 确认。规范特别强调:客户端必须等待 CANCELED 应答,丢弃此前的其他响应——否则客户端退出后,这些响应可能被直接打印到屏幕上:
→ action=cancel id=someid
← action=status id=someid status=CANCELED
2.4 静默应答(quiet 参数)
协议中有大量终端应答(确认收到数据、授权、确认取消等)。对于 shell 脚本这类极简客户端,可以在开始命令中加 quiet 键抑制应答:
→ action=send id=someid quiet=1
取值:1 = 抑制确认类应答(仅保留错误);2 = 抑制全部应答包括错误,只保留真正的数据响应。注意 quiet=1 连“传输获准”的确认也会抑制,因此通常只在配合 bypass 免交互鉴权时有用。kitty 实现中对应逻辑在 file_transmission.py 的 ActiveReceive 构造器中:send_acknowledgements = quiet < 1、send_errors = quiet < 2,与规范一一对应。
三、文件元数据:跨平台的最小公共集
元数据包含文件路径、权限和修改时间。不同操作系统支持的元数据类型不同,规范定义了跨多数平台可用的最小公共集。
3.1 文件路径
- 必须是合法 UTF-8 编码的 POSIX 路径(
/分隔);Linux 上的非 UTF-8 路径不受支持; - 前导
~/表示相对HOME目录;路径必须是绝对路径或相对 HOME; - 单个路径组件不超过 255 字节 UTF-8,总长不超过 4096 字节;
- Windows 路径必须用正斜杠,首段为带冒号的盘符:
C:\some\file.txt表示为/C:/some/file.txt; - 为最大可移植性,路径中应当避免以下字符(实现可以支持它们,遇到不可表示的路径时报错):
\ * : < > ? | /
3.2 修改时间
以纳秒为单位表示自 UNIX epoch 起的时间戳。文件系统若无法保存这么高的精度,应取最接近的近似值。kitty 实现直接使用 st_mtime_ns(见 make_ftc 函数 中 iter_file_metadata 内的 mtime=sr.st_mtime_ns)。
3.3 文件权限
以普通 UNIX 权限位数字表示(读写执行 + sticky、set-group-id、set-user-id),实现应尽力保留尽可能多的位。Windows 侧只有 read-only 位,转换规则为:
- 读取元数据时:若 read-only 位未设置,则所有
WRITE位置位;反之清零。所有READ位恒置位;文件可被 Windows 直接执行时EXECUTE位置位; - 写入时:若 user 可写位未置位则设置 read-only 位,其余 UNIX 位忽略;
- 不做 Windows ACL 到权限位的映射。
四、符号链接与硬链接的保留
符号链接和硬链接都可以保留。注意:当符号链接目标以实际路径形式传输时,其编码方式与路径规则相同,由接收侧翻译成本地操作系统合适的路径;若无法翻译,则不创建该符号链接或创建一个断链。
4.1 发送链接到终端
file 命令用 file_type 区分链接类型:
→ action=file id=someid file_id=f1 name=/path/to/link file_type=link # 硬链接
→ action=file id=someid file_id=f2 name=/path/to/symlink file_type=symlink
发数据阶段,硬链接的“数据”就是目标文件的 file_id(前提是目标文件也在传输中;否则该硬链接应按普通文件传输):
→ action=end_data id=someid file_id=f1 data=target_file_id_encoded_as_utf8
符号链接分三种情况:目标是正在传输的文件时用 fid:(相对路径)或 fid_abs:(绝对路径)前缀加目标 file_id;目标不在传输中时用 path: 前缀加符号链接中的实际路径:
→ action=end_data id=someid file_id=f1 data=fid:target_file_id_encoded_as_utf8
→ action=end_data id=someid file_id=f1 data=fid_abs:target_file_id_encoded_as_utf8
4.2 从终端接收链接
接收方向上,链接数据分两部分。终端发初始文件列表时,链接条目的 file_type 设为对应链接类型,data 字段设为目标文件的 file_id(若目标也在列表中):
← action=file id=someid file_id=f1 status=file_id1 ...
← action=file id=someid file_id=f1 status=file_id2 file_type=symlink data=file_id1 ...
即第二条是符号链接,其 data 字段指向第一条 status 字段的值;硬链接同理。客户端不应为硬链接请求数据,而应在传输完成后直接创建硬链接。对符号链接,终端必须在 data 字段发送实际的 UTF-8 目标路径,客户端可原样使用(目标不是传输文件时),或据此决定创建相对/绝对符号链接(目标是传输文件时)。kitty 发送侧的目录递归遍历与 file_id 分配逻辑可参见 iter_file_metadata:它用 status=str(next(counter)) 分配单调递增的真实 file_id,并用 (st_dev, st_ino) 作为 key 记录硬链接同一性。
五、二进制差量传输(rsync 增量)
对“两端仅少量变化”的大文件重复传输,只传变化的块可以显著加速。协议内置了基于 rsync 算法的支持:接收端先发送包含文件各块哈希的签名(signature),发送端只发送变化的块,接收端据此把文件更新到与发送端一致。
触发方式:请求文件时把 transmission_type 键设为 rsync。发送方向与接收方向的细节不同:
5.1 发送到终端
元数据中附加 transmission_type=rsync。若文件已存在且终端能发送签名,STARTED 应答会回带 transmission_type=rsync;随后终端用 data/end_data 发送签名,客户端解析完整签名后回传 delta:
→ action=file id=someid file_id=f1 name=/path/to/destination transmission_type=rsync
← action=status id=someid file_id=f1 status=STARTED transmission_type=rsync
← action=data id=someid file_id=f1 data=...
...
← action=end_data id=someid file_id=f1 data=...
→ action=data id=someid file_id=f1 data=...
...
→ action=end_data id=someid file_id=f1 data=...
5.2 从终端接收
客户端在请求数据时附加 transmission_type=rsync,表示自己会发送该文件的签名;终端收完签名后回传 delta 数据流,客户端用它更新本地文件。
5.3 签名与 delta 的二进制格式
以下所有整数一律使用小端编码(与机器架构无关)。哈希族:XXH3 系列(XXH3-128、XXH3-64)与 rsync 滚动校验和。
签名的 12 字节头:
uint16 version // 当前除 block_size 外全部为 0
uint16 checksum_type // 必须为 0,表示传输后完整性校验用 XXH3-128
uint16 strong_hash_type // 必须为 0,表示块强哈希用 XXH3-64
uint16 weak_hash_type // 必须为 0,表示块弱哈希用 rsync 滚动校验和
uint32 block_size // 通常为文件大小的平方根,实现可自定算法
头之后是块签名列表。块数量未知以便流式传输,流结束由 action=end_data 指示。每条签名:
uint64 index // 零基块号;块在文件中的位置 = index * block_size
uint32 weak_hash // 弱但易算的块哈希
uint64 strong_hash // 几乎不会碰撞的强哈希
发送端收到签名后,基于真实文件内容计算 delta——即一个“操作(operation)”列表。每个操作先 1 字节类型,后变长数据:
| 操作 | 类型值 | 载荷 | 语义 |
|---|---|---|---|
Block |
0 | 8 字节 uint64 块索引 |
从现有文件原样拷贝该块到输出 |
Data |
1 | 4 字节 uint32 长度 + 载荷 |
载荷原样写入输出 |
Hash |
2 | 2 字节 uint16 长度 + 校验和 |
输出文件整体校验和必须与此匹配,算法由签名头指定 |
BlockRange |
3 | 8 字节起始块索引 + 4 字节 N |
拷贝起始块后再连续拷贝 N 个块 |
kitty 的 rsync 核心算法实现位于 C 文件 algorithm.c(Go 侧接口声明见 rsync.pyi),接收侧打补丁的落盘逻辑见 file_transmission.py 中的 PatchFile 类——它先把补丁写入目标同目录下的临时文件,close() 时经 Patcher.finish_delta_data() 校验后再 os.replace 原子替换,保证损坏的 delta 不会破坏已有文件。
六、压缩
单个文件可请求压缩传输,目前仅支持 RFC 1950 的 ZLIB deflate,通过在请求文件的命令中加 compression=zlib 指定:
# 发送到终端时,file 元数据命令可带:
→ action=file id=someid file_id=f1 name=/path/to/destination compression=zlib
# 从终端接收时,客户端请求数据阶段的 file 命令可带:
→ action=file id=someid file_id=f1 name=/some/path compression=zlib
kitty 侧的编解码器即 file_transmission.py 中的 ZlibCompressor/ZlibDecompressor(后者用 zlib.decompressobj(wbits=0) 做流式解压,end_data 时 flush)。
七、绕过交互式用户授权(bypass)
为免交互确认,协议支持预共享密码。客户端在发起会话时发送“密码哈希 + 会话 ID”:
→ action=send id=someid bypass=sha256:hash_value
具体地,设会话 ID 为 mysession、共享密钥为 mypassword,则 bypass 值为 SHA256("mysession" + ";" + "mypassword"):
→ action=send id=mysession bypass=sha256:192bd215915eeaa8c2b2a4c0f8f851826497d12b30036d8b5b1b4fc4411caf2c
格式为 hash_function_name:hash_value(无空格),当前仅支持 SHA256。规范同时给出了明确的安全警告:
哈希并不能有效隐藏密码值,因此该功能只应安全/可信环境中使用。虽有比 SHA256 更慢的哈希函数,但会显著增加会话启动延迟,而且没有任何哈希函数在数学上被证明不可暴力破解。
以 sha 开头的前缀是保留的;终端实现可以使用自己的更高级方案。kitty 就是例子:它在 check_bypass 函数 中实现了 kitty-1 协议——客户端用 Kitty 通过环境变量注入的公钥(KITTY_PUBLIC_KEY,见 对 时间戳:会话ID;密码 做 AES-256-GCM 加密后 base85 编码发送;终端侧用本地私钥派生密钥解密,校验时间戳偏差不超过 ±5 分钟(防重放)后再用 hmac.compare_digest 比对明文。对应的用户配置项是 file_transfer_confirmation_bypass,用于存放共享密码;官方说明强调仅在可信计算机、可信网络或加密通道上使用,因为它会让远端机器上任何程序获得读写本地文件系统的权限。
八、转义序列编码:OSC 5113
传输命令编码为 OSC 转义序列:
<OSC> 5113 ; key=value ; key=value ... <ST>
其中 OSC 是字节 0x1b 0x5d,ST 是 0x1b 0x5c。键只含 [a-zA-Z0-9_] 字符;值的编码取决于键。解码时必须忽略未知键。5113 是常量,未被任何已知 OSC 码占用,它是单词 "file" 的数字化。kitty 中该常量定义在 control-codes.h 的 #define FILE_TRANSFER_CODE 5113,VT 解析器在 vt-parser.c 的 OSC 分发处将其路由到 Python 侧的 file_transmission 处理器。
8.1 键与值类型表
下表为规范定义的完整键表(Key name 列是实际序列化用的短名):
| Key | Key name | 值类型 | 说明 |
|---|---|---|---|
| action | ac | enum | send, file, data, end_data, receive, cancel, status, finish |
| compression | zip | enum | none, zlib |
| file_type | ft | enum | regular, directory, symlink, link |
| transmission_type | tt | enum | simple, rsync |
| id | id | safe_string | 近似唯一值,避免冲突 |
| file_id | fid | safe_string | 会话内每文件唯一 |
| bypass | pw | safe_string | bypass 密码与会话 ID 的哈希 |
| quiet | q | integer | 0 - 详细,1 - 仅错误,2 - 完全静默 |
| mtime | mod | integer | 文件修改时间,纳秒(自 UNIX epoch) |
| permissions | prm | integer | UNIX 文件权限位 |
| size | sz | integer | 字节数 |
| name | n | base64_string | 文件路径 |
| status | st | base64_string | 状态消息 |
| parent | pr | safe_string | 父目录的 file_id |
| data | d | base64_bytes | 二进制数据 |
Key name 是转义序列中实际发送的键名,例如 permissions=123 序列化为 prm=123,目的是压缩开销。
值类型定义:
- enum:允许值集合之一,如
ac=file; - safe_string:仅含
[0-9a-zA-Z_:./@-]中字符的字符串(注意不含分号,分号是字段分隔符); - integer:十进制数字字符
[0-9]组成,可带前导-;缺省即零; - base64_string:标准 base64 编码的 UTF-8 字符串;
- base64_bytes:标准 base64 编码的二进制数据。
序列化示例:
action=send id=test name=somefile size=3 data=01 02 03
编码为:
<OSC> 5113 ; ac=send ; id=test ; n=c29tZWZpbGU= ; sz=3 ; d=AQID <ST>
c29tZWZpbGU 是 somefile 的 base64,AQID 是字节 0x01 0x02 0x03 的 base64。编码形式中的空格仅为阅读清晰,实际序列中忽略。
kitty 的序列化/反序列化在 FileTransmissionCommand 数据类 中:每个字段用 metadata={'sname': ...} 声明短名,get_serialized_fields() 遍历字段、跳过默认值并按类型选择 enum 名字 / base64 / safe_string / 十进制字符串编码;deserialize() 则反向解析,且对未知键(fmap.get(key) 未命中)直接跳过——正好实现“未知键必须忽略”的要求。safe_string 的字符集校验即 safe_string_pat() 的正则 [^0-9a-zA-Z_:./@-],与规范完全一致。
九、kitty 实现中的补充约束
规范是协议层的契约,kitty 作为参考实现还附加了一些运行期约束,从源码结构看可以确认:
- 会话超时:空闲超过 10 分钟(
EXPIRE_TIME = 10)的会话会被过期清理; - 并发上限:
MAX_ACTIVE_RECEIVES = MAX_ACTIVE_SENDS = 10,同时活跃的收发会话各有上限; - 数据块 4096 字节的拆分由
split_for_transfer()统一完成,mark_last保证最后一块以end_data动作收尾; - bypass 密码比对全程使用
hmac.compare_digest常量时间比较,避免时序侧信道。
十、实战:用 transfer kitten 作为协议客户端
仓库内置的 transfer kitten 就是本协议的完整客户端实现(Go 核心在 kittens/transfer/,包括 send.go/receive.go/ftc.go),典型用法:
# 发送文件到终端所在的机器(接收端按提示批准)
kitty @ transfer --send file.txt
# 从终端所在机器接收文件
kitty @ transfer --receive remote/path
# 使用预共享密码跳过确认(对应协议的 bypass 机制)
kittens/transfer/main.py --permissions-bypass 'mypassword' --send file.txt
其中 --permissions-bypass 的值也可以是文件路径或描述符(见 main.go 的 read_bypass),kitten 会据此生成规范要求的 bypass=sha256:...(或 kitty-1:...)字段。测试方面,kitty_tests/file_transmission.py 覆盖序列化往返、元数据遍历与 bypass 校验,kittens/transfer/send_test.go 与 ftc_test.go 覆盖客户端侧命令构造,可作为协议行为的事实性验证依据。
结语
kitty 的文件传输协议用一条极其朴素的思路——把 key=value 命令塞进一个空闲的 OSC 码(5113)、所有二进制走 base64——换取了在任何 TTY 管道上都能工作的通用性。会话批准(或 bypass)、元数据先行/数据后行、4096 字节分块、fid/fid_abs/path: 三前缀链接编码、小端二进制的 rsync 签名与 delta 操作码、以及 quiet 静默级别,构成了这套协议的全部骨架。理解了本文的规范细节与 kitty/file_transmission.py 的对应实现,既可以自行编写最小客户端(哪怕 shell 脚本),也能深入调试 kitty 侧的传输行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00