Files

702 lines
51 KiB
Markdown
Raw Permalink Normal View History

# 功能打包到基础插件 · 分步实施方案(含逐棒验证与避坑)
- 日期:2026-09-26
- 上游判定:`交付物/全功能承载判定-底层插件能否承-20260926.md` §3.12(五块能进一个包;**六件只剩三件**)+ `交付物/AI1NET-三类节点结构调整可行性-20260926.md`(**承载两分**)
- 客户端侧依据:工作区 `E:/ProgramData/AIProject/dsh-ai1net-desktop`(**只读引用**,⛔ 不改其文件)
- 编排方式:技能 `dsh-auto-handoff-chain`(规划棒 ↔ 执行棒交替 · 一棒一件事 · 收尾四件套)
- 状态:**规划件** —— 本文不出手改码、不部署;执行由后续棒次照单落地
---
## §0 定位与四条硬边界
**这件事是什么**:把现在散在两处的功能(`src/supervisor` / `web/routes` / `db` / `net/relay` / `im` + 实例侧)**收敛成一个可分发的基础插件包**,让它成为「用户装官方 DSH + 一个包 = 全套能力」的那个**包**。
**四条硬边界(⛔ 本方案全程遵守)**:
| # | 边界 | 为什么 |
|---|---|---|
| **1** | ⛔ **不重造分发与更新机制** —— 平台侧共享包库 + 版本维度 + 三只只读口**已上机**;客户端侧拉取器已有对接单(`docs/对接单_桌面端接入版本管理与包更新机制_20260926.md`) | 重造 = 两套指纹两套语义,必定打架(见 §4-C1) |
| **2** | ⛔ **不改官方 dsh 主程序与缓存**(R2)—— 扩展只走官方插件/注入点 | 官方明写 developer preview「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」⇒ 任何 fork 都会在下次升级时全量返工 |
| **3** | ⛔ **不碰「包外面那三件」**(起实例 / 取身份 / 把包装进去) | 判定件 §3.12.5:那三件归**部署方案**与**引导层**,不在本方案的范围内 |
| **4** | 🔴 **执行前必须先拿到用户确认**(2026-09-26 13:19 用户明令:「现在只做方案 落地详细步骤和方案做好了 确认后在执行」) | 方案阶段**只出方案件与交接单**;⛔ 任何棒次**落码 / 部署**前必须先交用户确认,⛔ 不许"自动接续"把执行棒带跑(见 §6.0) |
---
## §1 开工前必须先纠正的三条前提
> ⚠️ 这三条我上一轮(只读了平台仓库时)判断有误或不全,**先纠正,否则方案会建在错前提上**。
### 1.1 ✅ 官方桌面壳**存在**,且插件机制是同一套
| 我上一轮的说法 | 实际 |
|---|---|
| 「本仓库无 `apps/`、无桌面壳」 | ⚠️ **只在平台仓库里成立** —— 官方壳在**上游**:`deepseek-ai/deepseek-harness` → **`apps/desktop`(0.1.6-alpha.1)+ `apps/desktop-host`**,`private: true`、未发 npm ⇒ **按官方源码直接构建,不 fork、零 patch** |
**实测结论**(客户端线 `docs/S6_官方二次开发能力与桌面端扩展点实测_20260921.md`):
> **官方明确支持二次开发,且是"一等公民"级设计**;载体 = **插件(组合包 bundle)+ patch + profile**;**桌面端不是另一套,用的是同一套机制**。
> 官方文档原文:「**独立插件窗口**获得结构化的**列出、安装、移除、更新和更新检查**操作」;「GUI 插件修改…使用相同的 staging、健康检查、激活与 rollback 路径」。
⛔ 我上一轮「桌面/服务器两条壳要分别实现」的说法**要修正为**:**壳是两条,插件机制是一条**。
### 1.2 ⚠️ 桌面端**能跑**平台,但**没有多租户隔离** —— 不是"跑不了"
| 我上一轮的说法 | 实际(客户端线 `CODEBUDDY.md §9` 原文) |
|---|---|
| 「`setpriv`/`systemd-run` 是 Linux 专有 ⇒ Windows 上这些代码路径不执行」 | ⚠️ **那条链是 `account` 隔离档的依赖;客户端线走的是 `soft` 档** ⇒ `setpriv`/`systemd-run` 根本不在这条路上 |
| 「桌面机不能当承载」 | ✅ **结论对,但理由要换** ⇒ 见下 |
🔴 **实测原文两条**:
1. 「**平台启动实例的方式在 Windows 上不成立**:裸名 `spawn('npm')` → `ENOENT`(全局包是 `.cmd` 垫片);显式 `.cmd` → `EINVAL`;**必须走 `cmd.exe /c` 或 `shell: true`**(`orchestrator.ts:663-665` 现为不带 shell)」
⇒ 🔴 **这是全案唯一必须改平台代码的地方**,也是客户端线的 **M0 验收点**。
2. 「**多人形态开关是零代码的(只配域名)**,但**隔离档仍是 `soft` ⇒ 用户之间无隔离**」
⇒ 🔴 **准确说法**:**Windows 上能跑、能起实例;但 `soft` 档下用户之间没有隔离** ⇒ 所以**桌面机只能当"主权承载"(自己的租户),不能当"公共承载"(别人的租户)** —— 与 `AI1NET-三类节点结构调整可行性` 的判定**同向,但现在有了实证理由**。
### 1.3 ⭐ 桌面端**没有 `webServer`** —— 这是"一个包两端跑"的真正门槛
> 🔴 **2026-09-28 加注(M6 归并的硬前提)**:本节不只是"一个包两端跑"的门槛 —— 它同样是 **M6「设备接入」归并的硬前提**。
> 依据(实测):垫片现状是 `inject = ['connection', 'webServer']`(靠 `webServer.port` 拿本机口,`dsh-client/packages/device-shim/lib/index.js:61`);而 ai1net 包要求 `inject = ['connection']`(`dsh-plugin-ai1net/lib/index.js:102`),且**包内门禁 ①c 逐字断言这一行**、并**逐行**断言代码里零 `webServer` 引用(`dsh-plugin-ai1net/scripts/build.mjs:123` 附近)。
> ⇒ 归并**必须先去掉 `webServer` 依赖**:端口改**包内配置常量** + **动态发现**(与 §5.2 的上游改造是同一处改动,正好合并)。⛔ 不改 ⇒ 门禁红 + 桌面端逐字复现本节的 `pending` 现象。
**实测**(`S6 §3`,出处 `apps/desktop-host/src/index.ts:326-343` + `dsh-client-connection/lib/index.js:547-601`):
| 扩展点 | Web 组合 | **桌面组合(实测)** |
|---|---|---|
| `webServer` 具名路由 / fallback | ✅ 有(激活即 listen) | ❌ **服务根本不存在**(壳内 host 走 fd 管道、不 listen) |
| `/api/*` 服务端逻辑 | 经 webServer | ✅ **`ctx.connection.fetch.register({path, methods, fetch})`** |
| ⚠️ 路径匹配 | 支持 `exact` / `prefix` | 🔴 **只支持精确路径,⛔ 无前缀匹配** |
| 页面级路由(新增一张页) | ✅ `register({kind:'exact'\|'prefix'})` | ❌ **无页面路由**(`/api` → connection,其余全归 SPA dist) |
| 浏览器端插件(client bundle) | ✅ `dsh.client` + `/plugins/*` | ✅ **同一套** |
| index 注入 | `webserver/index-inject` | ✅ 同一事件(`desktop-host/src/index.ts:191`) |
⇒ 🔴 **所以"一个包"不是"写一遍就行",而是"必须只用两端都存在的扩展点子集"**:
```
可用交集 = { connection.fetch.register(精确路径) · dsh.client(浏览器端) · index 注入 }
⛔ 禁用 = { webServer.register · 前缀路由 · 页面级路由 }
```
---
## §2 目标形态
```
┌──────────────── 一个发行包(组合包 bundle)────────────────┐
│ 模块① 多租户界面与请求 模块② 模型管理 │
│ 模块③ 技能插件管理 模块④ 覆盖网络界面 │
│ 模块⑤ IM 插件侧 (+ client bundle:五个卡片) │
└───────────────────────────┬───────────────────────────────┘
同一份代码,两处加载(同一个 pluginId、同一个库)
┌───────────────────────────┴───────────────────────────────┐
▼ ▼
【实例内 / 桌面壳内】 【平台进程】
· 全部业务逻辑 · 只接受"请求"
· 依赖 = 扩展点交集 · 要写平台库的动作在这里
· 桌面端 = 官方壳 + 本包;服务器端 = 官方 dsh + 本包 · ⚠️ 平台不是"实例"⇒ 装不了插件
(判定件 §3.12.3)
```
**一个包、一个 pluginId、一个库**(`dshs_pl_<pluginId>`),库内表按模块**分段命名**。
---
## §3 分步实施方案
> 每步格式:**目标 / 前置 / 步骤 / 🔴 验证(可复现)/ ⚠️ 坑 / 回滚 / 完成判据**。
> 🔴 **验证一律要"能变红"** —— 不通过"负控"(故意改坏一处必须报红)的判据,不算判据。
### S0 · 立验证地基(**先立尺,再动手**)
**目标**:让 S1 之后的每一步都有**一键可复现**的判据。⛔ 这一步不做,后面所有"验证"都是自说自话。
**前置**:无。**不需要拍板、不需要改任何代码。**
**步骤**:
1. **取基线**(两端各一条命令,结果落文件):
- 平台侧:共享层包数与指纹(现读数为 **4 个包**,`dsh-plugin-mcn-suite` = `0.5.1` / `27f6c6d7…` / 659 文件 / 2,018,751 B)
- 客户端侧:本机 profile 的 bundle 清单(现为 **4 个 bundle、无 `@dsh-local/*`**)
2. **建「插件四判据」冒烟脚本**(用**已有插件**当靶子,⛔ 不用新包):装配 ✓ / 运行 ✓ / 取数 ✓ / 回调 ✓
3. **建「浏览器级判据」脚本**(客户端 UI 必须有这一关)
4. **做负控**:故意把靶子改坏一处 ⇒ **脚本必须报红**;改回 ⇒ 必须报绿
**🔴 验证**:
- 同一脚本**连跑两次**,结果一致(排除偶发)
- **负控通过**:改坏 ⇒ 红|改回 ⇒ 绿(这一条是 S0 的**唯一**完成判据)
**⚠️ 坑**:
- ⚠️ **`dump-config` 验不了运行时挂载**(官方 "without booting" ⇒ 不跑 `apply()`)⇒ 证据**必须取活体 boot**(`$B/home-adapt/logs/startup-*.log` 有 fiber 级 outcome)
- ⚠️ **启动日志只记失败条目**(插件 `logger.info` 不在其中)⇒ 运行时挂载证据只能取 **RPC** 或 boot 失败面
- 🔴 **`node --check` +「client bundle 可组合」+「boot 本包零报错」三条全绿也证明不了客户端能加载** ⇒ **UI 交付必须有浏览器级判据**
**回滚**:删脚本(无副作用)。
**完成判据**:负控红/绿各一次,且连跑两次一致。
---
### S1 · 定「包规格书」(**不动一行代码**)
**目标**:产出一份冷读者能照做的**包规格书**。这是后面所有棒次的共同输入。
**前置**:S0 完成。
**步骤**:
1. **列模块**:五个模块的名称、职责、边界(哪一块的哪一半在平台侧)
2. **逐条列扩展点**,每条必须标注**两端是否都有**:
- ⛔ 出现任何 `webServer` / 前缀路由 / 页面级路由 ⇒ **当场改成 `connection.fetch.register` + 精确路径**
3. **列全部 API 精确路径**(⛔ 不能靠前缀)—— 桌面端**只认精确路径**
4. **定包名与库名**:包名 ⇒ 库名 `dshs_pl_<pluginId>`,**长度必须 ≤ 48**、`[a-z][a-z0-9_]{0,40}`
5. **定表名分段方案**(库内表加 `p_<pluginId>_` 前缀,模块靠后半段区分)
6. **定迁移入口**:只留**一个**入口、按模块分步执行(治"一个包一个库 ⇒ 一次迁移牵动全部")
**🔴 验证**:
- **冷读者测试**:把规格书给一个不参与本方案的会话读,问它「能不能照做」—— 不能 ⇒ 补
- **逐条扩展点对表**:与 §1.3 的表逐条比对,**⛔ 零条落在"桌面不存在"那一列**
- **库名长度硬校验**:用正则跑一遍(⛔ 别目测)
**⚠️ 坑**:见 §4-B1 / §4-D2。**回滚**:无(纯文档)。
**完成判据**:规格书里**没有一条扩展点只在 Web 端存在**。
---
### S2 · 空包两端落地(**最小可运行骨架**)
**目标**:一个**最小包**(一个 client 卡片 + 一条 host 路由),**两端都能装、都能出界面**。
**前置**:S1 规格书完成。
**步骤**:
1. 按官方三步做包:`apply(ctx)` 模块 → `package.json` 写 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` → `dsh plugin add`
2. host 半:`ctx.connection.fetch.register({path:'/api/<name>/ping', methods:['GET'], fetch})`,**`inject = ['connection']` 必写**
3. client 半:`dsh.client` + **lazy-CJS 封包**(⛔ 零顶层 `import`/`export`/`require`)
4. 两端各起一次,看界面
**🔴 验证(四层,缺一不算过)**:
| 层 | 怎么验 | 过了说明什么 |
|---|---|---|
| 语法 | `node --check` | ⚠️ **只说明语法** |
| 装进 profile | 本机 profile 里出现该包 | 装配通 |
| **活体 boot** | `startup-*.log` 有 fiber 级 outcome | 挂载通 |
| **浏览器级** | 真壳里出现卡片、**JS 错误 0**、无 `Failed to load plugins` | ✅ **唯一能证明 UI 真的能用** |
**⚠️ 坑**:§4-A1(lazy-CJS)· §4-A2(`inject` 必须声明)· §4-A3(`maxDepth` 不可省)· §4-A4(静态注入是坏的)· §4-E2(改插件后必重启壳)。
**回滚**:从 profile 移除该包(⛔ 目录用 `mv`,不删)。
**完成判据**:**两端**都出现卡片;浏览器控制台 JS 错误 = 0。
---
### S3 · 数据面(一个包一个库)
**目标**:包能建自己的库、跑迁移、被对账。
**前置**:S2 通过。
**步骤**:
1. 声明数据面 ⇒ **平台侧建库** `dshs_pl_<pluginId>`(⛔ 顺序:**先建库再投放**)
2. 迁移:模块分段、单入口分步执行
3. 对账:`pg_database` 的 `dshs_pl_*` × 控制面台账
4. **改声明后回读 yaml 实际值**(⛔ 不回读 = 会漏落)
**🔴 验证**:
- 建库 → 迁移 → 对账**三轮全绿**;库名匹配 `^dshs_pl_[a-z][a-z0-9_]{0,40}$`
- **负控**:改一处声明**不回读** ⇒ 必须能查出 yaml 与声明不一致(证明"回读"这一步真的在起作用)
**⚠️ 坑**:§4-D1 · §4-D2 · §4-D3 · §4-D4。
**回滚**:库用 `mv` 式子改名冻结,⛔ 不 `DROP`。
**完成判据**:三个模块的表都在同一个库里且**能单独回滚**。
---
### S4 · 接进既有的分发与更新机制(⛔ **一行都别重造**)
**目标**:包能被平台**对账 + 增量拉取 + 原子换上**,两端指纹逐字一致。
**前置**:S3 通过。⚠️ **本步依赖一个待拍板项**(§6-P1:平台侧只读三口增认「设备身份」)—— 未拍板时**只做能做的**(手工主机密钥干跑 happy path)。
**步骤**:
1. **照抄权威实现**:`src/worker/agent.ts` 的 `syncSharedLayerOnce()` + `pullOnePack()`(纯 Node、无框架依赖)
2. 实现四步:对账 → 取包 → 落地(原子换六动作)→ 回报
3. 接进 profile 装配:依赖写 **`"<包名>": "link:<目录绝对路径>"`**
4. **补 `@deepseek-ai/*` 软链**(共享层里**零个**这些包,它们是宿主提供的 optional peer)
5. 镜像目录绝对路径**与平台侧逐字同形**
**🔴 验证**:
- **指纹逐字相同**(⛔ 不是"大概一致");对账 `diff` 三个集合全空
- **三条反例判据(⛔ 不验不算完成)**:
1. **清单拿不到**(断网/黑洞)⇒ 本地文件**零改动**(前后指纹一致)
2. **单个包失败** ⇒ 其余包**照常更新**(不是整轮放弃)
3. **`extra`**(本地多出来的包)⇒ 平台只告知,**本地一个文件没被动过**
- **装配后**:本机 profile 出现该包 ⇒ 实例页「能力管理(插件/技能)」栏**开始出现**
**⚠️ 坑**:§4-C1 – §4-C5(**指纹算法不一致 ⇒ 永远 stale、每轮全量重拉**;对账与下载共用超时;`link:` 目标两机同路径;软链缺失表现为"包在但加载不了")。
**回滚**:**停掉拉取器即回滚**(拉取失败本就不动本地文件 ⇒ 无数据风险)。
**完成判据**:三条反例判据全过 + 指纹逐字相同。
---
### S5 · 第一块迁移:**IM 插件侧**(选它是因为它有现成模板)
**目标**:把 IM 的插件侧并入基础包。
**前置**:S4 通过。
**为什么第一个做它**:判定件 §3.12.3 / §3.12.6 —— **IM 是今天唯一「装配 / 运行 / 取数 / 回调」四面齐全的** ⇒ 它**就是模板**,其余四块照它做。
**步骤**:
1. 照 IM 的接入方式把插件侧搬进包(⛔ **是重写,不是 `git mv`**)
2. 补上残留两条:① 校验"插件是否真在该实例启用" ② 一条实例只该有**一条**常驻长轮询
**🔴 验证**:
- IM 四条面(装配/运行/取数/回调)在**新包**里逐条复现
- **回归**:迁移前后,同一房间的消息收发行为一致(⛔ 用同一组输入比对)
- **负控**:故意让长轮询起两条 ⇒ 必须能检出
**⚠️ 坑**:§4-B2 · §4-C1。
**回滚**:切回旧实现(两套并存一段时间,⛔ 不删旧的)。
**完成判据**:四条面齐全 + 行为回归一致 + 残留两条已补。
---
### S6 · 第二块:**技能插件管理**(含"更新-告知 / 自动")
**目标**:上传 → 检测 → 建库 → 启用 → 更新,全链进包。
**前置**:S5 通过。
**步骤**:
1. 管理面(上传/检测/建库/启用)进包
2. 「更新」四拆照判定件 §2.1:① 看见新版本 ✅ 包 ② 点更新按钮 ✅ 包(**只到"请求"**)③ 把新包搬进来 → **走 S4 的拉取器**(⛔ 不在包里自己搬)④ 「设置自动处理」→ **执行者必须在包外**
3. package.json 指向 yaml;自增 id ⛔ 不能照搬;UI ≤900px 必须独占
**🔴 验证**:
- 全链 dry-run 一遍
- **"看见新版本"的判据**:包只报"有差异",⛔ **不自己下结论**(对齐既有纪律:**「不知道」不是「已知」**)
- 「自动处理」开关在包外时,**关掉执行者 ⇒ 行为必须停**(证明执行者确实在包外)
**⚠️ 坑**:§4-D1 · §4-E1。
**完成判据**:更新链的**每一步**都能指出"谁在做",且执行者都在包外。
---
### S7 · 第三块:**模型管理**
**目标**:配置 / 凭据 / 共享授权 / 界面进包。
**前置**:S6 通过。
**🔴 验证**:
- 共享模型走**管理员逐用户授权**(`users.shared_model_granted`,v11 默认 0)—— 负控:未授权用户必须看不见
- ⚠️ **用户卷在 worker 上时,写 home 必须走 `UserFs`**;负控:直写 `fs` ⇒ 必须能检出"静默空操作"
**⚠️ 坑**:§4-D4(`homedir()` 必错)· §4-F3。
**完成判据**:模型配置在**实例内**可见、**平台级凭据⛔不下发到客户端**(R5 客户端专属红线)。
---
### S8 · 第四块:**覆盖网络界面**(⚠️ 身份与隧道留在包外)
**目标**:看哪些区 / 选加入 / 跨区可见性 / 界面进包。**入网凭据与隧道 ⛔ 不进**。
**前置**:S7 通过。⚠️ **并且 F1.5(G1 序号 + G6 撤根)必须先有结论** —— 定稿原文「**F1.5 是一切的前置**」。
**🔴 验证**:
- 界面能显示"有哪些区可加入" —— ⚠️ **⛔ 不可做成公开列表**(拓扑信息即扩大暴露面),改法=**凭邀请码**;负控:无邀请码时必须**看不到任何区**
- 跨区可见性跟随「用户 × 区」成员关系(已拍板);负控:无成员关系 ⇒ 不可见
**⚠️ 坑**:§4-F4(`egressCidrs` 语义)· §4-G1。
**完成判据**:包内**零**身份/隧道逻辑。
---
### S9 · 第五块:**多租户**(包里只做界面 + 请求)
**目标**:租户列表 / 配额 / 成员 / 账单界面进包;**"创建隔离实例"这个动作落平台进程**。
**前置**:S8 通过。⚠️ **依赖 §6-P2(平台侧那一半怎么落)拍板**。
**🔴 验证**:
- **界面 + 请求**在包内;**动作**在平台 —— 停掉平台侧 ⇒ 界面必须**明确报"做不到"**,⛔ 不许假装成功
- 配额相关:⚠️ 配额 384 MiB / V8 堆**按宿主算** —— 负控:改宿主规格,租户侧看到的数必须跟着变
**⚠️ 坑**:§4-B3(平台不是"实例"⇒ 装不了插件)。
**完成判据**:包里**没有**任何直接写平台库的代码路径。
---
## §3.10 落地详细步骤(**S0–S9 逐步照做清单**)
> §3 说的是「每步要达成**什么**」,本节说的是「**手怎么动**」。
> 🔴 本节仍是**方案**,⛔ 不是执行授权 —— 全部动作**须用户确认后才可开工**(见 §6.0)。
### 3.10.1 先把「尺子」定死(S0 的产出规格)
后面九步的判据**一律不许临时目测**,只许调 S0 建出来的四把尺子:
| 尺子 | 名字 | 用法 | 绿 / 红怎么算 |
|---|---|---|---|
| 基线 | `v-baseline.sh` | 取两端基线(共享层包数+指纹 / 本机 profile bundle 清单),落文件 | rc=0,且**连跑两次逐字一致** |
| 插件四判据 | `v-plugin.sh <装配\|运行\|取数\|回调>` | 拿**已有插件**当靶子,逐面判 | rc=0 绿 / rc≠0 红 |
| 浏览器级 | `v-ui.sh` | 真壳起一次:出卡片 + JS 错误 0 + 无 `Failed to load plugins` | rc=0 绿 / rc≠0 红 |
| 负控驱动 | `v-neg.sh <case>` | 改坏靶子一处 ⇒ 断言 `v-plugin.sh` **必须红** ⇒ 改回 ⇒ 断言**必须绿** | 两个断言都成立才 rc=0 |
🔴 **三条约定先立,否则后面全是扯皮**:
1. **退出码即结论**:绿 = `rc 0`、红 = `rc≠0`。⛔ 不许拿"脚本打印了一行 OK"当绿。
2. **只许测活体**:「不 boot 就出结果」的路子(`dump-config` 类)**测不出运行时挂载** ⇒ 证据必须来自活体 boot 日志的 fiber outcome 或一次真实请求。
3. **落点** = `交付物/验证脚本/`(与方案件同处,已在入库范围);⛔ **不放进文档库 `07-scripts/`**(那里绑宿主 hook,动它影响全平台)。
### 3.10.2 S0 · 立验证地基
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S0-1 | 取平台侧基线(包数 + 每包指纹) | 平台侧(**只读**) | `交付物/验证脚本/baseline-平台-<日期>.txt` | 现值应为 **4 包** |
| S0-2 | 取客户端侧基线(profile bundle 清单) | 本机 | `baseline-客户端-<日期>.txt` | 现值应为 **4 bundle**、**无 `@dsh-local/*`** |
| S0-3 | 写 `v-plugin.sh`(四面判据) | 本机 | 脚本 | 拿**已有插件**跑 ⇒ 四面全绿 |
| S0-4 | 写 `v-ui.sh`(浏览器级) | 本机 | 脚本 | 拿已有插件跑 ⇒ 卡片出现、JS 错误 0 |
| S0-5 | 写 `v-neg.sh` 并跑负控 | 本机 | 脚本 + 负控记录 | **改坏 ⇒ 红、改回 ⇒ 绿**(各一次) |
| S0-6 | 连跑两次对比 | 本机 | 对比记录 | 两次输出**逐字一致** |
**失败怎么办**:若 S0-3/S0-4 拿**已有插件**都跑不绿 ⇒ 🔴 **立刻停**,说明基线本身有问题,先修尺子再谈打包(⛔ 不许"先往下走、回头再修")。
### 3.10.3 S1 · 定包规格书
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S1-1 | 列五个模块的名称/职责/边界(标明哪一块的哪一半在平台侧) | 文档 | 规格书 §1 | 冷读者能说出"这块归谁" |
| S1-2 | 逐条列扩展点,**每条标"两端是否都有"** | 文档 | 规格书 §2 | 与 §1.3 表**逐条**比对 |
| S1-3 | 列全部 API **精确路径** | 文档 | 规格书 §3 | ⛔ 零条前缀 |
| S1-4 | 定包名 → 库名,**跑正则** | 文档 | 规格书 §4 | 匹配 `^dshs_pl_[a-z][a-z0-9_]{0,40}$` 且**总长 ≤48** |
| S1-5 | 定表名分段(`p_<pluginId>_` + 模块后半段) | 文档 | 规格书 §5 | 同库内任意两表不重名 |
| S1-6 | 定**唯一**迁移入口(按模块分步) | 文档 | 规格书 §6 | 能指出"只回滚某模块"走哪一步 |
**失败怎么办**:一旦出现 `webServer` / 前缀路由 / 页面级路由 ⇒ **当场改掉**,⛔ 不许"先留着后面再说"。
### 3.10.4 S2 · 空包两端落地
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S2-1 | 按官方三步建最小包(`apply(ctx)` → `package.json` 写 bundle patch → `dsh plugin add`) | 本机仓库 | 包骨架 | `v-plugin.sh 装配` 绿 |
| S2-2 | host 半:注册**一条**精确路径路由,**`inject` 必写** | 本机仓库 | 路由代码 | `v-plugin.sh 取数` 绿 |
| S2-3 | client 半:`dsh.client` + **lazy-CJS 封包**(⛔ 零顶层 `import`/`export`/`require`) | 本机仓库 | 卡片代码 | 封包契约门禁过 |
| S2-4 | **两端各起一次** | 平台侧 + 本机壳 | 截图 / 日志 | `v-ui.sh` 绿(**两端各一次**) |
**失败怎么办**:卡片不出现 ↦ §4-A1|路由静默 404 ↦ §4-A2(`inject` 没声明)|启动报错 ↦ §4-A3/A4。
### 3.10.5 S3 · 数据面
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S3-1 | 声明数据面 | 本机仓库 | 声明文件 | **回读 yaml 实际值**并与声明一致 |
| S3-2 | **先建库** `dshs_pl_<pluginId>`(⛔ 不许先投放) | 平台侧 | 库 | 库名匹配正则、总长 ≤48 |
| S3-3 | 迁移:**单入口**、按模块分步 | 平台侧 | 表 | 三模块的表都在同一库 |
| S3-4 | 对账:`pg_database` 的 `dshs_pl_*` × 控制面台账 | 平台侧 | 对账输出 | 两集合一致 |
| S3-5 | **负控**:故意改一处声明且**不回读** | 本机 | 负控记录 | 必须能查出"yaml 与声明不一致" |
**失败怎么办**:库没建上 ↦ §4-D5(顺序反了)|声明改了没落 ↦ §4-D3(没回读)。
### 3.10.6 S4 · 接进既有分发机制
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S4-1 | **照抄**权威实现(`syncSharedLayerOnce()` / `pullOnePack()`,纯 Node) | 本机仓库 | 拉取器 | 与权威实现**逐段对得上** |
| S4-2 | 实现四步:对账 → 取包 → 落地(**原子换六动作**)→ 回报 | 本机仓库 | 拉取器 | happy path 跑通 |
| S4-3 | 依赖写 `link:<目录绝对路径>` | profile | profile 依赖 | 两端路径**逐字同形** |
| S4-4 | 补 `@deepseek-ai/*` 软链 | 客户端侧 | 软链 | 缺软链的现象="包在但加载不了" |
| S4-5 | **三条反例判据**:清单拿不到 / 单包失败 / `extra` | 本机 | 三条记录 | ⛔ **不验不算完成** |
| S4-6 | 装进 profile 后看实例页能力栏 | 两端 | 截图 | 能力栏**开始出现** |
**失败怎么办**:每轮全量重拉 ↦ §4-C1(指纹算法不一致)|超时怪 ↦ §4-C2(对账 20 s 与下载 300 s **必须分开算**)|"不知道"被当成"一致" ↦ §4-C5。
⚠️ **本步依赖待拍板项 P1** —— 未拍板时只做能做的(用主机密钥干跑 happy path)。
### 3.10.7 S5 · 第一块迁移(IM 插件侧 · **模板块**)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S5-1 | 照 IM 接入方式把插件侧**重写**进包(⛔ 不是 `git mv`) | 本机仓库 | 模块⑤ | 四条面(装配/运行/取数/回调)逐条复现 |
| S5-2 | 补残留①:校验"插件是否**真在该实例启用**" | 本机仓库 | 代码 | 未启用 ⇒ 必须拒 |
| S5-3 | 补残留②:一个实例只允许**一条**常驻长轮询 | 本机仓库 | 代码 | **负控**:故意起两条 ⇒ 必须检出 |
| S5-4 | **回归**:迁移前后,同一组输入的消息收发行为一致 | 两端 | 比对记录 | 同输入 ⇒ 同行为 |
**失败怎么办**:旧实现**不删**,两套并存一段时间(回滚 = 切回旧的)。
### 3.10.8 S6 · 第二块(技能插件管理)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S6-1 | 上传/检测/建库/启用 进包 | 本机仓库 | 模块③ | `v-plugin.sh` 四面绿 |
| S6-2 | 「更新」四拆:①②进包(**只到"请求"**);③ 搬包**走 S4 拉取器**;④ 执行者**留包外** | 本机仓库 | 代码 | 能**逐条指出**"谁在做",且执行者在包外 |
| S6-3 | `package.json` 指向 yaml;自增 id ⛔ 不照搬;UI ≤900px 独占 | 本机仓库 | 代码 | 窄屏截图 |
| S6-4 | **负控**:关掉包外执行者 ⇒ 自动更新行为**必须停** | 本机 | 负控记录 | 行为确实停 |
**失败怎么办**:改完没生效 ↦ §4-E1(测试跑的是构建产物,**先 build**)。
### 3.10.9 S7 · 第三块(模型管理)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S7-1 | 模型配置/凭据/共享授权/界面 进包 | 本机仓库 | 模块② | `v-plugin.sh` 绿 |
| S7-2 | 共享模型走**管理员逐用户授权**(默认未授权) | 本机仓库 | 代码 | **负控**:未授权用户**看不见** |
| S7-3 | 写 home 一律走 `UserFs` | 本机仓库 | 代码 | **负控**:直写 `fs` ⇒ 能检出"静默空操作" |
| S7-4 | 验"平台级凭据 ⛔ 不下发客户端"(R5 红线) | 两端 | 检查记录 | 客户端侧**零**平台凭据 |
**失败怎么办**:`homedir()` 写错 ↦ §4-D4。
### 3.10.10 S8 · 第四块(覆盖网络界面)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S8-1 | 界面:看哪些区 / 选加入 / 跨区可见性 | 本机仓库 | 模块④ | `v-ui.sh` 绿 |
| S8-2 | ⚠️ **⛔ 不做公开区列表**(拓扑即暴露面)⇒ 改**凭邀请码** | 本机仓库 | 代码 | **负控**:无邀请码 ⇒ **看不到任何区** |
| S8-3 | 跨区可见性跟随「用户 × 区」成员关系 | 本机仓库 | 代码 | **负控**:无成员关系 ⇒ 不可见 |
| S8-4 | 确认包内**零**身份/隧道逻辑 | 本机仓库 | 检查记录 | 搜不到身份/隧道代码路径 |
⛔ **本步的硬门**:**F1.5(G1 序号 + G6 撤根)必须先有结论** —— 定稿原文「**F1.5 是一切的前置**」。
**失败怎么办**:连不上 ↦ §4-F2(改走 unix socket)|`egressCidrs` 语义 ↦ §4-F3。
### 3.10.11 S9 · 第五块(多租户)
| 编号 | 动作 | 在哪做 | 产物 | 判据 |
|---|---|---|---|---|
| S9-1 | 租户列表/配额/成员/账单 界面进包 | 本机仓库 | 模块① | `v-ui.sh` 绿 |
| S9-2 | **"创建隔离实例"这个动作留平台进程** | 平台侧 | 代码 | 包里搜不到写平台库的路径 |
| S9-3 | 验"平台侧停掉 ⇒ 界面必须**明确报做不到**" | 两端 | 负控记录 | ⛔ 不许假装成功 |
| S9-4 | 配额按**宿主**算的负控:改宿主规格 ⇒ 租户侧看到的数**跟着变** | 平台侧 | 负控记录 | 确实跟着变 |
**失败怎么办**:想"在包里直接写平台库" ↦ §4-B3(平台不是"实例",装不了插件)。
### 3.10.12 每一步都要"停得住"(治"做一半烂在那里")
| 步 | 停下来时的状态 | 会不会留半成品 |
|---|---|---|
| S0 | 只有几个脚本 | **不留**(零副作用) |
| S1 | 只有一份文档 | **不留** |
| S2 | 空包可卸 | 卸掉即回原状 |
| S3 | 库空着 | 库用**改名冻结**,⛔ 不 `DROP` |
| S4 | 停拉取器即停 | 拉取失败**本就不动本地文件** |
| S5–S9 | 新旧并存 | 切回旧实现 |
⇒ 🔴 **所以本方案允许"随时叫停"**:任何一步停下来,系统都还是能用的状态。这也是"确认后再执行"能成立的前提。
---
## §4 避坑总表(**这是本方案的第二主产出**)
> 每条:**现象 → 根因 → 后果 → 规避 → 证伪手段**。全部来自两端实测,⛔ 没有一条是推测。
### A 类 · 客户端 / UI(**最容易"看着是绿的其实没生效"**)
| # | 现象 | 根因 | 后果 | 规避 | 证伪手段 |
|---|---|---|---|---|---|
| **A1** | 卡片根本不出现、页面报 `Failed to load plugins` | 🔴 **官方 client bundle 契约 = lazy-CJS 封包**:形如 `window.__ModuleLoader__.load({id, factory})`,`factory(require)` 内 `require('react')`…,末尾 `exports.apply`/`exports.inject`;**零顶层 `import`/`export`/`require`**。手写 ESM 直交 ⇒ 浏览器按**经典脚本**加载 ⇒ `SyntaxError: Cannot use import statement outside a module` | 🔴 **`node --check` +「可组合」+「boot 零报错」三条全绿也证明不了能加载** | 按 lazy-CJS 封包写;**`scripts/build.mjs` 的封包契约门禁**必须过 | **浏览器级**:真壳里出现卡片 + **JS 错误 0** + 无 `Failed to load plugins` |
| **A2** | HTTP 路由**静默 404** | 🔴🔴 **cordis 的服务只能靠 `inject` 拿** —— `ctx.connection` 未在 `inject` 声明 ⇒ `cannot get property "connection" without inject`;`ctx.reflect.get('connection', false)`(官方注释 "without the inject requirement")**返回 undefined** | 只写防御式 `try/catch` ⇒ **路由不挂、静默 404**,看着像"没人调" | `inject = ['connection']` **必写**(官方同类包 `file-upload`/`ui-deliverables` 全如此) | 真发一次请求;⛔ 别只看"没报错" |
| **A3** | 挂载抛错 | `maxDepth:'provider-managed'` **不可省** | 整包挂不上 | 照官方样板写 | 活体 boot 有该条 fiber outcome |
| **A4** | `ERR_MODULE_NOT_FOUND` | **静态注入是坏的** ⇒ 以**动态**为准 | 包在但加载不了 | 用动态注入 | 活体 boot |
| **A5** | 改了却没生效 | 模块级常量**在加载时读**,**不解热更新** | 白改一轮 | **改插件后必须重启壳** | 重启前后各取一次快照对比 |
| **A6** | 一个可选插件把壳搞得起不来 | 可选插件在服务缺失时**抛错** ⇒ `plugin tree failed to load: 1 entry did not activate` | 整棵插件树不加载 | 服务缺失时**降级告警,⛔ 不抛错** | 故意注掉一个服务 ⇒ 其余插件仍活 |
| **A7** | 页面看着**完全正常**,但一调用全局就炸 | 把 JS/CSS 内联进 `<script>`/`<style>` **未转义 `</script`/`</style`/`<!--`** | HTML 解析器**提前闭合** ⇒ 全局从未建立 | 内联前一律转义;IIFE + 捕获阶段拦默认提交 | 浏览器控制台 |
| **A8** | 「登录接口 200,下一步却 401」 | `dsh-app:` 是**自定义协议** ⇒ Chromium **不落 `Set-Cookie`**(cookie 存储只认 http/https/ftp/ws/wss) | **看着像登录失败,其实是 cookie 被丢** | 壳内用**显式凭据往返** | 抓响应头/请求头,⛔ 不看 cookie 存储 |
| **A9** | 登录成功后**回到初始页** | 同 URL 整页导航会**冲掉壳内现场** | 观感"登录完又回到起点" | 改写成 `<入口>#<标记>` 走**同文档片段** | 观察是否整页重载 |
| **A10** | 同一份脚本塞进同一文档**跑第二次必崩** | 平台门户脚本体**不幂等**(经典脚本顶层写 `const pwInput`,而经典脚本**共享全局词法环境**)🔴 **最危险的不是报错,是静默降级**:`form.addEventListener('submit')` 没挂上 ⇒ 表单走默认提交 ⇒ **凭据进 URL** | 全新壳里第一次跑是好的 ⇒ **极易判成"偶发"** | body 内联脚本一律包 **IIFE** + 捕获阶段拦默认提交 | 挂载两次,看 URL 是否带 `?username=` |
| **A11** | host 半判"不安全"并**整体跳过注入** | 注入脚本正文里出现**脚本闭合标签字面量** | 现象="插件 mounted=9 但壳里什么都没有" | 注入脚本正文⛔ 不写闭合标签字面量 | host 半日志 |
### B 类 · 扩展点(**一个包两端跑的门槛**)
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **B1** | 桌面端插件树加载失败:`pending (waiting for service: webServer)` | 🔴 **桌面端没有 `webServer`**(壳内 host 走 fd 管道、不 listen) | 桌面端**必须**用 `connection`;规格书逐条标注"两端是否都有"|🔴 **这也是 M6 归并的硬前提**(2026-09-28 加注):M6 的 host 半若带 `webServer` 依赖,桌面端会**逐字复现本行现象**;归并时端口一律取**包内配置常量**(⛔ 不读宿主服务),且包内门禁 ①c 会逐行抓 `webServer` |
| **B2** | API 明明注册了却 404 | 🔴 桌面端 **只支持精确路径,⛔ 无前缀匹配** | 每条路由**逐条列精确路径**;证伪=逐条真发 |
| **B3** | 「放进一个包」落了地一半 | 🔴 **平台不是"实例" ⇒ 装不了插件** ⇒ 要写平台库的动作**只能加进平台进程本身** | 包里⛔ 不写直连平台库的路径;证伪=停掉平台侧,界面必须**明确报做不到** |
| **B4** | 新增页面无处可挂 | 桌面端**无页面路由** | 走**同文档渲染** |
### C 类 · 装配与分发
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **C1** | 永远判 stale、**每轮全量重拉** | 🔴 **指纹算法两侧不一致**(协议里的"指纹"是**内容归一化后**的哈希;⛔ 别用 `md5 of tar` / `sha256 of file list` 之类的近似物) | **逐字同规则**;抄 `src/worker/agent.ts` 的 `syncSharedLayerOnce()`/`pullOnePack()` |
| **C2** | 2 MB 包跨云报 `aborted due to timeout` | **对账与下载共用一个超时**(对账 20 s / 下载 300 s、上限 900 s、最低速率 64 KiB/s —— **必须分开算**) | 两套超时分开;全部**可用环境变量覆盖,⛔ 不写死** |
| **C3** | 装配失配 | `link:` 目标路径不同 | **镜像目录绝对路径与平台侧逐字同形** |
| **C4** | `ERR_MODULE_NOT_FOUND`("包在但加载不了"的**假故障**) | 共享层里**零个 `@deepseek-ai/*`**(宿主提供的 optional peer) | **必须补软链** |
| **C5** | 「不知道」被折算成「已知」 | 指纹拿不到时报/存了"没问题" | 🔴 纪律:**算不出指纹就报"算不出"**;传/存 `null` ⇒ 平台按 **stale** 处理,⛔ **绝不折算成"一致"** |
| **C6** | 失败一轮全放弃 / 删了不该删的 | 三条语义纪律 | ① **清单取不到 ⇒ 本轮什么都不做** ② **单包失败 ⇒ 只记 `failed`**,其余照常 ③ **`extra` ⇒ 只告知,绝不删除** |
### D 类 · 数据
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **D1** | 迁移一失败**五块全挂** | 一个包 = 一个 pluginId = **一个库** | 表名分段 + **单入口按模块分步** + 每模块可单独回滚 |
| **D2** | 库名非法 / 建不出来 | 库名形状 `dshs_pl_` + `[a-z][a-z0-9_]{0,40}`、**总长 ≤ 48** | 用**正则硬校验**,⛔ 别目测 |
| **D3** | 列限声明**改了却没生效** | 列级 `maxBytes` **平台无强制点** ⇒ 唯一把关人 = **插件侧 `COLUMN_MAX_BYTES`** | **改声明后必回读 yaml 实际值** |
| **D4** | 写 home **静默空操作** | `homedir()` 必错(实例 `HOME=ws`);用户卷在 worker 上时直写 `fs` = 静默空操作 | 写 home **必须走 `UserFs`**;⛔ 不得 `cp`/`mv`/直写 `fs` 动 home |
| **D5** | 顺序错导致白干 | **先建库再投放** | 写进步骤顺序 |
### E 类 · 构建 / 运行
| # | 现象 | 根因 | 规避 / 证伪 |
|---|---|---|---|
| **E1** | 测试全过、运行时还是旧行为 | **测试跑的是构建产物** ⇒ 改源码不构建 = **新旧混跑**,症状像"改动无效" | **改完必 build**;证伪=比对 `src` 与 `lib` 时间戳 |
| **E2** | "改了却没生效"(UI 侧) | 见 A5 | **改插件后必须重启壳** |
| **E3** | 脚本 top-level await 直接报错 | `.ts` **被当 CJS** | 自研 tsx 脚本一律用 **`.mts`** |
| **E4** | `EBUSY` | **同步派生被拦**(`execFileSync`/`spawnSync`) | 解包/校验**用异步 `spawn`** |
| **E5** | `npm` 起不来(Windows) | 平台 `orchestrator.ts:663-665` 现为**不带 shell** ⇒ 裸名 `ENOENT`(`.cmd` 垫片)、显式 `.cmd` `EINVAL` | 走 `cmd.exe /c` 或 `shell: true`;⚠️ **这是全案唯一必须改平台代码的地方** |
| **E6** | client bundle 加载失败 | `exports.default` **禁用**(lazy-CJS 封包契约) | 只导出 `apply` + `inject`(R3) |
### F 类 · 系统 / 沙箱 / 网络
| # | 现象 | 根因 | 规避 |
|---|---|---|---|
| **F1** | 🔴 **所有实例全挂** | 改 bwrap 参数前**没看目标机 bubblewrap 版本**(47=0.4.0/106=0.11.0);`--perms` 属 0.5+ ⇒ 47 上 `Unknown option` | 要权限**用 `--tmpfs`**;⚠️ `smoke-isolation` 不能作 account 模式验收 |
| **F2** | 插件连不上自己 spawn 的 gateway | 插件被 nft 拒连 `127.0.0.0/8` | 改走 **unix socket** |
| **F3** | 私网里的目标连不上 | `egressCidrs` 语义:empty = `0.0.0.0/0`(**except private ranges**) | 收敛 egress 前先确认目标不在私网 |
| **F4** | 实例「起不来」被误判成 OOM | 🔴 **先查属主/EACCES** —— root 跑过 `dsh --profile` ⇒ 属主变 root ⇒ 崩溃循环/404 | `find <home> -user root -exec chown <uid>:<uid> {} +` → 重启 |
| **F5** | 106 上实例必崩 | `users/` 权限**必须 711**(700 ⇒ 实例必崩) | 装机时校验 |
### G 类 · 流程 / 协作
| # | 规则 | 出处 |
|---|---|---|
| **G1** | 🔴 **不抢锁 / 抢不到就停手**;⛔ 绝不"人工删锁 / 接管"(R9) | 平台 §6 / 客户端 §7 |
| **G2** | ⛔ **绝不 `taskkill /IM electron.exe`**(WorkBuddy 自己就是 Electron)⇒ 杀壳**只能按端口定位 PID** | 客户端 |
| **G3** | ⛔ **`launcher \| head` 会让壳静默退出** ⇒ 启动器**不接管道** | 客户端 |
| **G4** | 清目录一律 **`mv` 改名,⛔ 不删**(safe-delete 护栏整会话累计、Node 侧无确认通道) | 客户端 |
| **G5** | ⛔ **不新发凭据体系**、⛔ **不下发平台共享密钥**、⛔ **不新增端口**(走 443) | 对接单 §8 |
| **G6** | ⚠️ **别用 `grep https?://` 判"有没有网络依赖"** —— updater 的 feed 地址来自 `app-update.yml`,源码里**一个 URL 都没有**(该 grep **必然漏**,实测漏过一次) | 客户端 §9 |
| **G7** | ⚠️ **"离线可用"要防的是"挂起",不是"秒拒"** —— 拿"没人监听的端口"(秒 `ECONNREFUSED`)去验离线是**假验证**;每一处对外调用都必须带**上限等待** | 客户端 §9 |
| **G8** | ⛔ **改 / 重启必须写在回执里** —— 别用"改了源码"冒充"改了运行态" | 对接单 §7-⑩ |
---
## §5 验证基建:判据分五层(**每层都要能变红**)
| 层 | 怎么验 | 能证明什么 | ⛔ 不能证明什么 |
|---|---|---|---|
| **1 语法** | `node --check` | 语法合法 | 一切运行时行为 |
| **2 契约** | `scripts/build.mjs` 封包契约门禁 | 封包格式对 | 浏览器能否加载 |
| **3 装配** | profile 里出现该包 | 装配通 | 运行态挂载 |
| **4 运行态** | **活体 boot**(`startup-*.log` 有 fiber outcome) | 挂载通 | UI 能否渲染 |
| **5 浏览器级** | 真壳出现卡片 + **JS 错误 0** + 无 `Failed to load plugins` | ✅ **UI 真的能用** | — |
🔴 **三层铁律**:
1. **UI 类交付必须有第 5 层**(`node --check` + 可组合 + boot 零报错**三条全绿也证明不了**)
2. **每条判据都要做负控**(改坏 ⇒ 必须红)—— ⛔ 不通过负控的判据不是判据
3. **反例与正例同等重要** —— 光验 happy path 不算验过(对接单 §5 已明写三条反例判据)
---
## §6 接续棒编排(**规划棒 ↔ 执行棒交替**)
### 6.0 🔴 第 0 号停点:**用户确认门**(2026-09-26 13:19 立)
**用户原话**:「现在只做方案 落地详细步骤和方案做好了 确认后在执行」。
⇒ 🔴 **本线的棒分两类,开跑条件不同**:
| 棒的类型 | 能不能自动开跑 | 依据 |
|---|---|---|
| **规划棒**(只出方案件 / 交接单;不改码、不部署、不 ssh) | ✅ **可以** | 属于"做方案"本身 |
| **执行棒**(任何落码 / 部署 / 改配置 / 动服务器) | 🔴 **一律先交用户确认**,⛔ **不许自动登记** | 用户明令"确认后再执行" |
**这条门禁怎么落地**(三条,⛔ 缺一不可):
1. 规划棒收官时 **⛔ 不登记执行棒**;只把成果交用户确认,并把"下一棒"写进入口「⏭️ 本线下一项」+ 逐字标注「**⏸ 等用户确认后才可立棒**」。
2. 用户确认后,才由**用户或新会话**立执行棒(⛔ 不预登记队列)。
3. 执行棒自己的 prompt 里也要写:**范围以用户确认过的口径为准**,⛔ 不自行扩大。
⚠️ **为什么这条要写进方案、而不是只写在入口**:本 §6.1 原本写着"①→⑧ 自动交替接力",照那样跑**会自动把执行棒带起来** ⇒ 与用户明令冲突。**已在第 38 棒的 automation prompt 里同步掐掉"登记下一棒"这一步。**
### 6.1 棒次表
| 棒 | 类型 | 目标 | 产出 / 判据 |
|---|---|---|---|
| **①** | 规划棒 | 冻结**包规格书**(S1)+ 把 S0 的验证基建写成执行单 | `05-交接单/交接单_包规格书_20260926.md`(8 段);判据=**零条扩展点只在 Web 端存在** |
| **②** | 执行棒 | S0 立验证地基 | 四判据脚本 + 浏览器级脚本;判据=**负控红/绿各一次** |
| **③** | 规划棒 | 按规格书出 S2+S3 执行单 | 交接单;判据=规格书与执行单**逐条对得上** |
| **④** | 执行棒 | S2 空包两端落地 | 两端出现卡片、JS 错误 0 |
| **⑤** | 执行棒 | S3 数据面 | 建库→迁移→对账三轮绿 + 负控 |
| **⑥** | 执行棒 | S4 接进既有分发机制 | 指纹逐字相同 + **三条反例判据** |
| **⑦** | 规划棒 | S5–S9 一块一单(或合并成两单) | 交接单 |
| **⑧** | 执行棒 ×N | S5 → S9 逐块迁移(**一块一棒**) | 每块各自的验证 + 回归 |
🔴 **上表的开跑门(按 §6.0)**:**① 规划棒可自动开跑**;**②–⑧ 全是执行棒 ⇒ 每一棒都要先拿到你的确认**,⛔ 不许上一棒自动带起来。⚠️ 也就是说:**这张棒次表是"路线图",不是"排期"。**
⚠️ **块间并行的判据**:S5–S9 **不要并行** —— 它们**共用一个库、一套迁移入口**(§4-D1)⇒ 域不重叠这条**不成立**。
### 6.2 每棒的固定纪律(照抄进 prompt)
```
第 0 步:跑 state.py 看状态(只读、免抢锁)。
第 1 步:preflight-lock.sh "<会话名>" <目标文件...> —— 【D】未归类 或【E】机制层非空 ⇒ rc=1 ⇒ 拒开工。
第 1b 步:handoff-guard.sh --claim-exec "<会话名>" --domains <域...>;抢不到 = 只报告并停(⛔ 不删锁,R9)。
第 2 步:读唯一执行依据 = 入口 §2「🎯 本轮动作」块点名的那份交接单。
收尾四件套(缺一即算未完成):释放锁(必带会话名)· 🔴 接续(规划棒:可登记"下一棒";**执行棒:⛔ 一律不登记**,见 §6.0)· 推进入口 §2 · 写当日日志。
约束:⛔ 不 commit / push;⛔ 不改官方 dsh 主程序(R2);⛔ 不重造分发机制;⛔ 不扩大单子范围。
成本纪律:批量活先写脚本;取证 ≤3 条命令;⛔ 不 Glob/Grep 全库摸底。
```
### 6.3 停点清单(⛔ 顺序不可颠倒)
| 序 | 停点 | 触发时怎么办 |
|---|---|---|
| **0** | 🔴 **用户确认门**(§6.0) | **任何执行棒开跑前**都要过;⛔ 规划棒也不许替用户跳过 |
| **1** | **P1**(桌面客户端要不要有只读读包权 · §7) | 撞上 ⇒ 停下问,⛔ 不许自动跨过 |
| **2** | **P2**(平台侧那一半怎么落) | 同上 |
| **3** | **F1.5**(G1 序号 + G6 撤根) | S8 的**硬门** —— 定稿原文「F1.5 是一切的前置」 |
| **4** | **S1 待拍板**(106 现存实例怎么处置) | 带数据不可逆 ⇒ 必须先拍板 |
⇒ 🔴 **一句话**:**规划棒可以自动跑;执行棒要过两道门 —— 用户确认门(0)+ 它撞上的具体停点。**
---
## §7 停点与待拍板
> ⛔ 顺序不可颠倒;**本轮要你确认两件:C0(路线)+ P1(权限)**(P2 与前一轮的 S1 同批,等 P1 定了再问)。
### 🔴 C0 —— 这份方案的整体路线,认不认?
**要你定的是**:后面**要不要按这份方案往下走**(S0 → S9,先立验证地基再动手,一块一棒)。
**为什么需要你定**:这是"**做不做、按什么顺序做**",属**业务优先级**,⛔ 不是技术取舍 ⇒ 我不自决。
**A 案 · 认这份路线**
(优点:S0 先立尺,后面每一步都有**可复现、能变红**的判据,不会出现"看着做完了其实没生效";一块一棒、**每一步都停得住**。
缺点:S0+S1 是**纯准备**,前期看不到画面,要等 S2 才第一次出界面。)
**B 案 · 先只做 S0+S1,看效果再定**
(优点:成本最低、**零副作用零部署**、不留半成品;看完规格书与判据再决定要不要继续,判断依据最足。
缺点:要分两次做决定。)
**C 案 · 调整顺序或范围**
(比如先做某一块、或砍掉某一块 —— 直接说改哪一处即可。)
**我的倾向**:**B 起手,认可后再走 A**(可推翻)。理由:S0/S1 不改一行代码、不碰服务器,做完你手上会多一份**可执行的包规格书** + 一套**能变红的判据**;这时候再决定后面走不走,依据最足。
### 🔴 P1 —— 要不要让桌面客户端有权读取平台的插件包?
**要你定的是**:桌面上的 dsh 实例,要不要被允许**从平台把插件包拉下来**(只读,不写)。
**为什么需要你定**:这是**权限面的扩大** —— 设备在此之前只能"入网",此后能**读到平台的插件包内容**。影响的是**所有装了客户端的设备**,不是一台机器;而权限一旦放开,收回要靠撤设备授权。
**A 案 · 放开(只读)**
(优点:桌面本机实例**终于能拿到平台业务插件**,否则那个能力栏永远不出现;复用**已有设备公钥**校验,⛔ 不新发凭据、⛔ 不下发平台共享密钥、⛔ 不新增端口;三口**全是只读**,读不到别的用户的数据、写不了任何东西;**可撤回** —— 撤回设备授权即失效。
缺点:可见面**确实扩大了一小格**(设备从"只能入网"变成"能读包库");需要平台侧改一处校验臂并重启控制面。)
**B 案 · 不放开**
(优点:可见面**一格不扩**,维持现状最小权限;⛔ 平台侧零改动。
缺点:桌面本机实例**永远拿不到平台业务插件** —— 那个缺口长期存在,「一个包两端一致」这件事在客户端侧**落不了地**,等于**一半的方案做不成**。)
**我的倾向**:**A**(可推翻)。理由:这是**小幅、可撤回、只读**的扩展,而它换来的是"两端装同一个包"这个**核心目标**;⛔ 不应为了一格只读可见面放弃整个目标。
---
## §8 出处
| 内容 | 出处 |
|---|---|
| 五块能否进一个包 · 三件留外面 · 平台进程装不了插件 | `交付物/全功能承载判定-底层插件能否承-20260926.md` §3.12 |
| 承载两分(公共 / 主权) | `交付物/AI1NET-三类节点结构调整可行性-20260926.md` §3 |
| 插件一等公民 · 桌面扩展点对照 · `webServer` 不存在 · 精确路径 | 客户端 `docs/S6_官方二次开发能力与桌面端扩展点实测_20260921.md` §1–§5 |
| Windows 起实例的唯一改动点 · 隔离档 soft · 离线防挂起 · updater 三重可容忍 | 客户端 `CODEBUDDY.md §9` |
| 客户端红线(凭据不下发 / 环境变量白名单) | 客户端 `CODEBUDDY.md §4 R2·R5` |
| lazy-CJS 封包契约 · 必须有浏览器级判据 | 客户端 `.workbuddy/接续入口_desktop_20260925.md` §2 ⑥ ⑪ |
| 分发四步协议 · 三条语义纪律 · 坑清单 10 条 · 边界 5 条 | 客户端 `docs/对接单_桌面端接入版本管理与包更新机制_20260926.md` |
| 平台权威实现(可照抄) | `src/worker/agent.ts` 的 `syncSharedLayerOnce()` / `pullOnePack()` |
| 一插件一库 · 库名规则 · `link:` 装配 · 列级 maxBytes | `src/db/plugin-data/schema.ts` · `diff.ts` · `registry.ts` |
| bwrap 版本 · nft loopback · `egressCidrs` · 属主/EACCES | 平台 `.workbuddy/memory/MEMORY.md` 本机铁律 + `src/supervisor/*` |
| 编排方法 | 技能 `dsh-auto-handoff-chain` |