agent-purge 0.2.8 → 0.2.10

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/web/server.mjs CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { detect } from "../lib/detect.mjs";
7
7
  import { oneshots } from "../backends/index.mjs";
8
8
  import { createStore } from "../lib/personas.mjs";
9
- import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setDshProfile } from "../lib/state.mjs";
9
+ import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setOmpAuto, setDshProfile } from "../lib/state.mjs";
10
10
  import { install, status as persistStatus, uninstall } from "../lib/persist.mjs";
11
11
  import { plans, summary as armorSummary } from "../lib/armor.mjs";
12
12
  import { installShim, shimStatus, uninstallShim } from "../lib/shim.mjs";
@@ -31,7 +31,14 @@ const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
31
31
  const store = createStore(DATA);
32
32
 
33
33
  export function serveWeb(port = 7788) {
34
- const server = createServer(handle);
34
+ // handle 是 async:任何异常都在这里收口,否则请求会挂着不响应
35
+ const server = createServer((req, res) => {
36
+ handle(req, res).catch((e) => {
37
+ try {
38
+ send(res, 500, { error: `服务端错误: ${e.message}` });
39
+ } catch {}
40
+ });
41
+ });
35
42
  server.on("error", (e) => {
36
43
  // 端口被旧实例占着的时候,--detach 是静默的 → 这里必须说人话,否则用户看到的是旧 UI
37
44
  if (e.code === "EADDRINUSE") {
@@ -51,11 +58,16 @@ async function handle(req, res) {
51
58
  const url = new URL(req.url, "http://127.0.0.1");
52
59
  const p = url.pathname;
53
60
 
61
+ // 防 CSRF:本机 Web 页面能 POST 就能写配置/改人设;跨站页面用 text/plain 也能发“简单请求”。
62
+ // 只放同源:Origin 的 host 必须和本次请求的 Host 完全一致(端口不同也挡住)。
63
+ if (isMutating(req.method) && !sameOrigin(req)) return send(res, 403, { error: "只接受本机同源请求" });
64
+
54
65
  if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
55
66
  if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
56
67
  if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
57
68
  if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
58
69
  if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)).on === true) });
70
+ if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, (await body(req)).on === true) });
59
71
  if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
60
72
  if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
61
73
  if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
@@ -144,6 +156,7 @@ async function state(withVersions) {
144
156
  dshAll: DSH_PROFILES,
145
157
  modes: MODES,
146
158
  piAuto: st.piAuto === true,
159
+ ompAuto: st.ompAuto === true,
147
160
  shimDir: shimStatus()[0]?.dir ?? null,
148
161
  slashes: slashes,
149
162
  };
@@ -228,7 +241,8 @@ function savePersona(payload) {
228
241
  function deletePersona(id) {
229
242
  if (!id) return { error: "缺少 id" };
230
243
  if (store.list().length <= 1) return { error: "至少保留一个 persona" };
231
- store.remove(id); // remove 内部会处理“删的就是 active”的指针改道
244
+ if (!store.read(id)) return { error: "找不到这个 persona" };
245
+ if (!store.remove(id)) return { error: "非法 persona id" };
232
246
  return { ok: true, active: store.activeId() };
233
247
  }
234
248
 
@@ -251,6 +265,7 @@ function usePersona(payload) {
251
265
 
252
266
  /** 全局总开关:所有 harness × 选定通道一次折腾完(= `agent-purge 注册 --all` / `disarm --all`)*/
253
267
  function applyArmAll(payload) {
268
+ if (!payload.action) return { error: "缺少 action" };
254
269
  const { action } = payload;
255
270
  const channel = payload.channel ?? "all";
256
271
  const verb = verbOf(action);
@@ -310,6 +325,7 @@ function verbOf(action) {
310
325
 
311
326
  /** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
312
327
  function applyArm(payload) {
328
+ if (!payload.action) return { error: "缺少 action" };
313
329
  const { harness, channel, action } = payload;
314
330
  const id = payload.persona ?? store.activeId();
315
331
  if (!store.read(id)) return { error: "没有可用 persona" };
@@ -363,21 +379,57 @@ function armChannel(harness, channel, verb, id) {
363
379
  return { error: `未知通道: ${channel}` };
364
380
  }
365
381
 
366
- const send = (res, code, obj) => res.writeHead(code, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(obj));
382
+ const send = (res, code, obj) =>
383
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(obj));
367
384
  const html = (res) =>
368
385
  res
369
386
  .writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
370
387
  .end(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
371
388
 
389
+ function isMutating(method) {
390
+ return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
391
+ }
392
+
393
+ /** 浏览器跨站请求会带 Origin;curl/脚本没有,放行。有 Origin 就必须和 Host 同源。 */
394
+ function sameOrigin(req) {
395
+ // 先看浏览器声明的跨站属性:没有 Origin 的跨站请求(部分 fetch 场景)也要挡住
396
+ const site = req.headers["sec-fetch-site"];
397
+ if (site && site !== "same-origin" && site !== "none") return false;
398
+ const origin = req.headers.origin;
399
+ if (!origin) return true;
400
+ try {
401
+ const u = new URL(origin);
402
+ 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();
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+
372
409
  function body(req) {
373
410
  return new Promise((resolve) => {
374
411
  let raw = "";
375
- req.on("data", (c) => (raw += c));
412
+ let size = 0;
413
+ let done = false;
414
+ const finish = (value) => {
415
+ if (done) return;
416
+ done = true;
417
+ resolve(value);
418
+ };
419
+ req.on("data", (c) => {
420
+ size += c.length;
421
+ // 本机也不该接无上限的 body:一个坏页面能靠大 POST 把内存吃光
422
+ if (size > 2 * 1024 * 1024) {
423
+ req.destroy();
424
+ return finish({});
425
+ }
426
+ raw += c;
427
+ });
376
428
  req.on("end", () => {
377
429
  try {
378
- resolve(JSON.parse(raw || "{}"));
430
+ finish(JSON.parse(raw || "{}"));
379
431
  } catch {
380
- resolve({});
432
+ finish({});
381
433
  }
382
434
  });
383
435
  });
package/web/ui.html CHANGED
@@ -567,8 +567,8 @@ const ARM_INFO = {
567
567
  shim: { name: "接管裸命令", desc: (h) => h?.id === "pi"
568
568
  ? "PATH 最前放个同名壳:敲 pi 就注入。想要“裸启动也带人设”,开这个。"
569
569
  : `往 PATH 前置一个同名小壳:直接敲 ${h?.id || "<harness>"} 也是接管版。` },
570
- slash: { name: "会话内热切", desc: (h) => h?.id === "pi"
571
- ? "写 pi 扩展,只挂 /purge:不敲 /purge 就原样跑,动手才改写。"
570
+ slash: { name: "会话内热切", desc: (h) => h?.id === "pi" || h?.id === "omp"
571
+ ? `写 ${h.id} 扩展,只挂 /purge:不敲 /purge 就原样跑,动手才改写。`
572
572
  : "写 harness 自己支持的扩展/命令,会话里 /purge 切换。" },
573
573
  install: { name: "配置文件常驻", desc: () => "写进 harness 自己的指令文件:连别人启动、GUI 里点开都生效。" },
574
574
  };
@@ -688,7 +688,7 @@ function renderOverview() {
688
688
  const hid = S.defaults.harness;
689
689
  const h = S.harnesses.find((x) => x.id === hid) || S.harnesses[0];
690
690
  const persona = S.personas.find((p) => p.id === (selPersona || S.persona)) || S.personas[0];
691
- const hot = S.harnesses.filter((h) => (h.armed || []).length).map((h) => h.id + "(" + h.armed.join("/") + (h.id === "pi" && S.piAuto ? "+裸启动" : "") + ")");
691
+ const hot = S.harnesses.filter((h) => (h.armed || []).length).map((h) => h.id + "(" + h.armed.join("/") + (((h.id === "pi" && S.piAuto) || (h.id === "omp" && S.ompAuto)) ? "+裸启动" : "") + ")");
692
692
 
693
693
  const why = mode === "off"
694
694
  ? "总电源关着 —— 下面写的都只是配置,实际一条都不会注入。"
@@ -704,7 +704,7 @@ function renderOverview() {
704
704
  const say = (c) => c === "shim"
705
705
  ? (h.id === "pi" ? "敲 pi 会注入(PATH 刚加的前置壳)" : "敲 " + h.id + " 会注入(PATH 前置壳)")
706
706
  : c === "slash"
707
- ? (h.id === "pi" ? (S.piAuto ? "裸敲 pi 也注入(扩展常驻)" : "裸敲 pi 不注入,只有会话里 /purge 才改写") : "会话里 /purge 展开成用户消息")
707
+ ? (h.id === "pi" || h.id === "omp" ? ((h.id === "pi" ? S.piAuto : S.ompAuto) ? `裸敲 ${h.id} 也注入(扩展常驻)` : `裸敲 ${h.id} 不注入,只有会话里 /purge 才改写`) : "会话里 /purge 展开成用户消息")
708
708
  : "改了它的配置文件,连别人启动的也生效";
709
709
  return chans.map(say).join(";") + "。";
710
710
  })()
@@ -812,6 +812,13 @@ function harnessCard(h) {
812
812
  <small class="${smallCls}" ${path && !why ? `title="${esc(path)}"` : ""}>${esc(smallText)}</small></span></button>`;
813
813
  }).join("");
814
814
 
815
+ // omp 的裸启动注入靠扩展常驻(ompAuto);只有扩展已装(slash 已武装)才给这个开关
816
+ const ompAutoBtn = h.id === "omp" && h.armed?.includes("slash")
817
+ ? `<button class="chan ${S.ompAuto ? "on" : ""}" data-ompauto="${S.ompAuto ? "off" : "on"}" type="button" title="扩展常驻,不改 PATH:开关写在 state.json 的 ompAuto">
818
+ <span class="dot"></span><span><b>裸命令接管${S.ompAuto ? " · 开" : ""}</b>
819
+ <small>${S.ompAuto ? "扩展常驻:敲 omp 就注入(每次启动)" : "关着:只有 /purge 或 agent-purge omp 才注入"}</small></span></button>`
820
+ : "";
821
+
815
822
  const notes = []
816
823
  // 卡片上的徽章已经写了“SYS 通过/部分通过”,同义的行就不再重复
817
824
  .concat((row?.warnings || []).filter((w) => !/^(sys|user) (通过|部分通过|未通过)$/.test(w)))
@@ -856,7 +863,7 @@ function harnessCard(h) {
856
863
  <span><k>home</k>${esc(h.home || "—")}${h.homeExists === false ? "(不存在)" : ""}</span>
857
864
  </div>
858
865
  ${notes.length ? `<div class="hnote">${notes.map(esc).join("<br>")}</div>` : ""}
859
- <div class="chans">${chans}</div>
866
+ <div class="chans">${chans}${ompAutoBtn}</div>
860
867
  ${profileRow}
861
868
  </div>`;
862
869
  }
@@ -1047,11 +1054,13 @@ $("#hpick").addEventListener("click", (e) => {
1047
1054
  selHarness = b.dataset.hpick; renderHarness();
1048
1055
  });
1049
1056
  $("#hsel").addEventListener("click", async (e) => {
1050
- const b = e.target.closest("[data-piauto]"); if (!b) return;
1057
+ const b = e.target.closest("[data-piauto], [data-ompauto]"); if (!b) return;
1058
+ const isPi = Boolean(b.dataset.piauto);
1059
+ const want = (isPi ? b.dataset.piauto : b.dataset.ompauto) === "on";
1051
1060
  await withBusy(b, async () => {
1052
1061
  try {
1053
- await api("/api/pi-auto", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ on: b.dataset.piauto === "on" }) });
1054
- toast("裸启动自动注入 → " + (b.dataset.piauto === "on" ? "开" : "关"));
1062
+ await api(isPi ? "/api/pi-auto" : "/api/omp-auto", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ on: want }) });
1063
+ toast((isPi ? "pi" : "omp") + " 裸启动自动注入 → " + (want ? "开" : "关"));
1055
1064
  await load();
1056
1065
  } catch (err) { toast("失败:" + err.message, true); }
1057
1066
  });