Files
dsh_shenxian/dsh-server-docs/04-调整方案/85-模型密钥开放给用户自助配置-两层密钥.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

236 lines
20 KiB
Markdown
Raw 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.
# 85 · 模型密钥开放给用户自助配置(两层密钥:自配自用 + 平台共享回落)
- 日期:2026-09-13
- 状态:✅ **已上线并端到端验证**(后端已部署 + 插件 **0.3.7** 已铺发 admin/guest)
- 触发:用户原话「**能否把配置模型密钥的功能开放给用户自己配置,参考原本 dsh 模型设置功能,用户自己配置模型自己用**」+补充「**admin 设置的 用户也可以用,和用户设置的区分开就行**」
- 落点:`src/web/server.ts`(注入层)+ `src/web/routes/auth.ts`(路由层)+ `poc/business-plugins/lib/client.js`(实例内入口)+ `web/portal.html`(门户文案)
> **TL;DR**|① 原先平台是「**统一 KEY**」——`resolveApiKey()` **显式忽略入参 userId**,不管谁 spawn 都注入 admin 那一把;② 本轮改为**两层**:**用户自己的 key 优先,没有才回落管理员的「平台共享密钥」**;③ 路由 `/api/me/keys` 由 `requireAdmin` 放开为 `requireAuth`(用户只能管**自己那一格**,数据层本来就是 per-user 的 `credential_vault`);④ 实例内新增「**我的密钥**」分区(**所有用户可见**);⑤ 换 key 只重启**自己的**实例(admin 改的是共享 key ⇒ 才广播)。
> ⚠️ 本轮同时撞上**并行会话同号冲突**(见 §八),版本从 0.3.6 提升为 **0.3.7**。
---
## 一、先取证:原口径是什么(不是猜)
| 层 | 原实现 | 证据 |
|---|---|---|
| 注入 | `resolveApiKey = async (_userId) => { … getEnabledCredentialKeyRef(admins[0].id) }` —— **下划线参数名就是"故意忽略"**;注释原文「统一 KEY 模式:所有用户共用管理员(admin)设置的启用 key,**用户不可自配**」 | `src/web/server.ts:66-78`(改前) |
| 路由 | `/api/me/keys` GET/POST/DELETE、`/:id/select` **四个全部 `requireAdmin`**;注释原文「普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403」 | `src/web/routes/auth.ts:135-175`(改前) |
| 数据 | **本来就是 per-user**:表 `credential_vault(user_id, key_name, secret_ref, enabled)`,`listCredentialKeys(userId)` / `getEnabledCredentialKeyRef(userId)` / `setCredentialKey`(写入时"把该用户其它 key 全禁用"再启用这把) | `src/db/repo.ts:214-268` |
| 生效 | `DEEPSEEK_API_KEY` 是 **spawn 时注入 env 的快照** ⇒ 换 key **必须重启实例**;原实现一律 `restartAllMains()` | `src/supervisor/orchestrator.ts:524`、`auth.ts:157-160`(改前) |
| 线上现状 | `credential_vault` 只有一行:`admin / deepseek-main / enabled=1`;**guest 一行都没有** ⇒ guest 一直靠"回落 admin"在跑 | 服务器 db 实测(§五) |
⇒ **结论:数据层天生支持 per-user,被卡住的只有「路由权限」与「注入选择」两处。**
⇒ 所以这不是"新建一套体系",而是**把已经存在的能力放开 + 加一层回落**。
## 二、设计(两层,互相独立、互不覆盖)
```
spawn 某个用户的实例
└─ resolveApiKey(userId)
├─ ① 该用户自己的启用 key(credential_vault.enabled=1)→ 有 ⇒ 用它
└─ ② 没有 ⇒ 回落 管理员(role=admin 第一个)的启用 key =「平台共享密钥」
```
| 原则 | 说明 |
|---|---|
| **区分开**(用户原话) | 两层各自独立:用户配了就用自己的;没配就用共享的。用户**不能**改、也**看不到**共享密钥的内容 |
| 删除即回落 | 用户删掉自己最后一把 key ⇒ 自动回落到平台共享密钥,**不需要额外开关**(这是"两层"的应有语义) |
| 刷新粒度跟随影响面 | admin 改的 key **就是**共享 key ⇒ `restartAllMains()`(所有仍在回落的用户都要刷新);**其他人改自己的 ⇒ 只 `restartMain(自己)`**,不动任何人 |
| 零新增后端路由 | 复用既有 `/api/me/keys` 四路由,只改 preHandler 与返回结构 |
| 不返回密钥原文 | GET 只返回元数据(`name / enabled / updatedAt`)+ 共享密钥的 `name/owner/ownerIsMe`;**任何路径都不回显密钥本身** |
### 接口变化(`GET /api/me/keys`)
```jsonc
{
"keys": [], // 我自己的(元数据)
"effective": "shared", // own | shared | none —— 当前**实际生效**的来源
"shared": { "available": true, "name": "deepseek-main", "owner": "admin", "ownerIsMe": false }
}
```
> `ownerIsMe`:admin 看的是**自己**配的那把 ⇒ 前端文案要区分「我配的共享 key」与「别人配的」,否则 admin 会困惑"为什么我也是'回落'到别人"。
### R5 权限影响评估(**先评估再动手**)
| 维度 | 判断 |
|---|---|
| 是否**扩大**权限? | **否**。用户新增的能力 = 管理**自己那一格**的 key(`WHERE user_id = 自己`,`deleteCredentialKey`/`selectCredentialKey` 都带 `AND user_id = ?`)。**不能**读/改/删他人(含 admin)的 key,也拿不到共享密钥的内容 |
| 是否新增**攻击面**? | 极小:`apiKey` 仍走既有 header-safe 白名单 `^[A-Za-z0-9\-_.]{1,256}$`;`name` 仍 `^[A-Za-z0-9\-_ .]{1,32}$`;写入仍 `encrypt(secret, deriveKey(encryptionSecret))` 后落 `secret_ref` |
| 新增的"用户可触发动作" | 用户能让**自己**的实例重启(`restartMain(自己)`)—— 平台本来就有 `/api/dsh/stop|launch`(requireAuth),不构成新的越权面 |
| 反向风险(**要盯**) | 用户配了**无效/欠费**的 key ⇒ 他自己的实例调不了模型。这是 BYOK 的固有代价,且**只影响他自己**;删掉即回落共享 ✅ |
| 结论 | **不需要**新的权限门禁;仅 admin 侧文案调整为「平台共享密钥」 |
## 三、改动清单
| 文件 | 改动 |
|---|---|
| `src/web/server.ts` | `resolveApiKey(userId)` 改**两级**(抽出 `decryptRef()` 复用解密+损坏兜底);新增注释块说明两层语义与刷新义务 |
| `src/web/routes/auth.ts` | 4 个路由 `requireAdmin` → **`requireAuth`**;新增 3 个内部辅助 `keySourceOf(userId)`(判 own/shared/none)、`sharedKeyInfo(userId)`(共享密钥非敏感信息 + `ownerIsMe`)、`refreshAfterKeyChange(userId, role)`(admin ⇒ 广播;其他人 ⇒ 只重启自己);GET 返回加 `effective` + `shared`;移除已无用的 `requireAdmin` import |
| `poc/business-plugins/lib/client.js` | 新增 `MyKeysSection` + 注册 **`settings.section` `id: my-keys`, order 103**(**无条件注册,不判角色** ⇒ 所有用户可见);词典新增 **20 个 `mk.*` 键**(zh/en 一一对应);复用「系统管理」那套 `.pa-*`(门户同款)样式 |
| `web/portal.html` | 「密钥管理」页语义改名:卡片说明 `全局 API 密钥` → **`平台共享密钥`**;页头副标题 → 「平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)」;空态文案改为指引用户去实例「设置 → 我的密钥」 |
| `scripts/verify-platform-admin-section.mjs` | 断言同步:注册数 2 → **3**(admin)/ 非 admin **2**(含 my-keys);新增 `mk` 分区 order=103、非 admin 可渲染「我的密钥/当前生效」、明示"只重启自己的实例";stub 数据加 `/api/me/keys` |
**实例内新分区(「我的密钥」)结构**:`① 我的密钥`(列表:名称 + 启用/删除;下方添加行:名称 + `sk-…` + 添加;附加密存储说明)+ `② 当前生效`(一行状态 + 依据来源的描述 + 「改 key 只重启你自己,其他用户不受影响」提示)。
## 四、出厂与铺发
- 平台:本机 `tsc` → 传 `src/web/{server.ts,routes/auth.ts}`(**转 LF**)→ 服务器 `npm run build` → `systemctl restart dshs`
- 门户:`web/portal.html` 直传 `/opt/dshs/web/portal.html`(静态页,无需重启)
- 插件:**`business-plugins-0.3.7.tgz`**(45,400 B)→ `scp /opt/dsh/artifacts/` → `ensure-biz-plugins.cjs --all --restart` ⇒ admin/guest 均 0.3.7;`sha256 = 16bb37dbcc13…` **本机 = artifacts = 两实例 `.dsh-stage` 三方一致**
- 回滚件:`/opt/dsh/backups/server.ts.bak-20260913-byok`、`/opt/dsh/backups/auth.ts.bak-20260913-byok`
## 五、验收(**改完必须在真环境跑一次** —— 本次做了端到端)
### 5.1 静态层
`npm run verify` **全绿**(含并行会话新增的 `verify-mem-model.mjs`);`node --check` 通过;`tsc --noEmit` rc=0。
### 5.2 真环境端到端(**R4 合规**:临时 session 直插,用完即删,未用真实账号登录)
用 `role=active`(guest)的临时 session `curl`:
| 步骤 | 结果 |
|---|---|
| ① `GET /api/me/keys`(guest) | **HTTP 200** `{"keys":[],"effective":"shared","shared":{"available":true,"name":"deepseek-main","owner":"admin","ownerIsMe":false}}` ← **改前这里应是 403**,这条即证明放开生效 |
| ② 对照:未登录 | **401** ✅ |
| ③ `POST /api/me/keys`(假 key `sk-test-byok-e2e-0000`) | **200**,返回 key 对象 |
| ④ 再 `GET` | `effective` 由 `shared` → **`own`** ✅ |
| ⑤ `DELETE /api/me/keys/:id` | **200** `{"ok":true}` |
| ⑥ 再 `GET` | `effective` 回落 **`shared`** ✅("删掉即回落"闭环成立) |
### 5.3 清理(**测试痕迹必须清干净**)
临时 session `user_agent='byok-e2e-test'` **0 条残留**;`credential_vault` 只剩 `admin/deepseek-main/enabled=1`(测试 key 已删);本机与服务器临时脚本已删。
## 六、未做 / 待办
1. **只支持"换 key",不支持"换 endpoint / 选模型"**:平台注入的是单一 `DEEPSEEK_API_KEY` env,base URL 仍是官方默认。若日后要支持自建网关/代理 endpoint,需要另开 env 注入通道(属新需求)。
2. **admin 的共享密钥在实例内没有编辑入口**:admin 要在门户「密钥管理」改它;实例内「我的密钥」把 admin 自己那把显示为「你配置的共享密钥」(`ownerIsMe`)。**是否把两者合并成一处编辑,未做**(保持"门户管共享、实例管自己"的分工)。
3. **未做浏览器截图验收**(本机 `agent-browser` daemon 起不来),视觉层只有 harness 断言 + 用户实看。
## 七、回滚
```bash
# 后端
cp /opt/dsh/backups/server.ts.bak-20260913-byok /opt/dshs/src/web/server.ts
cp /opt/dsh/backups/auth.ts.bak-20260913-byok /opt/dshs/src/web/routes/auth.ts
cd /opt/dshs && npm run build && systemctl restart dshs
# 插件:把候选池/artifacts 的 business-plugins 回落上一版并重铺
```
> 回滚后:`/api/me/keys` 重新只对 admin 开放;注入回到"统一 KEY"。**已写入的用户密钥会保留在库里**(只是不再被读取)—— 若要彻底清掉需手工删 `credential_vault` 里非 admin 的行。
## 八、⚠️ 本轮踩到的**并行冲突**(如实记录,供流程改进)
**现象**:我在 21:40 抢到全局执行锁后开工,21:50 打包时才从 `package.json` 发现 —— **另一个会话已经把版本改成了 `0.3.6`**(内容是「内存条改读平台真实配额」,见档案 84),且**它已经铺发过 0.3.6**。
**后果(差点出事)**:
1. `poc/business-plugins/lib/client.js` 里**同时含两方改动**(它的内存模型对齐 + 我的 MyKeysSection)—— 这是我 **pack 前才发现的**,不是抢锁时就知道的;
2. 我第一次 `npm pack` 出的 0.3.6 是**我的内容**,而 `ensure-biz-plugins.cjs` 按**文件名**判版本 ⇒ 输出「已是 business-plugins-0.3.6.tgz → **跳过**」⇒ **实例里其实还是它那版、artifacts 里已是我的版** ⇒ 典型的「**同号不同内容**」(这正是本项目三铁律里"同一名字不能既做脱敏目标又做公开值"的同一类陷阱)。
**处置**:
- 拒绝"同号不同内容",**提升为 0.3.7** 重新铺发(0.3.6 → 0.3.7 已实测生效);
- 出厂前跑**全量** `npm run verify`(含它挂的 `verify-mem-model.mjs`),**两方改动一起把关**,全绿才发;
- 本档案如实记录,并在 `package.json` 描述里注明"原拟 0.3.6,因并行同号冲突提升为 0.3.7"。
**🔴 教训(已写入记忆)**:
1. **抢到锁 ≠ 工作区干净**。接手前必须 `git status` + 看 `package.json` 版本,**先判断有没有别人的在途改动**;
2. **`ensure-biz-plugins.cjs` 按文件名判版本** ⇒ 「同号」会静默跳过。升版本号前必须先确认该号没被别人用过;
3. 多会话同改一个包时,**"谁先铺发"决定线上内容**,而 artifacts 可能已被后来者覆盖 ⇒ 铺发后**必须回读实例内的 `package.json` 版本 + sha256 三方比对**(本轮就是这么发现的)。
---
## 九、修正(2026-09-13 22:0x–22:2x):**改用官方「模型」页,不仿制** —— 并顺带炸出两处平台侧硬约束
> 用户追加要求(原话):「需要参考 **dsh 原始的配置密钥页面格式** 去开发页面,**那里面是可以选模型厂家的**」+「**界面交互都要和官方的一摸一样**」
### 9.1 为什么不"仿制"而要"直接用官方页"
「一模一样」这个要求,**只有用本体才能满足**(仿制品永远有差异)。于是先去读官方那个包:
| 事实 | 证据 |
|---|---|
| 包在 | `/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-ui-settings-models`(**官方代码,只读**) |
| 它支持什么 | `ProviderEditor`(每家一张卡:**一个 write-only API key 输入** + 折叠的「自定义设置」= **baseURL** + DeepSeek 的模型目录)+ `CustomProviderCard`(**声明官方目录里没有的厂家**:OpenAI 兼容网关 / 自建服务 / 更新更快的厂家,必填 endpoint + protocol + ≥1 个模型) |
| 数据来源 | `remote.llm` / `remote.settings` / `remote.credentials`(**dsh 内部通道,不是外网**)⇒ 之前补丁注释里"provider 目录在此环境不可用"的判断**已不成立** |
| 配置落点 | `settings.yaml`(`providers.<route>` / `agent-default-model`)+ `$DSH_HOME/.credentials.yaml`(凭据) |
| 平台原本 | `ensure-role-profile-patch.cjs` 把它 `disabled: true`(理由:「模型 KEY 由管理员统一管控」+「避免用户绕过统一 key」) |
**实测放开**:给 guest 删掉该禁用行 → 重启 → 日志出现 `POST /api/session/modelCatalog` **200**(用户当场在操作界面)⇒ **官方页在平台环境完全可用**。
### 9.2 🔴 平台侧两处硬约束(**决定了实现方式**,都是读官方源码挖出来的)
**(1) env 优先级最高** —— `dsh-credentials-local` 头注释原文:
```text
inherited process environment (read-only, wins)
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
> <invocation cwd>/.env (read-only fallback)
> $DSH_HOME/.env (read-only fallback)
```
**(2) 更狠的:`write()` 里有 `assertUnshadowed()`** —— 只要 env 存在同名 ref,用户在官方模型页**保存会直接抛错**:
> `credentials-local: "DEEPSEEK_API_KEY" is supplied read-only by the launching environment, so set would be shadowed; unset it in the shell you start dsh from instead`
⇒ **结论:只要平台继续注入 `DEEPSEEK_API_KEY` env,用户就被锁死在"不能自配 DeepSeek key"**(能配自定义厂家,但配 DeepSeek 报错)。这与"开放自配"直接冲突。
### 9.3 所以改成:**平台不再注入 env,改为把共享密钥预置进凭据文件**
`src/web/server.ts` 的 `resolveApiKey(userId)` 语义整体换掉:
| | 改前(§三~§七) | 改后(本节) |
|---|---|---|
| 共享 key 怎么给 | spawn 时注入 `DEEPSEEK_API_KEY` **env** | spawn 时**写入** `$DSH_HOME/.credentials.yaml` 的 `refs:` 段 |
| 返回值 | 返回 key 明文(⇒ 被注入 env) | **恒返回 `null`**(不注入 env —— 这是刻意的,不是"没有 key") |
| 用户自配 | 被 env 静默盖掉 / 保存报错 | **直接覆盖同一个 ref**,官方页保存成功、立即生效 |
| 用户没配 | 用共享 key | 平台预置的共享 key ⇒ 照旧能用 |
写入函数 `ensureRefInCredentials()` 的保守约束:① 只在 `refs:` **没有**该 ref 时写(用户配过绝不碰);② **只在 `version: 1`** 的文档上插入,不重排、不重写其它行;③ 写前备份;④ 写完 `chown` 给 home 属主(否则 600 权限下实例读不了自己的凭据文件);⑤ 认不出的布局**宁可不动**。失败时**退回 env 注入**保底。
**同一轮撤掉「我的密钥」分区**(`poc/business-plugins` **0.3.8**):官方页已承担该职能,两套入口并存会互相干扰(平台 vault 与官方凭据是**两个存储**,用户会分不清谁生效)。
### 9.4 🔴🔴 本轮**真事故**:备份文件落进 home ⇒ 实例崩溃循环(已修)
第一次实现时,写前备份落在 **`$DSH_HOME/.credentials.yaml.bak-platform`**(root 属主、600)。结果 guest 实例**起不来**:
```text
Error: EACCES: permission denied, watch '/var/lib/.../home/.credentials.yaml.bak-platform'
at chokidar ... NodeFsHandler._watchWithNodeFs
[crash-restart] {"event":"instance-restart", ..., "attempt":5, ...}
```
**真因**:dsh 用 **chokidar watch 整个 home 目录**;只要 home 里出现一个**实例身份读不了**的文件,watcher 就 `EACCES` ⇒ **进程直接退出** ⇒ 崩溃自愈反复重试(attempt 1→5)。
**修复**:备份改落**平台自己的目录** `/opt/dsh/backups/`(实例看不到),并补了**同族隐患检查**(扫 home 里 root 属主的文件)。
**用户影响**:约 1 分钟(22:14–22:15),期间 guest 实例反复重启;**已恢复**且后续验证 0 崩溃。
> **教训(已写进记忆)**:**用户 home 是 dsh 的 watch 域 ⇒ 绝不能往里放实例读不了的文件**。任何"平台侧顺手在 home 里放个东西"的动作,都要先问「实例能不能读」,否则就是崩溃循环。
### 9.5 验证(真环境,逐条实测)
| 检查 | 结果 |
|---|---|
| guest 的 `cordis.patch.yml` 里 `ui-settings-models` 禁用行 | **0 处** ⇒ 模型分区已对用户可见 |
| 用户操作官方页(`/api/session/modelCatalog`) | **200** ✅ |
| spawn 后凭据文件 | 被**预置** `refs: DEEPSEEK_API_KEY: <…>`,原 `records:` 内容**完整保留** |
| 凭据文件属主 | `dsh-eeccbc638afc46bdb663`(**实例 uid,非 root**)⇒ 实例可读 |
| 实例进程 env 里 `DEEPSEEK_API_KEY` | **0 条**(改前是 1 条)⇒ 不再注入 |
| 实例稳定性 | 起后 30s+ 稳定运行,`instance-restart` 0 次 |
| 备份落点 | `/opt/dsh/backups/credentials-home-*.yaml`(**不在 home**) |
| 抹掉 `refs` 段再 spawn | **又被正确重新预置**(写入分支闭环) |
### 9.6 回滚
```bash
cp /opt/dsh/backups/server.ts.bak-20260913-byok /opt/dshs/src/web/server.ts # ⚠️ 这是 §五 那版(两层 vault 版),如需回滚到"env 注入"需另取
cd /opt/dshs && npm run build && systemctl restart dshs
# 插件回落 0.3.7(含「我的密钥」分区);并给用户 patch 重新加回 models 禁用行
```
> ⚠️ **回到"env 注入"意味着用户在官方模型页**保存 DeepSeek key 会报错**(§9.2)—— 回滚前先想清楚要不要这个副作用。
### 9.7 未做 / 待观察
1. **`settings.yaml` 侧平台完全不介入**:用户选哪家、哪个模型由官方页自己写(`providers.<route>` / `agent-default-model`)。平台**不校验**用户配的自定义 endpoint(实例可出网,nft OUTPUT 默认 accept)—— 这是"用户自己的 key 自己的选择",但**如果**日后要限制出网目标,得另立需求。
2. **admin 自己的实例**同样走"预置共享 key"⇒ admin 若想在实例里用自己的 key,直接在官方模型页改即可(覆盖同一 ref)。
3. **待观察**:用户首次真配一把 DeepSeek key 后,确认 `.credentials.yaml` 里出现的是 `refs.DEEPSEEK_API_KEY`(本文的判据就是它)。