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

20 KiB
Raw Blame History

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)

{
  "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
反向风险(要盯) 用户配了无效/欠费的 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 断言 + 用户实看。

七、回滚

# 后端
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 头注释原文:

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 实例起不来:

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 回滚

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(本文的判据就是它)。