# 跨机器的窗格消息(设计稿) 状态:已实现(2026-09-28 与用户逐项确认,其余各条按推荐做法定下;验收记录见 `docs/acceptance.md` 第 79 条)。实现落在 `src/link.rs`(纯逻辑)、`src/server/link_state.rs`(密钥、表、本机命令)、`src/server/link_out.rs`(发出)、`src/server/link_in.rs`(收进)、`src/web.rs` 的 `/link/` 路由;与本文的出入:配对答复里的公钥证明放在 JSON 正文的 `proof` 字段而不是头里;对方在线与否 `link list` 不探测,只显示最近一次通信时间。 **已定的决定一览** 1. 寻址:`主机:端口/$会话:@窗口.%窗格`(第 3 节)。 2. 验证:像 SSH 那样用公钥,配对一次以后免密;配对那一次用对方的 web 密钥(第 4 节)。 3. 身份认公钥,不认地址:对方换了 IP 或换成 Tailscale 地址,签名验证通过就照常收,并把它的联系地址更新为新地址。 4. `keepane web` 默认同时监听局域网地址和本机的 Tailscale 地址(有的话);`--bind` 仍可只指定一个。 5. 远程执行默认不行,按机器授权(`--shell`);未授权时发往 shell 窗格的消息拒收,发件方立刻收到错误。 6. 第一版不做远程创建窗格。(2026-09-30 做了,用户逐项选定,见第 11 条。) 10. (2026-09-28 追加,用户要求三项都做)能问到对方的:机器信息(`link info`:主机名、系统、keepane 版本、开机时长、CPU、内存、电池、pane 数、时间,任何配对的机器都可以问);发过去的消息在那边的结果(`trace-message` 自动去问,`-w` 在那边等;只能问自己发过去的消息,按收件方记下的来源公钥核对);对方某个 pane 的屏幕(`link capture`,只读文本,要对方单独授权 `--screen`,与 `--shell` 一样只能在窗格之外授予,授权表一行可带 `shell`、`screen`)。 7. 对方离线立即报错,不排队补发;编号两边各自分配,回信时换算。 8. 其余默认:`web --read-only` 同时拒收远程消息;被新机器配对时本机状态栏提示一次并记入事件日志;协议带版本号;`link rekey` 换本机密钥;dashboard 与手机页面第一版不显示远程窗格;私钥文件只给本用户读写。 11. (2026-09-30,用户选定)在对方电脑上开窗格:默认关,按机器授权 `link allow <机器> --panes`(同 `--shell`、`--screen`,只能在窗格之外授予);只能启动对方 `agent-commands` 白名单里的程序,数量受对方 `agent-pane-limit` 限制、按发起机器单独计数;默认放进以发起机器主机名命名的 session(每次一个新窗口),可用 `-s` 指定;工作模式按程序自动定(`-m` 可指定),往那边 `shell` 窗格发命令仍要 `--shell`;只能关自己开的窗格(`link kill`),对方只在它的 server 运行期间记得哪些是谁开的。MCP:`create_remote_pane`,`kill_pane` 给远程地址时转成 `link kill`。 12. (2026-10-14,用户选定)把提交送到对方:`clone`、`push`,单向,只送已提交的内容;按机器授权 `link allow <机器> --push`(同 `--shell`、`--screen`、`--panes`)。设计见 [link-push.md](link-push.md)。 9. 密钥和授权表按服务端(`-L` socket)分开:一个服务端就是网络上的一个端点(它有自己的 web 端口),同一台电脑上的两个服务端也能互相配对。 ## 1. 目标与非目标 **目标** - 不同电脑上的 keepane 窗格能互发消息、回信,用法与本机的 `send-message` 相同([mailbox.md](mailbox.md))。 - 只要两台机器在同一网段就能用:局域网,或 Tailscale 这类私有网络。 - Windows、macOS、Linux 之间都能互发。 **非目标(第一版不做)** - 远程 `send-keys`、attach:手机页面已经能做。(看屏幕后来按第 10 条做成只读的 `link capture`,要对方授权。) - 自动发现(mDNS):Tailscale 不支持组播,局域网里也还是要确认;配对只有一种做法(第 4 节)。 - 穿越公网、TLS:和 `keepane web` 一样,只用于信任的网络;不信任的网络请用 Tailscale。 - 对方离线时替它排队、以后补发(第 7 节)。 ## 2. 传输:复用 `keepane web` 的端口 跨机器消息走 `keepane web` 已经在监听的地址和端口,路径在 `/link/` 下。**一台机器要收发远程消息,必须开着 `keepane web`**:它的 `host:port` 就是这台机器在地址里的写法,回信也是发回这个地址。 - 不另开端口:一个端口、一个防火墙放行、一套 `status`/`stop`。 - `keepane web --read-only` 同时拒收远程消息:只读的意思是网络上谁都不能让这台机器做事。 - 远程请求和手机请求的凭证不同(第 4 节:配对后用机器的公钥,web 密钥只在配对那一次用),权限也不同:配对的机器只能发消息、列窗格,不能 `send-keys`。 ## 3. 寻址(已定) 远程窗格的完整地址 = **主机:端口 + 会话编号 + 窗口编号 + 窗格编号**: ``` 100.64.0.3:7681/$1:@3.%7 [fd7a:115c::3]:7681/$1:@3.%7 # IPv6 加方括号 mac.tail1234.ts.net:7681/$1:@3.%7 # 主机也可以是名字(Tailscale MagicDNS) ``` - `/` 后面是对方机器上的本地地址,规则与本机相同:`$1:@3.%7` 以 `%7` 定位、前缀校验,窗格被移动过就报错不投递。 - `/` 后面也接受本机支持的其他写法(`%7`、`%builder`),但信封、列表里显示的永远是完整地址。 - 主机:端口是对方 `keepane web` 的地址,不是另起的名字。机器的地址变了(局域网 DHCP 换了 IP)就要重新配对;Tailscale 的地址是固定的。 ## 4. 验证与配对(已定:要验证;按 SSH 公钥的方式,配对后免密) 用户要求(2026-09-28):像 SSH 那样,配对一次以后免密。对应关系: | SSH | keepane | |---|---| | `~/.ssh/id_ed25519`(私钥,永不离开本机) | `link//key`:每个服务端一对 Ed25519 密钥(种子,base64url),第一次用到时生成,存在本机数据目录,只给本用户读写(Windows 受保护的 DACL,Unix 0600);损坏时报错、不重建 | | `ssh-keygen -y`、`id_ed25519.pub` | `keepane link id`:打印本机公钥 | | `authorized_keys`(谁能登录,行尾可带选项) | `link//authorized`:谁能发消息进来,一行一台机器:`公钥 地址 [shell] [screen]`,可以手工编辑(坏行跳过) | | `known_hosts`(记住对方主机的公钥) | 同一份表:配对即记下对方公钥,之后对方的回应也要用这把公钥验证,换了机器(同一 IP 上的另一台)会被拒绝 | | `ssh-copy-id`(用一次密码把公钥装过去) | `keepane link add <对方 web 地址>`:用一次对方的 web 密钥把公钥装过去 | **配对流程**(在 A 上加 B): ``` B$ keepane web # 打印二维码和 http://100.64.0.3:7681/#k=... A$ keepane link add http://100.64.0.3:7681/#k=... # 手机扫的同一个地址 ``` 1. A 发配对请求:A 的地址、A 的公钥,用 B 的 web 密钥做 HMAC(web 密钥不上网)。 2. B 验证 web 密钥,把"A 的地址 + A 的公钥"写进自己的 `authorized`(不带 `shell`),回应 B 的公钥(同样用 web 密钥做 HMAC,A 由此确认回应来自持有 web 密钥的 B,不是盲目的首次信任)。 3. A 把"B 的地址 + B 的公钥"写进自己的 `authorized`:一次配对,双向免密。执行权限两个方向各自单独授权(第 5 节)。 之后 web 密钥不再参与:B 重启 web、换了临时密钥,配对照样有效,直到任一方 `link remove`。**临时还是长期与 web 一致**,指的是配对时用的那把 web 密钥:默认每次启动 web 都换,`--keep-key` 保留;配对关系本身像 `authorized_keys` 一样长期有效。 **不经过 web 密钥的配对**(像手工往 `authorized_keys` 里贴公钥):A 上 `keepane link id` 打印公钥,B 上 `keepane link trust `,反方向同样做一次。 **每个请求**:请求头带协议版本、keepane 版本、发件方公钥、发件方的 web 端口、时间、随机数和 Ed25519 签名(签 `协议 方法 路径 发件方公钥 发件方端口 收件方公钥 时间 随机数 正文摘要`:收件方公钥在内,截下的请求拿到别的机器上无效);收件方按发件方公钥在 `authorized` 里找到那一行才验签(不在表里即拒;地址不参与认人,验签通过后把这一行的地址更新为请求实际来自的 IP 加声明的端口),回应也由收件方签名(签名里带请求的随机数),发件方用记下的对方公钥验证。时间与本机相差超过 120 秒就拒收(错误里提示检查时钟),随机数在 240 秒内不能重复(防重放)。私钥不上网,也不在任何请求里出现。依赖:`ed25519-dalek`(带 `sha2`)。 **不做无验证模式**:发给 ai 窗格的消息就是给 agent 的指令,等同于远程执行;免密靠公钥,不靠"不验证"。 **命令**: ``` keepane link id # 本机公钥 keepane link add <对方 web 地址> # 配对(ssh-copy-id) keepane link trust <主机:端口> <公钥> [--shell] [--screen] # 手工授权一台机器 keepane link list # 已配对的机器:地址、公钥指纹、能否执行命令、最近一次通信(不探测在不在线) keepane link panes <主机:端口> # 对方的窗格列表(完整地址、名字、模式、空闲、收件箱、程序;0.26 起再加 session 名、窗口序号、窗口是否当前、窗格序号、窗格是否活动、窗口名,供 choose-tree 的 chart 用) keepane link info <主机:端口> # 对方机器:主机名、系统、版本、开机时长、CPU、内存、pane 数(第 10 条) keepane link capture [-S 行数] <主机:端口/pane> # 对方 pane 的屏幕文字,要对方 --screen 授权(第 10 条) keepane link allow <主机:端口> [--shell | --no-shell] [--screen | --no-screen] keepane link remove <主机:端口> # 删本机这一条,并通知对方删它那一条(对方不在线时只删本机) ``` ## 5. 权限(已定:远程执行默认不行,按机器授权) - 配对后默认只能把消息投进 `ai`、`normal` 窗格。 - 发往 `shell` 窗格(收到就执行)要求这台机器对发件机器执行过 `link allow <发件机器> --shell`。未授权时**拒收**,发件方立刻收到错误:`100.64.0.3:7681 may not run commands here (%7 is in shell mode): on this machine, keepane link allow 100.64.0.3:7681 --shell`。(选项里写的是"排队不执行";改成拒收,因为排着的消息永远不会执行,发件方也不知道。用户已确认。) - 被拒的请求(未配对、签名不对、时钟差、重放、未授权的 shell)在状态栏提示并记入事件日志,同一地址 60 秒内只记一次:否则局域网上有人大量乱发,会刷屏,还会写满当天的事件日志配额(`event-log-max`),挤掉真正的消息事件。 - `link allow --shell` 与 `set-work-mode` 同一条规则:在窗格里运行时拒绝,只能在 keepane 之外的终端或 `C-b :` 里运行。否则窗格里的 agent 可以自己给远程机器开执行权限。 - 投递时已有的规则照旧:写给 agent 的消息不会因为窗格中途改成 `shell` 而被执行。 - 开窗格(`/link/pane`)要 `--panes`;程序取 `agent-commands` 白名单(与本机 agent 开窗格同一份),数量按发起机器单独计 `agent-pane-limit`;关窗格(`/link/kill`)只认发起机器自己开的(`made` 表,按公钥,只在内存里)。未授权 403,并告诉对方怎么授权。 ## 6. 信封、编号与回信 - `from` 写成发件方的远程完整地址:`[keepane id=5 task=5 from=100.64.0.3:7681/$1:@3.%7 name=lead mode=ai to=$1:@4.%9 via=ai hop=1]`。字段含义没变(仍是发件方的完整地址),不升版本。 - `from` 的主机:端口由收件方按验证通过的公钥填写(`authorized` 里那一行的地址),发件方伪造不了。 - 编号(`id`、`task`)各机器各自分配。B 记下"本机 #5 = A 的 #12(任务 #12)":B 上的窗格用 `-r` 或 `--re 5` 回信时,B 发回 A,并在 A 那边写成 `re=12 task=12`,所以 A 的 `show-task 12` 能看到回信。 - 跳数跨机器照常累加,`message-hop-limit` 同样拦得住两台机器上的 agent 互相转发。 - 事件日志两边各记自己这一侧:A 记 `sent`(带对方回的状态,例如"在 B 上是 #5,排队中"),B 记 `sent`、`delivered`、`done`。 ## 7. 离线与等待 - 对方不在线(连不上、超时 3 秒):`send-message` 立即失败,退出码非 0,由发件方(人或 agent)决定是否重试。不在本机排队补发:以后突然执行一条过时的命令比失败更糟,排队还要持久化和去重。 - `send-message -w 秒` 发往远程:B 在本地等到投递或超时再回答,A 的请求超时 = 等待时间 + 5 秒。时间到了仍在排队,A 报 `timed out: …`、退出码非 0,与本机的 `-w` 一致;消息仍在 B 的收件箱里,A 的记录是 `forwarded`。 - 发远程消息要求本机开着 `keepane web`(否则回信无处可去),报错会说明。 ## 8. MCP - `send_message`、`reply` 的 `to` 直接接受远程地址。 - `list_panes` 加可选参数 `host`(主机:端口);新增 `list_links`。 - `trace_message` 只查本机的记录;远程那一侧的经过在对方机器上查。 ## 9. 实现 - `src/link.rs`:地址解析、签名与验证、配对表的读写,全部是纯函数、不做 IO,便于单测。 - `src/web.rs`:`/link/pair`、`/link/send`、`/link/panes`、`/link/remove` 路由,验证失败返回 401。 - 服务端:发往远程的消息在 tokio 任务里发 HTTP 请求,结果以事件回到主循环再回答客户端(与 `web-stop` 的 `Pending` 同一做法),不阻塞服务端。收到的远程消息走本地 `send-message` 的同一条投递路径,只是来源、跳数、对方编号由链路填写。 - HTTP 客户端手写(请求简单,不引入 HTTP 库)。 ## 10. 验收要点 - 同一台电脑上起两个服务端(`-L a`、`-L b`,web 绑 `127.0.0.1` 的不同端口,各自的密钥目录)做端到端测试:配对、双向收发、回信编号换算、`-w`、跳数上限跨机器生效、shell 未授权被拒、授权后执行、在窗格里 `link allow --shell` 被拒、签名错、未授权的公钥、对方公钥被换(回应验证失败)、时间差超限、重放、`--read-only` 拒收、对方离线立即失败、web 换了临时密钥后配对仍有效、`link remove` 后被拒、手工 `link trust` 配对。 - 真机:Windows 与 macOS 通过 Tailscale 互发一次,shell 与 ai 各一条。 - 三轮干净规则照常,记入 `docs/acceptance.md`。