三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 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 一致)。
142 lines
6.4 KiB
JavaScript
142 lines
6.4 KiB
JavaScript
// LocalUserFs regression tests (node:test, against built lib/ — `npm test`
|
||
// builds first). Focus: the symlink-escape guard layered on top of lexical
|
||
// path containment. A link planted inside the workspace (`ws/link -> /etc`)
|
||
// passes the prefix check, so every operation must reject symlink components
|
||
// before touching the filesystem.
|
||
//
|
||
// Symlink creation is privilege-gated on Windows (junctions work unprivileged,
|
||
// file links need dev mode), so the link-based cases self-skip when the FS
|
||
// refuses to create one.
|
||
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
|
||
import { UserFsError } from '../lib/fs/user-fs.js'
|
||
|
||
const USER = 'u1'
|
||
|
||
function makeUser(t) {
|
||
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-lufs-'))
|
||
t.after(() => rmSync(dataRoot, { recursive: true, force: true }))
|
||
const ws = join(dataRoot, 'users', USER, 'ws')
|
||
mkdirSync(ws, { recursive: true })
|
||
return { fs: new LocalUserFs((id) => join(dataRoot, 'users', id)), dataRoot, ws }
|
||
}
|
||
|
||
/** Create a link; false when the platform refuses (e.g. unprivileged Windows). */
|
||
function tryLink(target, path, type) {
|
||
try {
|
||
symlinkSync(target, path, type)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function isBadPath(err) {
|
||
return err instanceof UserFsError && err.code === 'bad_path'
|
||
}
|
||
|
||
test('upload/mkdir/listDir work through nested directories', async (t) => {
|
||
const { fs } = makeUser(t)
|
||
await fs.mkdir(USER, 'proj')
|
||
assert.equal(await fs.upload(USER, 'proj', 'hello.txt', Buffer.from('hi')), 'hello.txt')
|
||
const entries = await fs.listDir(USER, 'proj')
|
||
assert.deepEqual(entries.map((e) => e.name).sort(), ['hello.txt'])
|
||
})
|
||
|
||
test('lexical traversal is still rejected with bad_path', async (t) => {
|
||
const { fs } = makeUser(t)
|
||
await assert.rejects(() => fs.upload(USER, '../outside', 'x.txt', Buffer.from('x')), isBadPath)
|
||
await assert.rejects(() => fs.listDir(USER, '../../etc'), isBadPath)
|
||
})
|
||
|
||
test('upload through an in-workspace directory symlink is rejected', async (t) => {
|
||
const { fs, dataRoot, ws } = makeUser(t)
|
||
const outside = join(dataRoot, 'outside')
|
||
mkdirSync(outside)
|
||
const type = process.platform === 'win32' ? 'junction' : 'dir'
|
||
if (!tryLink(outside, join(ws, 'link'), type)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
await assert.rejects(() => fs.upload(USER, 'link', 'escaped.txt', Buffer.from('x')), isBadPath)
|
||
await assert.rejects(() => fs.mkdir(USER, 'link/sub'), isBadPath)
|
||
await assert.rejects(() => fs.listDir(USER, 'link'), isBadPath)
|
||
await assert.rejects(() => fs.isDirectory(USER, 'link'), isBadPath)
|
||
})
|
||
|
||
test('upload onto an existing symlinked filename does not follow it', async (t) => {
|
||
const { fs, ws } = makeUser(t)
|
||
writeFileSync(join(ws, 'real.txt'), 'original')
|
||
if (!tryLink(join(ws, 'real.txt'), join(ws, 'alias.txt'), 'file')) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
await assert.rejects(() => fs.upload(USER, '', 'alias.txt', Buffer.from('evil')), isBadPath)
|
||
assert.equal(readFileSync(join(ws, 'real.txt'), 'utf8'), 'original', 'target untouched')
|
||
})
|
||
|
||
test('missing path components end the walk and surface as not_found', async (t) => {
|
||
const { fs } = makeUser(t)
|
||
await assert.rejects(
|
||
() => fs.isDirectory(USER, 'ghost/deep'),
|
||
(err) => err instanceof UserFsError && err.code === 'not_found',
|
||
)
|
||
})
|
||
|
||
|
||
// ─── 档案 56:readFile(门户/实例页「我的文件」下载) ───────────────
|
||
test("readFile: 返回文件名与内容", async (t) => {
|
||
const { fs, ws } = makeUser(t)
|
||
writeFileSync(join(ws, "报告.md"), "# 内容\n")
|
||
const out = await fs.readFile(USER, "报告.md")
|
||
assert.equal(out.name, "报告.md")
|
||
assert.equal(out.data.toString("utf8"), "# 内容\n")
|
||
})
|
||
|
||
test("readFile: 目录→not_a_file;缺失→not_found;超限→too_large", async (t) => {
|
||
const { fs, ws } = makeUser(t)
|
||
mkdirSync(join(ws, "dir"))
|
||
await assert.rejects(() => fs.readFile(USER, "dir"), (e) => e instanceof UserFsError && e.code === "not_a_file")
|
||
await assert.rejects(() => fs.readFile(USER, "ghost.txt"), (e) => e instanceof UserFsError && e.code === "not_found")
|
||
writeFileSync(join(ws, "big.bin"), Buffer.alloc(4096))
|
||
await assert.rejects(() => fs.readFile(USER, "big.bin", 1024), (e) => e instanceof UserFsError && e.code === "too_large")
|
||
})
|
||
|
||
test("readFile: 路径逃逸被拒", async (t) => {
|
||
const { fs } = makeUser(t)
|
||
await assert.rejects(() => fs.readFile(USER, "../outside.txt"), isBadPath)
|
||
await assert.rejects(() => fs.readFile(USER, "../../etc/passwd"), isBadPath)
|
||
})
|
||
|
||
test("readFile: 符号链接逃逸被拒(不跟随工作区内的链接)", async (t) => {
|
||
const { fs, ws } = makeUser(t)
|
||
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}`)
|
||
}
|
||
})
|