平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效

三条线合并入库(同一次部署批次,源码与生产此前已一致):

一、档案 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 一致)。
This commit is contained in:
admin committed 2026-09-19 13:51:01 +08:00
1 parent 971ccc3703
commit c2b7c5ef71
30 files changed
+770 -70

No files matched your search

+34
View File
@@ -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 {
+6 -6
View File
@@ -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) {
+21
View File
@@ -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}`)
}
})