---
url: /wave-agent/specs/remote/remote-machine.md
description: 同一台机器只有一行登记、同一时刻只有一个服务方，以及页面上的机器列表与切换
---

# 功能规格说明：机器身份

**创建日期**：2026-10-13（2026-10-30 从 `ui/remote-control.md` 拆出）

本文只讲**这台机器在远控里的身份**：hostId 从哪来、多进程并存时谁在服务、手机上怎么在多台电脑之间切换。通道本身见 `remote-channel.md`。

## 用户场景与测试 *（必填）*

### 用户故事：机器身份稳定——同一台机器只有一行登记（优先级：P1）

作为在同一台电脑上反复开关远程控制（CLI / 插件端 / 桌面端任一入口）的用户，我希望这台机器在中继上始终是同一行、同一个 hostId，而不是每丢一次本地状态就多出一条同名记录，并连带换掉页面地址与主屏图标。

**为什么是这个优先级**：首次使用感受不到它（首次登记本来就要建一行）。它决定的是**长期整洁与地址稳定**：hostId 今天是中继在建行那一刻随机生成的，本机只是把结果记在 `~/.wave/remote-control.json` 里——那个文件一丢（重装、清 home、只读 home、换 OS 用户），下次开启就会在同一个账号下再建一行，手机上机器列表里出现多条同名记录，页面地址与主屏图标也一起失效。把 id 的**来源**从「中继随机」换成「本机按机器标识派生」之后，同一台机器算出的 id 恒定，登记因此变成幂等：本地状态丢了也只是把同一行认回来。**跨仓**：这需要中继支持「按本机提交的 id 幂等登记」，本机侧只是换 id 的来源。

**独立测试**：在同一台机器上删掉 `~/.wave/remote-control.json` 后重新运行 `wave remote-control on`，中继的机器列表**行数不变**、hostId 与删除前相同；CLI 与桌面端分别开启得到同一个 hostId；屏蔽机器标识读取后开启仍成功（回退到中继生成的 id）。

**验收场景**：

1. **假设** 本机没有身份（`~/.wave/remote-control.json` 里没有 hostId），**当** 开启远程控制时，**则** 登记请求带上按机器标识派生的 id（「固定命名空间 + 机器标识 + 本机登录账号 id」派生出的 UUID，v5 / RFC 4122），中继按「本账号 + 该 id」查行——命中则复用那一行（不新增行）并重新签发 `hostToken`，未命中则以这个 id 建行；响应里的 `host.id` 与 `hostToken` 一起写进本地状态。
2. **假设** 本地状态文件被删除、损坏或写不进去（重装、清 home、半写），**当** 用户在同一台机器、同一账号下再次开启时，**则** 中继上**不新增行**：派生出的 id 与删除前相同，登记认回原来那一行，页面地址与主屏图标不变。
3. **假设** 同一台机器上先后从 CLI（含 TUI 斜杠命令）、插件端、桌面端开启，**则** 三个入口共用同一份本地状态与同一个 hostId（入口不同不产生额外行）——这一点与今天一致，本故事改的是「本地状态是唯一载体」这件事本身。
4. **假设** 本机已经有一行（老机器：本地存着历史上由中继随机生成的 hostId），**则** 继续用它、**不做迁移**：本地状态在就一直复用，而迁移会让每台老机器多出一行垃圾。派生只在本地没有身份时才参与。
5. **假设** 本机读不到机器标识（平台不支持、权限不足、取值失败），**当** 首次登记时，**则** 登记照常进行：请求不带 id，由中继生成随机 id（今天的行为），响应里的 id 写进本地状态；读不到标识不导致开启失败。
6. **假设** 同一台机器上换成另一个账号登录（另一个 OS 用户、或登出后再登入别的账号），**则** 派生的输入不同 ⇒ 另一个 hostId、中继上另一行——行的归属本来就是「某个账号的一台机器」，与中继 `relay_hosts.user_id` 的口径一致。
7. **假设** 派生出的 id 在中继上已经被**另一个账号**占用（同一份机器标识被两台机器共享：克隆的虚拟机、镜像还原），**则** 中继不覆盖他人的行，改为生成一个随机 id 建行并把实际 id 返回；本机以响应里的 id 为准。**已知代价**：这类机器每次丢本地状态都会再建一行（幂等在这条路径上不成立）——根因是它们本就自称同一台机器。
8. **假设** 机器标识要送往中继，**则** 送的是**不可逆的派生值**（派生摘要）而不是原文：标识只在本机读取、只参与派生，中继与手机上看到的永远只是 id。
9. **假设** 用户在中继侧吊销了这台机器的行（`DELETE /api/relay/hosts/:id`），**当** 本机带着同一个派生 id 重新登记时，**则** 中继复用那一行、把它翻回 active 并重新签发令牌——吊销的语义是「**立刻失效、断开连接、从列表消失**」，不是「永久拉黑这台机器」。⚠️ **因此本机侧的实际效果是「踢掉当前连接 + 从列表消失，直到本机下一次连接」**：本地状态还在并不会让吊销更彻底，因为连接被 `4003 invalid_host_token` 拒绝时本机会自愈重登记（连接生命周期故事场景 4–6）、把同一行认回来 —— 一次都不用在 `~/.wave/` 里手工挪文件。**真要表达「删掉就不许回来」，得先有中继侧的拉黑能力**（本规格不做）；在那之前，吊销只能表达「现在断开、列表里看不见」。
10. **假设** 本地状态丢失的同时有多个入口并发首次开启，**则** 它们提交同一个 id、都命中同一行，但**各自拿到一份新令牌**，后到的那份让先到的失效（一台机器一份令牌）——表现是先开的那个可能连不上，重新开启即恢复。**已知取舍**：中继只存令牌摘要，而「本地状态丢失后要能拿回令牌」只能靠重新签发，这个窄窗口不额外加锁。

***

### 用户故事：一个通道一个服务方——后开的入口接管，先开的自动停（优先级：P1）

作为在同一台机器上从不同入口（CLI / 插件端 / 桌面端，甚至桌面端的不同分屏）开远程控制的用户，我希望**最后开启的那一个**成为手机看到的那一个、先前那个自动退下来，而不是两个进程每两秒互相顶一次、手机跟着反复断流。

**为什么是这个优先级**：通道键是设备级的（一台机器一个 hostId），而「谁持有会话谁服务」意味着同一时刻只能有一个进程在服务。**中继侧已经就是这样**：同一通道上后到的主机上线时按「换代接管」处理——先把登记换成新的，再以 `4001` 关闭旧连接（跨中继实例经总线广播同样的接管），并且**不惊动订阅者**（不给手机推 `peer_disconnected`、不清在线键），所以手机侧看到的是一次无缝替换。缺的是本机侧的另一半：被顶掉的那个进程把 `4001` 当成瞬态错误、两秒后重连就又把对方顶掉，于是两个进程**每两秒互踢一次**、永不收敛（每次都能连上，而每次连上都会清空重试预算，谁都用不完预算）。本故事只补这一半：**被接管 = 这个进程不再服务**，不再抢回来。

**独立测试**：在同一台机器上先从一个入口开启远控、再从另一个入口开启；第二个的页面地址能连上；第一个的状态里出现「已被另一个入口接管」的说明、且不再拨号（观察两分钟无重连）；把第一个关掉再开启，它能重新接管回来。

**验收场景**：

1. **假设** 同一台机器上已经有一个进程在服务（daemon / GUI 子进程 / 前台 TUI），**当** 用户在另一个入口开启远程控制时，**则** 由**后开的那个**接管：中继把登记换成它、并以 `4001`（`reason: "replaced"`）关闭旧连接，手机侧不出现断开提示（不推 `peer_disconnected`、在线状态不翻）。
2. **假设** 一个进程的连接是被「换代接管」关掉的，**当** 本机收到那次关闭时，**则** 它按**被接管**处理：不再重连，状态明确写出「已被这台机器上的另一个入口接管」，不留一句笼统的「中继连接断开」。**依据**：中继用的是同一个 `4001`，光靠关闭码分不出「我被顶替了」与「这台机器没上线」；区分靠 close 帧的 `reason`（中继在接管时下发 `replaced`）。
3. **假设** 用户在被接管的那个入口里重新开启，**则** 它重新拨号并**接管回来**（后开启者为准）——接管关系不粘在进程上，`off` / `on` 始终是各入口自己的动作，谁最后连上谁服务。
4. **假设** 用户在某个入口里关闭（`off` / 弹窗断开），**则** 关掉的只是**那个进程**那条连接（既有口径不变）：别的进程若在服务，手机不受影响；没有任何进程在服务时，手机侧按 `4001` 重连（页面走既有的「等待上线」自愈路径）。
5. **假设** 桌面端的两个分屏各自开启远程控制，**则** 规则同上——后开的那个成为手机读到的会话集合，先开的那条连接停止（每个分屏一个 `--stdio` 子进程，各自都是一次「开启」）。
6. **假设** 一台远端机器上的 daemon 正开着远程控制（无人值守场景），**当** 有人在那台机器上跑一次前台 TUI 并开启时，**则** 前台进程接管、daemon 那条连接停止并如实显示被接管。**这是刻意的取舍**：接管只看「谁最后开启」，不按入口类型分优先级——无人值守要守的是「别在那台机器上再开一次」，而不是给 daemon 特权。
7. **假设** 先开的进程后来被重启（`wave daemon restart` 等），**则** 它启动后按自己的开关状态重新拨号——那是一次新的「开启」，于是它又接管回来（同场景 3 的规则）。

***

### 用户故事：机器列表与切换——一个页面管所有机器（优先级：P1）

作为有不止一台电脑的用户，我希望加到主屏幕的那**一个**图标就能列出并切换我账号下开启了远程控制的所有机器，而不是每台机器一个图标、各自登录一次、各自扫一次码。

**为什么是这个优先级**：单机器用户感受不到它（带 `host=` 的地址行为不变）；它是多机器用户「N 个图标 N 次登录」痛点的解药，也是「新机器开启远程控制后不必重新扫码」的前提——机器自己登记即出现在列表里，页面刷新即可见。收端的列表接口（`GET /api/relay/hosts`）已就绪，本故事是**纯页面侧**的活，收端零配合。本故事做的是**选择、切换，以及清理不再使用的老机器**：删除入口只对离线机器开放，删掉的是那一行登记，而不是「永久拉黑这台机器」——那台电脑重新开启远程控制后仍会回到列表，这正是我们要的口径（2026-11-01 定）；重命名等其余机器管理仍不做（收端有接口，页面不接）。

**独立测试**：打开不带 `host=` 的页面地址（已登录），页面列出账号下的机器（名称 / 平台 / 在线状态）；选中一台进入会话视图且地址被改写成带 `host=`；再次打开直接重连上次选中的那台；「换机器」入口能回到列表切换另一台，且列表读取中 / 取不到的提示只出现在弹窗里，关掉弹窗后页面与点击前一致；删掉一台离线机器后列表随即少一行，而它重新开启远程控制后再次出现。

**验收场景**：

1. **假设** 地址里带 `host=`，**则** 页面行为与今天**逐字节一致**：直接连那台机器，不请求机器列表——这是既有链接与二维码的兼容面，也是单机器用户的主路径。
2. **假设** 地址不带 `host=` 且本地记住了上次选中的机器、那台仍在列表里，**则** 页面直接重连它（不展示列表），并把选择写回地址（`history.replaceState` 补上 `host=`）——主屏图标点开仍是同一台；同一账号下新增一台机器不改变这一点。
3. **假设** 地址不带 `host=` 且没有本地记录、或记录的那台已不在列表里，**当** 页面取到机器列表时，**则** 展示机器列表（每台给名称、平台、在线状态），选中即写地址并连接。在线状态来自接口的 `online` 字段（心跳续期，断连后 ≤60 秒翻为 false），除了决定删除入口是否可用（场景 10）之外只作**展示**判据——选中一台离线机器是允许的，交给「等待上线」的自愈路径（见断线自愈故事），不让离线状态把入口关死。
4. **假设** 列表为空，**则** 页面给明确状态（还没有机器开启远程控制 + 在电脑侧开启的指引），不留白屏。
5. **假设** 用户想换一台机器，**则** 「换机器」入口是页面侧的一个常驻入口，但**收在「更多」菜单里**（菜单项文案「切换电脑」，排在菜单第一项；形态归页面侧壳，不改共享聊天 UI 的交互语义），电脑连不上或断开时也照常可用（状态条是流内一行、不压顶栏，见「状态只在一条流内状态条上播报」场景 1）。**依据**：换机器是低频动作（多机器用户才用得上），占着顶栏一枚常驻图标与「新建对话 / 会话 / 更多」抢位置不划算；菜单里的一整行既保住了可达性，也让顶栏只留高频的三个入口。切换 = 改通道键 + 丢弃地址里的 `session=`（那是上一台机器的会话）+ 重走完整连接（快照、忙闲、待审批、权限档全部按冷启动口径重读）。
6. **假设** 页面要取机器列表，**则** 它对**同源**的 `GET /api/relay/hosts` 发起请求（凭证就是页面已在用的企业端登录令牌，接口按「登录用户 == 主机属主」返回该账号的机器，无服务端配对步骤），因此 CSP 的 `connect-src` 必须从 `wss:` 放宽到 `wss: 'self'`。**依据**：这张 CSP 是页面初版定的，当时页面没有任何同源出站请求；后来加的登录态静默续期（`POST /api/auth/refresh`）同样是同源 fetch，却没放宽 CSP——线上那一步会被**静默拦掉**、退化成跳登录页，而 jsdom 不执行 CSP，测试咬不到。机器列表是第二个同源 fetch，本故事把这张网一起补上。
7. **假设** 列表请求失败（网络不可达、接口异常），**则** 页面给明确状态与重试入口，不白屏、也不静默渲染成空列表。
8. **假设** 用户点「换机器」入口（「更多」菜单里的「切换电脑」一项），**则** 这次读取的两种中间状态都由**弹窗自己**承载：读取期间弹窗里显示「正在读取机器列表…」，取不到时弹窗里显示原因与「重试」（重试就地重取、刷新同一个弹窗）——而页面顶部那条横幅在弹窗开合前后**逐字不变**，关闭弹窗（取消）后页面与点击前一致。**依据**：那条横幅是连接层的**状态**面（「电脑侧不在线，等待上线…」这一句，外加关闭键），列表读取则是**过渡**；把过渡写进状态面有两个后果——① 用错误色的横幅呈现「正在读取」，用户读到的是「出事了」（2026-10-15 实测反馈）；② 覆盖之后再无恢复路径，用户在等待上线时点一次「换机器」再取消，连「正在重连」那句一起丢掉。冷启动那次取列表（地址不带 `host=`、页面此刻还没有别的东西可说）仍用横幅占位，但取**中性**色：它说的是「正在做一件事」，不是「某件事失败了」。
9. **假设** 列表里某台机器处于离线状态（`online` 为 false），**当** 用户点它那一行的删除并确认，**则** 页面调用**同源**的 `DELETE /api/relay/hosts/:id`（凭证同机器列表；登录态过期就就地续期一次，续不上按既有阶梯跳登录页），成功后**弹窗留在原地、那一行随即消失**。删除的语义是**吊销那一行**（中继把 `status` 翻成 `revoked`，`GET /api/relay/hosts` 不再返回它），**不是拉黑**：那台电脑重新开启远程控制时会以同一个 hostId 重新登记并回到列表（中继对已吊销的行是复用 + 翻回 active）。请求失败时弹窗里给原因与「重试」，不静默把一行抹掉。**依据**：收端 `DELETE /api/relay/hosts/:id` 已就绪（成功 204、不是本账号的行 404、对已吊销的行幂等仍 204），本故事仍然收端零配合。
10. **假设** 那台机器在线（`online` 为 true，即最近 60 秒有心跳），**则** 它那一行的删除入口不可用，并说明原因（「这台电脑正在使用远程控制，请先在那台机器上关闭后再删除」）。**依据**：吊销会让中继把该 hostId 上的电脑侧与所有手机侧连接**一并以 4003 关掉**；不让删在线机器，等于把「删除动作把用户自己正在看的连接踢掉」这条交互分支挡在门口。**代价**：刚在那台电脑上关闭远程控制后，列表要等心跳过期（≤60 秒）才允许删——这个延迟可接受，不另加判据。
11. **假设** 被删掉的正是本页上次选中的机器（本地记住了它的 hostId），**则** 页面同时清掉这份本地记录；否则下次冷启动仍会拿这个 id 去连，订阅者鉴权按「行不存在」回 `unknown_host`（4003），页面退化成一条「中继拒绝连接」的错误。

***

### 边界情况

**一台机器只有一个服务方**：通道键既然是设备级的，同一台机器上的多个 wave 进程就只能**共用同一个通道键**（本机只有一份 `~/.wave/remote-control.json`）。于是「谁持有会话谁服务」在多进程并存时必须收敛成一个答案——手机指向的那台机器，同一时刻只由其中一个进程提供服务，不允许两个进程都以为自己在线（前台 CLI 故事第 8 条）。

**机器身份取自机器标识，不取自本地文件、也不取自主机名**：id 由本机按「机器标识 + 本机登录账号」派生（见「机器身份稳定」故事），同一台机器上的多个入口、多个进程、多个工作目录在任何时刻都算出同一个 id；`~/.wave/remote-control.json` 不再是身份本身，只是「已知身份 + 令牌」的缓存。机器标识按平台取系统自带的那个（Linux `/etc/machine-id`、macOS `IOPlatformUUID`、Windows `MachineGuid`），拿不到就回退到今天的行为（中继生成随机 id）；**不取主机名**——主机名可改、可重复，把它当身份只会制造「改个名就多一行」的假象，而它作为展示标签（`name`）已经够用。派生值一旦定型就不再改：换命名空间或换派生输入等于换身份、会让所有机器重新登记一行，所以它只在本机没有已存身份时才参与（故事场景 4），既有的行不追着算法迁移。

**本地身份是缓存，凭据类 4003 触发一次自愈**：`~/.wave/remote-control.json` 里那对 `hostId` + `hostToken` 会失效（行被吊销、令牌 90 天到期、或半写导致两者不配对），失效后的表现是中继用 `4003` + `auth_error: invalid_host_token` / `unknown_host` 把连接挡回来。**不修的话只剩「手工挪走那个文件」这一条路**，而用户根本不知道它存在（2026-10-16 实测：清空测试环境列表后，Mac 上只能靠人肉 `mv` 恢复）——所以本机按 `auth_error` **分类处置**：凭据类 ⇒ 作废本地身份、重新登记一次（派生 id 让这次登记幂等，认回同一行）、写回、重连；其余永久类（版本 / 组织 / 账号）**不自愈**，因为重新登记改变不了它们、只会盖掉真实原因。**代价是吊销的语义被摊薄**：「删掉这台机器」从「永久消失」变成「踢掉 + 从列表消失，直到本机下一次连接自己回来」，这是本规格明确的取舍（机器身份稳定故事场景 9）。**不做拉黑**也是刻意的：拉黑需要中继侧的持久拒绝能力，本规格不做；在没有它的时候假装「吊销 = 拉黑」，代价由用户以一个他无从诊断的坏文件来承担。

**`--stdio` 子进程与 daemon 都可能是服务方**：同一台机器上「谁持有会话谁服务」，手机看到的就是那个进程的会话集合；GUI 三端各自拉子进程 ⇒ 各自可能同时持有一条出站连接，这是设计而不是重复。

## 未落地与待决

* \***\*同一台机器两个服务方（2026-10-16 拍板为「后开接管」）**：通道键是设备级的（本机只有一份 `~/.wave/remote-control.json`），所以「daemon / GUI 子进程」与「前台 TUI」同时开启时，会出现同一个 hostId 的多条出站连接。中继侧的实际行为**已核对**：同一通道上后到的主机上线时按换代接管处理（换登记 → 以 `4001` + `reason: "replaced"` 关闭旧连接，跨中继实例经总线广播同样的接管），且不给订阅者推 `peer_disconnected`、不清在线键。此前未定的取舍已拍为**后开接管、先开自动停**（见「一个通道一个服务方」故事）；留给收端的固化项 = 把接管写成契约（关闭码与 reason 稳定、订阅者不受影响），因为本机侧靠 reason 区分「被接管」与「这台机器没上线」。
* \***\*机器列表与切换 / PWA 主屏元信息 / 断线自愈（#2432，2026-10-15 规格确认并实现）**：三条新故事（机器列表与切换、PWA 主屏元信息、断线自愈）已按规格落地——机器列表与切换（`/api/relay/hosts` + 机器选择浮层 + 「更多」菜单里的「切换电脑」入口）、PWA 主屏元信息（manifest + 真实 PNG 图标 + 两处引导文案）、断线自愈（瞬态断开无限退避重试、永久失败即停、回前台试连）。收端零配合的前提已对收端源码核实：`GET /api/relay/hosts` 就绪（含 `online` 字段）、运营平台上传白名单已含 `png` / `json` 且 MIME 齐全。页面侧的两处 CSP 放宽（`connect-src 'self'`——这一处同时修复静默续期被拦的既有缺陷——与 `manifest-src 'self'`）分别钉在机器列表故事场景 6 与 PWA 故事场景 2。
* \***\*机器身份稳定（2026-10-16 规格确认，两侧均未实现）**：新增「机器身份稳定」故事（10 条场景），把 hostId 的来源从「中继随机生成」改成「本机按机器标识 + 登录账号派生、随登记请求提交」。**收端（codechat）要给的配合**：`POST /api/relay/hosts` 接受**可选** `id`（UUID、36 字符，与 `relay_hosts.id CHAR(36)` 同形），按「`user_id` + `id`」幂等处理——命中已有行则复用该行（更新 `name` / `platform` / `last_seen_at`，把 `status` 翻回 `active`，**重新签发 `hostToken`** 与 `token_expires_at`），未命中则以该 id 建行；`id` 已属于另一个账号时不覆盖他人的行，改由中继生成随机 id 建行；响应形状（`{host, hostToken}`）不变，`host.id` 始终是最终身份；请求不带 `id` 时行为逐字不变（老版本客户端不受影响）。**本机侧**：登记请求多带一个由「机器标识 + 登录账号 id」派生的 UUIDv5，派生只在本地状态没有 hostId 时发生，机器标识读取失败即回退到今天的路径；`~/.wave/remote-control.json` 的字段与语义不变。**仍未做的**：两侧实现，以及一次「删掉本地状态后重新开启、中继列表行数不变且 hostId 与删除前一致」的验证（先测测试环境）。
* \***\*删除离线机器（2026-11-01 规格确认并实现）**：机器列表加了「删除」——只对离线机器开放，删的是中继上那一行登记，不是永久拉黑，那台电脑重新开启远程控制后会以同一个 hostId 回来（见「机器列表与切换」故事场景 9–11）。**落地位置**（全在 `packages/webview/remote/shim.js`）：`machineDeleteButton` 是入口与置灰理由（判据取接口的 `online === false`；字段缺失**不**算离线，宁可置灰也不误删一台正在用的机器），`askDeleteMachine` 是行内二次确认（不叠第二层浮层），`deleteMachine` 走 `withRenewal` + `relayRest` 打同源 `DELETE /api/relay/hosts/:id`（凭证与机器列表同一份、登录态过期就地续期一次、续不上按既有阶梯跳登录页），204 与 404 都让那一行消失（404 = 这一行已经不在这台账号下了，重试永远不会成功，所以不给「重试」），其余失败在弹窗里给原因与「重试」，不静默抹掉行；`dropMachine` 负责清本地记录、必要时连地址里的 `host=` 一起去掉（场景 11）。场景 10 的「说明原因」落在弹窗副标题上，不只靠 `title`——这一页的主读者是手机，hover 文案不出现。用例见 `packages/webview/tests/remote/shim.test.ts`（离线可删 / 在线置灰 / 状态缺失也置灰 / 续期一次后重试成功 / 续不上跳登录页 / 404 视为已不在 / 5xx 留弹窗给原因与重试 / 删的正是上次选中的机器则清记录并去掉地址里的 `host=` / 删别的机器不动记录）。**收端零配合**：`DELETE /api/relay/hosts/:id` 早已就绪，页面本来就有同源 CSP 与续期阶梯。**仍未做的**：真机（真中继 + 真手机）验一遍——删掉一台后，那台电脑重新开启远程控制确实以同一 hostId 回到列表。**同一轮顺带确认、但未纳入本规格的两件**：① 列表行是否显示「最近活跃」时间（接口已带 `lastSeenAt`，心跳里每 5 分钟最多回写一次，可用来识别老机器）——未拍板；② 「删了就不许这台再回来」需要中继侧的持久拉黑能力（登记时不再把 `revoked` 行翻回 `active`），本规格明确不做。
