summrise-agent 1.2.467 → 1.2.469

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/bin/summrise.js CHANGED
@@ -64,6 +64,7 @@ exports.newestOf = newestOf;
64
64
  exports.latestReleaseVersion = latestReleaseVersion;
65
65
  exports.cdnBase = cdnBase;
66
66
  exports.componentUrl = componentUrl;
67
+ exports.componentFetchUrl = componentFetchUrl;
67
68
  exports.componentKey = componentKey;
68
69
  exports.componentPins = componentPins;
69
70
  exports.sha256File = sha256File;
@@ -908,6 +909,29 @@ function cdnBase() {
908
909
  function componentUrl(name) {
909
910
  return `${cdnBase()}/summrise-agent/${name}`;
910
911
  }
912
+ /**
913
+ * The address to FETCH a boxed component from: the release manifest's own `url` for it, or
914
+ * `componentUrl`'s derived route when the manifest carries none.
915
+ *
916
+ * WHY THE MANIFEST IS THE AUTHOR. Every release since the pins existed has published a `url`
917
+ * beside each component's `sha256` in version.json, and this CLI read the digest while the
918
+ * address was retyped here — one fact, two authors. They agree today, which is why reading the
919
+ * manifest is a DRIFT GUARD rather than a fix: index/src/index.js rebuilds the published url
920
+ * against the origin of the request that asked for `/api/version`, so the address it hands back
921
+ * is the one this function would have derived — under $SUMMRISE_CDN too, where both move
922
+ * together. A release whose url and route disagreed (a renamed bundle, the round-115 shape)
923
+ * now fetches what it published; a silent manifest (an older release, or no network) keeps the
924
+ * derived route, which is what every release before this one did.
925
+ *
926
+ * Pure — the tests pin it. The digest check beside it is unchanged.
927
+ */
928
+ function componentFetchUrl(name, pins) {
929
+ const key = componentKey(name);
930
+ const u = key ? (pins[key] || {}).url : "";
931
+ return typeof u === "string" && /^https?:\/\/\S+$/.test(u.trim())
932
+ ? u.trim()
933
+ : componentUrl(name);
934
+ }
911
935
  /** The release manifest's key for a component's FILE name (they are not the same string). */
912
936
  function componentKey(fileName) {
913
937
  if (fileName.startsWith("summrise-playwright"))
@@ -969,7 +993,14 @@ function resolveComponent(name, pkgPath) {
969
993
  const dest = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "summrise-comp-")), name);
970
994
  // -f: an HTTP error is a FAILURE, not a 404 page written to disk (the
971
995
  // HTML-polluted download that round-54 exists to remember).
972
- const r = (0, child_process_1.spawnSync)("curl", ["-fsSL", "-m", "300", "-o", dest, componentUrl(name)], { encoding: "utf8", timeout: 320000 });
996
+ //
997
+ // THE MANIFEST IS READ ONCE, BEFORE THE FETCH, because it is the author of the DIGEST (below)
998
+ // and now of the ADDRESS too (componentFetchUrl). Same read, same object, same order of
999
+ // effects: an unreachable manifest yields `{}`, the fetch falls back to the derived route, and
1000
+ // the digest check then says "fetched WITHOUT a manifest pin — not verified", exactly as it
1001
+ // did when the address was derived unconditionally.
1002
+ const pins = componentPins();
1003
+ const r = (0, child_process_1.spawnSync)("curl", ["-fsSL", "-m", "300", "-o", dest, componentFetchUrl(name, pins)], { encoding: "utf8", timeout: 320000 });
973
1004
  if (r.status !== 0 || !fs.existsSync(dest) || fs.statSync(dest).size === 0) {
974
1005
  return null;
975
1006
  }
@@ -981,8 +1012,11 @@ function resolveComponent(name, pkgPath) {
981
1012
  // because the failure it prevents is a device running bytes nobody published.
982
1013
  // No pin at all (an older release) is a warning, not a refusal: it must not make
983
1014
  // an install impossible.
1015
+ //
1016
+ // `pins` is the read from above — ONE manifest read per component fetch, exactly as before; the
1017
+ // check itself is unchanged.
984
1018
  const key = componentKey(name);
985
- const pin = key ? (componentPins()[key] || {}).sha256 || "" : "";
1019
+ const pin = key ? (pins[key] || {}).sha256 || "" : "";
986
1020
  if (pin) {
987
1021
  const got = sha256File(dest);
988
1022
  if (got !== pin) {
@@ -1447,7 +1481,18 @@ function svc(action) {
1447
1481
  // RETURN the status. It used to be discarded, which is why `stop` printed "stopped"
1448
1482
  // and exited 0 for a missing task or an access-denied, and why `start`/`restart` were
1449
1483
  // silent either way. `autostart` already checks; this is the same pattern.
1450
- return sh(`schtasks /${action} /TN ${TASK}`, { stdio: "inherit" });
1484
+ //
1485
+ // argv, NOT an `sh()` command string: both `${action}` and `${TASK}` used to be
1486
+ // interpolated UNQUOTED into a line cmd.exe parses, which is the door the `psArgv()`
1487
+ // incident comment above describes — a value carrying a space, `&`, `|`, `>` or `<`
1488
+ // is re-parsed as an OPERATOR before schtasks ever sees it, and `TASK` is a
1489
+ // literal only until somebody makes the task name configurable.
1490
+ // schtasks needs no shell feature (no pipe, no `&&`, no redirection) and `/Query`
1491
+ // below already spawns it this way, so the door closes with no behaviour change:
1492
+ // the same program receives the same argv.
1493
+ return (0, child_process_1.spawnSync)("schtasks", [`/${action}`, "/TN", TASK], {
1494
+ stdio: "inherit",
1495
+ });
1451
1496
  }
1452
1497
  /**
1453
1498
  * The scheduled task's State (`Running`, `Ready`, `Disabled`, …) or **null when it could not be read**.
@@ -1881,12 +1926,18 @@ const commands = {
1881
1926
  });
1882
1927
  const pre = pfx.status === 0 ? String(pfx.stdout || "").trim() : "";
1883
1928
  if (selfVer && pre) {
1929
+ // `shell: true` is REQUIRED here (npm on Windows is a `.cmd` shim, which a bare
1930
+ // spawnSync cannot execute) — and that shell JOINS THIS ARGV INTO ONE cmd.exe
1931
+ // LINE, the shape the `processRunning` comment above records for the tasklist
1932
+ // filter. `pre` is a PATH (npm's global prefix), so it is DOUBLE-QUOTED for the
1933
+ // cmd layer: unquoted, a prefix like `C:\Program Files\nodejs` was split at its
1934
+ // space and npm installed the CLI somewhere else while reporting success.
1884
1935
  const inst = (0, child_process_1.spawnSync)("npm", [
1885
1936
  "i",
1886
1937
  "-g",
1887
- `summrise-agent@${selfVer}`,
1938
+ `"summrise-agent@${selfVer}"`,
1888
1939
  "--prefix",
1889
- pre,
1940
+ `"${pre}"`,
1890
1941
  "--registry=https://registry.npmjs.org/",
1891
1942
  "--no-audit",
1892
1943
  "--no-fund",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summrise-agent",
3
- "version": "1.2.467",
3
+ "version": "1.2.469",
4
4
  "description": "Summrise Agent — Windows device MCP server (terminal/SSH/serial) + panel. Self-hosted npm distribution.",
5
5
  "//build": "唯一构建路径:在本目录执行 npm run build。它是自包含的:经 npm exec 按需拉取 typescript@5,无需预装任何 node_modules(干净检出可跑;旧脚本引用 ../summrise-desktop-electron/node_modules/.bin/tsc,干净检出必 ENOENT)。必须用 npm exec --package … -- tsc 的形式:npx 会吃掉传给 tsc 的 -p/--outDir 等参数(round-435:npx 写法从未真正跑通过)。先编 electron TS(--noCheck 纯 emit,与 release.yml 一致)同步两处 src/*.js,再编 src/summrise.ts 到 bin/summrise.js 并校验 round-298 标记(弱 oracle,只证某次构建发生,新鲜性由 publish-release.sh 的 tsc 重编 + cmp 门保证)。",
6
6
  "bin": {
Binary file
@@ -165,6 +165,32 @@ function probeImage(p) {
165
165
  function frameOk(e) {
166
166
  return (0, url_policy_1.frameUrlOk)(e.senderFrame?.url || "");
167
167
  }
168
+ // THE ONE REFUSAL a forbidden frame gets. It used to have TWO shapes — seven
169
+ // handlers answered this one and the rest answered a bare `{ ok: false }`,
170
+ // which the SPA cannot tell apart from a dead view (`j?.ok` is falsy either
171
+ // way, and only this shape carries the reason). The preload's `invoke` callers
172
+ // were written against THIS shape, so every handler answers it now.
173
+ const FORBIDDEN_FRAME = Object.freeze({
174
+ ok: false,
175
+ error: "forbidden frame",
176
+ });
177
+ // THE ONE IPC DOOR: `ipcMain.handle` is called HERE and nowhere else, so the
178
+ // frame check above is applied ONCE per channel and no handler can forget it.
179
+ // A fourteenth handler that re-checked (or skipped) the frame was SILENT:
180
+ // main.ts imports electron, so no node suite can import it and notice — the
181
+ // pin is a SOURCE check instead (test/ipc-door.test.mjs counts the call sites).
182
+ //
183
+ // Handlers get the invoke ARGUMENTS only: every one of them used the event for
184
+ // nothing but the frame check. The refusal is decided before the handler runs,
185
+ // so a forbidden frame never reaches the implementation (no schtasks spawn, no
186
+ // browser window, no loadURL).
187
+ function ipcHandle(channel, fn) {
188
+ electron_1.ipcMain.handle(channel, (e, ...args) => {
189
+ if (!frameOk(e))
190
+ return FORBIDDEN_FRAME;
191
+ return fn(...args);
192
+ });
193
+ }
168
194
  // P1: CDP port for AI (playwright) to drive Summrise's own pages — the SAME
169
195
  // Electron window the user watches. Summrise's playwright-mcp connects via
170
196
  // connectOverCDP("http://127.0.0.1:9333") and drives this window's pages.
@@ -365,11 +391,30 @@ function buildMenu() {
365
391
  return electron_1.Menu.buildFromTemplate(template);
366
392
  }
367
393
  // --- Browser-session control: shared core (used by both IPC and HTTP) ---
368
- /** Only http/https/about:blank targets are allowed — file:// and other
369
- * schemes would hand the AI a local-file read primitive via the shared
370
- * CDP endpoint (stage-n hardening). */
394
+ /** THE ONE LOAD DOOR (stage-n hardening): every raw URL this shell is asked to
395
+ * load — a browser-session window, the embedded real-render view, a
396
+ * window.open() from a page inside it — is decided HERE and nowhere else.
397
+ *
398
+ * Only http/https/about:blank survive; file:// and every other scheme
399
+ * collapse to "about:blank", because a loadable file:// would hand the AI a
400
+ * local-file read primitive via the shared CDP endpoint (:9333).
401
+ *
402
+ * The DECISION itself is url-policy.sanitizeBrowserUrl() — pure, and
403
+ * unit-tested in test/url-policy.test.mjs, since main.ts imports electron and
404
+ * no node suite can import it. This function is that predicate's ONE call site
405
+ * in the shell (test/ipc-door.test.mjs pins the count), so a fourth load site
406
+ * cannot quietly reach for a weaker policy of its own.
407
+ *
408
+ * Callers keep their own about:blank semantics: browserOpen() opens a blank
409
+ * window, embeddedNavigate() loads it, and the embedded view's window-open
410
+ * handler REFUSES it (an external link must not blank the page being read). */
411
+ function loadTarget(raw) {
412
+ return (0, url_policy_1.sanitizeBrowserUrl)(raw);
413
+ }
414
+ /** Open a browser-session window on a decided load target (loadTarget() is the
415
+ * ONE load door — the scheme policy lives there). */
371
416
  function browserOpen(url) {
372
- const target = (0, url_policy_1.sanitizeBrowserUrl)(url);
417
+ const target = loadTarget(url);
373
418
  // stage-n: reuse an existing window on the same URL instead of stacking
374
419
  // duplicates (AI-driven browsing opens/closes sessions repeatedly); focus
375
420
  // the existing window. Match against the window's INITIAL target (stored
@@ -433,9 +478,9 @@ function browserList() {
433
478
  const list = [...browserSessions.entries()].map(([id, bw]) => ({ id, url: bw.webContents.getURL() }));
434
479
  return { ok: true, sessions: list, cdp: `http://127.0.0.1:${CDP_PORT}` };
435
480
  }
436
- electron_1.ipcMain.handle("browser-session:open", (e, url) => frameOk(e) ? browserOpen(url) : { ok: false, error: "forbidden frame" });
437
- electron_1.ipcMain.handle("browser-session:close", (e, id) => frameOk(e) ? browserClose(id) : { ok: false, error: "forbidden frame" });
438
- electron_1.ipcMain.handle("browser-session:list", (e) => frameOk(e) ? browserList() : { ok: false, error: "forbidden frame" });
481
+ ipcHandle("browser-session:open", (url) => browserOpen(url));
482
+ ipcHandle("browser-session:close", (id) => browserClose(id));
483
+ ipcHandle("browser-session:list", () => browserList());
439
484
  // ── Embedded real-render browser view (round-246) ──────────────────────────
440
485
  // The SPA's Browser page used to show the BRIDGE's headless-chromium as a
441
486
  // JPEG screencast (lossy q60-92 frames over a websocket) — never as sharp as
@@ -473,12 +518,13 @@ function embeddedViewEnsure() {
473
518
  // window) were DENIED, so clicking them did nothing. The embedded view is
474
519
  // a single-tab browser: intercept window.open and navigate the SAME view
475
520
  // to the requested URL instead (the address bar follows via the nav
476
- // events). Only http/https/data/about are honored — anything else
477
- // (javascript:, file:, chrome:) is dropped like the url-policy requires.
521
+ // events). The scheme allow-list is loadTarget()'s (the ONE load door):
522
+ // anything it refuses (javascript:, file:, chrome:) must NOT blank the page
523
+ // the user is reading, so a refused target is dropped here instead.
478
524
  view.webContents.setWindowOpenHandler(({ url }) => {
479
- const safe = (0, url_policy_1.sanitizeBrowserUrl)(url);
480
- if (safe && safe !== "about:blank") {
481
- embeddedNavigate(safe);
525
+ const target = loadTarget(url);
526
+ if (target !== "about:blank") {
527
+ embeddedNavigate(target);
482
528
  }
483
529
  return { action: "deny" };
484
530
  });
@@ -519,7 +565,9 @@ function embeddedViewPlace(bounds) {
519
565
  win.contentView.addChildView(view);
520
566
  }
521
567
  function embeddedNavigate(raw) {
522
- const url = (0, url_policy_1.sanitizeBrowserUrl)(raw);
568
+ // Every raw URL the SPA/AI hands the embedded view is decided by the ONE
569
+ // load door before it reaches loadURL.
570
+ const url = loadTarget(raw);
523
571
  const view = embeddedViewEnsure();
524
572
  embeddedUrl = url;
525
573
  view.webContents.loadURL(url).catch(() => { });
@@ -621,6 +669,8 @@ function embeddedRecover() {
621
669
  const view = embeddedViewEnsure();
622
670
  if (!view || view.webContents.isDestroyed())
623
671
  return { ok: false };
672
+ // Already decided by loadTarget() when embeddedUrl was set (the ONE load
673
+ // door) — recovery re-loads that decision, it does not re-open the policy.
624
674
  const url = embeddedUrl && embeddedUrl !== "about:blank" ? embeddedUrl : "https://www.bing.com";
625
675
  view.webContents.loadURL(url).catch(() => { });
626
676
  // Re-show if the SPA slot is live (bounds were placed before the crash).
@@ -631,16 +681,14 @@ function embeddedRecover() {
631
681
  }
632
682
  return { ok: true };
633
683
  }
634
- electron_1.ipcMain.handle("embedded-browser:recover", (e) => frameOk(e) ? embeddedRecover() : { ok: false });
635
- electron_1.ipcMain.handle("embedded-browser:navigate", (e, url) => frameOk(e) ? embeddedNavigate(String(url || "")) : { ok: false, error: "forbidden frame" });
636
- electron_1.ipcMain.handle("embedded-browser:back", (e) => frameOk(e) ? embeddedGo(-1) : { ok: false });
637
- electron_1.ipcMain.handle("embedded-browser:fwd", (e) => frameOk(e) ? embeddedGo(1) : { ok: false });
638
- electron_1.ipcMain.handle("embedded-browser:reload", (e) => frameOk(e) ? embeddedReload() : { ok: false });
684
+ ipcHandle("embedded-browser:recover", () => embeddedRecover());
685
+ ipcHandle("embedded-browser:navigate", (url) => embeddedNavigate(String(url || "")));
686
+ ipcHandle("embedded-browser:back", () => embeddedGo(-1));
687
+ ipcHandle("embedded-browser:fwd", () => embeddedGo(1));
688
+ ipcHandle("embedded-browser:reload", () => embeddedReload());
639
689
  /** round-251: zoom the REAL embedded view (webContents.setZoomFactor — the
640
690
  * native browser zooms, not a CSS scale). factor: 0.5-3.0. */
641
- electron_1.ipcMain.handle("embedded-browser:zoom", (e, factor) => {
642
- if (!frameOk(e))
643
- return { ok: false };
691
+ ipcHandle("embedded-browser:zoom", (factor) => {
644
692
  const view = embeddedView;
645
693
  if (!view || view.webContents.isDestroyed())
646
694
  return { ok: false };
@@ -653,13 +701,11 @@ electron_1.ipcMain.handle("embedded-browser:zoom", (e, factor) => {
653
701
  return { ok: false };
654
702
  }
655
703
  });
656
- electron_1.ipcMain.handle("embedded-browser:place", (e, bounds) => {
657
- if (!frameOk(e))
658
- return { ok: false };
704
+ ipcHandle("embedded-browser:place", (bounds) => {
659
705
  embeddedViewPlace(bounds);
660
706
  return { ok: true };
661
707
  });
662
- electron_1.ipcMain.handle("embedded-browser:state", (e) => frameOk(e) ? embeddedState() : { ok: false, error: "forbidden frame" });
708
+ ipcHandle("embedded-browser:state", () => embeddedState());
663
709
  // Desktop-app settings (auto-launch) — the Settings page toggles this. We
664
710
  // manage a per-user scheduled task ("SummriseDesktop", onlogon) instead of
665
711
  // Electron's setLoginItemSettings: in dev mode (electron .) the login-item
@@ -752,10 +798,10 @@ async function autoLaunchTaskSet(enabled) {
752
798
  return { ok: false, error: String(e) };
753
799
  }
754
800
  }
755
- electron_1.ipcMain.handle("desktop:get-auto-launch", async (e) => frameOk(e) ? { ok: true, enabled: await autoLaunchTaskExists() } : { ok: false, error: "forbidden frame" });
801
+ ipcHandle("desktop:get-auto-launch", async () => ({ ok: true, enabled: await autoLaunchTaskExists() }));
756
802
  // The auto-launch pair is the PERSISTENCE primitive (onlogon schtasks in an
757
803
  // admin context) — it absolutely cannot be reachable from a foreign frame.
758
- electron_1.ipcMain.handle("desktop:set-auto-launch", async (e, enabled) => frameOk(e) ? autoLaunchTaskSet(!!enabled) : { ok: false, error: "forbidden frame" });
804
+ ipcHandle("desktop:set-auto-launch", async (enabled) => autoLaunchTaskSet(!!enabled));
759
805
  // --- Agent lifecycle (stage-m: Rust owns it; the shell only probes/triggers) ---
760
806
  // review #2 (HIGH): a raw TCP connect only proves the PORT is held — an
761
807
  // agent that is wedged (deadlock / OOM-parked) but still listening keeps the