Files
dsh_ai1net_server/交付物/全功能承载判定-底层插件能否承-20260926.md
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

923 lines
80 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 全功能承载判定 —— 底层插件能否承整个项目 · 2026-09-26
> **判定对象**:七项功能特性 —— 多租户(含实例恢复)· 共享模型 · 私有模型(含 agent cli 通过客户端接入)· 三方技能 · 插件使用管理(重点:更新 → 告知用户手动 / 设置自动处理)· 覆盖网络 · IM 对话。
> **判定依据**:本仓代码实测(模块 / 端点级锚点)+ 覆盖网络定稿 `dsh-server-docs/02-架构设计/覆盖网络-顶层架构全貌.md` + 本线已出四份成品件(总盘点 / 能做不能做清单 / 节点与端关系图 / 五场景明确件)。
> ⛔ 本件不动代码、不动服务器;只做判定与缺口登记。
> 📌 口径提醒:本件里的「插件」= **跑在用户实例内的 DSH 插件**(profile 层,R2 允许的唯一扩展点),**不是**平台进程里的模块。
---
## §0 一句话判定
**能承「使用面」,不能承「承载面」。**
| | 内容 | 覆盖率 |
|---|---|---|
| ✅ **能承** | 入口 · 界面 · 配置 · 账本 · 告知 | 七项特性的**使用面近似 100%** |
| ❌ **不能承** | 起实例 / 起窗口首屏 / 取身份 / 把包装进去 / 原生网络守护 / 起承载服务(Manager·Worker 本体) | **引导层 6 件,0%** |
⇒ **「整个项目」不能被一个底层插件单独承起来。** 成立的形态是三层:**薄壳(官方 DSH)+ 引导层(6 件)+ 一个能力包(插件)**。缺任一层都不成立。
⇒ 换个说法:插件承的是**项目形态**,不是**项目本身** —— 每个用户仍旧需要一个实例、每个区仍旧需要一个 Manager,插件让这两样"看得见、点得到",但**不会让它们变少**。
### 一条判据(本件全部判定都用它,别另立)
> **「这件事发生在『插件存在之前』,还是『之后』?」**
> **之前** ⇒ 引导层(=插件还没被装进去,它当然做不了);**之后** ⇒ 可插件化。
---
## §1 七项功能逐项判定(主表)
| 功能 | 插件能承什么(✅) | 插件承不了什么(❌ / ⚠️) | 依据锚点 |
|---|---|---|---|
| **① 多租户(含实例恢复)** | 登录与身份(`/api/auth/*`);实例面板:启停重启+状态(`POST /api/dsh/launch` 130 / `restart` 152 / `stop` 177、`GET /api/dsh/status` 205);文件面(`GET /api/desktop/tree`、`POST /api/fs/mkdir\|upload\|create`、`GET /api/fs/download`);会话与设备台账(`GET /api/auth/sessions` 59、`POST …/revoke` 85、`logout-all` 113、`GET /api/overlay/my-devices` 136) | **起实例本体**(systemd scope `dsh-<uid>-<hash>.scope` + 确定性 uid + cgroup `MemoryHigh/MemoryMax`);**崩溃自动重拉 + 熔断冷却**(指数退避、熔断后冷却窗)。两者都发生在"实例还不存在 / 已经死了"的时刻 ⇒ 那时插件也不存在 | `src/supervisor/orchestrator.ts`|`src/supervisor/crash-policy.ts`(`decideCrashAction` / `breakerActive`)|`src/web/routes/dsh.ts`|`src/web/routes/sessions.ts` |
| **② 共享模型** | 用户侧开关(`POST /api/me/models/shared` 617);可选供应方列表(`GET /api/me/model-providers` 505)、`GET /api/me/keys` 484 | ⚠️ **门禁属管理员**:`shared_model_granted` 默认 0、写入口 `/api/admin/users/:id/shared-model`(admin.ts 107)—— **插件不得代替 admin 授权**(两列两个主体,⛔ 不共用写入口);把「已启用」列表注进实例的那一步在平台 spawn 侧 | `src/web/routes/auth.ts`|`admin.ts:99-109`|`src/db/schema.ts:450-466`|`src/web/server.ts:143` |
| **③ 私有模型** | 模型 / 密钥的增删改查与切换界面:`GET/POST /api/me/keys`(484 / 526)、`:id/toggle` 598、`:id/select` 657、`DELETE :id` 664 | 密钥**落库**(`credential_vault`)与**写进实例 home**(spawn 时铺 `settings.yaml` / `.credentials.yaml`)。🔴 关键推论:**用户自配凭据只有实例内可用** ⇒ **凡是要用模型的能力,插件必须在实例内跑** —— 这恰是插件跑在实例内的根本理由 | `src/db/repo.ts:376/422/429/461`|`src/db/pg.ts:603-701`|`src/fs/user-fs.ts`(`HOME_FILE_NAMES`) |
| **④ agent cli(通过客户端接入)** | — | 🔴 **平台侧零实现,属本件新具名缺口**:`grep -rn "agentCli\|agent-cli\|agent_cli" src/` = **0 命中**;且 `user-fs.ts` 明确本版**没有"实例 → 平台"的身份通道**(唯一那份 `.overlay-device.json` 由控制面代签)。⇒ **不能靠"开发一个插件"补上**,须先设计:客户端侧起/守护 agent CLI 进程 + 一条实例到平台的身份通路 | `src/web/routes/skills.ts`(全仓对照)|`src/fs/user-fs.ts` 模块注释|`src/db/schema.ts`(v13 `overlay_devices.kind`) |
| **⑤ 三方技能** | 安装 / 卸载界面与两阶段冲突流:`/api/skills/shared`(admin-only,rank 600 只读)、`/api/skills/mine`(requireAuth,rank 400);ZIP-only 契约、`conflict:true`+`stagedId` → apply 原子替换;watch 式发现(免重启) | ⚠️ **共享技能面 admin-only** ⇒ 插件只能**请求**、不能代替;**把技能落到目标 home** 必须走 `UserFs` seam(在 worker 上直写 `fs` = **静默空操作**) | `src/web/routes/skills.ts`(430/438/474/490/539/559/598/619/669)|`src/fs/user-fs.ts` |
| **⑥ 插件使用管理(更新)** | 见 **§2.1 展开**(四条分开判,两条 ✅ 两条 ❌) | 同 §2.1 | `src/supervisor/plugin-assembly.ts`|`src/worker/agent.ts`|`src/web/routes/business-plugins.ts` |
| **⑦ 覆盖网络** | 设备侧入网引导 + 台账界面(`POST /api/overlay/device-grant` 59);管理面(`GET /api/admin/overlay-nodes` 73、`/direct` 100/126、`GET /api/admin/overlay-devices` 155、`POST …/revoke` 189,全 `requireAdmin`) | **入网三级链与签名校验**(`directory.ts`:签名不对 ⇒ **失败关闭**);**relay 准入白名单**(`registry.ts` `deriveDialers()` **默认拒绝** ⇒ 结构性隔离);**中继 / 骨干本体**;**TUN 与原生网络守护**。四件都在"还没入网 / 还没有身份"的时刻 ⇒ 引导层 | `src/net/relay/{directory,registry,network,device-grant}.ts`|定稿 §二 · §5.1 |
| **⑧ IM 对话** | **四面齐全,全项目插件纵深最深的一条** —— ① 装配(profile `link:`)② 运行(实例内)③ 取数(`/api/im/data/*` + 三层授权)④ **回调(09-26 反向通道:`register` → `bridge/pull` 长轮询 → `bridge/result` 回投)**;房间 `/api/im/rooms*`(753/805/852/877/926/947)、消息 `/rooms/:id/messages`(970/999)、板面 `/rooms/:id/panels`(1075/1099) | **房间 / 成员 / 消息的权威落库**(`im/store.ts`);**agent 代答的判据**在平台(`agent-bridge.ts` 四条硬约束:禁用自激 / 每轮预算 / 静默期 / 房间级速率+排队)—— ⚠️ 但**拨出与重连在插件侧**(模块头原文)。⚠️ 已拍板代价:E2EE 强度档 ⇒ **agent 代答须重做**、群聊须 Megolm 化 | `src/im/agent-bridge.ts`|`src/im/store.ts`|`src/web/routes/im.ts:333/475`|`src/im/sdk/` |
> ✅ **总览**:七项里 **使用面全部可承**;**承载面(起/装/守/判/搬)全部不可承**。分水岭就是 §0 那条判据。
---
## §2 重点项展开
### §2.1 插件使用管理 —— 「更新 + 告知用户手动 / 设置自动处理」
用户点名的重点。**必须拆成四条分开判,不能合成一条答"能/不能"。**
| # | 能力 | 判定 | 为什么 |
|---|---|---|---|
| 1 | **看见「有新版本」** | ✅ **插件能做** | 判版本只看 `treeSha256`(⛔ 从不看版本号);`GET /api/plugins/shared/status` 矩阵已落地,通道 / 钉版 / 现役三者关系可读。插件拿这份数据渲染成"可更新"提示即可 |
| 2 | **点「更新」按钮** | ✅ **插件能做,但只到「请求」为止** | 插件发出请求、显示进度、显示结果、失败时给原因 |
| 3 | **把新包搬进来**(真正拉取) | ❌ **引导层** | 拉取器 = 引导层第 4 件「把包装进去」的**运行时对照面**:要在**实例没起**时也能跑、要写到**本机**共享只读层、要能重试与留痕(`worker/agent.ts` 的超时解耦 / 动态预算 / 只重试瞬态 / 失败留痕)。插件做不到其中任何一条的前提 |
| 4 | **「设置自动处理」** | ❌ **引导层(插件只能存偏好 + 触发)** | 🔴 **本件最锋利的一条**:自动化的执行者必须**活在被自动化的对象之外**。插件跑在实例内 ⇒ **实例没起 = 插件不存在 = 自动化不成立** ⇒ "关着实例就不更新"、"更新完需要重启却没人重启"。⇒ 自动更新的**执行者只能是引导层**,插件能做的只是写一条偏好、显示一条状态 |
⇒ **一句话给用户**:**「告知」和「按钮」可以全给插件;「拉包」和「自动」必须在插件外面。** 这四条里前两条是产品体验,后两条是物理约束。
### §2.2 多租户与实例恢复 —— 分清两种"恢复"
| 恢复的两种含义 | 判定 | 依据 |
|---|---|---|
| **崩溃后自动重拉**(进程死了再来) | ❌ 引导层 / 服务器侧 | `crash-policy.ts`:指数退避 + 熔断 + **跨轮存活冷却窗**(防"崩溃循环可无限重来")—— 全在"插件不存在"的时刻 |
| **手动复位 / 迁到别处**(跨节点迁移) | ⚠️ **机制未设计**,插件只做入口 | 承载归属(实例的家在哪台机)**尚无迁移设计** ⇒ 本线**只登记、⛔ 不立项** |
⇒ 插件能给的是**「看得见 + 点得动」**(状态、重启、停止、日志入口);**"它自己会好"这件事不在插件里**。
### §2.3 私有模型 / 共享模型 —— 两者不是一回事,别一起答
- **私有模型**:平台库存凭据(`credential_vault`),**spawn 时铺进实例 home**。插件在实例内 ⇒ **插件能直接用**(这正是"插件必须在实例内"的最大理由)。插件界面走 `/api/me/keys*`。
- **共享模型**:**两列两个主体** —— `shared_model_granted` = **管理员授权**(默认 0,用户改不了);`shared_model_enabled` = **用户偏好**。⚠️ 代码里明确写了「⛔ 不能共用一个写入口」。⇒ 插件的界面**只能碰用户偏好那一列**,授权那一列归 admin。
### §2.4 agent cli(通过客户端接入)—— 新具名缺口 🔴
- **实测**:全仓 `agentCli|agent-cli|agent_cli` = **0 命中**。平台侧**没有这条接入面**。
- **连带**:`user-fs.ts` 模块注释明确 —— 本版**没有「实例 → 平台」的身份通道**(唯一那份 `.overlay-device.json` 是**控制面代签**的)。
- ⇒ **判定**:提法**成立**(客户端侧跑 agent CLI、把结果回给平台),但 **今天没有落点,且不是"写个插件"就能补** —— 至少要新增:① 客户端侧进程的起与守护(引导层第 6 件同类物)② 一条稳定的实例→平台身份通路 ③ 与 `sessions.kind`(值域只有 `browser|desktop`)/`overlay_devices.kind`(含 `instance`)的身份对齐。
- ⇒ **登记为缺口,本件不含设计。**
### §2.5 覆盖网络 —— 三个门都在插件之前
入网要过三个门,插件**一门也过不了**:
① **入网**(三级链:env > 缓存目录 > 内置种子;**签名不对 ⇒ 失败关闭**;凭据**不含密钥本体**)
② **准入**(relay `dialers: Map<network, Set<hostId>>` **默认拒绝** ⇒ 结构性隔离)
③ **通路**(443 全可用,⛔ 不用开端口)
⇒ 三件都在"这台机还没成为节点"的时刻。插件的用武之地在**门内**:台账、设备授权引导、连接状态。
### §2.6 IM 对话 —— 插件纵深最深的一条 🔴 含一处更正
**🔴 更正(本件实测,推翻 09-25 的一条旧结论)**
| | 说法 | 现状 |
|---|---|---|
| 旧 | 09-25 审计:「扩展点注册/入向回调/出向投递**三者无跨进程通路**」⇒ 当时最大的接入缺口 | ❌ **已被推翻** |
| 新 | `im.ts:333` 起「跨进程桥:登记 / 注销 / 出向」+ `im.ts:475` 起「**反向通道(平台 → 插件)· 2026-09-26**」:`register` / `unregister` / `out/frame` / `out/message` + `bridge/pull` 长轮询 + `bridge/result` 回投;身份走**实例令牌 + 包名头**,⛔ 缺头/过期 ⇒ **401 且什么都不做**(fail-closed) | ✅ **通路已成立** ⇒ 插件接入**四个面齐全**(装配 / 运行 / 取数 / 回调) |
**残留(如实登记,两条)**
1. ⚠️ **不校验"该插件是否真在该实例启用"**(`im.ts:305-307` 自述:与 `/api/im/data/*` 同源,现靠**逐次审计兜底**)⇒ 补法 = **每插件令牌**,属投放线。
2. ⚠️ **一条实例只该有一条常驻长轮询**(`im.ts:482` 原文:⛔ 别每插件一条)—— 直接关系**连接额度**。
⇒ 所以「IM 对话」是**唯一一条插件能深度承下来**的特性;但它承的是**界面 + 收发 + 出向 + 回调**,**不是**权威库与代答判据。
---
## §3 为什么承不了 —— 总纲 + 六件逐项拆解
### §3.0 不是六条难题,是**同一个循环依赖的六个切面**
> **插件是「实例内的东西」;这六件事全是「让实例 / 平台 / 身份 / 网络存在起来」的事。**
> 用「存在的东西」去制造「它自己的存在条件」= **循环依赖**。
> ⇒ 必须有一个**在环外、先于插件**的存在 —— 那就是引导层。**不是"我们不想做",是"先有鸡还是先有蛋"。**
**三条判据**(可复用的检查单:问"这件事能不能插件化"时套这三条)
| 判据 | 问什么 | 命中 ⇒ |
|---|---|---|
| ① **时序** | 这件事要发生在插件被装进去**之前**吗? | 引导层 |
| ② **身份** | 需要**比插件更高的执行身份**(root / setuid / 内核网络栈)吗? | 引导层 |
| ③ **存活** | 要在**插件不存在的时候**照常发生吗?(更新 / 守护 / 自动处理) | 引导层 |
**⭐ 一条"分水岭"句式**(下面逐件都套它)
> **插件做的是「决定要什么 + 说出来」;引导层做的是「把那件事真的实现」。**
### §3.0.1 三条物理约束(总)
| # | 约束 | 出处 / 实测 |
|---|---|---|
| 1 | **插件跑在实例内** ⇒ 实例没起,插件不存在 | 私有模型凭据只有实例内可用 ⇒ 插件必须在实例内;于是"起实例"永远在插件之前 |
| 2 | **实例的家 = 那台机的绝对路径** ⇒ 隔着网络改不了 | `plugin-assembly.ts` 头:「Manager 隔着网络改不了(写了盘上一个不存在路径,**静默空操作**)」;`user-fs.ts` 同源 |
| 3 | **系统级能力不在插件权限内** | uid / cgroup `MemoryHigh`+`MemoryMax` / 防火墙 / 端口区间(`findFreePortInRange`:撞号会**打到别人的实例**)/ systemd scope —— 全是 OS 层,插件进程碰不到 |
⇒ 这三条决定了:**插件是"应用",引导层是"操作系统"。** 想要应用去干操作系统的活,只有两种结局:干不了,或者悄悄干坏。
### §3.1 起实例(systemd scope)—— ❌
**它成立的前提(四条,缺一不可)**
1. 先算出该用户的 **uid**(`isolation.ts hashUid`;新用户用 `baseUid + row_id`,无碰撞)
2. **以该 uid 起进程**:`orchestrator.ts:1221` → `setpriv --reuid <uid> --regid <uid> --clear-groups <cmd>`
3. **套 cgroup 与 scope**:`orchestrator.ts:1242-1249` → `systemd-run --scope --unit <unit> -p MemoryHigh=…M -p MemoryMax=…M`,产出 `dsh-<uid>-<hash>.scope`;且 **scope 名里的 uid 必须等于 argv 里的 `--reuid`**(`:259-274` 交叉验证)
4. **往那台机的 home 铺 profile 树**(`plugin-assembly.ts` 头:隔网改=静默空操作)
**插件为什么不满足**
插件**自己就活在这个 scope 里**(`:1030` 原文:`Spawn the child inside a bwrap sandbox (mount/pid isolation) with setpriv …`,外面再套 `systemd-run` scope)。要它去创建另一个 scope,等于**要它站在自己的容器外面**。而这四条前提**全是 root / setuid 级动作**,插件权限**只收窄**(R5 硬红线)⇒ 一条也做不到。
**✅ 插件那一半**:请求 + 显示(`POST /api/dsh/launch` 130 / `restart` 152 / `stop` 177、`GET /api/dsh/status` 205)
**分水岭**:**谁创建那个 scope**
#### ❓ 追问「插件代码不能起实例吗?」—— 四条路逐个否掉
| 路 | 能不能 | 一句话 |
|---|---|---|
| **A 插件自己创建 systemd scope** | ❌ | 四重门(下) |
| **B 插件自己 `spawn` 一个 node 进程** | ⚠️ **能起"进程",起不出"实例"** | 六个必要条件零满足(下) |
| **C 插件调平台 HTTP 让它起** | ⚠️ **形态成立,今天没通路** | 两套凭据不通用(下)⭐ 唯一真能补的口子 |
| **D 插件改自己的 profile 让 dsh 自己重启** | ❌ | 那只改变"下一个实例长什么样",不产生"下一个实例" |
**A 的四重门**(全部为实测事实,非推测)
1. **看不到 systemd** —— 实例被 `--unshare-pid`(`orchestrator.ts:1219`)丢进**独立 PID 命令空间** ⇒ 插件**连宿主上的 systemd 都看不见**,`systemd-run` 无从谈起
2. **不是 root,且一出生就被降权** —— 启动链是 `systemd-run` → `bwrap` → `setpriv --reuid <uid> --regid <uid> --clear-groups`(`:1221`)
3. **换不到别人的 uid** —— 以别的 uid 起进程要 `CAP_SETUID`=root 专属 ⇒ 插件**连"别的用户"这个概念都够不到**
4. **看不见别人用户的家** —— 沙箱里只 `--bind root root`(`:1179`,**它自己**的家);别人的 home 是 `0700` + uid 边界,且 `plugin-assembly.ts` 头已实测"隔网改=**静默空操作**"
**B 的"六个必要条件"** —— 平台语义里的「实例」=下面六样**同时在册**,插件**一样也办不到**
| 必要条件 | 平台怎么给 | 插件能自己办到? |
|---|---|---|
| 独立 uid | `setpriv --reuid`(要 CAP_SETUID) | ❌ |
| 独立 cgroup scope | `systemd-run -p MemoryHigh/MemoryMax/CPUQuota/TasksMax`(`:1241-1248`) | ❌ |
| 专属端口(worker 划的不重叠区间) | `findFreePortInRange(base, span)`(`spawn.ts:79-92`,区间耗尽即抛错) | ❌ 自选端口会**撞号 ⇒ 打到别人的实例**(2026-09-16 实测) |
| 自己的 profile 树 | `plugin-assembly.ts` 写目标 home 的 `package.json` | ❌ |
| 在册(DB 行 + 租约) | 只有 Manager 能写(三层权威:归属 / 租约唯一写者) | ❌ |
| 可被访问(代理路由 / 域名映射) | 平台侧 | ❌ |
⇒ 插件 `spawn('node', …)` **确实能起一个进程**,但那个进程:在**同一个**沙箱里、在**同一个** scope 里(抢**同一份** `MemoryMax` / `TasksMax`,OOM 会**连坐宿主实例**)、**没有专属端口**、**不在册**、**谁也进不去**、父实例一崩一起没。⇒ **它不是"实例",是"孤儿进程"。**
**一句话**:**能起进程 ≠ 能起实例。** 平台的「实例」是一组**同时在册的东西**,不是"一个 node 进程"。
**C 的两套凭据**(这条今天最实际,也最容易被忽略)
- `/api/dsh/launch` 的守卫是 `requireAuth`(`dsh.ts:130`)= **浏览器 session cookie**
- 插件手里能拿到的是 `x-dsh-im-instance-token`(`im/instance-token.ts`)—— 它**就是**为"实例内那个没有浏览器的 node 进程"补的通道,但**只服务 IM**(`imAuth`),且**跨区本期拒绝**、fail-closed
⇒ **两套凭据不通用** ⇒ **今天插件连"请求平台起实例"这一半也走不通**(要新开一条授权面)。⭐ 这条是"起实例"这一项上**唯一真能补的口子**,补法 = 给实例令牌一条**受限面**(只允许操作**自己**那个实例的 launch/restart/stop),⛔ 不给通用控制面权限。
**🔴 压轴:自举(bootstrapping)—— 这一条谁都绕不过**
问一句:**平台上第一个实例是谁起的?**
- 那时平台进程还没跑 ⇒ **没有 HTTP 可请求**
- 那时也**一个实例都没有** ⇒ **更没有插件**
⇒ 「起实例」这个能力**必须存在于插件之外**,否则**整个系统第一次起不来**。与"init 进程不是被某个 app 起的"同一个道理。
**🔴 还有一条:不是"做不到",是「互相排斥」**
「插件能起实例」与「实例是多租户隔离的」,**在物理上不能同时成立**:
- 前者要求插件有**跨 uid 边界**的权力(能替别人起进程、写别人的家)
- 后者**正是靠 uid 边界成立**的
⇒ 插件能起实例的那一刻,**恰好是它不再是"多租户实例"的那一刻**(退化成"你自己本机的一个进程" —— 没有隔离、没有配额、没有在册、没有租约,也就没有"多租户"这个能力)。⇒ 这不是"做不做得到",是**做了等于拆掉多租户**(击穿 R5「权限只收窄」)。
**⇒ 精确边界**:插件能表达**「我要起 / 重启 / 停止**我自己的**实例」**,由平台执行(今天连这条都要先补凭据面)。**"起"这个动作,归引导层。**
⚠️ **适用域收窄(见 §3.11)**:本节六条"不行"是**「在我们这条『插件跑在实例内、无集群身份』的形态下」**。**若改成 K8S + 给实例一把限定 namespace 的 SA + Role,插件确实能自主创建自己的实例** ⇒ 那时准确说法变成「**插件不能自己拿到钥匙**」(而不是"不能起实例")。
### §3.2 起窗口首屏 —— ❌(但插件那一半最大)
**它成立的前提**:一个**进程外**的窗口(用户设备上的浏览器窗口 / 桌面壳窗口)。插件是 **profile 层的 CJS 模块**,由**宿主加载**(`apply` + `inject` 契约)。
**插件为什么不满足**:「创建窗口」是**本机进程**的动作;插件运行在**远端实例进程**里(服务器上的 node 进程),它不在那台设备的窗口系统里,也就没有"开窗"这个动作可发。
⭐ **本条最特殊,要单独理解**:**"插件那一半"其实很大** —— 插件的 client bundle 可以做**首屏里的一切**。⇒ 这一项不是"插件没用",而是**别把「开窗」和「画窗」混成一件事**。
**✅ 插件那一半**:**首屏里的全部内容**
**分水岭**:**谁创建窗口**(画什么归插件)
### §3.3 取身份(入网凭据)—— ❌
**它成立的前提**:身份由**别人签发**,且要**在入网之前**就有。
- `device-grant.ts` **五道门**(顺序即判据):停发闸门 → 用户配额 → 签 grant → 注册表落盘 → 台账 → **relay 密钥表(写最后是刻意的)**
- 首启引导 **三级链**:env > 缓存目录 > 内置种子;**签名不对 ⇒ 失败关闭**
**插件为什么不满足**
1. 插件是"**被装配进来的**";装配要 profile;profile 要实例 home ⇒ **又一次循环依赖**
2. 插件**不是签发者**,也不该拿到密钥本体(凭据设计上**不含密钥本体**)
3. 本版**没有「实例 → 平台」的身份通道**(`user-fs.ts`:唯一那份 `.overlay-device.json` 由**控制面代签**)⇒ 插件连"以实例身份说话"这一句,都得**先由平台替它签**。
**✅ 插件那一半**:入网**引导界面**(申请、显示状态、重试)
**分水岭**:**谁签**
### §3.4 把包装进去(拉取器)—— ❌
**它成立的前提(三条,都是"实例没起时"的事)**
1. 要能在**实例没起的时候也跑**(否则"关着机就不更新")
2. 要写**本机**的共享只读层 `/var/lib/dshs/bundled-plugins`(**root:root 755**)
3. 要**重试 + 留痕**(`worker/agent.ts`:超时解耦 / 动态预算 / 只重试瞬态 / 失败留痕)
**插件为什么不满足**
① 插件只在实例起来后才存在 ⇒ **关着实例就永远不更新**(=§2.1 第 4 条)
② 共享只读层是 **root 属主**,插件是普通 uid 且在沙箱内 ⇒ **写不进去**
③ 装进 profile 的 `node_modules`(`link:` 软链)那一步叫**装配**,要改实例 home 的 `package.json` —— 又是"改别人的家"
**✅ 插件那一半**:感知(有新版本)+ 请求 + 进度 + 失败原因
**分水岭**:**谁搬包**
### §3.5 原生网络守护 —— ❌
**它成立的前提**:改内核网络栈(网卡 / 路由 / 防火墙)+ **常驻**。
**插件为什么不满足**
① 这些是 **CAP_NET_ADMIN / root** 级动作。实测旁证:**平台自己的端口守卫就是 root-only 的 iptables** —— `firewall.ts` 头原文「**Linux + root only**;opt in via `config.portGuard` and **fail loud when enabled on a host that cannot apply it**」
② **沙箱把插件的网络权限收得比想象的紧**:实测有一条 —— **插件被 nft 拒绝连 `127.0.0.0/8`(连它自己 spawn 的 gateway 都连不上),最后改成走 unix socket**(`orchestrator.ts:863`)⇒ **插件连本机回环都不是自由用的**
③ 守护必须在**实例之外**活着(实例没起也要守)
**✅ 插件那一半**:策略与状态(用哪条路 / 通不通 / 延迟)
**分水岭**:**谁碰内核网络栈**
### §3.6 起承载服务(Manager · Worker)—— ❌
**它成立的前提**:Manager / Worker 是**平台进程**(`dshs` / `dshs-worker`)。Manager 有**唯一权威库**、签发凭据、**起实例**;Worker 是本机执行者(`src/worker/` **零 DB 引用**、单向拨入、幂等键、self-fencing)。
**插件为什么不满足**
① **Manager 是引导层本体** —— 实例由它起 ⇒ 插件**装不进去**(自己把自己装进自己里)
② **判决者不能由被判决方自选** —— `lease.ts` 三条不变量 + `schema.ts` v7 原子 CAS + `epoch` fencing;双写 ⇒ 脑裂 ⇒ **会话日志 append 冲突 = 数据损坏**
③ Worker 要系统级能力 ⇒ **与 §3.1 同因**
**✅ 插件那一半**:角色选择界面 + 申请(§14 三档:用户设备 ✅ / 轻节点 ✅ / 重节点 ❌)
**分水岭**:**谁跑服务**
### §3.7 六件汇总表
| 六件 | 前提卡在哪 | ✅ 插件那一半 | 分水岭(执行半归谁) |
|---|---|---|---|
| 起实例 | root/setuid + systemd scope | 请求 + 状态 | 谁创建 scope |
| 起窗口首屏 | 本机窗口系统 | **首屏里的全部内容** | 谁创建窗口 |
| 取身份 | 由控制面签发 | 入网引导界面 | 谁签 |
| 把包装进去 | 实例没起也要跑 + root 属主共享层 | 感知 + 请求 + 进度 | 谁搬包 |
| 原生网络守护 | CAP_NET_ADMIN / root | 策略 + 状态 | 谁碰内核网络栈 |
| 起承载服务 | 平台进程本体 + 判决者唯一 | 选角色 + 申请 | 谁跑服务 |
**⭐ 一条必须说清(消解真实顾虑)**
> 六件"不行"**不等于"用户要多装两样东西"**。引导层可以做成**同一个安装包的另一半** —— 用户**只装一次**,装出来的就是"壳 + 引导层 + 插件"。
> ⇒ **"分两层"是我们内部的实现分工,不是用户的操作负担。** 真正要你拍板的仍是 **P1**。
**⚠️ 一条自我约束(别把话说满)**
本节六条的"不行",**强度并不相同**:
- **形式上就不可能**(换形态也一样):**§3.1 起实例**、**§3.6 起承载服务**(循环依赖 / 判决者唯一)
- **在当代 Linux 上不可能**(需要 root 级动作):**§3.3 取身份**、**§3.4 把包装进去**、**§3.5 原生网络守护**
- **是架构分工,不是能力问题**:**§3.2 起窗口首屏**(画窗归我们,开窗归壳)
⇒ 后五条**理论上**会变 —— 如果哪天形态变成"插件被装进一个**本机特权宿主**里"。但那时它已经**不是"插件"了**(它变成了引导层的一部分)。⇒ **本节的"不行"准确表述是:「在我们这条「插件跑在实例内」的形态下不行」**,⛔ 不说成"永远不行"。
---
### §3.8 评估用户方案:「第一个实例 = DSH 主体,加载基础插件后选择成为服务节点」
**用户原话**:「第一个实例是 dsh 主体的,加载这个基础插件后 选择成为服务节点,设置管理员账号,配置数据库后,提供多租户能力,本质是在服务上开多个相互隔离的 dsh 实例服务,这个看起来是成立的」
#### §3.8.0 一句话判定
**七条里六条成立,唯一要改的是一个词:那个"基础插件"不是插件,是引导层。**
分歧**不在架构,在权限**:**能不能拿到 root / 系统级能力**。
- 能 ⇒ **成立**(它就是引导层)
- 不能 ⇒ **四重门照旧**(§3.1)
#### §3.8.1 逐条对
| 用户的说法 | 判定 | 说明 |
|---|---|---|
| ① **第一个实例 = DSH 主体** | ✅ **成立,而且这就是"自举"的答案** | 第一个**不由任何人"起"** —— 管理员 / 安装脚本直接启动它。⇒ 与 §3.1 的「压轴·自举」**不矛盾,正是它的解**:「起实例」的能力确实在插件之外(=**管理员本人 + 安装脚本**) |
| ② 加载基础插件 | ⚠️ **成立,但名字要改** | 见 §3.8.2 |
| ③ 选择成为服务节点 | ✅ 成立 | 此时"服务节点"**已经在那儿了** ⇒ 这是**启用 / 初始化**,不是**创建** |
| ④ 设置管理员账号 | ✅ 成立 ⚠️ **但今天正是缺口** | =定稿 `02-架构设计/覆盖网络-顶层架构全貌.md` **§5.3 路径 A「新服务器首启」**:显示首管理员设置页(**一次性**,建完即永久关闭)。**定稿原文:⚠️ 今天只有 CLI,无 Web 通路**(缺口 G3/G4 同源) |
| ⑤ 配置数据库 | ✅ 成立 | PG + drop-in;一次性动作 |
| ⑥ 提供多租户能力 | ✅ 成立 —— **但这不是新方案,是现状** | 见 §3.8.3 |
| ⑦ **本质 = 在服务上开多个相互隔离的 dsh 实例** | ✅ **准确** | 隔离是**四层叠出来**的:uid + cgroup(`MemoryHigh`/`MemoryMax`/`CPUQuota`/`TasksMax`)+ bwrap(mount / PID 命名空间)+ 每 worker 的端口区间 ⇒ **用户对本质的理解是对的** |
#### §3.8.2 唯一要改的那个词:「基础插件」≠ 插件
**官方 DSH 的插件机制里,没有"以 root 运行"这个位置**:
- 插件是 profile 层的**依赖**(`plugin-assembly.ts`:装配 = 往 profile 的 `package.json` 写 `link:`)
- **加载它的宿主进程本身就被降权** —— `setpriv --reuid <uid> --regid <uid> --clear-groups`(`orchestrator.ts:1221`)+ bwrap(`:1219`)
⇒ **宿主不是 root ⇒ 宿主里的插件永远不可能是 root。** ⇒ "用插件做多租户"**在权限上从一开始就不成立**,与我们怎么写代码无关。
⇒ 两个位置必须分开命名,否则永远吵不清:
| | **业务插件** | **引导层(宿主扩展)** |
|---|---|---|
| 谁安装 | 平台装配 | **管理员本人**(安装包 / setup) |
| 跑在哪 | 用户实例进程内 | 机器上,root / 带能力 |
| 权限 | **只收窄**(沙箱内) | 系统级 |
| 今天叫什么 | mcn-suite / IM 插件 | **`dshs` / `dshs-worker`** |
| 能开隔离实例 | ❌ | ✅ |
⇒ 用户脑中那个"基础插件"= **右栏**。它确实是"装在官方 DSH 上的一层",但**不是 R2 说的那种插件**。
#### §3.8.3 「提供多租户能力」今天已经成立 —— 所以方案的真价值不在⑥
现状:47 上跑着 `dshs`(Manager,127.0.0.1:3080)+ `dshs-worker`(19100),实例是**独立 systemd scope**。
⇒ **"服务节点 + 多租户"今天就有,而且形状与用户描述的一致。**
⚠️ **但要说清"多租户能力"的载荷量**:它不是"插件顺带给的一个功能",**它就是整个平台本体** —— `supervisor/`(12 文件,含 orchestrator / lease / plugin-assembly / crash-policy)+ `web/routes/`(15 文件)+ `db/`(10)+ `net/relay/` + `im/`(12)+ `fs/`。⇒ 把它"装进第一个实例"= **把整个平台装进一个实例**,而不是"加一个小插件"。
⇒ **所以这个提法的真价值在另一件事**:**把引导层从"独立进程"改成"寄居在官方 DSH 里的一层"**(用户只装一次)。现状与它的**真正差别**是:今天 Manager 是**我们自己的程序**,不是 DSH 实例。
#### §3.8.4 "寄居"该不该做 —— 收益 vs 四条代价
**收益**:① 用户**只装一次**(壳 + 引导层 + 插件)② 引导层与 DSH 共用依赖 / 版本 ③ "第一个实例"天然就是宿主。
**代价(四条,如实标)**
1. 🔴 **可靠性降级** —— 引导层与 DSH **同生共死** ⇒ DSH 崩 = **所有实例没人管**。今天 `dshs` 与实例是**分离**的(**双向**都不互相牵连)⇒ 这一条是**净变差**(命中 R11),不能藏进交付里
2. 🔴 **升级耦合** —— 升级 DSH 会牵动引导层,而 **R1 明令不自动升级 dsh** ⇒ 升级动作的危险等级明显上升
3. 🔴 **权限要么混、要么分** —— 引导层要 root,DSH 主体通常以普通用户跑 ⇒ ① DSH 提权(危险,**会推翻现有的降权 + 沙箱设计**)② 引导层仍是独立进程
4. 🔴 **"实例没起也要活"的那几件塞不进去** —— 拉包、网络守护、自动更新(§2.1 第 3 / 4 条)在官方 DSH 的进程模型里**没有位置**
⇒ **建议(可推翻)**:**同一个安装包,两个进程** —— 安装脚本一次装好「官方 DSH(薄壳 / 界面)+ 引导层(以服务形式、root 起)」。
⇒ **用户视角"只装一次",架构上"权限分层不混"。** 这正是 §3.7 那条「引导层 = 同一安装包的另一半」的强化 —— 用户这个提法把它从"我们的说法"变成了**可实现的安装形态**。
#### §3.8.5 一个退化形态必须说清(免得以后争论)
如果**单机、单用户、全部同 uid**(放弃 uid 隔离):
⇒ "开多个 DSH 实例"退化成"多开几个**同 uid** 的 DSH 进程"(分 HOME、分端口)—— **插件确实起得动**(同 uid 可写自己目录、可 spawn、可挑端口)。
⇒ **但那不是"多租户"**:同 uid ⇒ **没有租户边界**,一个租户的会话能读另一个租户的文件。
⇒ 一句话:**"能起实例" 与 "多租户隔离" 在同一个进程身份下不能同时成立** —— 这就是 §3.1「互相排斥」那条的具体形态。
#### §3.8.6 §3.8 结论
**用户方案成立,而且比"插件能承一切"更准确。** 只需两处调整:
1. 把"基础插件"改称 **引导层 / 宿主扩展**(它需要系统级权限)
2. 接受**它是第二个进程**(可以同包安装、一次装好,但不要塞进同一个进程)
⇒ **本质是"同一个安装包,两个进程",不是"一个插件"。真正待拍板的仍是 P1。**
---
### §3.9 评估「把 DSH 实例当作服务器的系统,多租户(含管理员自己的 AI1net 实例)由插件创建 —— DSH 系统里嵌套的多租户 DSH 平台」
**用户原话**:「把现在的dsh实例当作是服务器的系统,多租户包括管理员的AI1net实例是插件创建的这样看呢,相当于dsh系统里面嵌套的 多租户dsh平台」
#### §3.9.0 一句话判定
**"嵌套"不成立;但你要的东西(管理员也有一个自己的 AI1net 实例)今天就是"并列",不需要嵌套。**
最要紧的一句:**「把 DSH 当系统看」≠「它就有系统的权限」。** 系统与应用的差别**不是名字,是特权层** —— 把 DSH 实例改名成"系统",它不会因此长出内核,也不会因此看得见 systemd(§3.1 四重门原样复现)。
#### §3.9.1 先分清两张图:嵌套 vs 并列
| | **嵌套(本提法)** | **并列(今天的形态)** |
|---|---|---|
| 结构 | 外层 DSH(当"系统")→ 插件 → 内层 N 个 DSH | 引导层 → N 个 DSH(**其中一个属于管理员**) |
| "外层"是谁 | 一个 DSH 实例 | **不是 DSH**:是引导层(`dshs` / `dshs-worker`) |
| 管理员实例 | 由插件"创建"出来 | **与其它实例同级、同一套机制**,区别只在 DB 里 `role='admin'` |
| 今天有没有 | ❌ 没有 | ✅ **已经是这样** |
#### §3.9.2 为什么"嵌套"不成立(三条)
① **"当系统看"不产生特权** —— 外层若是普通 DSH 实例,它**自己就在 bwrap 里、被 `setpriv` 降权、看不见 systemd**(`orchestrator.ts:1219/1221`)⇒ 名字改成"系统"不会让它长出内核。
② **要做成"系统",它必须已经是特权层** ⇒ 那就落回 §3.8 的结论:**那是引导层,不是插件**。
③ 🔴 **以 root 跑 DSH + 加载插件 = 把安全模型倒过来** —— 内层靠 uid / bwrap 隔离,而外层是 **root** 且加载**可替换的第三方插件** ⇒ 任何一个越权插件就能开任意 uid 的实例、读任意租户的家 ⇒ **多租户隔离退化成"自愿的"**。这与现有"降权 + 沙箱"设计**直接冲突**。
#### §3.9.3 "嵌套"的三笔实测代价(用本仓现成数字,不估算)
1. 🔴 **先白吃一份基座** —— `orchestrator.ts:1229` 原文实测:**dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB**(=**基座成本**,非用户行为);而宿主实机**只有 1870 MiB**(`:1233` 原文:并发由 2 核 / 1870 MiB 与实时 available 决定)。⇒ 嵌套 = **外层先扣一份**(且外层要跑平台逻辑,占用**必然显著大于 145 MiB**),内层每租户再各扣一份 ⇒ **承载能力净下降**(命中 **R11**)。
2. 🔴 **造出两个真相** —— 外层 DSH 自己也有"会话 / 实例 / 配置"的概念 ⇒ 嵌套后**外层自认宿主,内层平台也自认宿主** ⇒ uid / 端口 / 会话 / 账本**四套东西叠加**。而现有设计的核心不变量是**判决者唯一**(`lease.ts` 三条不变量 + `schema.ts` v7 原子 CAS + `epoch` fencing;双写 ⇒ 脑裂 ⇒ **会话日志 append 冲突 = 数据损坏**)⇒ **必须二选一**。
3. 🔴 **分配权会打架** —— `findFreePortInRange(base, span)` 的前提是**每台 worker 一段不重叠区间**;嵌套后外层也在占端口 ⇒ 区间必须由**唯一一方**划(现状是 Manager)。
#### §3.9.4 你要的效果:**不需要嵌套,只需要"并列 + 角色"** —— 而且今天已经是这样
**实测三条**
- `dsh_instances` 按 **`user_id`** 建(`repo.ts:617` INSERT、`:646` `findUserInstance(userId, role)`、`:915` 集群态同键)
- **没有任何"管理员不给实例"的排除逻辑** —— `grep -rn "role === 'admin'" src/supervisor/ src/web/routes/dsh.ts` = **零命中**
- 管理员的"管理员"身份**只体现在平台侧的数据与授权判断里**:`requireAdmin` 就是一句 **DB 角色判断** —— `middleware/authn.ts:31-38`,`request.user.role !== 'admin'` ⇒ 403
⇒ **今天管理员与普通租户跑的是同一个东西**,管理员特权**不在进程权限里**。
⇒ **这是刻意的,也是必须的**:管理员实例**不能比别的实例多一份特权** —— 否则**它一旦被攻破=全网**。⇒ 你要的"管理员也有自己的 AI1net 实例"**今天就有**,而且是**同级并列**。
⇒ **精确表述**:**不是"DSH 里嵌 DSH",是"引导层下面并列着 N 个 DSH 实例,其中第一个属于管理员"。**
#### §3.9.5 那"嵌套"有没有成立的位置?—— 有,但隔离强度会掉到零层
**唯一能叫"嵌套"的形态** = 在**同一个 DSH 里**做多会话 / 多工作区(DSH 自己的权限模型)。但按实测:
- DSH 的会话隔离粒度是**会话**,不是**租户**(`sessions` 表按 `user_id` + `workspaces`;`sessions.kind` 值域只有 `browser|desktop`)
- ⇒ 用它做租户隔离,**没有 uid、没有 cgroup、没有 bwrap** —— 三层全没了
🔴 **而且这条是"往回退"** —— 现成的决定性证据:`orchestrator.ts:823-827` 原文
> 「本机内核 5.10 无 Landlock、bwrap 后端在平台容器内探测失败 → 内置默认 `workspace-write` 会 fail-closed 拒绝执行任何 shell(P0)。改 `danger-full-access` = **不在实例内再叠加沙箱**,**隔离交由平台容器(bwrap + uid + cgroup)负责**」
> 对应代码:`DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'`
⇒ **平台已经明确把"隔离"这件事从 DSH 里搬到了外面**,并**主动关掉了 DSH 自带的沙箱**。⇒ "用 DSH 当系统、在 DSH 里做租户隔离"= **退到一个平台主动放弃的层**。
#### §3.9.6 §3.9 结论
| 提法 | 判定 |
|---|---|
| "DSH 当系统 + 插件建多租户" | ❌ 不成立(**"当系统看"不产生特权**;要特权就成引导层;以 root 跑宿主会把安全模型倒过来) |
| "DSH 实例里嵌多租户 DSH" | ❌ 不成立(**三笔代价**:基座重复扣 / 两个真相 / 端口与 uid 分配权打架) |
| **"管理员也有一个自己的 AI1net 实例"** | ✅ **今天就是,而且是同级并列**(`dsh_instances` 按 `user_id`;`requireAdmin` 只是 DB 角色判断) |
| "DSH 内做多租户" | ⚠️ 隔离强度**从四层降到零层**,且是平台**主动放弃**过的那一层 |
⇒ **现状与你要的形态其实已经是**「引导层 → N 个并列 DSH 实例(首个属管理员)」。差别只在:**引导层今天是我们自己的程序(`dshs`),不是官方 DSH。** ⇒ 待拍板的仍是 **P1**(三层形态 + 同包两进程,走不走)。
---
### §3.10 评估「假如用 K8S 的方案做多租户实例,是否可以解决这个问题」
#### §3.10.0 一句话判定
**能解决一部分 —— 而且解决的正是最难自研的那一部分;但「插件不能起实例」这句话在 K8S 下依然成立。**
因为 **K8S 把门槛从「你是不是 root」换成了「你有没有 RBAC 授权」—— 门槛没了,门还在。**
⇒ 它**把 §3.1 的四重门压成一道门**("有没有一个被授权的身份"),但**那一门仍然在插件之外**。
#### §3.10.1 先摆一个事实:**这条路代码里已经被承认了,只差实现**
| 层面 | 状态 | 证据 |
|---|---|---|
| **设计意图** | ✅ **写明** | `config.ts:18-19`:「`local` = single-host child_process (setuid/iptables);**`k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API**」 |
| 配置面 | ✅ **已预留** | `k8sNamespace` / `dshImage` / `controlPlaneImage` / `imagePullSecret` / `egressCidrs` / `k8sServiceAccount` / `podName`(env `POD_NAME`) |
| **权限载体** | ✅ **已经换过** | `config.ts:125-126` 原文:「`k8sServiceAccount` = **ServiceAccount the orchestrator runs as (k8s mode only)**」⇒ **从「root」变「SA」** |
| 数据库 | ✅ 已切 | `db/adapter.ts`:`local` ⇒ SQLite;`k8s` ⇒ Postgres(要 `DSHS_DB_URL`) |
| egress 收敛 | ✅ 有配置项 | `egressCidrs` 注释:「443 egress whitelist CIDRs for **per-user DSH Pods**(Phase 4 egress 收敛)」 |
| **实现** | 🔴 **零** | `grep "kubernetes\|@k8s\|coreV1\|appsV1\|createNamespacedPod" src/` = **0 命中**;`package.json` 无 k8s 依赖;**`podName` 零消费点**(选主只有位没实现);`proxy.ts:675/745` 里 `deployMode === 'k8s'` 只是传给 `proxyHttp` 的**一个布尔** |
⇒ **所以问题不是"能不能上 K8S",是"现在值不值得把这条线做出来"** —— 它是一整条线:控制面选主 + 执行体从 systemd 换 Pod + 状态回流 + 设备侧通道。
#### §3.10.2 K8S **真的解决**的四件(对齐 §3.1 的六件)
| §3.1 六件 | K8S 下 | 为什么 |
|---|---|---|
| **① 起实例** | ✅ **真松动** | 执行体从「本机 root + systemd + setuid」换成「**K8S API + Pod**」⇒ **不再需要 root,只需要一个有 RBAC 权限的 ServiceAccount**。配额从手写 cgroup 参数变成 `ResourceQuota` / `LimitRange`;隔离从"uid + bwrap"变成 **namespace + PodSecurityContext + NetworkPolicy**(标准、可审计、少自研) |
| **④ 把包装进去** | ✅ **部分解决** | `initContainer` / `DaemonSet` / `CronJob` 是**原生位置** ⇒ "实例没起也要跑"这件事第一次有了标准载体 |
| **⑤ 原生网络守护** | ✅ **部分解决** | CNI + `NetworkPolicy` + `egressCidrs`(**配置项已有**)接手**集群内**那一半 |
| **⑥ 起承载服务** | ⚠️ **形式变了** | 控制面可做成 `Deployment`(多副本)+ **K8S 原生 `Lease` 对象选主** ⇒ `podName` 那个"只有位没实现"的选主**有了现成载体** |
#### §3.10.3 K8S **不解决**的四件(这才是关键)
1. 🔴 **"谁调 K8S API" 不变** —— 插件若跑在**租户实例内的普通进程**里,它**依旧没有 kubeconfig / SA token / RBAC** ⇒ **一样起不了实例**。⇒ **K8S 把"需要 root"换成"需要 RBAC 授权",但没有取消"需要授权"这件事。** 于是**自举问题一模一样**:**第一个被授权的身份从哪来?** K8S 下也是同一个答案 —— **管理员 / 安装脚本 `apply` 一份 RBAC**。⇒ **§3.9 的"自举"、§3.1 的"四重门",K8S 一条也没绕开,只是换了门牌。**
⚠️ **措辞更正(见 §3.11)**:本条里的"没有…"指的是**没有身份**,⛔ **不是"网络连不上"** —— 沙箱**共享网络命名空间**(全仓唯一一处 `--unshare` 是 `--unshare-pid`,**没有 `--unshare-net`**)⇒ **出网是通的**。详见 **§3.11**。
2. 🔴 **"插件跑在实例内"这条约束不变** —— K8S 里 **Pod 内依旧是普通用户进程**(除非给 `privileged`,那又是把安全模型倒过来)。
3. 🔴 **"两个真相"不解决,只换了对手** —— K8S 帮你**管 Pod**,但**不管你的租户语义**:`dsh_instances` / 租约 / 归属 / 会话账本**还是你的**。⇒ 于是变成「**平台库 vs K8S 实际状态**」两套状态源,而且更难:Pod 会被**驱逐 / 重调度 / 抢占**,你的库得一直接受"我记的和你实际跑的不一样"。
⇒ **唯一干净的解法**:**把租户语义交给 K8S** —— **一个租户一个 namespace**,平台只做"**声明式 controller**"(见 §3.10.6)。
4. 🔴 **设备侧完全不解决** —— 用户的笔记本、浏览器**不是 Pod**,永远在集群外。⇒ 覆盖网络那一半(中继 / 打洞 / 两个网 `ops` / `u:<user>`)**K8S 帮不上**,且与 CNI 是**两套东西**,还得各管各的。
#### §3.10.4 一条必须先摆上桌的代价:**别在一台 2 核机上跑 K8S**
- 宿主实机:**2 核 / 1870 MiB**(`orchestrator.ts:1233` 原文)
- 每个 DSH 实例**基座**就要 **106~145 MiB**(`:1229` 实测)
- 而 `kubelet` + `containerd` + CNI + kube-proxy + 控制面本身,**基座成本会先切掉一大块 1870 MiB**
⇒ **在本机跑 K8S 再跑若干 DSH 实例 = 净变差(命中 R11)。**
⚠️ 但按 2026-09-22 拍的口径「**机器可换**」,这条**不构成否决**,只构成**前提**:**K8S 方案的前提是加机器(或直接用托管 K8S)。**
#### §3.10.5 隔离强度是"升"还是"降"?必须分两栏(⛔ 别笼统说"更强")
| | **现状(uid + bwrap + cgroup)** | **K8S(namespace + RBAC + PodSecurity)** |
|---|---|---|
| **管理面隔离** | 自研,单机够用 | ✅ **更强**(有标准、可审计、RBAC 细到动词) |
| **数据面隔离** | uid + mount/PID ns + iptables 端口守卫 | ⚠️ **默认是"共享内核"** —— Pod 之间靠 namespace / cgroup,**不是内核级隔离**;要真隔离得上 **gVisor / Kata / 独立节点池** ⇒ 复杂度与成本上量级 |
| **跨机能力** | ❌ 没有(§2.2「跨节点迁移未设计」) | ✅ **有**(调度就是迁移) |
⇒ **净判定**:管理面 + 跨机是**净变好**;数据面**取决于上不上强隔离运行时**。
#### §3.10.6 ⭐ 本轮最有价值的一条:**K8S 的声明式模型,与我们说的"分水岭"是同一个分形**
前面那句分水岭:**插件做的是「决定要什么 + 说出来」;引导层做的是「把那件事真的实现」。**
K8S 的模型**逐字同构**:**「写一个 CR(期望态)」vs「controller reconcile(执行)」。**
⇒ **所以 K8S 不解决"插件不能起实例",但它把"插件能做什么"抬高了**:从"只能调我们私有 API 请求"变成"**能写一个标准对象表达期望态**"。
**落法(可推翻):平台 = 一个 Operator + 一个 CRD**
- **CRD**:`DshInstance`(租户实例的期望态)
- **Operator**:watch CRD ⇒ 建 Pod / Service / PVC / NetworkPolicy ⇒ **回报状态**;用 K8S 原生 `Lease` 选主 ⇒ **"判决者唯一"有了天然实现**(只有一个 leader 在 reconcile)
- **租户边界**:**一个租户一个 namespace**(=K8S 原生的租户边界)
- **设备侧**:**不进集群**,仍走平台网关(自研 relay 保留)
⚠️ 但 **写 CR 也需要 RBAC 授权** ⇒ "谁有权写 CR"仍要拍板 —— 而这**正是本件那条新缺口(实例令牌 → 只操作自己实例的受限控制面)在 K8S 下的对应物**:**一个只允许 create/patch 自己那个 `DshInstance` 的 Role**。⇒ **同一个问题的两种实现,本质一致。**
#### §3.10.7 §3.10 结论
| 问题 | K8S 能不能解决 |
|---|---|
| 起实例(谁创建隔离单元) | ✅ **能**(换执行体 + 换权限载体:root ⇒ SA)—— **这是它最大的价值** |
| 配额 / 隔离 / 调度 / 跨机迁移 | ✅ **能,且更标准** |
| 守护与自动更新(实例没起也要跑) | ✅ **部分能**(`DaemonSet` / `initContainer` / `CronJob`) |
| **插件能不能自己起实例** | ❌ **仍然不能**(门槛从 root 换成 RBAC,**门还在**) |
| 自举(第一个被授权的身份) | ❌ 不解决 —— **同一道题换个说法** |
| 租户语义 / 归属 / 账本 | ❌ 不解决,**且会新增"平台库 vs K8S 状态"两套源**(除非 namespace=租户 + Operator 收编) |
| 设备侧互联 | ❌ **完全不解决**(设备不是 Pod) |
| 单机成本 | ⚠️ **净变差**,前提是**加机器** |
⇒ **一句话**:**K8S 能替我们做「操作系统那一半」,替不了「授权那一半」。引导层不会因此消失,只会变薄、变标准** —— 从「本机 root 服务」变成「**集群内被 RBAC 授权的 Operator**」。
---
### §3.11 「插件不能访问网络吗?为什么调不了 K8S API?」—— 一处更正 + 一条重要细化
#### §3.11.0 一句话
**网络这一层通常通;挡住的不是网,是身份。** 我在 §3.10.3 写「没有 kubeconfig / SA token / RBAC」—— **那句说的是「没有身份」,容易被读成「连不上」。此处更正措辞。**
🔴 **而更要紧的是**:**只要引导层在安装时给插件一把限定范围的钥匙,插件就真的能起实例。** ⇒ §3.1「插件不能起实例」在 K8S 下**不再无条件成立**,准确说法要改(见 §3.11.3)。
#### §3.11.1 网络:分三层看,哪层通、哪层不通
| 层 | 状态 | 依据 |
|---|---|---|
| **出网(公网)** | ✅ **通** | bwrap 只 `--unshare-pid`(`orchestrator.ts:1219`),**全仓唯一一处 `--unshare`,没有 `--unshare-net`** ⇒ **网络命名空间共享**;egress 默认 `0.0.0.0/0` |
| **本机回环 `127.0.0.0/8`** | ⚠️ **受限** | 实测:**插件被 nft 拒绝连 `127.0.0.0/8`(连它自己 spawn 的 gateway 都连不上)⇒ 该插件改用 unix socket**(`orchestrator.ts:863`) |
| **私网段**(K8S API 常在这里) | ⚠️ **按配置语义被排除** | `egressCidrs` 注释(`config.ts:122-124`):「443 egress whitelist CIDRs for per-user DSH Pods(Phase 4 egress 收敛);empty = keep the current `0.0.0.0/0`(**except private ranges**)」⇒ **收敛方向恰好会挡住私网里的 API server**。⚠️ 这是**配置语义**,本轮未实测其生效范围 |
⇒ **所以你这一问是对的:不是网络问题。** 大多数网络布局下,**"能连到 API" 是通的**。
#### §3.11.2 真正挡住的:**不是网,是身份**(认证 + 授权两道)
连上了,K8S API server 会依次问两件事:
| 道 | 要什么 | 插件为什么没有 |
|---|---|---|
| **认证**(你是谁) | client cert / **SA token** / OIDC token 之一 | 🔴 **现状连"载体"都没有** —— **SA token 是挂给 Pod 的**(`/var/run/secrets/kubernetes.io/serviceaccount/token`),而我们的实例**是 systemd scope,不是 Pod** ⇒ **没有地方去挂这把 token** |
| **授权**(你能做什么) | Role / ClusterRole + RoleBinding | 🔴 **K8S 的 ServiceAccount 默认零权限** —— 认证过了、没有 RoleBinding 一样 `403`。⇒ **必须由"别人"来绑** |
| 准入(允不允许) | PodSecurity / admission webhook | 另算 |
⇒ **所以现象是 `401 / 403`,不是"连不上"。** ⇒ **K8S 的门是身份门,不是网络门。**
#### §3.11.3 ⭐ 这一问带出的重要细化:**给了钥匙,插件真的能起实例**
**做法**:实例以 **Pod** 形态跑,挂一个 **ServiceAccount**,绑一个 **Role** —— 只允许它在**自己那个 namespace** 里 create / patch **自己那个 `DshInstance`**(或 Pod)。
⇒ 这时**插件是真的在"调用 K8S API 起实例"**,中间**没有我们的私有 API 代理**。
⇒ **所以 §3.1 那句「插件不能起实例」在 K8S 下不再无条件成立。** 准确说法要改成:
> **插件不能「自己获得权限」;但只要引导层在安装时给它一把限定范围的钥匙,它就能在范围内自主操作。**
> ⇒ 分水岭从「**谁执行**」细化为「**谁定义权限范围**」。
⚠️ **这不推翻更早的结论,只是把话说准**:
- **「说出来」vs「实现」仍成立** —— 插件写期望态(CR / Pod),**真正把它跑起来的是 K8S**(api-server → scheduler → kubelet)⇒ §3.10.6 那条"分形"不变
- **自举仍成立** —— 那把钥匙**必须由外部先给**(管理员 / 安装脚本 `apply` 一份 SA + Role + RoleBinding)⇒ **第一个身份仍由人给**
⇒ ⇒ **同一件事:门在,钥匙不在。** 而「插件不能起实例」的**准确版本**是:**「插件不能自己拿到钥匙」**。
#### §3.11.4 为什么现状下没有这把钥匙(三条,都不是"网络")
1. **没有载体** —— 实例是 **systemd scope 不是 Pod** ⇒ **没有地方挂 SA token**
2. **现有的那把钥匙不是这把** —— `x-dsh-im-instance-token`(`im/instance-token.ts`)是**平台自研的内存随机串**,与 K8S 完全无关,且**只服务 IM**(`imAuth`)、跨区拒绝、fail-closed ⇒ **跨不到任何控制面**
3. **就算挂上了,默认权限也是零** ⇒ 回到"必须有人绑 Role"
⇒ **今天缺的是「钥匙」,不是「网线」。**
#### §3.11.5 §3.11 结论(含对 §3.1 适用域的收窄)
| 问题 | 答案 |
|---|---|
| 插件能访问网络吗 | ✅ **能**(沙箱共享网络命名空间;只有本机回环与私网另有收紧) |
| 插件能"连到" K8S API 吗 | ✅ **多数布局下能** —— ⚠️ **§3.10.3 的措辞在本节更正** |
| 插件能"做事"吗 | ❌ **不能**(没 token 载体、默认零权限、要有权必须别人绑) |
| 能不能让插件**真的**起实例 | ✅ **能,而且这是 K8S 的真价值之一** —— 但**钥匙必须由引导层在安装时给**,并**限定到"自己的那个 namespace / 自己的那个实例"** |
| 那 §3.1「插件不能起实例」错了吗 | ⚠️ **在「K8S + 限定 SA」之下不成立**。⇒ 该句的**适用域要写清**:**「在我们这条『插件跑在实例内、无集群身份』的现状形态下不行」**,⛔ 不是"永远不行"(与 §3.7 那条自我约束同源) |
⭐ **对产品方向的正面意义**:**"插件应该能自己起实例"这个直觉,在 K8S 下是对的。** 而且它落地成的正是本件那条缺口 ——「**实例令牌 → 只操作自己实例的受限控制面**」的 K8S 版就是 **限定 namespace 的 SA + Role**。
⇒ **这把 P6 的 A 案多了一条理由**:它让"插件自主"这件事**不必我们自研一套受限令牌**,而是用**标准件**(SA + Role + Namespace)实现。
---
#### §3.11.6 直白回答:**能。** —— 这个功能在插件里怎么写、前置条件是什么
**结论先给**:**在插件里写一段"调 K8S API 起容器"的代码 —— 能做,而且这就是 K8S 下的正常做法。**
我在 §3.10.3 / §3.11.2 说的"不能",指的是**另一件事**:**插件不能自己把"身份"变出来**。这两件事我上一轮混在一段里说了,本节拆开。
**一、整个链条上,真正"必须由外部做好"的只有一环**
| 环节 | 谁做 | 插件能自己搞定吗 |
|---|---|---|
| **网络可达**(连 `kubernetes.default.svc`) | 集群自带(Service + DNS) | ✅ **不用操心** |
| **身份**(token / CA / namespace 三个文件) | **K8S 自动挂给 Pod** | ✅ **不用操心**(前提:实例是 Pod) |
| **授权**(RoleBinding 把 SA 绑到 Role) | **管理员 / 引导层在安装时 `apply` 一次** | ❌ **唯一必须外部的** |
| **镜像**(DSH 那套要能拉) | 安装时配 `dshImage`(**配置项已有**,`config.ts:117`) | ✅ 安装时配好 |
| **调 API 起容器** | **插件自己** | ✅ **这一段就是插件做的** |
⇒ **只有"授权"一环必须在插件之外**;其余要么是集群自带、要么是安装配置、要么就是插件自己干。
⇒ **所以"在插件里做个功能调 K8S API 起容器"完全成立。**
**二、插件里那段代码长什么样**(示意,不是最终实现)
```js
const SA = '/var/run/secrets/kubernetes.io/serviceaccount'
const token = fs.readFileSync(`${SA}/token`, 'utf8') // K8S 挂进来的
const ca = fs.readFileSync(`${SA}/ca.crt`) // 同上
const ns = fs.readFileSync(`${SA}/namespace`, 'utf8') // 同上
await fetch(`https://kubernetes.default.svc/apis/<group>/v1/namespaces/${ns}/dshinstances`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spec: { tenant: me, image: '…', quota: '…' } }),
})
```
⇒ 这就是**插件调用 K8S API 起实例**。没有魔法,也没有额外障碍。
**三、两条实务提醒(不是"不行",是"注意")**
1. **token 是"读进来的",不是"生出来的"** —— 插件不自己造身份,它读一个**外部塞进来的文件**。⇒ 这就是"钥匙是别人给的"的具体形态。
2. **RBAC 划多细由你定** —— 可以细到"只能在自己那个 namespace 里、只能建名字里带自己标识的对象"。⛔ **但别给 `cluster-admin`** —— 那等于把整个集群交给一个第三方插件。
**四、调哪个资源:直建 Pod vs 建自定义对象(这是唯一真需要选的地方)**
| | **直建 Pod** | **建自定义对象(CR)+ Operator reconcile**(推荐) |
|---|---|---|
| 插件做什么 | 直接 POST 一个 Pod | POST 一个期望态对象(如 `DshInstance`) |
| 能不能跑通 | ✅ **能** | ✅ 能 |
| "没人管"的问题 | ⚠️ Pod 是**一次性的** —— 挂了不会自动重建、被驱逐不会补、升级不会滚动 | ✅ Operator 持续 reconcile,把它维持成期望态 |
| 平台库怎么知道 | ⚠️ 插件要回报、或平台去 watch Pods ⇒ **两个真相** | ✅ Operator 从对象状态同步 ⇒ **平台库有唯一一份人类可读的期望态** |
⇒ **两种都是"插件调 K8S API",区别只在调哪个资源。** 推荐 CR —— 它是唯一能让"期望态只有一份"的写法,正对着 §3.10.3 那条"两个真相"。
**五、那为什么现状做不到?—— 因为承载方式还没换**
- 现状实例是 **systemd scope**,**不是 Pod** ⇒ `/var/run/secrets/kubernetes.io/serviceaccount/` **不存在** ⇒ 插件**没有 token 可读**
- ⇒ 要让上面那套成立,**先得把实例承载从 systemd scope 换成 Pod** —— **这正是 P6 的 A 案**
- ⚠️ **顺带校准一个细节**:代码里的预留是 **一个 namespace 装所有用户的 Pod**(`k8sNamespace` 是单数,注释「K8s namespace for **per-user DSH resources**」,`config.ts:114-115`;且已给文件面预留了 **sidecar** 镜像 `controlPlaneImage`,`:118-119`)。⇒ §3.10.6 我建议的「**一租户一 namespace**」是**更强的隔离方案,不是已有设计**,属可选强化。
**六、一句话收口**
> **链条上真正在插件之外的,只有一份授权绑定(RoleBinding)。而 RBAC 恰好是"一次 `apply`、永久生效、可审计、可回收、可限定范围"的东西** —— 这**正是它比"每台机 root + systemd"好的地方**:**特权被集中进一份声明式文件里。**
> ⇒ **所以在插件里做这个功能,不是"不行",是"要先把承载方式换成 Pod,再给那个 Pod 一份限定范围的授权"。**
---
#### §3.11.7 「为什么要做这条授权?它到底起什么作用」
**先回答"K8S 为什么非要这么设计"**
K8S 的 API server 有两个特殊性质:
1. **它是个网络服务** —— 集群内**任何**一个容器都可能连到它
2. **调它就会改变系统状态** —— 建一个 Pod = 占 CPU/内存/磁盘 + **跑任意镜像里的代码** ⇒ **等价于"在那台机器上执行代码"**
⇒ 所以它必须能回答两个**分开的**问题:
| 问题 | 由什么回答 | 我们的处境 |
|---|---|---|
| **你是谁?**(认证) | client cert / **SA token** / OIDC | ✅ K8S **自动挂给 Pod**,不用我们管 |
| **你能做什么?**(授权) | **RBAC** = Role + **RoleBinding** | 🔴 **必须有人先授** |
**为什么不给"认证过就行"**?因为集群里 Pod 成百上千(探针、日志代理、CNI、ingress、各种 job),**如果"在集群里 = 有权建 Pod"**,那**任何一个有漏洞的容器就能把整台机器占满、起挖矿容器、读别人的 Secret**。
⇒ **K8S 的默认是零信任**:**SA 认证过了,权限是空集。** 这就是 §3.11.2 那句"SA 默认零权限"的由来。
**Role 与 RoleBinding 的分工**(这是这条最容易被跳过的设计)
| 对象 | 一句话 | 类比 |
|---|---|---|
| **Role** | 「**有哪些权限**」——一份**名词表**(能对哪些资源、做哪些动词)。**它本身不生效,只是定义** | 像一份"岗位职责说明" |
| **RoleBinding** | 「**把这些权限发给谁**」——一个**转接头**,把 Role 连到 Subject(User / Group / **ServiceAccount**) | 像"把这份职责指派给某人" |
⇒ **为什么分两个**?因为**复用与审计**:
- 一份 Role 可以绑给很多人 ⇒ **改权限只改一处,全体生效**
- 某人的绑定可以换 ⇒ **不用动 Role**
- **审计时看的是 binding** —— "谁有这个权限"一目了然
⇒ 这与 Linux「文件 rwx 位(权限定义)」vs「用户/组归属(权限归属)」是**同一种思路**。
**在我们这个场景里,它的四条具体作用**
1. 🔴 **它就是"插件能不能建实例"的开关本身** —— 没有它,插件那段 `fetch` 拿到 `403 Forbidden`;有它,**同一段代码**就成功。⇒ **同一份插件代码,行为完全由这条绑定决定。**
2. 🔴 **它把"特权"从"机器"搬到了"一份声明式文件"** —— 现状要起实例必须是**那台机的 root**(特权**跟着机器走**);K8S 下是"有没有一条 binding"(特权**跟着声明走**)。
3. 🔴 **它划出插件的权力边界,而且能划得很细**
4. 🔴 **它把"授权"从代码里挪出去了** —— 插件代码里**不写任何权限判断**(写了也没意义,判断权不在它手里)⇒ 于是 **换授权 = 改一份 yaml 重新 `apply`,不用改插件、不用重启实例、不用重发版本**。
**这段 yaml 长什么样**(带注解)
```yaml
# ① Role = 权限清单:只允许操作「实例」这一类对象
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: dsh-instance-owner, namespace: tenants }
rules:
- apiGroups: ["dshs.example.com"]
resources: ["dshinstances"] # ⛔ 不含 secrets / pods / nodes
verbs: ["create", "get", "patch", "delete"] # ⛔ 不含 deletecollection 等宽动词
---
# ② RoleBinding = 把这份权限发给「我这个实例的 SA」,且只在 tenants 这个 namespace 内生效
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: tenant-a1net-owner, namespace: tenants }
subjects:
- kind: ServiceAccount
name: tenant-a1net
namespace: tenants
roleRef:
kind: Role
name: dsh-instance-owner
```
⇒ 有了这一对,插件**可以做**:建/改/删**自己的**实例。
⇒ **做不到**:看别的租户的实例、读别人的 Secret、动集群节点、建特权容器。**这就是"钥匙"的具体形状。**
**三种给法,差别是量级级的**
| 给法 | 插件调 API 的结果 | 实质 |
|---|---|---|
| **不给** | `403` | 插件只能"看",做不了事 |
| **给一条限定范围的**(上面那种) | ✅ **只能动自己的实例** | **刚好够用** ← 推荐 |
| **给 `cluster-admin`** | ⚠️ 能动整个集群 | ⛔ **等于把集群交给一个第三方插件** |
⚠️ 为什么单挑 `cluster-admin` 说:它等于**把现状的 root 特权原封不动送出去,还多送了一层跨租户能力**(`cluster-admin` 能读**所有** namespace 的 Secret ⇒ **能读别的租户的凭据**)⇒ **这会把多租户从"四层隔离"降成"数据被读走"** —— 比 §3.9 那条"嵌套退化"更严重(进程隔离还在,但**数据已经泄露了**)。
**谁来做这件事?—— 回到"自举"**
- 这条 binding **必须存在在插件运行之前** ⇒ **只能由管理员 / 安装脚本在装的时候 `apply`**
- ⇒ 这就是"自举"在 K8S 下的具体形态:**一把钥匙,装的时候给一次,之后插件自己用。**
⭐ **由此得到一个之前没说透的结论**:
> **在 K8S 形态下,「引导层」会退化成「一份授权 + 一个 reconcile 循环」。**
> 它不再是"每台机上跑着的特权服务",而是**一次 `apply` 的产物 + 一个 Operator**。
⇒ 这正是 §3.10.7 那句"引导层只会变薄、变标准"的**具体含义**。
**一句收口**
> **这条授权的作用,一句话:它把"插件能不能自己开实例"这件事,从"取决于它跑在谁的机器上",变成"取决于有没有人给它签这一张纸"。**
> ⇒ 而签这张纸**只需一次**,之后**可审计、可回收、可收窄,且不用碰代码**。
---
### §3.12 「按新规划,多租户 / 模型管理 / 技能插件管理 / 覆盖网络 / IM 会话能不能装进一个插件?」
> **用户原话(2026-09-26 13:0x)**:「明白这个意思就行 K8S只是一种实例部署方案,按照这个规划 可以把现有多租户,模型管理,技能插件管理,覆盖网络,IM会话等底层框架 放到一个插件中了吗」
#### §3.12.0 一句话判定
🔴 **能 —— 而且你这句话本身就是答案的一半。**
你说「**K8S 只是一种实例部署方案**」,等于把上一轮最难的那一块(**起实例**)从插件里**摘出去**了。摘掉之后,你列的这五块**全部是"业务面",不是"承载面"** ⇒ **能进一个包。**
⚠️ 但有一句必须分清:**「放进一个包」和「全都能自己干完」是两件事。** 五块里每一块都有**一个极小的内核**留在外面,合计**只剩三件**(§3.12.5)。
#### §3.12.1 为什么这次能,而第一轮说不能
| 轮次 | 问的是 | 判定 |
|---|---|---|
| **第一轮** | 插件能否承**整个项目从开始到现在的功能结构** | ⛔ **不能** —— 因为那里面含「承载面 6 件」 |
| **本轮** | 按新规划,五块**底层框架**能否装进一个插件 | ✅ **能** —— 因为这五块**全是使用面** |
⇒ **差别不在插件的能力变了,在问题问得准了**:
> **第一轮问的是「插件能不能让平台*存在*」;本轮问的是「插件能不能承平台的*业务*」。前者不行,后者行。**
⇒ 这也解释了为什么你会觉得"看起来是成立的"(§3.8 你提第一个方案时)—— **直觉是对的,只是当时那个方案里混进了一件不属于插件的事。**
#### §3.12.2 五块逐块
| 块 | 业务逻辑能进包吗 | 🔴 唯一留在外面的那个内核 | 跑在哪 |
|---|---|---|---|
| **多租户** | ✅ 能(租户列表 / 配额 / 成员 / 账单 / 界面) | ⚠️ **"创建隔离实例"这个动作本身** | 🔴 **平台进程** |
| **模型管理** | ✅ 能(配置 / 凭据 / 共享授权 / 界面) | ⚠️ 无额外内核 —— 但**必须跑在实例内** | 实例内 |
| **技能插件管理** | ✅ 能(上传 / 检测 / 建库 / 启用 / 更新 / 界面) | ⚠️ **把包搬进来**(实例没起时也要跑) | 平台 + 实例 |
| **覆盖网络** | ✅ 能(看哪些区 / 选加入 / 跨区可见性 / 界面) | ⚠️ **取身份(入网凭据)** | 主机级(引导层) |
| **IM 会话** | ✅ **已经在跑** —— 唯一「接入四面」齐全的一条 | ⚠️ 无(内核在平台、插件在实例,已走通) | 平台 + 实例 |
⇒ 🔴 **五块里有四块的"最小内核"是同一类东西:要么需要比插件更高的权限,要么要发生在插件存在之前。**(=§3.0.1 那三条判据:**时序 / 身份 / 存活**)
#### §3.12.3 🔴 本轮的第二个核心结论:**这五块不在同一个进程里**
实证(09-25 审计):**插件跑在「用户实例内」,IM 内核跑在「平台进程」⇒ 跨进程。**
| 块 | 天然归谁 | 为什么 |
|---|---|---|
| **多租户** | 🔴 **平台进程** | 要写**平台库**(租户 / 租约 / 归属 / 配额)—— 实例内的插件**连不到平台库、也没有平台权限** |
| **模型管理** | 🔴 **实例内** | 用户自配模型**只有实例内可用**(已实测) |
| **技能插件管理** | 平台(上传/检测/建库/投放)+ 实例(加载/使用) | 两半 |
| **覆盖网络** | 主机级(身份/隧道)+ 实例(界面) | 两半 |
| **IM 会话** | 平台(内核/房间/消息)+ 实例(插件侧) | 两半 |
⇒ 🔴 **所以"放进一个插件"的准确含义是:一个发行包,两个进程各加载它的一部分。**
- ✅ **今天有通路**:**实例内插件 ⇄ 平台进程**(HTTP 服务面 + 反向通道,IM 已走通)
- ⚠️ **今天没通路**:**平台进程不能装插件**(平台不是"实例")⇒ **要在平台侧加的能力,只能加进平台进程本身,⛔ 不能"装进去"**
⚠️ **对本轮问题的影响**:**多租户那一块的管理动作,⛔ 不可能由一个"插件"实现** —— 插件最多做**界面 + 请求**,真正的动作必须加在**平台进程**里。
⇒ **这不破例,它就是分水岭**,只是把分水岭从「插件 vs 引导层」细化成「**插件 vs 平台进程**」。
#### §3.12.4 「一个包」的收益与代价
**✅ 收益三条**(正是 P1 A 案要的东西):
1. **一次安装 = 全套能力**(用户装官方 DSH + 一个包就有全套)
2. **跨端一致**(服务器壳 / 桌面壳装同一个包)
3. **更新只需更一个包**
**⚠️ 代价三条(都要预先知道)**:
1. 🔴 **一插件一库 ⇒ 五块共用一个库。**
实证 —— `schema.ts:132` 原文「**包名 → 库名**:`@dsh-local/trpg-kit` ⇒ `dshs_pl_trpg_kit`」;`PLUGIN_DB_PREFIX = 'dshs_pl_'`(`:90`);库名形状 `dshs_pl_` + 至多 40 位、**总长 ≤ 48**(`:93`/`:100`);库内表再加 `p_<pluginId>_` 前缀(`diff.ts:248`)。
⇒ 落点:**一个包 = 一个 pluginId = 一个库** ⇒ **五块的表全在同一个库里** ⇒ **一次迁移失败牵动全部五块**。
2. 🔴 **一个包 = 一个版本 ⇒ 更新耦合**:改 IM 要把多租户一起重发,⛔ 不能"只更 IM"。
3. ⚠️ **边界会糊**:五块共库共迁移 ⇒ 表归属、迁移顺序、回滚范围都要人工守。
💡 **缓解(可推翻)**:**一个包做发行单元,包内分五个模块、表名分段**(如 `p_ai1net_im_*` / `p_ai1net_tenant_*`),**只留一个迁移入口、按模块分步执行** ⇒ 拿到"一次安装"的好处,同时保住"能单独回滚一块"的余地。
#### §3.12.5 必须留在外面的**只剩三件**(不是六件)
| # | 留在外面的是什么 | 归谁 | 为什么 |
|---|---|---|---|
| **1** | **起实例** | **部署方案**(K8S / systemd,随你选) | 🔴 **你这句话已经解决了** —— "K8S 只是实例部署方案" ⇒ 谁起、怎么起,**不归插件管** |
| **2** | **取身份**(入网凭据:根 / 签名者 / 节点密钥) | **引导层** | **信任根不能自己给自己发**(自举);⚠️ 且现状 **F1.5 未解** |
| **3** | **把插件包装进去** | **引导层** | 插件**不能在不存在时装自己**(自举) |
⇒ 🔴 **六件 → 三件。**
第一轮那六件里,**「起窗口首屏」「原生网络守护」「起承载服务」三件已经并入第 1 件** —— 因为它们本来就是"部署方案该管的"。**你这一句话,把六件里的三件一起划出去了。**
#### §3.12.6 如果真做,工作量在哪
⚠️ **"放进一个包"是重写,不是搬家** —— 这五块今天的代码分布在:
`src/supervisor/*`(承载)· `src/web/routes/*`(管理面)· `src/db/*`(租户与归属)· `src/net/relay/*`(覆盖网络)· `src/im/*`(IM 内核)+ 实例侧。
⇒ 要变成插件形态,等于把这些**重写成插件接口**,⛔ 不是 `git mv`。
✅ **但已经有现成模板**:**IM 那一块是今天唯一「装配 / 运行 / 取数 / 回调」四面齐全的** ⇒ 其余四块**照着 IM 的接入方式做**,是最短的路。⚠️ 残留两条(不校验"插件是否真在该实例启用";一条实例只该有一条常驻长轮询)已在 §4 登记。
#### §3.12.7 收口
> **能进一个包,但要分两半装:一半装进实例(界面 + 业务逻辑),一半长在平台进程里(要写平台库的动作)。**
> **包外面只剩三件:起实例归部署方案,取身份和把包装进去归引导层。**
⭐ **一句话记法**:
> **插件承"要什么",部署方案承"跑起来",引导层承"怎么开始"。**
🔴 **第一轮那个"六件承不了"的结论没有变** —— 变的是:**这六件现在只剩三件,而其中最大的那件(起实例)你已经用一句"K8S 只是部署方案"划到外面去了。**
---
## §4 缺什么(汇总)
| 缺什么 | 属谁 | 状态 |
|---|---|---|
| **agent cli 接入面**(含"实例 → 平台"身份通路) | 新设计 | 🔴 零实现,**本件新具名** |
| **拉取器 + 自动更新的执行者** | 引导层 | 服务器侧已落地(§2.1 情形 3、4 的能力已在);**桌面侧拉取器**已交对接单待实现 |
| **每插件令牌**(治"插件自称身份") | 投放线 + IM 线 | 已具名;本期逐次审计兜底 |
| 🔴 **实例令牌 → 「只操作自己实例」的受限控制面** | 平台侧(新) | **本件新具名**(§3.1 C 条):让插件能表达"起/重启/停止**我自己**的实例"。补法 = 给实例令牌一条**受限面**,⛔ 不给通用控制面权限 |
| 🔴 **平台侧插件宿主**(让同一份包在**平台进程**里也能跑) | 平台侧(新) | **本件新具名**(§3.12.3):现状**平台不是"实例"⇒ 装不了插件** ⇒ 五块里"要写平台库"的那一半(多租户等)**只能加进平台进程本身**。⇒ 没有它,「一个包两半装」的**平台那一半落不了地** |
| **设备读平台插件包权限** | 🔴 **待用户拍板** | 上轮已问,**尚未答复** |
| **插件数据面部署到 47 / 106** | 投放线 | 代码已落地,**未部署** |
| **实例跨节点迁移(承载归属)** | ⛔ 未设计 | 本线只登记,⛔ 不立项 |
| **包签名校验** | 跨线 | 只登记,不设计 |
| **定稿 F1.5**(签名清单序号,治 G1 重放 + G6 撤根) | 覆盖网络线 | **一切前置** —— 任何"下发签名清单"的动作都排在它之后 |
---
## §5 待拍板(⛔ 顺序不可颠倒)
> 下面是累计台账。**P1 是本件判定的直接产物**("插件承什么"的答案取决于"插件规划走不走");**P6 由 §3.10 新增**(承载实现这条轴)。
**P1 —— 底层插件这条路走不走?**
一句话:**要不要按「薄壳(官方 DSH)+ 引导层 + 一个能力包」这个形态做下去**(而不是继续在平台侧堆界面)。
为什么需要你定:它决定后面所有界面代码写到哪个进程里;一旦铺开再改,等于界面全重写。
**A 案(走)**:按三层形态做(优点:用户装官方 DSH + 一个包就有全套能力,跨端一致,更新只需更一个包;缺点:引导层 6 件要单独维护,首装包必须匿名可取,桌面/服务器两条壳要分别实现)。
**B 案(不走)**:维持现状(平台门户 + 实例内各自为政)(优点:不动现有结构,风险最低;缺点:每个端各自实现一套界面,更新只能按端推,用户看到的"能力"始终是拼出来的)。
**我的倾向**:A(可推翻)。
**P2 —— 设备要不要获得「读取平台插件包」的权限?** ⚠️ **上轮已问,尚未答复**(权限面,属红线)
**P3 —— 节点对外提供哪些服务?**(不选就等于节点只能跑实例,不能对外供能)
**P4 —— 跨用户要不要打通?**(不同意 ⇒ 保持结构性隔离,跨用户只能走平台)
**P5 —— 平台要不要做成用户可自建?**(与 P1 不同轴;倾向 A 案分两步:先做选主,「升根」挂起到撤根机制有结论;整体排在 **F1.5** 之后)
**P6 —— 实例的承载走不走容器编排(K8S)?**(与 P1 **正交**,可并行评估;见 §3.10)
一句话:**租户实例是继续用我们自研的「本机 root + systemd + setuid」起,还是改成「K8S API + Pod」。**
为什么需要你定:**它要加机器**(本机 2 核 / 1870 MiB,装不下 K8S 再装实例)⇒ 属**花钱与资源承诺**;同时它决定"隔离靠自研还是靠标准件"、"实例能不能跨机调度"。
**A 案(走 K8S)**:平台做成一个 Operator + 一个 `DshInstance` CRD,一租户一 namespace。
(优点:**起实例不再要 root、只要 RBAC**;配额 / 隔离 / 调度 / 守护全是标准件;**实例可跨机调度** —— 顺手解掉「跨节点迁移未设计」;多副本控制面选主有现成的 `Lease`;**见 §3.11:让"插件自主操作自己的实例"这件事用标准件(限定 namespace 的 SA + Role)就能实现,不必自研受限令牌**。
缺点:**要加机器**;数据面默认共享内核,强隔离要另上运行时;**新增"平台库 vs K8S 状态"两套源**;这条线**代码里实现是零**,控制面选主与执行体都要新做。)
**B 案(不走,维持自研单机)**:继续 `uid + bwrap + cgroup + systemd`。
(优点:**一台 2 核机就能跑**,单机多租户下隔离等级够用,零新增基础设施。
缺点:**不能跨机迁移**;选主只有位没实现;守护与自动更新仍要自研。)
**我的倾向**:**先 B 后 A** —— 现在按 B 把功能跑通(P1 优先),K8S 这条**登记为"承载演进方向"**,等真出现「必须加机器」或「必须跨机调度」的需求再切;⛔ 不建议现在启动。
**不在本线拍板(只指路)**:定稿 §8.1 骨干资格签发权(F4 前必须定,倾向 B 区签 + 根背书)· 定稿 §8.2 根清单几把 / 谁保管(凭据类,必须你定)。
---
## §6 出处
**代码锚点(本仓 `D:/github/dsh_shenxian`,实测)**
`src/supervisor/{orchestrator,crash-policy,plugin-assembly,lease,spawn}.ts`|`src/worker/agent.ts`|`src/web/routes/{dsh,sessions,desktop,skills,admin,auth,im,plugin-data,business-plugins,overlay,overlay-device,overlay-nodes,whitelist}.ts`|`src/im/{agent-bridge,store,ws,hub}.ts`|`src/net/relay/{directory,registry,network,device-grant}.ts`|`src/fs/user-fs.ts`|`src/db/{schema,repo,pg,types}.ts`|`src/config.ts`
**定稿(唯一权威)**:`dsh-server-docs/02-架构设计/覆盖网络-顶层架构全貌.md`(定稿 2026-09-21)
**本线前序成品件**
- `交付物/平台新增功能总盘点与插件化归属-20260926.md`(§3 官方基线 / §9 基础服务插件形态 / §10 ⛔不可进清单 / §12.5 追问链速览 / §16 为什么不行)
- `交付物/基础服务插件-能做不能做总清单与拍板路径-20260926.md`(§3 ✅能做 / §4 ❌不能做 6 件 / §6 拍板路径)
- `交付物/节点与端-互联互通与调用关系-20260926.md`(§1 七类节点端 / §3 联通矩阵 / §4 三条调用链路 / §6 缺口 7 条)
- `交付物/五种角色选择场景-明确件-20260926.md`(对定稿 §5.3 四条入口路径 A–D)
- `交付物/版本管理与包更新机制-执行报告-20260926.md`(treeSha256 判版本 / 通道 / 钉版 / 回滚实测)
---
**结论落一句**:**七项功能的使用面,一个底层插件基本都承得住;七项功能的承载面,一件都承不了 —— 而承载面恰好是"项目能不能跑起来"的那一半。所以答案不是"能"也不是"不能",是"得三层一起"。**