agent-purge 0.2.10 → 0.2.12
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 +956 -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/persona-import.mjs +351 -0
- 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 +590 -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 +330 -191
- package/web/styles.css +1072 -0
- package/web/ui.html +76 -1163
package/web/server.mjs
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
// 零依赖 Web 服务:配置台。选默认 harness/mode、管理 persona、常驻安装、探测。
|
|
2
2
|
// 刻意不做"运行":交互式 harness 属于终端/桌面,Web 只负责选(见 ARCHITECTURE.md)。
|
|
3
|
-
import { createServer } from "node:http";
|
|
4
|
-
import {
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { readFileSync, existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
6
8
|
import { detect } from "../lib/detect.mjs";
|
|
7
9
|
import { oneshots } from "../backends/index.mjs";
|
|
8
|
-
import { createStore } from "../lib/personas.mjs";
|
|
9
|
-
import { MODES, armedOf, dshProfilesOf, loadState,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
10
|
+
import { createStore, isValidId } from "../lib/personas.mjs";
|
|
11
|
+
import { MODES, ARMS, armedOf, dshProfilesOf, loadState, resolveFor, resolveActivation, setPiAuto, setOmpAuto, setDshProfile } from "../lib/state.mjs";
|
|
12
|
+
import { withControlLock } from "../lib/control.mjs";
|
|
13
|
+
import { status as persistStatus } from "../lib/persist.mjs";
|
|
14
|
+
import { projectStatus, projectDirectories } from "../lib/project.mjs";
|
|
15
|
+
import { plans } from "../lib/armor.mjs";
|
|
16
|
+
import { shimStatus } from "../lib/shim.mjs";
|
|
17
|
+
import { slashStatus } from "../lib/slashes.mjs";
|
|
14
18
|
import { reconcileArmed } from "../lib/armed.mjs";
|
|
15
19
|
import { capsFor } from "../lib/caps.mjs";
|
|
16
|
-
import { ensureDataHome } from "../lib/home.mjs";
|
|
20
|
+
import { dataHome, ensureDataHome } from "../lib/home.mjs";
|
|
17
21
|
import { DSH_PROFILES } from "../lib/dsh.mjs";
|
|
22
|
+
import { applyConfiguration, changeChannel, configureHarness, disableHarness } from "../lib/apply.mjs";
|
|
23
|
+
import { ROOT, isCompiled, readResource } from "../lib/root.mjs";
|
|
24
|
+
import { currentVersion } from "../lib/update.mjs";
|
|
25
|
+
import { verificationEvidence } from "../lib/evidence.mjs";
|
|
18
26
|
|
|
19
|
-
const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
27
|
// 数据(state.json + personas/)在用户目录,不在安装目录
|
|
21
|
-
const DATA =
|
|
28
|
+
const DATA = dataHome();
|
|
22
29
|
// 运行期真正被改写的层(≠ bench 能测到的层):每家只有它自己暴露的那一个写入点。
|
|
23
30
|
// sys = 系统提示/配置文件/插件 splice;user = 只落在用户消息里(zcode 的 hook)。
|
|
24
31
|
const LAYER = {
|
|
@@ -27,15 +34,25 @@ const LAYER = {
|
|
|
27
34
|
// zcode 只有文档说它会读 AGENTS.md,桌面版闭源、无法复核,层就不claim(UI 显示“—”+「文档」)
|
|
28
35
|
zcode: "",
|
|
29
36
|
};
|
|
30
|
-
const matrix =
|
|
31
|
-
|
|
37
|
+
const matrix = (() => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readResource("matrix.json").toString("utf8"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`无法加载执行器矩阵 matrix.json:${error.message}`, { cause: error });
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
let store = null;
|
|
45
|
+
// 同一次实测会拉起真 harness 子进程:同一家同时只能跑一份。
|
|
46
|
+
const verifying = new Set();
|
|
32
47
|
|
|
33
48
|
export function serveWeb(port = 7788) {
|
|
49
|
+
ensureDataHome(isCompiled() ? null : ROOT);
|
|
50
|
+
store ??= createStore(DATA);
|
|
34
51
|
// handle 是 async:任何异常都在这里收口,否则请求会挂着不响应
|
|
35
52
|
const server = createServer((req, res) => {
|
|
36
53
|
handle(req, res).catch((e) => {
|
|
37
54
|
try {
|
|
38
|
-
send(res, 500, { error:
|
|
55
|
+
send(res, e.statusCode ?? (/另一个配置操作|锁/.test(e.message) ? 409 : 500), { error: e.message });
|
|
39
56
|
} catch {}
|
|
40
57
|
});
|
|
41
58
|
});
|
|
@@ -50,11 +67,13 @@ export function serveWeb(port = 7788) {
|
|
|
50
67
|
process.exit(1);
|
|
51
68
|
});
|
|
52
69
|
server.listen(port, "127.0.0.1", () => {
|
|
53
|
-
console.log(`agent-purge web → http://127.0.0.1:${port}`);
|
|
70
|
+
console.log(`agent-purge web → http://127.0.0.1:${server.address().port}`);
|
|
54
71
|
});
|
|
72
|
+
return server;
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
async function handle(req, res) {
|
|
76
|
+
if (!localHost(req)) return send(res, 403, { error: "只接受本机 Host" });
|
|
58
77
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
59
78
|
const p = url.pathname;
|
|
60
79
|
|
|
@@ -63,18 +82,119 @@ async function handle(req, res) {
|
|
|
63
82
|
if (isMutating(req.method) && !sameOrigin(req)) return send(res, 403, { error: "只接受本机同源请求" });
|
|
64
83
|
|
|
65
84
|
if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
|
|
85
|
+
const assets = { '/app.js': 'text/javascript', '/model.mjs': 'text/javascript', '/evidence.mjs': 'text/javascript', '/styles.css': 'text/css' };
|
|
86
|
+
if (req.method === "GET" && Object.hasOwn(assets, p)) {
|
|
87
|
+
res.writeHead(200, { 'content-type': `${assets[p]}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
88
|
+
return res.end(readResource(p === '/evidence.mjs' ? 'lib/evidence.mjs' : `web/${p.slice(1)}`));
|
|
89
|
+
}
|
|
90
|
+
// web/assets/ 下的真实品牌图标(png/jpg/webp/ico),固定白名单后缀 + 只认单层文件名,不走路径拼接。
|
|
91
|
+
if (req.method === "GET" && p.startsWith("/assets/")) {
|
|
92
|
+
const name = p.slice("/assets/".length);
|
|
93
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon' };
|
|
94
|
+
const ext = name.slice(name.lastIndexOf('.'));
|
|
95
|
+
if (!name.includes('/') && !name.includes('..') && Object.hasOwn(mime, ext)) {
|
|
96
|
+
try {
|
|
97
|
+
// 图标会被换(官方图源改了/我改错了),一律 no-store:本地工具不值得为 24h 缓存换来“改了看不到”。
|
|
98
|
+
res.writeHead(200, { 'content-type': mime[ext], 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
99
|
+
return res.end(readResource(`web/assets/${name}`));
|
|
100
|
+
} catch { /* 落到 404 */ }
|
|
101
|
+
}
|
|
102
|
+
return send(res, 404, { error: "not found" });
|
|
103
|
+
}
|
|
104
|
+
if (req.method === "GET" && p === "/api/verification") {
|
|
105
|
+
const file = join(DATA, 'verification', 'latest.json');
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(file)) return send(res, 200, { report: null });
|
|
108
|
+
const report = JSON.parse(readFileSync(file, 'utf8'));
|
|
109
|
+
if (!report || !Array.isArray(report.results)) throw new Error('缺少 results 数组');
|
|
110
|
+
return send(res, 200, { report });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return send(res, 500, { error: `验证报告读取失败:${error.message}` });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
66
115
|
if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
|
|
67
116
|
if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
|
|
117
|
+
if (req.method === "POST" && p === "/api/apply") return send(res, 200, applyChanges(await body(req)));
|
|
118
|
+
if (req.method === "POST" && p === "/api/power") return send(res, 200, applyConfiguration(DATA, { enabled: booleanField(await body(req), "on") }));
|
|
119
|
+
if (req.method === "POST" && p === "/api/sync") { const payload = await body(req); return send(res, 200, applyConfiguration(DATA, { onlyFailed: booleanField(payload, "onlyFailed", false) })); }
|
|
68
120
|
if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
|
|
69
|
-
if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)
|
|
70
|
-
if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, (await body(req)
|
|
121
|
+
if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, booleanField(await body(req), "on")) });
|
|
122
|
+
if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, booleanField(await body(req), "on")) });
|
|
71
123
|
if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
|
|
72
124
|
if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
|
|
125
|
+
if (req.method === "POST" && p === "/api/persona/import") return send(res, 200, await handleImportPersonas(await body(req)));
|
|
73
126
|
if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
|
|
127
|
+
if (req.method === "POST" && p === "/api/harness") return send(res, 200, saveHarnessConfig(await body(req)));
|
|
74
128
|
if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
|
|
75
129
|
if (req.method === "POST" && p === "/api/install") return send(res, 200, applyInstall(await body(req)));
|
|
76
130
|
if (req.method === "POST" && p === "/api/arm") return send(res, 200, applyArm(await body(req)));
|
|
77
131
|
if (req.method === "POST" && p === "/api/arm-all") return send(res, 200, applyArmAll(await body(req)));
|
|
132
|
+
if (req.method === "POST" && p === "/api/clean") {
|
|
133
|
+
await body(req);
|
|
134
|
+
const { clean } = await import("../lib/clean.mjs");
|
|
135
|
+
const resClean = clean(DATA, { all: false, dryRun: false });
|
|
136
|
+
return send(res, 200, resClean);
|
|
137
|
+
}
|
|
138
|
+
if (req.method === "GET" && p === "/api/update") {
|
|
139
|
+
const { checkForUpdate, canReplaceSelf } = await import("../lib/update.mjs");
|
|
140
|
+
const force = url.searchParams.get("force") === "1";
|
|
141
|
+
const cacheFile = join(DATA, "update-cache.json");
|
|
142
|
+
const info = await checkForUpdate({ force, cacheFile });
|
|
143
|
+
return send(res, 200, {
|
|
144
|
+
...info,
|
|
145
|
+
isExe: canReplaceSelf(),
|
|
146
|
+
execPath: process.execPath,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (req.method === "POST" && p === "/api/update-upgrade") {
|
|
150
|
+
await body(req);
|
|
151
|
+
const { checkForUpdate, downloadFile, replaceSelfExecutable, canReplaceSelf } = await import("../lib/update.mjs");
|
|
152
|
+
const info = await checkForUpdate({ force: true });
|
|
153
|
+
if (!info.ok || !info.hasUpdate) {
|
|
154
|
+
return send(res, 400, { error: info.error || "暂无可用的新版本" });
|
|
155
|
+
}
|
|
156
|
+
const isExe = canReplaceSelf();
|
|
157
|
+
const targetExe = info.assets?.find((a) => a.name.toLowerCase() === "agent-purge.exe");
|
|
158
|
+
const targetSetup = info.assets?.find((a) => a.name.toLowerCase().endsWith("-setup.exe"));
|
|
159
|
+
|
|
160
|
+
const { tmpdir } = await import("node:os");
|
|
161
|
+
if (isExe && targetExe) {
|
|
162
|
+
const tempFile = join(tmpdir(), `agent-purge-download-${randomUUID()}.exe`);
|
|
163
|
+
try {
|
|
164
|
+
await downloadFile(targetExe.downloadUrl, tempFile, undefined, { expectedSize: targetExe.size, digest: targetExe.digest });
|
|
165
|
+
replaceSelfExecutable(tempFile);
|
|
166
|
+
} finally { rmSync(tempFile, { force: true }); }
|
|
167
|
+
return send(res, 200, { ok: true, type: "exe", version: info.latest, message: "便携版 exe 已就地升级成功!下次启动即为新版本。" });
|
|
168
|
+
} else if (process.platform === "win32" && targetSetup) {
|
|
169
|
+
const tempSetup = join(tmpdir(), `agent-purge-setup-${randomUUID()}.exe`);
|
|
170
|
+
await downloadFile(targetSetup.downloadUrl, tempSetup, undefined, { expectedSize: targetSetup.size, digest: targetSetup.digest });
|
|
171
|
+
const { spawn } = await import("node:child_process");
|
|
172
|
+
spawn(tempSetup, [], { detached: true, stdio: "ignore" }).unref();
|
|
173
|
+
return send(res, 200, { ok: true, type: "setup", version: info.latest, message: "安装程序已启动,请按界面向导完成安装。" });
|
|
174
|
+
}
|
|
175
|
+
return send(res, 400, { error: "未在 Release 中找到适配 Windows 的升级安装文件,请前往页面手动下载。" });
|
|
176
|
+
}
|
|
177
|
+
if (req.method === "POST" && p === "/api/verify") {
|
|
178
|
+
const { harness } = await body(req);
|
|
179
|
+
if (!Object.hasOwn(matrix.harnesses ?? {}, harness)) return send(res, 400, { error: `未知 harness:${harness}` });
|
|
180
|
+
if (verifying.has(harness)) return send(res, 409, { error: `${harness} 正在实测,等它跑完` });
|
|
181
|
+
verifying.add(harness);
|
|
182
|
+
const cap = await import("../lib/capture.mjs");
|
|
183
|
+
try {
|
|
184
|
+
const mode = resolveActivation(loadState(DATA).state, harness, { launch: true }).mode;
|
|
185
|
+
const report = await cap.verifyHarnesses([harness], { root: DATA, mode, timeout: 60000 });
|
|
186
|
+
const r = report.harnesses[0] ?? {};
|
|
187
|
+
const roles = [...new Set((r.modes ?? []).flatMap(m => (m.requests ?? []).filter(q => q.kind === "main").flatMap(q => q.roles ?? [])))];
|
|
188
|
+
return send(res, 200, { harness, status: r.status, reason: r.reason ?? null, version: r.version ?? "",
|
|
189
|
+
scope: r.scope, assessment: verificationEvidence(r), roles, processFile: r.processFile,
|
|
190
|
+
injected: r.modes?.[0]?.injected ?? null, chars: r.modes?.[0]?.chars ?? 0 });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return send(res, 500, { error: `实测失败:${error.message}` });
|
|
193
|
+
} finally {
|
|
194
|
+
verifying.delete(harness);
|
|
195
|
+
cap.cleanupRoots();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
78
198
|
send(res, 404, { error: "not found" });
|
|
79
199
|
}
|
|
80
200
|
|
|
@@ -108,17 +228,24 @@ async function state(withVersions) {
|
|
|
108
228
|
const { state: st } = loadState(DATA);
|
|
109
229
|
const shims = shimStatus();
|
|
110
230
|
const slashes = slashStatus(DATA);
|
|
231
|
+
const projects = new Map(projectStatus(process.cwd(), DATA).map(r => [r.id, r]));
|
|
111
232
|
const harnesses = Object.entries(matrix.harnesses).map(([id, m]) => {
|
|
112
233
|
const d = found[id] ?? {};
|
|
113
234
|
const inst = persisted.find((x) => x.name === id);
|
|
114
235
|
const armed = armedOf(st, id);
|
|
236
|
+
const eff = resolveFor(st, id);
|
|
237
|
+
const caps = capsFor(id, { bin: d.bin ?? null });
|
|
238
|
+
const activation = resolveActivation(st, id, { channel: armed.includes("install") ? "install" : "slash" });
|
|
239
|
+
let override = "no";
|
|
240
|
+
if (m.override === true) override = "yes";
|
|
241
|
+
else if (m.override) override = String(m.override);
|
|
115
242
|
return {
|
|
116
243
|
id,
|
|
117
244
|
class: m.class,
|
|
118
|
-
override
|
|
245
|
+
override,
|
|
119
246
|
inject: Boolean(m.inject),
|
|
120
247
|
hotSwitch: Boolean(m.hotSwitch),
|
|
121
|
-
chars: m.defaultSystemChars
|
|
248
|
+
chars: m.defaultSystemChars == null ? null : `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}`,
|
|
122
249
|
version: d.version ?? m.measuredVersion ?? null,
|
|
123
250
|
residue: m.residue ?? null,
|
|
124
251
|
reason: m.reason ?? null,
|
|
@@ -133,31 +260,53 @@ async function state(withVersions) {
|
|
|
133
260
|
installed: Boolean(inst?.installed),
|
|
134
261
|
persistFile: inst?.file ?? null,
|
|
135
262
|
armed,
|
|
263
|
+
// 这家实际生效的人设/模式 + 来源(override 还是跟随全局)
|
|
264
|
+
persona: eff.persona,
|
|
265
|
+
mode: eff.mode,
|
|
266
|
+
personaFrom: eff.personaFrom,
|
|
267
|
+
modeFrom: eff.modeFrom,
|
|
268
|
+
// 「本项目」按当前目录的选择显示;暂停撤块后仍可取消选择
|
|
269
|
+
projectOn: Boolean(projects.get(id)?.selected || projects.get(id)?.on),
|
|
270
|
+
projectFile: caps.project?.file ?? null,
|
|
271
|
+
projectPath: projects.get(id)?.file ?? null,
|
|
272
|
+
projectDirs: projectDirectories(DATA, id),
|
|
273
|
+
activation,
|
|
274
|
+
auto: id === "pi" ? st.piAuto === true : id === "omp" ? st.ompAuto === true : null,
|
|
275
|
+
profiles: id === "dsh" ? dshProfilesOf(st) ?? DSH_PROFILES : undefined,
|
|
136
276
|
shim: Boolean(shims.find((s) => s.id === id)?.installed),
|
|
137
277
|
slash: slashes.find((s) => s.id === id) ?? null,
|
|
138
278
|
// 每条通道「能不能用 + 为什么不能用」由 lib/caps.mjs 统一裁决:
|
|
139
279
|
// UI 只负责画,不再自己猜(以前 shim 硬编码可点,zcode 点了必然无效)
|
|
140
|
-
caps
|
|
280
|
+
caps,
|
|
141
281
|
};
|
|
142
282
|
});
|
|
143
283
|
const defaults = defaultsOf(st, harnesses);
|
|
144
284
|
return {
|
|
285
|
+
version: currentVersion(),
|
|
145
286
|
harnesses,
|
|
146
287
|
reconciled,
|
|
147
288
|
personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
|
|
148
289
|
active: store.activeId(),
|
|
149
290
|
persona: st.persona,
|
|
291
|
+
overrides: st.overrides ?? {},
|
|
150
292
|
defaults,
|
|
151
293
|
// false = state.json 里还没有默认(defaults 只是 UI 的建议值,裸敲 agent-purge 仍会提示“未设置”)
|
|
152
294
|
defaultsSet: Boolean(st.default?.harness),
|
|
153
295
|
armed: st.armed ?? {},
|
|
296
|
+
paused: st.default?.mode === "off",
|
|
297
|
+
prevMode: st.prevMode ?? 'replace',
|
|
298
|
+
pausedInstalls: st.pausedInstalls ?? [],
|
|
299
|
+
lastApply: st.lastApply ?? null,
|
|
154
300
|
// dsh 的三个 profile(desktop/web/headless);null 就是全开
|
|
155
301
|
dshProfiles: dshProfilesOf(st) ?? DSH_PROFILES,
|
|
156
302
|
dshAll: DSH_PROFILES,
|
|
303
|
+
// /purge off|mode 写进来的 profile 级运行时状态(暂停/覆盖模式),UI 要能看到,否则
|
|
304
|
+
// 「装了常驻却没生效」无从解释
|
|
305
|
+
dshRuntime: st.dshRuntime ?? {},
|
|
157
306
|
modes: MODES,
|
|
158
307
|
piAuto: st.piAuto === true,
|
|
159
308
|
ompAuto: st.ompAuto === true,
|
|
160
|
-
shimDir:
|
|
309
|
+
shimDir: shims[0]?.dir ?? null,
|
|
161
310
|
slashes: slashes,
|
|
162
311
|
};
|
|
163
312
|
}
|
|
@@ -178,34 +327,36 @@ function defaultsOf(st, harnesses) {
|
|
|
178
327
|
return { harness: pick?.id ?? null, mode };
|
|
179
328
|
}
|
|
180
329
|
|
|
181
|
-
function
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (mode && !MODES.includes(mode)) return { error: `mode 只能是 ${MODES.join(" / ")}` };
|
|
187
|
-
try {
|
|
188
|
-
return { ok: true, defaults: setDefault(DATA, { harness, mode: mode ?? "replace" }) };
|
|
189
|
-
} catch (e) {
|
|
190
|
-
return { error: e.message };
|
|
330
|
+
function applyChanges(payload) {
|
|
331
|
+
for (const key of Object.keys(payload)) if (!["harness", "persona", "mode", "resumeMode", "enabled"].includes(key)) throw httpError(400, `未知配置字段: ${key}`);
|
|
332
|
+
if (payload.harness !== undefined && payload.harness !== null) {
|
|
333
|
+
knownHarness(payload.harness);
|
|
334
|
+
if (matrix.harnesses[payload.harness].class === "unsupported") throw httpError(400, "默认执行器未开放启动入口");
|
|
191
335
|
}
|
|
336
|
+
if (payload.persona !== undefined && (typeof payload.persona !== "string" || !store.read(payload.persona))) throw httpError(400, "人设不存在或标识无效");
|
|
337
|
+
if (payload.mode !== undefined && !MODES.includes(payload.mode)) throw httpError(400, "模式无效");
|
|
338
|
+
if (payload.resumeMode !== undefined && !["strict", "replace", "append"].includes(payload.resumeMode)) throw httpError(400, "恢复模式无效");
|
|
339
|
+
if (payload.enabled !== undefined) booleanField(payload, "enabled");
|
|
340
|
+
return applyConfiguration(DATA, payload);
|
|
341
|
+
}
|
|
342
|
+
function saveDefaults(payload) {
|
|
343
|
+
const report = applyChanges(payload);
|
|
344
|
+
return { ...report, defaults: loadState(DATA).state.default };
|
|
192
345
|
}
|
|
193
346
|
|
|
194
347
|
function saveDshProfile(payload) {
|
|
195
|
-
const profile =
|
|
196
|
-
if (!DSH_PROFILES.includes(profile))
|
|
197
|
-
|
|
198
|
-
return { ok: true, dshProfiles: setDshProfile(DATA, profile, payload.on === true, DSH_PROFILES) };
|
|
199
|
-
} catch (e) {
|
|
200
|
-
return { error: e.message };
|
|
201
|
-
}
|
|
348
|
+
const profile = payload.profile;
|
|
349
|
+
if (!DSH_PROFILES.includes(profile)) throw httpError(400, `未知 profile: ${profile}`);
|
|
350
|
+
return { ok: true, dshProfiles: setDshProfile(DATA, profile, booleanField(payload, "on"), DSH_PROFILES) };
|
|
202
351
|
}
|
|
203
352
|
|
|
204
353
|
function savePersona(payload) {
|
|
205
|
-
const id =
|
|
206
|
-
if (!id)
|
|
207
|
-
if (!payload.body
|
|
208
|
-
|
|
354
|
+
const id = payload.id;
|
|
355
|
+
if (typeof id !== "string" || !isValidId(id) || id !== id.trim()) throw httpError(400, "人设标识无效");
|
|
356
|
+
if (typeof payload.body !== "string" || !payload.body.trim()) throw httpError(400, "正文应为非空文本");
|
|
357
|
+
if (payload.meta !== undefined && !plainObject(payload.meta)) throw httpError(400, "meta 应为对象");
|
|
358
|
+
if (payload.apply !== undefined) booleanField(payload, "apply");
|
|
359
|
+
return withControlLock(DATA, () => {
|
|
209
360
|
// 只覆盖表单带上来的键,其余 frontmatter(harness / order / description...)原样保留:
|
|
210
361
|
// 旧实现直接拿表单三个字段重写文件,点一次保存就把手写的键全吃掉
|
|
211
362
|
const meta = { ...(store.read(id)?.meta ?? {}) };
|
|
@@ -231,160 +382,142 @@ function savePersona(payload) {
|
|
|
231
382
|
if (list.length) meta.model = list;
|
|
232
383
|
else delete meta.model;
|
|
233
384
|
}
|
|
385
|
+
const current = store.activeId() === id;
|
|
386
|
+
const referenced = Object.values(loadState(DATA).state.overrides ?? {}).some(value => value?.persona === id);
|
|
234
387
|
const saved = store.save(id, { meta: { ...meta, id }, body: payload.body });
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
388
|
+
const applied = current || payload.apply === true ? applyConfiguration(DATA, { persona: id }) : referenced ? applyConfiguration(DATA) : null;
|
|
389
|
+
return { ok: applied?.ok ?? true, applied, persona: { id: saved.id, meta: saved.meta, body: saved.body } };
|
|
390
|
+
});
|
|
239
391
|
}
|
|
240
392
|
|
|
241
393
|
function deletePersona(id) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
394
|
+
return withControlLock(DATA, () => {
|
|
395
|
+
if (typeof id !== "string" || !isValidId(id)) throw httpError(400, "人设标识无效");
|
|
396
|
+
if (!store.read(id)) throw httpError(400, "找不到这个人设");
|
|
397
|
+
if (store.list().length <= 1) throw httpError(409, "至少保留一个人设");
|
|
398
|
+
const references = Object.entries(loadState(DATA).state.overrides ?? {}).filter(([, value]) => value?.persona === id).map(([harness]) => harness);
|
|
399
|
+
if (references.length) throw httpError(409, `人设仍被引用:${references.join("、")},请先调整分家选择`);
|
|
400
|
+
const current = store.activeId() === id;
|
|
401
|
+
if (!store.remove(id)) throw httpError(400, "人设标识无效");
|
|
402
|
+
const applied = current ? applyConfiguration(DATA, { persona: store.activeId() }) : null;
|
|
403
|
+
return { ok: applied?.ok ?? true, active: store.activeId(), applied };
|
|
404
|
+
});
|
|
247
405
|
}
|
|
248
406
|
|
|
249
407
|
function usePersona(payload) {
|
|
250
|
-
|
|
408
|
+
return { ...applyChanges({ persona: payload.id ?? "" }), active: store.activeId() };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function handleImportPersonas(payload) {
|
|
412
|
+
const source = String(payload?.source || "").trim();
|
|
413
|
+
if (!source) throw httpError(400, "导入源不能为空(请提供 GitHub 仓库如 ZiChuanLan/some-magic 或本地目录)");
|
|
414
|
+
const overwrite = payload?.overwrite !== false;
|
|
415
|
+
const { importPersonas } = await import("../lib/persona-import.mjs");
|
|
251
416
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
417
|
+
const result = await importPersonas(source, DATA, { overwrite });
|
|
418
|
+
let applied = null;
|
|
419
|
+
if (payload?.activateId && store.read(payload.activateId)) {
|
|
420
|
+
applied = applyConfiguration(DATA, { persona: payload.activateId });
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
result,
|
|
425
|
+
personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
|
|
426
|
+
persona: store.activeId(),
|
|
427
|
+
applied,
|
|
428
|
+
};
|
|
261
429
|
} catch (e) {
|
|
262
|
-
|
|
430
|
+
throw httpError(400, `拉取人设失败: ${e.message}`);
|
|
263
431
|
}
|
|
264
432
|
}
|
|
265
433
|
|
|
266
|
-
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const { state: cur } = loadState(DATA);
|
|
277
|
-
const m = cur.default?.mode ?? "off";
|
|
278
|
-
if (verb === "off" && m !== "off" && cur.default?.harness) {
|
|
279
|
-
setDefault(DATA, { harness: cur.default.harness, mode: "off" });
|
|
280
|
-
patchState(DATA, { prevMode: m });
|
|
281
|
-
}
|
|
282
|
-
if (verb === "on" && m === "off" && cur.default?.harness) {
|
|
283
|
-
const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
|
|
284
|
-
setDefault(DATA, { harness: cur.default.harness, mode: back });
|
|
434
|
+
// 按执行器覆盖人设/模式:值传 null / "" / "follow" = 恢复跟随全局。
|
|
435
|
+
function saveHarnessConfig(payload) {
|
|
436
|
+
const id = knownHarness(payload.harness);
|
|
437
|
+
const patch = {};
|
|
438
|
+
for (const key of Object.keys(payload)) if (!["harness", "persona", "mode"].includes(key)) throw httpError(400, `未知分家字段: ${key}`);
|
|
439
|
+
for (const key of ["persona", "mode"]) {
|
|
440
|
+
if (!Object.hasOwn(payload, key)) continue;
|
|
441
|
+
const value = payload[key];
|
|
442
|
+
if (value !== null && typeof value !== "string") throw httpError(400, `${key} 应为文本或 null`);
|
|
443
|
+
patch[key] = value === null || value === "" || value === "follow" ? null : value;
|
|
285
444
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
failed.push(`${h}: 做不到(${spec.reason})`);
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const r = applyArm({ harness: h, channel, action: verb, persona: payload.persona });
|
|
295
|
-
if (r.error) failed.push(`${h}: ${r.error}`);
|
|
296
|
-
else perHarness.push({ harness: h, done: (r.done ?? []).map((d) => d.channel), skipped: r.skipped ?? [] });
|
|
297
|
-
}
|
|
298
|
-
if (!perHarness.length) return { error: failed.join(";") || "没有可注册的 harness" };
|
|
299
|
-
const after = loadState(DATA).state.default;
|
|
300
|
-
return { ok: true, action: verb, channel, mode: after?.mode ?? null, harnesses: perHarness, failed };
|
|
445
|
+
if (!Object.keys(patch).length) throw httpError(400, "缺少分家配置字段");
|
|
446
|
+
if (patch.persona && !store.read(patch.persona)) throw httpError(400, "人设不存在");
|
|
447
|
+
if (patch.mode && !MODES.includes(patch.mode)) throw httpError(400, "模式无效");
|
|
448
|
+
const applied = configureHarness(DATA, id, patch);
|
|
449
|
+
return { ok: applied.ok, applied, overrides: loadState(DATA).state.overrides ?? {}, effective: applied.effective };
|
|
301
450
|
}
|
|
302
451
|
|
|
452
|
+
// 兼容旧客户端的全局开关:只暂停/恢复原范围,不再一键全量注册。
|
|
453
|
+
function applyArmAll(payload) {
|
|
454
|
+
return applyConfiguration(DATA, { enabled: verbOf(payload.action) === "on" });
|
|
455
|
+
}
|
|
303
456
|
function applyInstall(payload) {
|
|
304
|
-
|
|
305
|
-
if (action === "uninstall") {
|
|
306
|
-
const r = uninstall(harness);
|
|
307
|
-
setArmed(DATA, harness, "install", false);
|
|
308
|
-
return { ok: true, result: r };
|
|
309
|
-
}
|
|
310
|
-
// 装谁就把谁设为 active:active.md 与 install 指向始终是同一份正文
|
|
311
|
-
const wanted = payload.persona ?? store.activeId();
|
|
312
|
-
if (!store.read(wanted)) return { error: "没有可用 persona" };
|
|
313
|
-
store.setActive(wanted);
|
|
314
|
-
const r = install(harness, { root: DATA, active: store.refreshActive() });
|
|
315
|
-
if (r.error) return { error: r.error };
|
|
316
|
-
setArmed(DATA, harness, "install", true);
|
|
317
|
-
return { ok: true, result: r };
|
|
457
|
+
return changeChannel(DATA, { harness: knownHarness(payload.harness), channel: "install", enabled: verbOf(payload.action) === "on" });
|
|
318
458
|
}
|
|
319
|
-
|
|
320
|
-
/** UI 传 install/uninstall、CLI 传 on/off —— 统一成 on/off,否则 uninstall 会被当成安装 */
|
|
321
459
|
const VERB_OFF = new Set(["off", "uninstall", "remove", "stop", "false", false, 0]);
|
|
460
|
+
const VERB_ON = new Set(["on", "install", "add", "start", "true", true, 1]);
|
|
322
461
|
function verbOf(action) {
|
|
323
|
-
|
|
462
|
+
if (VERB_OFF.has(action)) return "off";
|
|
463
|
+
if (VERB_ON.has(action)) return "on";
|
|
464
|
+
throw httpError(400, "action 应明确指定 on 或 off");
|
|
324
465
|
}
|
|
325
|
-
|
|
326
|
-
/** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
|
|
327
466
|
function applyArm(payload) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function armChannel(harness, channel, verb, id) {
|
|
346
|
-
// 开之前先问能力表:这条通道在这家身上本来就无效的,不写任何文件、直接说明原因
|
|
347
|
-
if (verb === "on") {
|
|
348
|
-
const cap = capsFor(harness)[channel];
|
|
349
|
-
if (cap && !cap.ok) return { error: cap.why };
|
|
350
|
-
}
|
|
351
|
-
if (channel === "install") {
|
|
352
|
-
const r = verb === "off" ? applyInstall({ harness, action: "uninstall" }) : applyInstall({ harness, action: "install", persona: id });
|
|
353
|
-
return r.error ? { error: r.error } : (r.result ?? { ok: true });
|
|
354
|
-
}
|
|
355
|
-
if (channel === "shim") {
|
|
356
|
-
if (verb === "off") {
|
|
357
|
-
const r = uninstallShim(DATA, harness);
|
|
358
|
-
if (r.error) return { error: r.error };
|
|
359
|
-
setArmed(DATA, harness, "shim", false);
|
|
360
|
-
return { removed: r.removed };
|
|
361
|
-
}
|
|
362
|
-
const r = installShim(DATA, harness);
|
|
363
|
-
if (r.error) return { error: r.error };
|
|
364
|
-
setArmed(DATA, harness, "shim", true);
|
|
365
|
-
return { file: r.file, real: r.real };
|
|
366
|
-
}
|
|
367
|
-
if (channel === "slash") {
|
|
368
|
-
if (verb === "off") {
|
|
369
|
-
const r = uninstallSlash(harness, DATA);
|
|
370
|
-
if (r.error) return { error: r.error };
|
|
371
|
-
setArmed(DATA, harness, "slash", false);
|
|
372
|
-
return { removed: r.removed };
|
|
467
|
+
knownHarness(payload.harness);
|
|
468
|
+
if (payload.channel !== undefined && ![...ARMS, "auto", "all"].includes(payload.channel)) throw httpError(400, "通道无效");
|
|
469
|
+
const on = verbOf(payload.action) === "on";
|
|
470
|
+
return withControlLock(DATA, () => {
|
|
471
|
+
// auto:开关式语义。开 = 挑这个执行器最省心的可用通道;关 = 只撤它真正装着的那几条。
|
|
472
|
+
if (payload.channel === "auto") {
|
|
473
|
+
const { state: st } = loadState(DATA);
|
|
474
|
+
const current = st.armed?.[payload.harness] ?? [];
|
|
475
|
+
if (on) {
|
|
476
|
+
const caps = capsFor(payload.harness, { bin: detect()[payload.harness]?.bin ?? null });
|
|
477
|
+
const pick = ["install", "slash", "shim"].find(channel => caps[channel]?.ok);
|
|
478
|
+
if (!pick) return { error: "这个执行器暂时没有可用的接管方式,展开细节看原因。" };
|
|
479
|
+
// 已经是这个通道就什么都不做,开关是幂等的
|
|
480
|
+
if (current.includes(pick)) return { ok: true, results: [], channel: pick, done: [], skipped: [] };
|
|
481
|
+
const result = changeChannel(DATA, { harness: payload.harness, channel: pick, enabled: true });
|
|
482
|
+
return { ok: result.ok, results: [result], channel: pick, done: result.ok ? [result] : [], skipped: result.ok ? [] : [result.error] };
|
|
373
483
|
}
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
setArmed(DATA, harness, "slash", true);
|
|
377
|
-
return { file: r.file, layer: r.layer };
|
|
484
|
+
const result = disableHarness(DATA, payload.harness);
|
|
485
|
+
return { ...result, channel: null, done: result.results.filter(r => r.ok), skipped: result.results.filter(r => !r.ok).map(r => r.error) };
|
|
378
486
|
}
|
|
379
|
-
|
|
487
|
+
const channels = !payload.channel || payload.channel === "all" ? ["install", "shim", "slash"] : [payload.channel];
|
|
488
|
+
const results = channels.map(channel => changeChannel(DATA, { harness: payload.harness, channel, enabled: on, ...(channel === "project" ? { dir: process.cwd() } : {}) }));
|
|
489
|
+
return { ok: results.every(r => r.ok), results, done: results.filter(r => r.ok), skipped: results.filter(r => !r.ok).map(r => r.error) };
|
|
490
|
+
});
|
|
380
491
|
}
|
|
381
492
|
|
|
382
|
-
const send = (res, code, obj) =>
|
|
383
|
-
res.
|
|
493
|
+
const send = (res, code, obj) => {
|
|
494
|
+
if (res.destroyed || res.writableEnded) return;
|
|
495
|
+
res.writeHead(code === 200 && obj?.error ? 400 : code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }).end(JSON.stringify(obj));
|
|
496
|
+
};
|
|
497
|
+
const plainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
498
|
+
const httpError = (statusCode, message) => Object.assign(new Error(message), { statusCode });
|
|
499
|
+
function booleanField(payload, key, fallback) {
|
|
500
|
+
if (!Object.hasOwn(payload, key) && fallback !== undefined) return fallback;
|
|
501
|
+
if (typeof payload[key] !== "boolean") throw httpError(400, `${key} 应为布尔值`);
|
|
502
|
+
return payload[key];
|
|
503
|
+
}
|
|
504
|
+
function knownHarness(id) {
|
|
505
|
+
if (typeof id !== "string" || !Object.hasOwn(matrix.harnesses, id)) throw httpError(400, "未知执行器");
|
|
506
|
+
return id;
|
|
507
|
+
}
|
|
508
|
+
function localHost(req) {
|
|
509
|
+
const host = req.headers.host;
|
|
510
|
+
if (typeof host !== "string") return false;
|
|
511
|
+
try {
|
|
512
|
+
const url = new URL(`http://${host}`);
|
|
513
|
+
return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && !url.username && !url.password &&
|
|
514
|
+
url.host === host.toLowerCase() && Number(url.port || 80) === req.socket.localPort;
|
|
515
|
+
} catch { return false; }
|
|
516
|
+
}
|
|
384
517
|
const html = (res) =>
|
|
385
518
|
res
|
|
386
|
-
.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
|
|
387
|
-
.end(
|
|
519
|
+
.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" })
|
|
520
|
+
.end(readResource("web/ui.html").toString("utf8"));
|
|
388
521
|
|
|
389
522
|
function isMutating(method) {
|
|
390
523
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
@@ -400,43 +533,49 @@ function sameOrigin(req) {
|
|
|
400
533
|
try {
|
|
401
534
|
const u = new URL(origin);
|
|
402
535
|
const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1" || u.hostname === "[::1]";
|
|
403
|
-
return loopback && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
|
|
536
|
+
return loopback && u.protocol === "http:" && u.origin === origin && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
|
|
404
537
|
} catch {
|
|
405
538
|
return false;
|
|
406
539
|
}
|
|
407
540
|
}
|
|
408
541
|
|
|
409
542
|
function body(req) {
|
|
410
|
-
return new Promise((resolve) => {
|
|
411
|
-
|
|
412
|
-
let size = 0;
|
|
413
|
-
|
|
414
|
-
const finish = (value) => {
|
|
543
|
+
return new Promise((resolve, reject) => {
|
|
544
|
+
const chunks = [];
|
|
545
|
+
let size = 0, done = false;
|
|
546
|
+
const finish = (error, value) => {
|
|
415
547
|
if (done) return;
|
|
416
548
|
done = true;
|
|
417
|
-
|
|
549
|
+
chunks.length = 0;
|
|
550
|
+
if (error) reject(error); else resolve(value);
|
|
418
551
|
};
|
|
419
|
-
req.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
552
|
+
const contentType = req.headers["content-type"];
|
|
553
|
+
if (contentType && !/^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(contentType)) {
|
|
554
|
+
req.resume(); finish(httpError(415, "请求应为 JSON")); return;
|
|
555
|
+
}
|
|
556
|
+
req.on("data", chunk => {
|
|
557
|
+
if (done) return;
|
|
558
|
+
size += chunk.length;
|
|
559
|
+
if (size > 2 * 1024 * 1024) { finish(httpError(413, "请求超过 2 MiB")); return; }
|
|
560
|
+
chunks.push(chunk);
|
|
427
561
|
});
|
|
428
|
-
req.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
562
|
+
req.once("end", () => {
|
|
563
|
+
if (done) return;
|
|
564
|
+
let value;
|
|
565
|
+
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
566
|
+
catch { finish(httpError(400, "JSON 格式无效")); return; }
|
|
567
|
+
if (!plainObject(value)) { finish(httpError(400, "JSON 应为对象")); return; }
|
|
568
|
+
finish(null, value);
|
|
434
569
|
});
|
|
570
|
+
req.once("aborted", () => finish(httpError(400, "请求已中断")));
|
|
571
|
+
req.once("error", error => finish(httpError(400, `请求读取失败:${error.message}`)));
|
|
435
572
|
});
|
|
436
573
|
}
|
|
437
574
|
|
|
438
575
|
// 只有被直接当脚本跑(node web/server.mjs --serve 7788)才自启动;
|
|
439
576
|
// 被 bin 导入时必须保持安静——否则 `agent-purge web --serve 7789` 会两个端口各起一个实例,
|
|
440
577
|
// 并在第二个 bind 时吐出误导性的“端口已被占用:老实例还在跑”。
|
|
441
|
-
|
|
578
|
+
// 编译态 import.meta.url 也是 exe 路径,光比路径会把 exe 误当入口:只认 .mjs 脚本。
|
|
579
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
580
|
+
const isEntry = Boolean(process.argv[1]) && selfPath.endsWith(".mjs") && resolve(process.argv[1]) === selfPath;
|
|
442
581
|
if (isEntry && process.argv.includes("--serve")) serveWeb(Number(process.argv[process.argv.indexOf("--serve") + 1] ?? 7788));
|