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 一律写「远程服务器」。
20 KiB
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 已删);本机与服务器临时脚本已删。
六、未做 / 待办
- 只支持"换 key",不支持"换 endpoint / 选模型":平台注入的是单一
DEEPSEEK_API_KEYenv,base URL 仍是官方默认。若日后要支持自建网关/代理 endpoint,需要另开 env 注入通道(属新需求)。 - admin 的共享密钥在实例内没有编辑入口:admin 要在门户「密钥管理」改它;实例内「我的密钥」把 admin 自己那把显示为「你配置的共享密钥」(
ownerIsMe)。是否把两者合并成一处编辑,未做(保持"门户管共享、实例管自己"的分工)。 - 未做浏览器截图验收(本机
agent-browserdaemon 起不来),视觉层只有 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。
后果(差点出事):
poc/business-plugins/lib/client.js里同时含两方改动(它的内存模型对齐 + 我的 MyKeysSection)—— 这是我 pack 前才发现的,不是抢锁时就知道的;- 我第一次
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"。
🔴 教训(已写入记忆):
- 抢到锁 ≠ 工作区干净。接手前必须
git status+ 看package.json版本,先判断有没有别人的在途改动; ensure-biz-plugins.cjs按文件名判版本 ⇒ 「同号」会静默跳过。升版本号前必须先确认该号没被别人用过;- 多会话同改一个包时,"谁先铺发"决定线上内容,而 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 未做 / 待观察
settings.yaml侧平台完全不介入:用户选哪家、哪个模型由官方页自己写(providers.<route>/agent-default-model)。平台不校验用户配的自定义 endpoint(实例可出网,nft OUTPUT 默认 accept)—— 这是"用户自己的 key 自己的选择",但如果日后要限制出网目标,得另立需求。- admin 自己的实例同样走"预置共享 key"⇒ admin 若想在实例里用自己的 key,直接在官方模型页改即可(覆盖同一 ref)。
- 待观察:用户首次真配一把 DeepSeek key 后,确认
.credentials.yaml里出现的是refs.DEEPSEEK_API_KEY(本文的判据就是它)。