agent-purge 0.2.10 → 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 +169 -138
- 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 -780
- 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/shim.mjs
CHANGED
|
@@ -1,160 +1,203 @@
|
|
|
1
|
-
// 裸命令接管:在你的 PATH 前面放一层同名 shim,敲 `codex` 等于敲 `agent-purge codex`。
|
|
2
|
-
//
|
|
3
|
-
// 关键点:shim 会把「真身路径」写进 AGENT_PURGE_REAL_BIN,spawn 时优先用它,
|
|
4
|
-
// 否则 resolver 会在 PATH 里先找到 shim 自己 → 无限套娃。
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!existsSync(DIR)) return
|
|
110
|
-
const
|
|
111
|
-
for (const f of readdirSync(DIR)) {
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
1
|
+
// 裸命令接管:在你的 PATH 前面放一层同名 shim,敲 `codex` 等于敲 `agent-purge codex`。
|
|
2
|
+
//
|
|
3
|
+
// 关键点:shim 会把「真身路径」写进 AGENT_PURGE_REAL_BIN,spawn 时优先用它,
|
|
4
|
+
// 否则 resolver 会在 PATH 里先找到 shim 自己 → 无限套娃。
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { ownShimDirectory } from "./environment.mjs";
|
|
10
|
+
import { looksOwned, removeOwnedFile } from "./persist.mjs";
|
|
11
|
+
import { record } from "./footprints.mjs";
|
|
12
|
+
|
|
13
|
+
const WIN = process.platform === "win32";
|
|
14
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const DIR = ownShimDirectory();
|
|
16
|
+
// 与 spawn 的 SPAWN_EXTS 保持同一优先级:.ps1 走 powershell 数组传参,
|
|
17
|
+
// 不会被 cmd.exe 做 %VAR% 展开;.cmd 只作兜底。
|
|
18
|
+
const EXTS = WIN ? [".exe", ".ps1", ".cmd", ".bat"] : [""];
|
|
19
|
+
|
|
20
|
+
export function shimDir() {
|
|
21
|
+
return DIR;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 本工具自己的调用前缀:bun 编译版直接用自身 exe,普通 node 版本带上脚本路径 */
|
|
25
|
+
function selfPrefix() {
|
|
26
|
+
if (process.versions.bun) return { cmd: process.execPath, args: [] };
|
|
27
|
+
return { cmd: process.execPath, args: [join(ROOT, "bin", "agent-purge.mjs")] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** PATH 里找同名可执行文件,跳过我们自己的 shim 目录 */
|
|
31
|
+
export function resolveReal(id) {
|
|
32
|
+
const dirs = (process.env.PATH ?? "").split(WIN ? ";" : ":").filter(Boolean);
|
|
33
|
+
for (const d of dirs) {
|
|
34
|
+
if (resolve(d) === resolve(DIR)) continue;
|
|
35
|
+
for (const ext of EXTS) {
|
|
36
|
+
const p = join(d, id + ext);
|
|
37
|
+
if (existsSync(p)) return p;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function installShim(root, id) {
|
|
44
|
+
const real = resolveReal(id);
|
|
45
|
+
if (!real) return { error: `PATH 里找不到真正的 ${id}(agent-purge doctor 看探测结果)` };
|
|
46
|
+
mkdirSync(DIR, { recursive: true });
|
|
47
|
+
for (const name of WIN ? [`${id}.cmd`, `${id}.ps1`] : [id]) {
|
|
48
|
+
const file = join(DIR, name);
|
|
49
|
+
if (existsSync(file) && !looksOwned(readFileSync(file, "utf8"))) return { error: `同名文件不是托管 shim:${file}` };
|
|
50
|
+
}
|
|
51
|
+
const self = selfPrefix();
|
|
52
|
+
const files = [];
|
|
53
|
+
// 拼接时不能折叠空格:node.exe 常在 "C:\Program Files\..." 下
|
|
54
|
+
const callLine = [`"${self.cmd}"`, ...self.args.map((a) => `"${a}"`), id].join(" ");
|
|
55
|
+
|
|
56
|
+
if (WIN) {
|
|
57
|
+
const cmdFile = join(DIR, `${id}.cmd`);
|
|
58
|
+
writeFileSync(
|
|
59
|
+
cmdFile,
|
|
60
|
+
[
|
|
61
|
+
"@echo off",
|
|
62
|
+
"rem agent-purge shim — 删掉本文件即取消接管",
|
|
63
|
+
`set "AGENT_PURGE_REAL_BIN=${real}"`,
|
|
64
|
+
`${callLine} %*`,
|
|
65
|
+
"",
|
|
66
|
+
].join("\r\n"),
|
|
67
|
+
"utf8",
|
|
68
|
+
);
|
|
69
|
+
files.push(cmdFile);
|
|
70
|
+
|
|
71
|
+
const psFile = join(DIR, `${id}.ps1`);
|
|
72
|
+
writeFileSync(
|
|
73
|
+
psFile,
|
|
74
|
+
[
|
|
75
|
+
"# agent-purge shim — 删掉本文件即取消接管",
|
|
76
|
+
`$env:AGENT_PURGE_REAL_BIN = '${real.replace(/'/g, "''")}'`,
|
|
77
|
+
`& '${self.cmd}' ${self.args.map((a) => `'${a}'`).join(" ")} ${id} @args`,
|
|
78
|
+
"exit $LASTEXITCODE",
|
|
79
|
+
"",
|
|
80
|
+
].join("\r\n"),
|
|
81
|
+
"utf8",
|
|
82
|
+
);
|
|
83
|
+
files.push(psFile);
|
|
84
|
+
} else {
|
|
85
|
+
const sh = join(DIR, id);
|
|
86
|
+
writeFileSync(
|
|
87
|
+
sh,
|
|
88
|
+
[
|
|
89
|
+
"#!/bin/sh",
|
|
90
|
+
"# agent-purge shim — 删掉本文件即取消接管",
|
|
91
|
+
`AGENT_PURGE_REAL_BIN='${real.replace(/'/g, "'\\''")}' exec '${self.cmd}' ${self.args.map((a) => `'${a}'`).join(" ")} ${id} "$@"`,
|
|
92
|
+
"",
|
|
93
|
+
].join("\n"),
|
|
94
|
+
{ encoding: "utf8", mode: 0o755 },
|
|
95
|
+
);
|
|
96
|
+
files.push(sh);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 账本才是卸载的依据:按文件名扫 bin/ 扫不到改过名的孤儿
|
|
100
|
+
for (const f of files) record(root, { kind: "file", harness: id, channel: "shim", path: f, undo: { type: "file-delete" } });
|
|
101
|
+
record(root, { kind: "path", harness: id, channel: "shim", path: DIR, undo: { type: "path-env" } });
|
|
102
|
+
// Web 的 shim 开关也必须把目录放进 PATH,否则文件写了但裸命令仍走原程序。
|
|
103
|
+
const path = ensureUserPath();
|
|
104
|
+
return { file: files[0], files, real, path };
|
|
105
|
+
}
|
|
106
|
+
export function uninstallShim(root, id) {
|
|
107
|
+
// 没有 shim 不等于失败:state 里可能记着一条从来没落地的 armed(换了机器、装了又手工删了)。
|
|
108
|
+
// 这里报 error 会让 clean 永远「有残留」,就是那种清不掉的 codebuddy:shim。
|
|
109
|
+
if (!existsSync(DIR)) return { action: "already-gone", detail: `shim 目录不存在: ${DIR}` };
|
|
110
|
+
const removed = [];
|
|
111
|
+
for (const f of readdirSync(DIR)) {
|
|
112
|
+
const base = f.replace(/\.(cmd|ps1|bat|exe)$/i, "");
|
|
113
|
+
if (base === id) {
|
|
114
|
+
const file = join(DIR, f);
|
|
115
|
+
const r = removeOwnedFile(file, { root });
|
|
116
|
+
if (r.action === "skipped-foreign") return { error: `保留外来文件:${file}`, removed };
|
|
117
|
+
removed.push(file);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!removed.length) return { action: "already-gone", detail: `没有 ${id} 的 shim` };
|
|
121
|
+
return { file: removed[0], removed, action: "deleted-file" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function shimStatus() {
|
|
125
|
+
if (!existsSync(DIR)) return [];
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
for (const f of readdirSync(DIR)) {
|
|
128
|
+
let owned = false;
|
|
129
|
+
try { owned = looksOwned(readFileSync(join(DIR, f), "utf8")); } catch {}
|
|
130
|
+
if (!owned) continue;
|
|
131
|
+
const id = f.replace(/\.(cmd|ps1|bat|exe)$/i, "");
|
|
132
|
+
if (!seen.has(id)) seen.set(id, { id, installed: true, dir: DIR, files: [] });
|
|
133
|
+
seen.get(id).files.push(join(DIR, f));
|
|
134
|
+
}
|
|
135
|
+
return [...seen.values()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 只读:shim 目录是否已在用户 PATH(且在最前)。供 status 用,不写注册表。 */
|
|
139
|
+
export function pathState() {
|
|
140
|
+
if (!WIN || process.env.AGENT_PURGE_ISOLATED === "1") {
|
|
141
|
+
const parts = (process.env.PATH ?? "").split(WIN ? ";" : ":");
|
|
142
|
+
return { dir: DIR, present: parts.includes(DIR), first: parts[0] === DIR };
|
|
143
|
+
}
|
|
144
|
+
const read = spawnSync("powershell", ["-NoProfile", "-Command", "[Environment]::GetEnvironmentVariable('Path','User')"], { encoding: "utf8" });
|
|
145
|
+
const parts = (read.stdout ?? "").trim().split(";").filter(Boolean);
|
|
146
|
+
const norm = (s) => resolve(s.trim().replace(/\\{2,}/g, "\\")).toLowerCase();
|
|
147
|
+
const at = parts.findIndex((p) => norm(p) === norm(DIR));
|
|
148
|
+
return { dir: DIR, present: at >= 0, first: at === 0 && parts[0].trim() === DIR };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 把 shim 目录放到用户 PATH 最前面(幂等)。Windows 走 [Environment]::SetEnvironmentVariable,
|
|
153
|
+
* 不用 setx(setx 在 >1024 字符时会截断)。
|
|
154
|
+
*/
|
|
155
|
+
export function ensureUserPath() {
|
|
156
|
+
if (!WIN || process.env.AGENT_PURGE_ISOLATED === "1") return { changed: false, dir: DIR, how: `把 ${DIR} 加到 shell rc 的 PATH 最前:export PATH="${DIR}:$PATH"`, first: false };
|
|
157
|
+
const read = spawnSync("powershell", ["-NoProfile", "-Command", "[Environment]::GetEnvironmentVariable('Path','User')"], { encoding: "utf8" });
|
|
158
|
+
const current = (read.stdout ?? "").trim();
|
|
159
|
+
const parts = current.split(";").filter(Boolean);
|
|
160
|
+
// 合并连续反斜杠:上一版曾把 "C:\\Users\\..." 这种双反斜杠写进 PATH,
|
|
161
|
+
// 归一后才能认出它是同一条并顺手清掉
|
|
162
|
+
const norm = (s) => resolve(s.trim().replace(/\\{2,}/g, "\\"));
|
|
163
|
+
const at = parts.findIndex((p) => norm(p).toLowerCase() === norm(DIR).toLowerCase());
|
|
164
|
+
// 位置对还不够:得确认存的就是干净路径(曾被写成 "C:\\\\Users\\\\...",位置对但 where 找不到)
|
|
165
|
+
const clean = at === 0 && parts[0].trim() === DIR;
|
|
166
|
+
if (clean) return { changed: false, dir: DIR, first: true, how: "已在最前" };
|
|
167
|
+
|
|
168
|
+
const next = [DIR, ...parts.filter((p) => norm(p).toLowerCase() !== norm(DIR).toLowerCase())];
|
|
169
|
+
// PowerShell 单引号是字面量:用 '' 包住,内部单引号翻倍。绝不能走 JSON.stringify
|
|
170
|
+
// (JSON 会把 \\ 写成 \\\\,写进 PATH 就变成带双反斜杠的废路径)
|
|
171
|
+
const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
172
|
+
const write = spawnSync(
|
|
173
|
+
"powershell",
|
|
174
|
+
["-NoProfile", "-Command", `[Environment]::SetEnvironmentVariable('Path', ${psQuote(next.join(";"))}, 'User')`],
|
|
175
|
+
{ encoding: "utf8" },
|
|
176
|
+
);
|
|
177
|
+
if (write.status !== 0) return { changed: false, dir: DIR, first: false, how: `写入用户 PATH 失败:${(write.stderr ?? "").trim()}` };
|
|
178
|
+
return { changed: true, dir: DIR, first: true, how: at === 0 ? "已就地修正 PATH 里的脏路径" : "已写入用户 PATH(新开终端生效;disarm 不会自动移除)" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 把 ensureUserPath 加进去的那一段拿掉(只动自己那一条,别的条目原样保留) */
|
|
182
|
+
export function removeUserPath(dir = DIR) {
|
|
183
|
+
if (!WIN || process.env.AGENT_PURGE_ISOLATED === "1") {
|
|
184
|
+
const present = (process.env.PATH ?? "").split(WIN ? ";" : ":").some(p => resolve(p || ".").toLowerCase() === resolve(dir).toLowerCase());
|
|
185
|
+
return { ok: !present, changed: false, dir, how: present ? `从 shell rc 移除 ${dir}` : "PATH 中已无该目录" };
|
|
186
|
+
}
|
|
187
|
+
const read = spawnSync("powershell", ["-NoProfile", "-Command", "[Environment]::GetEnvironmentVariable('Path','User')"], { encoding: "utf8" });
|
|
188
|
+
if (read.status !== 0) return { ok: false, changed: false, dir, how: `读取用户 PATH 失败:${read.stderr ?? ""}` };
|
|
189
|
+
const parts = (read.stdout ?? "").trim().split(";").filter(Boolean);
|
|
190
|
+
const norm = (s) => resolve(s.trim().replace(/\\{2,}/g, "\\")).toLowerCase();
|
|
191
|
+
const left = parts.filter((p) => norm(p) !== norm(dir));
|
|
192
|
+
if (left.length === parts.length) return { ok: true, changed: false, dir, how: "不在用户 PATH 里" };
|
|
193
|
+
// 整条 PATH 只有我们这一条时不要写空值:那是把用户的 PATH 清空,不是撤销
|
|
194
|
+
if (!left.length) return { ok: false, changed: false, dir, how: "用户 PATH 只剩这一条,请手工确认清空" };
|
|
195
|
+
const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
196
|
+
const write = spawnSync(
|
|
197
|
+
"powershell",
|
|
198
|
+
["-NoProfile", "-Command", `[Environment]::SetEnvironmentVariable('Path', ${psQuote(left.join(";"))}, 'User')`],
|
|
199
|
+
{ encoding: "utf8" },
|
|
200
|
+
);
|
|
201
|
+
if (write.status !== 0) return { ok: false, changed: false, dir, how: `写入用户 PATH 失败:${(write.stderr ?? "").trim()}` };
|
|
202
|
+
return { ok: true, changed: true, dir, how: "已从用户 PATH 移除(新开终端生效)" };
|
|
203
|
+
}
|