# 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.` / `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) > /.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.` / `agent-default-model`)。平台**不校验**用户配的自定义 endpoint(实例可出网,nft OUTPUT 默认 accept)—— 这是"用户自己的 key 自己的选择",但**如果**日后要限制出网目标,得另立需求。 2. **admin 自己的实例**同样走"预置共享 key"⇒ admin 若想在实例里用自己的 key,直接在官方模型页改即可(覆盖同一 ref)。 3. **待观察**:用户首次真配一把 DeepSeek key 后,确认 `.credentials.yaml` 里出现的是 `refs.DEEPSEEK_API_KEY`(本文的判据就是它)。