初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
/**
* Admin routes: list users and approve/disable accounts. All guarded by
* `requireAdmin`.
* @module dshs/web/routes/admin
*/
import type { FastifyPluginAsync } from 'fastify'
import { execFileSync, spawn } from 'node:child_process'
import { rm } from 'node:fs/promises'
import { requireAdmin } from '../middleware/authn.js'
import { userRoot } from '../../fs/workspace.js'
/** 档案 36:新用户审批通过后自动铺「功能插件」分区的脚本(幂等)。 */
const ENSURE_BIZ_PLUGINS =
process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs'
export const adminRoutes: FastifyPluginAsync = async (app) => {
// 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du)
app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => {
const { readFileSync } = await import('node:fs')
try {
return JSON.parse(readFileSync(process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json', 'utf8'))
} catch {
return { generatedAt: null, users: [], note: '报告尚未生成(cron 每小时刷新一次)' }
}
})
app.get('/api/admin/users', { preHandler: requireAdmin }, async () => ({
users: await app.db.listPublicUsers(),
}))
app.post('/api/admin/users/:id/approve', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role !== 'pending') return reply.code(409).send({ error: 'not_pending' })
await app.db.setUserRole(id, 'active', request.user?.id)
await app.db.audit(request.user?.id ?? null, 'approve', JSON.stringify({ userId: id }))
// 档案 36:审批通过后异步铺「功能插件」分区(普通用户才能在设置里启停插件)。
// fire-and-forget:不阻塞审批响应;失败由巡检(cron 跑同一脚本)兜底。
const prov = spawn(process.execPath, [ENSURE_BIZ_PLUGINS, id], { stdio: "ignore", detached: true })
prov.on("error", (err) => app.log.warn({ err }, "ensure-biz-plugins 调用失败(将由巡检兜底)"))
prov.unref()
return { ok: true }
})
app.post('/api/admin/users/:id/disable', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role === 'admin') return reply.code(409).send({ error: 'cannot_disable_admin' })
await app.db.setUserRole(id, 'disabled', request.user?.id)
await app.db.deleteUserSessions(id)
await app.supervisor.stop(id) // 停掉该用户运行中的 DSH(待办.md §二)
await app.db.audit(request.user?.id ?? null, 'disable', JSON.stringify({ userId: id }))
return { ok: true }
})
app.post('/api/admin/users/:id/enable', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role !== 'disabled') return reply.code(409).send({ error: 'not_disabled' })
await app.db.setUserRole(id, 'active', request.user?.id)
await app.db.audit(request.user?.id ?? null, 'enable', JSON.stringify({ userId: id }))
return { ok: true }
})
// 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 →
// 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。
app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role === 'admin') return reply.code(409).send({ error: 'cannot_delete_admin' })
await app.supervisor.stop(id) // 停掉运行中的 DSH,避免进程占用刚删的目录
if (!(await app.db.deleteUser(id))) return reply.code(404).send({ error: 'not_found' })
// 数据目录:users/<id>/{home,ws} 整棵删除(force 容忍不存在)
await rm(userRoot(app.config.dataRoot, id), { recursive: true, force: true })
// OS 账号:与 provision-new-users.sh 同规则 dsh-<id 去横线前 20 位>;不存在则忽略
const short = id.replace(/-/g, '').slice(0, 20)
try {
execFileSync('userdel', [`dsh-${short}`], { stdio: 'ignore' })
} catch {
// 账号可能从未被 provision(如 uid 冲突跳过)——DB/目录已清,可接受
}
await app.db.audit(request.user?.id ?? null, 'delete_user', JSON.stringify({ userId: id, username: user.username }))
return { ok: true }
})
/**
* 档案 47:实例共享运行时/工具清单(admin 只读)。
* 数据全部用 shell 取(避免为此新增 import):版本 = 直接执行二进制;
* 基线 = cat /opt/dsh/state/runtime-baseline.json;清单 = cat SHARED-TOOLS.md。
* 升级/卸载不在此做 —— 走 `scripts/install-*.sh`(幂等、带 sha256 校验),
* 升级后必须跑 `runtime-baseline.cjs --accept` 刷新基线(与档案 44 的版本冻结配套)。
*/
app.get('/api/admin/runtime', { preHandler: requireAdmin }, async () => {
const sh = (cmd: string, args: string[], fallback = ''): string => {
try { return execFileSync(cmd, args, { encoding: 'utf8', timeout: 20000 }).trim() } catch { return fallback }
}
const first = (cmd: string, args: string[]): string => sh(cmd, args, '缺失').split('\n')[0]
const items = [
{ name: 'python3', group: '运行时', kind: '可移植发行版', version: first('/usr/local/bin/python3', ['-V']),
source: 'python-build-standalone install_only_stripped', script: 'scripts/install-python-runtime.sh', removable: false },
{ name: 'pip3', group: '运行时', kind: '随 python3', version: first('/usr/local/bin/pip3', ['-V']),
source: '随 python-build-standalone', script: 'scripts/install-python-runtime.sh', removable: false },
{ name: 'node', group: '运行时', kind: '平台预装', version: first('/usr/local/bin/node', ['-v']),
source: '官方 node 分发(平台部署时预装)', script: '—(升级需走独立流程)', removable: false },
{ name: 'npm', group: '运行时', kind: '随 node', version: first('/usr/local/bin/npm', ['-v']),
source: '随 node', script: '—(升级需走独立流程)', removable: false },
{ name: 'jq', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/jq', ['--version']),
source: 'jqlang/jq 官方静态构建', script: 'scripts/install-shared-tools.sh', removable: true },
{ name: 'rg', group: '共享工具', kind: '静态单文件(musl)', version: first('/usr/local/bin/rg', ['--version']),
source: 'BurntSushi/ripgrep musl 静态', script: 'scripts/install-shared-tools.sh', removable: true },
{ name: 'ffmpeg', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/ffmpeg', ['-version']),
source: 'BtbN/FFmpeg-Builds 静态构建', script: 'scripts/install-shared-tools.sh', removable: true },
{ name: 'ffprobe', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/ffprobe', ['-version']),
source: 'BtbN/FFmpeg-Builds 静态构建', script: 'scripts/install-shared-tools.sh', removable: true },
]
// 与基线对比(档案 44)
let baseline: unknown = null
let drift: string[] = []
try {
baseline = JSON.parse(sh('cat', ['/opt/dsh/state/runtime-baseline.json'], '{}'))
const v = (baseline as { versions?: Record<string, string> }).versions ?? {}
const num = (s: string): string => (s.match(/\d+\.\d+(\.\d+)?/) ?? [''])[0]
const pair: Array<[string, string]> = [
['python3', num(first('/usr/local/bin/python3', ['-V']))],
['pip3', num(first('/usr/local/bin/pip3', ['-V']))],
['node', num(first('/usr/local/bin/node', ['-v']))],
['npm', num(first('/usr/local/bin/npm', ['-v']))],
['jq', num(first('/usr/local/bin/jq', ['--version']))],
['rg', num(first('/usr/local/bin/rg', ['--version']))],
]
drift = pair.filter(([k, now]) => v[k] !== undefined && v[k] !== now).map(([k, now]) => `${k}: 基线 ${v[k]} → 现在 ${now}`)
} catch { /* 基线缺失不报错 */ }
const bytes = Number(sh('du', ['-sk', '/usr/local/dsh-runtime'], '0').split(/\s+/)[0] || 0) * 1024
return {
runtimeDir: { path: '/usr/local/dsh-runtime', bytes, installedAt: sh('stat', ['-c', '%y', '/usr/local/dsh-runtime']) },
items,
baseline,
drift,
manifest: sh('cat', ['/usr/local/dsh-runtime/SHARED-TOOLS.md']),
installScripts: ['scripts/install-python-runtime.sh', 'scripts/install-shared-tools.sh'],
baselineScript: 'scripts/runtime-baseline.cjs --accept',
note: '实例内 /usr 为只读挂载 + /usr/local/bin 在实例 PATH 首位 → 这里装的东西对全部用户立即生效,用户无法自行安装或修改。',
}
})
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Auth routes: self-registration (→ pending), login, logout, and the current
* identity. Registration always yields a `pending` user; an admin approves it.
* @module dshs/web/routes/auth
*/
import type { FastifyPluginAsync } from 'fastify'
import { randomUUID } from 'node:crypto'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js'
import {
clearSessionCookie,
hashPassword,
hashSessionToken,
newSessionToken,
parseCookie,
sessionCookie,
verifyPassword,
} from '../auth.js'
const registerSchema = {
body: {
type: 'object',
required: ['username', 'password'],
additionalProperties: false,
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
password: { type: 'string', minLength: 8, maxLength: 128 },
},
},
} as const
const loginSchema = {
body: {
type: 'object',
required: ['username', 'password'],
additionalProperties: false,
properties: {
username: { type: 'string', maxLength: 64 },
password: { type: 'string', maxLength: 128 },
},
},
} as const
interface Credentials {
username: string
password: string
}
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/api/auth/register',
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
if ((await app.db.findUserByUsername(username)) !== undefined) {
return reply.code(409).send({ error: 'username_taken' })
}
const id = randomUUID()
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
const passHash = await hashPassword(password)
// Create the user first so `initUserRoot` resolves the DB-assigned uid
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
await app.userFs.initUserRoot(id, user.uid ?? undefined)
await app.db.audit(id, 'register', JSON.stringify({ username }))
return reply.code(201).send({ user: { id, username, role: 'pending' } })
},
)
app.post(
'/api/auth/login',
{ schema: loginSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
const user = await app.db.findUserByUsername(username)
if (user === undefined || !(await verifyPassword(password, user.pass_hash))) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
if (user.role === 'pending') return reply.code(403).send({ error: 'pending_review' })
if (user.role === 'disabled') return reply.code(403).send({ error: 'disabled' })
const token = newSessionToken()
// 单活跃会话(last-wins):新登录顶掉该账号此前所有会话——旧浏览器的
// sid 立即失效,proxy/API 对其返回 401,需重新登录后才能继续访问。
await app.db.deleteUserSessions(user.id)
await app.db.createSession({
tokenHash: hashSessionToken(token),
userId: user.id,
expiresAt: Date.now() + app.config.sessionTtlSeconds * 1000,
ip: request.ip,
userAgent: request.headers['user-agent'],
})
await app.db.audit(user.id, 'login', null)
reply.header(
'set-cookie',
sessionCookie(token, app.config.sessionTtlSeconds, app.config.secureCookies, app.config.cookieDomain),
)
return { user: toPublicUser(user) }
},
)
app.post('/api/auth/logout', async (request, reply) => {
const token = parseCookie(request.headers.cookie, 'sid')
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
return { ok: true }
})
// ---- 同源退出页:会话内「退出登录」整页跳转(清 sid + 删 session → 回登录页) ----
app.get('/logout', async (request, reply) => {
const token = parseCookie(request.headers.cookie, 'sid')
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
return reply.redirect('/')
})
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => ({ user: request.user }))
const keyAddSchema = {
body: {
type: 'object',
required: ['name', 'apiKey'],
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1, maxLength: 32 },
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
},
},
} as const
// ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey),
// 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ----
app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({
keys: await app.db.listCredentialKeys(request.user!.id),
}))
app.post('/api/me/keys', { preHandler: requireAdmin, schema: keyAddSchema }, async (request, reply) => {
const { name, apiKey } = request.body as { name: string; apiKey: string }
const cleanName = name.trim()
if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) {
return reply.code(400).send({ error: 'invalid_name' })
}
// Header-safe charset only: reject spaces, quotes, non-ASCII, etc.
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) {
return reply.code(400).send({ error: 'invalid_api_key' })
}
const key = await app.db.setCredentialKey(
request.user!.id,
cleanName,
encrypt(apiKey, deriveKey(app.config.encryptionSecret)),
)
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName }))
// 统一 KEY:admin 换 key 后广播重启所有用户的实例(DEEPSEEK_API_KEY 是 spawn 时
// 注入 env 的快照,不重启运行中的实例不会刷新)。
await app.supervisor.restartAllMains()
return { key }
})
app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
// 切换启用的全局 key 后同样广播刷新
await app.supervisor.restartAllMains()
return { ok: true }
})
app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
return { ok: true }
})
}
+742
View File
@@ -0,0 +1,742 @@
/**
* Business-plugin (系统外插件) candidate-pool routes. Admin-only.
*
* Upload contract: a `.tgz` bundle containing a dsh cordis bundle — a
* `package.json` with `name` (npm package-name shape, scope allowed). The
* archive is extracted into staging, its `package.json` is validated, and its
* text files are scanned for obviously-malicious patterns (安全检测) before it
* is admitted to the pool.
*
* Same-name upload REPLACES the pool entry: the previous `.tgz` file is removed
* in full first (替换策略, not overwrite — stale files cannot survive).
*
* The pool is a set of `.tgz` files under `<dataRoot>/business-plugins/` plus a
* `business_plugins` row of metadata. Users enable a plugin per-instance in a
* later phase (档案 16 阶段 2), which copies the bundle into their profile.
* @module dshs/web/routes/business-plugins
*/
import type { FastifyPluginAsync } from 'fastify'
import { execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdir, rm, rename } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { httpError, scanDirDetailed, type ScanBlock, type ScanFinding } from '../security-scan.js'
import { checkPluginCompat, type CompatResult } from '../plugin-compat.js'
import { userRoot } from '../../fs/workspace.js'
/** Route-level body limit for uploads (base64 ~4/3 of archive size). */
const UPLOAD_BODY_LIMIT = 180 * 1024 * 1024
const STAGE_PREFIX = '.biz-plugin-'
const STAGE_ID_RE = /^\.biz-plugin-[0-9a-f]{12}$/
const STAGE_TTL_MS = 10 * 60 * 1000
/** npm package-name shape, scope (`@scope/name`) allowed. */
const PKG_NAME_RE = /^(?:@[a-z0-9][a-z0-9-]*\/)?[a-z0-9][a-z0-9-]*$/
/** Sanitize a package name into a flat, path-safe filename. */
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, '_')
}
interface ProfileManifest {
bundles: string[]
deps: Record<string, string>
}
function readProfileManifest(dir: string): ProfileManifest {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
dsh?: { profile?: { bundles?: string[] } }
dependencies?: Record<string, string>
}
return { bundles: pkg.dsh?.profile?.bundles ?? [], deps: pkg.dependencies ?? {} }
} catch {
return { bundles: [], deps: {} }
}
}
function writeProfileManifest(dir: string, bundles: string[], deps: Record<string, string>): void {
const path = join(dir, 'package.json')
let pkg: Record<string, unknown>
try {
pkg = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
} catch {
pkg = { name: 'dsh-profile-web', private: true }
}
const dsh = (pkg.dsh ?? {}) as Record<string, unknown>
const profile = (dsh.profile ?? {}) as Record<string, unknown>
profile.bundles = bundles
dsh.profile = profile
pkg.dsh = dsh
pkg.dependencies = deps
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
}
/** Find the first `package.json` under `root` (depth ≤ 3), tolerant of the npm
* `package/` wrapper directory produced by `npm pack`. */
function findPackageJson(root: string, depth = 0): string | null {
if (depth > 3) return null
const candidate = join(root, 'package.json')
if (existsSync(candidate)) return candidate
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
if (lstatSync(abs).isDirectory() && !entry.startsWith('.')) {
const found = findPackageJson(abs, depth + 1)
if (found !== null) return found
}
}
return null
}
interface StagedPlugin {
stage: string
tgzName: string
name: string
description: string | null
version: string | null
fileCount: number
/** P0 命中(**不阻断**,交调用方裁决:默认拒绝,或 admin 显式信任后放行)。 */
blocked: ScanBlock[]
/** P1 告警(不阻断,记录 + 可回显)。 */
warnings: ScanFinding[]
/**
* 兼容性预检结果(档案 71):`incompatible` 表示与当前平台 dsh 版本不兼容
* (判据:`@deepseek-ai/*` 依赖范围 / 导出符号),**默认拒绝投放**,admin 可显式信任。
* `unknown` = 静态判不了(放行 + 标记待装后复核)。
*/
compat: CompatResult
}
/** Extract + validate + scan a `.tgz` bundle under `<dataRoot>/<staging>`. */
export async function stageTgzArchive(dataRoot: string, archive: Buffer): Promise<StagedPlugin> {
const stage = join(dataRoot, STAGE_PREFIX + randomBytes(6).toString('hex'))
const payload = join(stage, 'payload.tgz')
const unzipDir = join(stage, 'unzip')
mkdirSync(unzipDir, { recursive: true, mode: 0o700 })
try {
writeFileSync(payload, archive)
// 1. List members, reject absolute / traversal paths.
const members = execFileSync('tar', ['-tzf', payload], { encoding: 'utf8', timeout: 30000 }).split('\n')
let fileCount = 0
for (let raw of members) {
raw = raw.trim()
if (raw === '' || raw.startsWith('./')) raw = raw.replace(/^\.\/+/, '')
if (raw === '') continue
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) throw httpError(400, `tgz member uses an absolute path: ${raw}`)
if (raw.split('/').includes('..')) throw httpError(400, `tgz member escapes the root: ${raw}`)
if (!raw.endsWith('/')) fileCount += 1
}
if (fileCount === 0) throw httpError(400, 'tgz 为空(无文件)')
// 2. Extract.
execFileSync('tar', ['-xzf', payload, '-C', unzipDir], { timeout: 120000 })
// 3. Validate package.json.
const pkgJsonPath = findPackageJson(unzipDir)
if (pkgJsonPath === null) throw httpError(400, 'tgz 缺少 package.json —— 不是 dsh 插件包')
let pkg: { name?: unknown; version?: unknown; description?: unknown }
try {
pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as typeof pkg
} catch {
throw httpError(400, 'package.json 解析失败')
}
const name = typeof pkg.name === 'string' ? pkg.name.trim() : ''
if (name === '' || !PKG_NAME_RE.test(name)) {
throw httpError(400, `package.json name 无效(须为 npm 包名,可含 @scope):"${name}"`)
}
const version = typeof pkg.version === 'string' ? pkg.version.trim() : null
const description = typeof pkg.description === 'string' ? pkg.description.trim() : null
// 4. Security scan (危险内容扫描).
// **收集式**:P0 命中不在此处抛出,而是随结构返回交调用方裁决。理由:命中常来自文档里的
// 说明性字样(典型:第三方插件的 README 教用户把 key 写进 `.credentials.yaml`,被
// `/\.credentials\.yaml/` 规则判成「读取实例会话密钥」)—— 直接 400 会让 admin 看不到
// 命中详情、也无从对**已人工确认**的包放行。调用方(上传接口)默认拒绝并把逐条命中回显,
// 只有 admin **显式声明信任**(并写 audit)后才放行。
const scanned = scanDirDetailed(unzipDir)
if (scanned.blocked.length > 0 || scanned.warnings.length > 0) {
process.stderr.write(
`[upload-scan] ${JSON.stringify({
name,
blocked: scanned.blocked.length,
warnings: scanned.warnings.length,
detail: scanned.blocked.slice(0, 10),
})}\n`,
)
}
// 5. 兼容性预检(档案 71)。
// 与安全扫描同一套裁决方式:**不在此处抛出**,把结果随结构返回交调用方裁决
// (`incompatible` 默认拒绝并把逐条依据回显,admin 显式信任后才放行并留痕)。
// 为什么必须在投放时判:不兼容的插件装上去会让实例 `plugin tree failed to load`
// → 崩溃循环(档案 70 的 anysearch 事故就是这么发生的)。
const compat = await checkPluginCompat(unzipDir)
if (compat.level !== 'ok') {
process.stderr.write(
`[upload-compat] ${JSON.stringify({
name,
level: compat.level,
findings: compat.findings.slice(0, 10),
note: compat.note ?? null,
})}\n`,
)
}
return {
stage,
tgzName: sanitizeFileName(name) + '.tgz',
name,
description,
version,
fileCount,
blocked: scanned.blocked,
warnings: scanned.warnings,
compat,
}
} catch (err) {
rm(stage, { recursive: true, force: true }).catch(() => undefined)
throw err
}
}
function collectStaleStages(dataRoot: string): string[] {
let names: string[] = []
try {
names = readdirSync(dataRoot).filter((n) => n.startsWith(STAGE_PREFIX))
} catch {
return []
}
const stale: string[] = []
for (const name of names) {
try {
if (Date.now() - lstatSync(join(dataRoot, name)).mtimeMs > STAGE_TTL_MS) stale.push(name)
} catch {
stale.push(name)
}
}
return stale
}
// ── web provider 联动(2026-09-12 · 档案 65)──────────────────────────────
// 为什么需要:dsh 的 web provider 选择是**单选** —— 显式配置即硬绑定(provider 一旦不在就报
// CONFIGURED_MISSING 且**不回落**),未配置时只有「恰好一个可用」才自动选,多个可用直接
// AMBIGUOUS 报错。于是「两个 provider 共存 + 用户可任意启停」必然出现坏状态。
// 解法:平台在插件启停后**按当前 bundles 重算**这一段 ——
// 插件在 → 写死它声明的 searchProvider;插件不在 → 整段删除(回到「只有一个可用」时自动选)。
// 平台策略:`fetchProvider` 恒为本地 `http`(保留 SSRF 防护),**忽略**插件对 fetch 的声明。
//
// 放在模块级并导出(不依赖 `app`),以便被独立验证。
export const WEB_PROVIDER_OPEN = '# >>> platform: web-provider (managed by business-plugins)'
export const WEB_PROVIDER_CLOSE = '# <<< platform: web-provider'
/** 历史托管段(`scripts/ensure-anysearch-admin.cjs` 早期直铺时写的),同步时一并清掉。 */
export const WEB_PROVIDER_LEGACY: ReadonlyArray<readonly [string, string]> = [
['# >>> platform: anysearch-search', '# <<< platform: anysearch-search'],
]
/** 扫描已启用 bundle 自带的 cordis.patch.yml,收集它们对 `dsh-web` 的 provider 声明。 */
export function collectWebProviderClaims(dir: string, bundles: readonly string[]): { search: string | null } {
let search: string | null = null
for (const b of bundles) {
if (b.startsWith('@deepseek-ai/')) continue
let text: string
try {
text = readFileSync(join(dir, 'node_modules', b, 'cordis.patch.yml'), 'utf8')
} catch {
continue
}
let inWeb = false
let inConfig = false
for (const raw of text.split('\n')) {
const line = raw.replace(/\s+$/, '')
const idm = /^-\s+id:\s*(\S+)/.exec(line)
if (idm !== null) {
inWeb = idm[1] === 'web'
inConfig = false
continue
}
if (!inWeb) continue
if (/^\s+config:\s*$/.test(line)) {
inConfig = true
continue
}
if (!inConfig) continue
const sm = /^\s+searchProvider:\s*(\S+)/.exec(line)
if (sm !== null && search === null) search = sm[1]
}
}
return { search }
}
/** 按当前 bundles 重算 profile 的 web provider 托管段(幂等:先摘旧段,再按需追加)。 */
export function syncWebProviderPatch(dir: string): void {
const path = join(dir, 'cordis.patch.yml')
let text = existsSync(path) ? readFileSync(path, 'utf8') : ''
const blocks: ReadonlyArray<readonly [string, string]> = [[WEB_PROVIDER_OPEN, WEB_PROVIDER_CLOSE], ...WEB_PROVIDER_LEGACY]
for (const [open, close] of blocks) {
const s = text.indexOf(open)
const e = text.indexOf(close)
if (s < 0 || e <= s) continue
const lineStart = text.lastIndexOf('\n', s)
text = text.slice(0, lineStart < 0 ? s : lineStart) + text.slice(e + close.length)
}
const { search } = collectWebProviderClaims(dir, readProfileManifest(dir).bundles)
if (search !== null) {
text = text.replace(/\s*$/, '\n') + [
'',
WEB_PROVIDER_OPEN,
'# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。',
'- id: web',
' config:',
` searchProvider: ${search}`,
' fetchProvider: http',
WEB_PROVIDER_CLOSE,
'',
].join('\n')
}
writeFileSync(path, text.replace(/^\s*\n/, ''))
}
export const businessPluginRoutes: FastifyPluginAsync = async (app) => {
const poolDir = (): string => join(app.config.dataRoot, 'business-plugins')
const gc = (): void => {
for (const stale of collectStaleStages(app.config.dataRoot)) {
rm(join(app.config.dataRoot, stale), { recursive: true, force: true }).catch(() => undefined)
}
}
app.get('/api/plugins/business', { preHandler: requireAdmin }, async () => {
return { plugins: await app.db.listBusinessPlugins() }
})
app.post(
'/api/plugins/business',
{ preHandler: requireAdmin, bodyLimit: UPLOAD_BODY_LIMIT },
async (request, reply) => {
const body = request.body as {
file?: string
filename?: string
/** admin 对 P0 命中的显式信任声明;缺省时命中即拒绝(fail-closed)。 */
trust?: { confirmed?: boolean; reason?: string }
}
try {
const filename = body.filename ?? ''
if (!basename(filename).toLowerCase().endsWith('.tgz')) throw httpError(400, '仅支持 .tgz 文件')
const archive = Buffer.from(body.file ?? '', 'base64')
if (archive.length === 0) throw httpError(400, 'empty archive payload')
gc()
const staged = await stageTgzArchive(app.config.dataRoot, archive)
// 安全检测裁决:默认 fail-closed —— P0 命中即拒绝,但把逐条命中回显给 admin
//(旧行为只有一句脱敏文案,admin 无从判断)。admin 显式声明信任后才放行并留痕。
const trusted = body.trust?.confirmed === true
if (staged.blocked.length > 0 && !trusted) {
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
return reply.code(409).send({
error: 'scan_blocked',
message: '安全检测命中 P0 规则,已拒绝投放。请逐条查看命中内容;确认该包可信后,再提交并在请求中声明信任(会记入审计日志)。',
blocked: staged.blocked,
warnings: staged.warnings,
})
}
// 兼容性裁决(档案 71):同一套 fail-closed + 显式信任。
// 判据 A = `@deepseek-ai/*` 依赖范围是否接受平台版本;判据 B = import 的符号是否在平台包导出里。
if (staged.compat.level === 'incompatible' && !trusted) {
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
return reply.code(409).send({
error: 'compat_incompatible',
message: '该插件与当前平台 dsh 版本不兼容,已拒绝投放(判据:依赖版本范围 / 导出符号)。请逐条查看依据;若已人工确认可用,再提交并在请求中声明信任(会记入审计日志)。',
compat: staged.compat,
warnings: staged.warnings,
})
}
// 替换策略:同名先删旧 tgz(旧文件无残留),再落新包。
const dir = poolDir()
await mkdir(dir, { recursive: true, mode: 0o755 })
const existing = await app.db.findBusinessPlugin(staged.name)
if (existing !== undefined) {
if (existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true })
}
const destPath = join(dir, staged.tgzName)
await rename(join(staged.stage, 'payload.tgz'), destPath)
const plugin = await app.db.upsertBusinessPlugin({
id: staged.name,
name: staged.name,
description: staged.description,
version: staged.version,
tgzPath: destPath,
fileSize: archive.length,
uploadedBy: request.user?.id ?? null,
})
const compatOverrode = trusted && staged.compat.level === 'incompatible'
const overrode = trusted && (staged.blocked.length > 0 || compatOverrode)
await app.db.audit(
request.user?.id ?? null,
'upload_business_plugin',
JSON.stringify({
name: plugin.name,
version: plugin.version,
trustedOverride: overrode,
compatLevel: staged.compat.level,
}),
)
if (overrode) {
await app.db.audit(
request.user?.id ?? null,
'trust_business_plugin',
JSON.stringify({
name: staged.name,
version: staged.version,
blocked: staged.blocked,
compat: compatOverrode ? staged.compat : undefined,
reason: body.trust?.reason ?? '',
}),
)
}
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
return {
ok: true,
plugin,
replaced: existing !== undefined,
trustedOverride: overrode,
compat: staged.compat,
warnings: staged.warnings,
}
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
app.delete('/api/plugins/business/:id', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!PKG_NAME_RE.test(id)) return reply.code(400).send({ error: 'invalid_plugin_id' })
const existing = await app.db.findBusinessPlugin(id)
if (existing === undefined) return reply.code(404).send({ error: 'not_found' })
if (existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true })
await app.db.deleteBusinessPlugin(id)
await app.db.audit(request.user?.id ?? null, 'delete_business_plugin', JSON.stringify({ name: id }))
return { ok: true }
})
// ── mine: per-user enable/disable of business plugins(异步 + pnpm) ──────
// 启用 = `pnpm add file:<tgz>`(装插件 + 依赖,pnpm 全局 store 硬链复用依赖)+ reconcile
// bundles;禁用 = `pnpm remove`(依赖从 dependencies 移除,重新启用靠 store 硬链秒恢复)。
// 改完重启实例。安装异步执行:POST 返回 taskId,前端轮询 GET task/:id 看「阶段 + 状态」
//(B 方案:不滚日志,失败给友好文案)。
const profileDir = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'home', 'profiles', 'web')
/** 该用户的 `<root>/ws` —— pnpm 的 HOME(store / cache 落在这里)。 */
const wsDir = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'ws')
/**
* 该用户的 Linux uid/gid —— 取 `<root>/home` 的属主(编排器创建时就 chown 给了该用户)。
* 不查 DB:uid 列与本目录属主由同一次创建写入,读目录少一处耦合。
*/
const uidOf = (userId: string): number => lstatSync(join(userRoot(app.config.dataRoot, userId), 'home')).uid
/**
* **以该用户身份**跑 pnpm(setpriv 降权)。
*
* 为什么必须有:这条路(门户候选池启停)早期直接**以平台进程身份**跑 pnpm,装出来的文件属主是
* root —— 此后该用户自己跑任何 `pnpm add/remove` 都会撞 `EACCES`,插件彻底装不上
* (2026-09-12 实测:guest profile 下积了 561 个 root 属主项,升级直接失败)。
* 平台侧脚本(`ensure-biz-plugins.cjs` 等)一直用的是 setpriv 正姿,两条路线不同所以没暴露。
*/
const runPnpmAs = (userId: string, args: readonly string[], cwd: string): void => {
const uid = uidOf(userId)
execFileSync('setpriv', [
'--reuid', String(uid), '--regid', String(uid), '--clear-groups',
'env', `HOME=${wsDir(userId)}`,
'pnpm', ...args,
], { cwd, timeout: 180000, stdio: 'pipe' })
}
/**
* 属主自愈:把 profile 下 **root 属主**的残留项还给该用户(历史根因留下的存量)。
*
* 每次改插件前跑一次 —— 这是「修根因 + 防复发」里的防复发那一半:即便将来**别的**写入路径
* 又污染了属主,用户下次启停插件时也会被自动清理,不必再人工排查。
* 用 `find -exec chown +` 而不是一次传全部路径,避免路径过多撞命令行长度上限。
*
* @param userId - 目标用户。
* @returns 修复的条目数(0 = 本来就干净)。
*/
const healOwnership = (userId: string): number => {
const dir = profileDir(userId)
let count = 0
try {
const out = execFileSync('find', [dir, '-user', 'root'], { encoding: 'utf8', timeout: 60000 })
count = out.split('\n').filter((line) => line.trim() !== '').length
if (count === 0) return 0
} catch {
return 0
}
const uid = uidOf(userId)
try {
// `-h`:`.bin/*` 是指向别处的符号链接,要改链接本身而不是它的目标。
execFileSync('find', [dir, '-user', 'root', '-exec', 'chown', '-h', `${uid}:${uid}`, '{}', '+'], { timeout: 180000 })
} catch {
return 0
}
return count
}
// pnpm 的 store 位置由 HOME 决定;dsh 跑 pnpm 时 HOME 指向用户 ws,store 落在
// `<root>/ws/.local/share/pnpm/store/v3`(.modules.yaml 记录)。平台跑 pnpm 必须设同样的
// HOME,否则会报 ERR_PNPM_UNEXPECTED_STORE —— 见上面 `runPnpmAs` 里的 `HOME=<ws>`。
interface PluginTask {
status: 'pending' | 'running' | 'success' | 'failed' | 'timeout'
stage: string
error?: string
enabled?: string[]
/** 档案 34:本轮因「启用后实例起不来」被自动摘掉的插件。 */
rejected?: Array<{ id: string; reason: string }>
restarted: boolean
startedAt: number
updatedAt: number
}
const tasks = new Map<string, PluginTask>()
/** 一个依赖是否带 dsh.bundle.patch(= 是 bundle,对齐 dsh 的 isBundle 判定)。 */
function isBundle(dir: string, dep: string): boolean {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8')) as {
dsh?: { bundle?: { patch?: unknown } }
}
return pkg.dsh?.bundle?.patch !== undefined
} catch {
return false
}
}
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles,
* 非 @deepseek-ai 模板且已不在 dependencies 的从 bundles 移除。返回最终 bundles。 */
function reconcileBundles(dir: string): string[] {
const path = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8')) as {
dependencies?: Record<string, string>
dsh?: { profile?: { bundles?: string[] } }
}
const deps = Object.keys(pkg.dependencies ?? {})
const bundles = pkg.dsh?.profile?.bundles ?? []
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
for (const dep of deps) {
if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
}
pkg.dsh = pkg.dsh ?? {}
pkg.dsh.profile = pkg.dsh.profile ?? {}
pkg.dsh.profile.bundles = kept
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
return kept
}
/** 把 pnpm 的原始报错翻译成用户能懂的友好文案。 */
function friendlyError(err: unknown): string {
const message = String((err as Error)?.message ?? err)
const stderr = String((err as { stderr?: Buffer })?.stderr ?? '')
const text = stderr || message
if (text.includes('ETIMEDOUT') || text.includes('timed out')) return '安装超时,请重试'
if (text.includes('No matching version')) return '插件版本不匹配'
if (text.includes('ENOTFOUND') || text.includes('getaddrinfo')) return '网络连接失败,请重试'
if (text.includes('Not Found') || text.includes('E404')) return '依赖包不存在,请重试'
return '安装失败,请重试或联系管理员'
}
// ── 档案 34:profile 快照与还原(插件启用失败时回滚用)────────────────
// 只快照我们会改动的文件;node_modules 不回滚(无害,重装很快,回滚反而慢且易碎)。
const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'] as const
function snapshotProfile(dir: string): Record<string, string | null> {
const snap: Record<string, string | null> = {}
for (const f of SNAPSHOT_FILES) {
const p = join(dir, f)
snap[f] = existsSync(p) ? readFileSync(p, 'utf8') : null
}
return snap
}
function restoreProfile(dir: string, snap: Record<string, string | null>): void {
for (const [f, content] of Object.entries(snap)) {
const p = join(dir, f)
if (content === null) {
if (existsSync(p)) rmSync(p, { force: true })
} else {
writeFileSync(p, content)
}
}
}
app.get('/api/plugins/mine', { preHandler: requireAuth }, async (request) => {
const user = request.user!
const { bundles } = readProfileManifest(profileDir(user.id))
const enabled = new Set(bundles)
const plugins = await app.db.listBusinessPlugins()
return {
plugins: plugins.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
version: p.version,
fileSize: p.fileSize,
enabled: enabled.has(p.id),
})),
}
})
app.post('/api/plugins/mine/apply', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const body = request.body as { plugins?: Array<{ id: string; enabled: boolean }> }
const selections = body.plugins ?? []
for (const sel of selections) {
if (!PKG_NAME_RE.test(sel.id)) return reply.code(400).send({ error: `invalid plugin id: ${sel.id}` })
}
const dir = profileDir(user.id)
await mkdir(dir, { recursive: true })
const { bundles } = readProfileManifest(dir)
const enabledSet = new Set(bundles)
const toEnable = selections.filter((s) => s.enabled && !enabledSet.has(s.id))
const toDisable = selections.filter((s) => !s.enabled && enabledSet.has(s.id))
if (toEnable.length === 0 && toDisable.length === 0) return { ok: true, restarted: false, noop: true }
const taskId = randomBytes(8).toString('hex')
tasks.set(taskId, { status: 'pending', stage: '排队中', restarted: false, startedAt: Date.now(), updatedAt: Date.now() })
void (async () => {
const task = tasks.get(taskId)!
task.status = 'running'
const setStage = (stage: string): void => {
task.stage = stage
task.updatedAt = Date.now()
}
setStage('准备中…')
// 档案 34:应用前快照,供探活失败时回滚。
const snap = snapshotProfile(dir)
const applied: string[] = []
const rejected: Array<{ id: string; reason: string }> = []
const install = async (id: string): Promise<void> => {
const plugin = await app.db.findBusinessPlugin(id)
if (plugin === undefined) throw httpError(404, `插件不在候选池:${id}`)
// 先自愈存量属主污染 —— 否则以用户身份跑 pnpm 连旧文件都覆盖不了(EACCES 直接失败)。
const healed = healOwnership(user.id)
if (healed > 0) {
await app.db.audit(user.id, 'heal_plugin_ownership', JSON.stringify({ stage: `install:${id}`, healed }))
}
runPnpmAs(user.id, ['add', 'file:' + plugin.tgzPath, '--ignore-workspace-root-check', '--reporter', 'silent'], dir)
reconcileBundles(dir)
syncWebProviderPatch(dir)
}
const uninstall = async (id: string): Promise<void> => {
const healed = healOwnership(user.id)
if (healed > 0) {
await app.db.audit(user.id, 'heal_plugin_ownership', JSON.stringify({ stage: `uninstall:${id}`, healed }))
}
runPnpmAs(user.id, ['remove', id, '-w', '--reporter', 'silent'], dir)
reconcileBundles(dir)
syncWebProviderPatch(dir)
}
const restartProbe = async (): Promise<{ ok: boolean; reason: string }> => {
await app.userFs.writeHandoff(user.id, JSON.stringify({ command: '', createdAt: Date.now() }))
return await app.supervisor.restartAndProbe(user.id)
}
try {
// ① 禁用项:pnpm remove 不引入新代码,永远安全 → 不需要探活。
for (const sel of toDisable) {
setStage(`正在停用 ${sel.id}…`)
await uninstall(sel.id)
}
if (toEnable.length > 0) {
// ② 先整批试一次:正常路径(绝大多数)只花 1 次重启,与旧行为一致。
setStage(`正在安装 ${toEnable.length} 个插件…`)
for (const sel of toEnable) await install(sel.id)
setStage('正在重启实例并探活…')
const probe = await restartProbe()
if (probe.ok) {
applied.push(...toEnable.map((s) => s.id))
} else {
// ③ 探活失败 → 回滚到快照,再逐插件隔离定位:坏插件单独摘掉,好插件保留。
setStage('实例未能启动,正在定位问题插件…')
restoreProfile(dir, snap)
for (let i = 0; i < toEnable.length; i++) {
const sel = toEnable[i]
setStage(`正在定位问题插件(${i + 1}/${toEnable.length}):${sel.id}`)
await install(sel.id)
const p = await restartProbe()
if (p.ok) {
applied.push(sel.id)
continue
}
setStage(`插件 ${sel.id} 与实例不兼容,已自动禁用`)
// 档案 79(2026-09-13):**必须 await** —— uninstall 是 async,缺 await 时它的 rejection
// 会逃出这个同步 try/catch,变成 unhandled rejection ⇒ **整个 orchestrator 进程退出**
// (07:43:58 实证:pnpm remove 报 Unknown option → 平台被带崩、全体用户瞬断)。
try { await uninstall(sel.id) } catch { /* 尽力而为 */ }
rejected.push({ id: sel.id, reason: p.reason })
await app.db.audit(user.id, 'plugin_incident', JSON.stringify({ pluginId: sel.id, reason: p.reason }))
}
// 定位循环结束时实例可能停在「刚摘掉坏插件」的状态 → 再确认一次。
setStage('正在恢复实例…')
await restartProbe()
}
}
const finalBundles = readProfileManifest(dir).bundles
task.enabled = finalBundles
task.rejected = rejected
task.restarted = true
task.status = 'success'
task.stage = rejected.length === 0
? '完成'
: `完成:${applied.length} 个已启用,${rejected.length} 个已自动禁用`
if (rejected.length > 0) {
task.error = `以下插件与实例不兼容,已自动禁用:${rejected.map((r) => r.id).join('、')}`
}
task.updatedAt = Date.now()
await app.db.audit(user.id, 'apply_business_plugins', JSON.stringify({ plugins: selections.map((s) => s.id), applied, rejected }))
} catch (err) {
// 兜底:任何异常都把 profile 还原,绝不让用户实例停在半应用状态。
try { restoreProfile(dir, snap) } catch { /* ignore */ }
try { await restartProbe() } catch { /* ignore */ }
const friendly = friendlyError(err)
task.status = friendly === '安装超时,请重试' ? 'timeout' : 'failed'
task.stage = '失败'
task.error = friendly
task.updatedAt = Date.now()
}
})().catch((err: unknown) => {
// 档案 79 加固(2026-09-13):**双保险** —— 任何逃出内层 try/catch 的异常都不许终止进程。
// 没有这一层时,一次 uninstall 失败就把整个 dshs 带崩(全体用户瞬断 + 所有实例被停)。
// 注意:`task` 是 IIFE 内的局部量,这里只能按 taskId 从 tasks 表取回。
try {
const t = tasks.get(taskId)
if (t !== undefined) {
t.status = 'failed'
t.stage = '失败'
t.error = String((err as Error)?.message ?? err)
t.updatedAt = Date.now()
}
} catch { /* ignore */ }
try { console.error('[business-plugins] apply task crashed:', err) } catch { /* ignore */ }
})
return { ok: true, taskId }
})
app.get('/api/plugins/mine/task/:id', { preHandler: requireAuth }, async (request, reply) => {
const { id } = request.params as { id: string }
const task = tasks.get(id)
if (task === undefined) return reply.code(404).send({ error: 'not_found' })
return { ...task }
})
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Desktop / filesystem routes: list the user's workspace, create a folder, and
* upload a file. Every path is resolved against the caller's own workspace
* root, so one user can never address another user's files.
*
* The routes never touch `node:fs` themselves — they go through the
* {@link UserFs} seam, which is in-process under `local` and a per-user file
* sidecar under `k8s` (docs/k8s.md §4.10).
* @module dshs/web/routes/desktop
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import { requireAuth } from '../middleware/authn.js'
import { UserFsError } from '../../fs/user-fs.js'
import { extname } from 'node:path'
const mkdirSchema = {
body: {
type: 'object',
required: ['path'],
additionalProperties: false,
properties: { path: { type: 'string', maxLength: 512 } },
},
} as const
const uploadSchema = {
body: {
type: 'object',
required: ['path', 'name', 'data'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
data: { type: 'string' },
},
},
} as const
const createSchema = {
body: {
type: 'object',
required: ['path', 'name', 'type'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
type: { type: 'string', enum: ['file', 'dir'] },
},
},
} as const
/** 档案 56:下载时的 content-type(只覆盖常见类型,其余 application/octet-stream)。 */
const DL_TYPES: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.cjs': 'text/javascript; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
'.py': 'text/plain; charset=utf-8',
'.sh': 'text/plain; charset=utf-8',
'.yml': 'text/plain; charset=utf-8',
'.yaml': 'text/plain; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
}
function downloadType(name: string): string {
return DL_TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream'
}
/** Turn a seam failure into the `{error}` body the desktop UI switches on. */
export function sendFsError(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof UserFsError) return reply.code(err.status).send({ error: err.code })
throw err
}
export const desktopRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/desktop/tree', { preHandler: requireAuth }, async (request, reply) => {
const { path = '' } = request.query as { path?: string }
try {
return { path, entries: await app.userFs.listDir(request.user!.id, path) }
} catch (err) {
return sendFsError(reply, err)
}
})
app.post('/api/fs/mkdir', { preHandler: requireAuth, schema: mkdirSchema }, async (request, reply) => {
const { path } = request.body as { path: string }
try {
await app.userFs.mkdir(request.user!.id, path)
return { ok: true }
} catch (err) {
return sendFsError(reply, err)
}
})
app.post('/api/fs/upload', { preHandler: requireAuth, schema: uploadSchema }, async (request, reply) => {
const { path, name, data } = request.body as { path: string; name: string; data: string }
let buf: Buffer
try {
buf = Buffer.from(data, 'base64')
} catch {
return reply.code(400).send({ error: 'bad_data' })
}
try {
return { ok: true, name: await app.userFs.upload(request.user!.id, path, name, buf) }
} catch (err) {
return sendFsError(reply, err)
}
})
// 档案 56:下载/查看工作区文件 —— 之前**没有**任何读取端点,AI 产出的文件用户既看不到也下不了,
// 而 AI 只能给出 `/var/lib/.../ws/...` 这类宿主绝对路径(浏览器打不开)。
// 路径经 UserFs 解析(限定在调用者自己的根内,逃逸即 bad_path),并限制单文件大小。
app.get('/api/fs/download', { preHandler: requireAuth }, async (request, reply) => {
const { path = '' } = request.query as { path?: string }
try {
const { name, data } = await app.userFs.readFile(request.user!.id, path)
return reply
.header('content-type', downloadType(name))
.header('content-disposition', "attachment; filename*=UTF-8''" + encodeURIComponent(name))
.header('cache-control', 'no-store')
.send(data)
} catch (err) {
return sendFsError(reply, err)
}
})
app.post('/api/fs/create', { preHandler: requireAuth, schema: createSchema }, async (request, reply) => {
const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' }
try {
return { ok: true, name: await app.userFs.createEntry(request.user!.id, path, name, type), type }
} catch (err) {
return sendFsError(reply, err)
}
})
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Custom-domain routes: set/get a user's domain, regenerate its nginx config,
* and admin verification. Real ACME/DNS ownership verification is deferred;
* the `verified` flag is set by an admin as a placeholder until then.
* @module dshs/web/routes/domain
*/
import type { FastifyPluginAsync } from 'fastify'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { isValidDomain, renderServerBlock } from '../../nginx/generate.js'
const putSchema = {
body: {
type: 'object',
required: ['domain'],
additionalProperties: false,
properties: { domain: { type: 'string', minLength: 1, maxLength: 253 } },
},
} as const
export const domainRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/domain', { preHandler: requireAuth }, async (request) => {
const domain = await app.db.findDomainByUser(request.user!.id)
if (domain === undefined) return { domain: null }
return { domain: domain.domain, verified: domain.verified === 1, nginx_config: domain.nginxConfig }
})
app.put('/api/domain', { preHandler: requireAuth, schema: putSchema }, async (request, reply) => {
const normalized = (request.body as { domain: string }).domain.toLowerCase().trim()
if (!isValidDomain(normalized)) return reply.code(400).send({ error: 'invalid_domain' })
const config = renderServerBlock(normalized, request.user!.id, app.config.port)
await app.db.upsertDomain(request.user!.id, normalized, config)
return { domain: normalized, verified: false, nginx_config: config }
})
app.post('/api/nginx/regen', { preHandler: requireAuth }, async (request, reply) => {
const domain = await app.db.findDomainByUser(request.user!.id)
if (domain === undefined) return reply.code(404).send({ error: 'no_domain' })
const config = renderServerBlock(domain.domain, request.user!.id, app.config.port)
await app.db.upsertDomain(request.user!.id, domain.domain, config)
return { domain: domain.domain, nginx_config: config }
})
app.get('/api/admin/domains', { preHandler: requireAdmin }, async () => ({
domains: (await app.db.listDomains()).map((d) => ({
id: d.id,
userId: d.userId,
domain: d.domain,
verified: d.verified === 1,
})),
}))
app.post('/api/admin/domains/:id/verify', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if ((await app.db.findDomainById(id)) === undefined) return reply.code(404).send({ error: 'not_found' })
await app.db.setDomainVerified(id, true)
return { ok: true }
})
}
+242
View File
@@ -0,0 +1,242 @@
/**
* DSH launch / supervise / restart routes + the reverse proxy to a running
* instance. Launch resolves the requested folder, reads its enabled plugins,
* writes a cordis patch, and spawns a main+watchdog pair; restart writes a
* post-restart command handoff and respawns the main.
* @module dshs/web/routes/dsh
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import { requireAuth } from '../middleware/authn.js'
import { sendFsError } from './desktop.js'
import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js'
import { renderPatch } from '../../supervisor/patch.js'
import { subdomainForUser } from '../../supervisor/proxy.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { latestSessionPreset } from '../../supervisor/session-preset.js'
const launchSchema = {
body: {
type: 'object',
required: ['folder'],
additionalProperties: false,
properties: { folder: { type: 'string', maxLength: 512 } },
},
} as const
const restartSchema = {
body: {
type: 'object',
required: ['command'],
additionalProperties: false,
properties: { command: { type: 'string', maxLength: 1024 } },
},
} as const
function alive(status: string | undefined): boolean {
// 'failed' = 崩溃熔断后停止自动重启(档案 20),同样不可复用。
return status !== undefined && status !== 'crashed' && status !== 'stopped' && status !== 'failed'
}
/**
* 档案 78:熔断冷却期内的统一响应(503 + 可读信息)。
*
* 为什么是 503 而不是 500:这不是"服务坏了",而是平台**主动拒绝在冷却期内启动**,
* 客户端应展示「稍后重试」。`retryAfterMs` 供前端提示具体等待时长;
* `opens` 是累计熔断次数(同一实例反复崩 → 该值递增,可据此判断"该找人了")。
*/
function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply {
return reply.code(503).send({
error: 'instance_circuit_open',
opens: err.opens,
retryAfterMs: err.retryAfterMs,
message: '实例因连续崩溃被暂时熔断,请稍后重试',
})
}
function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string {
const sub = subdomainForUser(baseDomain, user.username)
const base = sub !== null ? `https://${sub}/` : `/u/${user.id}/dsh/`
return token !== undefined && token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base
}
export const dshRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/dsh/launch', { preHandler: requireAuth, schema: launchSchema }, async (request, reply) => {
const { folder } = request.body as { folder: string }
const user = request.user!
const fs = app.userFs
let folderAbs: string
try {
folderAbs = fs.resolvePath(user.id, folder)
if (!(await fs.isDirectory(user.id, folder))) return reply.code(400).send({ error: 'not_a_folder' })
} catch (err) {
return sendFsError(reply, err)
}
// Per-folder plugin selection → cordis patch. Rendered here but *not*
// written: the spawner decides where it lands (a file under local, a
// ConfigMap under k8s, where the control plane has no user volume).
let patch: string | undefined
if (app.config.enablePatch) {
const workspace = await app.db.findWorkspaceByPath(user.id, folder)
// Only inject plugins the user still has installed; a stale selection for
// a since-removed bundle would otherwise fail to resolve in the child DSH.
const installed = new Set((await fs.listInstalledPlugins(user.id)).map((plugin) => plugin.id))
const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id))
.filter((id) => installed.has(id))
patch = renderPatch(enabled)
}
try {
const instance = await app.supervisor.launch(user.id, folderAbs, patch)
return {
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
} catch (err) {
if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' })
// 档案 78:熔断冷却期内的启动被拒(用户选文件夹也会走到这里)
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
throw err
}
})
// 档案 20:handoff 停写。重启后执行命令的能力依赖 watchdog,而 watchdog 需要
// `ENABLE_PATCH=true`(线上为 false → 永不启动),且 handoff.json 无人消费。
// 停写避免"写了没人读"的误导;command 若不为空则记日志留痕。
app.post('/api/dsh/restart', { preHandler: requireAuth, schema: restartSchema }, async (request, reply) => {
const { command } = request.body as { command: string }
const user = request.user!
if (command !== '') {
process.stderr.write(
`[handoff-disabled] restart-with-command ignored (userId=${user.id}, bytes=${command.length})\n`,
)
}
const instance = await app.supervisor.restartMain(user.id)
if (instance === undefined) return reply.code(404).send({ error: 'not_running' })
await app.supervisor.spawnWatchdog(user.id)
return {
instance: {
id: instance.id,
port: instance.port,
status: instance.status,
launchToken: instance.launchToken,
restarts: instance.restarts ?? 0,
},
// 能力未启用:请勿依赖"重启后执行命令"(档案 20 §四 A3)。
handoff: { accepted: false, reason: 'watchdog_disabled' },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
})
app.post('/api/dsh/stop', { preHandler: requireAuth }, async (request) => {
await app.supervisor.stop(request.user!.id)
return { ok: true }
})
// 档案 56 ①:会话权限档位提示 —— 档位是**会话创建时播种**的,平台改默认值不会更新既有会话;
// 老会话停在 workspace-write 时(本机沙箱后端不可用)dsh 会 fail-closed 拒绝任何 shell,
// 用户只看到「bash 不可用」。这里把差异暴露出来,由实例页面提示用户切换。
app.get('/api/dsh/session-permission', { preHandler: requireAuth }, async (request) => {
const expected = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
const home = homeRoot(userRoot(app.config.dataRoot, request.user!.id))
const session = latestSessionPreset(home) ?? null
// 只有"受限档位 + 沙箱后端不可用"这一种组合会真正挡住用户,故仅对 workspace-write 报警。
const stale = session !== null && session.preset !== expected && session.preset === 'workspace-write'
return { expected, session, stale }
})
// 档案 56 ②:实例能力清单(由 scripts/gen-capabilities.cjs 生成,与实例内 skill 同源)。
app.get('/api/capabilities', { preHandler: requireAuth }, async () => {
const { readFileSync } = await import('node:fs')
const file = process.env.DSH_CAPABILITIES_FILE ?? '/opt/dsh/state/capabilities.json'
try {
return JSON.parse(readFileSync(file, 'utf8'))
} catch {
return { generatedAt: null, note: '能力清单尚未生成(运行 scripts/gen-capabilities.cjs)' }
}
})
app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => {
const { main, watchdog } = await app.supervisor.status(request.user!.id)
return {
running: alive(main?.status),
instance: main
? {
id: main.id,
port: main.port,
status: main.status,
exitCode: main.exitCode,
lastError: main.lastError,
// 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间
restarts: main.restarts ?? 0,
lastCrashedAt: main.lastCrashedAt ?? null,
}
: null,
watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null,
// 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到
breaker: app.supervisor.breakerInfo?.(request.user!.id) ?? null,
url: dshUrl(app.config.baseDomain, request.user!, main?.launchToken),
}
})
// 登录直达(方案 05,2026-09-09;admin 亦直达 —— 2026-09-09 决策②补充):
// 所有已放行角色(admin / active)统一:已运行实例复用其 launchToken URL,
// 未运行则在 ws 根目录 launch 后返回带 token 的会话 URL。
// desktop 管理台不再作为登录落点,admin 经会话侧栏"门户/管理台"入口或直
// 接访问 /desktop.html 进入。
app.post('/api/dsh/enter', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
if (user.role !== 'active' && user.role !== 'admin') return reply.code(403).send({ error: 'not_allowed' })
// Entering the workspace is itself activity (idle-reap signal).
app.supervisor.touch(user.id)
// 复用已运行实例(含刚被其它请求 spawn、仍在启动中的实例):等 launch token 到位
// 再返回 URL,避免把浏览器直接导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。
let { main } = await app.supervisor.status(user.id)
if (main !== undefined && alive(main.status)) {
if ((main.launchToken ?? '') === '') await app.supervisor.waitForLaunchTokenForUser(user.id)
main = (await app.supervisor.status(user.id)).main
if (main !== undefined && alive(main.status) && (main.launchToken ?? '') !== '') {
return {
kind: 'session',
instance: { id: main.id, port: main.port, status: main.status },
url: dshUrl(app.config.baseDomain, user, main.launchToken),
}
}
// 启动超时或中途崩溃:不给空 token URL(否则浏览器会撞 404),改提示稍后重试
return reply.code(503).send({ error: 'instance_starting' })
}
try {
const fs = app.userFs
const folderAbs = fs.resolvePath(user.id, '')
if (!(await fs.isDirectory(user.id, ''))) return reply.code(400).send({ error: 'not_a_folder' })
// 根目录 launch:无文件夹级插件勾选 → patch 传 undefined(渲染为空)
const instance = await app.supervisor.launch(user.id, folderAbs, undefined)
return {
kind: 'session',
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
} catch (err) {
if (err instanceof AlreadyRunningError) {
// 并发进入:另一请求正在 spawn 同一实例 → 同样等 token 到位再返回
await app.supervisor.waitForLaunchTokenForUser(user.id)
const m = (await app.supervisor.status(user.id)).main
if (m !== undefined && alive(m.status) && (m.launchToken ?? '') !== '') {
return {
kind: 'session',
instance: { id: m.id, port: m.port, status: m.status },
url: dshUrl(app.config.baseDomain, user, m.launchToken),
}
}
return reply.code(503).send({ error: 'instance_starting' })
}
// 档案 78:熔断冷却期内拒绝隐式拉起(用户 F5 / 注入脚本自愈都会到这里)
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
throw err
}
})
}
+686
View File
@@ -0,0 +1,686 @@
/**
* Skill management routes.
*
* Two scopes, mirroring the dsh skill layers discovered by
* `@deepseek-ai/dsh-skill-filesystem`:
* - shared (`/api/skills/shared`): admin-only. Targets the platform
* bundled layer (`DSH_BUNDLED_SKILL_DIR`, rank 600, read-only for users).
* - mine (`/api/skills/mine`): any authenticated user. Targets the
* user's own `$DSH_HOME/skills` (rank 400 user-dsh layer).
*
* Upload contract (ZIP only): one base64 `.zip` whose root holds exactly one
* top-level directory containing `SKILL.md` with frontmatter `name`
* (`^[a-z0-9]+(-[a-z0-9]+)*$`) and `description`. Both are REQUIRED — dsh
* ignores skills that miss either (see parseSkillFile / SKILL_NAME in
* @deepseek-ai/dsh-skill).
*
* Two-phase install for overwrite safety:
* 1. POST upload → validate + stage the archive. If no same-name skill
* exists it is installed immediately; if one exists the response carries
* `conflict: true` + `stagedId` and nothing is touched yet.
* 2. POST apply → after the user confirms, atomically REPLACE the target:
* the previous skill directory is removed in full (files absent from the
* new zip are deleted too), then the staged one is renamed in.
*
* Discovery is watch-based on the host, so install/remove take effect without
* an instance restart (verified: addDir/unlinkDir watch events invalidate).
* @module dshs/web/routes/skills
*/
import type { FastifyPluginAsync } from 'fastify'
import { execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { chmodSync, chownSync, existsSync, lchownSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { scanDir } from '../security-scan.js'
import { userRoot } from '../../fs/workspace.js'
/** dsh skill-name rule (mirrors SKILL_NAME in @deepseek-ai/dsh-skill). */
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/** Route-level body limit for uploads (base64 ~4/3 of archive size). */
const UPLOAD_BODY_LIMIT = 180 * 1024 * 1024
const STAGE_PREFIX = '.skill-upload-'
/** A staged upload id is just the staging dir name under `dataRoot`. */
const STAGE_ID_RE = /^\.skill-upload-[0-9a-f]{12}$/
/** Staging dirs older than this (ms) are garbage-collected on next upload. */
const STAGE_TTL_MS = 10 * 60 * 1000
/**
* 上传硬上限(档案 41 · zip bomb 防护)。
* `UPLOAD_BODY_LIMIT` 只限制 **HTTP body(= 压缩体)**;宿主 `quotaon /` 未启用 = **无磁盘配额**
* (档案 38),一个 180MB 的 zip 可解压出几十 GB → 撑爆 40G 单分区、**打瘫全平台**(跨租户)。
* 因此必须限制**解压后**的规模。
*/
const MAX_SKILL_FILES = 2000
const MAX_SKILL_UNCOMPRESSED_BYTES = 200 * 1024 * 1024
/**
* 「已禁用」的用户技能存放处(在 `$DSH_HOME` 下、但 **不在** `skills/` 内)。
* dsh 的 `discoverRoot` 只扫 `$DSH_HOME/skills`,所以放进这里是"保留但不可见"= 禁用,
* 从而在 dsh **没有 disable 机制**的前提下实现用户侧「启用 / 禁用 / 删除」。
*/
const SKILL_LIBRARY_DIR = 'skills-library'
function httpError(statusCode: number, message: string): Error & { statusCode: number } {
const e = new Error(message) as Error & { statusCode: number }
e.statusCode = statusCode
return e
}
/** Parse `name`/`description` from a SKILL.md frontmatter block. */
function parseSkillMeta(markdown: string): { name?: string; description?: string } {
const block = /^---\s*\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
if (block === null) return {}
const fm = block[1]
const name = /^name:\s*["']?([^"'\n]+)["']?\s*$/m.exec(fm)
const description = /^description:\s*["']?(.*?)["']?\s*$/m.exec(fm)
return {
name: name?.[1]?.trim(),
description: description?.[1]?.trim(),
}
}
async function skillMetaAt(dir: string): Promise<{ name?: string; description?: string }> {
try {
return parseSkillMeta(await readFileSync(join(dir, 'SKILL.md'), 'utf8'))
} catch {
return {}
}
}
interface SkillSummary {
/** On-disk directory name, always == frontmatter name. */
dir: string
/** Frontmatter `name`. */
name: string
description: string
/** Recursive bytes. */
size: number
/** Recursive file count. */
files: number
mtimeMs: number
}
async function dirSizeAndCount(abs: string): Promise<{ size: number; files: number }> {
let size = 0
let files = 0
for (const entry of await readdir(abs, { withFileTypes: true })) {
const st = await stat(join(abs, entry.name))
if (st.isDirectory()) {
const sub = await dirSizeAndCount(join(abs, entry.name))
size += sub.size
files += sub.files
} else {
size += st.size
files += 1
}
}
return { size, files }
}
async function summarizeSkillDir(abs: string): Promise<SkillSummary> {
const meta = await skillMetaAt(abs)
const st = await stat(abs)
const { size, files } = st.isDirectory() ? await dirSizeAndCount(abs) : { size: st.size, files: 1 }
return {
dir: basename(abs),
name: meta.name ?? basename(abs),
description: meta.description ?? '',
size,
files,
mtimeMs: st.mtimeMs,
}
}
/** List single-level skill directories under `root` (dirs only, skip hidden). */
async function listSkills(root: string): Promise<SkillSummary[]> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch {
return []
}
const out: SkillSummary[] = []
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
try {
out.push(await summarizeSkillDir(join(root, entry.name)))
} catch {
// entry vanished mid-scan
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
/** Remove staging dirs left over from abandoned uploads (TTL). */
function collectStaleStages(dataRoot: string): string[] {
let names: string[] = []
try {
names = readdirSync(dataRoot).filter((n) => n.startsWith(STAGE_PREFIX))
} catch {
return []
}
const stale: string[] = []
for (const name of names) {
try {
if (Date.now() - lstatSync(join(dataRoot, name)).mtimeMs > STAGE_TTL_MS) stale.push(name)
} catch {
stale.push(name)
}
}
return stale
}
/**
* 累加 zip 内所有成员的**解压后**字节数(解析 `unzip -l` 的表格区)。
* 用于 zip bomb 防护:压缩体 180MB 上限对解压规模毫无约束力。
*/
function sumUncompressedSize(payload: string): number {
const out = execFileSync('unzip', ['-l', payload], { encoding: 'utf8', timeout: 30000 })
let total = 0
let inTable = false
for (const line of out.split('\n')) {
if (/^\s*-{5,}/.test(line)) {
if (inTable) break // 第二条分隔线 = 表格结束
inTable = true
continue
}
if (!inTable) continue
const n = Number(line.trim().split(/\s+/)[0])
if (Number.isFinite(n)) total += n
}
return total
}
/**
* 找出任何**非普通文件/目录**的条目(symlink / 设备 / FIFO / socket),返回其相对路径。
*
* ⚠️ 这是档案 41 修的 P0 的根因所在:zip 的成员**可以是符号链接**,而 `unzip` 默认**原样恢复**它。
* symlink 的目标写在 **zip 元数据**里,不在任何文件内容里 → `scanDir`(只读文件内容,且遇到
* `!isFile()` 直接 `continue`)**永远看不到它**。而随后以 **root** 身份执行的 `chownTree` /
* `chmodTree` 会**跟随**该链接 → **改写链接目标(宿主任意文件)的属主与权限**。
* 实测:`-rw------- root:root` → `-rw-r--r-- <租户uid>`;指向 `/etc/shadow` 即等于
* 把密码哈希 chmod 成 644 并 chown 给该租户。
*/
function findSpecialEntry(root: string, rel = ''): string | null {
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
const relPath = rel === '' ? entry : `${rel}/${entry}`
let st: ReturnType<typeof lstatSync>
try {
st = lstatSync(abs)
} catch {
continue
}
if (st.isSymbolicLink()) return relPath
if (st.isDirectory()) {
const hit = findSpecialEntry(abs, relPath)
if (hit !== null) return hit
continue
}
if (!st.isFile()) return relPath
}
return null
}
/**
* Validate + extract a `.zip` skill archive under `<dataRoot>/<staging>`.
* @returns staged top dir, registered skill name/description, file count.
*/
function stageZipArchive(dataRoot: string, archive: Buffer): { stage: string; topDir: string; name: string; description: string; fileCount: number } {
const stage = join(dataRoot, STAGE_PREFIX + randomBytes(6).toString('hex'))
const payload = join(stage, 'payload.zip')
const unzipDir = join(stage, 'unzip')
mkdirSync(unzipDir, { recursive: true, mode: 0o700 })
try {
writeFileSync(payload, archive)
// 1. List members (no extraction yet) and validate every path.
const members = execFileSync('unzip', ['-Z1', payload], { encoding: 'utf8', timeout: 30000 }).split('\n')
const tops = new Set<string>()
let hasSkillMd = false
let fileCount = 0
for (let raw of members) {
raw = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
if (raw === '' || raw === '.' || raw === '__MACOSX' || raw.startsWith('__MACOSX/')) continue
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) throw httpError(400, `zip member uses an absolute path: ${raw}`)
if (raw.split('/').includes('..')) throw httpError(400, `zip member escapes the root: ${raw}`)
const top = raw.split('/')[0]!
if (top === '.DS_Store') continue
tops.add(top)
if (raw.endsWith('/SKILL.md')) hasSkillMd = true
if (!raw.endsWith('/')) fileCount += 1
}
if (tops.size !== 1) {
throw httpError(400, `zip 必须包含且仅包含一个顶层技能目录(当前 ${tops.size} 个)`)
}
const top = [...tops][0]!
if (top.startsWith('.')) throw httpError(400, 'zip 顶层目录名无效(不能以 . 开头)')
if (!hasSkillMd) throw httpError(400, 'zip 缺少 <顶层目录>/SKILL.md —— 不是 dsh 技能包')
if (fileCount === 0) throw httpError(400, 'zip 为空(无文件)')
// 1b. 规模上限(zip bomb 防护;档案 41)——压缩体上限对解压规模无约束力。
if (fileCount > MAX_SKILL_FILES) {
throw httpError(400, `技能包文件数超限(${fileCount} > ${MAX_SKILL_FILES})`)
}
const uncompressedBytes = sumUncompressedSize(payload)
if (uncompressedBytes > MAX_SKILL_UNCOMPRESSED_BYTES) {
throw httpError(
400,
`技能包解压后体积超限(${Math.ceil(uncompressedBytes / 1048576)}MB > ${MAX_SKILL_UNCOMPRESSED_BYTES / 1048576}MB)`,
)
}
// 2. Extract.
execFileSync('unzip', ['-q', payload, '-d', unzipDir], { timeout: 120000 })
// 2a. 拒绝任何非普通文件条目(symlink / 设备 / FIFO / socket)——见 findSpecialEntry 注释。
// 必须在 scanDir 之前、也必须在任何 chown/chmod 之前拦住。
const specialEntry = findSpecialEntry(unzipDir)
if (specialEntry !== null) {
throw httpError(400, `技能包不允许包含符号链接/设备/管道文件:${specialEntry}`)
}
// 2b. Security scan (危险内容扫描) — reject obviously-malicious patterns.
const scanFindings = scanDir(unzipDir)
if (scanFindings.length > 0) {
process.stderr.write(
`[upload-scan-warnings] ${JSON.stringify({ count: scanFindings.length, findings: scanFindings.slice(0, 20) })}\n`,
)
}
// 3. Validate the dsh-standard skill files inside.
const skillMd = readFileSync(join(unzipDir, top, 'SKILL.md'), 'utf8')
const meta = parseSkillMeta(skillMd)
if (meta.name === undefined || !SKILL_NAME_RE.test(meta.name)) {
throw httpError(
400,
`SKILL.md frontmatter name 必须为小写连字符(如 mcn-workstation),当前为 "${meta.name ?? ''}" —— 请修改 SKILL.md 后重新打包`,
)
}
if (meta.description === undefined || meta.description.trim() === '') {
throw httpError(400, `SKILL.md frontmatter 缺少 description —— dsh 要求 name 与 description 同时存在`)
}
return {
stage,
topDir: join(unzipDir, top),
name: meta.name,
description: meta.description.trim(),
fileCount,
}
} catch (err) {
rm(stage, { recursive: true, force: true }).catch(() => undefined)
throw err
}
}
function chownTree(root: string, uid: number, gid: number): void {
lchownSync(root, uid, gid)
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
const st = lstatSync(abs)
// 档案 41:绝不跟随符号链接(跟随 = 以 root 改写**链接目标**的属主)。
// 即便 stageZipArchive 已拒绝 symlink 成员,这里也保持"不跟随"作为纵深防御。
if (st.isSymbolicLink()) continue
if (st.isDirectory()) chownTree(abs, uid, gid)
else lchownSync(abs, uid, gid)
}
}
function chmodTree(root: string, dirMode: number, fileMode: number): void {
chmodSync(root, dirMode)
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
const st = lstatSync(abs)
// Linux 无 lchmod(ENOSYS),因此对 symlink 一律**跳过**而不是"不跟随地改"。
if (st.isSymbolicLink()) continue
if (st.isDirectory()) chmodTree(abs, dirMode, fileMode)
else chmodSync(abs, fileMode)
}
}
/**
* Phase 2: atomically REPLACE `<targetRoot>/<name>` with the staged skill.
* The previous directory is removed IN FULL first — files that exist only in
* the old skill (absent from the new zip) are deleted as well.
*/
async function applyStaged(
dataRoot: string,
stagedId: string,
targetRoot: string,
owner?: { uid: number; gid: number },
): Promise<SkillSummary> {
if (!STAGE_ID_RE.test(stagedId)) throw httpError(400, 'invalid staged id')
const stage = join(dataRoot, stagedId)
if (!existsSync(stage)) throw httpError(404, 'staged upload not found or expired — 请重新上传')
const unzipDir = join(stage, 'unzip')
let top: string | undefined
for (const entry of readdirSync(unzipDir)) {
if (lstatSync(join(unzipDir, entry)).isDirectory()) { top = entry; break }
}
if (top === undefined) throw httpError(400, 'staged skill is empty')
const meta = await skillMetaAt(join(unzipDir, top))
if (meta.name === undefined) throw httpError(400, 'staged skill has no valid SKILL.md')
const target = join(targetRoot, meta.name)
try {
// Full replacement: remove everything the old skill had, then move the new
// one in — files absent from the new zip cannot survive.
if (existsSync(target)) await rm(target, { recursive: true, force: true })
const topAbs = join(unzipDir, top)
if (owner !== undefined) {
chownTree(topAbs, owner.uid, owner.gid)
chmodTree(topAbs, 0o755, 0o644)
}
await rename(topAbs, target)
return await summarizeSkillDir(target)
} finally {
rm(stage, { recursive: true, force: true }).catch(() => undefined)
}
}
interface UploadBody {
file: string
filename: string
}
interface ApplyBody {
stagedId: string
}
const uploadSchema = {
body: {
type: 'object',
required: ['file', 'filename'],
additionalProperties: false,
properties: {
file: { type: 'string', maxLength: UPLOAD_BODY_LIMIT },
filename: { type: 'string', maxLength: 255 },
},
},
} as const
const applySchema = {
body: {
type: 'object',
required: ['stagedId'],
additionalProperties: false,
properties: { stagedId: { type: 'string', maxLength: 64 } },
},
} as const
function requireZipName(filename: string): void {
if (!basename(filename).toLowerCase().endsWith('.zip')) {
throw httpError(400, '仅支持 .zip 文件')
}
}
export const skillRoutes: FastifyPluginAsync = async (app) => {
const gc = (): void => {
for (const stale of collectStaleStages(app.config.dataRoot)) {
rm(join(app.config.dataRoot, stale), { recursive: true, force: true }).catch(() => undefined)
}
}
// ── shared: platform bundled layer, admin only ──────────────────────────
app.get('/api/skills/shared', { preHandler: requireAdmin }, async () => {
const root = app.config.bundledSkillDir
await mkdir(root, { recursive: true, mode: 0o755 }).catch(() => undefined)
return { skills: await listSkills(root) }
})
// Phase 1: validate + stage. No same-name skill → install immediately.
// Same-name skill exists → 200 with conflict:true + stagedId (nothing touched).
app.post(
'/api/skills/shared',
{ preHandler: requireAdmin, bodyLimit: UPLOAD_BODY_LIMIT, schema: uploadSchema },
async (request, reply) => {
const root = app.config.bundledSkillDir
await mkdir(root, { recursive: true, mode: 0o755 }).catch(() => undefined)
const body = request.body as UploadBody
try {
requireZipName(body.filename)
const archive = Buffer.from(body.file, 'base64')
if (archive.length === 0) throw httpError(400, 'empty archive payload')
gc()
const staged = stageZipArchive(app.config.dataRoot, archive)
const existing = existsSync(join(root, staged.name))
if (!existing) {
const skill = await applyStaged(app.config.dataRoot, basename(staged.stage), root)
await app.db.audit(request.user?.id ?? null, 'install_shared_skill', JSON.stringify({ name: skill.name }))
return { ok: true, skill }
}
// Conflict: keep the staged copy, ask the user to confirm replacement.
const old = await summarizeSkillDir(join(root, staged.name))
return {
ok: true,
conflict: true,
stagedId: basename(staged.stage),
skill: { name: staged.name, description: staged.description, files: staged.fileCount },
existing: { name: old.name, size: old.size, files: old.files, mtimeMs: old.mtimeMs },
}
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
// Phase 2: confirm replacement (full replace — old files not in the new zip are removed).
app.post(
'/api/skills/shared/apply',
{ preHandler: requireAdmin, schema: applySchema },
async (request, reply) => {
const { stagedId } = request.body as ApplyBody
try {
const skill = await applyStaged(app.config.dataRoot, stagedId, app.config.bundledSkillDir)
await app.db.audit(request.user?.id ?? null, 'replace_shared_skill', JSON.stringify({ name: skill.name }))
return { ok: true, skill }
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
app.delete('/api/skills/shared/:name', { preHandler: requireAdmin }, async (request, reply) => {
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
const root = app.config.bundledSkillDir
const target = join(root, name)
if (!existsSync(target)) return reply.code(404).send({ error: 'not_found' })
await rm(target, { recursive: true, force: true })
await app.db.audit(request.user?.id ?? null, 'delete_shared_skill', JSON.stringify({ name }))
return { ok: true }
})
// ── mine: personal layer under $DSH_HOME/skills, any authenticated user ─
const mineRoot = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'home', 'skills')
/** 「已禁用」用户技能的存放处 —— 在 `$DSH_HOME` 下但不在 `skills/` 内,故 dsh 扫不到。 */
const libraryRoot = (userId: string): string =>
join(userRoot(app.config.dataRoot, userId), 'home', SKILL_LIBRARY_DIR)
/** 平台共享技能(rank 600)名集合:对用户**只读**,不可禁用/删除/覆盖。 */
const sharedNames = (): Set<string> => {
const dir = app.config.bundledSkillDir
if (dir === '' || !existsSync(dir)) return new Set()
try {
return new Set(readdirSync(dir).filter((n) => SKILL_NAME_RE.test(n)))
} catch {
return new Set()
}
}
/** 用户技能名 → 是否被平台共享层占用(占用则 409,避免用户白上传一份永远不生效的技能)。 */
const assertNotShared = (name: string): void => {
if (sharedNames().has(name)) {
throw httpError(409, `「${name}」是平台共享技能(全员只读,用户不可禁用/删除/覆盖)—— 请改用其他技能名`)
}
}
async function ensureMineRoot(userId: string): Promise<{ root: string; owner: { uid: number; gid: number } }> {
const root = mineRoot(userId)
const home = join(userRoot(app.config.dataRoot, userId), 'home')
const homeStat = lstatSync(home)
const owner = { uid: homeStat.uid, gid: homeStat.gid }
if (!existsSync(root)) {
mkdirSync(root, { recursive: true, mode: 0o755 })
chownSync(root, owner.uid, owner.gid)
}
return { root, owner }
}
// 列表:合并三类 —— 平台共享(locked,只读)/ 用户已启用 / 用户已禁用(库中保留)。
app.get('/api/skills/mine', { preHandler: requireAuth }, async (request) => {
const user = request.user!
const { root } = await ensureMineRoot(user.id)
const lib = libraryRoot(user.id)
const sharedDir = app.config.bundledSkillDir
const [enabled, disabled, shared] = await Promise.all([
listSkills(root),
existsSync(lib) ? listSkills(lib) : Promise.resolve([]),
sharedDir !== '' && existsSync(sharedDir) ? listSkills(sharedDir) : Promise.resolve([]),
])
return {
skills: [
// 平台共享层(rank 600,全员只读;dsh 无 disable 机制 → 标 locked,前端不可操作)
...shared.map((s) => ({ ...s, source: 'shared' as const, enabled: true, locked: true })),
...enabled.map((s) => ({ ...s, source: 'user' as const, enabled: true, locked: false })),
...disabled.map((s) => ({ ...s, source: 'user' as const, enabled: false, locked: false })),
],
}
})
app.post(
'/api/skills/mine',
{ preHandler: requireAuth, bodyLimit: UPLOAD_BODY_LIMIT, schema: uploadSchema },
async (request, reply) => {
const user = request.user!
const { root, owner } = await ensureMineRoot(user.id)
const body = request.body as UploadBody
try {
requireZipName(body.filename)
const archive = Buffer.from(body.file, 'base64')
if (archive.length === 0) throw httpError(400, 'empty archive payload')
gc()
const staged = stageZipArchive(app.config.dataRoot, archive)
// 与平台共享技能同名 → 直接拒(否则用户会白上传一份永远被 rank 600 压住的技能)。
try {
assertNotShared(staged.name)
} catch (err) {
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
throw err
}
if (!existsSync(join(root, staged.name))) {
const skill = await applyStaged(app.config.dataRoot, basename(staged.stage), root, owner)
return { ok: true, skill }
}
const old = await summarizeSkillDir(join(root, staged.name))
return {
ok: true,
conflict: true,
stagedId: basename(staged.stage),
skill: { name: staged.name, description: staged.description, files: staged.fileCount },
existing: { name: old.name, size: old.size, files: old.files, mtimeMs: old.mtimeMs },
}
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
app.post(
'/api/skills/mine/apply',
{ preHandler: requireAuth, schema: applySchema },
async (request, reply) => {
const user = request.user!
const { root, owner } = await ensureMineRoot(user.id)
const { stagedId } = request.body as ApplyBody
try {
const skill = await applyStaged(app.config.dataRoot, stagedId, root, owner)
return { ok: true, skill }
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
/**
* 启用:库(skills-library)→ `$DSH_HOME/skills`。
* dsh 的 skill 发现是 watch 驱动的,所以**即时生效、无需重启实例**。
*/
app.post('/api/skills/mine/:name/enable', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
try {
assertNotShared(name)
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 409).send({ error: e.message })
}
const { root, owner } = await ensureMineRoot(user.id)
const src = join(libraryRoot(user.id), name)
if (!existsSync(src)) return reply.code(404).send({ error: 'not_found' })
const dst = join(root, name)
if (existsSync(dst)) return reply.code(409).send({ error: 'already_enabled' })
await rename(src, dst)
chownTree(dst, owner.uid, owner.gid)
chmodTree(dst, 0o755, 0o644)
await app.db.audit(user.id, 'enable_skill', JSON.stringify({ name }))
return { ok: true, skill: await summarizeSkillDir(dst) }
})
/**
* 禁用:`$DSH_HOME/skills` → 库。**保留文件**(可再次启用),只是移出 dsh 的扫描根
* —— 因为 `dsh-skill-filesystem` **没有 disable/deny 机制**。
*/
app.post('/api/skills/mine/:name/disable', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
try {
assertNotShared(name)
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 409).send({ error: e.message })
}
const { root, owner } = await ensureMineRoot(user.id)
const src = join(root, name)
if (!existsSync(src)) return reply.code(404).send({ error: 'not_found' })
const lib = libraryRoot(user.id)
mkdirSync(lib, { recursive: true, mode: 0o755 })
chownSync(lib, owner.uid, owner.gid)
const dst = join(lib, name)
if (existsSync(dst)) await rm(dst, { recursive: true, force: true })
await rename(src, dst)
await app.db.audit(user.id, 'disable_skill', JSON.stringify({ name }))
return { ok: true }
})
/** 删除:库与启用位置一起删(彻底移除用户自建技能)。 */
app.delete('/api/skills/mine/:name', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
try {
assertNotShared(name)
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 409).send({ error: e.message })
}
const targets = [join(mineRoot(user.id), name), join(libraryRoot(user.id), name)]
const present = targets.filter((t) => existsSync(t))
if (present.length === 0) return reply.code(404).send({ error: 'not_found' })
for (const t of present) await rm(t, { recursive: true, force: true })
await app.db.audit(user.id, 'delete_skill', JSON.stringify({ name }))
return { ok: true }
})
}
+319
View File
@@ -0,0 +1,319 @@
/**
* 官方白名单来源(方案 C · 方案 B 口径):把 awesome-dsh-plugin 官方目录接入
* 门户「插件管理」,admin 挑选后导入功能插件候选池。
*
* 数据源:`https://awesome-dsh-plugin.com/plugins.json`(官方规范地址;npm 镜像
* `dsh-plugin-catalog`)。约 3400 条,每条含 `npm` 包名 / `install` 规格 /
* `stars` / `downloads` / 分类 / 中英描述。相比 git clone 再解析 3431 个 yml,
* 一次 HTTP 拿全,且字段更全。
*
* 导入口径 = **仅预构建**(不在平台侧执行任何第三方构建脚本):
* 1. 有 `npm` 包名 → 取 registry.npmjs.org 官方 tarball(预构建,秒装)
* 2. 无 npm、有 `tarball` → 取 GitHub release 资产直链
* 3. 两者皆无(官方 `install` 形态为 `github:owner/repo`,需源码构建)→ 不支持
* 一键导入,接口返回明确原因,前端标记「需源码构建」。
* 覆盖约 54%(1854/3408),官方下载量 TOP10 热门插件全部包含。
*
* 导入复用 business-plugins 的 `stageTgzArchive`(成员路径校验 + package.json
* 校验 + 危险内容安全扫描)+ 同名替换策略,与管理员手工上传是同一条链路。
*
* 注:官方清单的「收录」不等于安全审计(上游 README 明确声明)。安全兜底完全
* 依赖 `stageTgzArchive` 的安全扫描;P0 命中时阻断并把原因原样回显给 admin。
* @module dshs/web/routes/whitelist
*/
import type { FastifyPluginAsync } from 'fastify'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdir, rename, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { requireAdmin } from '../middleware/authn.js'
import { httpError } from '../security-scan.js'
import { stageTgzArchive } from './business-plugins.js'
/** 官方目录规范地址(同一份内容亦镜像为 npm 包 `dsh-plugin-catalog`)。 */
const CATALOG_URL = 'https://awesome-dsh-plugin.com/plugins.json'
const REGISTRY = 'https://registry.npmjs.org'
/** 索引缓存 TTL:6 小时。 */
const CACHE_TTL = 6 * 60 * 60 * 1000
/**
* 缓存结构版本。改字段/换数据源时必须 +1 —— 否则 TTL 内的旧格式缓存会被直接
* 复用,条目缺字段,后续按字段过滤就会在请求里抛异常(v1 = git clone + yml 版)。
*/
const CACHE_VERSION = 3
const FETCH_TIMEOUT_MS = 60_000
/** 单次导入上限(避免一次拉太多把请求拖死)。 */
const MAX_IMPORT = 20
const MAX_ARCHIVE_BYTES = 150 * 1024 * 1024
/** 官方 `plugins.json` 的单条原始形态(字段可能缺失,全部按需兜底)。 */
interface RawEntry {
name?: string
owner?: string
url?: string
page?: string
category?: string
description?: { zh?: string; en?: string }
npm?: string | null
version?: string | null
stars?: number
downloads?: number | null
install?: string
tarball?: string | null
screenshots?: unknown
}
/** 导入方式:`npm` = registry 官方 tarball;`tarball` = release 资产;`source` = 需源码构建(不支持)。 */
type ImportKind = 'npm' | 'tarball' | 'source'
interface WhitelistEntry {
/** `owner/name`,清单内唯一,前端勾选与导入回查都用它。 */
id: string
name: string
owner: string
url: string
page: string
category: string
/** 分类中文名(来自目录顶层 `categories` 映射,如 `ui` → UI 增强);缺映射时回退为 id。 */
categoryLabel: string
description: string
npm: string | null
version: string | null
stars: number
downloads: number
install: string
importKind: ImportKind
tarball: string | null
/** 官方目录是否给该插件配了截图(约 17% 条目有;有则可去 `page` 看效果图)。 */
hasShots: boolean
}
interface CachedIndex {
version: number
fetchedAt: number
source: string
/** 分类 id → 中文名(目录顶层 `categories` 的映射,随缓存一起留存)。 */
categoryLabels: Record<string, string>
entries: WhitelistEntry[]
}
export const whitelistRoutes: FastifyPluginAsync = async (app) => {
const cacheDir = join(app.config.dataRoot, 'whitelist-cache')
const indexFile = join(cacheDir, 'index.json')
const asString = (v: unknown): string => (typeof v === 'string' ? v : '')
/** 把官方目录 JSON 归一化成内部条目表 + 分类中文名映射,条目按热度排序。 */
const buildIndex = (raw: unknown): { entries: WhitelistEntry[]; categoryLabels: Record<string, string> } => {
const obj = raw as { plugins?: RawEntry[]; categories?: Record<string, { en?: string; zh?: string }> }
const list = obj.plugins
if (!Array.isArray(list)) throw httpError(502, '官方目录格式异常:缺少 plugins 数组')
// 顶层 categories = { id: { en, zh } } —— 官方只在这里给分类的中文名。
const categoryLabels: Record<string, string> = {}
for (const [id, v] of Object.entries(obj.categories ?? {})) {
const label = asString(v?.zh) !== '' ? asString(v.zh) : asString(v?.en)
if (label !== '') categoryLabels[id] = label
}
const entries: WhitelistEntry[] = []
for (const e of list) {
const name = asString(e.name)
if (name === '') continue
const owner = asString(e.owner)
const category = asString(e.category)
const npm = asString(e.npm)
const tarball = asString(e.tarball)
const importKind: ImportKind = npm !== '' ? 'npm' : tarball !== '' ? 'tarball' : 'source'
entries.push({
id: owner === '' ? name : `${owner}/${name}`,
name,
owner,
url: asString(e.url),
page: asString(e.page),
category,
categoryLabel: categoryLabels[category] ?? category,
description: asString(e.description?.zh) !== '' ? asString(e.description?.zh) : asString(e.description?.en),
npm: npm === '' ? null : npm,
version: asString(e.version) === '' ? null : asString(e.version),
stars: Number(e.stars ?? 0) || 0,
downloads: Number(e.downloads ?? 0) || 0,
install: asString(e.install),
importKind,
tarball: tarball === '' ? null : tarball,
hasShots: Array.isArray(e.screenshots) && e.screenshots.length > 0,
})
}
// 下载量降序(未发 npm 的条目 downloads 为 0,自然沉底)→ 热门 = 可预构建安装。
entries.sort((a, b) => b.downloads - a.downloads || b.stars - a.stars)
return { entries, categoryLabels }
}
/** 拉官方目录并落缓存。 */
const fetchCatalog = async (): Promise<{ entries: WhitelistEntry[]; categoryLabels: Record<string, string> }> => {
const res = await fetch(CATALOG_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' })
if (!res.ok) throw httpError(502, `官方目录拉取失败 HTTP ${res.status}`)
const { entries, categoryLabels } = buildIndex(await res.json())
if (entries.length === 0) throw httpError(502, '官方目录为空')
mkdirSync(cacheDir, { recursive: true, mode: 0o755 })
const payload: CachedIndex = { version: CACHE_VERSION, fetchedAt: Date.now(), source: CATALOG_URL, categoryLabels, entries }
writeFileSync(indexFile, JSON.stringify(payload))
return { entries, categoryLabels }
}
/** 读缓存:版本或结构不符一律当未命中(旧格式不能被复用)。 */
const readCache = (): CachedIndex | null => {
if (!existsSync(indexFile)) return null
try {
const cached = JSON.parse(readFileSync(indexFile, 'utf8')) as CachedIndex
if (cached.version !== CACHE_VERSION) return null
if (!Array.isArray(cached.entries) || cached.entries.length === 0) return null
if (typeof cached.entries[0]?.importKind !== 'string') return null
return cached
} catch {
return null
}
}
/** 取索引:命中 TTL 用缓存;否则拉取;拉取失败但有旧缓存 → 降级用旧缓存并标 `stale`。 */
const getIndex = async (force = false): Promise<{ fetchedAt: number; entries: WhitelistEntry[]; categoryLabels: Record<string, string>; stale: boolean }> => {
const cached = readCache()
if (!force && cached !== null && Date.now() - cached.fetchedAt < CACHE_TTL) return { fetchedAt: cached.fetchedAt, entries: cached.entries, categoryLabels: cached.categoryLabels ?? {}, stale: false }
try {
const { entries, categoryLabels } = await fetchCatalog()
return { fetchedAt: Date.now(), entries, categoryLabels, stale: false }
} catch (err) {
if (cached !== null) app.log.warn({ err }, 'whitelist: 官方目录拉取失败,降级使用本地缓存')
if (cached !== null) return { fetchedAt: cached.fetchedAt, entries: cached.entries, categoryLabels: cached.categoryLabels ?? {}, stale: true }
throw err
}
}
/** 解析出可下载的预构建 tarball 直链(方案 B:绝不触发源码构建)。 */
const resolveTarball = async (entry: WhitelistEntry): Promise<{ url: string; version: string | null }> => {
if (entry.importKind === 'npm' && entry.npm !== null) {
const name = entry.npm
// scoped 包(@scope/name)的 packument 地址:`@` 保留、`/` 编码。
const res = await fetch(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!res.ok) throw httpError(502, `npm registry 查询失败 HTTP ${res.status}(${name})`)
const meta = (await res.json()) as {
'dist-tags'?: { latest?: string }
versions?: Record<string, { dist?: { tarball?: string } }>
}
const latest = meta['dist-tags']?.latest
const url = latest === undefined ? undefined : meta.versions?.[latest]?.dist?.tarball
if (typeof url !== 'string' || url === '') throw httpError(502, `npm 包无可下载的已发布版本:${name}`)
return { url, version: latest ?? null }
}
if (entry.tarball !== null) return { url: entry.tarball, version: entry.version }
throw httpError(400, `该插件需从源码构建安装${entry.install === '' ? '' : `(${entry.install})`},暂不支持一键导入`)
}
app.get('/api/plugins/whitelist', { preHandler: requireAdmin }, async (request) => {
const { q, category, onlyImportable } = request.query as { q?: string; category?: string; onlyImportable?: string }
const { fetchedAt, entries, categoryLabels, stale } = await getIndex()
let list = entries
if (category !== undefined && category !== '') list = list.filter((e) => e.category === category)
if (onlyImportable === '1') list = list.filter((e) => e.importKind !== 'source')
if (q !== undefined && q !== '') {
const kw = q.toLowerCase()
list = list.filter(
(e) =>
e.name.toLowerCase().includes(kw) ||
(e.description ?? '').toLowerCase().includes(kw) ||
(e.npm ?? '').toLowerCase().includes(kw) ||
(e.owner ?? '').toLowerCase().includes(kw),
)
}
// 分类清单带中文名 + 计数(按条目数降序),供前端下拉直接用中文展示。
const counts = new Map<string, number>()
for (const e of entries) if (e.category !== '') counts.set(e.category, (counts.get(e.category) ?? 0) + 1)
const categories = [...counts.entries()]
.map(([id, count]) => ({ id, label: categoryLabels[id] ?? id, count }))
.sort((a, b) => b.count - a.count)
return {
fetchedAt,
stale,
total: entries.length,
count: list.length,
importable: entries.filter((e) => e.importKind !== 'source').length,
categories,
plugins: list.slice(0, 300),
}
})
app.post('/api/plugins/whitelist/refresh', { preHandler: requireAdmin }, async () => {
const { entries, fetchedAt } = await getIndex(true)
return { ok: true, total: entries.length, importable: entries.filter((e) => e.importKind !== 'source').length, fetchedAt }
})
app.post('/api/plugins/whitelist/import', { preHandler: requireAdmin }, async (request, reply) => {
const { names } = request.body as { names?: string[] }
if (!Array.isArray(names) || names.length === 0) return reply.code(400).send({ error: 'no_plugins_selected' })
const { entries } = await getIndex()
const byId = new Map(entries.map((e) => [e.id, e]))
const results: Array<{ id: string; name: string; ok: boolean; kind?: ImportKind; version?: string | null; error?: string }> = []
for (const id of names.slice(0, MAX_IMPORT)) {
try {
const entry = byId.get(id)
if (entry === undefined) throw httpError(404, `不在官方清单:${id}`)
const { url, version } = await resolveTarball(entry)
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' })
if (!res.ok) throw httpError(502, `下载失败 HTTP ${res.status}`)
const archive = Buffer.from(await res.arrayBuffer())
if (archive.length === 0 || archive.length > MAX_ARCHIVE_BYTES) throw httpError(400, '包大小异常')
const staged = await stageTgzArchive(app.config.dataRoot, archive)
// 投放裁决(与门户上传同一套口径):P0 命中 / 版本不兼容 → 跳过该包并回显依据。
// ⚠️ 本路径原先只依赖 `stageTgzArchive` 内部 throw;档案 66 把它改成「收集式返回」
// (P0 不再抛出、由调用方裁决)之后**这里漏了裁决** → P0 会被静默放过。
// 本次一并补上,并叠加档案 71 的兼容性判定(导入 = 用户要求的第二个入口)。
// 拒绝以 error 文本回显:批量导入是逐个 try/catch 收集结果,没有单条交互式「显式信任」入口。
if (staged.blocked.length > 0) {
throw httpError(
409,
`安全检测命中 P0,已跳过该包:${staged.blocked
.slice(0, 3)
.map((b) => `${b.why}(${b.file})`)
.join(';')}`,
)
}
if (staged.compat.level === 'incompatible') {
throw httpError(
409,
`与当前平台 dsh 版本不兼容,已跳过该包:${staged.compat.findings
.slice(0, 3)
.map((f) => `${f.pkg} — ${f.detail}`)
.join(';')}`,
)
}
const dir = join(app.config.dataRoot, 'business-plugins')
await mkdir(dir, { recursive: true, mode: 0o755 })
const existing = await app.db.findBusinessPlugin(staged.name)
if (existing !== undefined && existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true })
const destPath = join(dir, staged.tgzName)
await rename(join(staged.stage, 'payload.tgz'), destPath)
const plugin = await app.db.upsertBusinessPlugin({
id: staged.name,
name: staged.name,
// 说明优先用**目录条目的中文**:`WhitelistEntry.description` 在 `getIndex()`
// 建索引时已按 `zh ?? en` 归一(见该处映射),比 tgz 里 `package.json` 的
// description(第三方作者写的,通常英文)更贴合中文用户。
// 之前这里直接写 `staged.description`,等于把目录里现成的中文丢掉 ——
// 门户「已投放插件」表与实例内「功能管理」因此只显示英文
//(2026-09-12 实测:anysearch / @liustack/modlens / dsh-univer-office 三者全部如此)。
description: entry.description !== '' ? entry.description : staged.description,
version: staged.version ?? version,
tgzPath: destPath,
fileSize: archive.length,
uploadedBy: request.user?.id ?? null,
})
await app.db.audit(request.user?.id ?? null, 'import_whitelist_plugin', JSON.stringify({ id, name: plugin.name, kind: entry.importKind }))
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
results.push({ id, name: staged.name, ok: true, kind: entry.importKind, version: plugin.version })
} catch (err) {
results.push({ id, name: id, ok: false, error: (err as Error).message })
}
}
return { ok: true, results }
})
}