# 手机 App 经 ai1net 覆盖网络操作 WorkBuddy · 架构定稿 **v3**(2026-09-29) > 🔴 **本件取代 v2**(`手机App经覆盖网络操作WorkBuddy-架构定稿v2-20260928.md` 降为过程档案)。 > 取代原因:v2 的 **§5.3 凭据注入**、**§5.2 端口判据** 已被实测推翻/不足;且新增两件 v2 完全没写的**会话侧硬约束**。 > 更正来源:`交付物/手机接入-目标与协同计划-20260929.md` §三(C1–C6)+ 本工作区棒 0b 取证。 --- ## 0 目标与验收(与协同计划 §一 一致,此处为唯一权威副本) **手机 App 通过 ai1net 覆盖网络(HTTPS 443)操作自己电脑上的 WorkBuddy 会话,能力做成 DSH 插件** (`@dsh-local/ai1net` 客户端半 + 平台半**同一个包**),并由账号核对保证「我的账号只能连我的客户端、访问我的 WorkBuddy」。 | # | 判据 | 怎么验 | |---|---|---| | V1 | 手机(真机、非 USB)能看到桌面会话列表与最新消息 | 设备入口 `https://<门户>/u//desk//…` 打开即见 | | V2 | 手机发出的消息**进入桌面会话**(任意同项目会话) | 目标会话 `replay` 出现 `user_message_chunk`,文本逐字一致 | | V3 | 桌面正在用的会话不被夺走 | 活会话走 `reply`;非活会话走 ACP 借用并归还(§3.4) | | V4 | `src/net/relay/**` 零协议改动 | 该目录无新增 op | | V5 | 权限面不扩大:**不开任何对外端口**、准入四道闸保持、默认关 | 端口清单 + `device_web_enabled` 默认值 | | V6 | 垫片是 **DSH 客户端插件**(并入 ai1net 包第 6 模块),⛔ 不是独立进程 | 包内模块清单 + `dsh.profile.bundles` | | **V7** | 🔴 **绝不影响 WorkBuddy 本身的运行**(2026-09-29 用户明令 · 见 §0.5) | 见 §0.5「验收判据」 | --- ## 0.5 🔴 硬约束:**绝不影响 WorkBuddy 本身的运行**(2026-09-29 用户明令 · 最高优先级) > 用户原话:「**做这些功能都不能影响 workbuddy 本身的运行,要禁止这种锁死,技术实现上要严格遵循这个规则**」 > ⇒ 本约束**优先于**本件其它一切设计选择;与其它条款冲突时,**按本约束裁剪**。 ### 0.5.1 总则(单向依赖 · fail-safe 方向) **垫片 / 桥 / 手机侧 对 WorkBuddy 只有"读动机"、没有"写动机"。** 任一环节**崩溃、被 kill、超时、卡住** ⇒ **必须表现为"这个功能不可用",⛔ 绝不允许表现为"WorkBuddy 异常"**。 ⇒ 一切降级方向 = **关掉自己**,不是"重试到成功"。 ### 0.5.2 七条技术条款(每条都可检验 · 实现时必须逐条落地) | # | 条款 | 为什么(本机实测教训) | 检验判据 | |---|---|---|---| | **T1** | **进程隔离**:⛔ 垫片/桥**不得**放进 **WorkBuddy 的"会话后台任务"**里跑;长跑一律走 **① 一次性定时自动化(跑完即退)** 或 **② 独立进程(输出不接回任何会话)** | 会话后台任务**每轮输出都会唤醒宿主会话 ⇒ 会话永不空闲 ⇒ 用户看到"卡死"**(09-28 已立禁令,09-29 我违反 6 次复现) | 起/停垫片 ≥10 次:WorkBuddy 主进程 **pid 恒定**、宿主会话**能回 idle**、UI 无卡顿 | | **T2** | **不夺会话**:⛔ 不得对**正在运行 / 正被 writer 占用**的会话做 `session/load` 或任何接管;`reply` **只在目标=当前活会话**时用;ACP 借用**先记原活会话**,**无论成败 `finally` 归还**;遇 `writer_occupied` ⇒ **放弃,不重试、不抢** | 实测:`session/load` 会**把桌面"当前对话"切走**;被占用的会话 `load` 报 `-32000 writer_occupied`;我借用后**没还回去**,把用户会话指针带走了 | 借用流程跑 100 次:**结束态 `liveSessionId` 与原值一致**;期间桌面未被夺 | | **T3** | **不占资源、不改它**:⛔ 不绑可路由地址、⛔ 只绑**一个**回环口(`20090`)、⛔ 不开对外端口;⛔ **不改** WorkBuddy 的 `settings.json` / 令牌 / 插件 / 配置;⛔ **不 kill/restart 它的进程** | 端口/配置一改,影响面超出本功能 | 操作前后对 WorkBuddy 配置目录做哈希比对:**零差异**;端口清单无新增对外口 | | **T4** | **限流友好**:⛔ **绝不反复试凭据**(网关失败限流 `2/min` + `12/h`,**只计失败** ⇒ 试错会把自己锁死一小时);⛔ **不轮询**(客户端走 SSE/事件);页面静默同步若保留 ⇒ **仅读**、间隔 ≥ 5 s、且**仅在可见时**;探活只用**不计失败额度**的路径 | 09-29 实测:`x-access-token` 未试出前连打多次 401 ≈ 逼近额度 | 连续 1 h / 50 次操作:**无 429**;无凭据失败计数增长 | | **T5** | **超时与熔断**:所有对 WorkBuddy 的调用带**短超时**(读 ≤ 5 s、写 ≤ 10 s、ACP 借用整体 ≤ 120 s);**连续 3 次失败 ⇒ 降级为只读并停止写**,⛔ **不自动重试写操作** | 无超时 = 把"等待"传导成"卡住" | 拔网/关网关演练:调用**在超时内返回**,功能自报不可用,WorkBuddy 无感 | | **T6** | **不挡宿主**:⛔ 不得长期持有项目执行锁(`handoff-guard` 全局/域锁);⛔ 不得让任何"每轮输出"回流到会话 | 长跑常驻任务的 stdout 会反复唤醒宿主会话 | 锁检查 `--check-exec`:**始终空闲**(持有仅限单次短操作内) | | **T7** | **可静默、可回滚**:任一环节**能被直接停掉且无残留**(⛔ 不留守护进程、⛔ 不留后台任务、⛔ 不留改过的 WorkBuddy 状态);停掉后 WorkBuddy 行为与**装之前逐项一致** | 出问题时"关得掉"比"修得好"更重要 | 直接 kill 垫片 ⇒ WorkBuddy 与手机侧均无异常、无残留进程/端口/文件 | ### 0.5.3 验收判据(V7 · 必过) **连续 1 小时 / ≥50 次会话操作期间,以下各项同时成立**: 1. WorkBuddy 主进程 **pid 恒定**(未重启、未崩溃); 2. 宿主会话**能正常回 idle**,且用户发消息的响应**无退化**; 3. 桌面**"当前对话"未被改变**(借用必须归还); 4. **无 429**、无凭据失败计数增长; 5. **无新增对外端口**;WorkBuddy 配置目录哈希零差异; 6. 演练一次"垫片直接被杀" ⇒ WorkBuddy 与手机侧均无异常。 ### 0.5.4 ⛔ 明确禁止清单(违一条即违规,各线自查) 1. ⛔ 给网关加 CORS 白名单 / 关闭网关鉴权(如 `gateway.auth=none`)/ 把口令写进文件或配置 2. ⛔ 把长跑服务放进**会话后台任务**(含"反正跑完就停"的自我安慰) 3. ⛔ 对**宿主正在跑的会话**做 `load` / 接管 / 抢占 4. ⛔ 抢 `writer_occupied` 的会话(应放弃并如实报错) 5. ⛔ 改 WorkBuddy 的配置 / 插件 / 令牌 / 进程(不 kill、不 restart) 6. ⛔ 轮询 + 高频读写;⛔ 反复试凭据 7. ⛔ 无超时地等待上游 --- --- ## 1 🔴 v2 → v3 更正表(逐条含依据;各线按本表为准,⛔ 别照 v2 抄) | # | v2 原文 | v3 更正 | 依据 | |---|---|---|---| | **C1** | §5.3「首跳用 `?password=` 换 Cookie,之后全程复用 Cookie」;§1.1 以「凭据只在进程内存」论证垫片必须进程内 | 🔴 **`?password=` 在受保护路径上永远失效**(`requireAuth` 构造请求对象时写死 `query:{}`)。**唯一稳定带法是 `x-access-token: <明文>`**。口令来源 = **`process.env.CODEBBUDDY_GATEWAY_PASSWORD`**(网关源码 `GatewayAuth.resolve`:env → settings → 都没有才随机生成并写回) | 源码 + 实测 200 | | **C2** | 同上 | **§5.3 整段作废**,改为「取口令 + `x-access-token`」。⚠️ 由此**§1.1 的"必须进程内"论据被削弱**:口令确实只在内存,但**凡 WorkBuddy 的后代进程都能从 env 继承** ⇒ 垫片只需**在 WorkBuddy 进程树内**,不必在 WorkBuddy 进程**内**。若 DSH Host 不在该树内 ⇒ 需 WorkBuddy 侧薄组件投递口令(**此点插件线须复核并给结论**) | 同上 | | **C3** | §5.1「Origin 归一:需要」 | 🔴 **不够 —— 必须"剥掉" `Origin`(连同 `Referer`)**。实测:无 `Origin` → 409(正常);带 `Origin` → **`403 {"error":"Origin not allowed","hint":"Add this origin to CODEBUDDY_CODE_CORS_ORIGINS …"}`**,**一切写操作全废**,而 GET 正常 ⇒ 表现为"能看不能发" | 实测对照 | | **C4** | §5.2 三判据(进程名 / `GET /` 含 title / `health` 401) | ⚠️ **三判据不足以选对实例**:本机实测**并存 3 个 `WorkBuddy.exe`**(父链 pid `3176/22592/24260`),各有网关端口,且**共用同一口令** ⇒ 鉴权判据也不能区分。**必须加判据④:父链**(沿本进程父链上溯,选自己那条进程树里的宿主所监听的口);同档位多候选再按"活会话=列表里最近活跃那条"择一 | 实测(选中 `57452`,其 `/live` 与"最近会话"均指向本会话) | | **C5** | (v2 无) | 🔴 **网关只读写"与自己同工作目录"的会话**:跨项目会话 `history`/`replay` 一律 **404 `SESSION_NOT_FOUND`**(直连网关亦然,加 `cwd` 参数无用)。实测同目录 5 条可达 / 跨项目 19 条不可达 ⇒ **手机端会话列表必须按"可操作"过滤**,工作目录取 `GET /api/v1/info` 的 `cwd` | 实测 | | **C6** | §5.1 只说"上游改指 gateway" | 🔴 **"活会话"=桌面当前打开的那一个**(`sessionManager.sessionSubject.value`),`POST /sessions/{id}/reply` **只认它**,其余 `409 SESSION_FOLLOW_NOT_LIVE`。**往任意会话发消息须走 ACP**:`session/load`+`session/prompt`;且 `session/load|new` **必须带 `cwd` + `mcpServers`**(缺则 `-32602`)。⚠️ **被 writer 占用的会话接管不了**:`-32000 "already has an external writer" {reason:"writer_occupied"}` | 源码 + 实测 | --- ## 2 目标架构(v3 · 仅列与 v2 有差异处,其余照 v2 §2/§3/§4) ### 2.1 垫片(M6 模块)职责(v3 版) 本机回环反代:占 `127.0.0.1:20090`、承接中继来连、**注入本机服务凭据**、转发到 **WorkBuddy gateway(动态口)**。四件事: | # | 做什么 | v3 要点 | |---|---|---| | 1 | **端口发现** | 四判据(v2 三条 + **父链**),找不到 **fail-closed** | | 2 | **凭据注入** | `x-access-token: <口令>`;口令优先取自 env,取不到则求 WorkBuddy 侧投递(⛔ 不落盘) | | 3 | **头改写** | `Host` → `127.0.0.1:`;🔴 **剥掉 `Origin` 与 `Referer`**(C3) | | 4 | **去 `webServer` 依赖** | 照 v2 §4.1 归并口径不变 | ### 2.2 🔴 会话侧契约(v2 完全没有 · 手机端按此实现) ``` ① 能操作哪些会话? 仅 workdir = GET /api/v1/info 的 cwd 的会话(其余 404) ← C5 ② 会话列表 GET /api/v1/sessions (只返回①这批) 跨项目浏览 GET /api/v1/sessions?cwd=* (含跨项目;⚠️ 它们不可操作,须标灰) ③ 读历史 GET /api/v1/sessions/{id}/history → {data:{name, requests:[{userInput, finalReply}], sessionId}} 完整事件 GET /api/v1/sessions/{id}/replay → {data:{events:[…]}}(实测 953+ 事件 / 2.6 MB,⚠️ 别整包拉) ④ 哪个是"当前会话" GET /api/v1/sessions/live → {sessionId, writerOccupied} ⚠️ sessionId 为 null = 桌面没打开任何会话;⛔ 列表里的 isCurrent **恒 false**,不能用 ⑤ 发消息 · 目标是 live 会话 ⇒ POST /api/v1/sessions/{id}/reply {"text":"…"} ← 官方路径,不夺 writer (缺 text ⇒ 400;非 live ⇒ 409) · 目标不是 live ⇒ ACP:connect → initialize → session/load{id,cwd,mcpServers:[]} → session/prompt{sessionId,prompt:[{type:"text",text}]} → (归还)session/load 回原 live ⑥ ACP 细节 必带 `Accept: application/json, text/event-stream`(否则 406); 响应走 **POST 回体的 SSE 流**(⛔ 不必另开长连);`DELETE /api/v1/acp` 断开 ⑦ 回环豁免 `/api/v1/acp` 一族从 127.0.0.1 来**免凭据**(源码 `AcpSecurity.checkAuth`/`checkSessionToken` 均 `isLoopback → true`) ``` --- ## 3 棒次与归属(与协同计划 §四 一致) | 棒 | 归属线 | 做什么 | 前置 | |---|---|---|---| | 棒 0 / 0b | ✅ 本工作区 | 端口发现 + 取证件 | — | | **棒 0c** | ✅ 本工作区(**本件**) | 6 条更正回写 → 架构 v3 + 同步工单 | 棒 0b ✅ | | **🅐G-A** | `ai1net-dsh-desktop` | 解 `desktop` profile 缺口 + 给挂载点结论 | — | | **棒 1** | `ai1net_ui` | 垫片归并入 `@dsh-local/ai1net` 第 6 模块 + 按 §2.1 改造 | 🅐 可并行 | | **棒 2** | `ai1net_ui`(同包平台半) | 装同一包 + 开关登记(准入零改动) | 棒 1 | | 棒 3 | ✅ `ai1net-dsh-anywhere` | Android MVP | 已完 | | **棒 4** | `ai1net-dsh-anywhere` + 三方 | 对接单同步 + 端侧按 §2.2 调整 + 真机端到端 | 棒 1/2、棒 3 | --- ## 4 红线(v2 §8 + 本轮新增) 1. ⛔ **不给网关加 CORS 白名单、不开任何对外端口** —— C3 的正解是**剥掉 Origin**,不是放宽网关 2. ⛔ **不动 `src/net/relay/**`**(V4) 3. ⛔ **不改 DSH 那条已验通通道**(两条通道并存,端口/设备行分开) 4. ⛔ **口令不落盘、不进平台库、不进日志**(现靠 env 自动取,最干净 —— 别退回"写文件") 5. ⛔ **动 `E:/github/dsh-client` 前先入库**(该仓 0 commit) 6. ⛔ **子进程杀不掉上游就 fail-closed**;⛔ 端口发现失败不许沿用上次值 --- ## 6 🔴 G-C 处置(插件线 09-29 提出 · 本文定案) **问题(G-C)**:口令只在 **WorkBuddy 进程树内**可读(`process.env.CODEBBUDDY_GATEWAY_PASSWORD` 由其主进程下发、子进程继承)。 而垫片跑在 **DSH Host** 进程里 ⇒ **若 DSH Host 不在 WorkBuddy 进程树内,就读不到口令**。 **为什么不能靠"改 WorkBuddy"或"口令落盘"绕过**: - 写 `settings.json`(如加 hook)去投递口令 ⇒ 违反 **T3「⛔ 不改 WorkBuddy 的配置」**; - 把口令写进文件/环境变量配置文件 ⇒ 违反 **红线 4「口令不落盘」+ T3**。 ⇒ 两条"省事的路"都被本件红线堵死,**必须从"启动方式"上解**。 **定案(按优先级)**: | 序 | 做法 | 判据 | 是否需改 WorkBuddy | |---|---|---|---| | **①(默认)** | **DSH 客户端从 WorkBuddy 内启动**(在 WorkBuddy 内跑一条脚本/自动化把客户端拉起来)⇒ DSH Host 自然成为 WorkBuddy 后代,**env 直接可读** | 垫片自检:`DSH Host 父链含 WorkBuddy` ⇒ 口令可得 | ⛔ 不改(只是"谁拉起它"的差别) | | ② | 若 V1 要求"客户端由系统自启/资源管理器启动" ⇒ 垫片**读不到口令**时必须 **fail-closed**:**不启监听 + 返回 `503 gateway-token-unavailable` + 具名日志** | 负控:非后代启动 ⇒ 503、⛔ 不重试、⛔ 不退化成明文假定 | ⛔ 不改 | | ⛔ | ~~WorkBuddy 侧薄投递器~~(v2 §10.3 候选 1) | —— | **作废**:需给 WorkBuddy 加组件/配置 ⇒ 违 T3 | **垫片自检必须暴露的三项**(供 V7 验收,⛔ 不回显口令本身):`口令来源名`(env)/`头名`(`x-access-token`)/`是否在场`。 --- ## 7 ⚠️ 关于 `tmp/wb-phone/` 的定位(防后人误读) **它是「本机旁路原型 + 能力取证」,⛔ 不是产品形态,也不在本架构的实施路径上。** | 维度 | 本架构(上表) | 旁路原型实际做法 | 判定 | |---|---|---|---| | 传输 | ai1net 设备入口(443) | USB `adb reverse` + 手机浏览器 `localhost:18899` | ❌ 不符 | | 承载 | DSH Host 插件(M6) | 独立 Python 进程 `bridge.py` | ❌ 不符 | | 手机侧 | Android App | 手机浏览器页面(APK 未装) | ⚠️ 原型 | 它的价值是**把 §1 的 6 条更正经真机验出来**(含"手机→桌面会话"的端到端 200)。产品实施仍按本件 §2 走。 ### 5.1 遗留副作用(需知情) - 联调期间把**活会话指针**切走过(`38499a2e` → `b419f820`);用户会话 `3a46cebb` 因 writer 被占**load 不回去** ⇒ **在桌面上点一下要用的会话即可归位**。 - 两个旧会话各多 1 条联调消息(`38499a2e`、`b419f820`)。 - ⛔ **不要再把桥当"会话后台任务"起**(见 §5.2)。 ### 5.2 🔴 一条已被写进项目工具的禁令(本轮违反 6 次,代价=用户感到"卡") 把长跑服务放进**会话的后台任务** ⇒ **它每轮输出都会唤醒宿主会话** ⇒ 会话**永不空闲** ⇒ 用户看到"卡住 / 发消息不恢复"。 ✅ 正确用法(二选一):① **单轮 + 定时自动化**叫(`--once`,跑完即退);② **独立进程**(独立窗口/计划任务,输出不接回任何会话)。 📂 依据:`tmp/supervise-inbox/watch.log` 2026-09-28 23:17:54 的拒绝启动判决。