初始提交: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

+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# DSH 平台一键备份(2026-09-11 加固:可执行位 + SQLite 一致性快照 + 纳入运维资产)
# 用法:/opt/dsh/backup.sh 产物:/opt/dsh/backups/dsh-platform-backup-<TS>.tar.gz
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/opt/dsh/backups/dsh-platform-backup-${TS}.tar.gz
TMP=$(mktemp -d /tmp/dsh-bk-XXXXXX)
trap 'rm -rf "$TMP"' EXIT
# 1) SQLite 一致性快照(运行中服务用 .backup,避免只拷到 -wal 而数据库不一致)
if command -v sqlite3 >/dev/null 2>&1 && [ -f /var/lib/dshs/dshs.db ]; then
sqlite3 /var/lib/dshs/dshs.db ".backup '$TMP/dshs.db'"
DB_SNAP=1
else
DB_SNAP=0
fi
# 2) 打包:用户数据 + 平台库/配置 + 运维资产(可重建)
cd /
ITEMS="var/lib/dshs etc/dshs.env etc/systemd/system/dshs.service etc/systemd/system/dsh-provision.path etc/systemd/system/dsh-provision.service"
[ -d /opt/dsh/artifacts ] && ITEMS="$ITEMS opt/dsh/artifacts"
[ -d /opt/dshs/scripts ] && ITEMS="$ITEMS opt/dshs/scripts"
for f in /etc/cron.d/dsh-maintenance /etc/cron.d/dsh-backup /etc/nftables-dsh-egress.nft; do
[ -f "$f" ] && ITEMS="$ITEMS ${f#/}"
done
[ -d /www/server/panel/vhost/nginx ] && ITEMS="$ITEMS www/server/panel/vhost/nginx"
tar -czf "$OUT" $ITEMS 2>/dev/null || true
# 3) 把一致性 DB 快照追加进归档(覆盖 tar 里的实时 db 副本,确保恢复时用的是快照)
if [ "$DB_SNAP" = "1" ]; then
tar -czf "${OUT%.tar.gz}-db-snapshot.tar.gz" -C "$TMP" dshs.db
fi
echo "备份完成: $OUT"
ls -lh "$OUT" ${OUT%.tar.gz}-db-snapshot.tar.gz 2>/dev/null || true
+5
View File
@@ -0,0 +1,5 @@
// Placeholder web build. The real admin + desktop UI (P1/P2) will be a small
// SPA compiled here into `dist-web/`. For the scaffold the static placeholder
// pages under `web/` are served directly, so this script intentionally does
// nothing yet.
console.log('[build-web] nothing to bundle yet (placeholder)')
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# 平台侧 CI:类型检查 + 构建 + 单元测试(档案 19 §C3)
#
# 设计取舍:**不纳入** scripts/smoke-*.mjs —— 它们需要「平台正在运行 + 有效账号凭据」,
# 属于手工/回归冒烟,不适合无人值守 CI。其中:
# - smoke-plugins.mjs 已删除(其目标 /api/plugins、/api/plugins/select 在档案 16 阶段 0 移除)
# - smoke-watchdog.mjs 已删除(watchdog 依赖 ENABLE_PATCH=true,线上为 false,从不启动;档案 20)
#
# 用法:bash scripts/ci.sh
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== 1/2 typecheck =="
npm run typecheck
echo "== 2/2 build + unit tests =="
npm test
echo "CI OK ✅"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* clean-ws-pollution.cjs —— 清理"平台安装插件"在用户工作区留下的污染(档案 28)
*
* 白名单(精确匹配,绝不碰其它内容):
* ws/.local pnpm store(旧 HOME=<ws> 造成)
* ws/.cache pnpm metadata 缓存
* ws/.poc-backup PoC 备份
* ws/poc PoC 源码副本
* ws/*.tgz 安装用插件包(现在改为直接用 /opt/dsh/artifacts/ 不再复制)
*
* 用法:
* node clean-ws-pollution.cjs # dry-run(默认,只打印)
* node clean-ws-pollution.cjs --apply # 实际执行:mv 到 <userRoot>/trash/<日期>-ws-pollution/
* node clean-ws-pollution.cjs --apply --user-id <uuid>
* 动作是**移动**(同盘 mv,秒级、可恢复),不是删除。
*/
const { execFileSync } = require('node:child_process')
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const APPLY = process.argv.includes('--apply')
const idx = process.argv.indexOf('--user-id')
const onlyId = idx >= 0 ? process.argv[idx + 1] : ''
const STAMP = new Date().toISOString().slice(0, 10)
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all()
.filter((u) => onlyId === '' || u.id === onlyId)
const du = (p) => {
try {
const out = execFileSync('du', ['-sk', p], { encoding: 'utf8' })
return Number(out.split(/\s+/)[0]) * 1024
} catch { return 0 }
}
const fmt = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
for (const u of users) {
const ws = join(u.home_dir, '..', 'ws')
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
const cands = []
for (const rel of ['.local', '.cache', '.poc-backup', 'poc']) {
const p = join(ws, rel)
if (existsSync(p)) cands.push({ p, size: du(p) })
}
for (const f of readdirSync(ws)) {
if (f.endsWith('.tgz')) {
const p = join(ws, f)
try { if (statSync(p).isFile()) cands.push({ p, size: statSync(p).size }) } catch {}
}
}
const total = cands.reduce((a, c) => a + c.size, 0)
console.log(` ${u.username}: 候选 ${cands.length} 项 / ${fmt(total)}${APPLY ? ' → 移入回收站' : '(dry-run)'}`)
for (const c of cands) console.log(` - ${c.p.replace(ws, 'ws')} ${fmt(c.size)}`)
if (APPLY && cands.length > 0) {
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-pollution`)
mkdirSync(trash, { recursive: true })
for (const c of cands) {
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
try {
execFileSync('mv', [c.p, dest])
execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest])
} catch (e) { console.log(` ! 移动失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
}
console.log(` → 已移至 ${trash.replace(u.home_dir, 'home')}(保留 30 天,可整目录移回恢复)`)
}
}
db.close()
console.log(APPLY ? 'done(已移动)' : 'done(dry-run,未改动)')
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env node
/**
* ensure-anysearch-admin.cjs —— 给指定实例接入 AnySearch 联网搜索(档案 64 · B 方案)
*
* 背景:平台现用 DeepSeek 官方搜索(`dsh-web-search-deepseek`,走 Anthropic Messages
* 的原生 `web_search` server tool)——**一次搜索 = 一次模型 turn**,成本偏高。本脚本把
* `@anysearch/anysearch-dsh` 装进目标用户 profile,并把 key 写进该用户的 dsh 凭据文件,
* 让 `web_search` 改走 AnySearch REST `/v1/search`(返回结构化 title/url/snippet)。
*
* 三个设计点(用户 2026-09-12 拍板):
* ① **分两步走**:先只装 admin 验证效果与配额,确认后再铺普通用户 —— 故默认只处理 admin。
* ② **保留本地抓取**:插件自带的 patch 会把 `fetchProvider` 也换成 anysearch;这里在
* profile 层追加覆写段把它拉回本地 `http`(保留 SSRF 防护:拒非公网地址、逐跳校验重定向)。
* ③ **key 只写该用户自己的 `.credentials.yaml`**(`refs` 段按环境变量名存),
* 不注入实例 env、不改平台 `baseEnv` —— 少一处外泄点。
*
* 顺序不可颠倒:**先写 key、再装插件**。profile 的 `patchReload: live` 有热加载可能,
* 反序会出现「provider 已切到 anysearch、key 还没配」的窗口。
*
* 用法:
* node ensure-anysearch-admin.cjs # 干跑(只打印计划,默认目标 admin)
* node ensure-anysearch-admin.cjs --apply # 执行(写 key + patch + 装插件)
* node ensure-anysearch-admin.cjs --apply --restart # 执行并停实例(新 bundle 才生效)
* node ensure-anysearch-admin.cjs --apply --restart guest # 第二步:铺普通用户
*
* key 从环境变量 `ANYSEARCH_API_KEY` 读,**不落盘到本脚本**:
* ANYSEARCH_API_KEY=as_sk_… node ensure-anysearch-admin.cjs --apply --restart
*
* 幂等:凭据已含该 key / patch 已含管理标记 / 依赖已是该 tgz → 各自跳过。
*/
// ─────────────────────────────────────────────────────────────────────────────
// ⛔ 2026-09-13 已退役(档案 65 §7.3 第 3 条 · 档案 70 §九)
// AnySearch 已按用户决定【彻底放弃】(候选池条目下架 + 存量插件下架)。
// 本脚本「写 provider 覆写段 + 装 anysearch 插件」的职责,已由**平台托管段**
// (档案 65:功能插件启停 ↔ web provider 配置联动)接管。
// 若两段并存,后者会覆盖前者 → 产生难以察觉的配置漂移,故在此**硬拦**。
// 确需运行(考古 / 回滚)时,显式设 DSH_ALLOW_RETIRED_ANYSEARCH=1。
// ─────────────────────────────────────────────────────────────────────────────
if (process.env.DSH_ALLOW_RETIRED_ANYSEARCH !== "1") {
console.error("⛔ ensure-anysearch-admin.cjs 已退役:AnySearch 已彻底放弃,provider 托管段由平台(档案 65)接管。");
console.error(" 确需运行请显式设置 DSH_ALLOW_RETIRED_ANYSEARCH=1(仅考古/回滚用)。");
process.exit(2);
}
const { execFileSync } = require('node:child_process')
const {
chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync,
} = require('node:fs')
const { basename, dirname, join, resolve } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB_PATH = '/var/lib/dshs/dshs.db'
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
const PKG = '@anysearch/anysearch-dsh'
const PROFILE = 'web'
const KEY_ENV = 'ANYSEARCH_API_KEY'
const MARK = 'platform: anysearch-search'
const PATCH_BLOCK = [
'',
`# >>> ${MARK} (managed by ensure-anysearch-admin.cjs)`,
'# 只换 search:AnySearch 提供联网搜索;fetch 仍走本地 http provider(保留 SSRF 防护)。',
'# ⚠ 本块对 dsh-web 条目是 **整体替换 config**(非字段级合并),所以两个字段都必须写全:',
'# 实测只写 fetchProvider 时,插件自带 patch 的 searchProvider 会被一并冲掉,',
'# 而 search registry 上 deepseek-official 与 anysearch 都 available()=true、又无显式选择,',
'# → 命中 WEB_PROVIDER_AMBIGUOUS(不是自动选一个,是直接报错)。',
'- id: web',
' config:',
' searchProvider: anysearch',
' fetchProvider: http',
`# <<< ${MARK}`,
'',
].join('\n')
const argv = process.argv.slice(2)
const APPLY = argv.includes('--apply')
const RESTART = argv.includes('--restart')
const positional = []
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--user') { positional.push(argv[++i] ?? ''); continue }
if (a.startsWith('--')) continue
positional.push(a)
}
const wanted = positional.filter(Boolean)
const KEY = process.env[KEY_ENV] ?? ''
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
function artifactPath() {
const prefix = 'anysearch-dsh-'
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(prefix) && f.endsWith('.tgz'))
if (cands.length === 0) throw new Error(`no ${prefix}*.tgz under ${ART_DIR}`)
const ver = (f) => f.slice(prefix.length, -4).split('.').map(Number)
cands.sort((a, b) => {
const va = ver(a); const vb = ver(b)
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
return 0
})
return join(ART_DIR, cands[cands.length - 1])
}
/** 读既有 node_modules 的 storeDir(沿用旧 store,否则 pnpm 会要求全量重装)。 */
function existingStoreDir(profileDir) {
try {
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
const m = /^storeDir:\s*(.+)$/m.exec(txt)
return m ? m[1].trim() : ''
} catch { return '' }
}
/** 摘掉指向不存在文件的 `file:` 依赖,否则 pnpm add 会在解析阶段 ENOENT 失败(档案 63)。 */
function pruneBrokenFileDeps(pkgPath) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const deps = pkg.dependencies ?? {}
const removed = []
for (const [k, v] of Object.entries(deps)) {
if (typeof v !== 'string' || !v.startsWith('file:')) continue
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
if (!existsSync(abs)) { delete deps[k]; removed.push(`${k} → ${v}`) }
}
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
return removed
} catch { return [] }
}
function isBundle(dir, dep) {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
return pkg.dsh?.bundle?.patch !== undefined
} catch { return false }
}
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
function reconcileBundles(dir) {
const path = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8'))
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
}
/** 停掉该 uid 名下所有 dsh scope(下次访问由编排器自动拉起)。 */
function stopInstance(uid) {
let out = ''
try {
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
} catch { return 0 }
let n = 0
for (const line of out.split('\n')) {
const unit = line.trim().split(/\s+/)[0]
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
try {
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
n += 1
} catch { /* 单个 scope 停不掉不阻断 */ }
}
return n
}
/**
* 在凭据文档里放一个 `refs.<ENV>` 条目(refs 段按环境变量名存 key 值)。
* 文档只有 `version` / `refs` / `records` 三个顶层段,其余一律拒绝加载(dsh 的行为)。
* 已存在同名条目 → 跳过(不覆盖用户可能已改过的值)。
*/
function ensureCredential(credPath, value) {
const text = readFileSync(credPath, 'utf8')
if (new RegExp(`(^|\\n)\\s*${KEY_ENV}:`, 'm').test(text)) return 'present'
let next
if (/^refs:[ \t]*$/m.test(text)) {
next = text.replace(/^refs:[ \t]*$/m, `refs:\n ${KEY_ENV}: ${value}`)
} else if (/^version: 1[ \t]*$/m.test(text)) {
next = text.replace(/^version: 1[ \t]*$/m, `version: 1\nrefs:\n ${KEY_ENV}: ${value}`)
} else {
throw new Error('凭据文档结构异常:找不到 "version: 1" 行,拒绝写入')
}
writeFileSync(credPath, next)
return 'inserted'
}
/** 幂等写入覆写段:无则追加,有但内容落后(缺字段)则原地替换整块。 */
function ensurePatch(patchPath) {
const text = readFileSync(patchPath, 'utf8')
const open = `# >>> ${MARK}`
const close = `# <<< ${MARK}`
const start = text.indexOf(open)
const end = text.indexOf(close)
if (start >= 0 && end > start) {
const tail = end + close.length
const next = text.slice(0, start) + PATCH_BLOCK.trimStart() + text.slice(tail)
if (next === text) return 'present'
writeFileSync(patchPath, next)
return 'updated'
}
writeFileSync(patchPath, text.replace(/\s*$/, '\n') + PATCH_BLOCK)
return 'appended'
}
// ---- main ----
if (!existsSync(DB_PATH)) { console.error('✗ 找不到平台 DB'); process.exit(1) }
const db = new Database(DB_PATH, { readonly: true })
const all = db.prepare('SELECT id, username, uid, role, home_dir FROM users').all()
db.close()
const users = all.filter((u) => (wanted.length > 0
? (wanted.includes(u.username) || wanted.includes(u.id) || wanted.includes(String(u.uid)))
: u.username === 'admin'))
if (users.length === 0) { console.error(`✗ 没匹配到用户:${wanted.join(',') || 'admin'}`); process.exit(1) }
if (APPLY && KEY === '') { console.error(`✗ 需要环境变量 ${KEY_ENV} 提供 key`); process.exit(1) }
let artifact
try { artifact = artifactPath() } catch (err) { console.error(`✗ ${err.message}`); process.exit(1) }
console.log(`artifact = ${artifact}`)
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}${RESTART ? ' + RESTART' : ''}`)
console.log(`targets = ${users.map((u) => `${u.username}(uid ${u.uid})`).join(', ')}`)
for (const u of users) {
console.log(`\n=== ${u.username} (uid ${u.uid}) ===`)
const root = join(u.home_dir, '..')
const ws = join(root, 'ws')
const dir = join(u.home_dir, 'profiles', PROFILE)
const pkgPath = join(dir, 'package.json')
if (!existsSync(dir) || !existsSync(pkgPath)) { console.log(' · 无 profile(未首登)→ 跳过'); continue }
const credPath = join(u.home_dir, '.credentials.yaml')
const patchPath = join(dir, 'cordis.patch.yml')
const pkg0 = JSON.parse(readFileSync(pkgPath, 'utf8'))
const hasDep = Object.keys(pkg0.dependencies ?? {}).includes(PKG)
const inBundles = (pkg0.dsh?.profile?.bundles ?? []).includes(PKG)
const staged = join(u.home_dir, '.dsh-stage', basename(artifact))
console.log(` [计划] 凭据 ${credPath} → refs.${KEY_ENV}`)
console.log(` [计划] patch ${patchPath} → 追加 fetchProvider: http 覆写段`)
console.log(` [计划] 安装 ${PKG}(现 deps=${hasDep ? '有' : '无'} / bundles=${inBundles ? '有' : '无'})`)
console.log(` [计划] 停实例 scope:${RESTART ? '是' : '否'}`)
if (!APPLY) continue
// 1) 先写 key(顺序不可颠倒)
if (!existsSync(credPath)) { console.log(` ✗ 缺凭据文件 ${credPath} → 跳过该用户`); continue }
const credBackup = `${credPath}.bak-anysearch`
if (!existsSync(credBackup)) { copyFileSync(credPath, credBackup); chmodSync(credBackup, 0o600) }
try {
const credAction = ensureCredential(credPath, KEY)
chownSync(credPath, u.uid, u.uid)
chmodSync(credPath, 0o600)
console.log(` ✓ 凭据 ${credAction}(已恢复 600 + uid ${u.uid})`)
} catch (err) {
console.log(` ✗ 凭据写入失败:${err.message} → 跳过该用户(插件未装,避免无 key 窗口)`)
continue
}
// 2) 再写 profile patch(保留本地 fetch)
if (existsSync(patchPath)) {
const patchBackup = `${patchPath}.bak-anysearch`
if (!existsSync(patchBackup)) copyFileSync(patchBackup === patchPath ? patchPath : patchPath, patchBackup)
const patchAction = ensurePatch(patchPath)
chownSync(patchPath, u.uid, u.uid)
console.log(` ✓ patch ${patchAction}`)
} else {
console.log(` ⚠ 无 ${patchPath} → 跳过覆写(fetch 将跟随插件默认走 anysearch)`)
}
// 3) 装插件
try {
const pruned = pruneBrokenFileDeps(pkgPath)
if (pruned.length > 0) {
console.log(` · 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
try { chownSync(pkgPath, u.uid, u.uid) } catch { /* 尽力而为 */ }
}
const stageDir = join(u.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
if (!existsSync(staged)) copyFileSync(artifact, staged)
chmodSync(staged, 0o444)
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
const legacyStore = existingStoreDir(dir)
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
const legacyCache = join(ws, '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
const args = [
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
'--store-dir', storeDir, '--cache-dir', cacheDir,
]
if (isRoot) args.push('-w')
args.push(`file:${staged}`)
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
console.log(` ✓ 已安装 ${PKG}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'})`)
const final = reconcileBundles(dir)
console.log(` ✓ bundles=${final.length}(含 ${PKG}: ${final.includes(PKG)})`)
} catch (err) {
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
console.log(` ✗ 安装失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
continue
}
// 4) 停实例(新 bundle 才生效)
if (RESTART) {
const n = stopInstance(u.uid)
console.log(` ✓ 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
} else {
console.log(' · 未停实例:bundle 尚未生效,需后续重启')
}
}
console.log('\ndone')
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* ensure-anysearch-pool.mjs —— 把 AnySearch 插件投放进「功能插件」候选池(档案 64 / 65)
*
* 与门户 `POST /api/plugins/business`(admin 上传)走**同一条校验路径**:复用平台编译产物
* 导出的 `stageTgzArchive()`(列成员防路径穿越 → 解压 → `package.json` 校验 → 危险内容扫描),
* 再按同一「替换策略」(同名先删旧 tgz、旧文件无残留)落盘进 `<dataRoot>/business-plugins/`,
* 并 upsert `business_plugins` 行。
*
* 投放完成后,用户在实例「设置 → 功能管理」里自助**启用/禁用**;启用/禁用会由
* `syncWebProviderPatch()`(档案 65)自动维护 profile 的 web provider 托管段。
*
* 为什么需要它:门户上传需要浏览器的 admin 会话;本脚本用于无会话场景(如本次迁移),
* 产物与走门户完全一致。
*
* 用法:
* node scripts/ensure-anysearch-pool.mjs # 干跑(只打印)
* node scripts/ensure-anysearch-pool.mjs --apply # 执行投放
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import Database from '/opt/dshs/node_modules/better-sqlite3/lib/index.js'
const DATA_ROOT = process.env.DSH_DATA_ROOT ?? '/var/lib/dshs'
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
const POOL_DIR = join(DATA_ROOT, 'business-plugins')
const DB_PATH = join(DATA_ROOT, 'dshs.db')
const PLUGIN_ROUTES = '/opt/dshs/lib/web/routes/business-plugins.js'
const PREFIX = 'anysearch-dsh-'
const APPLY = process.argv.includes('--apply')
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
function pickArtifact() {
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(PREFIX) && f.endsWith('.tgz'))
if (cands.length === 0) throw new Error(`no ${PREFIX}*.tgz under ${ART_DIR}`)
const ver = (f) => f.slice(PREFIX.length, -4).split('.').map(Number)
cands.sort((a, b) => {
const va = ver(a); const vb = ver(b)
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
return 0
})
return join(ART_DIR, cands[cands.length - 1])
}
const artifact = pickArtifact()
const size = statSync(artifact).size
console.log(`artifact = ${artifact}`)
console.log(`size = ${size} B`)
console.log(`pool dir = ${POOL_DIR}`)
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}`)
if (!APPLY) {
console.log('\n(干跑结束;加 --apply 执行)')
process.exit(0)
}
// 1) 走平台自己的校验 + staging(与门户上传完全一致)
const { stageTgzArchive } = await import(PLUGIN_ROUTES)
const staged = stageTgzArchive(DATA_ROOT, readFileSync(artifact))
console.log(`staged = name=${staged.name} version=${staged.version} tgz=${staged.tgzName} files=${staged.fileCount}`)
// 1b) 安全检测裁决 —— 与上传接口同一套语义:**默认 fail-closed**,只有显式声明信任才放行。
const TRUST = process.argv.includes('--trust')
if (staged.blocked.length > 0) {
console.log(`\n⚠ 安全检测命中 P0 规则 ${staged.blocked.length} 处:`)
for (const b of staged.blocked) console.log(` - ${b.file} — ${b.why}`)
if (!TRUST) {
rmSync(staged.stage, { recursive: true, force: true })
console.log('\n默认拒绝投放(fail-closed)。逐条确认确属误报 / 风险可控后,加 --trust 重新执行;')
console.log('放行会写入 audit_log(trust_business_plugin),留痕「谁 / 何时 / 命中什么 / 理由」。')
process.exit(2)
}
console.log(' (--trust 已声明 → 继续投放并留痕)')
} else {
console.log('scan = clean(无 P0 命中)')
}
if (staged.warnings.length > 0) console.log(`scan = ${staged.warnings.length} 条 P1 告警(不阻断)`)
// 2) 替换策略落盘
mkdirSync(POOL_DIR, { recursive: true, mode: 0o755 })
const db = new Database(DB_PATH)
const existing = db.prepare('SELECT id, tgz_path FROM business_plugins WHERE id = ?').get(staged.name)
if (existing !== undefined && existsSync(existing.tgz_path)) {
rmSync(existing.tgz_path, { force: true })
console.log(`replaced = 已删除旧包 ${existing.tgz_path}`)
}
const dest = join(POOL_DIR, staged.tgzName)
renameSync(join(staged.stage, 'payload.tgz'), dest)
// 3) upsert 元数据行
const now = Date.now()
const admin = db.prepare("SELECT id FROM users WHERE username = 'admin'").get()
const uploadedBy = admin?.id ?? null
if (existing !== undefined) {
db.prepare(
'UPDATE business_plugins SET name=?, description=?, version=?, tgz_path=?, file_size=?, uploaded_by=?, updated_at=? WHERE id=?',
).run(staged.name, staged.name, staged.description, staged.version, dest, size, uploadedBy, now, staged.name)
} else {
db.prepare(
'INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)',
).run(staged.name, staged.name, staged.description, staged.version, dest, size, uploadedBy, now, now)
}
// 留痕:与上传接口同一组 action(命中 P0 时另记一条 trust_business_plugin)
db.prepare('INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
now,
uploadedBy,
'upload_business_plugin',
JSON.stringify({ name: staged.name, version: staged.version, trustedOverride: staged.blocked.length > 0, via: 'ensure-anysearch-pool.mjs' }),
)
if (staged.blocked.length > 0) {
db.prepare('INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
now,
uploadedBy,
'trust_business_plugin',
JSON.stringify({ name: staged.name, version: staged.version, blocked: staged.blocked, reason: (process.env.TRUST_REASON ?? '').trim() }),
)
}
const rows = db.prepare('SELECT id, version, file_size, tgz_path FROM business_plugins ORDER BY id ASC').all()
db.close()
rmSync(staged.stage, { recursive: true, force: true })
console.log(`\n✓ 已投放:${dest}`)
console.log('候选池当前内容:')
for (const r of rows) console.log(` - ${r.id} @ ${r.version} (${r.file_size} B) → ${r.tgz_path}`)
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env node
/**
* ensure-biz-plugins.cjs —— 给用户铺「功能插件」分区(档案 36 · 批次 2)
*
* 背景:`@dsh-local/business-plugins` 是「dsh 设置面板 → 功能插件」分区的 client bundle
* (列 admin 投放的插件 + 批量启停 + 确认 + 重启)。它原先只装在 admin profile 里,
* 普通用户看不到该分区 → 本脚本把它铺给普通用户。
*
* 幂等:判定依据是 **bundles**(包内 cordis.patch.yml 只对 bundles 成员生效),
* 不是 dependencies —— 只有 dep 而没进 bundles 时只需 reconcile,不必重装。
*
* 用法:
* node ensure-biz-plugins.cjs # 所有 role=active 的非 admin 用户
* node ensure-biz-plugins.cjs <userId|username>...
* node ensure-biz-plugins.cjs --all # 含 admin(自检用)
* node ensure-biz-plugins.cjs --restart # 铺完停掉其实例 scope(下次访问自动拉起,bundle 才生效)
*/
const { execFileSync } = require('node:child_process')
const { chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } = require('node:fs')
const { basename, dirname, join, resolve } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB_PATH = '/var/lib/dshs/dshs.db'
const BUNDLE = '@dsh-local/business-plugins'
// 自动取产物目录里版本号最大的 business-plugins-*.tgz(升级只需丢新包,不用改脚本)
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
const ARTIFACT = (function () {
const PREFIX = 'business-plugins-'
const cands = readdirSync(ART_DIR).filter((f) => f.indexOf(PREFIX) === 0 && f.slice(-4) === '.tgz')
const ver = (f) => f.slice(PREFIX.length, -4).split('.').map(Number)
cands.sort((a, b) => {
const va = ver(a)
const vb = ver(b)
for (let i = 0; i < 3; i++) {
const x = va[i] || 0
const y = vb[i] || 0
if (x !== y) return x - y
}
return 0
})
if (cands.length === 0) throw new Error('no ' + PREFIX + '*.tgz under ' + ART_DIR)
return join(ART_DIR, cands[cands.length - 1])
})()
const PROFILE = 'web'
/** guest 的 profile 里有 pnpm-workspace.yaml → pnpm 视为 workspace root 而拒绝 add;
* `--ignore-workspace-root-check` 让它在两种 profile 下都能工作。 */
const WFLAG = '--ignore-workspace-root-check'
const argv = process.argv.slice(2)
const ALL = argv.includes('--all')
const RESTART = argv.includes('--restart')
const wanted = argv.filter((a) => !a.startsWith('--'))
/**
* 读出既有 node_modules 记录的 storeDir(不存在则返回空串)。
*
* 为什么必须读:pnpm 对**已有安装**做增量时会校验 store 位置,不一致直接拒绝 ——
* ERR_PNPM_UNEXPECTED_STORE(档案 57 实测):存量 profile 的 node_modules 诞生于
* 「HOME=<ws>」时代,storeDir 记为 ws 内路径;脚本若硬换 <home>/.pnpm-store,
* pnpm 要求先 `pnpm install` 重建全量依赖(需联网重拉整棵依赖树)。
* 所以:**已有安装沿用旧 store,全新 profile 才用 <home>/.pnpm-store**。
*/
function existingStoreDir(profileDir) {
try {
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
const m = /^storeDir:\s*(.+)$/m.exec(txt)
return m ? m[1].trim() : ''
} catch {
return ''
}
}
/**
* 清掉「指向不存在文件的 `file:` 依赖」,返回被删清单。
*
* 由来(2026-09-12,档案 63):档案 60 把 ws 里的安装残留 tgz 移入 trash 后,profile 的
* `dependencies` 里 `file:<ws>/xxx.tgz` 的 spec 就断了 —— 此后**任何** `pnpm add` 都会在
* 解析阶段直接 ENOENT 失败(两个用户全中,用户侧表现为「安装失败」)。这正是档案 57 §五.2
* 预警过的隐患。此处自愈:解析不到的 `file:` 依赖先摘除,随后 add 新包会写入正确的新路径。
* 安全边界:只删 `file:` 且**目标确实不存在**的条目;registry 依赖与其他依赖一概不动。
*/
function pruneBrokenFileDeps(pkgPath) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const deps = pkg.dependencies ?? {}
const removed = []
for (const [k, v] of Object.entries(deps)) {
if (typeof v !== 'string' || !v.startsWith('file:')) continue
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
if (!existsSync(abs)) {
delete deps[k]
removed.push(`${k} → ${v}`)
}
}
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
return removed
} catch {
return []
}
}
function isBundle(dir, dep) {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
return pkg.dsh?.bundle?.patch !== undefined
} catch {
return false
}
}
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
function reconcileBundles(dir) {
const path = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8'))
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
}
/** 停掉该 uid 名下所有 dsh scope(与编排器 stopScopesByPrefix 同法)。 */
function stopInstance(uid) {
let out = ''
try {
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
} catch {
return 0
}
let n = 0
for (const line of out.split('\n')) {
const unit = line.trim().split(/\s+/)[0]
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
try {
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
n += 1
} catch {
/* 单个 scope 停不掉不阻断 */
}
}
return n
}
if (!existsSync(ARTIFACT)) {
console.error(`✗ 缺产物:${ARTIFACT}(用 04-调整方案/poc/business-plugins 重新打包)`)
process.exit(1)
}
const db = new Database(DB_PATH, { readonly: true })
const users = db
.prepare('SELECT id, username, uid, role, home_dir FROM users')
.all()
.filter((u) => (wanted.length > 0 ? wanted.includes(u.id) || wanted.includes(u.username) : true))
.filter((u) => (ALL || wanted.length > 0 ? true : u.role === 'active' && u.username !== 'admin'))
db.close()
if (users.length === 0) {
console.log('没有匹配的用户')
process.exit(0)
}
for (const u of users) {
const root = join(u.home_dir, '..')
const ws = join(root, 'ws')
const dir = join(u.home_dir, 'profiles', PROFILE)
const pkgPath = join(dir, 'package.json')
if (!existsSync(dir) || !existsSync(pkgPath)) {
console.log(` ${u.username}: 无 profile(尚未启动过实例)→ 跳过`)
continue
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const bundles = pkg.dsh?.profile?.bundles ?? []
const inBundles = bundles.includes(BUNDLE)
const inDeps = Object.keys(pkg.dependencies ?? {}).includes(BUNDLE)
const wantBase = basename(ARTIFACT)
const depSpec = String((pkg.dependencies ?? {})[BUNDLE] ?? "")
const upToDate = depSpec.endsWith(wantBase)
if (inBundles && upToDate) {
console.log(` ${u.username}: 已是 ${wantBase} → 跳过`)
continue
}
if (inBundles && !upToDate) {
console.log(` ${u.username}: 版本落后(${depSpec.split("/").pop() || "无"} → ${wantBase}),升级中…`)
}
try {
if (!inDeps || !upToDate) {
// 2026-09-12(档案 61):安装姿势与 ensure-portal-entry.cjs 对齐。
// 旧姿势 =「复制 tgz 进 ws + root 身份 + HOME=<ws> 跑 pnpm」,实测三个副作用:
// ① ws 里堆 `*.tgz` / `.local` / `.cache`(档案 60 实测:admin 4 个 · guest 3 个残留)
// ② 用户家目录留 root 属主项 → 用户 `pip install --user` 报 Permission denied(档案 43)
// ③ **升级存量 node_modules 时会撞 ERR_PNPM_UNEXPECTED_STORE**(老依赖由 ws 内 store
// 链接而来,换位置即被 pnpm 拒绝)—— 档案 57 已在 portal-entry 上实测到
// 新姿势:tgz 暂存 <home>/.dsh-stage/、以该用户 uid 执行 setpriv、store 位置自适应。
// (注:原 WFLAG 常量随之弃用,保留定义不影响运行。)
// 安装前自愈:先清掉指向已不存在文件的 `file:` 依赖,否则下面的 `pnpm add` 必定
// 在解析阶段 ENOENT 失败(2026-09-12 实测两用户全中)。
const pruned = pruneBrokenFileDeps(pkgPath)
if (pruned.length > 0) {
console.log(` ${u.username}: 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
// 以 root 改写过 package.json → 属主收回给该用户,避免用户侧读到 root 属主文件。
try { execFileSync('chown', [`${u.uid}:${u.uid}`, pkgPath], { stdio: 'pipe' }) } catch { /* 尽力而为 */ }
}
const stageDir = join(u.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
const staged = join(stageDir, basename(ARTIFACT))
if (!existsSync(staged)) copyFileSync(ARTIFACT, staged)
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
const legacyStore = existingStoreDir(dir)
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
const legacyCache = join(ws, '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
const args = [
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
'--store-dir', storeDir, '--cache-dir', cacheDir,
]
if (isRoot) args.push('-w')
args.push(`file:${staged}`)
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
console.log(
` ${u.username}: 已安装/更新依赖${isRoot ? '(-w)' : ''}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'},ws 保持干净)`,
)
} else {
console.log(` ${u.username}: 依赖已是最新,仅 reconcile`)
}
const final = reconcileBundles(dir)
console.log(` ${u.username}: ✓ bundles=${final.length}(含 ${BUNDLE}: ${final.includes(BUNDLE)})`)
if (RESTART) {
const n = stopInstance(u.uid)
console.log(` 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
}
} catch (err) {
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
console.log(` ${u.username}: ✗ 失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
}
}
console.log('done')
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* ensure-portal-entry.cjs —— 给**所有**用户铺「设置面板 → 用户管理」入口
*
* 背景:`@dsh-local/portal-entry` 的 client 面在 dsh 设置面板注册「用户管理」分区
* (管理员:门户地址 + 打开管理台 + 退出登录;普通用户:仅退出登录)。
* 它原先只装在 admin 的 profile 里(用户要求"普通用户也要有用户管理入口")→ 本脚本铺给全员。
*
* 与其他铺开脚本的区别(重要):
* · portal-entry **不需要**写 cordis.patch.yml 平台段 —— 它由 profile 的
* `package.json → dsh.profile.bundles` 加载(与 admin 现状一致)。
* · 安装姿势沿用 ensure-workspace-picker.cjs 的 **2026-09-11 修复版**:
* tgz 暂存到 <home>/.dsh-stage/、以该用户 uid 执行 setpriv、store/cache 显式指向 <home>。
* **绝不**把 tgz 复制进工作区、**绝不**让 pnpm 把 store 写进 ws
* (旧姿势实测污染 admin ws 达 17MB / 2045 文件)。
*
* 幂等:判定依据是 node_modules 里已装版本 + dep spec 是否指向本次产物;两者都对即跳过。
*
* 用法:
* node ensure-portal-entry.cjs # 全部用户兜底
* node ensure-portal-entry.cjs --all # 同上(显式)
* node ensure-portal-entry.cjs admin guest # 指定用户名
* node ensure-portal-entry.cjs --user-id <uuid> # 指定用户 id
* node ensure-portal-entry.cjs --tgz <path> # 指定产物
* node ensure-portal-entry.cjs --dry-run # 只打印计划
* node ensure-portal-entry.cjs --restart # 装完停掉其实例 scope(下次访问自动拉起才生效)
*/
const { execFileSync } = require('node:child_process')
const { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require('node:fs')
const { basename, dirname, join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB = '/var/lib/dshs/dshs.db'
const ARTIFACTS = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
const PROFILE = 'web'
const BUNDLE = '@dsh-local/portal-entry'
const PKG_DIR = '@dsh-local/portal-entry'
const PREFIX = 'portal-entry-'
const argv = process.argv.slice(2)
const DRY = argv.includes('--dry-run')
const RESTART = argv.includes('--restart')
const valueOf = (flag) => {
const i = argv.indexOf(flag)
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
}
const tgzFlag = valueOf('--tgz')
const onlyIds = valueOf('--user-id') === '' ? [] : [valueOf('--user-id')]
const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== ''))
const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a))
function pickTgz() {
if (tgzFlag !== '') return tgzFlag
const files = readdirSync(ARTIFACTS)
.filter((f) => f.startsWith(PREFIX) && f.slice(-4) === '.tgz')
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到 ${PREFIX}*.tgz`)
return join(ARTIFACTS, files[files.length - 1])
}
const TGZ = pickTgz()
if (!existsSync(TGZ)) {
console.error(`✗ 缺产物:${TGZ}`)
process.exit(1)
}
const VER = (TGZ.match(new RegExp(`${PREFIX.replace(/\./g, '\\.')}(.+)\\.tgz$`)) || [])[1] || 'unknown'
const WANT_BASE = basename(TGZ)
/** 已装版本(读该用户 profile 的 node_modules)。 */
function installedVersion(profileDir) {
try {
return JSON.parse(readFileSync(join(profileDir, 'node_modules', PKG_DIR, 'package.json'), 'utf8')).version
} catch {
return null
}
}
/** 用户根目录(<dataRoot>/users/<id>)—— home_dir 恒为 <userRoot>/home。 */
function userRootOf(u) {
return dirname(u.home_dir)
}
/**
* 读出既有 node_modules 记录的 storeDir。
*
* 为什么必须读:pnpm 对**已有安装**做增量时会校验 store 位置,不一致直接拒绝 ——
* ERR_PNPM_UNEXPECTED_STORE(实测 2026-09-11):
* dependencies at ".../profiles/web/node_modules" are currently linked from the
* store at "<userRoot>/ws/.local/share/pnpm/store/v3"
* pnpm now wants to use the store at "<home>/.pnpm-store/v3"
* 存量 profile 的 node_modules 全部诞生于「HOME=<ws>」时代的安装,storeDir 记为 ws 内路径,
* 因此**沿用旧 store** 才装得动。若硬换新位置,pnpm 要求先 `pnpm install` 重建全量依赖
* (需联网重拉整棵 dsh 依赖树)—— 风险与耗时都不成比例。
* 全新 profile(无 node_modules)没有历史包袱 → 走 <home>/.pnpm-store(不污染 ws)。
*/
function existingStoreDir(profileDir) {
try {
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
const m = /^storeDir:\s*(.+)$/m.exec(txt)
return m ? m[1].trim() : ''
} catch {
return ''
}
}
/** 该包是否声明了 dsh.bundle.patch —— dsh 只把这类 dep 视为 bundle 成员。 */
function isBundle(profileDir, dep) {
try {
const pkg = JSON.parse(readFileSync(join(profileDir, 'node_modules', dep, 'package.json'), 'utf8'))
return pkg.dsh?.bundle?.patch !== undefined
} catch {
return false
}
}
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
function reconcileBundles(profileDir) {
const path = join(profileDir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8'))
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(profileDir, 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
}
/** 停掉该 uid 名下所有 dsh scope(与编排器 stopScopesByPrefix 同法)。 */
function stopInstance(uid) {
let out = ''
try {
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
} catch {
return 0
}
let n = 0
for (const line of out.split('\n')) {
const unit = line.trim().split(/\s+/)[0]
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
try {
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
n += 1
} catch {
/* 单个 scope 停不掉不阻断 */
}
}
return n
}
const db = new Database(DB, { readonly: true })
const users = db
.prepare('SELECT id, username, uid, role, home_dir FROM users')
.all()
.filter(
(u) => (only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
)
db.close()
if (users.length === 0) {
console.log('没有匹配的用户')
process.exit(0)
}
console.log(`产物: ${TGZ}(版本 ${VER})`)
for (const u of users) {
const profileDir = join(u.home_dir, 'profiles', PROFILE)
if (!existsSync(profileDir)) {
console.log(` ${u.username}: NO_PROFILE(尚未首登 spawn)→ 跳过`)
continue
}
const pkgPath = join(profileDir, 'package.json')
if (!existsSync(pkgPath)) {
console.log(` ${u.username}: 无 package.json → 跳过`)
continue
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const depSpec = String((pkg.dependencies ?? {})[BUNDLE] ?? '')
const inBundles = (pkg.dsh?.profile?.bundles ?? []).includes(BUNDLE)
const installed = installedVersion(profileDir)
const upToDate = installed === VER && depSpec.endsWith(WANT_BASE) && inBundles
if (upToDate) {
console.log(` ${u.username}: skip(已装 v${installed} 且在 bundles)`)
continue
}
if (DRY) {
console.log(
` ${u.username}: [dry-run] 已装=${installed ?? '无'} 目标=${VER} bundles=${inBundles} spec=${depSpec.split('/').pop() || '无'}`,
)
continue
}
try {
// ① 暂存产物到该用户自己的 home(/opt/dsh 是 drwx------ root,用户 uid 读不到其中文件)
const stageDir = join(u.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
const staged = join(stageDir, WANT_BASE)
if (!existsSync(staged)) copyFileSync(TGZ, staged)
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
// ② 以该用户身份安装。store 位置**自适应**(见 existingStoreDir 的说明):
// 已有安装 → 沿用其 .modules.yaml 记录的 store(否则 ERR_PNPM_UNEXPECTED_STORE);
// 全新 profile → <home>/.pnpm-store(干净,不写进 ws)。
// cache 同理:沿用既有 ws/.cache/pnpm 可免重新拉 metadata;新 profile 用 <home>/.pnpm-cache。
// profile 若为 pnpm workspace 根必须 -w,否则 ERR_PNPM_ADDING_TO_ROOT。
const legacyStore = existingStoreDir(profileDir)
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
const legacyCache = join(userRootOf(u), 'ws', '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
const args = [
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
'--store-dir', storeDir,
'--cache-dir', cacheDir,
]
if (isRoot) args.push('-w')
args.push(`file:${staged}`)
execFileSync('setpriv', args, { cwd: profileDir, stdio: 'pipe', timeout: 180000 })
// ③ bundles reconcile(dsh 只加载 bundles 成员;hook 未进 bundles 则插件不生效)
const final = reconcileBundles(profileDir)
const ok = final.includes(BUNDLE)
console.log(
` ${u.username}: ✓ v${installedVersion(profileDir) ?? '?'}(${isRoot ? '-w,' : ''}bundles=${final.length},含本插件=${ok},store=${legacyStore !== '' ? '沿用旧(ws 内)' : '新建 home'})`,
)
if (RESTART) {
const n = stopInstance(u.uid)
console.log(` 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
}
} catch (err) {
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
console.log(` ${u.username}: ✗ 失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
}
}
console.log('done')
+58
View File
@@ -0,0 +1,58 @@
// Stand-in for a real `dsh` process used by the smoke tests. It is role-aware
// via DSHS_ROLE:
// - main: bind the loopback port and serve a marker page; /crash exits 1.
// - watchdog: headless; polls the handoff file and records what it "executes".
import { createServer } from 'node:http'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
const role = process.env.DSHS_ROLE ?? 'main'
const port = Number(process.env.DSHS_PORT ?? '3080')
const cwd = process.cwd()
if (role === 'watchdog') {
// One-shot watchdog: mark it ran, execute any handoff command, then exit.
const handoffPath = process.env.DSHS_HANDOFF_PATH
console.log(`fake-watchdog one-shot running cwd=${cwd}`)
if (handoffPath !== undefined) {
writeFileSync(join(dirname(handoffPath), 'watchdog-ran.json'), JSON.stringify({ at: Date.now() }))
}
setTimeout(() => {
let command = null
if (handoffPath !== undefined && existsSync(handoffPath)) {
const content = readFileSync(handoffPath, 'utf8').trim()
if (content !== '') {
try {
command = JSON.parse(content).command ?? content
} catch {
command = content
}
writeFileSync(join(dirname(handoffPath), 'watchdog-executed.json'), JSON.stringify({ command, at: Date.now() }))
writeFileSync(handoffPath, '')
}
}
console.log(`fake-watchdog done${command ? ` (executed: ${command})` : ''}`)
process.exit(0)
}, 300)
} else {
const server = createServer((req, res) => {
if (req.url === '/crash') {
res.writeHead(500, { 'content-type': 'text/plain' })
res.end('crashing')
setTimeout(() => process.exit(1), 10)
return
}
if (req.url === '/redirect') {
res.writeHead(302, { location: '/somewhere' })
res.end('redirecting')
return
}
res.writeHead(200, { 'content-type': 'text/plain' })
res.end(
`fake-dsh pid=${process.pid} port=${port} cwd=${cwd} url=${req.url} argv=${process.argv.slice(2).join(' ')}`,
)
})
server.listen(port, '127.0.0.1', () => {
console.log(`fake-dsh listening on ${port}`)
})
}
+16
View File
@@ -0,0 +1,16 @@
// Stand-in for a setuid wrapper (e.g. `setpriv --reuid`). Logs the uid it was
// given, then execs the remaining argv (the real DSH command) so the smoke test
// can verify the account-level uid was passed while still running the child.
import { spawn } from 'node:child_process'
import { writeFileSync } from 'node:fs'
const uidIdx = process.argv.indexOf('--uid')
const uid = uidIdx >= 0 ? process.argv[uidIdx + 1] : 'unknown'
writeFileSync('setpriv-uid.txt', String(uid))
const rest = process.argv.slice(uidIdx >= 0 ? uidIdx + 2 : 2)
if (rest.length > 0) {
const child = spawn(rest[0], rest.slice(1), { stdio: 'inherit' })
child.on('exit', (code) => process.exit(code ?? 0))
} else {
process.exit(0)
}
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* gen-capabilities.cjs —— 生成「实例能力清单」(档案 56)
*
* 解决什么:agent 每开新会话都要**现场试一遍**才能知道能做什么(实测同一批探测重复 3 轮,
* 撞同样 4 类墙:/etc 白名单、127.0.0.1 被 SSRF 拒、技能不存在、写边界),用户也跟着反复问
* "能力有变化吗"。本脚本把**平台事实**固化成两份同源产物:
* ① /opt/dsh/state/capabilities.json —— 给平台 API / 门户(机读)
* ② <bundled-skill-dir>/platform-capabilities/SKILL.md —— 给实例内 agent(它可被 skill 机制加载)
*
* 用法:node scripts/gen-capabilities.cjs # 生成
* node scripts/gen-capabilities.cjs --print # 只打印 JSON
* 幂等、只写上述两个路径;任何探测失败只降级为 "unknown",不报错退出。
*/
'use strict'
const { execFileSync } = require('node:child_process')
const { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const STATE = process.env.DSH_STATE_DIR ?? '/opt/dsh/state'
const OUT_JSON = join(STATE, 'capabilities.json')
const BUNDLED = process.env.DSH_BUNDLED_SKILL_DIR ?? '/var/lib/dshs/bundled-skills'
const USERS = process.env.DSH_USERS_DIR ?? '/var/lib/dshs/users'
const PERMISSION_MODE = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
const run = (cmd, args) => {
try {
return execFileSync(cmd, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
.trim().split('\n')[0].slice(0, 80)
} catch {
return 'MISSING'
}
}
// ── 1) 工具链(宿主层安装 → 实例经 /usr 只读可见)──────────────
const tools = {
node: run('node', ['-v']),
npm: run('npm', ['-v']),
pnpm: run('pnpm', ['-v']),
python3: run('python3', ['--version']),
git: run('git', ['--version']),
curl: run('curl', ['--version']),
ripgrep: run('rg', ['--version']),
jq: run('jq', ['--version']),
ffmpeg: run('ffmpeg', ['-version']),
bubblewrap: run('bwrap', ['--version']),
}
// ── 2) 技能清单(共享层 + 各用户个人层)────────────────────────
const listSkills = (dir) => {
if (!existsSync(dir)) return []
try {
return readdirSync(dir).filter((n) => {
try { return statSync(join(dir, n)).isDirectory() } catch { return false }
})
} catch {
return []
}
}
const sharedSkills = listSkills(BUNDLED)
const perUserSkills = {}
if (existsSync(USERS)) {
for (const uid of readdirSync(USERS)) {
const s = listSkills(join(USERS, uid, 'home', 'skills'))
if (s.length) perUserSkills[uid.slice(0, 8)] = s
}
}
// ── 3) 能力清单(结论层;每项都可在平台上核对)──────────────────
const capabilities = {
generatedAt: new Date().toISOString(),
permissionMode: PERMISSION_MODE,
note: PERMISSION_MODE === 'danger-full-access'
? '当前平台默认档位 = 完全权限(不在实例内叠加文件沙箱、不弹审批)。dsh 界面里可切换。'
: '当前平台默认档位 = workspace-write(需要可用的沙箱后端;本机不可用时会拒绝任何 shell)。',
read: {
allowed: ['/usr(工具链与运行时,只读)', '/etc 白名单(见平台补丁)', '/proc /dev(沙箱内)', '自己的工作区 ws/ 与实例 home/'],
denied: ['其他用户目录(users/<其他 uuid>/)', '宿主内部路径与凭据', '/etc 白名单以外的配置'],
},
write: {
allowed: ['工作区 ws/**(含子目录)', '实例 home/**(如 home/skills、home/profiles)', '私有 /tmp'],
denied: ['/usr /etc /var 等系统路径(只读挂载)', '平台策略文件(profile 的 cordis.patch.yml / package.json / pnpm-lock.yaml 为只读)'],
},
network: {
egress: '可出公网(仅封云元数据端点 100.100.100.200)',
denied: ['宿主 loopback 127.0.0.0/8(含 127.0.0.1:3080 门户、:22 等)', '内网卡与 docker0 网关', '平台公网 EIP'],
hint: '**不要把 127.0.0.1 当作可用入口**:web_fetch 会以「resolves to a non-public IP」被拒;实例内也不可访问门户。',
},
tools,
skills: {
shared: sharedSkills,
perUser: perUserSkills,
hint: sharedSkills.length === 0 && Object.keys(perUserSkills).length === 0
? '当前平台**没有任何已注册技能**(共享层与个人层均为空)→ 调用 skill 工具必然报 unknown,请改用 bash/文件工具完成。'
: '技能来自共享只读层与用户个人层;调用前先按名字确认存在于上表。',
},
fileDelivery: {
hint: '把产出交给用户时:**用工作区相对路径**(如 `报告.md`),并提示「点会话页右下角『我的文件』可查看/下载」。',
avoid: ['不要给 `/var/lib/dshs/users/...` 这类服务器绝对路径', '不要给 127.0.0.1:<port> 链接(用户浏览器打不开)'],
howToShare: '用户在会话页右下角「我的文件」面板里可浏览工作区并下载任意文件(平台代理,不暴露宿主路径)。',
},
}
// ── 4) 写 JSON ────────────────────────────────────────────────
try {
mkdirSync(STATE, { recursive: true })
} catch { /* 已存在 */ }
if (!process.argv.includes('--print')) {
writeFileSync(OUT_JSON, JSON.stringify(capabilities, null, 2) + '\n')
}
// ── 5) 写 agent 可读的 SKILL.md(共享只读层)──────────────────
const fmtKV = (o) => Object.entries(o).map(([k, v]) => `| ${k} | ${v} |`).join('\n')
const skillMd = `---
name: platform-capabilities
description: 本 dsh 实例(托管在 dshs 平台上)的**能力边界与交付约定**。开工前读它可省去自行探测;包含可读/可写路径、网络边界、可用工具版本、已注册技能清单、以及把文件交给用户的正确方式。
---
# 平台能力清单(由 \`scripts/gen-capabilities.cjs\` 生成 · ${capabilities.generatedAt})
> **本文件是权威结论,不要靠现场试探推断能力**。若与实测冲突,先按本文件执行,并把差异报告给用户。
## 1. 权限档位
${capabilities.note}
## 2. 可读
| 允许 | 说明 |
|---|---|
${capabilities.read.allowed.map((x) => `| ${x} | — |`).join('\n')}
**不可读**:${capabilities.read.denied.join(';')}
## 3. 可写
**可写**:${capabilities.write.allowed.join(';')}
**不可写**:${capabilities.write.denied.join(';')}
## 4. 网络
- 出网:${capabilities.network.egress}
- **不可达**:${capabilities.network.denied.join(';')}
- ⚠️ ${capabilities.network.hint}
## 5. 可用工具(宿主层安装,全部实例共享)
| 工具 | 版本 |
|---|---|
${fmtKV(tools)}
## 6. 已注册技能
- 共享层(所有人可用):${sharedSkills.length ? sharedSkills.join(', ') : '**(空)**'}
- 个人层:${Object.keys(perUserSkills).length ? JSON.stringify(perUserSkills) : '**(空)**'}
- ⚠️ ${capabilities.skills.hint}
## 7. 把文件交给用户(重要)
${capabilities.fileDelivery.hint}
- ❌ 不要做:${capabilities.fileDelivery.avoid.join(';')}
- ✅ 怎么做:${capabilities.fileDelivery.howToShare}
`
if (!process.argv.includes('--print')) {
const dir = join(BUNDLED, 'platform-capabilities')
try {
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'SKILL.md'), skillMd)
} catch (e) {
console.error('写 SKILL.md 失败:', e.message)
}
}
if (process.argv.includes('--print')) {
console.log(JSON.stringify(capabilities, null, 2))
} else {
console.log('已生成:')
console.log(' ' + OUT_JSON)
console.log(' ' + join(BUNDLED, 'platform-capabilities', 'SKILL.md'))
console.log(' 工具: ' + Object.entries(tools).map(([k, v]) => `${k}=${v}`).join(' '))
console.log(' 技能: 共享 ' + sharedSkills.length + ' 个,个人层用户 ' + Object.keys(perUserSkills).length + ' 个')
}
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
# 一键复现 dsh 实例出网护栏(/etc/nftables-dsh-egress.nft + dsh-egress.service)
#
# 档案 14 · H1 阻断云元数据端点(100.100.100.200)
# 档案 14 · H4 观测实例新建外联(只记录不拦截)
# 档案 39 · H5 封锁实例**主动**访问宿主自身(loopback / eth0 / docker0 / 公网 EIP)
#
# 用法(需 root):bash scripts/install-egress-guard.sh
# 回滚:systemctl disable --now dsh-egress
#
# ⚠️ 改 H5 前必读档案 39「事故/踩坑记录」:**不能只按目的地址匹配** ——
# 实例是「先收后回」的服务端,回包目的地址同样是 127.0.0.1,
# 按 daddr 一刀切会把回包一起拒掉 → nginx 无法回源、实例整体不可用
# (判定特征:root 连实例端口 **timeout 而非 refused**)。
# 必须只匹配主动发起:TCP 用纯 SYN,UDP 用 ct state new。
set -euo pipefail
if [ "$(id -u)" != "0" ]; then
echo "需要 root" >&2
exit 1
fi
echo "==> 写入 /etc/nftables-dsh-egress.nft"
cat > /etc/nftables-dsh-egress.nft <<'NFTEOF'
#!/usr/sbin/nft -f
# dsh 实例出网护栏
# 档案 14 · H1(云元数据端点)+ H4(外联观测)
# 档案 39 · H5(实例不得「主动」访问宿主自身 —— 切断宿主服务面与跨租户互通)
# 只作用于 dsh 实例 uid 段 100000-199999;root / 云监控 / Docker 不受影响。
#
# 坑 1:阿里云 DNS 是 100.100.2.136 / 100.100.2.138 —— 绝不能封 100.100.0.0/16 整段,
# 本文件只精确封元数据端点 100.100.100.200(/32)。
# 坑 2:Docker 用 iptables-nft,本表独立,不与 docker 链混用。
# 坑 3:观测要排除 53 端口 —— nft 日志不记录查询域名,DNS 行是纯噪音且量最大。
# 坑 4(档案 39,实测踩到):H5 **绝不能只按目的地址匹配**。实例是「先收后回」的服务端:
# nginx(root) → 实例端口的 SYN 合法,但实例回的 SYN-ACK 与后续数据包
# **目的地址同样是 127.0.0.1**(客户端就是本机)。若按 daddr 一刀切 reject,
# 回包会被一起拒掉 → root 连实例端口 **超时**、nginx 无法回源、实例整体不可用。
# → 必须只匹配**实例主动发起**的连接:TCP 用纯 SYN(`tcp flags & (fin|syn|rst|ack) == syn`,
# SYN-ACK 带 ack 位故不匹配),UDP 用 `ct state new`。
table ip dsh_egress {
chain output {
type filter hook output priority filter; policy accept;
# H1 · 阻断云元数据端点(先记录再拒绝)
meta skuid 100000-199999 ip daddr 100.100.100.200 log prefix "dsh-egress-BLOCK " level warn
meta skuid 100000-199999 ip daddr 100.100.100.200 counter reject
# H5 · 实例不得主动访问宿主自身(档案 39)
# 封 4 类目的地址(仅实例主动发起的连接):
# 127.0.0.0/8 → 宿主全部 loopback 服务(sshd 22/32022、nginx 80/443/888、
# 门户 3080、BT-Panel 58888/8765)+ 其他实例的 127.0.0.1 监听
# 172.18.16.212 → 宿主内网卡(eth0,同样到达 nginx/面板)
# 172.17.0.1 → docker0 网桥网关
# 47.77.182.89 → 公网 EIP(回环到本机 nginx/sshd)
# 不影响:公网访问、阿里云内网 DNS(100.100.2.136/138)、pip/npm 下载、
# 实例自身监听端口、nginx 回源(root 发包 + 实例回包带 ack)
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn \
counter log prefix "dsh-egress-HOST " level warn limit rate 20/minute
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn \
counter reject with tcp reset
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
meta l4proto udp ct state new counter reject
# H4 · 观测实例新建外联(先记录不拦截;排除 loopback 与 DNS 降噪)
meta skuid 100000-199999 ip daddr != 127.0.0.0/8 tcp dport != 53 ct state new counter log prefix "dsh-egress " level info
meta skuid 100000-199999 ip daddr != 127.0.0.0/8 udp dport != 53 ct state new counter log prefix "dsh-egress " level info
}
}
NFTEOF
echo "==> 写入 /etc/systemd/system/dsh-egress.service"
cat > /etc/systemd/system/dsh-egress.service <<'SVCEOF'
[Unit]
Description=DSH instance egress guard (nftables)
Documentation=file:/etc/nftables-dsh-egress.nft
After=network-pre.target
Before=network.service
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
# 幂等:nft -f 遇到已存在的表会报 File exists,故先删(忽略不存在时的报错)
ExecStartPre=-/usr/sbin/nft delete table ip dsh_egress
ExecStart=/usr/sbin/nft -f /etc/nftables-dsh-egress.nft
ExecStop=-/usr/sbin/nft delete table ip dsh_egress
[Install]
WantedBy=multi-user.target
SVCEOF
echo "==> 语法预检"
nft -c -f /etc/nftables-dsh-egress.nft
echo "==> 启用并重载"
systemctl daemon-reload
systemctl enable dsh-egress
systemctl restart dsh-egress
echo "==> 当前规则"
nft list table ip dsh_egress
echo "OK"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# 安装「dsh 实例共享运行时」—— 可移植 Python(python-build-standalone / install_only)
#
# 目的(档案 42):
# 1. 系统 python3.6.8 太老(技能脚本常用特性不可用),且它是 dnf 的兄弟解释器,**不能动**;
# 2. 实例内 `/usr` 只读、无 sudo → 用户/AI 自己装不上系统级;
# 3. `/usr` 已 ro-bind → **装到 /usr/local 即对全部实例可见,零代码、新用户自动生效**。
#
# 为什么用 python-build-standalone 而不是 `dnf install python3.11`:
# 它是**自包含单目录发行版**(自带 libssl/libffi/sqlite 等,不依赖系统 rpm),
# 解压即用 → **服务器迁移时把 /usr/local/dsh-runtime 整个打包带走即可**,
# 不会因为目标机缺包/版本不同而出问题(用户的明确要求)。
#
# 迁移打包:
# tar czf dsh-python-runtime.tar.gz -C /usr/local dsh-runtime
# 目标机:解压回 /usr/local/ 后,跑本脚本(不加 --tarball 也可,会只重建软链)
#
# 用法:
# bash scripts/install-python-runtime.sh # 用默认 tarball 路径/版本
# bash scripts/install-python-runtime.sh --tarball /path/x.tar.gz
# PY_VER=3.13.7 bash scripts/install-python-runtime.sh
set -euo pipefail
PY_VER="${PY_VER:-3.12.14}"
PY_BUILD="${PY_BUILD:-20260901}"
RT="/usr/local/dsh-runtime"
LINK_DIR="/usr/local/bin"
TARBALL=""
URL_DEFAULT="https://github.com/astral-sh/python-build-standalone/releases/download/${PY_BUILD}/cpython-${PY_VER}%2B${PY_BUILD}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
while [ $# -gt 0 ]; do
case "$1" in
--tarball) TARBALL="$2"; shift 2 ;;
--version) PY_VER="$2"; shift 2 ;;
*) echo "未知参数: $1" >&2; exit 2 ;;
esac
done
if [ "$(id -u)" != "0" ]; then echo "需要 root" >&2; exit 1; fi
PYDIR="$RT/python-$PY_VER"
mkdir -p "$RT"
# ── 1. 解压(已存在则跳过 —— 幂等,也是迁移后的"重连"路径)──────────────
if [ -x "$PYDIR/bin/python3" ]; then
echo "==> 已存在 $PYDIR,跳过解压"
else
if [ -z "$TARBALL" ]; then
for c in "/opt/dsh/artifacts/cpython-$PY_VER.tar.gz" "$RT/cpython-$PY_VER.tar.gz"; do
[ -f "$c" ] && TARBALL="$c" && break
done
fi
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
echo "==> 本地无 tarball,联网下载($PY_VER)"
mkdir -p /opt/dsh/artifacts
TARBALL="/opt/dsh/artifacts/cpython-$PY_VER.tar.gz"
curl -fL --max-time 900 -o "$TARBALL" "$URL_DEFAULT"
fi
echo "==> 解压 $TARBALL"
tar xzf "$TARBALL" -C "$RT" # install_only 解出顶层 python/
rm -rf "$PYDIR"
mv "$RT/python" "$PYDIR"
fi
# ── 2. 运行时内 bin(相对软链,便于整体搬迁)────────────────────────────
mkdir -p "$RT/bin"
for n in python3 python3.12 python pip3 pip; do
[ -e "$PYDIR/bin/$n" ] || continue
ln -sfn "../python-$PY_VER/bin/$n" "$RT/bin/$n"
done
# ── 3. /usr/local/bin 接线(已在实例 PATH 首位;`/usr` ro-bind → 实例立即可见)──
for n in python3 python pip3; do
[ -e "$RT/bin/$n" ] || continue
ln -sfn "$RT/bin/$n" "$LINK_DIR/$n"
done
# ── 4. 版本记录(迁移核对用)────────────────────────────────────────────
cat > "$RT/VERSION" <<EOF
python_version=$PY_VER
python_build=$PY_BUILD
source=$URL_DEFAULT
installed_at=$(date -Is)
note=python-build-standalone install_only_stripped(自包含,可整体打包迁移)
EOF
# ── 5. 自检 ─────────────────────────────────────────────────────────────
echo "==> 自检"
"$LINK_DIR/python3" -c 'import sys,ssl,sqlite3,lzma,zlib,ctypes,urllib.request;print(" python3 =",sys.version.split()[0],"exe =",sys.executable);print(" 模块 ssl/sqlite3/lzma/zlib/ctypes 全可用");print(" ssl =",ssl.OPENSSL_VERSION)'
"$LINK_DIR/pip3" --version | sed 's/^/ /'
echo " 目录体积: $(du -sh "$PYDIR" | cut -f1)"
echo " 软链:"; ls -la "$LINK_DIR/python3" "$LINK_DIR/python" "$LINK_DIR/pip3" | sed 's/^/ /'
echo
echo "OK —— 实例内 PATH=/usr/local/bin:/usr/bin:/bin,且 /usr ro-bind → 立即可用,无需重启实例"
echo "注意:dnf/yum 的 shebang 是 /usr/libexec/platform-python(绝对路径),不受影响。"
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
# 安装「实例共享工具」到 /usr/local/dsh-runtime/bin(+ /usr/local/bin 软链)
#
# 为什么共享而不是每个用户装一份(档案 46):
# · 实例内 `/usr` 是 ro-bind,且 `/usr/local/bin` 在实例 PATH **首位** →
# **宿主装一次,全部实例(含新用户)立即可见,零代码、无需重启**;
# · 用户侧装不上(`/usr` 只读 + 无 sudo),且每人一份会重复占磁盘、版本还不一致。
#
# 迁移:整个 `/usr/local/dsh-runtime/` 就是一个打包单元 →
# tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime
# 目标机解压回原位后跑本脚本(已存在则只重建软链)。
#
# 用法:bash scripts/install-shared-tools.sh [--force]
set -euo pipefail
RT=/usr/local/dsh-runtime
BIN="$RT/bin"
LINK=/usr/local/bin
ART=/opt/dsh/artifacts
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
JQ_VER=1.8.2
RG_VER=15.2.0
# ffmpeg 用 BtbN 的 master 静态构建(单个二进制、无系统库依赖)
FF_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"
FF_SUM_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/checksums.sha256"
mkdir -p "$BIN" "$ART" /opt/dsh/state
say() { printf '==> %s\n' "$*"; }
fail() { printf '!! %s\n' "$*" >&2; exit 1; }
# ── 取官方校验值并核对 ────────────────────────────────────────────────
verify() { # verify <file> <expected-sha256>
local f="$1" want="$2" got
got=$(sha256sum "$f" | awk '{print $1}')
[ "$got" = "$want" ] || fail "校验失败:$f
期望 $want
实际 $got"
say "校验通过 $(basename "$f")"
}
curl_o() { curl -fsSL --retry 3 --retry-delay 3 --max-time 600 -o "$2" "$1"; }
# ── jq(官方静态单文件)────────────────────────────────────────────────
if [ "$FORCE" = 1 ] || ! [ -x "$BIN/jq" ] || [ "$("$BIN/jq" --version 2>/dev/null | sed 's/^jq-//')" != "$JQ_VER" ]; then
say "安装 jq $JQ_VER"
curl_o "https://github.com/jqlang/jq/releases/download/jq-$JQ_VER/jq-linux-amd64" "$ART/jq-$JQ_VER"
curl_o "https://github.com/jqlang/jq/releases/download/jq-$JQ_VER/sha256sum.txt" "$ART/jq-$JQ_VER.sha256"
WANT=$(grep -E 'jq-linux-amd64$' "$ART/jq-$JQ_VER.sha256" | awk '{print $1}')
[ -n "$WANT" ] || fail "取不到 jq 官方校验值"
verify "$ART/jq-$JQ_VER" "$WANT"
install -m 0755 "$ART/jq-$JQ_VER" "$BIN/jq"
else
say "jq 已就绪 $("$BIN/jq" --version)"
fi
# ── ripgrep(musl 静态,无 glibc 依赖)─────────────────────────────────
if [ "$FORCE" = 1 ] || ! [ -x "$BIN/rg" ]; then
say "安装 ripgrep $RG_VER"
TGZ="ripgrep-$RG_VER-x86_64-unknown-linux-musl.tar.gz"
curl_o "https://github.com/BurntSushi/ripgrep/releases/download/$RG_VER/$TGZ" "$ART/$TGZ"
curl_o "https://github.com/BurntSushi/ripgrep/releases/download/$RG_VER/$TGZ.sha256" "$ART/$TGZ.sha256"
verify "$ART/$TGZ" "$(awk '{print $1}' "$ART/$TGZ.sha256")"
TMPD=$(mktemp -d); tar xzf "$ART/$TGZ" -C "$TMPD"
install -m 0755 "$TMPD/ripgrep-$RG_VER-x86_64-unknown-linux-musl/rg" "$BIN/rg"
rm -rf "$TMPD"
else
say "rg 已就绪 $("$BIN/rg" --version | head -1)"
fi
# ── ffmpeg / ffprobe(静态单文件,自带全部解码库)─────────────────────
if [ "$FORCE" = 1 ] || ! [ -x "$BIN/ffmpeg" ]; then
say "安装 ffmpeg(BtbN master 静态构建)"
curl_o "$FF_URL" "$ART/ffmpeg-static.tar.xz"
curl_o "$FF_SUM_URL" "$ART/ffmpeg-static.sha256"
WANT=$(grep -E 'ffmpeg-master-latest-linux64-gpl\.tar\.xz$' "$ART/ffmpeg-static.sha256" | awk '{print $1}')
[ -n "$WANT" ] || fail "取不到 ffmpeg 官方校验值(checksums.sha256 里没有该资产)"
verify "$ART/ffmpeg-static.tar.xz" "$WANT"
TMPD=$(mktemp -d); tar xJf "$ART/ffmpeg-static.tar.xz" -C "$TMPD"
FFDIR=$(find "$TMPD" -maxdepth 1 -type d -name "ffmpeg-*" | head -1)
for b in ffmpeg ffprobe; do install -m 0755 "$FFDIR/bin/$b" "$BIN/$b"; done
rm -rf "$TMPD"
else
say "ffmpeg 已就绪 $("$BIN/ffmpeg" -version 2>/dev/null | head -1)"
fi
# ── /usr/local/bin 接线(已在实例 PATH 首位)──────────────────────────
for n in jq rg ffmpeg ffprobe; do
[ -e "$BIN/$n" ] || continue
ln -sfn "$BIN/$n" "$LINK/$n"
done
# ── 清单(迁移与审计用)──────────────────────────────────────────────
{
echo "# dsh 实例共享工具(随 /usr/local/dsh-runtime 一起迁移)"
echo "updated_at=$(date -Is)"
for n in jq rg ffmpeg ffprobe; do
[ -x "$BIN/$n" ] || continue
printf '%s: ' "$n"; ("$BIN/$n" --version 2>&1 | head -1) || true
done
} > "$RT/SHARED-TOOLS.md"
echo
say "结果"
ls -la "$LINK"/{jq,rg,ffmpeg,ffprobe} 2>/dev/null | sed 's/^/ /'
cat "$RT/SHARED-TOOLS.md" | sed 's/^/ /'
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 安装 @dsh-local/workspace-scoped-picker 指定版本到所有用户 profile(幂等)
set -euo pipefail
TGZ_SRC="${1:?用法: install-wsp.sh <tgz路径>}"
VER=$(basename "$TGZ_SRC" | sed -E 's/^workspace-scoped-picker-(.*)\.tgz$/\1/')
echo "版本: $VER"
for U in cce6d1cd-b376-4304-80f0-0e1c58c9ffde:114801:"" 4092b965-2f68-4977-9989-68b3966f7df0:100002:-w; do
ID=$(echo "$U" | cut -d: -f1); UID_=$(echo "$U" | cut -d: -f2); FLAG=$(echo "$U" | cut -d: -f3)
WS=/var/lib/dshs/users/$ID/ws
PP=/var/lib/dshs/users/$ID/home/profiles/web
cp -f "$TGZ_SRC" "$WS/workspace-scoped-picker-$VER.tgz"
chown "$UID_:$UID_" "$WS/workspace-scoped-picker-$VER.tgz"
( cd "$PP" && setpriv --reuid "$UID_" --regid "$UID_" --clear-groups env HOME="$WS" pnpm add $FLAG "file:$WS/workspace-scoped-picker-$VER.tgz" >/tmp/pnpm-$ID.log 2>&1 ) && echo " [${ID:0:8}] pnpm OK" || { echo " [${ID:0:8}] pnpm FAILED"; tail -3 /tmp/pnpm-$ID.log; }
printf " [%s] lib: " "${ID:0:8}"; ls "$PP/node_modules/@dsh-local/workspace-scoped-picker/lib/" | tr "\n" " "; echo
printf " [%s] version: " "${ID:0:8}"; grep -o '"version": "[^"]*"' "$PP/node_modules/@dsh-local/workspace-scoped-picker/package.json" | head -1
done
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* instance-mem-sample.cjs —— 实例内存用量采样 + 阈值告警(cron 每 10 分钟;只读 + 追加式写一个 json)
*
* 为什么需要:本机内核 5.10 的 cgroup v2 **没有 `memory.peak`**(5.19+ 才有),
* 因此 systemd 的 `MemoryPeak` 属性恒为 `[not set]`,`systemctl show` 也拿不到历史峰值。
* 没有峰值数据,「MemoryMax 该定 384 还是能再降」就只能拍脑袋。
* 本脚本把所有 `dsh-*.scope` 的 `memory.current` 记一次,按 **uid** 维护历史峰值
* (scope 每次重启都换名字,所以按 uid 聚合才连续),为配额调整提供依据(档案 58)。
*
* 2026-09-12(档案 74)新增**阈值告警**:同一 uid 连续 `HIGH_STREAK` 次采样都 ≥
* `limitMiB × HIGH_PCT` 时,日志行尾部追加 `⚠ 连续 N 次 ≥ 85%`,并给整行加 `WARN` 前缀(便于 grep)。
* 阈值可用 env 覆盖:`DSH_MEM_ALERT_PCT`(默认 0.85)/ `DSH_MEM_ALERT_STREAK`(默认 3)。
* 未达「连续」但已达单次高位时,也会标注 `(高位 N%,x/3)`,便于观察爬升趋势。
*
* ⚠️ 为什么要配套把 cron 从「每小时」提到「每 10 分钟」:每小时 × 连续 3 次 = 3 小时才报,
* 而实例 OOM 常发生在分钟级(实测 guest 20:11:10 被 OOM kill,上一次采样还是 19:35),
* 小时级采样根本抓不到,等于没有预警。10 分钟 × 3 次 = 30 分钟,才有实际提前量。
*
* 输出 `/opt/dsh/state/instance-mem-peak.json`:
* { "updatedAt": <ms>, "uids": { "<uid>": { peakMiB, peakAt, lastMiB, samples, unit, limitMiB, pct, highStreak } } }
*
* 用法:node instance-mem-sample.cjs [--print]
*/
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const STATE = process.env.DSH_PLATFORM_STATE_DIR ?? '/opt/dsh/state'
const OUT = `${STATE}/instance-mem-peak.json`
// ⚠️ systemd 的 MemoryCurrent / MemoryMax 单位是**字节**(不是 KB)。
// 写成 /1024 会得到 1024 倍的假数字(首次运行实测:98 MiB 显示成 100336 MiB)。
const MiB = (bytes) => Math.round(Number(bytes) / 1048576)
// 阈值告警参数(env 可覆盖:DSH_MEM_ALERT_PCT / DSH_MEM_ALERT_STREAK)
const HIGH_PCT = Number(process.env.DSH_MEM_ALERT_PCT ?? 0.85)
const HIGH_STREAK = Number(process.env.DSH_MEM_ALERT_STREAK ?? 3)
// 采样间隔下限:距上次采样不足此时长(默认 60s)视为「同一轮内的重复触发」(手动调试 / 人工跑),
// 只保持计数、不累计也不清零 —— 否则连续手动跑几次就会把 highStreak 顶到阈值造成误告警。
const MIN_SAMPLE_GAP_MS = Number(process.env.DSH_MEM_ALERT_MIN_GAP_MS ?? 60000)
function scopes() {
try {
const out = execFileSync(
'systemctl',
['list-units', '--type=scope', '--all', '--no-legend', '--plain', 'dsh-*.scope'],
{ encoding: 'utf8' },
)
return out
.split('\n')
.map((l) => l.trim().split(/\s+/)[0])
.filter((u) => /^dsh-\d+-[0-9a-f]+\.scope$/.test(u))
} catch {
return []
}
}
function show(unit, prop) {
try {
return execFileSync('systemctl', ['show', unit, `-p${prop}`, '--value'], { encoding: 'utf8' }).trim()
} catch {
return ''
}
}
const now = Date.now()
let db = { updatedAt: now, uids: {} }
try {
db = JSON.parse(fs.readFileSync(OUT, 'utf8'))
if (typeof db.uids !== 'object' || db.uids === null) db.uids = {}
} catch {
/* 首次运行或文件损坏 → 重建 */
}
const seen = []
for (const unit of scopes()) {
const uid = (unit.match(/^dsh-(\d+)-/) || [])[1]
if (!uid) continue
const cur = show(unit, 'MemoryCurrent')
if (cur === '' || cur === '[not set]') continue
const max = show(unit, 'MemoryMax')
const lastMiB = MiB(cur)
const limitMiB = max && max !== 'infinity' ? MiB(max) : null
const prev = db.uids[uid] ?? { peakMiB: 0, peakAt: null, samples: 0, highStreak: 0 }
const pct = limitMiB ? lastMiB / limitMiB : 0
const tooSoon = prev.updatedAt ? now - Date.parse(prev.updatedAt) < MIN_SAMPLE_GAP_MS : false
const highStreak = tooSoon
? (prev.highStreak ?? 0)
: limitMiB && pct >= HIGH_PCT
? (prev.highStreak ?? 0) + 1
: 0
const entry = {
peakMiB: Math.max(prev.peakMiB ?? 0, lastMiB),
peakAt: lastMiB >= (prev.peakMiB ?? 0) ? new Date(now).toISOString() : (prev.peakAt ?? null),
lastMiB,
limitMiB,
pct: Math.round(pct * 100),
highStreak,
samples: (prev.samples ?? 0) + 1,
unit,
updatedAt: new Date(now).toISOString(),
}
db.uids[uid] = entry
seen.push({ uid, ...entry })
}
db.updatedAt = now
try {
fs.mkdirSync(STATE, { recursive: true })
fs.writeFileSync(OUT, JSON.stringify(db, null, 2) + '\n')
} catch (err) {
console.error(`写入 ${OUT} 失败: ${String(err.message ?? err)}`)
process.exitCode = 1
}
let alerted = 0
const line = seen
.map((s) => {
let note = ''
if (s.limitMiB && s.highStreak >= HIGH_STREAK) {
alerted++
note = ` ⚠ 内存连续 ${s.highStreak} 次 ≥ ${Math.round(HIGH_PCT * 100)}%(${s.lastMiB}/${s.limitMiB} MiB)`
} else if (s.limitMiB && s.pct >= Math.round(HIGH_PCT * 100)) {
note = ` (高位 ${s.pct}%,${s.highStreak}/${HIGH_STREAK})`
}
return `uid ${s.uid}: 当前 ${s.lastMiB} MiB / 峰值 ${s.peakMiB} MiB / 上限 ${s.limitMiB ?? '-'} MiB${note}`
})
.join(' | ')
if (process.argv.includes('--print') || seen.length > 0) {
const tag = alerted > 0 ? 'WARN ' : ''
console.log(`[${new Date(now).toISOString()}] ${tag}${seen.length} 个实例;${line || '无运行中实例'}`)
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* probe-instance-mem.cjs —— 实例内存分解探针(只读)
*
* 回答"一个用户实例凭什么占这么多内存":把 RSS 拆成
* ① 共享只读代码页(node 二进制 + 原生模块)—— **多实例物理上只占一份**
* ② 私有匿名页(V8 堆 / 线程栈 / Buffer)—— 每实例真正独占,也是 cgroup 计数的口径
* 并列出最大的内存段,便于识别是不是某个大依赖(sharp / koffi / node-pty 等)。
*
* 用法(root 在宿主执行):
* node probe-instance-mem.cjs # 自动找实例进程 + 打印空 node 基线
* node probe-instance-mem.cjs <pid> [pid...] # 指定进程
*/
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const MiB = (kb) => Math.round(kb / 1024)
function rollup(pid) {
const s = fs.readFileSync(`/proc/${pid}/smaps_rollup`, 'utf8')
const num = (k) => Number((s.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm')) || [])[1] || 0)
return {
rss: num('Rss'),
pss: num('Pss'),
shared: num('Shared_Clean'),
privateDirty: num('Private_Dirty'),
privateClean: num('Private_Clean'),
anon: num('Anonymous'),
swap: num('Swap'),
}
}
function biggestSegments(pid, top = 12) {
const smaps = fs.readFileSync(`/proc/${pid}/smaps`, 'utf8')
const blocks = smaps.split(/(?=^[0-9a-f]+-[0-9a-f]+ )/m).filter((b) => /^[0-9a-f]+-[0-9a-f]+ /.test(b))
const rows = blocks.map((b) => {
const nameM = b.match(/^[0-9a-f]+-[0-9a-f]+ \S+ \S+ \S+ \S+ +(.*)$/m)
return {
rss: Number((b.match(/^Rss:\s+(\d+)/m) || [])[1] || 0),
anon: Number((b.match(/^Anonymous:\s+(\d+)/m) || [])[1] || 0),
name: nameM ? nameM[1].trim() : '[anon]',
}
})
rows.sort((a, b) => b.rss - a.rss)
return rows.slice(0, top)
}
/** 按"归属"聚合:每个段拆 Shared_Clean/Dirty 与 Private_Clean/Dirty,按私有量排序。
* 这能直接回答"私有内存到底是什么" —— 是 node 二进制被写时复制,还是 JS 堆。 */
function breakdownByOwner(pid, top = 10) {
const smaps = fs.readFileSync(`/proc/${pid}/smaps`, 'utf8')
const blocks = smaps.split(/(?=^[0-9a-f]+-[0-9a-f]+ )/m).filter((b) => /^[0-9a-f]+-[0-9a-f]+ /.test(b))
const agg = new Map()
for (const b of blocks) {
const nameM = b.match(/^[0-9a-f]+-[0-9a-f]+ \S+ \S+ \S+ \S+ +(.*)$/m)
const name = (nameM ? nameM[1].trim() : '') || '[anon]'
const g = (k) => Number((b.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm')) || [])[1] || 0)
const cur = agg.get(name) || { rss: 0, shared: 0, priv: 0 }
cur.rss += g('Rss')
cur.shared += g('Shared_Clean') + g('Shared_Dirty')
cur.priv += g('Private_Clean') + g('Private_Dirty')
agg.set(name, cur)
}
return [...agg.entries()]
.map(([name, v]) => ({ name, ...v }))
.sort((a, b) => b.priv - a.priv)
.slice(0, top)
}
function findInstances() {
try {
const out = execFileSync('ps', ['-eo', 'pid,user,args', '--no-headers'], { encoding: 'utf8' })
return out
.split('\n')
.filter((l) => {
// 只认真正的实例进程;跳过 bwrap 包装进程(它的参数里同样含 "dsh --profile",
// 且只是 ~1 MiB 的转发壳,混进来会污染"最大内存段"清单)。
const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(l)
if (!m) return false
return /(^|\s)node\s+\S*dsh\s+--profile/.test(m[3]) && !/bwrap/.test(m[3])
})
.map((l) => {
const parts = l.trim().split(/\s+/)
return { pid: parts[0], user: parts[1] }
})
} catch {
return []
}
}
/** 空 node 基线:用来回答"dsh 自身让一个 node 进程多花多少"。 */
function baseline() {
const code = `
const fs=require('node:fs');
const s=fs.readFileSync('/proc/self/smaps_rollup','utf8');
const n=k=>Number((s.match(new RegExp('^'+k+':\\\\s+(\\\\d+)','m'))||[])[1]||0);
process.stdout.write(JSON.stringify({rss:n('Rss'),pss:n('Pss'),shared:n('Shared_Clean'),priv:n('Private_Dirty')}));`
const out = execFileSync(process.execPath, ['-e', code], { encoding: 'utf8' })
return JSON.parse(out)
}
const args = process.argv.slice(2)
const targets = args.length > 0 ? args.map((p) => ({ pid: p, user: '?' })) : findInstances()
console.log('=== 环境 ===')
console.log('node', process.version, '| enableCompileCache:', typeof require('node:module').enableCompileCache)
console.log('NODE_OPTIONS =', process.env.NODE_OPTIONS ?? '(未设置)')
const b = baseline()
console.log(
`空 node 基线:Rss ${MiB(b.rss)} MiB | 共享 ${MiB(b.shared)} | 私有 ${MiB(b.priv)} | 虚拟已用 ${MiB(b.pss)} MiB`,
)
for (const t of targets) {
let r
try {
r = rollup(t.pid)
} catch (e) {
console.log(`\n=== pid ${t.pid}:读不到(${String(e.message).slice(0, 60)})`)
continue
}
const cmd = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' ')
console.log(`\n=== pid ${t.pid}(user ${t.user})===`)
console.log(cmd.slice(0, 150))
console.log(
`RSS ${MiB(r.rss)} MiB = 共享代码 ${MiB(r.shared)} + 私有 ${MiB(r.privateDirty)}\n` +
` PSS ${MiB(r.pss)} MiB(按共享比例摊分后的"真实归属")| 匿名 ${MiB(r.anon)} | swap ${MiB(r.swap)}`,
)
console.log(` dsh 自身开销(相对空 node):私有 +${MiB(r.privateDirty - b.priv)} MiB | RSS +${MiB(r.rss - b.rss)} MiB`)
console.log(' 最大的内存段:')
for (const s of biggestSegments(t.pid)) {
console.log(` ${String(MiB(s.rss)).padStart(5)} MiB (anon ${String(MiB(s.anon)).padStart(4)}) ${s.name.slice(0, 64)}`)
}
console.log(' 按归属拆「共享 / 私有」(私有才是每实例独占,按私有量排序):')
for (const o of breakdownByOwner(t.pid)) {
console.log(
` 共享 ${String(MiB(o.shared)).padStart(4)} MiB | 私有 ${String(MiB(o.priv)).padStart(4)} MiB | 合计 ${String(MiB(o.rss)).padStart(4)} MiB ${o.name.slice(0, 56)}`,
)
}
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
for dir in /var/lib/dshs/users/*/; do
[ -d "$dir" ] || continue
id="$(basename "$dir")"
[ "$id" = . ] && continue
short="$(echo "$id" | tr -d '-' | cut -c1-20)"
user="dsh-$short"
if ! id "$user" &>/dev/null; then
uid="$(node /opt/dshs/lib/cli.js uid-for-user "$id" --db /var/lib/dshs/dshs.db 2>/dev/null || echo 0)"
[ "$uid" = 0 ] && { echo "SKIP $id (uid-for-user失败)"; continue; }
# 若该uid已被其他账号占用则跳过
if id "$uid" &>/dev/null; then echo "SKIP $id (uid $uid 已被占用)"; continue; fi
useradd -u "$uid" -M -s /usr/sbin/nologin "$user"
echo "created $user (uid $uid) for $id"
fi
uid="$(id -u "$user")"
chown -R "$uid:$uid" "$dir"
echo "provisioned $id -> uid $uid"
done
# 2026-09-11 追加(档案 18 v3 收尾):为该批新用户补齐「目录选择器收敛」——
# ① 安装受限插件(逐用户 pnpm add)② 写平台段(cordis.patch.yml,幂等)。
# best-effort:失败不影响上面的账号 provisioning;日志见 journalctl -u dsh-provision。
if [ -f /opt/dshs/poc/workspace-scoped-picker/ensure-workspace-picker.cjs ]; then
node /opt/dshs/poc/workspace-scoped-picker/ensure-workspace-picker.cjs 2>&1 | sed "s/^/[picker] /" || true
fi
# 2026-09-11 追加:「设置面板 → 用户管理」入口(@dsh-local/portal-entry)全员兜底。
# 该插件提供设置面板最后一个分区「用户管理」(管理员=门户地址+打开管理台+退出登录;
# 普通用户=仅退出登录)。**普通用户没有它就没有任何退出登录入口**,故必须与新用户
# 注册同步补齐(与上面的 picker 同一时机、同一 best-effort 策略)。
# 幂等:按 node_modules 已装版本 + dep spec 判定,已是最新即跳过。
if [ -f /opt/dshs/scripts/ensure-portal-entry.cjs ]; then
node /opt/dshs/scripts/ensure-portal-entry.cjs 2>&1 | sed "s/^/[portal-entry] /" || true
fi
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# purge-trash.sh —— 清理各用户 trash/ 下超过 30 天的回收目录(档案 28)
# 回收站是"清理动作的缓冲区":脚本只做 mv 进来,只有本脚本才真正 rm。
set -uo pipefail
KEEP_DAYS="${1:-30}"
LOG=/var/log/dsh-trash-purge.log
echo "[$(date -Is)] purge trash older than ${KEEP_DAYS}d" >> "$LOG"
for t in /var/lib/dshs/users/*/trash; do
[ -d "$t" ] || continue
find "$t" -mindepth 1 -maxdepth 1 -mtime "+${KEEP_DAYS}" -print -exec rm -rf {} + >> "$LOG" 2>&1
done
echo "[$(date -Is)] done" >> "$LOG"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* 运行时版本基线巡检(档案 44)。
*
* 目的:**保证实例共享的基础运行时(Python / pip / node / npm)版本稳定**,
* 避免"版本差异导致插件功能无法使用"。
*
* 背景:运行时是平台在 `/usr/local/dsh-runtime/` 装的可移植发行版,
* 实例内 `/usr` 只读 → 理论上改不了;本脚本是**观测兜底** ——
* 一旦版本偏离基线(例:有人在宿主上手动装/换了版本),立刻告警,而不是等插件坏掉才发现。
*
* 用法:
* node runtime-baseline.cjs # 对比,偏离则退出码 1(cron 会记日志)
* node runtime-baseline.cjs --accept # 主动把当前版本写入基线(升级运行时后执行)
*/
const { execFileSync } = require('node:child_process')
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
const BASE = '/opt/dsh/state/runtime-baseline.json'
const ACCEPT = process.argv.includes('--accept')
const run = (cmd, args) => {
try { return execFileSync(cmd, args, { encoding: 'utf8', timeout: 20000 }).trim().split('\n')[0] }
catch (e) { return `ERR:${String(e.message).split('\n')[0].slice(0, 60)}` }
}
const ver = (cmd, args) => (run(cmd, args).match(/\d+\.\d+(\.\d+)?/) ?? ['?'])[0]
const probe = () => ({
rg: ver('/usr/local/bin/rg', ['--version']),
jq: ver('/usr/local/bin/jq', ['--version']),
ffmpeg: (run('/usr/local/bin/ffmpeg', ['-version']).match(/ffmpeg version (\S+)/)?.[1] ?? '?'),
python3: ver('/usr/local/bin/python3', ['-V']),
pip3: ver('/usr/local/bin/pip3', ['-V']),
node: ver('/usr/local/bin/node', ['-v']),
npm: ver('/usr/local/bin/npm', ['-v']),
runtimePinned: existsSync('/usr/local/dsh-runtime/VERSION')
? (readFileSync('/usr/local/dsh-runtime/VERSION', 'utf8').match(/^python_version=(.+)$/m)?.[1] ?? '?')
: 'MISSING',
})
const now = probe()
if (ACCEPT || !existsSync(BASE)) {
writeFileSync(BASE, JSON.stringify({ acceptedAt: new Date().toISOString(), versions: now }, null, 2) + '\n')
console.log(`==> 基线已写入 ${BASE}`)
console.log(' ', JSON.stringify(now))
process.exit(0)
}
const base = JSON.parse(readFileSync(BASE, 'utf8'))
const drift = Object.keys(now).filter((k) => base.versions[k] !== now[k])
if (drift.length === 0) {
console.log(`ok 运行时版本与基线一致:${JSON.stringify(now)}`)
process.exit(0)
}
console.log('!! 运行时版本漂移(档案 44)')
for (const k of drift) console.log(` ${k}: 基线 ${base.versions[k]} → 现在 ${now[k]}`)
console.log(` 基线时间 ${base.acceptedAt};若本次是有意升级,执行:node runtime-baseline.cjs --accept`)
process.exit(1)
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* session-gc.cjs —— 会话记录保留期回收(档案 14 §H3 / 档案 28)
* 规则(用户 2026-09-11 定):**每个用户的 sessions 达到阈值才触发**,清理 **超过 N 天** 的会话目录。
* 结构:<home>/sessions/<workspace-slug>/<session-id>/session.jsonl.zstd
* 安全:默认 dry-run;--apply 时 `mv` 到 <userRoot>/trash/<日期>-session-gc/(保留 30 天)。
* 说明:storages/session_projcache 体积很小(KB 级),本脚本不动它(留作后续细化)。
*
* 用法:node session-gc.cjs [--apply] [--threshold 500] [--days 90] [--user-id <uuid>]
*/
const { execFileSync } = require('node:child_process')
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const argv = process.argv.slice(2)
const APPLY = argv.includes('--apply')
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
const THRESHOLD_MB = num('--threshold', 500)
const DAYS = num('--days', 90)
const idx = argv.indexOf('--user-id')
const ONLY = idx >= 0 ? argv[idx + 1] : ''
const STAMP = new Date().toISOString().slice(0, 10)
const CUTOFF = Date.now() - DAYS * 86400_000
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
const fmt = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
for (const u of users) {
const sessions = join(u.home_dir, 'sessions')
if (!existsSync(sessions)) { console.log(` ${u.username}: NO_SESSIONS`); continue }
const total = duKB(sessions)
const over = total >= THRESHOLD_MB * 1048576
const stale = []
for (const slug of readdirSync(sessions)) {
const slugDir = join(sessions, slug)
let st; try { st = statSync(slugDir) } catch { continue }
if (!st.isDirectory()) continue
for (const sid of readdirSync(slugDir)) {
const sd = join(slugDir, sid)
let sst; try { sst = statSync(sd) } catch { continue }
if (!sst.isDirectory()) continue
const f = join(sd, 'session.jsonl.zstd')
let mtime = sst.mtimeMs
try { if (existsSync(f)) mtime = statSync(f).mtimeMs } catch {}
if (mtime < CUTOFF) stale.push({ p: sd, size: duKB(sd), mtime: new Date(mtime) })
}
}
const staleB = stale.reduce((a, c) => a + c.size, 0)
console.log(` ${u.username}: sessions=${fmt(total)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | 超 ${DAYS} 天会话=${stale.length} 个 / ${fmt(staleB)}`)
for (const c of stale) console.log(` ${c.p.split('/').slice(-2).join('/')} ${fmt(c.size)} (${c.mtime.toISOString().slice(0, 10)})`)
if (APPLY && over && stale.length > 0) {
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-session-gc`)
mkdirSync(trash, { recursive: true })
for (const c of stale) {
const dest = join(trash, c.p.split('/').slice(-2).join('__'))
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
}
// 同步回收投影缓存:storages/session_projcache/sessions/<session-id>.json(按 id 精确匹配)
const pcDir = join(u.home_dir, 'storages', 'session_projcache', 'sessions')
let pcMoved = 0
if (existsSync(pcDir)) {
for (const c of stale) {
const sid = c.p.split('/').pop()
const pc = join(pcDir, `${sid}.json`)
if (existsSync(pc)) {
try { execFileSync('mv', [pc, join(trash, `projcache__${sid}.json`)]); pcMoved += 1 } catch {}
}
}
}
console.log(` → 已移 ${stale.length} 个会话 / ${fmt(staleB)}${pcMoved > 0 ? `(+ ${pcMoved} 个投影缓存)` : ''} → trash/${STAMP}-session-gc(保留 30 天)`)
} else if (APPLY && !over) console.log(' (未超阈值,跳过)')
}
db.close()
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')
+91
View File
@@ -0,0 +1,91 @@
// Admin account flow: approve → disable → enable (restore), plus guard rails.
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-admin-'))
const app = await buildServer(resolveConfig({ port: 0, dbPath: ':memory:', dataRoot }))
await app.listen({ port: 0 })
const base = `http://127.0.0.1:${app.server.address().port}`
await app.db.createUser({
id: 'admin',
username: 'admin',
passHash: await hashPassword('adminpass123'),
role: 'admin',
homeDir: join(dataRoot, 'users', 'admin', 'home'),
})
await app.db.createUser({
id: 'bob',
username: 'bob',
passHash: await hashPassword('bobpass123'),
role: 'pending',
homeDir: join(dataRoot, 'users', 'bob', 'home'),
})
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(base + path, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
try {
let r
// Pending user cannot log in.
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
assert(r.status === 403 && r.body.error === 'pending_review', 'pending user blocked from login')
// Admin login.
r = await json('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'adminpass123' } })
assert(r.status === 200, 'admin logs in')
const cookie = r.setCookie.split(';')[0]
// Approve bob.
r = await json('/api/admin/users/bob/approve', { method: 'POST', cookie })
assert(r.status === 200, 'approve succeeds')
// Bob can now log in.
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
assert(r.status === 200, 'approved user logs in')
// Disable bob.
r = await json('/api/admin/users/bob/disable', { method: 'POST', cookie })
assert(r.status === 200, 'disable succeeds')
// Bob blocked again (role disabled + sessions cleared).
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
assert(r.status === 403 && r.body.error === 'disabled', 'disabled user blocked from login')
// Enable (restore) bob.
r = await json('/api/admin/users/bob/enable', { method: 'POST', cookie })
assert(r.status === 200, 'enable succeeds')
// Bob can log in again.
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
assert(r.status === 200, 'restored user logs in')
// Guard rails.
r = await json('/api/admin/users/bob/enable', { method: 'POST', cookie })
assert(r.status === 409 && r.body.error === 'not_disabled', 'enable on non-disabled → 409')
r = await json('/api/admin/users/admin/disable', { method: 'POST', cookie })
assert(r.status === 409 && r.body.error === 'cannot_disable_admin', 'cannot disable admin')
console.log('OK: admin approve/disable/enable flow passed')
} finally {
await app.close()
rmSync(dataRoot, { recursive: true, force: true })
}
+107
View File
@@ -0,0 +1,107 @@
// End-to-end auth + review flow: seed an admin, register a user, verify they
// cannot log in while pending, approve them, then verify login + /me.
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const app = await buildServer(resolveConfig({ port: 0, dbPath: ':memory:' }))
await app.listen({ port: 0 })
const base = `http://127.0.0.1:${app.server.address().port}`
// Seed the first admin directly (what `bootstrap-admin` does).
await app.db.createUser({
id: 'admin-1',
username: 'admin',
passHash: await hashPassword('adminpass123'),
role: 'admin',
homeDir: '/tmp/admin-home',
})
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(base + path, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
const sid = (setCookie) => (setCookie ? setCookie.split(';')[0] : undefined)
let r
r = await json('/api/auth/register', { method: 'POST', body: { username: 'alice', password: 'alicepass123' } })
console.log('register alice ->', r.status)
assert(r.status === 201, 'register creates a pending user')
r = await json('/api/auth/login', { method: 'POST', body: { username: 'alice', password: 'alicepass123' } })
console.log('login alice (pending) ->', r.status, r.body?.error)
assert(r.status === 403 && r.body?.error === 'pending_review', 'pending user is refused login')
r = await json('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'adminpass123' } })
console.log('login admin ->', r.status)
assert(r.status === 200, 'admin login succeeds')
const adminCookie = sid(r.setCookie)
r = await json('/api/admin/users', { cookie: adminCookie })
console.log('list users ->', r.status, r.body?.users?.map((u) => `${u.username}:${u.role}`))
assert(r.status === 200, 'admin can list users')
const alice = r.body.users.find((u) => u.username === 'alice')
assert(alice?.role === 'pending', 'alice listed as pending')
r = await json(`/api/admin/users/${alice.id}/approve`, { method: 'POST', cookie: adminCookie })
console.log('approve alice ->', r.status)
assert(r.status === 200, 'approve succeeds')
r = await json('/api/auth/login', { method: 'POST', body: { username: 'alice', password: 'alicepass123' } })
console.log('login alice (approved) ->', r.status, r.body?.user)
assert(r.status === 200 && r.body.user.role === 'active', 'approved user logs in')
const aliceCookie = sid(r.setCookie)
r = await json('/api/auth/me', { cookie: aliceCookie })
console.log('me (alice) ->', r.status, r.body?.user)
assert(r.status === 200 && r.body.user.username === 'alice', '/me returns the current user')
r = await json('/api/me/keys', { cookie: aliceCookie })
console.log('keys (none) ->', r.status, r.body)
assert(r.status === 200 && r.body.keys.length === 0, 'no keys initially')
r = await json('/api/me/keys', { method: 'POST', cookie: aliceCookie, body: { name: 'home', apiKey: 'sk-good1-abc' } })
console.log('keys (add home) ->', r.status, r.body?.key)
assert(r.status === 200 && r.body.key.enabled === true, 'first key added + enabled')
const firstKeyId = r.body.key.id
r = await json('/api/me/keys', { method: 'POST', cookie: aliceCookie, body: { name: 'server', apiKey: 'sk-good2-xyz' } })
console.log('keys (add server) ->', r.status, r.body?.key)
assert(r.status === 200 && r.body.key.enabled === true, 'second key added + enabled')
r = await json('/api/me/keys', { cookie: aliceCookie })
console.log('keys (list) ->', r.status, r.body?.keys?.map((k) => `${k.name}:${k.enabled}`))
assert(r.body.keys.length === 2 && r.body.keys.find((k) => k.name === 'server').enabled === true, 'two keys, server enabled')
r = await json(`/api/me/keys/${firstKeyId}/select`, { method: 'POST', cookie: aliceCookie })
assert(r.status === 200, 're-select first key')
r = await json('/api/me/keys', { cookie: aliceCookie })
assert(r.body.keys.find((k) => k.name === 'home').enabled === true, 'home enabled after select')
r = await json('/api/me/keys', { method: 'POST', cookie: aliceCookie, body: { name: 'bad', apiKey: 'bad key with space' } })
console.log('keys (bad charset) ->', r.status)
assert(r.status === 400, 'header-hostile key is rejected')
r = await json(`/api/me/keys/${firstKeyId}`, { method: 'DELETE', cookie: aliceCookie })
assert(r.status === 200, 'key deleted')
r = await json('/api/me/keys', { cookie: aliceCookie })
assert(r.body.keys.length === 1, 'one key left after delete')
await app.close()
console.log('OK: full auth + review + key vault flow passed')
+126
View File
@@ -0,0 +1,126 @@
// Domain + nginx + proxy-rewrite flow: set/get a custom domain, regenerate its
// nginx config, admin verification, and the proxy rewriting Location redirects
// under the /u/<id>/dsh subpath.
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const here = dirname(fileURLToPath(import.meta.url))
const fakeDsh = join(here, 'fake-dsh.mjs')
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-domain-'))
const app = await buildServer(
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, dshCommand: [process.execPath, fakeDsh] }),
)
await app.listen({ port: 0 })
const base = `http://127.0.0.1:${app.server.address().port}`
await app.db.createUser({
id: 'u1',
username: 'frank',
passHash: await hashPassword('frankpass123'),
role: 'active',
homeDir: '/tmp/u1-home',
})
await app.db.createUser({
id: 'admin',
username: 'admin',
passHash: await hashPassword('adminpass123'),
role: 'admin',
homeDir: '/tmp/admin-home',
})
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(base + path, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
try {
let r
r = await json('/api/auth/login', { method: 'POST', body: { username: 'frank', password: 'frankpass123' } })
const cookie = r.setCookie.split(';')[0]
r = await json('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'adminpass123' } })
const adminCookie = r.setCookie.split(';')[0]
r = await json('/api/domain', { cookie })
console.log('domain (empty) ->', r.status, r.body)
assert(r.status === 200 && r.body.domain === null, 'no domain initially')
r = await json('/api/domain', { method: 'PUT', cookie, body: { domain: 'http://bad' } })
console.log('put invalid ->', r.status)
assert(r.status === 400, 'invalid domain rejected')
r = await json('/api/domain', { method: 'PUT', cookie, body: { domain: 'ALICE.Example.com' } })
console.log('put domain ->', r.status, r.body?.domain)
assert(r.status === 200 && r.body.domain === 'alice.example.com', 'domain normalized + stored')
assert(r.body.nginx_config.includes('server_name alice.example.com'), 'config has server_name')
assert(r.body.nginx_config.includes('/u/u1/dsh/'), 'config rewrites to user subpath')
r = await json('/api/domain', { cookie })
console.log('domain (get) ->', r.status, r.body?.domain, 'verified:', r.body?.verified)
assert(r.body.domain === 'alice.example.com' && r.body.verified === false, 'domain retrieved, unverified')
r = await json('/api/nginx/regen', { method: 'POST', cookie })
console.log('regen ->', r.status)
assert(r.status === 200 && r.body.nginx_config.includes('server_name alice.example.com'), 'regen returns config')
r = await json('/api/admin/domains', { cookie: adminCookie })
console.log('admin list ->', r.status, r.body?.domains?.map((d) => `${d.domain}:${d.verified}`))
const dom = r.body.domains.find((d) => d.domain === 'alice.example.com')
assert(dom && dom.verified === false, 'admin lists unverified domain')
r = await json(`/api/admin/domains/${dom.id}/verify`, { method: 'POST', cookie: adminCookie })
console.log('verify ->', r.status)
assert(r.status === 200, 'admin verifies domain')
r = await json('/api/admin/domains', { cookie: adminCookie })
assert(r.body.domains.find((d) => d.domain === 'alice.example.com').verified === true, 'domain now verified')
// Proxy Location rewrite.
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
assert(r.status === 200, 'launch succeeds')
let location
for (let i = 0; i < 20; i++) {
try {
const res = await fetch(base + '/u/u1/dsh/redirect', { headers: { cookie }, redirect: 'manual' })
if (res.status === 302) {
location = res.headers.get('location')
break
}
} catch {
// retry until the child is listening
}
await sleep(100)
}
console.log('proxy redirect ->', location)
assert(location === '/u/u1/dsh/somewhere', 'Location rewritten under subpath')
console.log('OK: domain + nginx + proxy-rewrite flow passed')
} finally {
await app.close()
await sleep(300)
try {
rmSync(dataRoot, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
}
+93
View File
@@ -0,0 +1,93 @@
// DSH launch/status/proxy/stop flow against a stand-in `dsh` (fake-dsh.mjs).
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const here = dirname(fileURLToPath(import.meta.url))
const fakeDsh = join(here, 'fake-dsh.mjs')
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-dsh-'))
const app = await buildServer(
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, dshCommand: [process.execPath, fakeDsh] }),
)
await app.listen({ port: 0 })
const base = `http://127.0.0.1:${app.server.address().port}`
await app.db.createUser({
id: 'u1',
username: 'carol',
passHash: await hashPassword('carolpass123'),
role: 'active',
homeDir: '/tmp/u1-home',
})
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(base + path, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
try {
let r
r = await json('/api/auth/login', { method: 'POST', body: { username: 'carol', password: 'carolpass123' } })
assert(r.status === 200, 'login succeeds')
const cookie = r.setCookie.split(';')[0]
r = await json('/api/dsh/status', { cookie })
assert(r.body.running === false, 'not running initially')
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
console.log('launch ->', r.status, r.body)
assert(r.status === 200 && r.body.url, 'launch succeeds')
const url = r.body.url
r = await json('/api/dsh/status', { cookie })
console.log('status ->', r.status, r.body)
assert(r.body.running === true, 'running after launch')
// The child binds its port asynchronously; poll the proxy until it answers.
let proxyText
for (let i = 0; i < 20; i++) {
try {
const res = await fetch(base + url + 'hello', { headers: { cookie } })
if (res.status === 200) {
proxyText = await res.text()
break
}
} catch {
// connection refused until the child is listening — retry
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
console.log('proxy ->', proxyText)
assert(proxyText !== undefined && proxyText.includes('fake-dsh'), 'proxy reaches the child DSH')
r = await json('/api/dsh/stop', { method: 'POST', cookie })
console.log('stop ->', r.status)
assert(r.status === 200, 'stop succeeds')
await new Promise((resolve) => setTimeout(resolve, 100))
r = await json('/api/dsh/status', { cookie })
assert(r.body.running === false, 'stopped after stop')
console.log('OK: dsh launch/status/proxy/stop flow passed')
} finally {
await app.close()
rmSync(dataRoot, { recursive: true, force: true })
}
+103
View File
@@ -0,0 +1,103 @@
// File sidecar round-trip: drive K8sUserFs against a real buildFileService
// instance and assert it behaves identically to LocalUserFs on the same volume.
// This is the k8s desktop-FS path (docs/k8s.md §4.10) exercised without a
// cluster: the sidecar is bound on loopback and the client's Service-DNS
// resolution is stubbed to point at it.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildFileService } from '../lib/web/file-service.js'
import { K8sUserFs } from '../lib/fs/k8s-user-fs.js'
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
import { UserFsError } from '../lib/fs/user-fs.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
async function rejectsWith(fn, code, message) {
try {
await fn()
} catch (err) {
assert(err instanceof UserFsError, `${message} (got ${err})`)
assert(err.code === code, `${message} (got ${err.code})`)
return
}
throw new Error('ASSERT: ' + message + ' (resolved instead)')
}
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-sidecar-'))
const userRoot = join(dataRoot, 'users', 'u1')
// The sidecar serves exactly this one user's root, as its Pod would.
const sidecar = buildFileService(userRoot, { bodyLimit: 8 * 1024 * 1024, logLevel: 'warn' })
await sidecar.listen({ host: '127.0.0.1', port: 0 })
const port = sidecar.server.address().port
// In-cluster this resolves to `dsh-files-<id>.<ns>.svc.cluster.local:8082`;
// here it points at the loopback sidecar bound above.
const local = new LocalUserFs(() => userRoot)
let ensured = 0
const remote = new K8sUserFs(async () => { ensured += 1 }, () => ({ host: '127.0.0.1', port }))
try {
await remote.initUserRoot('u1')
assert(ensured > 0, 'initUserRoot brings the sidecar up first')
// Seed a plugin profile so the catalog read has something to find.
const profile = join(userRoot, 'home', 'profiles', 'web')
mkdirSync(join(profile, 'node_modules', 'p1'), { recursive: true })
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dsh: { profile: { bundles: ['p1', '@deepseek-ai/core'] } } }))
writeFileSync(join(profile, 'node_modules', 'p1', 'package.json'), JSON.stringify({ description: 'first plugin' }))
let entries = await remote.listDir('u1', '')
assert(entries.length === 0, 'fresh workspace is empty')
const dirName = await remote.createEntry('u1', '', 'proj', 'dir')
assert(dirName === 'proj', 'createEntry returns the sanitized name')
assert(await remote.isDirectory('u1', 'proj'), 'created entry is a directory')
const uploaded = await remote.upload('u1', 'proj', 'notes.txt', Buffer.from('hello sidecar'))
assert(uploaded === 'notes.txt', 'upload returns the sanitized name')
await remote.mkdir('u1', 'proj/sub')
// Same volume, two implementations → identical view.
entries = await remote.listDir('u1', 'proj')
const localEntries = await local.listDir('u1', 'proj')
assert(JSON.stringify(entries) === JSON.stringify(localEntries), 'sidecar and local agree on the listing')
const file = entries.find((e) => e.name === 'notes.txt')
assert(file.type === 'file' && file.size === 13, 'uploaded file has the right type/size')
const plugins = await remote.listInstalledPlugins('u1')
assert(plugins.length === 1 && plugins[0].id === 'p1', 'installation-scoped bundles are filtered out')
assert(plugins[0].description === 'first plugin', 'plugin description comes from its package.json')
assert(JSON.stringify(plugins) === JSON.stringify(await local.listInstalledPlugins('u1')), 'catalogs agree')
await remote.writeHandoff('u1', JSON.stringify({ command: 'echo hi' }))
// Error codes survive the HTTP hop as the same UserFsError the UI switches on.
await rejectsWith(() => remote.listDir('u1', '../../etc'), 'bad_path', 'traversal rejected')
await rejectsWith(() => remote.listDir('u1', 'nope'), 'not_found', 'missing dir is not_found')
await rejectsWith(() => remote.createEntry('u1', '', 'proj', 'dir'), 'exists', 'duplicate create is exists')
await rejectsWith(() => remote.createEntry('u1', 'nope', 'x', 'file'), 'parent_missing', 'missing parent')
await rejectsWith(() => remote.upload('u1', '', '..', Buffer.from('x')), 'bad_name', 'path in name rejected')
// resolvePath is pure POSIX path math on the in-Pod layout.
assert(
remote.resolvePath('u1', 'proj') === '/var/lib/dshs/users/u1/ws/proj',
'resolvePath yields the in-Pod path',
)
let escaped = false
try {
remote.resolvePath('u1', '../../etc')
} catch (err) {
escaped = err instanceof UserFsError && err.code === 'bad_path'
}
assert(escaped, 'resolvePath rejects traversal')
console.log('OK: file sidecar round-trip matches LocalUserFs')
} finally {
await sidecar.close()
rmSync(dataRoot, { recursive: true, force: true })
}
+93
View File
@@ -0,0 +1,93 @@
// Desktop/FS flow: login, list (empty), mkdir (nested), upload, and isolation
// checks (path escape rejected, upload name sanitized, unauthenticated rejected).
// Uses a throwaway dataRoot + in-memory DB so each run is fully isolated.
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-fs-'))
const app = await buildServer(resolveConfig({ port: 0, dbPath: ':memory:', dataRoot }))
await app.listen({ port: 0 })
const base = `http://127.0.0.1:${app.server.address().port}`
await app.db.createUser({
id: 'u1',
username: 'bob',
passHash: await hashPassword('bobpass123'),
role: 'active',
homeDir: '/tmp/u1-home',
})
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(base + path, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
try {
let r
r = await json('/api/desktop/tree')
assert(r.status === 401, 'unauthenticated tree is rejected')
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
assert(r.status === 200, 'login succeeds')
const cookie = r.setCookie.split(';')[0]
r = await json('/api/desktop/tree', { cookie })
console.log('tree (empty) ->', r.status, r.body?.entries)
assert(r.status === 200 && r.body.entries.length === 0, 'empty workspace lists zero entries')
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj' } })
console.log('mkdir proj ->', r.status)
assert(r.status === 200, 'mkdir succeeds')
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj/sub' } })
console.log('mkdir proj/sub ->', r.status)
assert(r.status === 200, 'nested mkdir succeeds')
r = await json('/api/fs/upload', {
method: 'POST',
cookie,
body: { path: 'proj', name: 'hello.txt', data: Buffer.from('hi there').toString('base64') },
})
console.log('upload ->', r.status)
assert(r.status === 200, 'upload succeeds')
r = await json('/api/desktop/tree?path=proj', { cookie })
console.log('tree proj ->', r.status, r.body?.entries?.map((e) => `${e.name}:${e.type}`))
assert(r.status === 200 && r.body.entries.some((e) => e.name === 'hello.txt'), 'uploaded file is listed')
// isolation: a `..` in the *path* is rejected outright
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: '../escape' } })
console.log('mkdir ../escape ->', r.status)
assert(r.status === 400, 'path escape is rejected')
// isolation: a path component in the *name* is sanitized to its base name
r = await json('/api/fs/upload', {
method: 'POST',
cookie,
body: { path: '', name: '../../evil.txt', data: 'aGk=' },
})
console.log('upload ../../ ->', r.status, r.body?.name)
assert(r.status === 200 && r.body?.name === 'evil.txt', 'upload name is sanitized to its base name')
console.log('OK: desktop/fs flow + isolation checks passed')
} finally {
await app.close()
rmSync(dataRoot, { recursive: true, force: true })
}
+105
View File
@@ -0,0 +1,105 @@
// Account-level isolation spawn flow: in 'account' mode the orchestrator passes
// a deterministic uid to the setuid wrapper, and the child still runs through it.
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const here = dirname(fileURLToPath(import.meta.url))
const fakeDsh = join(here, 'fake-dsh.mjs')
const fakeSetpriv = join(here, 'fake-setpriv.mjs')
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-iso-'))
const app = await buildServer(
resolveConfig({
port: 0,
dbPath: ':memory:',
dataRoot,
isolationMode: 'account',
baseUid: 50000,
spawnAsUserCommand: [process.execPath, fakeSetpriv, '--uid', '{UID}'],
dshCommand: [process.execPath, fakeDsh],
}),
)
await app.listen({ port: 0 })
const base = `http://127.0.0.1:${app.server.address().port}`
await app.db.createUser({
id: 'u1',
username: 'gina',
passHash: await hashPassword('ginapass123'),
role: 'active',
homeDir: '/tmp/u1-home',
})
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(base + path, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
try {
let r = await json('/api/auth/login', { method: 'POST', body: { username: 'gina', password: 'ginapass123' } })
const cookie = r.setCookie.split(';')[0]
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
console.log('launch ->', r.status)
assert(r.status === 200, 'launch succeeds')
let uid
for (let i = 0; i < 20; i++) {
try {
uid = readFileSync(join(dataRoot, 'users', 'u1', 'ws', 'proj', 'setpriv-uid.txt'), 'utf8').trim()
break
} catch {
// the wrapper hasn't written yet
}
await sleep(100)
}
const expected = (await app.db.findUserById('u1')).uid
console.log('setuid ->', uid, '(expected', expected + ')')
assert(uid === String(expected), 'setuid wrapper received the DB-assigned uid')
let proxyText
for (let i = 0; i < 20; i++) {
try {
const res = await fetch(base + '/u/u1/dsh/hello', { headers: { cookie } })
if (res.status === 200) {
proxyText = await res.text()
break
}
} catch {
// retry until the child is listening
}
await sleep(100)
}
console.log('proxy ->', proxyText !== undefined)
assert(proxyText !== undefined && proxyText.includes('fake-dsh'), 'dsh runs through the setuid wrapper')
console.log('OK: account-level isolation spawn flow passed')
} finally {
await app.close()
await sleep(300)
try {
rmSync(dataRoot, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
}
+106
View File
@@ -0,0 +1,106 @@
// Subdomain routing + auth flow: a per-user `Host: <username>.<baseDomain>` routes
// to that user's DSH only when the caller's session cookie matches the subdomain.
import { request as httpRequest } from 'node:http'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
import { hashPassword } from '../lib/web/auth.js'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const here = dirname(fileURLToPath(import.meta.url))
const fakeDsh = join(here, 'fake-dsh.mjs')
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-subdomain-'))
const app = await buildServer(
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, baseDomain: 'test.local', dshCommand: [process.execPath, fakeDsh] }),
)
await app.listen({ port: 0 })
const port = app.server.address().port
await app.db.createUser({
id: 'u1',
username: 'Carol',
passHash: await hashPassword('carolpass123'),
role: 'active',
homeDir: '/tmp/u1-home',
})
await app.db.createUser({
id: 'u2',
username: 'bob',
passHash: await hashPassword('bobpass123'),
role: 'active',
homeDir: '/tmp/u2-home',
})
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
async function json(path, { method = 'GET', body, cookie } = {}) {
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
}
function getWithHost(path, host, cookie) {
return new Promise((resolve, reject) => {
const req = httpRequest(
{ hostname: '127.0.0.1', port, path, method: 'GET', headers: { host, ...(cookie ? { cookie } : {}) } },
(res) => {
let data = ''
res.on('data', (chunk) => (data += chunk))
res.on('end', () => resolve({ status: res.statusCode, body: data }))
},
)
req.on('error', reject)
req.end()
})
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
try {
let r = await json('/api/auth/login', { method: 'POST', body: { username: 'Carol', password: 'carolpass123' } })
const cookie = r.setCookie.split(';')[0]
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
const bobCookie = r.setCookie.split(';')[0]
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
console.log('launch ->', r.status, r.body?.url)
assert(r.status === 200, 'launch succeeds')
assert(r.body.url === 'https://carol.test.local/', 'launch returns the subdomain URL')
await sleep(200)
let res = await getWithHost('/hello', 'carol.test.local', cookie)
console.log('authed /hello ->', res.status)
assert(res.status === 200 && res.body.includes('fake-dsh'), 'authed subdomain routes to the DSH')
res = await getWithHost('/hello', 'carol.test.local')
console.log('no-cookie /hello ->', res.status)
assert(res.status === 401, 'unauthenticated subdomain is rejected')
res = await getWithHost('/hello', 'carol.test.local', bobCookie)
console.log('bob /hello ->', res.status)
assert(res.status === 403, 'wrong user is rejected')
res = await getWithHost('/', `127.0.0.1:${port}`)
assert(!res.body.includes('fake-dsh'), 'non-subdomain Host does not proxy')
console.log('OK: subdomain routing + auth flow passed')
} finally {
await app.close()
await sleep(300)
try {
rmSync(dataRoot, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
}
+19
View File
@@ -0,0 +1,19 @@
// Smoke test: boot the orchestrator on an ephemeral port, hit `/` and
// `/api/auth/me`, then close cleanly. Proves the scaffold compiles, migrates
// the DB, serves static, and exercises the authn guard.
import { buildServer } from '../lib/web/server.js'
import { resolveConfig } from '../lib/config.js'
const app = await buildServer(resolveConfig({ port: 0, dbPath: './dev.local.db' }))
await app.listen({ port: 0 })
const addr = app.server.address()
const base = `http://127.0.0.1:${addr.port}`
const home = await fetch(base + '/')
console.log('GET / ->', home.status, (await home.text()).replace(/\s+/g, ' ').slice(0, 60))
const me = await fetch(base + '/api/auth/me')
console.log('GET /api/auth/me ->', me.status, await me.text())
await app.close()
console.log('OK: booted, migrated, served, closed')
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* storage-report.cjs —— 每用户存储用量上报(档案 28)
* 输出:/var/run/dsh-storage-report.json(供门户 GET /api/admin/storage 直接读取,避免每次请求都 du)
* cron:每小时一次。
*/
const { execFileSync } = require('node:child_process')
const { existsSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const OUT = process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json'
const WS_MB = Number(process.env.DSH_WS_THRESHOLD_MB ?? 2048)
const SESS_MB = Number(process.env.DSH_SESSIONS_THRESHOLD_MB ?? 1024)
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
const users = db.prepare('SELECT username, home_dir FROM users ORDER BY username').all()
const rows = users.map((u) => {
const ws = join(u.home_dir, '..', 'ws'), sessions = join(u.home_dir, 'sessions'), trash = join(u.home_dir, '..', 'trash')
const t1 = [], t2 = []
// 仅统计顶层候选(与 ws-cleanup 口径一致),供管理员判断
if (existsSync(ws)) {
const { readdirSync, statSync } = require('node:fs')
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
const CUTOFF = Date.now() - 90 * 86400_000
for (const e of readdirSync(ws)) {
const p = join(ws, e)
let st; try { st = statSync(p) } catch { continue }
if (st.isDirectory()) { if (T1_DIRS.includes(e)) t1.push({ name: e, size: duKB(p) }); continue }
const dot = e.lastIndexOf('.')
const ext = dot >= 0 ? e.slice(dot).toLowerCase() : ''
if (T1_SUFFIX.includes(ext)) t1.push({ name: e, size: st.size })
else if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) t2.push({ name: e, size: st.size })
}
}
const wsB = existsSync(ws) ? duKB(ws) : 0
const sessB = existsSync(sessions) ? duKB(sessions) : 0
const trashB = existsSync(trash) ? duKB(trash) : 0
return {
username: u.username,
ws: wsB, sessions: sessB, trash: trashB, total: wsB + sessB + trashB,
wsOver: wsB >= WS_MB * 1048576, sessionsOver: sessB >= SESS_MB * 1048576,
cleanableT1: t1.length, cleanableT2: t2.length,
cleanableBytes: [...t1, ...t2].reduce((a, c) => a + c.size, 0),
topCleanable: [...t1, ...t2].sort((a, b) => b.size - a.size).slice(0, 5).map((c) => `${c.name} (${(c.size / 1048576).toFixed(1)}MB)`),
}
})
db.close()
const report = {
generatedAt: new Date().toISOString(),
thresholds: { wsMB: WS_MB, sessionsMB: SESS_MB, keepDays: { ws: 90, sessions: 365 }, trashKeepDays: 30 },
totals: { ws: rows.reduce((a, r) => a + r.ws, 0), sessions: rows.reduce((a, r) => a + r.sessions, 0), trash: rows.reduce((a, r) => a + r.trash, 0) },
users: rows,
}
writeFileSync(OUT, JSON.stringify(report, null, 2) + '\n')
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + 'G' : (b / 1048576).toFixed(1) + 'M')
for (const r of rows) console.log(` ${r.username}: ws=${fmt(r.ws)} sessions=${fmt(r.sessions)} trash=${fmt(r.trash)} | 可清 ${r.cleanableT1 + r.cleanableT2} 项/${fmt(r.cleanableBytes)}`)
console.log(` → 已写入 ${OUT}`)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* verify-inject.cjs —— 注入脚本校验(**档案 81 · R1 起改为校验独立文件**)
*
* 历史(2026-09-13 事故):注入脚本原先是 `proxy.ts` 里的 **TS 模板字面量**,里面的 `\n` 会在
* 模板求值时先被转义 ⇒ 注入浏览器的那段 JS 变 SyntaxError ⇒ **整段脚本静默不执行**(浮层/自愈/助手全废)。
* 当时"校验"只抽原始文本跑 new Function,**跳过了求值** ⇒ 假绿。
*
* 现在(R1):脚本已外置到 `assets/inject/*.js`(纯 JS),本脚本负责:
* ① 断言这些文件存在、非空、且能通过 `node --check`(等价于浏览器解析);
* ② 断言 `src/supervisor/proxy.ts` **不再**把注入脚本内联成模板字面量(防回退);
* ③ 断言运行时串里不含 `<script` / `</script>`(会提前结束注入的 script 标签)。
*
* 用法:node scripts/verify-inject.cjs 退出码 0=合格 / 1=不合格
*/
const fs = require('fs')
const os = require('os')
const path = require('path')
const cp = require('child_process')
const ROOT = path.join(__dirname, '..')
const DIR = path.join(ROOT, 'assets', 'inject')
const PROXY_SRC = path.join(ROOT, 'src', 'supervisor', 'proxy.ts')
let bad = 0
const files = fs.existsSync(DIR) ? fs.readdirSync(DIR).filter((f) => f.endsWith('.js')) : []
console.log('=== 注入脚本(' + DIR + ',' + files.length + ' 个)===')
if (files.length === 0) { console.log(' ✗ assets/inject 下没有 .js(档案 81 R1 要求注入脚本外置)'); bad++ }
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vinj-'))
for (const f of files) {
const abs = path.join(DIR, f)
const code = fs.readFileSync(abs, 'utf8')
if (code.trim().length < 100) { console.log(' ✗ ' + f + ':内容过短,疑似被清空'); bad++; continue }
const t = path.join(tmp, f)
fs.writeFileSync(t, code)
const r = cp.spawnSync(process.execPath, ['--check', t], { encoding: 'utf8' })
if (r.status !== 0) {
console.log(' ✗ ' + f + ':**语法失败**(浏览器里会静默失效)→ ' + String(r.stderr || '').split('\n').slice(0, 2).join(' '))
bad++
} else {
const danger = ['</script', '<script'].filter((k) => code.includes(k))
if (danger.length) { console.log(' ✗ ' + f + ':含 ' + danger.join('/') + '(会提前结束注入的 script 标签)'); bad++ }
else console.log(' ✓ ' + f + ' 语法通过(' + code.length + ' 字符)')
}
}
fs.rmSync(tmp, { recursive: true, force: true })
// 防回退:proxy.ts 不得再内联注入脚本
if (fs.existsSync(PROXY_SRC)) {
const src = fs.readFileSync(PROXY_SRC, 'utf8')
const inlined = /const SESSION_[A-Z_]+ = `/g.test(src)
if (inlined) { console.log(' ✗ proxy.ts 仍把注入脚本内联成模板字面量(应改为 loadInject 读 assets/inject)'); bad++ }
else {
const loads = (src.match(/loadInject\('([^']+)'\)/g) || []).length
console.log(' ✓ proxy.ts 已走 loadInject(' + loads + ' 处)')
if (loads < files.length) { console.log(' ⚠️ loadInject 处数(' + loads + ') < 文件数(' + files.length + '),确认是否漏用'); }
}
}
console.log(bad ? '结论:' + bad + ' 项不合格 ❌ —— 别推上线' : '结论:全部合格 ✅')
process.exit(bad ? 1 : 0)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* verify-static.mjs —— 静态页不变量校验(2026-09-13 新增)
*
* 为什么需要:平台有 9 个静态页,其中 wake.html 承担"启动过渡页"(有 5 个 id 被内联 JS 依赖),
* 而"去平台痕迹"(用户可见面不得出现 dshs)是个**容易回归**的约束 ——
* 新人加个页面忘了改名就会漏出去。把它变成 CI 可跑的判据。
*
* 用法:node scripts/verify-static.mjs 退出码 0=全绿 / 1=有违规
*/
import { readFileSync, readdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const WEB = join(ROOT, 'web')
const BANNED = 'dshs' // 用户可见面不得出现的平台内部名
const WAKE_IDS = ['spin', 'step', 'acts', 'retry', 'note'] // wake.html 内联 JS 依赖的 id
let bad = 0
const pages = readdirSync(WEB).filter((f) => f.endsWith('.html'))
console.log('=== 静态页不变量(' + pages.length + ' 页)===')
for (const f of pages) {
const s = readFileSync(join(WEB, f), 'utf8')
const problems = []
const title = /<title>([^<]*)<\/title>/.exec(s)
if (!title || title[1].trim() === '') problems.push('缺 <title> 或为空')
else if (title[1].includes(BANNED)) problems.push('title 含内部平台名: ' + title[1])
if (s.includes(BANNED)) problems.push('页面正文含内部平台名(去痕迹约束)')
if (f === 'wake.html') {
for (const id of WAKE_IDS) {
if (!new RegExp('id="' + id + '"').test(s)) problems.push('缺 id="' + id + '"(内联 JS 依赖)')
}
if (!s.includes('instance_circuit_open')) problems.push('缺档案 78 的熔断提示分支')
}
if (problems.length) { bad++; console.log(' ✗ ' + f + ':' + problems.join(';')) }
else console.log(' ✓ ' + f + (title ? '(title=' + title[1] + ')' : ''))
}
// 内联 <script> 必须能被 JS 解析(2026-09-13 事故:脚本语法错误 = 静默失效)
import { writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
const tmp = mkdtempSync(join(tmpdir(), 'vstatic-'))
for (const f of pages) {
const s = readFileSync(join(WEB, f), 'utf8')
const blocks = [...s.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1])
blocks.forEach((code, i) => {
const file = join(tmp, f.replace(/\W/g, '_') + '.' + i + '.js')
writeFileSync(file, code)
try { execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' }); console.log(' ✓ ' + f + ' 内联脚本#' + i + ' 语法通过') }
catch (e) { bad++; console.log(' ✗ ' + f + ' 内联脚本#' + i + ' **语法失败**:' + String(e.stderr || e).split('\n').slice(0, 2).join(' ')) }
})
}
// CSS/JS 资源也要过一遍去痕迹约束
for (const f of readdirSync(WEB).filter((f) => /\.(css|js)$/.test(f))) {
const s = readFileSync(join(WEB, f), 'utf8')
if (s.includes(BANNED)) { bad++; console.log(' ✗ ' + f + ':含内部平台名') }
else console.log(' ✓ ' + f)
}
console.log(bad ? '结论:' + bad + ' 项不合格 ❌' : '结论:全部合格 ✅')
process.exit(bad ? 1 : 0)
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* ws-cleanup.cjs —— 用户工作区(ws)定期清理 + 磁盘画像(档案 28)
*
* 分三档(**判定依据只有"路径/文件名模式 + 年龄"**,因为无法可靠区分"AI 写的"):
* T1 明确垃圾/平台产物 → 直接清(白名单精确匹配)
* T2 临时脚本(一次性、无复用价值)→ 超过 N 天未修改才清(默认 90 天)
* T3 其余一切(文档/表格/图片/视频/数据/目录)→ **永不自动删**,只统计
*
* 安全:默认 dry-run;--apply 时一律 `mv` 到 <userRoot>/trash/<日期>-ws-cleanup/(保留 30 天可恢复);
* 仅当该用户 ws ≥ --threshold MB 才动手(默认 2048 MB)。
*
* 用法:
* node ws-cleanup.cjs # dry-run + 画像
* node ws-cleanup.cjs --apply # 执行(含阈值判断)
* node ws-cleanup.cjs --apply --threshold 1024 --days 60
* node ws-cleanup.cjs --user-id <uuid>
*/
const { execFileSync } = require('node:child_process')
const { existsSync, lchownSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync } = require('node:fs')
const { join, extname, basename } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const argv = process.argv.slice(2)
const APPLY = argv.includes('--apply')
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
const THRESHOLD_MB = num('--threshold', 2048)
const DAYS = num('--days', 90)
const idx = argv.indexOf('--user-id')
const ONLY = idx >= 0 ? argv[idx + 1] : ''
/** 档案 43:`--reclaim-only` = 只做属主回收,不跑清理(供高频 cron 用,与阈值无关)。 */
const RECLAIM_ONLY = argv.includes('--reclaim-only')
const STAMP = new Date().toISOString().slice(0, 10)
const CUTOFF = Date.now() - DAYS * 86400_000
// T1:平台产物/明确垃圾(精确名或后缀);T2:一次性脚本(仅"顶层脚本文件"才算,避免误伤项目目录里的源码)
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
/**
* 属主回收(档案 43):把用户工作区里**非该用户属主**的项 chown 回该用户。
*
* 根因:平台以 **root** 执行 `pnpm`,且把 `HOME` 指向用户工作区
* (`src/web/routes/business-plugins.ts` 的 `pnpmEnv`、`scripts/ensure-biz-plugins.cjs`)——
* 因为要与 dsh 实例共用同一个 pnpm store,否则报 `ERR_PNPM_UNEXPECTED_STORE`(不能用别处的 HOME 绕)。
* 副作用是 pnpm 在用户家目录建出 **root 属主**的 `.local/`(`.local/share/pnpm`)→
* 用户之后再 `pip install --user` / npm user-prefix 就报 `Permission denied`
* ——「在自己的目录里装不了包」(2026-09-11 实证)。
*
* 本函数**只 chown、不删除**:用户资产不受影响;root 建的 pnpm store 改属主后 pnpm 依旧可读写。
* 与清理阈值**无关**,永远执行;不跟随软链(用 lstat + lchown)。
*/
function reclaimOwnership(root, uid) {
let fixed = 0
const walk = (dir) => {
let entries
try { entries = readdirSync(dir) } catch { return }
for (const e of entries) {
const p = join(dir, e)
let st
try { st = lstatSync(p) } catch { continue }
if (st.uid !== uid || st.gid !== uid) {
try { lchownSync(p, uid, uid); fixed += 1 } catch { /* 尽力而为 */ }
}
if (st.isDirectory() && !st.isSymbolicLink()) walk(p)
}
}
walk(root)
return fixed
}
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
for (const u of users) {
const ws = join(u.home_dir, '..', 'ws')
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
// 档案 43:属主回收 —— 与阈值无关,always 执行(默认 dry-run 只报告,--apply 才动手)。
const reclaimable = (() => {
let n = 0
const walk = (dir) => {
let entries; try { entries = readdirSync(dir) } catch { return }
for (const e of entries) {
const p = join(dir, e); let st
try { st = lstatSync(p) } catch { continue }
if (st.uid !== u.uid || st.gid !== u.uid) n += 1
if (st.isDirectory() && !st.isSymbolicLink()) walk(p)
}
}
walk(ws); return n
})()
if (reclaimable > 0) {
if (APPLY) {
const fixed = reclaimOwnership(ws, u.uid)
console.log(` ${u.username}: 属主回收 ${fixed} 项 → ${u.uid}:${u.uid}(平台以 root 跑 pnpm 的残留)`)
} else {
console.log(` ${u.username}: 发现 ${reclaimable} 项非本用户属主(--apply 时回收)`)
}
}
if (RECLAIM_ONLY) continue
const wsBytes = duKB(ws)
const t1 = [], t2 = [] // T2 可被 ws/.keep 豁免
let otherBytes = 0, otherCount = 0
// 档案 35:平台自建 bundle 以 `file:<ws>/xxx.tgz` 安装(portal-entry / business-plugins /
// workspace-scoped-picker)。T1 的 `.tgz` 规则会删掉它们 → profile 的 dependencies 指向
// 不存在的文件 → 之后**任何 pnpm 操作**(含用户启用功能插件)都 ENOENT 失败。
// 故:凡被该用户任一 profile 的 `file:` 依赖引用的 ws 文件名,一律豁免 T1。
const protectedNames = new Set()
try {
const profRoot = join(u.home_dir, 'profiles')
if (existsSync(profRoot)) {
for (const pname of readdirSync(profRoot)) {
const pkgPath = join(profRoot, pname, 'package.json')
if (!existsSync(pkgPath)) continue
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
for (const spec of Object.values(pkg.dependencies ?? {})) {
if (typeof spec === 'string' && spec.startsWith('file:')) protectedNames.add(basename(spec.slice(5)))
}
}
}
} catch { /* 读不到就不豁免,保持原行为 */ }
for (const entry of readdirSync(ws)) {
const p = join(ws, entry)
let st
try { st = statSync(p) } catch { continue }
if (st.isDirectory()) {
if (T1_DIRS.includes(entry)) { t1.push({ p, size: duKB(p) }); continue }
otherBytes += duKB(p); otherCount += 1 // 目录一律算用户资产(T3)
continue
}
const ext = extname(entry).toLowerCase()
// 档案 35:被 profile 引用的平台 bundle 包不参与清理(体积计入"资产")。
if (protectedNames.has(entry)) { otherBytes += st.size; otherCount += 1; continue }
if (T1_SUFFIX.includes(ext)) { t1.push({ p, size: st.size }); continue }
if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) { t2.push({ p, size: st.size, mtime: st.mtime }); continue }
otherBytes += st.size; otherCount += 1
}
// .keep 豁免(档案 28):ws 顶层放一个 .keep 文件 → 该用户**跳过 T2**(一次性脚本不清理),T1 仍清
const keepAll = existsSync(join(ws, '.keep'))
if (keepAll && t2.length > 0) {
console.log(` (.keep 已存在 → T2 保留 ${t2.length} 项,不清理)`)
t2.length = 0
}
const t1B = t1.reduce((a, c) => a + c.size, 0), t2B = t2.reduce((a, c) => a + c.size, 0)
const over = wsBytes >= THRESHOLD_MB * 1048576
console.log(` ${u.username}: ws=${fmt(wsBytes)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | T1=${t1.length}项/${fmt(t1B)} | T2=${t2.length}项(>${DAYS}天)/${fmt(t2B)} | 资产=${otherCount}项/${fmt(otherBytes)}`)
for (const c of t1) console.log(` T1 ${basename(c.p)} ${fmt(c.size)}`)
for (const c of t2) console.log(` T2 ${basename(c.p)} ${fmt(c.size)} (mtime ${c.mtime.toISOString().slice(0, 10)})`)
if (APPLY && over && (t1.length + t2.length) > 0) {
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-cleanup`)
mkdirSync(trash, { recursive: true })
for (const c of [...t1, ...t2]) {
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
}
console.log(` → 已清 ${t1.length + t2.length} 项 / ${fmt(t1B + t2B)} → trash/${STAMP}-ws-cleanup(保留 30 天)`)
} else if (APPLY && !over) {
console.log(` (未超阈值,跳过清理)`)
}
}
db.close()
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')