agent-purge 0.2.9 → 0.2.11
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/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -21
- package/README.md +176 -118
- package/backends/index.mjs +204 -244
- package/bench/bench-armor.mjs +844 -832
- package/bench/capture-hot.mjs +58 -0
- package/bench/capture-provider.mjs +66 -0
- package/bench/capture-report.mjs +15 -0
- package/bench/capture-server-hot.mjs +55 -0
- package/bench/capture-system.mjs +36 -0
- package/bench/judge-armor.mjs +119 -119
- package/bin/agent-purge.mjs +906 -772
- package/docs/LEGAL.md +27 -0
- package/docs/architecture.md +39 -0
- package/docs/compatibility.md +78 -0
- package/docs/images/console-desktop.png +0 -0
- package/docs/images/console-mobile.png +0 -0
- package/docs/releasing.md +87 -0
- package/docs/usage.md +158 -0
- package/lib/activation.mjs +38 -0
- package/lib/apply.mjs +227 -0
- package/lib/armed.mjs +38 -38
- package/lib/armor.mjs +225 -222
- package/lib/caps.mjs +182 -173
- package/lib/capture.mjs +324 -0
- package/lib/clean.mjs +124 -0
- package/lib/control.mjs +60 -0
- package/lib/detect.mjs +74 -70
- package/lib/doctor.mjs +7 -7
- package/lib/dsh.mjs +495 -453
- package/lib/environment.mjs +36 -0
- package/lib/evidence.mjs +41 -0
- package/lib/extension.mjs +140 -0
- package/lib/footprints.mjs +59 -0
- package/lib/home.mjs +47 -47
- package/lib/launch-conflicts.mjs +24 -0
- package/lib/launch.mjs +57 -0
- package/lib/opencode.mjs +62 -0
- package/lib/paths.mjs +25 -0
- package/lib/persist.mjs +439 -280
- package/lib/personas.mjs +17 -7
- package/lib/private-server.mjs +54 -0
- package/lib/project.mjs +134 -0
- package/lib/restore.mjs +131 -114
- package/lib/rewrite.mjs +124 -0
- package/lib/root.mjs +37 -0
- package/lib/shim.mjs +203 -160
- package/lib/slashes.mjs +227 -628
- package/lib/spawn.mjs +182 -126
- package/lib/state.mjs +85 -137
- package/lib/templates/personas/neutral.md +30 -30
- package/lib/update.mjs +137 -0
- package/lib/verification.mjs +23 -0
- package/lib/window.mjs +58 -0
- package/lib/wrappers.mjs +49 -0
- package/magpie-plugin/README.md +44 -0
- package/matrix.json +325 -341
- package/package.json +30 -4
- package/web/app.js +525 -0
- package/web/assets/agent-purge.ico +0 -0
- package/web/assets/claude.ico +0 -0
- package/web/assets/codebuddy.png +0 -0
- package/web/assets/codex.webp +0 -0
- package/web/assets/dsh.ico +0 -0
- package/web/assets/grok.ico +0 -0
- package/web/assets/hermes.ico +0 -0
- package/web/assets/omp.png +0 -0
- package/web/assets/opencode.ico +0 -0
- package/web/assets/opencode2.ico +0 -0
- package/web/assets/pi.png +0 -0
- package/web/assets/zcode.png +0 -0
- package/web/model.mjs +49 -0
- package/web/server.mjs +309 -194
- package/web/styles.css +917 -0
- package/web/ui.html +64 -1163
package/lib/capture.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// 证据链:跑真二进制 → 本机假 provider → 看它到底把什么发给了模型。
|
|
2
|
+
// 这里不信任 harness 的自述、也不信 matrix.json 的常量表,只认抓到的请求体。
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawnBin, resolveBin } from "./spawn.mjs";
|
|
10
|
+
import { isolatedEnv } from "./environment.mjs";
|
|
11
|
+
import { isCompiled } from "./root.mjs";
|
|
12
|
+
export { isolatedEnv } from "./environment.mjs";
|
|
13
|
+
import { inspectRequest, requestKind, startCaptureProvider } from "../bench/capture-provider.mjs";
|
|
14
|
+
import { writeVerification } from "../bench/capture-report.mjs";
|
|
15
|
+
|
|
16
|
+
const CLI = resolve(dirname(fileURLToPath(import.meta.url)), "..", "bin", "agent-purge.mjs");
|
|
17
|
+
|
|
18
|
+
// 隔离 HOME 是临时脚手架:证据落在 out/,临时树不能越攒越多。
|
|
19
|
+
const ISOLATED_ROOTS = [];
|
|
20
|
+
export function trackRoot(root) {
|
|
21
|
+
ISOLATED_ROOTS.push(root);
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cleanupRoots() {
|
|
26
|
+
if (process.env.AP_KEEP_ROOTS || process.argv.includes("--keep-roots")) return;
|
|
27
|
+
for (const root of ISOLATED_ROOTS.splice(0)) {
|
|
28
|
+
// 不用 rm -rf:Windows 没有;node 自己的重试删除两边都可靠
|
|
29
|
+
try {
|
|
30
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
31
|
+
} catch {
|
|
32
|
+
/* 临时目录删不掉不影响证据 */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function agentCommand(args) { return { cmd: process.execPath, args: isCompiled() ? args : [CLI, ...args] }; }
|
|
38
|
+
|
|
39
|
+
export function captureOutcome(result, requests, mode) {
|
|
40
|
+
const detail = String(result.error || result.stderr || result.stdout || '').trim().slice(-1800);
|
|
41
|
+
if (result.timedOut) return { status: "error", reason: `执行器超时${detail ? `:${detail}` : ""}` };
|
|
42
|
+
if (result.error || result.code !== 0) return { status: "error", reason: `执行器未正常结束(退出码 ${result.code ?? "未知"})${detail ? `:${detail}` : ""}` };
|
|
43
|
+
if (!requests.length) return { status: "no-request", reason: "执行器已退出,但没有捕获到主请求;不能据此判断人设是否生效。" };
|
|
44
|
+
const passed = requests.every(request => mode === "off" ? request.markerCount === 0 : request.markerCount === 1);
|
|
45
|
+
return { status: passed ? "captured" : "mismatch", reason: passed ? null : "已捕获主请求,但人设次数与测试模式不符。" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function prepareCapture(harness, bin, env, cwd, out, timeout) {
|
|
49
|
+
if (harness !== "dsh") return;
|
|
50
|
+
// Single-mode verification used to fail in a fresh HOME: only the first
|
|
51
|
+
// `off` run in the four-mode benchmark happened to initialize this profile.
|
|
52
|
+
const result = await runProcess(bin, ["--profile", "headless", "--dump-config"], { env, cwd, timeout });
|
|
53
|
+
if (out) put(join(out, harness, "prepare.process.json"), result);
|
|
54
|
+
if (result.error || result.timedOut || result.code !== 0)
|
|
55
|
+
throw new Error("无法初始化隔离 DSH headless profile:" + (result.error || result.stderr || `exit ${result.code}`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function runProcess(cmd, args, { env, cwd, timeout = 35000, input } = {}) {
|
|
59
|
+
return new Promise((done) => {
|
|
60
|
+
let child;
|
|
61
|
+
try {
|
|
62
|
+
child = spawnBin(cmd, args, { env, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
done({ code: null, error: e.message, stdout: "", stderr: "" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let stdout = "",
|
|
68
|
+
stderr = "",
|
|
69
|
+
timedOut = false;
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timedOut = true;
|
|
72
|
+
if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
73
|
+
else child.kill("SIGKILL");
|
|
74
|
+
}, timeout);
|
|
75
|
+
child.stdout.on("data", (b) => (stdout = (stdout + b).slice(-100000)));
|
|
76
|
+
child.stderr.on("data", (b) => (stderr = (stderr + b).slice(-100000)));
|
|
77
|
+
child.on("error", (e) => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
done({ code: null, error: e.message, stdout, stderr });
|
|
80
|
+
});
|
|
81
|
+
child.on("close", (code) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
done({ code, timedOut, stdout, stderr });
|
|
84
|
+
});
|
|
85
|
+
child.stdin.end(input);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function put(file, content) {
|
|
90
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
91
|
+
writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content, null, 2));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function seed(env, harness, mode, body) {
|
|
95
|
+
const root = env.AGENT_PURGE_HOME;
|
|
96
|
+
put(join(root, "personas", "capture-a.md"), body);
|
|
97
|
+
put(join(root, "personas", "active.md"), body);
|
|
98
|
+
put(join(root, "state.json"), { v: 1, persona: "capture-a", default: { harness, mode }, armed: {}, piAuto: false, ompAuto: false });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function configure(h, env, url, cwd) {
|
|
102
|
+
// 常规代理变量也被同一个 loopback 服务器接住,避免子进程偷偷走真网络。
|
|
103
|
+
Object.assign(env, {
|
|
104
|
+
HTTP_PROXY: url,
|
|
105
|
+
HTTPS_PROXY: url,
|
|
106
|
+
ALL_PROXY: url,
|
|
107
|
+
NO_PROXY: "127.0.0.1,localhost",
|
|
108
|
+
ANTHROPIC_BASE_URL: url,
|
|
109
|
+
ANTHROPIC_API_KEY: "capture-fixture-key",
|
|
110
|
+
OPENAI_BASE_URL: url + "/v1",
|
|
111
|
+
OPENAI_API_KEY: "capture-fixture-key",
|
|
112
|
+
});
|
|
113
|
+
const model = {
|
|
114
|
+
id: "capture",
|
|
115
|
+
name: "Capture fixture",
|
|
116
|
+
reasoning: false,
|
|
117
|
+
input: ["text"],
|
|
118
|
+
contextWindow: 128000,
|
|
119
|
+
maxTokens: 2048,
|
|
120
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
121
|
+
};
|
|
122
|
+
const providers = { capture: { baseUrl: url + "/v1", api: "openai-completions", apiKey: "capture-fixture-key", models: [model] } };
|
|
123
|
+
if (h === "pi") {
|
|
124
|
+
put(join(env.PI_CODING_AGENT_DIR, "models.json"), { providers });
|
|
125
|
+
put(join(env.PI_CODING_AGENT_DIR, "settings.json"), { packages: [], mcp: { servers: {} } });
|
|
126
|
+
return ["--provider", "capture", "--model", "capture", "--no-mcp", "--no-skills", "--no-context-files", "--no-prompt-templates", "--no-session"];
|
|
127
|
+
}
|
|
128
|
+
if (h === "omp") {
|
|
129
|
+
env.PI_CODING_AGENT_DIR = join(env.OMP_HOME, "agent");
|
|
130
|
+
put(join(env.PI_CODING_AGENT_DIR, "models.yml"), JSON.stringify({ providers }));
|
|
131
|
+
return ["--provider", "capture", "--model", "capture", "--no-extensions", "--no-skills", "--no-rules", "--no-context-files", "--no-lsp", "--no-title", "--no-session"];
|
|
132
|
+
}
|
|
133
|
+
if (h === "claude") return ["--model", "claude-sonnet-4-5", "--max-turns", "1", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--no-session-persistence", "--setting-sources", ""];
|
|
134
|
+
if (h === "codex") {
|
|
135
|
+
put(
|
|
136
|
+
join(env.CODEX_HOME, "config.toml"),
|
|
137
|
+
`model = "gpt-5.4"\nmodel_provider = "capture"\n[model_providers.capture]\nname = "Capture"\nbase_url = "${url}/v1"\nwire_api = "responses"\nrequires_openai_auth = false\n`,
|
|
138
|
+
);
|
|
139
|
+
return ["--skip-git-repo-check", "--ephemeral"];
|
|
140
|
+
}
|
|
141
|
+
if (h === "opencode") {
|
|
142
|
+
put(join(cwd, "opencode.json"), {
|
|
143
|
+
model: "capture/capture",
|
|
144
|
+
small_model: "capture/capture",
|
|
145
|
+
provider: { capture: { npm: "@ai-sdk/openai-compatible", options: { baseURL: url + "/v1", apiKey: "capture-fixture-key" }, models: { capture: { name: "Capture", limit: { context: 128000, output: 2048 } } } } },
|
|
146
|
+
});
|
|
147
|
+
return ["--model", "capture/capture"];
|
|
148
|
+
}
|
|
149
|
+
if (h === "dsh") {
|
|
150
|
+
Object.assign(env, { DEEPSEEK_BASE_URL: url, DEEPSEEK_API_KEY: "capture-fixture-key", DSH_TELEMETRY_MODE: "DISABLED", DSH_TELEMETRY_OTLP_URL: url + "/telemetry" });
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
if (h === "codebuddy") {
|
|
154
|
+
Object.assign(env, {
|
|
155
|
+
CODEBUDDY_BASE_URL: url,
|
|
156
|
+
CODEBUDDY_API_KEY: "capture-fixture-key",
|
|
157
|
+
CODEBUDDY_MODEL: "capture",
|
|
158
|
+
CODEBUDDY_SMALL_FAST_MODEL: "capture",
|
|
159
|
+
CODEBUDDY_BIG_SLOW_MODEL: "capture",
|
|
160
|
+
});
|
|
161
|
+
return ["--model", "capture", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--no-session-persistence", "--setting-sources", ""];
|
|
162
|
+
}
|
|
163
|
+
if (h === "grok") {
|
|
164
|
+
Object.assign(env, { XAI_API_KEY: "capture-fixture-key", GROK_XAI_API_BASE_URL: url + "/v1", GROK_MODELS_BASE_URL: url + "/v1", GROK_DISABLE_AUTOUPDATER: "1" });
|
|
165
|
+
put(
|
|
166
|
+
join(env.GROK_HOME, "config.toml"),
|
|
167
|
+
`[model.capture]\nmodel = "capture"\nbase_url = "${url}/v1"\nenv_key = "XAI_API_KEY"\napi_backend = "chat_completions"\n[models]\ndefault = "capture"\n`,
|
|
168
|
+
);
|
|
169
|
+
return ["--model", "capture", "--max-turns", "1", "--disable-web-search", "--no-subagents"];
|
|
170
|
+
}
|
|
171
|
+
if (h === "opencode2") {
|
|
172
|
+
put(join(cwd, "opencode.json"), {
|
|
173
|
+
model: "capture/capture",
|
|
174
|
+
providers: { capture: { package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: url + "/v1", apiKey: "capture-fixture-key" }, models: { capture: { name: "Capture" } } } },
|
|
175
|
+
});
|
|
176
|
+
return ["--standalone", "--model", "capture/capture"];
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
/** 一次抓包:装好种子人设 → 跑一条真请求 → 分析请求体里的 marker 落在哪一层。 */
|
|
183
|
+
export async function captureMode(h, { out, mode = "replace", timeout = 45000 } = {}) {
|
|
184
|
+
const bin = resolveBin(h);
|
|
185
|
+
if (!bin) return { harness: h, mode, status: "missing", reason: "Executable not found" };
|
|
186
|
+
const root = trackRoot(mkdtempSync(join(tmpdir(), `ap-capture-${h}-`)));
|
|
187
|
+
const env = isolatedEnv(root);
|
|
188
|
+
const cwd = join(root, "work");
|
|
189
|
+
mkdirSync(cwd);
|
|
190
|
+
env.AGENT_PURGE_REAL_BIN = bin;
|
|
191
|
+
const version = await runProcess(bin, ["--version"], { env, cwd, timeout: 12000 });
|
|
192
|
+
const base = { harness: h, mode, executable: bin, version: version.stdout.trim(),
|
|
193
|
+
scope: { kind: "isolated-cli", profile: h === "dsh" ? "headless" : null, syntheticPersona: true, currentSession: false } };
|
|
194
|
+
if (h === "hermes") return { ...base, status: "blocked", reason: (version.stderr || version.stdout).trim() || "No isolated provider adapter" };
|
|
195
|
+
|
|
196
|
+
const provider = await startCaptureProvider(join(out ?? root, h, "requests"));
|
|
197
|
+
let flags;
|
|
198
|
+
try {
|
|
199
|
+
flags = configure(h, env, provider.url, cwd);
|
|
200
|
+
if (flags) await prepareCapture(h, bin, env, cwd, out, timeout);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
await provider.close();
|
|
203
|
+
return { ...base, status: "error", reason: e.message };
|
|
204
|
+
}
|
|
205
|
+
if (!flags) {
|
|
206
|
+
await provider.close();
|
|
207
|
+
return { ...base, status: "blocked", reason: "Provider/isolation adapter not yet verified for this executable" };
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const marker = `AP_CAPTURE_${h}_${mode}_${randomUUID()}`;
|
|
211
|
+
const body = `Capture-only neutral persona. Marker: ${marker}. Reply briefly.`;
|
|
212
|
+
seed(env, h, mode, body);
|
|
213
|
+
const start = provider.captures.length;
|
|
214
|
+
const command = agentCommand([h, "--ask", "Reply CAPTURE_OK.", "--", ...flags]);
|
|
215
|
+
const result = await runProcess(command.cmd, command.args, { env, cwd, timeout });
|
|
216
|
+
const processFile = join(out ?? root, h, mode + ".process.json");
|
|
217
|
+
put(processFile, result);
|
|
218
|
+
const analyses = provider.captures.slice(start).map((r) => ({ id: r.id, protocol: r.protocol, kind: requestKind(r.body), ...inspectRequest(r.body, marker) }));
|
|
219
|
+
// 未知/辅助请求仍是证据,但不能替代主回合。
|
|
220
|
+
const checks = analyses.filter((a) => a.kind === "main");
|
|
221
|
+
const hit = checks.find((a) => a.markerCount > 0);
|
|
222
|
+
return {
|
|
223
|
+
...base,
|
|
224
|
+
...captureOutcome(result, checks, mode),
|
|
225
|
+
processFile,
|
|
226
|
+
exitCode: result.code,
|
|
227
|
+
timedOut: result.timedOut,
|
|
228
|
+
marker,
|
|
229
|
+
// 注入层:inspectRequest 的 text/markerCount 只算特权角色(system/developer),
|
|
230
|
+
// 用户消息里的命中由 userMarkerCount 单独给,两个合起来才是「注到哪层」。
|
|
231
|
+
injected: { sys: Boolean(hit), user: checks.some(request => request.userMarkerCount > 0) },
|
|
232
|
+
chars: hit?.chars ?? 0,
|
|
233
|
+
head: (hit?.text ?? "").slice(0, 200),
|
|
234
|
+
requests: analyses.map(({ text: _text, ...a }) => a),
|
|
235
|
+
};
|
|
236
|
+
} finally {
|
|
237
|
+
await provider.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** verify:只抓「当前模式」一次,写 web 读的那份 verification/latest.json。
|
|
242
|
+
* 不写原始提示词文本(那是本地抓包目录的事):摘要只有角色、字数、marker 命中数。 */
|
|
243
|
+
export async function verifyHarnesses(harnesses, { root, timeout, mode = "replace" } = {}) {
|
|
244
|
+
const at = new Date().toISOString();
|
|
245
|
+
const out = join(root, "verification", at.replace(/[:.]/g, "-"));
|
|
246
|
+
const report = {
|
|
247
|
+
schema: 1,
|
|
248
|
+
at,
|
|
249
|
+
kind: "loopback-real-binary",
|
|
250
|
+
out,
|
|
251
|
+
limitations: ["只证明请求体里有人设:不代表模型行为质量", "loopback 假 provider 不是 OS 级网络沙箱"],
|
|
252
|
+
harnesses: [],
|
|
253
|
+
};
|
|
254
|
+
for (const h of harnesses) {
|
|
255
|
+
let r;
|
|
256
|
+
try {
|
|
257
|
+
r = await captureMode(h, { out, mode, timeout });
|
|
258
|
+
} catch (e) {
|
|
259
|
+
r = { harness: h, status: "error", reason: e.message };
|
|
260
|
+
}
|
|
261
|
+
report.harnesses.push({
|
|
262
|
+
harness: h,
|
|
263
|
+
executable: r.executable ?? null,
|
|
264
|
+
version: r.version ?? "",
|
|
265
|
+
scope: r.scope ?? null,
|
|
266
|
+
processFile: r.processFile ?? null,
|
|
267
|
+
status: r.status,
|
|
268
|
+
reason: r.reason ?? null,
|
|
269
|
+
modes: r.requests
|
|
270
|
+
? [{ mode: r.mode, status: r.status, exitCode: r.exitCode, timedOut: r.timedOut, marker: r.marker, injected: r.injected, chars: r.chars, requests: r.requests }]
|
|
271
|
+
: [],
|
|
272
|
+
hotSwitch: { status: "not-tested" },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
writeVerification(join(root, "verification", "latest.json"), report);
|
|
276
|
+
return report;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function captureHarness(h, out) {
|
|
280
|
+
const bin = resolveBin(h);
|
|
281
|
+
if (!bin) return { harness: h, status: "unavailable", reason: "Executable not found" };
|
|
282
|
+
const root = trackRoot(mkdtempSync(join(tmpdir(), `ap-capture-${h}-`)));
|
|
283
|
+
const env = isolatedEnv(root);
|
|
284
|
+
const cwd = join(root, "work");
|
|
285
|
+
mkdirSync(cwd);
|
|
286
|
+
env.AGENT_PURGE_REAL_BIN = bin;
|
|
287
|
+
const version = await runProcess(bin, ["--version"], { env, cwd, timeout: 12000 });
|
|
288
|
+
put(join(out, h, "version.json"), version);
|
|
289
|
+
const report = { harness: h, executable: bin, version: version.stdout.trim(), isolation: root,
|
|
290
|
+
scope: { kind: "isolated-cli", profile: h === "dsh" ? "headless" : null, syntheticPersona: true, currentSession: false },
|
|
291
|
+
modes: [], hotSwitch: { status: "not-tested", reason: "Startup capture does not prove same-session switching" } };
|
|
292
|
+
if (h === "hermes") {
|
|
293
|
+
report.status = "blocked";
|
|
294
|
+
report.reason = (version.stderr || version.stdout).trim() || "No isolated provider adapter";
|
|
295
|
+
return report;
|
|
296
|
+
}
|
|
297
|
+
const provider = await startCaptureProvider(join(out, h, "requests"));
|
|
298
|
+
const flags = configure(h, env, provider.url, cwd);
|
|
299
|
+
if (!flags) {
|
|
300
|
+
await provider.close();
|
|
301
|
+
return { ...report, status: "blocked", reason: "Provider/isolation adapter not yet verified for this executable" };
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await prepareCapture(h, bin, env, cwd, out, 35000);
|
|
305
|
+
for (const mode of ["off", "strict", "replace", "append"]) {
|
|
306
|
+
const marker = `AP_CAPTURE_${h}_${mode}_${randomUUID()}`;
|
|
307
|
+
const body = `Capture-only neutral persona. Marker: ${marker}. Reply briefly.`;
|
|
308
|
+
seed(env, h, mode, body);
|
|
309
|
+
const start = provider.captures.length;
|
|
310
|
+
const command = agentCommand([h, "--ask", "Reply CAPTURE_OK.", "--", ...flags]);
|
|
311
|
+
const result = await runProcess(command.cmd, command.args, { env, cwd });
|
|
312
|
+
put(join(out, h, mode + ".process.json"), result);
|
|
313
|
+
const requests = provider.captures.slice(start);
|
|
314
|
+
const analyses = requests.map((r) => ({ id: r.id, protocol: r.protocol, kind: requestKind(r.body), ...inspectRequest(r.body, marker) }));
|
|
315
|
+
const checks = analyses.filter((a) => a.kind === "main");
|
|
316
|
+
report.modes.push({ mode, ...captureOutcome(result, checks, mode), exitCode: result.code, timedOut: result.timedOut, marker, requests: analyses.map(({ text: _text, ...a }) => a) });
|
|
317
|
+
console.log(`${h} ${mode}: ${report.modes.at(-1).status} (${checks.length} request, exit ${result.code})`);
|
|
318
|
+
}
|
|
319
|
+
report.status = report.modes.every((r) => r.status === "captured") ? "captured" : "partial";
|
|
320
|
+
return report;
|
|
321
|
+
} finally {
|
|
322
|
+
await provider.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
package/lib/clean.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 撤销先发布全局暂停,再按登记逐项还原;失败保留原位置的账本与 stash 供重试。
|
|
2
|
+
import { existsSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { basename, resolve, sep } from "node:path";
|
|
5
|
+
import { changeChannel } from "./apply.mjs";
|
|
6
|
+
import { withControlLock } from "./control.mjs";
|
|
7
|
+
import { cancel, listLedger } from "./footprints.mjs";
|
|
8
|
+
import { removeOwnedFile, revertFile, uninstall as uninstallPersist } from "./persist.mjs";
|
|
9
|
+
import { removeUserPath, shimDir } from "./shim.mjs";
|
|
10
|
+
import { loadState, patchState } from "./state.mjs";
|
|
11
|
+
|
|
12
|
+
export function clean(root, { all = false, dryRun = false } = {}) {
|
|
13
|
+
// 必须在创建锁/写暂停状态之前拒绝危险的数据根;不能等撤销一半才发现。
|
|
14
|
+
const blocked = all && refuseReason(resolve(root));
|
|
15
|
+
if (blocked) return { ok: false, steps: [{ what: "data-home", ok: false, detail: blocked }], leftovers: [blocked] };
|
|
16
|
+
if (dryRun) return runClean(root, { all, dryRun });
|
|
17
|
+
return withControlLock(root, () => runClean(root, { all, dryRun }));
|
|
18
|
+
}
|
|
19
|
+
function runClean(root, { all, dryRun }) {
|
|
20
|
+
const steps = [], home = resolve(root);
|
|
21
|
+
const add = (what, work) => {
|
|
22
|
+
try {
|
|
23
|
+
const detail = work();
|
|
24
|
+
if (detail?.error || detail?.ok === false || detail?.status === "failed") throw new Error(detail.error ?? JSON.stringify(detail));
|
|
25
|
+
steps.push({ what, ok: true, detail });
|
|
26
|
+
return true;
|
|
27
|
+
} catch (error) { steps.push({ what, ok: false, detail: error.message }); return false; }
|
|
28
|
+
};
|
|
29
|
+
let state;
|
|
30
|
+
if (!add("pause", () => {
|
|
31
|
+
state = loadState(root).state;
|
|
32
|
+
if (dryRun) return "(dry-run:会先暂停并撤销会话启动凭据)";
|
|
33
|
+
patchState(root, { default: { harness: state.default?.harness ?? null, mode: "off" },
|
|
34
|
+
resumeMode: state.default?.mode && state.default.mode !== "off" ? state.default.mode : state.resumeMode ?? "replace" });
|
|
35
|
+
return "已暂停;旧启动凭据不再恢复";
|
|
36
|
+
})) return { ok: false, steps, leftovers: ["配置未成功暂停,未开始撤销"] };
|
|
37
|
+
|
|
38
|
+
for (const [harness, channels] of Object.entries(state.armed ?? {})) {
|
|
39
|
+
for (const channel of Array.isArray(channels) ? channels : []) {
|
|
40
|
+
add(`${harness}/${channel}`, () => dryRun ? "(dry-run:会撤销)" : changeChannel(root, { harness, channel, enabled: false }));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let entries = [];
|
|
44
|
+
add("read-ledger", () => { entries = listLedger(root); return `${entries.length} 条恢复记录`; });
|
|
45
|
+
const customDone = new Set();
|
|
46
|
+
for (const entry of [...entries].reverse()) {
|
|
47
|
+
if (dryRun) { steps.push({ what: `ledger:${entry.undo.type}`, ok: true, detail: `(dry-run:会撤销 ${entry.path})` }); continue; }
|
|
48
|
+
if (entry.undo.type === "custom" && customDone.has(entry.harness)) continue;
|
|
49
|
+
add(`ledger:${entry.undo.type}`, () => {
|
|
50
|
+
let detail;
|
|
51
|
+
if (entry.undo.type === "custom") {
|
|
52
|
+
customDone.add(entry.harness);
|
|
53
|
+
detail = uninstallPersist(entry.harness, { root });
|
|
54
|
+
} else detail = reverse(entry, root);
|
|
55
|
+
if (detail?.error || detail?.ok === false) throw new Error(detail.error ?? JSON.stringify(detail));
|
|
56
|
+
cancel(root, { id: entry.id, kind: entry.kind, path: entry.path, harness: entry.harness, channel: entry.channel });
|
|
57
|
+
return detail;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// 目录内允许共存用户文件;只收空目录,不递归删除未知文件。
|
|
61
|
+
if (existsSync(shimDir())) add("shim-dir", () => {
|
|
62
|
+
if (dryRun) return "(dry-run:仅收空目录,保留外来文件)";
|
|
63
|
+
if (readdirSync(shimDir()).length) return "保留目录中未登记的文件";
|
|
64
|
+
rmdirSync(shimDir());
|
|
65
|
+
return "removed-empty-directory";
|
|
66
|
+
});
|
|
67
|
+
if (!dryRun && steps.every(s => s.ok)) add("reset-selection", () => patchState(root, { armed: {}, pausedInstalls: [], projectDirs: {} }));
|
|
68
|
+
let pending = leftovers(root);
|
|
69
|
+
if (all) add("data-home", () => {
|
|
70
|
+
const blocked = refuseReason(home);
|
|
71
|
+
if (blocked) throw new Error(blocked);
|
|
72
|
+
if (dryRun) return `(dry-run:确认全部还原后删除 ${home})`;
|
|
73
|
+
if (pending.length || steps.some(s => !s.ok)) throw new Error(`保留原数据目录供重试:${home};${pending.join(";")}`);
|
|
74
|
+
if (!existsSync(home)) return "already-gone";
|
|
75
|
+
const trash = `${home}.trash-${Date.now()}`;
|
|
76
|
+
renameSync(home, trash);
|
|
77
|
+
try { rmSync(trash, { recursive: true, force: true }); }
|
|
78
|
+
catch (error) {
|
|
79
|
+
try { if (!existsSync(home)) renameSync(trash, home); } catch {}
|
|
80
|
+
throw new Error(`删除失败,恢复资料保留于 ${existsSync(home) ? home : trash}:${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
return home;
|
|
83
|
+
});
|
|
84
|
+
pending = leftovers(root);
|
|
85
|
+
return { ok: steps.every(s => s.ok) && (dryRun || !pending.length), steps, leftovers: pending };
|
|
86
|
+
}
|
|
87
|
+
function refuseReason(home) {
|
|
88
|
+
const canonical = file => {
|
|
89
|
+
let full = resolve(file);
|
|
90
|
+
try { full = realpathSync(full); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
91
|
+
return process.platform === "win32" ? full.toLowerCase() : full;
|
|
92
|
+
};
|
|
93
|
+
const h = canonical(home), cwd = canonical(process.cwd());
|
|
94
|
+
const contains = file => file === h || file.startsWith(h.endsWith(sep) ? h : h + sep);
|
|
95
|
+
if (contains(cwd)) return `当前工作目录位于数据目录中,请手工处理:${home}`;
|
|
96
|
+
const protectedDirs = [process.env.USERPROFILE || process.env.HOME || homedir(), homedir(), tmpdir()];
|
|
97
|
+
if (protectedDirs.some(file => contains(canonical(file))) || !basename(h) || /^[A-Za-z]:[\\/]?$/.test(h) || h === sep)
|
|
98
|
+
return `数据目录指向用户主目录、临时根或其上级,请手工处理:${home}`;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function reverse(entry, root) {
|
|
102
|
+
if (entry.undo.type === "file-delete") {
|
|
103
|
+
const result = removeOwnedFile(entry.path, { root, expectedSha: entry.sha256 });
|
|
104
|
+
if (result.action === "skipped-foreign") throw new Error(`保留外来文件与恢复记录:${entry.path}`);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
if (entry.undo.type === "block-revert") return revertFile(entry.path, { root });
|
|
108
|
+
if (entry.undo.type === "path-env") return removeUserPath(entry.path);
|
|
109
|
+
throw new Error(`未知撤销方式,保留账本:${entry.undo.type}`);
|
|
110
|
+
}
|
|
111
|
+
function leftovers(root) {
|
|
112
|
+
if (!existsSync(root)) return [];
|
|
113
|
+
const out = [];
|
|
114
|
+
try {
|
|
115
|
+
const pending = listLedger(root);
|
|
116
|
+
if (pending.length) out.push(`${pending.length} 条账本记录未撤销(${pending.map(e => e.path).join(", ")})`);
|
|
117
|
+
} catch (error) { out.push(error.message); }
|
|
118
|
+
try {
|
|
119
|
+
const state = loadState(root).state;
|
|
120
|
+
const armed = Object.entries(state.armed ?? {}).filter(([, channels]) => channels?.length);
|
|
121
|
+
if (armed.length) out.push(`state 仍记着 armed:${armed.map(([h, cs]) => `${h}=${cs.join("+")}`).join(", ")}`);
|
|
122
|
+
} catch (error) { out.push(error.message); }
|
|
123
|
+
return out;
|
|
124
|
+
}
|
package/lib/control.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 短时同步本地事务;同进程内部调用复用持有的锁,不重复获取。
|
|
2
|
+
import * as controlFs from "node:fs";
|
|
3
|
+
import { resolve as resolveControlRoot } from "node:path";
|
|
4
|
+
import { randomUUID as controlUUID } from "node:crypto";
|
|
5
|
+
const controlHeld = new Set();
|
|
6
|
+
|
|
7
|
+
export function withControlLock(root, work) {
|
|
8
|
+
const key = resolveControlRoot(root);
|
|
9
|
+
if (controlHeld.has(key)) return work();
|
|
10
|
+
controlFs.mkdirSync(key, { recursive: true });
|
|
11
|
+
const file = key + "/.apply.lock";
|
|
12
|
+
let fd;
|
|
13
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
14
|
+
try { fd = controlFs.openSync(file, "wx", 0o600); break; }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code !== "EEXIST") throw error;
|
|
17
|
+
let owner, bytes;
|
|
18
|
+
try { bytes = controlFs.readFileSync(file, "utf8"); owner = JSON.parse(bytes); } catch {}
|
|
19
|
+
let dead = false;
|
|
20
|
+
if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
|
|
21
|
+
try { process.kill(owner.pid, 0); } catch (e) { dead = e.code === "ESRCH"; }
|
|
22
|
+
}
|
|
23
|
+
if (attempt === 0 && dead && bytes === controlFs.readFileSync(file, "utf8")) { controlFs.unlinkSync(file); continue; }
|
|
24
|
+
throw new Error(`另一个配置操作尚未结束(PID ${owner?.pid ?? "unknown"}):${file}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (fd === undefined) throw new Error(`配置锁获取失败:${file}`);
|
|
28
|
+
const token = controlUUID();
|
|
29
|
+
try {
|
|
30
|
+
controlFs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token, at: new Date().toISOString() }));
|
|
31
|
+
controlFs.fsyncSync(fd);
|
|
32
|
+
// Windows 的打开句柄会阻止数据目录改名;锁的所有权由 wx 文件与 token 保持。
|
|
33
|
+
controlFs.closeSync(fd);
|
|
34
|
+
fd = undefined;
|
|
35
|
+
controlHeld.add(key);
|
|
36
|
+
const result = work();
|
|
37
|
+
if (result && typeof result.then === "function") throw new Error("配置事务必须是同步的本地文件操作");
|
|
38
|
+
return result;
|
|
39
|
+
} finally {
|
|
40
|
+
controlHeld.delete(key);
|
|
41
|
+
if (fd !== undefined) controlFs.closeSync(fd);
|
|
42
|
+
try { if (JSON.parse(controlFs.readFileSync(file, "utf8")).token === token) controlFs.unlinkSync(file); } catch {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function atomicControlJSON(file, value) {
|
|
47
|
+
const tmp = file + ".tmp-" + controlUUID();
|
|
48
|
+
try {
|
|
49
|
+
controlFs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
50
|
+
controlFs.renameSync(tmp, file);
|
|
51
|
+
} finally { controlFs.rmSync(tmp, { force: true }); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const controlRuntime = `import * as controlFs from "node:fs";
|
|
55
|
+
import { resolve as resolveControlRoot } from "node:path";
|
|
56
|
+
import { randomUUID as controlUUID } from "node:crypto";
|
|
57
|
+
const controlHeld = new Set();
|
|
58
|
+
${withControlLock.toString()}
|
|
59
|
+
${atomicControlJSON.toString()}
|
|
60
|
+
`;
|
package/lib/detect.mjs
CHANGED
|
@@ -1,70 +1,74 @@
|
|
|
1
|
-
// 自动探测:这台机器上装了哪些 harness、装在哪、配置目录是哪个。
|
|
2
|
-
// 只读文件系统与 PATH,不执行 harness(版本号要 --versions 才去 spawn)。
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { captureBin, which } from "./spawn.mjs";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return raw.startsWith(".") || raw.startsWith("AppData")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
1
|
+
// 自动探测:这台机器上装了哪些 harness、装在哪、配置目录是哪个。
|
|
2
|
+
// 只读文件系统与 PATH,不执行 harness(版本号要 --versions 才去 spawn)。
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import { captureBin, which } from "./spawn.mjs";
|
|
6
|
+
import { configHome } from "./paths.mjs";
|
|
7
|
+
|
|
8
|
+
const home = process.env.USERPROFILE || process.env.HOME;
|
|
9
|
+
const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
10
|
+
const win = process.platform === "win32";
|
|
11
|
+
|
|
12
|
+
// 每家的"长什么样":可执行文件、配置根(含环境变量覆盖)、版本命令
|
|
13
|
+
export const specs = {
|
|
14
|
+
codex: { bins: ["codex"], homes: [["CODEX_HOME", ".codex"]], version: ["--version"], marker: "config.toml" },
|
|
15
|
+
codebuddy: { bins: ["codebuddy"], homes: [["CODEBUDDY_HOME", ".codebuddy"]], version: ["--version"], marker: "history.jsonl" },
|
|
16
|
+
claude: { bins: ["claude"], homes: [["CLAUDE_CONFIG_DIR", ".claude"]], version: ["--version"], marker: "settings.json" },
|
|
17
|
+
opencode: { bins: ["opencode"], homes: [["XDG_CONFIG_HOME", ".config/opencode"]], version: ["--version"], marker: "opencode.json" },
|
|
18
|
+
opencode2: { bins: ["opencode2"], homes: [["XDG_CONFIG_HOME", ".config/opencode"]], version: ["--version"], marker: "opencode.json" },
|
|
19
|
+
dsh: { bins: ["dsh"], homes: [["DSH_HOME", ".dsh"]], version: ["--version"], marker: "profiles" },
|
|
20
|
+
grok: { bins: ["grok"], homes: [["GROK_HOME", ".grok"]], version: ["--version"], marker: "config.toml" },
|
|
21
|
+
omp: { bins: ["omp"], homes: [["OMP_HOME", ".omp"]], version: ["--version"], marker: "agent/config.yml" },
|
|
22
|
+
hermes: { bins: ["hermes"], homes: [["HERMES_HOME", "AppData/Local/hermes"]], version: ["--version"], marker: "hermes-agent" },
|
|
23
|
+
pi: { bins: ["pi"], homes: [["PI_HOME", ".pi/agent"]], version: ["--version"], marker: "extensions" },
|
|
24
|
+
zcode: {
|
|
25
|
+
bins: ["zcode"],
|
|
26
|
+
homes: [["ZCODE_HOME", ".zcode"]],
|
|
27
|
+
version: ["--version"],
|
|
28
|
+
marker: "v2/provider_config.json",
|
|
29
|
+
extra: ["Programs/zcode", "..\\zcode"],
|
|
30
|
+
},
|
|
31
|
+
antigravity: { bins: ["antigravity"], homes: [["", "Programs/antigravity"]], version: null, marker: "resources/bin/language_server.exe" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function detect({ versions = false } = {}) {
|
|
35
|
+
const path = process.env.PATH || "";
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [id, spec] of Object.entries(specs)) {
|
|
38
|
+
const bin = which(spec.bins, path);
|
|
39
|
+
const roots = configHome(id) ? [configHome(id)] : spec.homes.map(([env, rel]) => {
|
|
40
|
+
const raw = env && process.env[env] ? process.env[env] : rel;
|
|
41
|
+
// 显式环境变量可能是任意平台的绝对路径:不能靠 ":" 判断(POSIX 路径没有冒号)
|
|
42
|
+
return raw.startsWith(".") || raw.startsWith("AppData")
|
|
43
|
+
? join(home, raw)
|
|
44
|
+
: isAbsolute(raw)
|
|
45
|
+
? raw
|
|
46
|
+
: join(local, raw);
|
|
47
|
+
});
|
|
48
|
+
const found = roots.map((r) => ({ path: r, exists: existsSync(join(r, spec.marker)) })).filter((r) => r.exists);
|
|
49
|
+
out[id] = {
|
|
50
|
+
id,
|
|
51
|
+
bin,
|
|
52
|
+
version: versions && bin && spec.version ? probe(bin, spec.version) : null,
|
|
53
|
+
home: found[0]?.path ?? roots[0],
|
|
54
|
+
homeExists: found.length > 0,
|
|
55
|
+
found: Boolean(bin || found.length),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function probe(bin, args) {
|
|
62
|
+
// 走 spawn 桥接:.cmd/.ps1 shim 才能启动(execFileSync 直接打 .cmd 在 Windows 上必失败)
|
|
63
|
+
const out = captureBin(bin, args);
|
|
64
|
+
return out ? out.trim().split("\n")[0].slice(0, 60) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 已安装的常驻配置(由 persist 层告知,这里只做展示用的存在性判断)
|
|
68
|
+
export function readJsonSafe(file) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|