dsh-any-agent 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -1
- package/USAGE-NOTES.en.md +3 -1
- package/USAGE-NOTES.md +9 -2
- package/bin/dsh-any-agent-workspaces.mjs +12 -5
- package/bin/dsh-any-agent.mjs +4 -1
- package/bin/restore.mjs +1 -1
- package/docs/en/compatibility.md +27 -6
- package/docs/en/reference.md +2 -1
- package/docs/en/release.md +4 -0
- package/docs/en/tests.md +57 -15
- package/docs/zh/compatibility.md +25 -6
- package/docs/zh/reference.md +2 -1
- package/docs/zh/release.md +3 -0
- package/docs/zh/tests.md +57 -15
- package/package.json +4 -1
- package/scripts/detect-dsh.mjs +55 -0
- package/scripts/lint.mjs +595 -0
- package/scripts/sync-test-counts.mjs +699 -0
- package/src/acp-session.mjs +63 -0
- package/src/acp.mjs +14 -5
- package/src/config.mjs +14 -111
- package/src/conversation.mjs +1600 -0
- package/src/doctor.mjs +203 -0
- package/src/dsh-locate.mjs +560 -0
- package/src/git-snapshot.mjs +196 -0
- package/src/hub-store.mjs +108 -0
- package/src/hub.mjs +26 -1972
- package/src/mcp.mjs +21 -75
- package/src/residue.mjs +278 -0
- package/src/sentinel.mjs +97 -0
- package/src/tool-defs.mjs +399 -0
- package/src/tool-render.mjs +262 -0
- package/src/tools.mjs +18 -767
- package/src/workspace-guard.mjs +89 -0
- package/src/workspace.mjs +295 -59
package/src/mcp.mjs
CHANGED
|
@@ -29,87 +29,33 @@ export const SERVER_VERSION = (() => {
|
|
|
29
29
|
})();
|
|
30
30
|
const FALLBACK_PROTOCOL = '2025-06-18';
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
/**
|
|
33
|
+
* 服务说明(initialize.instructions)。★ 必须短:客户端会截断过长的说明(实测 Claude Code 在
|
|
34
|
+
* 约 4000 字处截断,旧版 4.4k 字的末尾 —— 包括【不要做】—— 根本到不了模型)。所以:最要紧的
|
|
35
|
+
* 规则放最前,细节留给各工具自己的 description 与收据文本(收据里有现成的等待/读取命令)。
|
|
36
|
+
* test/smoke.mjs 守住长度上限 INSTRUCTIONS_MAX_CHARS。
|
|
37
|
+
*/
|
|
38
|
+
export const INSTRUCTIONS_MAX_CHARS = 2400;
|
|
39
|
+
|
|
40
|
+
export const INSTRUCTIONS = `本服务把 DeepSeek Harness (DSH) 当作可长期共事的编码 agent 驱动。DSH 自带文件/shell/搜索/技能/子代理;你给**目标**,它自己决定怎么做。
|
|
41
|
+
|
|
42
|
+
【铁律】
|
|
43
|
+
- 给目标,不要给命令;DSH 内部工具名(read/pwsh…)不是本服务的工具,本服务只有 dsh_*。
|
|
44
|
+
- 会话在忙时不要再 dsh_send:改口用 dsh_interject,排到本轮之后用 dsh_interject(mode='queue') 或 dsh_send(queue=true),停下用 dsh_interrupt。
|
|
45
|
+
- 结果当作"完成"之前,先用你自己的工具独立核验(读文件、git diff、跑测试),再 dsh_review 提交 pass/partial/fail(fail/partial 要写理由)。
|
|
46
|
+
- 本服务驱动的会话**不要在 DSH GUI 里打开**:点开即被 GUI 拿走写锁且不释放。要在 GUI 看,先 dsh_release。
|
|
35
47
|
|
|
36
|
-
【三个 id
|
|
37
|
-
- conversation_id —— 长期会话。可反复派活,历史完整保留。进程只是缓存:被回收或崩溃后,
|
|
38
|
-
下次派活会自动 resume 复活,对话不丢。适合"这个项目一直用这个会话"。
|
|
39
|
-
- run_id —— **一次派活**的记录。dsh_send 每次返回一个,用它验收那一次的结果。
|
|
40
|
-
- cursor —— dsh_read 的增量游标,用来观察某个会话当前吐出的内容。
|
|
48
|
+
【三个 id】conversation_id = 长期会话(进程只是缓存,回收/崩溃后下次派活自动 resume);run_id = 一次派活;cursor = dsh_read 的增量游标。
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
1. dsh_start(cwd?, ...) 建会话。cwd 省略时自动用客户端当前打开的项目目录
|
|
44
|
-
(MCP roots);给了就必须是**已存在**的绝对路径,别传家目录本身。
|
|
45
|
-
2. dsh_send(conversation_id, prompt) **默认不阻塞**:立刻返回 run_id + sentinel_file。
|
|
46
|
-
想原地等结果才传 wait=true。
|
|
47
|
-
3. 之后按需:
|
|
48
|
-
dsh_status(conversation_id) 还在跑吗?跑到哪了?写锁在谁手上?
|
|
49
|
-
dsh_read(conversation_id, cursor) 中途看它当前吐出的内容(增量)
|
|
50
|
-
dsh_get(conversation_id, run_id) 验收:取那一次派活的完整结果 + 审核单
|
|
51
|
-
4. **dsh_review(conversation_id, run_id, verdict, notes?)** 调用方审核裁决。
|
|
52
|
-
结果里的 review 字段是审核单(核验清单 + 证据 + review_id)。在把结果当作完成之前,
|
|
53
|
-
先用你自己的工具独立核验(读改动的文件、看 git diff、跑相关测试),再提交
|
|
54
|
-
verdict=pass / partial / fail。默认是**建议式**:不阻塞后续派活,也不会自动回灌给 DSH;
|
|
55
|
-
但未审核的结果不应直接转述成"已完成"。fail/partial 必须写 notes 说明理由。
|
|
56
|
-
5. dsh_interject / dsh_interrupt 跑偏了就插话改口 / 直接打断
|
|
57
|
-
(会话空闲时 interject 等价于 wait=true,会阻塞到回合结束)
|
|
58
|
-
6. dsh_release 交还会话(释放写锁,之后仍可 resume)
|
|
59
|
-
7. dsh_takeover 写锁被别人占着时的**抢占**(见下)
|
|
50
|
+
【流程】dsh_start(cwd?) → dsh_send(conversation_id, prompt) 默认不阻塞,立刻返回 run_id + sentinel_file → 等哨兵 → 读哨兵或 dsh_get 验收 → dsh_review。随时可用 dsh_status / dsh_read / dsh_list(only_running=true)。用完 dsh_release。
|
|
60
51
|
|
|
61
|
-
|
|
62
|
-
DSH 的会话目录有跨进程写锁,**持有者活着就永不过期,且没有 API 能直接夺走**。所以:
|
|
63
|
-
- 本服务为"保持对话"让一个 DSH 进程常驻 → 期间**用户自己的 DSH GUI 打不开这个会话**,反之亦然。
|
|
64
|
-
- 撞上 "session ... is already owned by an active write handle"(可能被 ACP 包装成笼统的
|
|
65
|
-
"Internal error")时**不要盲目重试**:先 dsh_status 看 lock_holder 字段,它会是:
|
|
66
|
-
self / stale-mcp(本服务自己的孤儿,可安全抢占)/ live-mcp(另一个 dsh-any-agent 实例)/
|
|
67
|
-
none(多半是你自己的 GUI 正开着它)
|
|
68
|
-
- 要拿回写锁:
|
|
69
|
-
· stale-mcp → dsh_takeover 直接抢占(会自动杀掉那个孤儿子进程);
|
|
70
|
-
· live-mcp → 先在那边 dsh_release;确实要夺再 dsh_takeover(force=true);
|
|
71
|
-
· none(GUI 持有)→ 本服务**绝不杀**(那会把用户的整个界面连同正在看的对话一起杀掉)。
|
|
72
|
-
请用户在那个窗口里关掉该会话后重试,或重启 dsh web。
|
|
73
|
-
- 操作铁律:**本服务驱动的会话不要在 DSH GUI 里打开**(★实测:光是"点开"这个动作就会被 dsh web 拿走写锁,而且**切走也不释放** —— 点开一次,这个会话在整个 web 生命周期内就归 GUI,要重启 dsh web 才能还回来)。要看进度用 dsh_read 或哨兵文件;真要在 GUI 里看,先 dsh_release。
|
|
52
|
+
【等结果:哨兵文件,不要轮询】每个回合结束(成功/失败/取消)都会原子写出哨兵 JSON。用你的**后台任务**等它出现(收据里给了现成命令:sentinel_file_posix 给 Bash,sentinel_wait_powershell 给 PowerShell),文件一到宿主就唤醒你;读它拿 status 与 result(Windows PowerShell 读取要 -Encoding UTF8,收据里有 sentinel_read_powershell)。wait=true 会原地阻塞,等超时只转后台、不取消回合。本服务不发 MCP 通知。
|
|
74
53
|
|
|
75
|
-
|
|
76
|
-
**每一个回合结束时都会原子写出一个哨兵文件**(无论成功、失败、被取消;也无论是 dsh_send 还是
|
|
77
|
-
dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send 的返回里直接给 sentinel_file;
|
|
78
|
-
若某次调用没给,路径就是 状态目录/runs/<conversation_id>/<run_id>.json 。三种收活姿势:
|
|
79
|
-
- **首选(真·跑完通知,不占轮次)**:用你自己的后台任务等这个文件出现。文件一到,宿主就唤醒你,
|
|
80
|
-
你再读文件即得结果。示例(Bash;直接用返回里的 sentinel_file_posix,别手工转换路径):
|
|
81
|
-
sent="$sentinel_file_posix"
|
|
82
|
-
dl=$(( $(date +%s)+2100 )); until [ -f "$sent" ]; do [ $(date +%s) -ge $dl ] && { echo TIMEOUT; exit 1; }; sleep 2; done; echo DONE
|
|
83
|
-
宿主是 Windows、只有 PowerShell(或 Git Bash 不可用)时,用原生路径 sentinel_file:
|
|
84
|
-
$f='<sentinel_file>'; $dl=(Get-Date).AddSeconds(2100); while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }; 'DONE'
|
|
85
|
-
(路径里有单引号时写成两个单引号;后台收据会直接给出拼好的这一行。)
|
|
86
|
-
读文件即可拿到 status 和 result(result.review 是审核单);**不受内存窗口限制、也扛本服务重启**。文件里 status=error 表示失败。
|
|
87
|
-
拿到结果后先按审核单独立核验,再调用 dsh_review 提交裁决;审核状态会回写进哨兵。
|
|
88
|
-
- 想阻塞拿结果 → 传 **wait=true**;即使等超时也不会丢结果:返回会变成收据(still_running + sentinel_file),
|
|
89
|
-
回合继续跑,结果照常由哨兵送达。
|
|
90
|
-
- 临时查一下 → dsh_get(conversation_id, run_id) / dsh_status / dsh_read。
|
|
91
|
-
本服务**不会**主动给你发 MCP 通知(协议上只应答请求);"跑完通知"就是靠哨兵文件 + 你的后台等待任务实现的。
|
|
92
|
-
**多会话并发安全**:哨兵路径按 conversation_id 分目录,不同会话的 run 各写各的、不串台;每个 run 起一个独立的
|
|
93
|
-
等待任务即可,各自完成、各自唤醒你。哨兵是一次性闩锁:即便 run 在你启动等待任务之前就已完成,[ -f ] 也会立刻为真。
|
|
54
|
+
【写锁】一个会话同时只能有一个写者。撞上 "already owned by an active write handle"(或笼统的 Internal error)时别重试,先看 dsh_status 的 lock_holder:stale-mcp → dsh_takeover;live-mcp → 那边 dsh_release,确需再 dsh_takeover(force=true);none 多半是 GUI 持有 → 请用户在 GUI 里关掉,本服务绝不杀 GUI。
|
|
94
55
|
|
|
95
|
-
|
|
96
|
-
- 任务可能超过一两分钟 → **用 wait=false**。MCP 工具调用是阻塞的,傻等会把你自己卡住。
|
|
97
|
-
- 会话在忙时**不要**再 dsh_send:想改口用 dsh_interject,想停用 dsh_interrupt,
|
|
98
|
-
想把话留到本轮结束后说用 dsh_interject(mode='queue')。
|
|
99
|
-
- 思考内容**默认隐藏**(只返回字数/耗时统计)。确实需要时传 reasoning='full',但很占上下文。
|
|
100
|
-
- 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。**read-only 强制 auto-deny**:传 auto-allow/ask 会被直接拒绝(否则 DSH 的 sandbox 提权会被放行)。分析不信任样本(恶意软件、来路不明 dump)时用
|
|
101
|
-
read-only:沙箱只读 + 审批一律拒绝。
|
|
102
|
-
- 思考深度默认 max;想省钱可在 dsh_start 传 reasoning_effort 调低。
|
|
103
|
-
- 隐私边界:本服务自己的日志、哨兵、注册表都不写思考正文(哨兵只留思考统计);但 **DSH 自己的会话日志
|
|
104
|
-
~/.dsh/sessions/** 会保存 reasoning,reasoning=hide 也照存** —— 分析敏感数据时要把它算进去。
|
|
105
|
-
- 异步哨兵文件保存最终正文与工具输入(常见密钥字面量已脱敏),默认保留 7 天;消费后建议自行删除。
|
|
106
|
-
- 想并行:开多个会话,dsh_list(only_running=true) 看谁在跑,再轮流 dsh_read。
|
|
56
|
+
【默认值】权限 ${DEFAULT_PERMISSION},审批 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'};read-only 强制 auto-deny(分析不信任样本用它)。思考默认隐藏(reasoning='full' 才返回),思考深度默认 max(reasoning_effort 可调低)。
|
|
107
57
|
|
|
108
|
-
|
|
109
|
-
- 不要用 dsh_send 去"执行一条命令"。DSH 是 agent,请给它目标而不是命令;
|
|
110
|
-
它自己会选工具、分步骤、必要时派子代理。
|
|
111
|
-
- 不要把 DSH 内部的工具名(read/pwsh/glob 等)当成本服务的工具——本服务只有 dsh_* 这些。
|
|
112
|
-
- 不要假设回合秒回。长任务请走 wait=false + 事后验收。`;
|
|
58
|
+
【隐私】本服务的日志/哨兵/注册表不写思考正文;但 DSH 自己的 ~/.dsh/sessions 会保存 reasoning。哨兵含正文与工具输入(常见密钥已脱敏),保留 7 天。`;
|
|
113
59
|
|
|
114
60
|
/**
|
|
115
61
|
* 把 MCP root 的 file:// URI 转成本地路径。
|
package/src/residue.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 测试残留清理:一个测试会话留下的**全部**东西,一次清干净。
|
|
3
|
+
*
|
|
4
|
+
* 一个会话在磁盘上会留下两类东西:
|
|
5
|
+
*
|
|
6
|
+
* DSH 侧(`~/.dsh`,由 `workspace.mjs` 的 purgeTestSessions 负责)
|
|
7
|
+
* - sessions/<桶>/<id>/ 会话日志
|
|
8
|
+
* - storages/session_projcache/ 投影缓存 <id>.json
|
|
9
|
+
* - storages/workspace.json 工作区登记里的 id
|
|
10
|
+
* - .dsh-any-agent-forgotten/ forget 墓碑
|
|
11
|
+
*
|
|
12
|
+
* 本项目侧(状态目录,默认 `~/.dsh/.dsh-any-agent/`;本文件负责)
|
|
13
|
+
* - conversations.json 里的注册表条目
|
|
14
|
+
* - meta/<id>.json 元数据侧车(forget 后**特意**保留,所以必须显式清)
|
|
15
|
+
* - runs/<id>/ 完成哨兵
|
|
16
|
+
* - reviews/<id>/ 调用方审核记录
|
|
17
|
+
* - locks/<id>.json 写锁持有者登记
|
|
18
|
+
*
|
|
19
|
+
* ★ 护栏(和 purgeTestSessions 同一条):只清"能证明是测试会话"的。证据按可信度:
|
|
20
|
+
* 会话头里的 cwd > 元数据侧车里的 cwd > 注册表条目里的 cwd。
|
|
21
|
+
* 任何一处证据指向非测试目录 → 拒绝;完全没有证据 → 只有"调用方亲手造的 id"
|
|
22
|
+
* (trusted)才清,扫荡出来的不清。
|
|
23
|
+
*
|
|
24
|
+
* 为什么不是 `test/` 下的帮助文件:`bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`
|
|
25
|
+
* 与全局 `test/cleanup.mjs` 也要用,而且要能在沙箱 DSH_HOME 里做离线回归。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
31
|
+
import { REVIEWS_DIR, RUNS_DIR, STATE_FILE } from './config.mjs';
|
|
32
|
+
import { LOCKS_DIR } from './locks.mjs';
|
|
33
|
+
import { isTestSessionCwd, pruneTestWorkspaces, purgeOrphanProjcache, purgeTestSessions } from './workspace.mjs';
|
|
34
|
+
|
|
35
|
+
const SAFE_ID = /^[A-Za-z0-9._-]{4,80}$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 一份"状态目录规格"。字符串 = 注册表文件路径(meta/runs/reviews/locks 取它的同级目录);
|
|
39
|
+
* 对象可逐项覆盖(`DSH_MCP_RUNS_DIR` 之类环境变量改过位置时用)。
|
|
40
|
+
*/
|
|
41
|
+
function resolveState(spec) {
|
|
42
|
+
const s = typeof spec === 'string' ? { stateFile: spec } : spec;
|
|
43
|
+
const stateFile = s.stateFile ?? (s.dir ? join(s.dir, 'conversations.json') : null);
|
|
44
|
+
if (!stateFile) throw new Error('状态目录规格缺少 stateFile/dir');
|
|
45
|
+
const dir = dirname(stateFile);
|
|
46
|
+
return {
|
|
47
|
+
stateFile,
|
|
48
|
+
metaDir: s.metaDir ?? join(dir, 'meta'),
|
|
49
|
+
runsDir: s.runsDir ?? join(dir, 'runs'),
|
|
50
|
+
reviewsDir: s.reviewsDir ?? join(dir, 'reviews'),
|
|
51
|
+
locksDir: s.locksDir ?? join(dir, 'locks'),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 当前进程配置的状态目录(= MCP 服务默认用的那份,通常是 `~/.dsh/.dsh-any-agent`)。 */
|
|
56
|
+
export function defaultState() {
|
|
57
|
+
return { stateFile: STATE_FILE, runsDir: RUNS_DIR, reviewsDir: REVIEWS_DIR, locksDir: LOCKS_DIR };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readJson = (file) => {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 读注册表;文件不存在 → null;坏了 → 抛错(调用方 fail-closed,绝不覆盖)。 */
|
|
69
|
+
function readRegistry(stateFile) {
|
|
70
|
+
if (!existsSync(stateFile)) return null;
|
|
71
|
+
const doc = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
72
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw new Error('注册表结构异常');
|
|
73
|
+
return doc;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 这个 id 在这份状态目录里的 cwd 证据(元数据侧车 + 注册表条目)。 */
|
|
77
|
+
function stateCwds(st, id, registry) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const meta = readJson(join(st.metaDir, `${id}.json`));
|
|
80
|
+
if (typeof meta?.cwd === 'string' && meta.cwd) out.push(meta.cwd);
|
|
81
|
+
const entry = (registry?.conversations ?? []).find((c) => c?.id === id);
|
|
82
|
+
if (typeof entry?.cwd === 'string' && entry.cwd) out.push(entry.cwd);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 删一个文件/目录;返回是否真的存在过并已删除。 */
|
|
87
|
+
function removePath(p, dryRun) {
|
|
88
|
+
if (!existsSync(p)) return false;
|
|
89
|
+
if (!dryRun) rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 清掉一批会话 id 在若干状态目录里的 per-run 数据。
|
|
95
|
+
*
|
|
96
|
+
* @param {object} opts
|
|
97
|
+
* @param {string[]} opts.ids 要清的会话 id
|
|
98
|
+
* @param {Array<string|object>} [opts.states] 状态目录规格;缺省 = 当前进程配置的那份
|
|
99
|
+
* @param {Map<string,string>|Record<string,string>} [opts.headerCwds] 已核实的会话头 cwd(id → cwd)
|
|
100
|
+
* @param {boolean|string[]} [opts.trusted] 这些 id 是调用方亲手造的(没证据也清)。true = 全部。
|
|
101
|
+
* @param {boolean} [opts.dryRun]
|
|
102
|
+
*/
|
|
103
|
+
export function purgeProjectState({ ids, states, headerCwds = new Map(), trusted = false, dryRun = false }) {
|
|
104
|
+
const header = headerCwds instanceof Map ? headerCwds : new Map(Object.entries(headerCwds));
|
|
105
|
+
const trustedSet = trusted === true ? null : new Set(Array.isArray(trusted) ? trusted : []);
|
|
106
|
+
const isTrusted = (id) => trusted === true || (trustedSet?.has(id) ?? false);
|
|
107
|
+
const specs = (states?.length ? states : [defaultState()]).map(resolveState);
|
|
108
|
+
const res = { removed: { registry: 0, meta: 0, runs: 0, reviews: 0, locks: 0 }, refused: [], errors: [] };
|
|
109
|
+
const wanted = [...new Set((ids ?? []).filter((x) => typeof x === 'string' && SAFE_ID.test(x)))];
|
|
110
|
+
|
|
111
|
+
for (const st of specs) {
|
|
112
|
+
let registry = null;
|
|
113
|
+
let registryBroken = false;
|
|
114
|
+
try {
|
|
115
|
+
registry = readRegistry(st.stateFile);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
registryBroken = true;
|
|
118
|
+
res.errors.push(`${st.stateFile}: ${e.message}(注册表不动;其余产物仍按元数据证据清理)`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const allowed = [];
|
|
122
|
+
for (const id of wanted) {
|
|
123
|
+
const cwds = [header.get(id), ...stateCwds(st, id, registry)].filter(Boolean);
|
|
124
|
+
if (cwds.some((c) => !isTestSessionCwd(c))) {
|
|
125
|
+
res.refused.push({ id, state: st.stateFile, reason: 'cwd 证据指向非测试目录' });
|
|
126
|
+
} else if (!cwds.length && !isTrusted(id)) {
|
|
127
|
+
res.refused.push({ id, state: st.stateFile, reason: '没有任何 cwd 证据' });
|
|
128
|
+
} else {
|
|
129
|
+
allowed.push(id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const id of allowed) {
|
|
134
|
+
if (removePath(join(st.runsDir, id), dryRun)) res.removed.runs++;
|
|
135
|
+
if (removePath(join(st.reviewsDir, id), dryRun)) res.removed.reviews++;
|
|
136
|
+
if (removePath(join(st.locksDir, `${id}.json`), dryRun)) res.removed.locks++;
|
|
137
|
+
if (removePath(join(st.metaDir, `${id}.json`), dryRun)) res.removed.meta++;
|
|
138
|
+
// 侧车写到一半崩溃会留 `<id>.json.tmp.<pid>.<rand>`
|
|
139
|
+
try {
|
|
140
|
+
for (const f of existsSync(st.metaDir) ? readdirSync(st.metaDir) : []) {
|
|
141
|
+
if (f.startsWith(`${id}.json.tmp.`)) removePath(join(st.metaDir, f), dryRun);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
/* 读不了就算了 */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 注册表:只摘 allowed 里的条目;其它条目与顶层字段原样保留;没变化不写盘。
|
|
149
|
+
if (registry && !registryBroken && Array.isArray(registry.conversations)) {
|
|
150
|
+
const drop = new Set(allowed);
|
|
151
|
+
const kept = registry.conversations.filter((c) => !drop.has(c?.id));
|
|
152
|
+
const n = registry.conversations.length - kept.length;
|
|
153
|
+
if (n > 0) {
|
|
154
|
+
res.removed.registry += n;
|
|
155
|
+
if (!dryRun) {
|
|
156
|
+
const tmp = `${st.stateFile}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(tmp, JSON.stringify({ ...registry, conversations: kept }, null, 2), 'utf8');
|
|
159
|
+
renameSync(tmp, st.stateFile);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
rmSync(tmp, { force: true });
|
|
162
|
+
res.errors.push(`${st.stateFile}: 写注册表失败: ${e.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return res;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 测试套件收尾用的一站式清理:先清 DSH 侧会话,再清本项目 per-run 数据。
|
|
173
|
+
*
|
|
174
|
+
* 套件只会在自己的 `finally` 里调它,所以只传**自己造的**:`ids`(dsh_start 返回的)
|
|
175
|
+
* 与 `roots`(自己的临时根,用来兜住没来得及记 id、以及 DSH 自己派出的子代理会话)。
|
|
176
|
+
* 永远是点名模式,不会扫荡别人的会话。
|
|
177
|
+
*
|
|
178
|
+
* @param {object} opts
|
|
179
|
+
* @param {string[]} [opts.ids]
|
|
180
|
+
* @param {string[]} [opts.roots]
|
|
181
|
+
* @param {Array<string|object>} [opts.stateFiles] 这次测试起的 MCP 服务用的状态目录规格
|
|
182
|
+
* (`DSH_MCP_STATE`);缺省 = 当前进程配置的那份
|
|
183
|
+
* @param {boolean} [opts.dryRun]
|
|
184
|
+
*/
|
|
185
|
+
export function purgeTestResidue({ ids = [], roots = [], stateFiles, dryRun = false } = {}) {
|
|
186
|
+
const sessions = purgeTestSessions({ ids, roots, idsOnly: true, dryRun });
|
|
187
|
+
const refused = new Set(sessions.skipped.map((s) => s.id));
|
|
188
|
+
const headerCwds = new Map(sessions.victims.map((v) => [v.id, v.cwd]));
|
|
189
|
+
const all = [...new Set([...ids, ...sessions.victims.map((v) => v.id)])].filter((id) => !refused.has(id));
|
|
190
|
+
// 调用方点名的 id 是它亲手造的:会话目录已不在(比如测试中途特意删过)时,
|
|
191
|
+
// 没有头 cwd 也没有侧车,仍然算可清;有任何非测试证据则照样拒绝。
|
|
192
|
+
const project = purgeProjectState({ ids: all, states: stateFiles, headerCwds, trusted: ids.filter((id) => !refused.has(id)), dryRun });
|
|
193
|
+
return { sessions, project };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 扫荡一份状态目录:把里面**有证据证明是测试会话**的 per-run 数据清掉。
|
|
198
|
+
*
|
|
199
|
+
* 和 purgeProjectState 的区别:这里的 id 是从目录内容里收集来的(不是调用方点名的),
|
|
200
|
+
* 所以一律按"没证据不清"处理——别人的会话、没有侧车的孤儿条目原样留着。
|
|
201
|
+
*
|
|
202
|
+
* @param {object} opts
|
|
203
|
+
* @param {string|object} opts.state 状态目录规格
|
|
204
|
+
* @param {boolean} [opts.removeEmptyDirs] 清空后把 runs/reviews/locks/meta 这几个空目录也收掉
|
|
205
|
+
*/
|
|
206
|
+
export function sweepStateDir({ state, dryRun = false, removeEmptyDirs = false }) {
|
|
207
|
+
const spec = resolveState(state);
|
|
208
|
+
const ids = new Set();
|
|
209
|
+
const collect = (d, stripJson) => {
|
|
210
|
+
try {
|
|
211
|
+
for (const f of readdirSync(d)) ids.add(stripJson ? f.replace(/\.json$/, '') : f);
|
|
212
|
+
} catch {
|
|
213
|
+
/* 目录不存在 */
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
collect(spec.runsDir, false);
|
|
217
|
+
collect(spec.reviewsDir, false);
|
|
218
|
+
collect(spec.locksDir, true);
|
|
219
|
+
collect(spec.metaDir, true);
|
|
220
|
+
try {
|
|
221
|
+
for (const c of readRegistry(spec.stateFile)?.conversations ?? []) if (c?.id) ids.add(c.id);
|
|
222
|
+
} catch {
|
|
223
|
+
/* 注册表坏了:purgeProjectState 会再报一次,这里不重复 */
|
|
224
|
+
}
|
|
225
|
+
const purged = purgeProjectState({ ids: [...ids], states: [spec], trusted: false, dryRun });
|
|
226
|
+
const dirsRemoved = [];
|
|
227
|
+
if (removeEmptyDirs && !dryRun) {
|
|
228
|
+
for (const d of [spec.runsDir, spec.reviewsDir, spec.locksDir, spec.metaDir]) {
|
|
229
|
+
try {
|
|
230
|
+
if (existsSync(d) && readdirSync(d).length === 0) {
|
|
231
|
+
rmdirSync(d);
|
|
232
|
+
dirsRemoved.push(d);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
/* 占用就算了 */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { ...purged, dirsRemoved };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 清理 `%TEMP%` 根下遗留的扁平状态目录。
|
|
244
|
+
*
|
|
245
|
+
* 历史包袱:好几个套件把 `DSH_MCP_STATE` 设成 `%TEMP%/dsh-any-agent-xxx-state-<ts>.json`,
|
|
246
|
+
* 而 runs/reviews/locks/meta 是按"注册表同级目录"推导的,于是它们全落在 `%TEMP%/runs`、
|
|
247
|
+
* `%TEMP%/meta`…… 里,没有任何一个 `dsh-any-agent-*` 目录兜着,永远不会被清掉。
|
|
248
|
+
* 现在各套件的状态都收进自己的临时根里了;这里只负责清旧版本留下的,也兜住
|
|
249
|
+
* 还在跑旧版测试的分支。
|
|
250
|
+
*
|
|
251
|
+
* 这些目录名很通用(runs/meta…),所以**必须有证据**:只清元数据侧车证明 cwd 是测试目录的 id。
|
|
252
|
+
*/
|
|
253
|
+
export function sweepLegacyTmpState({ dir = tmpdir(), dryRun = false } = {}) {
|
|
254
|
+
return sweepStateDir({ state: { dir }, dryRun, removeEmptyDirs: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 全局扫荡(`test/cleanup.mjs` 的核心,也是各套件 finally 之外的兜底):
|
|
259
|
+
* 1) 磁盘上 cwd 是测试目录的会话(含 DSH 侧产物、子会话)
|
|
260
|
+
* 2) 这些会话在**当前进程配置的状态目录**里的 per-run 数据
|
|
261
|
+
* 3) `%TEMP%` 根下遗留的扁平状态目录(旧版套件)
|
|
262
|
+
* 4) 投影缓存里的测试孤儿(会话早没了、缓存还在)
|
|
263
|
+
* 5) 测试工作区登记
|
|
264
|
+
*/
|
|
265
|
+
export function sweepTestResidue({ dryRun = false, tmpRoot = tmpdir(), minOrphanAgeMs = 60_000 } = {}) {
|
|
266
|
+
const sessions = purgeTestSessions({ dryRun });
|
|
267
|
+
const headerCwds = new Map(sessions.victims.map((v) => [v.id, v.cwd]));
|
|
268
|
+
// 被扫荡会话的 per-run 数据 + 状态目录里其它有证据的测试条目(会话目录早没了的那种)。
|
|
269
|
+
const project = purgeProjectState({ ids: sessions.victims.map((v) => v.id), headerCwds, dryRun });
|
|
270
|
+
const stale = sweepStateDir({ state: defaultState(), dryRun });
|
|
271
|
+
for (const k of Object.keys(project.removed)) project.removed[k] += stale.removed[k];
|
|
272
|
+
project.refused.push(...stale.refused);
|
|
273
|
+
project.errors.push(...stale.errors);
|
|
274
|
+
const legacy = sweepLegacyTmpState({ dir: tmpRoot, dryRun });
|
|
275
|
+
const orphans = purgeOrphanProjcache({ dryRun, minAgeMs: minOrphanAgeMs });
|
|
276
|
+
const workspaces = pruneTestWorkspaces({ dryRun });
|
|
277
|
+
return { sessions, project, legacy, orphans, workspaces };
|
|
278
|
+
}
|
package/src/sentinel.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 哨兵 / 审核记录这类"落盘副本"的卫生:写盘前的脱敏,以及过期清理。
|
|
3
|
+
*
|
|
4
|
+
* 从 hub.mjs 拆出来:两件事都只处理 plain object / 目录,不依赖 Hub 的运行期状态。
|
|
5
|
+
* 红线不变:思考内容默认不落盘(见 hub.mjs 文件头说明)。hub.mjs 仍然重新导出
|
|
6
|
+
* `sanitizeResultForSentinel`,外部的导入路径不变。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { SENTINEL_INCLUDE_REASONING } from './config.mjs';
|
|
12
|
+
import { sanitizeApprovalContext } from './redact.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 哨兵文件里的 result 是否要剥离思考内容。
|
|
16
|
+
* 默认剥离以维持"思考不落盘"的保证,并留下 thinking_omitted 标记,
|
|
17
|
+
* 免得调用方以为字段是意外丢失的。
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeResultForSentinel(result) {
|
|
20
|
+
if (!result || typeof result !== 'object') return result ?? null;
|
|
21
|
+
const { thinking, ...rest } = result;
|
|
22
|
+
// ★ 即使 DSH_MCP_SENTINEL_INCLUDE_REASONING=1,也只额外保留 thinking;
|
|
23
|
+
// answer/error/title/tools_used 仍然必须脱敏(以前这个开关会跳过整段清洗)。
|
|
24
|
+
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
25
|
+
for (const key of ['answer', 'error', 'title']) {
|
|
26
|
+
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
27
|
+
}
|
|
28
|
+
// 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
|
|
29
|
+
// 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
|
|
30
|
+
if (Array.isArray(rest.tools_used)) {
|
|
31
|
+
rest.tools_used = rest.tools_used.map((t) => {
|
|
32
|
+
if (!t || typeof t !== 'object') return t;
|
|
33
|
+
const { input, ...meta } = t;
|
|
34
|
+
const safeMeta = sanitizeApprovalContext(meta);
|
|
35
|
+
return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// 自动放行审批摘要记录时已脱敏;落盘前再过一遍,防止将来有人加字段漏掉。
|
|
39
|
+
if (Array.isArray(rest.auto_approvals)) {
|
|
40
|
+
rest.auto_approvals = rest.auto_approvals.map((a) => (a && typeof a === 'object' ? sanitizeApprovalContext(a) : a));
|
|
41
|
+
}
|
|
42
|
+
// 审核单里可能有原任务预览、审核理由与证据摘要;哨兵会保留 7 天,同样要脱敏。
|
|
43
|
+
if (rest.review && typeof rest.review === 'object') {
|
|
44
|
+
const rv = { ...rest.review };
|
|
45
|
+
for (const key of ['prompt_preview', 'notes']) {
|
|
46
|
+
if (typeof rv[key] === 'string') rv[key] = sanitizeApprovalContext(rv[key], key);
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(rv.checklist)) rv.checklist = rv.checklist.map((x) => sanitizeApprovalContext(x));
|
|
49
|
+
if (Array.isArray(rv.findings)) rv.findings = rv.findings.map((x) => sanitizeApprovalContext(x));
|
|
50
|
+
if (rv.evidence) rv.evidence = sanitizeApprovalContext(rv.evidence);
|
|
51
|
+
rest.review = rv;
|
|
52
|
+
}
|
|
53
|
+
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
54
|
+
const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
|
|
55
|
+
if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 清掉 `dir` 下超过 TTL 的文件与空会话目录;`.tmp` 残留不论年龄都按同一 cutoff 清。
|
|
61
|
+
* 只遍历一层会话目录,绝不递归进别处 —— 调用方(哨兵/审核目录)都是本服务自己的。
|
|
62
|
+
*/
|
|
63
|
+
export function pruneDirTree(dir, ttlMs) {
|
|
64
|
+
if (!ttlMs || !existsSync(dir)) return { pruned: 0, dirs_removed: 0 };
|
|
65
|
+
let pruned = 0;
|
|
66
|
+
let dirsRemoved = 0;
|
|
67
|
+
const cutoff = Date.now() - ttlMs;
|
|
68
|
+
try {
|
|
69
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
if (!entry.isDirectory()) continue;
|
|
71
|
+
const sub = join(dir, entry.name);
|
|
72
|
+
for (const f of readdirSync(sub, { withFileTypes: true })) {
|
|
73
|
+
if (!f.isFile()) continue;
|
|
74
|
+
const fp = join(sub, f.name);
|
|
75
|
+
try {
|
|
76
|
+
if (statSync(fp).mtimeMs < cutoff) {
|
|
77
|
+
rmSync(fp, { force: true });
|
|
78
|
+
pruned++;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* 单个文件失败就跳过 */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
if (readdirSync(sub).length === 0) {
|
|
86
|
+
rmSync(sub, { recursive: true, force: true });
|
|
87
|
+
dirsRemoved++;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
/* 目录清理失败无所谓 */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* 目录读不了就跳过 */
|
|
95
|
+
}
|
|
96
|
+
return { pruned, dirs_removed: dirsRemoved };
|
|
97
|
+
}
|