初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -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
|
||||
@@ -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)')
|
||||
@@ -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 ✅"
|
||||
@@ -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,未改动)')
|
||||
@@ -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')
|
||||
@@ -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}`)
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 + ' 个')
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(绝对路径),不受影响。"
|
||||
@@ -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/^/ /'
|
||||
@@ -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
|
||||
@@ -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 || '无运行中实例'}`)
|
||||
}
|
||||
@@ -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)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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,未改动)')
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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}`)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,未改动)')
|
||||
Reference in new issue
Block a user