From c2b7c5ef71f074ba7bd5dd13472196355198f322 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Sat, 19 Sep 2026 13:51:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=85=B1=E4=BA=AB=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=94=B9=E4=B8=BA=E3=80=8C=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E9=80=90=E7=94=A8=E6=88=B7=E6=8E=88=E6=9D=83=E3=80=8D=EF=BC=8B?= =?UTF-8?q?=20=E5=93=81=E7=89=8C=E4=B8=AD=E6=96=87=E5=90=8D=E6=94=B9?= =?UTF-8?q?=E3=80=8C=E8=83=BD=E5=8A=9B=E7=BD=91=E7=BB=9C=E3=80=8D=EF=BC=8B?= =?UTF-8?q?=20=E4=BF=AE=E6=8E=89=E8=B7=A8=E6=9C=BA=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=90=BD=E5=9C=B0/=E8=AF=AD=E8=A8=80=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条线合并入库(同一次部署批次,源码与生产此前已一致): 一、档案 138 · 平台共享模型:管理员逐用户授权(默认关闭) 用户口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭), 用户才能在会话中使用(以及在设置的模型设置页面展示)」。 · DB 迁移 v11:新增 users.shared_model_granted(DEFAULT 0 = 默认关闭)。 ⚠️ 刻意**不**复用 v6 的 shared_model_enabled —— 那是用户侧偏好(用户能自己关,默认 1), 而本需求要的是**管理员门禁**;共用一列则用户点一下就给自己授权,门禁形同不存在。 生效 = granted ∧ enabled(server.ts#sharedLandingRows)。 · 新路由 POST /api/admin/users/:id/models/shared(requireAdmin)+ 审计 shared_model_grant + **尽力而为**重启该用户实例(租约被占/实例未运行都不算失败)。 · admin 用户列表新增「共享模型」列;用户侧 /api/me/keys 增 shared.granted / sharedModelGranted; 插件 0.3.24:未授权时「平台共享模型」整块不渲染。 二、顺带修掉一个既有真缺陷:平台侧写用户 home 必须走 UserFs(用户卷在 worker 上) landModels 原先 join(owner.home_dir, …) + 本机 fs ⇒ 对「实例不在控制面本机」的用户 读到空串(**不报错**)⇒ 模型落地一直是**静默空操作**(托管清单还被清空) ——即档案 87 的模型设置页对 guest 这类用户**从未生效**。 · UserFs 新增 readHomeFile/writeHomeFile + 文件名白名单(settings.yaml / .credentials.yaml) · worker agent 新增 /fs/home-read /fs/home-write(只认白名单裸文件名) · landModels 改走 userFs(与"文件面"同一份按归属路由 ⇒ 落地与实例必然同机) · 同轮把 /api/me/locale(档案 102 语言偏好)也改成同一套(原先同样失效) · home-files.ts 抽出 backupHomeFile(备份留平台侧,命名规则逐字不变) 三、档案 139 · 品牌中文名:能力枢纽 → 能力网络(其他语言仍 CapabilityNet) 落点四处:i18n 中文词条 / admin.html 顶栏 / favicon.svg 的 title+aria-label / design.css 注释; test/i18n-brand.test.mjs 期望值同步。档案 137 顶部加"后续"指针,不改历史。 验证(全部真机实测): · 红腿:未授权 → 106 上 guest 的 .credentials.yaml refs 变空(共享 key 被撤) · 绿腿:授权 → key 回来 + 托管清单恢复 ["DEEPSEEK_API_KEY"] · admin 列表带出 sharedModelGranted;用户侧 granted 随授权翻面(true/shared ↔ false/none) · 开关两次均 200(不再假失败);插件实装 0.3.24 且含 gating 字符串 · 语言偏好:106 上 settings.yaml 出现 locale.preference=en(属主=实例属主,既有段逐字保留) · 本机 npm test 226 tests / 225 pass / 0 fail / 1 skipped;四个 verify 脚本全绿 部署:47 推 51 个 lib 产物、106 推 12 个(lib/ 是 gitignore ⇒ 回滚点物化在 /opt/dsh/backups/seq138b-20260919-125345/,逐文件对账 0 不一致;先 106 后 47); 插件 business-plugins 0.3.24(两机 artifacts 与本机 pack md5 一致)。 --- .../137-品牌标识改造-去DeepSeek图形.md | 3 + .../138-平台共享模型-管理员逐用户授权.md | 154 ++++++++++++++++++ .../04-调整方案/139-品牌中文名改为能力网络.md | 55 +++++++ dsh-server-docs/INDEX.md | 4 +- dsh-server-docs/docs-manifest.json | 56 +++++-- poc/business-plugins/lib/client.js | 7 +- poc/business-plugins/package.json | 2 +- scripts/verify-models-render.cjs | 4 +- scripts/verify-platform-admin-section.mjs | 20 ++- src/db/adapter.ts | 6 + src/db/pg.ts | 25 ++- src/db/repo.ts | 28 +++- src/db/schema.ts | 25 +++ src/db/sqlite.ts | 10 ++ src/db/types.ts | 21 ++- src/fs/local-user-fs.ts | 37 ++++- src/fs/remote-user-fs.ts | 16 +- src/fs/user-fs.ts | 35 ++++ src/web/home-files.ts | 34 ++-- src/web/routes/admin.ts | 54 ++++++ src/web/routes/auth.ts | 49 +++++- src/web/server.ts | 60 +++++-- src/worker/agent.ts | 31 +++- test/db.test.mjs | 34 ++++ test/i18n-brand.test.mjs | 12 +- test/local-user-fs.test.mjs | 21 +++ web/admin.html | 26 ++- web/design.css | 2 +- web/favicon.svg | 4 +- web/i18n.js | 5 +- 30 files changed, 770 insertions(+), 70 deletions(-) create mode 100644 dsh-server-docs/04-调整方案/138-平台共享模型-管理员逐用户授权.md create mode 100644 dsh-server-docs/04-调整方案/139-品牌中文名改为能力网络.md diff --git a/dsh-server-docs/04-调整方案/137-品牌标识改造-去DeepSeek图形.md b/dsh-server-docs/04-调整方案/137-品牌标识改造-去DeepSeek图形.md index d3cc912..dead107 100644 --- a/dsh-server-docs/04-调整方案/137-品牌标识改造-去DeepSeek图形.md +++ b/dsh-server-docs/04-调整方案/137-品牌标识改造-去DeepSeek图形.md @@ -1,5 +1,8 @@ # 137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识) +> ⚠️ **后续(2026-09-19)**:中文品牌名已由「能力枢纽」改为 **「能力网络」**(其他语言仍 `CapabilityNet`) +> —— 见档案 **139**。本档正文保留当时的定名,**不改历史**。 + > **一句话**:把我们自己的页面上所有 **DeepSeek 品牌标识**(鲸鱼图标 / 「DeepSeek」文字图形 / favicon) > 换成**平台自有标识** —— 中文「**能力枢纽**」,英语及其他语言「**CapabilityNet**」。 > **触发**:用户 2026-09-19 原话三句 —— diff --git a/dsh-server-docs/04-调整方案/138-平台共享模型-管理员逐用户授权.md b/dsh-server-docs/04-调整方案/138-平台共享模型-管理员逐用户授权.md new file mode 100644 index 0000000..a653ac3 --- /dev/null +++ b/dsh-server-docs/04-调整方案/138-平台共享模型-管理员逐用户授权.md @@ -0,0 +1,154 @@ +# 138 · 平台共享模型 —— 管理员**逐用户**授权(默认关闭) + +- 日期:2026-09-19 +- 状态:✅ **已上线并端到端验证**(控制面 + 前端 + 插件 **0.3.24**;两腿实测:未授权撤销、授权恢复) +- 触发(用户口径,原话):「**调整设计 admin设置的共享模型,需要admin在用户列表中开启(新增选项,默认关闭),用户才能在会话中使用(以及在设置的模型设置页面展示)**」 +- 落点:`src/db/{schema,types,repo,pg,sqlite,adapter}.ts`、`src/web/routes/{admin,auth}.ts`、`src/web/server.ts`、`web/admin.html`、`poc/business-plugins/lib/client.js`(**0.3.24**)+ **顺带修掉一个既有真缺陷**:`src/fs/{user-fs,local-user-fs,remote-user-fs}.ts`、`src/worker/agent.ts`、`src/web/home-files.ts`(见 §五) + +> **TL;DR**|① 共享模型从「人人默认可用(用户可自关)」改成「**管理员逐用户授权**」——新增列 +> `users.shared_model_granted`(**默认 0 = 关闭**),**生效 = granted ∧ enabled**(前者 admin 在**用户列表**里开, +> 后者是用户自己的偏好,保留不动);② 未授权时**设置页连"平台共享模型"那一块都不渲染**(用户原话"开启才展示"); +> ③ 🔴 **顺带发现并修掉一个既有真缺陷**:落地层原先用**本机 fs** 写用户 home,而用户卷在 worker 上 +> ⇒ 对"实例不在本机"的用户,**模型落地一直是静默空操作**(托管清单被清空、目标文件一个字节没动) +> —— 也就是说档案 87 那套「模型设置页」对 **guest 这类用户从来就没生效过**;本档把它改成走 +> `UserFs`(与"文件面"同一份按归属路由),并给 worker agent 补了两个**白名单**端点。 + +--- + +## 一、为什么是"新开一列"而不是改旧列的默认值(本轮最关键的设计判断) + +| 事实 | 依据 | +|---|---| +| 旧列 `users.shared_model_enabled`(v6)语义 = **用户侧偏好**(用户自己能在设置页关掉,默认 1) | `schema.ts` 的 `SQLITE_V6` 注释 + `dashboard`(档案 87 口径②) | +| 用户要的是**管理员门禁** —— "只有 admin 开了,用户才能用" | 本轮触发原话 | +| 若两者共用一列:**用户在自己设置页点一下就把自己"授权"了** ⇒ 门禁形同不存在 | 直接推论 | + +⇒ 故**两列两人**:`shared_model_granted`(admin,默认 **0**)+ `shared_model_enabled`(用户,默认 1,**不变**), +**生效 = `granted ∧ enabled`**(`server.ts#sharedLandingRows`)。缺一不给。 + +> ⚠️ `DEFAULT 0` 让**存量行也一并变 0**(两个方言加列都用默认值回填)⇒ 迁移后所有既有用户都"未授权"。 +> 这正是"默认关闭"的字面语义;代价是 **guest 需要 admin 开一次**才会重新拿到共享模型(见 §四-③)。 + +## 二、改动清单 + +| 层 | 文件 | 改动 | +|---|---|---| +| DB 迁移 | `src/db/schema.ts` | **v11**:`ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;`(两方言同款;注释写清"为什么新开列") | +| 类型 | `src/db/types.ts` | `User.shared_model_granted` + `PublicUser.sharedModelGranted`;`toUser` 用 **`Number(row.x ?? 0) !== 0`** | +| 仓储 | `src/db/repo.ts` | `USER_COLS` 加列、`findSessionWithUser` 补列、新增 `get/setSharedModelGranted`(缺失行按 **false** = 失败关闭) | +| PG | `src/db/pg.ts` | 🔴 **本文件另有一份 `USER_COLS`** —— 加列未同步它 ⇒ `listPublicUsers` 读不到该列、admin 列表恒显示"未开启"(**实测踩到**,见 §六-2) | +| SQLite | `src/db/sqlite.ts` / `adapter.ts` | 转发 + 接口声明 | +| 生效判据 | `src/web/server.ts` | `sharedLandingRows` 三条件(**granted ∧ enabled ∧ 不是那个 admin 本人**);`resolveApiKey` 的回退注入**也判门禁**(失败关闭) | +| 管理面路由 | `src/web/routes/admin.ts` | 新增 **`POST /api/admin/users/:id/models/shared`**(`requireAdmin`,body `{enabled}`)→ 落库 + 审计 `shared_model_grant` + **尽力而为**重启该用户实例 | +| 用户面接口 | `src/web/routes/auth.ts` | `sharedKeyInfo` 加 `granted`;`keySourceOf` 加门禁(未授权 ⇒ `none`);`GET /api/me/keys` 增 `sharedModelGranted` | +| 管理页 | `web/admin.html` | 用户表**新增「共享模型」列**(已开启/已关闭按钮;admin 与 pending 行显示「—」)+ 一行说明 | +| 插件 | `poc/business-plugins/lib/client.js`(0.3.24) | 「平台共享模型」区块改为 **`shared.granted` 为真才渲染**(未授权整块不出现) | +| 测试 | `test/db.test.mjs`、`test/local-user-fs.test.mjs`、`scripts/verify-platform-admin-section.mjs` | 新增 4 条:granted 默认关 ∧ 可开关 ∧ 与偏好互不影响、列表带出该列、**未授权 ⇒ 区块不渲染(正反两腿)** | + +## 三、🔴 三条判据(都是踩出来的) + +1. **门禁类判据一律"失败关闭"**:`getSharedModelGranted` 对"查不到这个人"返回 **false**(而 `getSharedModelEnabled` + 对同样情形返回 true —— 两条**故意相反**,已在单测里钉死)。 +2. **门禁判在"使用点",不判在"归属判据"里**(本轮红腿实测):`sharedDeepseekKey()` 同时被 + ① 「一次性交接」用来**认领**老实现写下的那行 key(这是**归属**问题)② 回退 env 注入(这是**授权**问题)。 + 第一版把门禁加在函数内部 ⇒ 被撤销授权的用户**认不出自己写过的行** ⇒ 那行永远删不掉 + ⇒ "关掉即生效"不成立。**修法**:函数保持不判,门禁挪到 `resolveApiKey` 的调用点。 +3. **授权路由里的"重启实例"必须尽力而为**:授权已落库才是真结果;实例没在跑、或归属租约被别的持有者占着, + 都**不算开启失败**(第一版直接 `await restartMain` ⇒ 租约被占时回 500,admin 界面显示"操作失败", + 而库里其实已经改对了)。 + +## 四、验证(全部真机实测) + +| # | 项 | 证据 | +|---|---|---| +| ① | **迁移 v11 生效** | `SELECT version FROM schema_migrations` ⇒ `11`;`\d users` 出现 `shared_model_granted integer not null default 0`;四个用户该列全 0(默认关闭) | +| ② | **红腿:未授权 ⇒ 共享 key 被撤** | guest(实例在 **w-106**)设为 `false` → 重启实例 → 106 上 `.credentials.yaml` 的 `refs:` **变空**(`DEEPSEEK_API_KEY` 消失)、托管清单 `{"refs":[],"routes":[]}` | +| ③ | **绿腿:授权 ⇒ 恢复** | 设为 `true` → 重启 → `refs: DEEPSEEK_API_KEY: 'sk-…'` **回来**、托管清单 `{"refs":["DEEPSEEK_API_KEY"]}` | +| ④ | **admin 列表带出状态** | `GET /api/admin/users` ⇒ guest `sharedModelGranted=True`(其余 false) | +| ⑤ | **用户侧展示判据翻面** | guest 会话 `GET /api/me/keys`:授权时 `shared.granted=true effective=shared`;撤销后立刻 `granted=false effective=none` | +| ⑥ | **开关不再报假失败** | 撤销/开启两次调用均 **200** `{ok:true, sharedModelGranted:…, restarted:false}`(实例未运行) | +| ⑦ | **插件实装** | 106 上 guest 的 `profiles/web/node_modules/@dsh-local/business-plugins` = **0.3.24**,`lib/client.js` 含 `shared.granted` ×2;`.dsh-stage/` 有 0.3.24.tgz(本机 pack 与 `/opt/dsh/artifacts/` md5 一致 `75f342eb…`) | +| ⑧ | **零回归(本机)** | `npm test` **226 tests / 225 pass / 0 fail / 1 skipped**;`verify-static` / `verify-inject` / `verify-platform-admin-section`(含新 2 条)/ `verify-models-render` 全绿 | +| ⑨ | **语言偏好跨机修复生效** | guest(实例在 **w-106**)`POST /api/me/locale {locale:'en'}` ⇒ 200 `changed:true`;106 上 `settings.yaml` 出现 `locale: preference: en`(属主 = 实例属主,既有段逐字保留);平台侧备份 `settings--.yaml` 同步落地 | + +⚠️ **未做**:无浏览器的 UI 目视(admin 表格多了一列、「共享模型」区块的隐藏效果)—— 留给你看一眼。 +⛔ 未重启任何**正在服务**的用户实例去做验证(验证用的 guest 实例由我自己停/起,收口时回到 stopped)。 + +## 五、🔴 顺带修掉的既有真缺陷:**跨机用户的"模型落地"一直是空操作** + +**怎么发现的**:按上面 §四-② 第一次跑红腿时,guest 的凭据文件**一个字节没动**、而托管清单却被清空了。 +根因链(文件级): + +| # | 位置 | 事实 | +|---|---|---| +| 1 | `server.ts#landModels`(改前) | `join(owner.home_dir, '.credentials.yaml')` + **本机 fs** 读 —— 而 `home_dir` 是**worker 上的路径** | +| 2 | 实测 | 47 上 `/var/lib/dshs/users//` **只有 `ws/`**、没有 `home/` ⇒ 读到空串(**不报错**) | +| 3 | 后果 | `reconcileCredentials('', [], [...])` ⇒ 无变化 ⇒ 不写盘;但 `writeManaged()` 照写 ⇒ **托管清单被清成空**(平台从此"忘记"自己写过的那行) | + +⇒ 影响面**不止本档**:**档案 87 的整套「模型设置页」对"实例不在控制面本机"的用户从来就没生效过** +(guest 的实例在 w-106 ⇒ 用户改模型设置不生效);档案 102 的**语言偏好**(`/api/me/locale`)走的是同一套 +`home-files` 写入,**同样命中这个缺陷**(⚠️ 本档**未修**它,见 §七-3)。 + +**修法**(与既有"文件面"设计对齐 —— `server.ts:848-850` 那段注释原本就写着"文件写到 A、实例起在 B"的教训): + +| 层 | 改动 | +|---|---| +| `src/fs/user-fs.ts` | 新增 `readHomeFile` / `writeHomeFile` + **文件名白名单** `HOME_FILE_NAMES = ['settings.yaml','.credentials.yaml']`(⛔ 只收裸文件名,等于"平台只管自己那两个配置") | +| `local-user-fs.ts` | 本机实现(`0600` + chown 给 home 属主);非白名单名 ⇒ `bad_path` | +| `remote-user-fs.ts` | 走 agent 的 `/fs/home-read` `/fs/home-write`,目标是 `hostIdFor` **钉住的那台机**(与实例同机) | +| `worker/agent.ts` | 新增两个端点(**只认白名单裸文件名**,非法即 400 `bad_path`) | +| `web/home-files.ts` | 抽出 `backupHomeFile()`(**平台侧**备份;`writeHomeFile` 改为复用它,命名规则逐字不变) | +| `web/server.ts` | `landModels` 的读写**全部改走 `userFs`**:`readHomeFile(...) ?? ''` ∧ `backupHomeFile(...)` + `userFs.writeHomeFile(...)` | + +**为什么这是"必须做"而不是"顺手优化"**:不做 ⇒ 用户这条需求("admin 开了才能在会话中使用") +对 guest 这类用户**永远不成立**(授权改了、实例里什么也没变)。R11「只做正向迭代」不允许留这种"看起来做了"的状态。 + +**部署面**(`lib/` 是 gitignore 的 ⇒ 回滚点只能物化,⛔ 别指望 `git checkout`): + +- **106** `/opt/dshs-cluster/lib`:`fs/*` + `worker/agent.*` 共 12 个(回滚点 `/opt/dsh/backups/seq138b-20260919-125345/opt/dshs-cluster/lib`,先抓旧版再覆盖,对账 0 不一致) +- **47** `/opt/dshs/lib`:42 + 3 + 3 个(同上,回滚点同目录 `/opt/dsh/backups/seq138b-…/opt/dshs/lib`);`restart dshs dshs-worker` +- 顺序:**先 worker(106)后 Manager(47)** —— 新端点必须先在位;两版并存时表现为"落地仍不生效"(= 改前状态),⛔ 不会更糟 +- ⚠️ **106 上跑 `ensure-biz-plugins.cjs` 需要补丁**:它 `require('/opt/dshs/node_modules/better-sqlite3')` + 而 106 只有 `/opt/dshs-cluster/node_modules`(且该原生模块在 106 上没为 node 22 编译)⇒ 用**临时副本** + `/tmp/ebp-106.cjs`(只改两处:require 置空、用户清单改由 `DSH_USERS_JSON` 注入,清单取自权威 PG)。 + ⛔ 正本 `scripts/ensure-biz-plugins.cjs` **未动**。 + +## 六、事故 / 踩坑 + +1. **用户卷在远端时,"落地"与"备份"要分开**:备份(平台自己的副本)留在控制面 `/opt/dsh/backups` 正合适; + ⛔ 不要为了备份再往远端开第二条通道。 +2. 🔴 **`pg.ts` 有自己的一份 `USER_COLS`**(与 `repo.ts` 那份是**两份**)⇒ 加列必须**两处都改**。 + 只改 `repo.ts` 时:SQLite 路径正常、**PG 路径(= 生产)读不到新列**,admin 用户列表**恒显示"未开启"** + (而 DB 里其实是 1)—— 这种"两个方言行为不一致"的缺陷只在生产暴露,本档已实测踩到。 +3. **临时会话(`mksess.cjs`)TTL = 10 分钟** ⇒ 验证脚本必须"现建现用";跨多次手工调用必撞 401 + (本轮撞了两次,写成了 `seq138-leg.sh` 一次跑完)。 +4. `scp root@106.54.21.172:` 不通(无对应密钥/别名),要用别名 `test106`。 + +## 七、未做 / 待办 + +1. **UI 目视**留给用户(无浏览器验收已全绿):admin 用户列表多一列「共享模型」、未授权时设置页不出现共享区块。 +2. **`.lock-131/132/133/134/138` 空目录堆积**(占号后没清)—— 文档库卫生,`docs-audit.py` 不报。 +3. ✅ **已修(同轮收口时一并做掉)**:`/api/me/locale`(档案 102 的语言偏好)原先**也走本机 fs** + ⇒ 对远端用户同样失效。改法与 `landModels` **完全同构**(`userFs.readHomeFile('settings.yaml')` + + `backupHomeFile` + `userFs.writeHomeFile`)。 + **实测**:guest(实例在 106)`POST /api/me/locale {locale:'en'}` ⇒ **200 `changed:true`**; + 106 上 `settings.yaml` 出现 `locale: preference: en`、属主 = 实例属主 `dsh-`、 + 既有段(`agent-default-model` / `agent-presets` / `llm-pi-ai`)**逐字保留**; + 平台侧备份同步落地 `settings--.yaml`。 + ⇒ **本档之后,平台侧"写用户 home"只剩 `UserFs` 这一条路**(`readTextOrEmpty` / `writeHomeFile` + 的**本地版**仍保留给 `deployMode=local` 的单机形态与 `home-files.ts` 内部复用)。 +4. ⚠️ **`shared_model_enabled`(用户偏好)现在只在"已授权"时才有可见效果**:用户侧那条 + `POST /api/me/models/shared` 路由**未加门禁**(有意 —— 见 `auth.ts` 该路由注释:让用户能先关掉自己不想用的、 + 也允许先打开,授权一到即生效;且不泄露"管理员是否授权了别人")。若将来要"授权后用户不得自关",需另立需求。 + +## 八、回滚 + +```bash +# ① 控制面代码:从本仓重建 HEAD 版 lib 并覆盖(lib/ 不入仓,⛔ 不能用 git checkout) +git -C <本机仓> show HEAD:src/... # 或直接在 47 侧用回滚点: +cp -r /opt/dsh/backups/seq138b-20260919-125345/opt/dshs/lib/* /opt/dshs/lib/ && systemctl restart dshs +# ② worker(106):同样用该回滚点下的 /opt/dshs-cluster/lib +# ③ 插件:确保产物回落 0.3.23(/opt/dsh/artifacts 保留旧包) +# ④ DB:v11 的 ADD COLUMN 不回滚(SQLite 无 down migration;空列不影响旧代码) +``` +⚠️ 回滚 ① 会把 §五 的跨机落地修复一起回退 ⇒ 远端用户的模型落地重新变成空操作(不致命,但那是既有缺陷)。 diff --git a/dsh-server-docs/04-调整方案/139-品牌中文名改为能力网络.md b/dsh-server-docs/04-调整方案/139-品牌中文名改为能力网络.md new file mode 100644 index 0000000..59424dc --- /dev/null +++ b/dsh-server-docs/04-调整方案/139-品牌中文名改为能力网络.md @@ -0,0 +1,55 @@ +# 139 · 品牌中文名:能力枢纽 → **能力网络** + +- 日期:2026-09-19 +- 状态:✅ **已上线**(静态资源直覆,无需重启;页面与接口双端实测) +- 触发(用户口径,原话):「**把页面上的能力枢纽 改为能力网络**」(同轮先说过「能力领域」,随即更正为「能力网络」) +- 上游:档案 **137**(品牌标识去 DeepSeek 图形,定名「能力枢纽」)—— 本档**只改名,不动结构** +- 落点:`web/i18n.js`、`web/admin.html`、`web/favicon.svg`、`web/design.css`(注释)、`test/i18n-brand.test.mjs` + +> **TL;DR**|中文品牌名 **「能力枢纽」→「能力网络」**;**其他语言不变**(仍 `CapabilityNet`, +> 走 `en` 词条 = 所有其他语言的回退)。改名落在**四处**:i18n 的中文词条、`admin.html` 顶栏文字、 +> **`favicon.svg` 的 `` 与 `aria-label`**(浏览器标签页/读屏可见 —— 最容易漏)、`design.css` 注释。 +> 回归测试与 `verify-static`(含 SVG 真解析)全绿;线上 4 文件两端 md5 一致。 + +--- + +## 一、为什么改这几处(判据:**用户可见面**,不是"字符串出现即改") + +| 落点 | 是否用户可见 | 处置 | +|---|---|---| +| `web/i18n.js` 的 `zh` 表 `brand.name` | ✅ 登录 / 注册 / 唤醒 / 工作台页头 | **改** 为 `能力网络` | +| `web/i18n.js` 的 `en` 表 `brand.name` | ✅ 其他语言**全靠它回退** | **不动**(仍是 `CapabilityNet`) | +| `web/admin.html` 顶栏 `<span class="wordmark">` | ✅ 管理页页头(该页纯中文、未接 i18n) | **改** | +| `web/favicon.svg` 的 `<title>` / `aria-label` | ✅ 标签页提示 / 读屏 | **改** —— 只 grep 改 `.html/.js` 会漏掉它(本轮第一版就漏了) | +| `web/design.css` 第 53 行注释 | ❌ 不可见,但**写的是现行事实** | 顺手更正(留着会误导下一个人) | +| `test/i18n-brand.test.mjs` | — 测试断言 | 六条语言路径的期望值同步 | +| 档案 137 / 本档引用的历史记述 | — | **不改历史**(137 保持原样,仅由 TOP 指针指过来) | + +## 二、验证 + +| 项 | 结果 | +|---|---| +| 品牌回归测试 | `test/i18n-brand.test.mjs` **2/2 通过**(中文三条路径 = 能力网络;英文与未支持语言 = CapabilityNet) | +| 静态校验 | `verify-static.mjs` 全合格(含 `favicon.svg` 段) | +| SVG 真解析 | `xml.etree.ElementTree.parse('web/favicon.svg')` = **OK**(⚠️ 137 那次踩过"注释含 `--` 导致整份 SVG 静默失效",本档只改文本节点,仍逐次复验) | +| 旧名残留 | `grep -rn 能力枢纽\|能力领域 web/ test/ scripts/ poc/ src/` ⇒ **仅剩 i18n 注释里一句"此前用过哪个名"的留痕**(有意保留) | +| 线上 | 4 文件(`admin.html` / `i18n.js` / `design.css` / `favicon.svg`)本机 ↔ `/opt/dshs/web/` **md5 逐一致**;回滚点 `/opt/dsh/backups/seq138-web-20260919-125345/`(4 个旧文件) | +| 零回归 | 全量 `npm test` **226 / 225 pass / 0 fail / 1 skipped** | + +## 三、两个执行细节(都是既有经验的重用,不是新坑) + +1. **行尾按"目标目录既有风格"逐文件对齐**(⛔ 不按 HEAD 判):服务器 `web/` 目录实测 + `admin.html = CRLF` / `design.css = CRLF` / `i18n.js = LF` / `favicon.svg = LF` ⇒ 推 `admin.html` 前先用 + 脚本转 CRLF 并断言 `CR 数 == 行数`(本机工作树是 LF,直接 scp 会把整文件行尾翻掉)。 +2. **改 i18n 的"改名类"改动必须配断言**:品牌词条漏配/写错是**静默失败**(中文用户看到英文名), + 所以 `test/i18n-brand.test.mjs` 用 `node:vm` 跑**仓库里那份真实 `i18n.js`**,断言六条语言解析路径的 + 实际渲染结果 —— 本轮把期望值从「能力枢纽」改为「能力网络」,跑通即证。 + +## 四、未做 / 待办 + +1. 🔴 **`alotbuy.com` 的旧域页面仍会显示旧名?** 不会 —— 旧域已降级为 301 过渡装置(档案 135), + 页面由同一份 `/opt/dshs/web/` 直出 ⇒ 旧域访客看到的也是新名。 +2. ⚠️ **会话页面左上角**(用户实例内的 dsh 会话窗口)不属本仓 `web/`,由官方 `@deepseek-ai/dsh` 的 client bundle + 渲染、受 **R2** 约束 ⇒ 本档不涉及(与档案 137 的范围声明一致)。 +3. ⚠️ **词条语义澄清**:用户曾提到「小写 CapabilityNet」;本档按「中文=能力网络/其他语言=CapabilityNet」实现。 + 若原意是"再加一行小写英文",改动量 = `i18n.js` 一处 + `design.css` 一行(⛔ 不碰 HTML)。 diff --git a/dsh-server-docs/INDEX.md b/dsh-server-docs/INDEX.md index 3e252d6..89e1549 100644 --- a/dsh-server-docs/INDEX.md +++ b/dsh-server-docs/INDEX.md @@ -44,7 +44,7 @@ ## 二、全量清单 -> **状态摘要**(**机器生成,勿手改**):档案 **121** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 20 | 🔄 5 | 🔍 1 | 📋 23 | 🟡 1 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 +> **状态摘要**(**机器生成,勿手改**):档案 **123** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 22 | 🔄 5 | 🔍 1 | 📋 23 | 🟡 1 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 > **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / tier,可 `jq` 先筛后读):**`docs-manifest.json`**;复跑 `scripts/docs-manifest.py` 即刷新。 > 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓|PoC 实证完成(插件 v0|已上线|生效|已实施|已实施 + API 全链路验|已冻结|已落地 + 验证通过|审计已完成 → 加固项待用户|已上线,页面全部 200|已封板,实施中|核查完成 → 收敛项 H1-|✅ 已实施并在 admin/|🔄 批次 1 因改走 bun|方案 A + handoff|根因已定位并修复(nginx|修复已编译(CI ✅),待重|已实施并上线|已修复并上线(服务已重启|清单建立|✅ 已实施并端到端验证|✅ 已实施并 live 验证|✅ 已实施并视觉验证|🔍 核查完成,P0 待决策|✅ 已实施(commit 见|✅ 已实施(commit `|✅ 已修复(commit `|🔍 取证完成|🔍 核查完成|✅ 已实施(`99de8c5|✅ 已落地|✅ 已修复|已落地并实测验证|已落地并端到端验证|🔄 第一批已修复|✅ 已实施(两批):第一批|✅ 已实施并端到端验证(co|✅ 已实施并验证(服务器已生|admin 侧已实施|✅ 已部署并生效(服务器 `|✅ 已实现、已验证、已部署|✅ 已完成并部署|✅ 全部完成并验证|✅ 已完成(2026-09-|✅ 已止损|✅ 已落地并部署(2026-|✅ 已完成并部署(2026-|✅ 已实现并单点验证|✅ 已落地并实测验证(配置|📋 标准已立|✅ 代码改造 + 组件级验证|✅ 已落地并验证(服务器 `|✅ 已部署并验证(2026-|✅ 已修复并部署|📋 评估中|🚧 执行中(R0 已完成|🚧 代码完成、产物已出|✅ 已完成并上线验证|✅ 已上线并端到端验证(后端|✅ 已修复并验证(源仓 `c|🔄 进行中(L2 机制级已验 @@ -182,6 +182,8 @@ | 04-135 | ✅ | **旧域 `alotbuy.com` 从项目移除**(2026-09-19 落地):代码 / 服务端 / 文档技能三层收敛为 `ai1net.com` 单域;旧域站点**降级为 301 过渡装置**(保留 ACME + `/dshs-relay` + `/dshs-overlay/bootstrap`,**30 天零流量**后退役);relay 候选 **5 → 3**(删掉两条“摘名不同、同落 47”的伪多样性);修 `wake.html` 转义点残留 + 演练脚本硬编码域造成的**假红**;参数表 2 键与 §10 指纹同步 | | 04-136 | 📋 | **控制面按两台中继取并集 —— 立项交接单**(2026-09-19 立项 · **未落地**):平台 web 层只取 47 中继 ⇒ 106 中继不被使用;根因链 7 行(取址路径 `src/web/server.ts` / `rendezvous.ts`)、**先红后绿**强制、候选 A/B 各带优缺点、验收 E1–E8 + 回滚 | | 04-137 | ✅ | **品牌标识改造 —— 去 DeepSeek 图形 → 平台自有标识**(2026-09-19 上线):**四页 + favicon 一并替换** —— `login` / `register` / `admin` 页头删 **DeepSeek 鲸鱼图标 +「DeepSeek」文字图形** 改平台标识(中文「**能力枢纽**」/英语及其他语言「**CapabilityNet**」,走 i18n 词条 `brand.name`,`en` 一条即覆盖其他语言回退);`portal` 顶栏换图标(页面名「管理门户」保留);**新建 `web/favicon.svg`**(平台自有 hub 图标,避开 DeepSeek 蓝);🔴 **会话页面左上角那处不动**(用户明示「先不替换」+它属官方 dsh client bundle,受 R2 约束);🔴 踩坑:**SVG 的 XML 注释不得含 ASCII 双连字符 `--`**(写 CSS 变量名即踩)⇒ 整份 SVG 解析失败、图标**静默不显示**,已加进 `verify-static.mjs` 并做反向自证;新增 `test/i18n-brand.test.mjs`|档案 `04-调整方案/137-品牌标识改造-去DeepSeek图形.md` | +| 04-138 | ✅ | **平台共享模型 —— 管理员逐用户授权(默认关闭)**(2026-09-19 上线):新增列 `users.shared_model_granted`(v11,**默认 0**),**生效 = `granted ∧ enabled`** —— 前者 admin 在**用户列表**里逐个开(新增「共享模型」列),后者仍是用户自己的偏好(保留);未授权时设置页**连"平台共享模型"那一块都不渲染**(插件 0.3.24);🔴 **顺带修掉一个既有真缺陷**:落地层原先用**本机 fs** 写用户 home,而用户卷在 worker 上 ⇒ **模型落地对"实例不在本机"的用户一直是静默空操作**(托管清单被清空、目标文件一字节没动;档案 87 的模型设置页对 guest 这类用户从未生效)⇒ 改为走 `UserFs` + worker agent 新增**白名单**端点 `/fs/home-read|write`;两腿实测(未授权撤销 / 授权恢复)|档案 `04-调整方案/138-平台共享模型-管理员逐用户授权.md` | +| 04-139 | ✅ | **品牌中文名:能力枢纽 → 能力网络**(2026-09-19 上线):其他语言仍 `CapabilityNet`(en 词条即回退);落点四处 = i18n 中文词条 / `admin.html` 顶栏 / **`favicon.svg` 的 `<title>`+`aria-label`**(最易漏)/ `design.css` 注释;品牌回归测试期望值同步,`verify-static` + SVG 真解析全绿;🔴 行尾按**目标目录既有风格**逐文件对齐(服务器 `admin.html`=CRLF 而本机=LF,直传会翻掉整个文件)|档案 `04-调整方案/139-品牌中文名改为能力网络.md` | | — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 | | — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)| diff --git a/dsh-server-docs/docs-manifest.json b/dsh-server-docs/docs-manifest.json index bed6c22..125a172 100644 --- a/dsh-server-docs/docs-manifest.json +++ b/dsh-server-docs/docs-manifest.json @@ -1,20 +1,20 @@ { "generatedFrom": "scripts/docs-manifest.py", "counts": { - "files": 194, - "chars": 2050190 + "files": 196, + "chars": 2062355 }, "tiers": { "hot": 6, "cur": 25, "warm": 61, - "cold": 42, + "cold": 44, "doc": 60 }, "domains": { - "platform": 75, + "platform": 76, "method": 47, - "ui": 17, + "ui": 18, "plugin": 27, "ops": 16, "?": 8, @@ -22,7 +22,7 @@ }, "layers": { "L0": 1, - "L5": 149, + "L5": 151, "L4": 22, "L2": 3, "?": 4, @@ -218,7 +218,7 @@ "date": "04-10", "chars": 6261, "lines": 129, - "refs": 58, + "refs": 60, "refsCurrent": 4, "tier": "hot", "layer": "L5", @@ -713,7 +713,7 @@ "date": "04-13", "chars": 2444, "lines": 58, - "refs": 9, + "refs": 12, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -786,8 +786,8 @@ "title": "137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识)", "status": "✅", "date": "09-19", - "chars": 6287, - "lines": 119, + "chars": 6401, + "lines": 122, "refs": 0, "refsCurrent": 0, "tier": "cold", @@ -795,6 +795,36 @@ "domain": "ui", "tldr": "" }, + { + "path": "04-调整方案/138-平台共享模型-管理员逐用户授权.md", + "num": "138", + "title": "138 · 平台共享模型 —— 管理员**逐用户**授权(默认关闭)", + "status": "✅ 已上线并端到端验证(控制", + "date": "2026-09-19", + "chars": 8578, + "lines": 148, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "① 共享模型从「人人默认可用(用户可自关)」改成「**管理员逐用户授权**」——新增列" + }, + { + "path": "04-调整方案/139-品牌中文名改为能力网络.md", + "num": "139", + "title": "139 · 品牌中文名:能力枢纽 → **能力网络**", + "status": "✅ 已上线(静态资源直覆,无", + "date": "2026-09-19", + "chars": 2666, + "lines": 56, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "ui", + "tldr": "中文品牌名 **「能力枢纽」→「能力网络」**;**其他语言不变**(仍 `CapabilityNet`," + }, { "path": "04-调整方案/14-实例会话敏感信息暴露面审计与加固.md", "num": "14", @@ -1898,7 +1928,7 @@ "date": "2026-09-13", "chars": 14028, "lines": 218, - "refs": 14, + "refs": 18, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -2181,8 +2211,8 @@ "title": "dsh 平台文档导航(INDEX)", "status": "?", "date": "", - "chars": 24468, - "lines": 268, + "chars": 25275, + "lines": 270, "refs": 0, "refsCurrent": 0, "tier": "doc", diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index bcbd4ee..de30e08 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -2919,7 +2919,12 @@ window.__ModuleLoader__.load({ jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }), jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }), - // ── admin 配的「平台共享模型」(口径②:也列在这里、用户可开关)── + // ── admin 配的「平台共享模型」(档案 138:**管理员逐用户开启后才有这一块**)── + // 判据 = `shared.granted`(后端 `users.shared_model_granted`,默认关闭)。 + // 未授权 ⇒ 整块不渲染(用户口径:「admin 开启,用户才能使用 + 才在设置页展示」)。 + // ⚠️ 与 `sharedOn`(`sharedModelEnabled`,用户自己的偏好)**不是一回事**: + // granted 决定"有没有",sharedOn 决定"你自己要不要用"。 + !shared.granted ? null : jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [ jsxRuntime.jsxs("div", { className: "pa-card-h", children: [ jsxRuntime.jsx("span", { children: t("ms.shared") }), diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index 5f5d9e6..1152f67 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.23", + "version": "0.3.24", "description": "能力管理(原「功能管理」/「功能插件」)section for dsh web profile — v0.3.23(2026-09-15):**撤除「偏好设置」分区**(用户:「把偏好设置中的 语言切换功能放到用户设置中,去掉偏好设置这个栏目」)。语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(id `user-settings`,order 103)—— 语言是**用户级偏好**,与「账号 / 退出登录」同区即可,再单开一个分区就是**重复入口**。撤掉:`PreferencesSection` 整段、`ctx.slots.inject` 里 id `preferences` order 99 的注册、zh/en 各 3 条 `pref.*` 词条 ⇒ 本 section 现只剩 **2 个分区**(能力管理 101 + 模型设置 100)。配套:`verify-platform-admin-section.mjs` 分区数断言 3 → 2 并新增「preferences 已撤除」断言;语言切换的功能断言移到新脚本 `scripts/verify-portal-entry.mjs`。|v0.3.22(2026-09-15):**分区改名「能力管理」+ 页内 tab 分页(插件 / 技能)+ 插件卡片中文用途描述三行**。① **改名**(用户:「设置中 功能管理改为能力管理」)—— **只改 label 级**(zh `能力管理` / en `Capability management`),代码标识符 / section id / 包名 / API 路径一律不动(素材库 U10「术语统一只改用户可见那层」);② **页内 tab**(用户:「能力管理页面改造为 tab可切换 插件和技能分别进行操作」)—— 复用既有 `.pa-tabs/.pa-tab/.pa-tcnt`(下划线式;规范 §4.4「数据页用下划线式」),两个页签各带**计数**;**插件页内容原样**(内存条 / 工具行 / 卡片网格 / 应用更改),**技能页**去掉重复的分组标题(标题已由 tab 承担)只留「生效方式说明 + 上传入口」工具行;两页**各自独立操作**,默认落插件页(与改造前一致);③ **插件卡片用途描述 = 3 行**(用户:「插件卡片中增加中文用途描述 显示三行」)—— 新增 `.bp-plugDesc`(`-webkit-line-clamp:3` + 显式 `white-space:normal`,否则继承单行截断的 nowrap),单行 ellipsis 会把「这个插件干什么」截得看不全;`title` 仍挂全文。④ **「内置 DeepSeek」→「DeepSeek」**(用户:「内置 DeepSeek,去掉内置就叫 DeepSeek 就行」)—— 仍只动**用户可见文案**:行标题 `ms.kindBuiltin`、新增下拉选项 `ms.optBuiltin`(原「内置 DeepSeek(平台共享)」,那个「(平台共享)」是**误导**:该选项走内置通道但**要填用户自己的 key**)、选择提示 `ms.builtinPickHint`、目录不可用时的兜底文案(`ms.catUnreadable`/`ms.catEmpty`)、以及种类小标 `ms.tagBuiltin`「内置」→「官方」+ `ms.builtinHint`「平台内置通道」→「官方通道」;⛔ **词典键名与代码标识符一律不动**(U10)。|v0.3.21(2026-09-15):**实例内「我的技能」分组**(档案 100 / 交接单 T01)—— 用户在同一页管理「功能插件 + 我的技能」两类功能。① **形态**:并入既有「功能管理」section 内**分组**,**不新开 section**(依据用户口径「不要分开管理」+ 档案 60 的分区命名);**仅入口层合并、机制层独立** —— 技能是 watch 驱动(启停**立即生效、无需重启**)而插件要重启实例,两条 API(`/api/skills/mine*` vs `/api/plugins/mine*`)与两套落盘位置一律不动(档案 16 §2.1 决策 2)。② **技能行**:技能名(截断三件套)+ 来源徽章(共享/我的)+ 状态(● 已启用 / ○ 已停用)+ 事实(文件数 · 体积,`tabular-nums`)+ 动作(启用/停用、删除);沿用官方卡片行语汇(动作区 `margin-left:auto` + `nowrap`)⇒ **结构上不可能换行**。③ **平台共享技能**(`source:'shared'`、`locked:true`)显示 🔒只读并**不渲染任何动作按钮** —— 与后端 `assertNotShared()` 的 409 双向一致(前端不给注定失败的按钮,后端仍兜底)。④ **上传**:虚线拖拽区 + 点击选文件(`label` 包隐藏 `input`,无需 ref),客户端先校 `.zip` / ≤150MB;**同名走两阶段**(先暂存 → 页内弹窗确认,写明「替换将删除旧技能全部文件、不可恢复」+ 旧/新文件数对照 → `apply`),与门户同语义。⑤ **删除**同样必须过页内确认(不可逆);启用/停用**可逆故不弹窗**。⑥ 失败一律**行内展示后端中文原文**(如「「x」是平台共享技能…请改用其他技能名」),不自己造词、不裸露状态码。⑦ zh/en 全量词条(`msk.*`);`scripts/verify-my-skills.mjs` 把「不换行 / 危险操作必须二次确认 / 锁定行无按钮 / tab 分页」钉成机械断言并入 `npm run verify`。|v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `<npm>@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`<details>`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输入你的 DeepSeek API 密钥」。⑥ 新增 `scripts/verify-models-dict.mjs` 断言 zh/en 键集一致 + 代码引用的键都已声明(本轮就是靠它抓到 `ms.needUrl`/`ms.needModels` 被引用未声明)。|v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", diff --git a/scripts/verify-models-render.cjs b/scripts/verify-models-render.cjs index 27d291f..39eb0b1 100644 --- a/scripts/verify-models-render.cjs +++ b/scripts/verify-models-render.cjs @@ -86,8 +86,10 @@ const FAKE_DATA = { { id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null }, { id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' }, ], - shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, count: 1 }, + // 档案 138:`granted` = 管理员授权(前端据它决定是否渲染「平台共享模型」区块) + shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, granted: true, count: 1 }, sharedModelEnabled: true, + sharedModelGranted: true, protocols: ['openai-completions', 'anthropic-messages'], } const FAKE_PROVIDERS = [ diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs index dc7ed7b..3f58c65 100644 --- a/scripts/verify-platform-admin-section.mjs +++ b/scripts/verify-platform-admin-section.mjs @@ -61,8 +61,10 @@ const DATA = { { id: "k2", name: "我的中转网关", enabled: false, updatedAt: 2, route: "my-gw", baseUrl: "https://api.example.com/v1", api: "openai-completions", models: '["gpt-4o","gpt-4o-mini"]' }, ], effective: "own", - shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, count: 1 }, + // `granted`(档案 138)= **管理员**有没有给这个人开;`enabled` = 用户自己的偏好。 + shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, granted: true, count: 1 }, sharedModelEnabled: true, + sharedModelGranted: true, protocols: ["openai-completions", "openai-responses", "anthropic-messages"], }, // 档案 87 补做:官方 pi-ai 厂家目录(「新增」选择框的数据源)—— 国内 + 国际各一家代表 @@ -247,6 +249,22 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器")); // 口径②:共享模型也列入且可开关 ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型")); + // ── 档案 138(2026-09-19 用户口径):**管理员逐用户开启后才有这一块** ────────────── + // 判据 = `shared.granted`。未授权时整块不渲染(用户原话:「admin 在用户列表中开启… + // 用户才能在会话中使用(以及在设置的模型设置页面展示)」)。 + // ⚠️ 这条断言必须**正反两腿都跑**:只测"已授权时能看到"会漏掉"未授权时也没隐藏"这种 + // 半实现(那就是门禁只做了一半 —— 用户看不到授权状态却仍能看到共享模型)。 + { + const keep = DATA["/api/me/keys"].shared; + DATA["/api/me/keys"].shared = Object.assign({}, keep, { granted: false }); + const tNo = text(await render(ms.Comp)).join(" | "); + ok("模型设置:**未授权**时「平台共享模型」整块不渲染(档案 138)", + !tNo.includes("平台共享模型") && !tNo.includes("停用共享模型"), + "-> 仍有共享字样 = 门禁没生效"); + DATA["/api/me/keys"].shared = keep; + const tYes = text(await render(ms.Comp)).join(" | "); + ok("模型设置:恢复授权后该块回来(正反两腿对照)", tYes.includes("平台共享模型")); + } // 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」 ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用")); // 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。 diff --git a/src/db/adapter.ts b/src/db/adapter.ts index b471062..f7b3cbc 100644 --- a/src/db/adapter.ts +++ b/src/db/adapter.ts @@ -115,6 +115,12 @@ export interface DbAdapter { /** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */ getSharedModelEnabled(userId: string): Promise<boolean> setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> + /** + * 档案 138(v11):**管理员**是否已给该用户开启「平台共享模型」—— 逐用户门禁,默认关闭。 + * 生效 = 本项 ∧ `getSharedModelEnabled`(缺一不给)。 + */ + getSharedModelGranted(userId: string): Promise<boolean> + setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> deleteCredentialKey(userId: string, id: string): Promise<boolean> // instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7) upsertInstance(input: UpsertDshInstanceInput): Promise<void> diff --git a/src/db/pg.ts b/src/db/pg.ts index ad931ef..b4e61e1 100644 --- a/src/db/pg.ts +++ b/src/db/pg.ts @@ -54,7 +54,11 @@ import { // both backends. This is process-global and idempotent. types.setTypeParser(20, (value: string) => Number(value)) -const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email' +// ⚠️ **本文件有一份自己的列清单**(PG 方言的查询都直接用它)—— ⛔ 与 `repo.ts` 的 +// `USER_COLS` 是**两份**,加列时**两处都要改**(档案 138 实测踩到:只改了 repo.ts +// ⇒ PG 下 `listPublicUsers` 读不到 `shared_model_granted`,admin 用户列表恒显示"未开启")。 +const USER_COLS = + 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email, shared_model_granted' const EMAIL_CODE_COLS = 'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at' const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' @@ -137,6 +141,8 @@ export class PgAdapter implements DbAdapter { approved_by: null, uid, email: input.email ?? null, + // v11:新用户未授权平台共享模型(默认关闭),见 repo.ts 同处注释。 + shared_model_granted: false, } }) } catch (e) { @@ -310,7 +316,7 @@ export class PgAdapter implements DbAdapter { async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> { const { rows } = await this.pool.query( `SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid, - s.expires_at + u.shared_model_granted, u.email, s.expires_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = $1`, [tokenHash], @@ -589,6 +595,21 @@ export class PgAdapter implements DbAdapter { return (result.rowCount ?? 0) > 0 } + /** 管理员是否已给该用户开启「平台共享模型」(档案 138 · v11)。缺失行按 `false`(失败关闭)。 */ + async getSharedModelGranted(userId: string): Promise<boolean> { + const { rows } = await this.pool.query('SELECT shared_model_granted FROM users WHERE id = $1', [userId]) + const row = rows[0] as { shared_model_granted: number } | undefined + return row === undefined ? false : Number(row.shared_model_granted) !== 0 + } + + async setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> { + const result = await this.pool.query('UPDATE users SET shared_model_granted = $1 WHERE id = $2', [ + granted ? 1 : 0, + userId, + ]) + return (result.rowCount ?? 0) > 0 + } + async deleteCredentialKey(userId: string, id: string): Promise<boolean> { const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId]) return (result.rowCount ?? 0) > 0 diff --git a/src/db/repo.ts b/src/db/repo.ts index d28a49c..e6dc14a 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -49,7 +49,8 @@ import { type Workspace, } from './types.js' -const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email' +const USER_COLS = + 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email, shared_model_granted' const EMAIL_CODE_COLS = 'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at' const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' @@ -78,6 +79,9 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number approved_by: null, uid, email: input.email ?? null, + // v11:新用户**未授权**平台共享模型(默认关闭)—— 与列默认值一致,这里显式写出来 + // 是为了让"新用户拿到什么"在这一个地方就能读全,不必再去翻迁移 SQL。 + shared_model_granted: false, } })() } @@ -481,6 +485,26 @@ export function setSharedModelEnabled(db: Database, userId: string, enabled: boo return info.changes > 0 } +/** + * **管理员**是否已给该用户开启「平台共享模型」(档案 138 · v11)—— 逐用户门禁。 + * + * 缺失行按 **`false`**(= 未授权):这与 `getSharedModelEnabled` 的"缺失按 true"**故意相反**, + * 因为两者方向不同 —— 用户偏好缺失时"多给"是无害的,而门禁缺失时"多给"就等于把门打开了。 + * 门禁类判据一律**失败关闭**。 + */ +export function getSharedModelGranted(db: Database, userId: string): boolean { + const row = prepare(db, 'SELECT shared_model_granted FROM users WHERE id = ?').get(userId) as + | { shared_model_granted: number } + | undefined + return row === undefined ? false : row.shared_model_granted !== 0 +} + +/** 设置「平台共享模型」的管理员授权(档案 138)。@returns 是否命中该用户。 */ +export function setSharedModelGranted(db: Database, userId: string, granted: boolean): boolean { + const info = prepare(db, 'UPDATE users SET shared_model_granted = ? WHERE id = ?').run(granted ? 1 : 0, userId) + return info.changes > 0 +} + /** Delete a named key (by id, scoped to the user). */ export function deleteCredentialKey(db: Database, userId: string, id: string): boolean { const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId) @@ -530,7 +554,7 @@ export function findSessionWithUser(db: Database, tokenHash: string): SessionUse const row = prepare( db, `SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid, - s.expires_at + u.shared_model_granted, u.email, s.expires_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ?`, ).get(tokenHash) as Record<string, unknown> | undefined diff --git a/src/db/schema.ts b/src/db/schema.ts index a5bbed3..915e9a2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -442,6 +442,30 @@ CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at); ` +// v11: **平台共享模型改为「管理员逐用户授权」**(2026-09-19 用户口径)。 +// +// 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭), +// 用户才能在会话中使用(以及在设置的模型设置页面展示)」。 +// +// 为什么**新开一列**而不是把 v6 的 `shared_model_enabled` 改成默认 0: +// · v6 那一列的语义是**用户侧偏好**(用户能在「设置 → 模型设置」里自己关掉,默认开)—— +// 需求要的是**管理员门禁**,两者是**不同的人、不同的意图**; +// · 若共用一列,用户在自己的设置里点一下就能把自己"授权"了 ⇒ 门禁形同不存在; +// · 故:`shared_model_granted` = **管理员授权**(本列,`DEFAULT 0` = 默认关闭, +// 用户自己改不了);`shared_model_enabled` = **用户偏好**(不变)。 +// · **生效 = granted AND enabled**(`server.ts#sharedLandingRows`)。 +// +// ⚠️ `DEFAULT 0` 让**存量行也一并变 0**(PG/SQLite 加列都用默认值回填)⇒ 迁移后 +// **所有既有用户都处于"未授权"**,需要 admin 在用户列表里逐个开启。这正是"默认关闭"的字面语义, +// 也是这条门禁第一次生效的可见证据(验收见档案 138)。 +const SQLITE_V11 = ` +ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0; +` + +const PG_V11 = ` +ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0; +` + interface Migration { version: number name: string @@ -460,6 +484,7 @@ const MIGRATIONS: readonly Migration[] = [ { version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 }, { version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 }, { version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 }, + { version: 11, name: 'shared model admin grant (per-user, default off)', sqlite: SQLITE_V11, pg: PG_V11 }, ] /** Apply unapplied SQLite migrations inside a single transaction. */ diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts index 940bee4..ddecfb7 100644 --- a/src/db/sqlite.ts +++ b/src/db/sqlite.ts @@ -44,6 +44,7 @@ import { getEnabledPluginIds as getEnabledPluginIdsSync, getOrCreateWorkspace as getOrCreateWorkspaceSync, getSharedModelEnabled as getSharedModelEnabledSync, + getSharedModelGranted as getSharedModelGrantedSync, latestSentEmailCode as latestSentEmailCodeSync, listBusinessPlugins as listBusinessPluginsSync, listCredentialKeys as listCredentialKeysSync, @@ -61,6 +62,7 @@ import { setFolderPlugins as setFolderPluginsSync, setInstanceStatus as setInstanceStatusSync, setSharedModelEnabled as setSharedModelEnabledSync, + setSharedModelGranted as setSharedModelGrantedSync, setUserRole as setUserRoleSync, setUserUid as setUserUidSync, toggleCredentialKey as toggleCredentialKeySync, @@ -331,6 +333,14 @@ export class SqliteAdapter implements DbAdapter { return setSharedModelEnabledSync(this.db, userId, enabled) } + async getSharedModelGranted(userId: string): Promise<boolean> { + return getSharedModelGrantedSync(this.db, userId) + } + + async setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> { + return setSharedModelGrantedSync(this.db, userId, granted) + } + async deleteCredentialKey(userId: string, id: string): Promise<boolean> { return deleteCredentialKeySync(this.db, userId, id) } diff --git a/src/db/types.ts b/src/db/types.ts index ddefc54..d433c44 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -25,6 +25,14 @@ export interface User { uid: number | null /** Registration e-mail (v10); null for the legacy rows created before v10. */ email: string | null + /** + * **管理员**是否已给该用户开启「平台共享模型」(v11,**默认 false**)。 + * + * 与 `shared_model_enabled`(用户侧偏好,默认 true)是**两回事**:本列是门禁(只有 admin + * 能在用户列表里改),两者**同时为真**才真正把 admin 配的模型落到该用户实例上。 + * 详见 `schema.ts` 的 `SQLITE_V11` 注释。 + */ + shared_model_granted: boolean } /** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */ @@ -52,6 +60,8 @@ export interface PublicUser { username: string role: UserRole createdAt: number + /** v11:管理员是否已为该用户开启「平台共享模型」(admin 用户列表要显示它)。 */ + sharedModelGranted: boolean } /** A persisted login session (token stored only as its hash). */ @@ -269,7 +279,13 @@ export interface UpsertDshInstanceInput { } export function toPublicUser(user: User): PublicUser { - return { id: user.id, username: user.username, role: user.role, createdAt: user.created_at } + return { + id: user.id, + username: user.username, + role: user.role, + createdAt: user.created_at, + sharedModelGranted: user.shared_model_granted, + } } // Row mappers. Shared by both adapters — they read the same column names, so the @@ -288,6 +304,9 @@ export function toUser(row: Record<string, unknown>): User { approved_by: (row.approved_by as string | null) ?? null, uid: (row.uid as number | null) ?? null, email: (row.email as string | null) ?? null, + // ⚠️ 用 `?? 0`:`findSessionWithUser` 走的是自己的列清单,未取该列时这里是 `undefined` + // —— 若写成 `!== 0`,`undefined !== 0` 会得到 `true`(把没授权的人当成已授权)。 + shared_model_granted: Number(row.shared_model_granted ?? 0) !== 0, } } diff --git a/src/fs/local-user-fs.ts b/src/fs/local-user-fs.ts index 15b4fa5..da8d791 100644 --- a/src/fs/local-user-fs.ts +++ b/src/fs/local-user-fs.ts @@ -7,12 +7,12 @@ */ import { chownSync, chmodSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { chmod, chown, mkdir, readFile, writeFile } from 'node:fs/promises' import { stat } from 'node:fs/promises' import { basename, join } from 'node:path' import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js' import { listInstalledPlugins, type PluginInfo } from './plugins.js' -import { UserFsError, type UserFs } from './user-fs.js' +import { UserFsError, isHomeFileName, type HomeFileName, type UserFs } from './user-fs.js' import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js' /** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a @@ -141,6 +141,39 @@ export class LocalUserFs implements UserFs { await writeFile(handoffPath(root), content) } + /** + * 读 home 下的平台托管配置文件(档案 138)。 + * 名字走**白名单**(只允许固定文件名)⇒ 不可能拼出 `../` 之类的越界路径。 + */ + async readHomeFile(userId: string, name: HomeFileName): Promise<string | null> { + if (!isHomeFileName(name)) throw new UserFsError('bad_path') + try { + return await readFile(join(homeRoot(this.rootFor(userId)), name), 'utf8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } + } + + /** + * 写 home 下的平台托管配置文件:`0600` + chown 给 **home 属主**。 + * chown 不能省 —— 实例以 `dsh-<uid>` 身份读它(见 `home-files.ts` 头注释① ②)。 + */ + async writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> { + if (!isHomeFileName(name)) throw new UserFsError('bad_path') + const home = homeRoot(this.rootFor(userId)) + ensureDir(home) + const file = join(home, name) + await writeFile(file, text, { mode: 0o600 }) + try { + const st = await stat(home) + await chown(file, st.uid, st.gid) + await chmod(file, 0o600) + } catch { + /* chown 失败(非 root 运行等)不阻断 —— 与 home-files.ts 同款 */ + } + } + /** Resolve a workspace-relative path, self-healing the root the way the * pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */ private resolve(userId: string, relPath: string): string { diff --git a/src/fs/remote-user-fs.ts b/src/fs/remote-user-fs.ts index 1693b9c..4ef0dd7 100644 --- a/src/fs/remote-user-fs.ts +++ b/src/fs/remote-user-fs.ts @@ -17,7 +17,7 @@ import { AGENT_TOKEN_HEADER } from '../worker/agent.js' import { PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js' import type { PluginInfo } from './plugins.js' -import { UserFsError, isUserFsErrorCode, type UserFs } from './user-fs.js' +import { UserFsError, isUserFsErrorCode, type HomeFileName, type UserFs } from './user-fs.js' import type { FsEntry } from './workspace.js' import { userRoot, workspaceRoot } from './workspace.js' @@ -201,6 +201,20 @@ export class RemoteUserFs implements UserFs { await this.post('/fs/handoff', { userId, content }) } + /** + * 读用户 home 下的平台托管配置文件(档案 138)—— 走 agent 的 `/fs/home-read`, + * 目标是 `hostIdFor(userId)` 钉住的那台机(与实例同机,见 `user-fs.ts#readHomeFile` 注释)。 + * 文件不存在 ⇒ `null`。 + */ + async readHomeFile(userId: string, name: HomeFileName): Promise<string | null> { + const out = await this.post<{ text: string | null }>('/fs/home-read', { userId, name }) + return out.text + } + + async writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> { + await this.post('/fs/home-write', { userId, name, text }) + } + /** 探测 worker 的 dataRoot(用于启动时的基线一致性检查,见 `server.ts`)。 */ async probeWorkerRoot(): Promise<string | undefined> { try { diff --git a/src/fs/user-fs.ts b/src/fs/user-fs.ts index dd7390d..4a74182 100644 --- a/src/fs/user-fs.ts +++ b/src/fs/user-fs.ts @@ -90,4 +90,39 @@ export interface UserFs { listInstalledPlugins(userId: string): Promise<PluginInfo[]> /** Write the post-restart command handoff the watchdog reads. */ writeHandoff(userId: string, content: string): Promise<void> + /** + * 读用户 **home**(`$DSH_HOME`)下的**平台托管配置文件**(档案 138)。 + * + * 为什么必须走本 seam 而不是直接 `fs.readFile(home_dir)`:**用户卷跟着实例走** + * —— 实例在 worker 上时,`home/` 就在那台机器上。平台侧直接 `readFile` 只会读到 + * 自己盘上一个**不存在的路径**(返回空串、不报错)⇒ 落地层静默变成空操作 + * (2026-09-19 实测:托管清单被清空、目标文件一个字节没动)。 + * 归属的钉法与本 seam 的其它方法**完全一致**(`hostIdFor` 的粘性选机,见 `server.ts` + * 「文件写到 A、实例起在 B」那段注释)⇒ 落地与实例必然同机。 + * + * `name` 只接受 {@link HOME_FILE_NAMES} 里的**固定文件名**(⛔ 不收路径): + * 这几个是平台自己写的配置,用户的其它文件不归平台碰。 + * 文件不存在 ⇒ `null`(**不是**抛错:首次落地就该从"没有文件"开始)。 + */ + readHomeFile(userId: string, name: HomeFileName): Promise<string | null> + /** + * 写用户 home 下的平台托管配置文件。 + * ⚠️ 与 `writeHomeFile`(本地版)同一组约束:`0600` + **chown 给 home 属主** + * —— 实例以 `dsh-<uid>` 身份跑,root 写的 0600 文件它**读不了**(档案 43 / R10 同族)。 + */ + writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> +} + +/** + * 平台托管的 home 配置文件名白名单(⛔ 只许这些**裸文件名**,不许带路径)。 + * + * - `settings.yaml` —— dsh 自己的设置(平台只写 `llm-pi-ai.providers.*` 与 `locale.*`); + * - `.credentials.yaml` —— dsh 的凭据 refs(平台只写自己 refs 段)。 + */ +export const HOME_FILE_NAMES = ['settings.yaml', '.credentials.yaml'] as const +export type HomeFileName = (typeof HOME_FILE_NAMES)[number] + +/** 白名单校验(两端都调一次:agent 侧拦住非法入参,本地实现拦住越界调用)。 */ +export function isHomeFileName(name: unknown): name is HomeFileName { + return typeof name === 'string' && (HOME_FILE_NAMES as readonly string[]).includes(name) } diff --git a/src/web/home-files.ts b/src/web/home-files.ts index b1d89fa..eb57bb7 100644 --- a/src/web/home-files.ts +++ b/src/web/home-files.ts @@ -32,17 +32,7 @@ export async function readTextOrEmpty(file: string): Promise<string> { * 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。 */ export async function writeHomeFile(homeDir: string, file: string, text: string): Promise<void> { - try { - const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' - await mkdir(bakDir, { recursive: true }) - const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '') - // ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home", - // 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。 - const who = basename(dirname(homeDir)) - writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 }) - } catch { - /* 备份失败不阻断 */ - } + await backupHomeFile(homeDir, file, text) await writeFile(file, text, { mode: 0o600 }) try { const st = await stat(homeDir) @@ -51,3 +41,25 @@ export async function writeHomeFile(homeDir: string, file: string, text: string) /* chown 失败(非 root 运行等)不阻断 */ } } + +/** + * 只做**备份**那一步(写进平台备份目录),**不碰用户文件**。 + * + * 为什么单独抽出来(档案 138):用户卷可能**不在本机**(实例在 worker 上)⇒ 写入必须走 + * `UserFs`(会按归属路由到那台机),而备份是**平台自己**的副本 —— 落在控制面的 + * `/opt/dsh/backups` 正合适,也不该为了备份再往远端开一条通道。 + * 备份的命名规则与 {@link writeHomeFile} 的①步**逐字一致**(⛔ 别各写一套)。 + */ +export async function backupHomeFile(homeDir: string, fileOrName: string, text: string): Promise<void> { + try { + const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' + await mkdir(bakDir, { recursive: true }) + const label = basename(fileOrName).replace(/^\./, '').replace(/\.ya?ml$/, '') + // ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home", + // 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。 + const who = basename(dirname(homeDir)) + writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 }) + } catch { + /* 备份失败不阻断 */ + } +} diff --git a/src/web/routes/admin.ts b/src/web/routes/admin.ts index 49369a3..b25e381 100644 --- a/src/web/routes/admin.ts +++ b/src/web/routes/admin.ts @@ -14,6 +14,16 @@ import { userRoot } from '../../fs/workspace.js' const ENSURE_BIZ_PLUGINS = process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs' +/** 档案 138:「平台共享模型」逐用户授权的入参(只有开关本身)。 */ +const sharedModelSchema = { + body: { + type: 'object', + required: ['enabled'], + additionalProperties: false, + properties: { enabled: { type: 'boolean' } }, + }, +} as const + export const adminRoutes: FastifyPluginAsync = async (app) => { // 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du) app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => { @@ -66,6 +76,50 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { return { ok: true } }) + /** + * **逐个用户**开启/关闭「平台共享模型」(档案 138 · 2026-09-19 用户口径)。 + * + * 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,**默认关闭**), + * 用户才能在会话中使用(以及在设置的模型设置页面展示)」。 + * + * 为什么放在 admin 路由而不是复用 `/api/me/models/shared`:那一条写的是**用户偏好** + * (`shared_model_enabled`,用户自己在设置页开关);本条的写入目标是**管理员授权** + * (`shared_model_granted`,默认 0)—— 两列、两个主体,⛔ 不能共用一个写入口, + * 否则用户点一下就等于给自己授权了(门禁失效)。 + * + * ⚠️ 落地发生在 **spawn 时**(`server.ts#landModels`)⇒ 改完必须让目标用户的实例重启 + * 才能看到变化。这里直接调 `restartMain(id)`:**只影响这一个用户**;他没在跑就是空操作 + * (下次启动自然按新授权落地)。这与用户自己改条目的行为一致(档案 87 §四)。 + */ + app.post( + '/api/admin/users/:id/models/shared', + { preHandler: requireAdmin, schema: sharedModelSchema }, + async (request, reply) => { + const { id } = request.params as { id: string } + const { enabled } = request.body as { enabled: boolean } + const user = await app.db.findUserById(id) + if (user === undefined) return reply.code(404).send({ error: 'not_found' }) + if (!(await app.db.setSharedModelGranted(id, enabled))) return reply.code(404).send({ error: 'not_found' }) + await app.db.audit( + request.user?.id ?? null, + 'shared_model_grant', + JSON.stringify({ userId: id, username: user.username, enabled }), + ) + // 重启目标实例是**尽力而为**:授权已经落库了,它才是这次操作的真结果。 + // ⚠️ 两种"没重启"都不算失败:① 实例没在跑(`restartMain` 返 undefined)—— + // 下次启动自然按新授权落地;② 归属租约被别的持有者占着(抛 LeaseBusyError) + // —— 那不是本次授权的问题,⛔ 不能把它报成"开启失败"(2026-09-19 实测: + // 报 500 时 admin 界面显示"操作失败",而库里其实已经改对了)。 + let restarted = false + try { + restarted = (await app.supervisor.restartMain(id)) !== undefined + } catch (err) { + app.log.warn({ err, userId: id }, '共享模型授权已落库,但重启目标实例失败(下次启动生效)') + } + return { ok: true, sharedModelGranted: enabled, restarted } + }, + ) + // 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 → // 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。 app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => { diff --git a/src/web/routes/auth.ts b/src/web/routes/auth.ts index 7dc64e4..d3d3ef2 100644 --- a/src/web/routes/auth.ts +++ b/src/web/routes/auth.ts @@ -12,7 +12,7 @@ import { deriveKey, encrypt } from '../../crypto.js' import { toPublicUser } from '../../db/types.js' import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js' import { PROTOCOLS } from '../model-landing.js' -import { readTextOrEmpty, writeHomeFile } from '../home-files.js' +import { backupHomeFile } from '../home-files.js' import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js' import { captchaActive, @@ -380,23 +380,37 @@ export const authRoutes: FastifyPluginAsync = async (app) => { if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own' // 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。 if (!(await app.db.getSharedModelEnabled(userId))) return 'none' + // 档案 138:**管理员没授权 ⇒ 就是 none**(门禁先于内容;用户自己的条目不受影响, + // 因为上面那一步已经判过了)。 + if (!(await app.db.getSharedModelGranted(userId))) return 'none' const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin') if (admins.length === 0) return 'none' return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none' } - /** 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 */ + /** + * 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 + * + * `granted`(档案 138)= **管理员有没有给这个人开**;前端据它决定**要不要渲染**整个 + * 「平台共享模型」区块 —— 未授权时连块都不出现(用户口径:开了才"在使用 + 在设置页展示")。 + * ⚠️ 未授权时**仍然返回** `owner` / `count` 等字段(不额外做信息收窄),因为这条接口 + * 只有**本人或 admin** 能看,且这些是"平台有哪些共享模型"这种非敏感目录信息。 + */ async function sharedKeyInfo(userId: string): Promise<{ available: boolean name: string | null owner: string | null ownerIsMe: boolean enabled: boolean + granted: boolean count: number }> { const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin') const enabled = await app.db.getSharedModelEnabled(userId) - if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false, enabled, count: 0 } + const granted = await app.db.getSharedModelGranted(userId) + if (admins.length === 0) { + return { available: false, name: null, owner: null, ownerIsMe: false, enabled, granted, count: 0 } + } // 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。 const keys = await app.db.listEnabledCredentialKeys(admins[0].id) // `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。 @@ -406,6 +420,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { owner: admins[0].username, ownerIsMe: admins[0].id === userId, enabled, + granted, count: keys.length, } } @@ -418,9 +433,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => { app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({ keys: await app.db.listCredentialKeys(request.user!.id), effective: await keySourceOf(request.user!.id), + // `shared.granted`(档案 138)= admin 是否已授权;`shared.enabled` = 用户自己的偏好。 shared: await sharedKeyInfo(request.user!.id), // 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。 sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id), + // 档 138:管理员授权(前端只在 true 时渲染「平台共享模型」区块)。 + sharedModelGranted: await app.db.getSharedModelGranted(request.user!.id), protocols: [...PROTOCOLS], })) @@ -536,7 +554,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => { return { ok: true } }) - /** 平台共享模型的开关(档案 87 口径②)—— 只动**自己**的偏好,不碰 admin 的配置。 */ + /** + * 平台共享模型的开关(档案 87 口径② + 档案 138)—— 只动**自己**的偏好,不碰 admin 的配置。 + * + * ⚠️ 语义边界(别混):本路由写的是 `shared_model_enabled` = **用户偏好**("我要不要用"); + * **能否用**由 admin 在用户列表里写的 `shared_model_granted` 先定(默认关闭)。 + * 两者是**与**关系 ⇒ 用户把开关打开、但 admin 没授权时,仍然什么也不会落地。 + * 所以这里**不校验授权**:让用户能先关掉自己不想用的、也允许他先打开(授权一到即生效), + * 且**不泄露**"管理员是否授权了别人"这类信息。 + */ app.post('/api/me/models/shared', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => { const { enabled } = request.body as { enabled: boolean } if (!(await app.db.setSharedModelEnabled(request.user!.id, enabled))) { @@ -558,15 +584,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => { * 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`), * ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。 * ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。 + * 🔴 但**读写必须走 `app.userFs`**(档案 138 §五):用户卷跟着实例走 —— 实例在 worker 上时 + * `home/` 就在那台机,直接 `join(home_dir, …)` + 本机 fs 只会读到自己盘上一个不存在的路径 + * (**空串、不报错**)⇒ 这条路由对"实例不在控制面本机"的用户**静默失效**(语言选择永远存不上)。 */ app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => { const { locale } = request.body as { locale: string } if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' }) - const homeDir = homeRoot(userRoot(app.config.dataRoot, request.user!.id)) - const file = join(homeDir, 'settings.yaml') - const text = await readTextOrEmpty(file) + const userId = request.user!.id + const text = (await app.userFs.readHomeFile(userId, 'settings.yaml')) ?? '' const next = reconcileLocalePreference(text, locale) - if (next.changed) await writeHomeFile(homeDir, file, next.text) + if (next.changed) { + // 备份放**平台侧**(控制面的备份目录),写入走 `userFs`(可能落到远端那台机)—— + // 与 `server.ts#landModels` 同一套口径,⛔ 别在这里自己拼绝对路径。 + await backupHomeFile(homeRoot(userRoot(app.config.dataRoot, userId)), 'settings.yaml', text) + await app.userFs.writeHomeFile(userId, 'settings.yaml', next.text) + } return { ok: true, locale, changed: next.changed } }) diff --git a/src/web/server.ts b/src/web/server.ts index 7c2123c..3ac664b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -52,7 +52,7 @@ import { refForEntry, type SettingsEntry, } from './model-landing.js' -import { readTextOrEmpty, writeHomeFile } from './home-files.js' +import { backupHomeFile } from './home-files.js' import { rateLimit } from './middleware/rate-limit.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' @@ -115,11 +115,16 @@ function isAllowedOrigin(origin: string, baseDomain: string): boolean { export async function buildServer(config: ServerConfig): Promise<FastifyInstance> { const db = await createDbAdapter(config) const encryptionKey = deriveKey(config.encryptionSecret) - // ── 模型条目落地(档案 87)────────────────────────────────────────────────── + // ── 模型条目落地(档案 87 + 138)──────────────────────────────────────────── // 用户口径(2026-09-13 定):条目**各自开关、可同时启用**;admin 配的**平台共享模型 // **也列入**、用户可开关(`users.shared_model_enabled`);平台只负责把「**已启用**」 // 的都配好 —— 具体用哪个模型在 dsh 对话框的模型选择器里挑。 // + // 🔴 2026-09-19 追加门禁(档案 138):平台共享模型**不再是"人人默认可用"** —— + // admin 必须在**用户列表**里逐个开启(`users.shared_model_granted`,v11,默认关闭), + // 用户才能用(也才会在「设置 → 模型设置」里看到那一块)。 + // ⇒ 落地判据 = `granted ∧ enabled`(两个开关分属**不同的人**:前者 admin,后者用户)。 + // // 为什么必须由平台写文件:官方「设置 → 模型」页在平台环境**必然报错**(该页要 Host // settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` ⇒ persistence // 降级 `memory` ⇒ 页面报「加载提供方目录失败」)。详见 `ensure-role-profile-patch.cjs`。 @@ -160,8 +165,17 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance // `readTextOrEmpty` / `writeHomeFile` 已抽到 `./home-files.js`(2026-09-15:语言偏好 // 持久化也要用同一套「写 home 文件」语义 —— 与其复制一份,不如共用;约束见该模块头注释)。 - /** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */ + /** + * 平台共享条目(admin 配的、已启用的那些)—— 三条件**全满足**才纳入: + * ① **管理员已授权**该用户(档案 138 · v11,默认关闭,只能由 admin 在用户列表里开) + * ② **用户自己没有关掉**(档案 87 的用户侧偏好) + * ③ 该用户**不是那个 admin 本人**(admin 用的是他自己配的,再回落一次等于重复) + * + * ①② 是**两个不同的人的两个开关**,缺一不给 —— 判据是"生效 = 门禁 ∧ 偏好", + * ⛔ 别把任意一个当成"可以覆盖另一个"。 + */ const sharedLandingRows = async (userId: string): Promise<CredentialLandingRow[]> => { + if (!(await db.getSharedModelGranted(userId))) return [] if (!(await db.getSharedModelEnabled(userId))) return [] const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') if (admins.length === 0 || admins[0].id === userId) return [] @@ -213,10 +227,14 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance } } } - const credFile = join(owner.home_dir, '.credentials.yaml') - const setFile = join(owner.home_dir, 'settings.yaml') - const credText = await readTextOrEmpty(credFile) - const setText = await readTextOrEmpty(setFile) + // 🔴 读写**必须走 `userFs`**(档案 138)—— 用户卷跟着实例走:实例在 worker 上时 + // `home/` 就在那台机。原先这里直接 `join(owner.home_dir, …)` + 本机 fs ⇒ + // 控制面读到自己盘上一个不存在的路径(**空串、不报错**)⇒ 落地静默变成空操作: + // 托管清单被清空、目标文件一个字节没动(2026-09-19 实测,guest 就是这种用户)。 + // `userFs` 的归属与本 seam 其它方法**同一份**粘性选机(`hostIdForFile`)⇒ + // "落地与实例同机"由这条路由保证,⛔ 别在这里自己拼路径。 + const credText = (await userFs.readHomeFile(userId, '.credentials.yaml')) ?? '' + const setText = (await userFs.readHomeFile(userId, 'settings.yaml')) ?? '' // 一次性交接(档案 87):老实现把平台共享 key 写进 `refs.DEEPSEEK_API_KEY` 时没有托管清单, // 新逻辑会把它当成"用户自己写的" ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立)。 // 首次运行(没有任何清单)且**文件里那行确实等于平台共享 key 明文**时,认领它; @@ -230,12 +248,28 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance } const nextCred = reconcileCredentials(credText, creds, prevRefs) const nextSet = reconcileSettings(setText, providers, previous.routes) - if (nextCred.text !== credText) await writeHomeFile(owner.home_dir, credFile, nextCred.text) - if (nextSet.text !== setText) await writeHomeFile(owner.home_dir, setFile, nextSet.text) + // 备份在**平台侧**做(旧文本就在手上),写入走 `userFs`(可能落到远端那台机)。 + if (nextCred.text !== credText) { + await backupHomeFile(owner.home_dir, '.credentials.yaml', credText) + await userFs.writeHomeFile(userId, '.credentials.yaml', nextCred.text) + } + if (nextSet.text !== setText) { + await backupHomeFile(owner.home_dir, 'settings.yaml', setText) + await userFs.writeHomeFile(userId, 'settings.yaml', nextSet.text) + } await writeManaged(userId, { refs: nextCred.managed, routes: nextSet.managed }) } - /** 保底:平台共享的那把内置 DeepSeek key 明文 —— 只在写配置失败退回 env 注入时才用。 */ + /** + * 平台共享的那把内置 DeepSeek key 明文。 + * + * 🔴 **本函数刻意不判门禁**(档案 138 踩过):它有两个用途,其中一个**不该**被判。 + * ① 「一次性交接」用它**认领**老实现写下的那一行(判断"这行是不是平台自己写的") + * —— 这是**归属判据**,与"该用户现在有没有授权"无关。若在这里判门禁,被撤销授权的 + * 用户反而**认领不出来** ⇒ 那行永远删不掉 ⇒ "关掉即生效"不成立(红腿实测到了)。 + * ② 写配置失败时退回 env 注入的保底值 —— 这一路**必须**判门禁,判在**调用点** + * (`resolveApiKey` 里),⛔ 别挪回这里。 + */ const sharedDeepseekKey = async (): Promise<string | null> => { const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') if (admins.length === 0) return null @@ -260,6 +294,12 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance return null } catch (err) { console.error('model landing failed, falling back to env injection', err) + // 🔴 **门禁判在这里**(不能挪进 `sharedDeepseekKey`):这是"落地失败"的应急路, + // 不判就等于给未授权用户在异常路径上开门 —— 门禁类判据一律失败关闭。 + if (!(await db.getSharedModelGranted(userId))) return null + if (!(await db.getSharedModelEnabled(userId))) return null + const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') + if (admins.length === 0 || admins[0].id === userId) return null return await sharedDeepseekKey() } } diff --git a/src/worker/agent.ts b/src/worker/agent.ts index 540cea7..eb90194 100644 --- a/src/worker/agent.ts +++ b/src/worker/agent.ts @@ -25,7 +25,7 @@ import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify' import type { ServerConfig } from '../config.js' import { LocalUserFs } from '../fs/local-user-fs.js' import { userRoot } from '../fs/workspace.js' -import { isUserFsErrorCode, UserFsError } from '../fs/user-fs.js' +import { isHomeFileName, isUserFsErrorCode, UserFsError, type HomeFileName } from '../fs/user-fs.js' import { hashUid } from '../isolation.js' import { LocalSpawner } from '../supervisor/orchestrator.js' import { normalizeTunnelTarget, SshTunnel, type WorkerTunnel } from './tunnel.js' @@ -585,6 +585,35 @@ export function buildWorkerAgent( })) ?? reply }) + /** + * **home 下的平台托管配置文件**读(档案 138)—— 为什么必须由 worker 提供: + * 用户卷(含 `home/`)在**本机**,控制面(Manager)隔着网络 ⇒ 它直接读只会读到 + * 自己盘上一个不存在的路径(返回空串、还不报错 ⇒ 静默空操作)。 + * + * ⛔ 只收**裸文件名**且必须在白名单里(`settings.yaml` / `.credentials.yaml`): + * 这是"平台写自己的两个配置文件",⛔ 不是"给远端一个任意文件读接口"。 + * 越界/非法名一律 400 `bad_path`。 + */ + app.post('/fs/home-read', async (request, reply) => { + const body = request.body as { userId?: string; name?: unknown } + if (body.userId === undefined) return reply.code(400).send({ error: 'userId is required' }) + if (!isHomeFileName(body.name)) return reply.code(400).send({ error: 'bad_path' }) + const out = await fsCall(reply, () => userFs.readHomeFile(body.userId as string, body.name as HomeFileName)) + return out === undefined ? reply : { text: out } + }) + + app.post('/fs/home-write', async (request, reply) => { + const body = request.body as { userId?: string; name?: unknown; text?: unknown } + if (body.userId === undefined || typeof body.text !== 'string') { + return reply.code(400).send({ error: 'userId and text are required' }) + } + if (!isHomeFileName(body.name)) return reply.code(400).send({ error: 'bad_path' }) + return (await fsCall(reply, async () => { + await userFs.writeHomeFile(body.userId as string, body.name as HomeFileName, body.text as string) + return { ok: true } + })) ?? reply + }) + /** 本机 dataRoot(Manager 的 RemoteUserFs 用它做 `resolvePath` 的路径数学)。 */ app.get('/fs/root', async () => ({ dataRoot: config.dataRoot })) diff --git a/test/db.test.mjs b/test/db.test.mjs index 52ac788..26b5b8f 100644 --- a/test/db.test.mjs +++ b/test/db.test.mjs @@ -118,6 +118,40 @@ function register(backend, makeAdapter) { } }) + // 档案 138(v11):管理员逐用户授权 —— **默认关闭**、可开可关、且与用户偏好**互相独立**。 + // 这三条判据是门禁的全部内容;它们任一被写反(尤其"默认"那条)都等于门禁不存在。 + test(`${backend}: sharedModelGranted 默认关、可开可关,且与用户偏好互不影响(档案 138)`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + assert.equal(await db.getSharedModelGranted('a'), false, 'v11 默认 false(授权默认关闭)') + assert.equal(await db.setSharedModelGranted('a', true), true) + assert.equal(await db.getSharedModelGranted('a'), true) + // 用户偏好仍是它自己的默认值 —— 授权不改变偏好(两列互不覆盖)。 + assert.equal(await db.getSharedModelEnabled('a'), true, '授权不改动用户侧偏好') + assert.equal(await db.setSharedModelGranted('a', false), true) + assert.equal(await db.getSharedModelGranted('a'), false) + // 未知用户:授权按 false(**失败关闭**),偏好按 true(宁可多给)—— 两条故意相反,钉死它。 + assert.equal(await db.getSharedModelGranted('nobody'), false) + assert.equal(await db.getSharedModelEnabled('nobody'), true) + } finally { + await db.close() + } + }) + + test(`${backend}: listPublicUsers 带出 sharedModelGranted(admin 列表要用)`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + await db.setSharedModelGranted('a', true) + const users = await db.listPublicUsers() + assert.equal(users.length, 1) + assert.equal(users[0].sharedModelGranted, true) + } finally { + await db.close() + } + }) + test(`${backend}: selectCredentialKey 不再关掉别的条目(档案 87)`, async () => { const db = await makeAdapter() try { diff --git a/test/i18n-brand.test.mjs b/test/i18n-brand.test.mjs index 486d8c5..7f0a0d9 100644 --- a/test/i18n-brand.test.mjs +++ b/test/i18n-brand.test.mjs @@ -1,8 +1,8 @@ /** * `web/i18n.js` 的品牌文案渲染回归(2026-09-19,档案 134 配套)。 * - * 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力枢纽」/ - * 英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文** + * 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力网络」(2026-09-19 + * 定名)/英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文** * —— 页面上看不出报错,只有中文用户看到英文名。这类"静默失败"必须靠测试钉住。 * * 做法:用 `node:vm` 跑**仓库里那份真实的 `web/i18n.js`**(不是复制一遍逻辑), @@ -69,13 +69,13 @@ function renderBrand({ search = '', cookie = '', language = 'en-US' } = {}) { return { brand: brandNode.textContent, lang: sandbox.window.I18N.lang } } -test('品牌文案:中文显示「能力枢纽」,英文及其他语言显示 CapabilityNet', () => { +test('品牌文案:中文显示「能力网络」,英文及其他语言显示 CapabilityNet', () => { const cases = [ ['默认(浏览器 en-US)', { language: 'en-US' }, 'CapabilityNet'], - ['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力枢纽'], - ['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力枢纽'], + ['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力网络'], + ['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力网络'], ['英文(?lang=en 覆盖中文浏览器)', { search: '?lang=en', language: 'zh-CN' }, 'CapabilityNet'], - ['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力枢纽'], + ['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力网络'], ['未支持语言(ja)⇒ 回退英文', { language: 'ja-JP' }, 'CapabilityNet'], ] for (const [label, opts, expected] of cases) { diff --git a/test/local-user-fs.test.mjs b/test/local-user-fs.test.mjs index 8e0a85a..eabd628 100644 --- a/test/local-user-fs.test.mjs +++ b/test/local-user-fs.test.mjs @@ -118,3 +118,24 @@ test("readFile: 符号链接逃逸被拒(不跟随工作区内的链接)", a if (!tryLink("/etc/passwd", join(ws, "link.txt"), "file")) return await assert.rejects(() => fs.readFile(USER, "link.txt"), isBadPath) }) + +// ── 档案 138:home 下平台托管配置文件(跨机读写那一层的地基)──────────────────── +// 这组判据存在的理由:落地层原先直接 `join(home_dir, name)` + 本机 fs ⇒ 对"实例在 +// worker 上"的用户静默空操作(读回空串、写到自己盘的野路径)。改成走 UserFs 之后, +// 名字必须**只**能是那两个固定文件名,否则就等于给远端开了一个任意文件读写口。 +test("home 文件:读写往返 + 不存在 ⇒ null(不是抛错)", async (t) => { + const { fs } = makeUser(t) + assert.equal(await fs.readHomeFile(USER, ".credentials.yaml"), null, "首次应为空") + await fs.writeHomeFile(USER, ".credentials.yaml", "version: 1\nrefs:\n A: 'b'\n") + assert.equal(await fs.readHomeFile(USER, ".credentials.yaml"), "version: 1\nrefs:\n A: 'b'\n") + // 两个白名单文件互不串(别把 settings 写到 credentials 上) + assert.equal(await fs.readHomeFile(USER, "settings.yaml"), null) +}) + +test("home 文件:⛔ 只收白名单里的裸文件名(路径/越界名一律 bad_path)", async (t) => { + const { fs } = makeUser(t) + for (const bad of ["../.credentials.yaml", "home/.credentials.yaml", "/etc/passwd", "id_rsa", "", "settings.yaml.bak"]) { + await assert.rejects(() => fs.readHomeFile(USER, bad), isBadPath, `读应拒:${bad}`) + await assert.rejects(() => fs.writeHomeFile(USER, bad, "x"), isBadPath, `写应拒:${bad}`) + } +}) diff --git a/web/admin.html b/web/admin.html index 57877f2..7e5f8bf 100644 --- a/web/admin.html +++ b/web/admin.html @@ -26,7 +26,7 @@ <div class="auth-card" style="max-width: 720px"> <div class="auth-brand"> - <span class="wordmark">能力枢纽</span> + <span class="wordmark">能力网络</span> </div> <p class="auth-sub" id="sub">仅限管理员</p> @@ -43,10 +43,12 @@ <button class="btn ghost small" id="logoutBtn">退出</button> </div> <table class="admin"> - <thead><tr><th>用户名</th><th>角色</th><th>注册时间</th><th>操作</th></tr></thead> + <thead><tr><th>用户名</th><th>角色</th><th>共享模型</th><th>注册时间</th><th>操作</th></tr></thead> <tbody id="users"></tbody> </table> + <p class="auth-sub" style="margin:6px 0 0">「共享模型」= 你在「密钥管理」里配的平台共享模型,<strong>逐个用户开启后</strong>该用户才用得上(也才会在实例的「设置 → 模型设置」里出现);默认关闭。开启即时生效(会重启该用户实例)。</p> + <h3 style="margin:22px 0 8px">存储用量 <span class="auth-sub" id="storageAt" style="font-weight:400"></span></h3> <table class="admin"> <thead><tr><th>用户</th><th>工作区</th><th>会话</th><th>回收站</th><th>可清理</th></tr></thead> @@ -75,7 +77,14 @@ const del = u.role !== 'admin' ? `<button class="btn small ghost" style="color:var(--danger)" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">🗑</button>` : '' - tbody.insertAdjacentHTML('beforeend', `<tr><td>${esc(u.username)}</td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${new Date(u.createdAt).toLocaleString()}</td><td>${actions}${actions && del ? ' ' : ''}${del}</td></tr>`) + // 共享模型:admin 本人不需要(他自己配的就是"共享"的源头)⇒ 显示「—」; + // 待审核的人还没实例,开了也没意义 ⇒ 也显示「—」。其余用户可以开/关。 + const grant = u.role === 'admin' || u.role === 'pending' + ? '<span class="badge">—</span>' + : u.sharedModelGranted + ? `<button class="btn small danger" data-act="grant-off" data-id="${u.id}" title="关闭该用户的平台共享模型">已开启</button>` + : `<button class="btn small ghost" data-act="grant-on" data-id="${u.id}" title="允许该用户使用平台共享模型">已关闭</button>` + tbody.insertAdjacentHTML('beforeend', `<tr><td>${esc(u.username)}</td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${grant}</td><td>${new Date(u.createdAt).toLocaleString()}</td><td>${actions}${actions && del ? ' ' : ''}${del}</td></tr>`) } tbody.onclick = async (event) => { const btn = event.target.closest('button[data-act]') @@ -89,6 +98,17 @@ else alert('删除失败(admin 账号不可删除)') return } + if (act === 'grant-on' || act === 'grant-off') { + const enabled = act === 'grant-on' + const res = await fetch(`/api/admin/users/${id}/models/shared`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled }), + }) + if (res.ok) await loadUsers() + else alert('操作失败') + return + } const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' }) if (res.ok) await loadUsers() else alert('操作失败') diff --git a/web/design.css b/web/design.css index d78f08d..0c71118 100644 --- a/web/design.css +++ b/web/design.css @@ -50,7 +50,7 @@ body { .wordmark { display: inline-flex; align-items: center; } .wordmark svg { height: 100%; width: auto; display: block; } /* 2026-09-19:登录 / 注册页的品牌标识由 **DeepSeek 图形**改为**本平台文字** - (中文「能力枢纽」/ 英文及其他语言 CapabilityNet,走 i18n 词条 `brand.name`)。 + (中文「能力网络」(2026-09-19 定名)/ 英文及其他语言 CapabilityNet,走 i18n 词条 `brand.name`)。 上面那套定高 + svg 的规则对文字不适用 ⇒ 这里补字号 / 字重 / 行高(`height:auto` 覆盖旧定高)。 ⚠️ `.topbar .wordmark`(admin / portal 顶栏)**未动** —— 那两处仍用 svg,用户本轮只要求登录 / 注册页。 */ .auth-brand .wordmark { height: auto; font-size: 19px; font-weight: 600; line-height: 1.2; letter-spacing: 0.2px; color: var(--ink); } diff --git a/web/favicon.svg b/web/favicon.svg index fa042bd..1eb402f 100644 --- a/web/favicon.svg +++ b/web/favicon.svg @@ -1,5 +1,5 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="能力枢纽"> - <title>能力枢纽 + + 能力网络