agentus 0.4.3 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -420,14 +420,29 @@ const agent = () => ({
420
420
  // it: an EMPTY thought chunk whose `_meta.hermes` holds the sentence. Sent mid-turn on purpose, so a
421
421
  // sweep can prove the line appears while the turn runs and is gone when it ends, and that NOTHING
422
422
  // of it reaches the stored transcript.
423
- if (/\[status\]/.test(text)) {
424
- setTimeout(() => {
425
- void send(agent._conn, sessionId, {
426
- sessionUpdate: "agent_thought_chunk",
427
- _meta: { hermes: { kind: "status", statusKind: "lifecycle", text: "👁️ Hindsight — recalled 8 memories" } },
428
- content: { type: "text", text: "" },
429
- }).catch(() => undefined);
430
- }, Math.min(slow * 2, 1200));
423
+ // `[status]` / `[status:<kind>]` / `[status:<kind>#<n>]` — the carrier above, with the kind and a
424
+ // repeat count the sweeps need: a compaction is NOT one frame (start, then heartbeats, then done),
425
+ // and the cockpit must fold the heartbeats into the row they belong to instead of stacking rows.
426
+ const statusM = /\[status(?::([a-z_]+))?(?:#(\d+))?\]/.exec(text);
427
+ if (statusM) {
428
+ const kind = statusM[1] ?? "lifecycle";
429
+ const times = Math.min(Number(statusM[2] ?? 1), 8);
430
+ const line = kind === "lifecycle"
431
+ ? "👁️ Hindsight — recalled 8 memories"
432
+ : kind === "compacting"
433
+ ? (times > 1 ? "🗜️ Compacting context — still summarizing earlier conversation..." : "🗜️ Compacting context — summarizing earlier conversation...")
434
+ : kind === "compacted"
435
+ ? "✓ Context compaction complete — continuing turn..."
436
+ : `⚠️ mock ${kind} status`;
437
+ for (let i = 0; i < times; i++) {
438
+ setTimeout(() => {
439
+ void send(agent._conn, sessionId, {
440
+ sessionUpdate: "agent_thought_chunk",
441
+ _meta: { hermes: { kind: "status", statusKind: kind, text: line } },
442
+ content: { type: "text", text: "" },
443
+ }).catch(() => undefined);
444
+ }, Math.min(slow * 2, 1200) + i * 350);
445
+ }
431
446
  }
432
447
 
433
448
  // `[blank]` — the SAME carrier with its `_meta` stripped, i.e. the shape a status line takes when
@@ -1888,7 +1888,18 @@ export class SessionManager {
1888
1888
  }
1889
1889
  const statusText = statusLineOf(u);
1890
1890
  if (statusText) {
1891
- this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind: statusKindOf(u) });
1891
+ const statusKind = statusKindOf(u);
1892
+ const category = noteCategoryOf(statusText, statusKind);
1893
+ if (category) {
1894
+ // 落库(操作者 2026-10-18 选的 A 档):刷新页面、重连、换设备之后,这行还在。
1895
+ // 折叠在 store 里做(见 Store.appendNote),因为「压缩开始 → 心跳 → 完成」和每回合的
1896
+ // 一次回忆都必须收敛成一行 —— 客户端折只治屏幕噪音,库里照样长心跳。
1897
+ const row = this.#store.appendNote(live.info.id, { category, text: statusText });
1898
+ this.#emit({ t: "message", message: row });
1899
+ } else {
1900
+ // 剩下的(provider 等待的倒计时、租约闲聊)本来就是一秒就作废的呼吸声:只走 live 通道。
1901
+ this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind });
1902
+ }
1892
1903
  return;
1893
1904
  }
1894
1905
  let msg: Parameters<Store["appendMessage"]>[0] | null = null;
@@ -2590,6 +2601,27 @@ function statusKindOf(u: Record<string, unknown>): string | undefined {
2590
2601
  return typeof kind === "string" && kind ? kind : undefined;
2591
2602
  }
2592
2603
 
2604
+ /** 哪些状态行值得留档(操作者 2026-10-18 选的是 A 档)。
2605
+ *
2606
+ * Hermes 发来的「状态行」是一根混在一起的管子:`status_output.py:102` 把所有东西都标成
2607
+ * `lifecycle`,只有警告是 `warn`。所以分类只能看**文本**,而这些字符串是从 fork 里抄来的
2608
+ * 真实文案(`conversation_compression.py` 的 `🗜️ Compacting context` / `✓ Context compaction
2609
+ * complete` / `📦 Pre-API compression:`,`memory_manager.describe_recall` 的 `recalled N memories`):
2610
+ *
2611
+ * · memory —— 「这一轮记忆召回了什么」,每回合都会发生,事后有意义;
2612
+ * · compaction —— 「上下文压过」,一行就够(记住它压缩过,不需要记住它压了 12 次心跳);
2613
+ * · warn —— 稀有,且几乎总是"要人看一眼"的东西。
2614
+ *
2615
+ * provider 等待(`⏳ waiting on … 15s waiting for the first stream chunk`,十来秒一条倒计时)和
2616
+ * 「Session is free; loading the latest transcript...」这类租约闲聊返回 null:它们描述的是"此刻
2617
+ * 网络的呼吸",一秒后就作废,落库只会把会话记录变成心跳日志。 */
2618
+ export function noteCategoryOf(text: string, statusKind?: string): "memory" | "compaction" | "warn" | null {
2619
+ if (statusKind === "warn") return "warn";
2620
+ if (/Compacting context|Context compaction complete|compression:|compacting ~/i.test(text)) return "compaction";
2621
+ if (/recalled (?:relevant memory|1 memory|\d+ memories)/i.test(text)) return "memory";
2622
+ return null;
2623
+ }
2624
+
2593
2625
  function buildPromptBlocks(text: string, attachments: PromptAttachment[]): unknown[] {
2594
2626
  const blocks: unknown[] = [];
2595
2627
  const body = text.trim();
@@ -2,7 +2,7 @@
2
2
  // The browser never talks to CLI subprocesses directly (design.md §1).
3
3
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import type { Duplex } from "node:stream";
5
- import { createHash, randomUUID } from "node:crypto";
5
+ import { createHash, randomUUID, X509Certificate } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
@@ -101,6 +101,19 @@ const APK_FILE = process.env.AGENTUS_APK
101
101
  path.join(DATA_DIR, "agentus-companion.apk"),
102
102
  ].find((p) => fs.existsSync(p))
103
103
  ?? path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk");
104
+ // The root CA this instance's HTTPS is signed by, served to logged-in operators so a phone can
105
+ // trust the tunnel's self-signed cert once instead of tapping through a warning forever. Same
106
+ // reasoning as the APK: an artifact you install on a device belongs behind the operator login, and
107
+ // a page that invites you to install a TRUST ANCHOR especially so. Public half only — `ca.key` is
108
+ // never read here, and the file this points at holds the certificate alone.
109
+ // Candidates: an explicit pin (a Caddy/safe-nat root lives outside any checkout), then the data
110
+ // dir for a self-contained install, then the Caddy default. Absent is not fatal: the card says so.
111
+ const CA_FILE = process.env.AGENTUS_CA_FILE
112
+ ?? [
113
+ path.join(DATA_DIR, "tls", "ca.pem"),
114
+ path.join(homedir(), ".config", "caddy", "tls", "ca.pem"),
115
+ ].find((p) => fs.existsSync(p))
116
+ ?? path.join(DATA_DIR, "tls", "ca.pem");
104
117
  // `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
105
118
  // what it decided, then exits — before any listener, store or subprocess exists. Print and
106
119
  // exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
@@ -760,6 +773,41 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
760
773
  return res.end("Agentus: authentication required\n");
761
774
  }
762
775
 
776
+ // ---- the root CA, behind the operator login too -------------------------------------
777
+ // `GET /api/tls` what the cert is (subject, validity, SHA-256 fingerprint) so the
778
+ // settings card can show it before you download anything;
779
+ // `GET /api/tls/ca.crt` the DER the phone/Mac installs. DER + `application/x-x509-ca-cert`
780
+ // is what makes iOS/macOS open the *profile install* flow on a plain
781
+ // tap — serve it as text/plain or octet-stream and iOS just shows text.
782
+ // Nothing here reads a key: this is the certificate, which is public material by design.
783
+ if (url.pathname === "/api/tls" || url.pathname === "/api/tls/ca.crt") {
784
+ if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
785
+ if (!fs.existsSync(CA_FILE)) return send(res, 404, { error: "no ca configured", path: CA_FILE });
786
+ let cert: X509Certificate;
787
+ try { cert = new X509Certificate(fs.readFileSync(CA_FILE)); }
788
+ catch (e) { return send(res, 500, { error: `ca is not a certificate: ${(e as Error).message}` }); }
789
+ if (url.pathname === "/api/tls/ca.crt") {
790
+ res.writeHead(200, {
791
+ "content-type": "application/x-x509-ca-cert",
792
+ "content-length": String(cert.raw.length),
793
+ "content-disposition": 'attachment; filename="agentus-ca.crt"',
794
+ "cache-control": "no-store",
795
+ });
796
+ return res.end(cert.raw);
797
+ }
798
+ return send(res, 200, {
799
+ ok: true,
800
+ hasCA: true,
801
+ subject: cert.subject.replace(/\n/g, ", "),
802
+ issuer: cert.issuer.replace(/\n/g, ", "),
803
+ notBefore: cert.validFrom,
804
+ notAfter: cert.validTo,
805
+ fingerprint256: cert.fingerprint256,
806
+ bytes: cert.raw.length,
807
+ selfSigned: cert.subject === cert.issuer,
808
+ });
809
+ }
810
+
763
811
  // ---- the phone's onboarding page + the APK, both behind the operator login ----------
764
812
  // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
765
813
  // sent to the login screen (the SPA at /) and comes back with a session cookie.
@@ -17,6 +17,7 @@ import type { Duplex } from "node:stream";
17
17
  import { WebSocketServer, WebSocket } from "ws";
18
18
  import type { PermissionRequestView, PlanSnapshot, ServerEvent } from "../../../shared/src/index.js";
19
19
  import { DEFAULT_NOTIFY_RULES } from "./types.js";
20
+ import { WebPushService } from "./webpush.js";
20
21
  import type {
21
22
  ActionCallback,
22
23
  ActivityObject,
@@ -98,6 +99,33 @@ function tapTarget(url: string, prefer: OpenTarget["prefer"] = "app"): Pick<Acti
98
99
  return { deeplink: url, open: { url, prefer: prefer ?? "auto" } };
99
100
  }
100
101
 
102
+ /** The session a notice points at, from its `open` URL (`/?session=<id>`). */
103
+ function sessionOfOpen(open: string | null | undefined): string | null {
104
+ if (!open) return null;
105
+ try {
106
+ return new URL(open, "http://local").searchParams.get("session");
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** A subscription as the settings card may see it: the device, never the endpoint.
113
+ * The endpoint is a long-lived capability URL (whoever holds it can post to that browser's push
114
+ * service) — the card only needs to tell one device from another, so host + tail is enough. */
115
+ function publicSub(rec: { endpoint: string; ua?: string; createdAt: number; lastOkAt?: number; fails: number; lastError?: string }) {
116
+ let host = "";
117
+ try { host = new URL(rec.endpoint).host; } catch { host = "?"; }
118
+ return {
119
+ host,
120
+ tail: rec.endpoint.slice(-6),
121
+ ua: rec.ua,
122
+ createdAt: rec.createdAt,
123
+ lastOkAt: rec.lastOkAt,
124
+ fails: rec.fails,
125
+ lastError: rec.lastError,
126
+ };
127
+ }
128
+
101
129
  /** How a finished turn reads on a lock screen: 「用时 1m12s」.
102
130
  *
103
131
  * The protocol's own answer (`stopReason: end_turn`) is a fact about the wire, not about the
@@ -166,6 +194,8 @@ export class NotifyCenter {
166
194
  private onActionCb: ((e: ActionEvent) => void) | null = null;
167
195
  /** The page channel's sink: index.ts fans this out to every cockpit websocket. */
168
196
  private onNoticeCb: ((n: WebNotice) => void) | null = null;
197
+ /** The third channel: the only one that reaches a LOCKED screen (see webpush.ts). */
198
+ private readonly push: WebPushService;
169
199
  private timers: NodeJS.Timeout[] = [];
170
200
  private wss: WebSocketServer | null = null;
171
201
  /** Which session the cockpit is showing, and whether its tab is visible. Deliberately in
@@ -278,6 +308,7 @@ export class NotifyCenter {
278
308
  this.sessionPlan = opts.sessionPlan ?? null;
279
309
  this.apkPath = opts.apkPath ?? null;
280
310
  this.onFocus = opts.onFocus ?? null;
311
+ this.push = new WebPushService(opts.dataDir, { log: (l) => this.log(l) });
281
312
  this.state = this.loadState();
282
313
  this.events = this.loadEvents();
283
314
  }
@@ -528,11 +559,56 @@ export class NotifyCenter {
528
559
  this.onNoticeCb = cb;
529
560
  }
530
561
 
531
- /** Emit one page notice, gated on the web channel's own switches. */
562
+ /** Emit one page notice, gated on the web channel's own switches — and hand the same notice to the
563
+ * push services, so 「关屏也能收」 needs no second decision path (and cannot drift from the page's). */
532
564
  private notice(n: Omit<WebNotice, "at">, rule: keyof Omit<NotifyWebRules, "enabled">): void {
533
565
  const web = this.rules().web;
534
566
  if (!web.enabled || !web[rule]) return;
567
+ this.emitNotice(n);
568
+ }
569
+
570
+ /** The page + push half of a notice, without any rule gate — for the two cleanups (「跑完了」撤掉
571
+ * 「运行中」卡、批准后撤掉批准卡) that must happen even when the rule that would have SENT the card
572
+ * is off. Taking a dead card off the screen is not an interruption, which is why these two calls
573
+ * skip the switches; the push side follows the same logic, or a locked phone would keep a card
574
+ * advertising a decision that was already made. */
575
+ private emitNotice(n: Omit<WebNotice, "at">): void {
535
576
  this.onNoticeCb?.({ ...n, at: Date.now() });
577
+ this.pushNotice(n);
578
+ }
579
+
580
+ /** The push renderer of a notice. Narrower than the page channel on purpose:
581
+ *
582
+ * · 「运行中」 never pushes. A lock-screen card saying "still running" is noise by definition, and
583
+ * what was asked for is 完成 and 需要批准 — the two things that want a human.
584
+ * · a notice about the session he is LOOKING AT never pushes. The page channel leaves presence to
585
+ * the page (`document.hidden`); a push cannot see that, so the server judges here. A phone
586
+ * buzzing about the window already on screen is pure interruption.
587
+ * · a dismiss IS pushed: it takes the stale card off the lock screen (the worker closes the
588
+ * notification by tag), which is the same unconditional cleanup the other two channels do.
589
+ *
590
+ * Fire-and-forget: a push service being slow or dead must never delay the frame to the phone or the
591
+ * page. Each send records its own outcome on the subscription (fails / lastError) for the UI to show. */
592
+ private pushNotice(n: Omit<WebNotice, "at">): void {
593
+ if (!this.push.subscriptions().length) return;
594
+ if (n.kind === "agent_running") return; // never pushed, so a dismiss for it has nothing to close
595
+ const sessionId = sessionOfOpen(n.open);
596
+ if (sessionId && this.watching(sessionId)) return;
597
+ const dismiss = n.op === "dismiss";
598
+ const payload = dismiss
599
+ ? { op: "dismiss", noticeId: n.noticeId }
600
+ : { op: "upsert", noticeId: n.noticeId, kind: n.kind, title: n.title, body: n.body, open: n.open, sticky: Boolean(n.sticky) };
601
+ // 审批卡要活到有人动手:TTL 一天,urgency high(叫醒设备)。完成/出错一小时就够。
602
+ const approval = n.kind === "approval";
603
+ void this.push
604
+ .broadcast(payload, dismiss
605
+ ? { urgency: "very-low", ttlSec: 300 }
606
+ : { urgency: approval ? "high" : "normal", ttlSec: approval ? 86_400 : 3_600 })
607
+ .then((results) => {
608
+ const bad = results.filter((r) => !r.ok);
609
+ if (bad.length) this.log(`[notify] web push: ${bad.length}/${results.length} 未送达(${bad[0]?.error ?? ""})`);
610
+ })
611
+ .catch((e: Error) => this.log(`[notify] web push threw: ${e.message}`));
536
612
  }
537
613
 
538
614
  // ---- actions -------------------------------------------------------------
@@ -609,6 +685,54 @@ export class NotifyCenter {
609
685
  }) && true;
610
686
  }
611
687
 
688
+ // ---- 网页推送(关屏也能收)-----------------------------------------------------
689
+ // The public key has to reach the browser to subscribe; the SUBSCRIPTION LIST must not — it says
690
+ // how many devices listen to this cockpit. So the whole group sits behind the operator gate, the
691
+ // same reason the APK and the root certificate do.
692
+ if (p === "/api/notify/webpush" || p.startsWith("/api/notify/webpush/")) {
693
+ if (!isOperator()) return json(401, { error: "operator auth required" }) && true;
694
+ const subs = () => this.push.subscriptions().map(publicSub);
695
+
696
+ if (p === "/api/notify/webpush" && req.method === "GET") {
697
+ return json(200, { ok: true, publicKey: this.push.publicKey(), subs: subs() }) && true;
698
+ }
699
+
700
+ if (p === "/api/notify/webpush/subscribe" && req.method === "POST") {
701
+ const body = await readBody();
702
+ const rec = this.push.subscribe({
703
+ endpoint: body.endpoint,
704
+ keys: (body.keys ?? {}) as { p256dh?: unknown; auth?: unknown },
705
+ ua: body.ua,
706
+ });
707
+ if ("error" in rec) return json(400, { error: rec.error }) && true;
708
+ this.log(`[notify] 网页推送已订阅:${new URL(rec.endpoint).host}(共 ${this.push.subscriptions().length} 台)`);
709
+ return json(200, { ok: true, subs: subs() }) && true;
710
+ }
711
+
712
+ if (p === "/api/notify/webpush/unsubscribe" && req.method === "POST") {
713
+ const body = await readBody();
714
+ const removed = this.push.unsubscribe(String(body.endpoint ?? ""));
715
+ return json(200, { ok: true, removed, subs: subs() }) && true;
716
+ }
717
+
718
+ // The test button: proves the WHOLE chain (our VAPID key → push service → service worker →
719
+ // lock screen). A test that only logged to the server would prove none of it.
720
+ if (p === "/api/notify/webpush/test" && req.method === "POST") {
721
+ if (!this.push.subscriptions().length) return json(400, { error: "这台服务器还没有任何浏览器订阅" }) && true;
722
+ const results = await this.push.broadcast({
723
+ op: "upsert", noticeId: "test:webpush", kind: "agent_done", sticky: false,
724
+ title: "推送测试", body: "锁屏上能看到这条,就说明关屏也能收", open: "/",
725
+ }, { urgency: "high", ttlSec: 300 });
726
+ return json(200, {
727
+ ok: results.every((r) => r.ok),
728
+ results: results.map((r) => ({ ok: r.ok, status: r.status, error: r.error, gone: r.gone })),
729
+ subs: subs(),
730
+ }) && true;
731
+ }
732
+
733
+ return json(404, { error: "unknown webpush route" }) && true;
734
+ }
735
+
612
736
  if (p === "/api/notify/presence" && req.method === "POST") {
613
737
  // The cockpit telling us what it is showing. Operator-authenticated (the SPA has the
614
738
  // cookie); a missing report just means presence goes stale and quiet-when-watching stops
@@ -1037,17 +1161,13 @@ export class NotifyCenter {
1037
1161
  // taking a dead card off the screen is not an interruption, and gating it on a preference
1038
1162
  // would leave a card nobody can answer.
1039
1163
  this.dismiss(`perm:${evt.requestId}`);
1040
- this.onNoticeCb?.({
1041
- op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "", at: Date.now(),
1042
- });
1164
+ this.emitNotice({ op: "dismiss", noticeId: `perm:${evt.requestId}`, kind: "approval", title: "", body: "" });
1043
1165
  return;
1044
1166
  case "turn-end": {
1045
1167
  // The running card goes away unconditionally — a zombie "still running" on the phone is worse
1046
1168
  // than no card at all, even when the frame that started it was suppressed.
1047
1169
  this.dismiss(`turn:${evt.sessionId}`);
1048
- this.onNoticeCb?.({
1049
- op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "", at: Date.now(),
1050
- });
1170
+ this.emitNotice({ op: "dismiss", noticeId: `turn:${evt.sessionId}`, kind: "agent_running", title: "", body: "" });
1051
1171
  // Both gates belong here and both were missing: `completion` was stored but never read, and
1052
1172
  // presence was only consulted for turn-start — so the completion card fired while the operator was watching
1053
1173
  // the very session (and, mid-call, with a sound: the reported bug).
@@ -0,0 +1,295 @@
1
+ // Web Push — the third renderer of the same notification decision.
2
+ //
3
+ // The three channels and why they exist:
4
+ //
5
+ // phone (WS) — the Android companion holds one long connection; only works while the phone is awake.
6
+ // page — the open tab draws a notification through the page's own Notification API.
7
+ // push (here) — the ONLY channel that survives a locked screen: the page is frozen by iOS/Android
8
+ // the moment it leaves the foreground, and no JS runs to notice anything. A push
9
+ // service holds the message until the device is reachable, wakes the service worker,
10
+ // and the worker draws the card. That is why this exists and the page renderer cannot
11
+ // replace it.
12
+ //
13
+ // Nothing here decides WHAT is worth notifying — `center.ts` owns that (one decision, three renderers).
14
+ // This module only knows how to hand bytes to a push service: encryption (RFC 8291 / aes128gcm) and
15
+ // VAPID authorization (RFC 8292).
16
+ //
17
+ // No dependency on `web-push`: the whole job is ~120 lines of node:crypto, and a dependency here would
18
+ // mean an npm install on a machine whose registry access is already the flakiest part of a release.
19
+ // The RFC's own test vector is checked by `scripts/qa/webpush-crypto.mts` — see the note at encryptPush().
20
+ import crypto from "node:crypto";
21
+ import fs from "node:fs";
22
+ import http from "node:http";
23
+ import https from "node:https";
24
+ import path from "node:path";
25
+
26
+ /** One browser's subscription, as `pushManager.subscribe()` returned it. */
27
+ export interface PushSubscriptionRecord {
28
+ endpoint: string;
29
+ /** base64url of the UA's uncompressed P-256 point (65 bytes) — the `keys.p256dh` the browser gave us. */
30
+ p256dh: string;
31
+ /** base64url of the 16-byte auth secret — `keys.auth`. */
32
+ auth: string;
33
+ ua?: string;
34
+ createdAt: number;
35
+ /** Last time a push was accepted by the push service (HTTP 201). */
36
+ lastOkAt?: number;
37
+ fails: number;
38
+ lastError?: string;
39
+ }
40
+
41
+ interface VapidFile {
42
+ /** base64url uncompressed point (65 bytes) — what the browser needs as applicationServerKey. */
43
+ publicKey: string;
44
+ /** The private half, kept as a JWK because that is what createPrivateKey() eats. */
45
+ jwk: { kty: string; crv: string; x: string; y: string; d: string };
46
+ subject: string;
47
+ }
48
+
49
+ interface SubscriptionFile {
50
+ subs: PushSubscriptionRecord[];
51
+ }
52
+
53
+ // ---- base64url ----------------------------------------------------------------
54
+
55
+ export const b64u = (buf: Buffer): string => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
56
+
57
+ export const unb64u = (str: string): Buffer => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
58
+
59
+ // ---- RFC 8291: encrypting one push message ------------------------------------
60
+
61
+ /** The message body a push service carries for us: `aes128gcm` single record (RFC 8188 §2, RFC 8291 §4).
62
+ *
63
+ * Layout of the body: 16-byte salt ‖ rs (u32 BE = 4096) ‖ idlen (u8 = 65) ‖ AS public key ‖ ciphertext.
64
+ * The plaintext gets the padding delimiter 0x02 appended, because it is the last (only) record.
65
+ *
66
+ * `opts` exists ONLY so the RFC 8291 §5 test vector can be reproduced byte-for-byte: it pins the salt
67
+ * and the application-server keypair. Production calls always pass no salt and a fresh keypair — the
68
+ * whole security property rests on both being random per message.
69
+ *
70
+ * Verified against that vector (Appendix A gives every intermediate: ecdh_secret, PRK_key, IKM, PRK,
71
+ * CEK, NONCE, and the final body). If this function and the vector disagree, the push is silently
72
+ * undecryptable by the browser and nothing else in the system would tell us — hence a test, not hope. */
73
+ export function encryptPush(
74
+ uaPublicRaw: Buffer,
75
+ authSecret: Buffer,
76
+ plaintext: Buffer,
77
+ opts: { salt?: Buffer; asKeys?: crypto.KeyObject[] } = {},
78
+ ): Buffer {
79
+ const salt = opts.salt ?? crypto.randomBytes(16);
80
+ const pair = opts.asKeys ?? (() => {
81
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
82
+ return [privateKey, publicKey];
83
+ })();
84
+ const [asPrivate, asPublic] = pair;
85
+
86
+ const uaPublic = crypto.createPublicKey({
87
+ key: { kty: "EC", crv: "P-256", x: b64u(uaPublicRaw.subarray(1, 33)), y: b64u(uaPublicRaw.subarray(33, 65)) },
88
+ format: "jwk",
89
+ });
90
+ const ecdhSecret = crypto.diffieHellman({ privateKey: asPrivate, publicKey: uaPublic });
91
+ const asPoint = pointFromJwk(asPublic.export({ format: "jwk" }) as { x: string; y: string });
92
+
93
+ // IKM = HKDF-Extract(auth_secret, ecdh_secret) then Expand with "WebPush: info\0" ‖ ua ‖ as.
94
+ const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0", "utf8"), uaPublicRaw, asPoint]);
95
+ const ikm = Buffer.from(crypto.hkdfSync("sha256", ecdhSecret, authSecret, keyInfo, 32));
96
+
97
+ // CEK/NONCE come from RFC 8188's HKDF with the per-message salt.
98
+ const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0", "utf8"), 16));
99
+ const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0", "utf8"), 12));
100
+
101
+ const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
102
+ const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce);
103
+ const ct = Buffer.concat([cipher.update(record), cipher.final(), cipher.getAuthTag()]);
104
+
105
+ const rs = Buffer.alloc(4);
106
+ rs.writeUInt32BE(4096, 0);
107
+ return Buffer.concat([salt, rs, Buffer.from([asPoint.length]), asPoint, ct]);
108
+ }
109
+
110
+ function pointFromJwk(jwk: { x: string; y: string }): Buffer {
111
+ return Buffer.concat([Buffer.from([0x04]), unb64u(jwk.x), unb64u(jwk.y)]);
112
+ }
113
+
114
+ // ---- VAPID + delivery ---------------------------------------------------------
115
+
116
+ export interface SendResult { ok: boolean; status: number; error?: string; gone?: boolean }
117
+
118
+ export class WebPushService {
119
+ private readonly dir: string;
120
+ private readonly subject: string;
121
+ private readonly log: (line: string) => void;
122
+ private vapidCache: VapidFile | null = null;
123
+ private subsCache: PushSubscriptionRecord[] | null = null;
124
+
125
+ constructor(dataDir: string, opts: { subject?: string; log?: (line: string) => void } = {}) {
126
+ this.dir = path.join(dataDir, "notify");
127
+ this.subject = opts.subject ?? process.env.AGENTUS_VAPID_SUBJECT ?? "mailto:agentus@localhost";
128
+ this.log = opts.log ?? ((l) => console.log(l));
129
+ }
130
+
131
+ private get vapidFile(): string { return path.join(this.dir, "vapid.json"); }
132
+ private get subsFile(): string { return path.join(this.dir, "webpush.json"); }
133
+
134
+ /** The server's identity to push services. Generated on first use and then never rotated by itself:
135
+ * rotating the VAPID key invalidates every existing subscription, so that must stay a deliberate act. */
136
+ private vapid(): VapidFile {
137
+ if (this.vapidCache) return this.vapidCache;
138
+ try {
139
+ this.vapidCache = JSON.parse(fs.readFileSync(this.vapidFile, "utf8")) as VapidFile;
140
+ return this.vapidCache;
141
+ } catch { /* first boot */ }
142
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
143
+ const jwk = privateKey.export({ format: "jwk" }) as VapidFile["jwk"];
144
+ const record: VapidFile = { publicKey: b64u(pointFromJwk(jwk)), jwk, subject: this.subject };
145
+ fs.mkdirSync(this.dir, { recursive: true });
146
+ fs.writeFileSync(this.vapidFile, JSON.stringify(record, null, 1), { mode: 0o600 });
147
+ this.log(`[notify] generated a VAPID keypair (${this.vapidFile}) — back it up with the rest of <data>`);
148
+ this.vapidCache = record;
149
+ return record;
150
+ }
151
+
152
+ /** `applicationServerKey` for `pushManager.subscribe()`. */
153
+ publicKey(): string { return this.vapid().publicKey; }
154
+
155
+ subscriptions(): PushSubscriptionRecord[] {
156
+ if (this.subsCache) return this.subsCache;
157
+ try {
158
+ const raw = JSON.parse(fs.readFileSync(this.subsFile, "utf8")) as SubscriptionFile;
159
+ this.subsCache = Array.isArray(raw.subs) ? raw.subs : [];
160
+ } catch {
161
+ this.subsCache = [];
162
+ }
163
+ return this.subsCache;
164
+ }
165
+
166
+ private save(): void {
167
+ fs.mkdirSync(this.dir, { recursive: true });
168
+ fs.writeFileSync(this.subsFile, JSON.stringify({ subs: this.subsCache ?? [] }, null, 1), { mode: 0o600 });
169
+ }
170
+
171
+ /** Idempotent by endpoint: re-subscribing the same browser replaces its keys instead of piling up rows.
172
+ * A browser re-subscribes on every page load in some engines, so this must not grow. */
173
+ subscribe(input: { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown }; ua?: unknown }): PushSubscriptionRecord | { error: string } {
174
+ const endpoint = typeof input.endpoint === "string" ? input.endpoint : "";
175
+ const p256dh = typeof input.keys?.p256dh === "string" ? input.keys.p256dh : "";
176
+ const auth = typeof input.keys?.auth === "string" ? input.keys.auth : "";
177
+ if (!/^https?:\/\//.test(endpoint)) return { error: "endpoint must be an https URL from the browser" };
178
+ if (unb64u(p256dh).length !== 65) return { error: "keys.p256dh must be an uncompressed P-256 point (65 bytes)" };
179
+ if (unb64u(auth).length !== 16) return { error: "keys.auth must be 16 bytes" };
180
+ const subs = this.subscriptions();
181
+ const prior = subs.find((s) => s.endpoint === endpoint);
182
+ const rec: PushSubscriptionRecord = {
183
+ endpoint, p256dh, auth,
184
+ ua: typeof input.ua === "string" ? input.ua.slice(0, 200) : undefined,
185
+ createdAt: prior?.createdAt ?? Date.now(),
186
+ lastOkAt: prior?.lastOkAt,
187
+ fails: 0,
188
+ };
189
+ this.subsCache = [...subs.filter((s) => s.endpoint !== endpoint), rec];
190
+ this.save();
191
+ return rec;
192
+ }
193
+
194
+ unsubscribe(endpoint: string): boolean {
195
+ const subs = this.subscriptions();
196
+ const next = subs.filter((s) => s.endpoint !== endpoint);
197
+ if (next.length === subs.length) return false;
198
+ this.subsCache = next;
199
+ this.save();
200
+ return true;
201
+ }
202
+
203
+ /** ES256 JWT over the endpoint's own origin (RFC 8292 §2). `aud` is per-endpoint on purpose: a token
204
+ * minted for one push service must not be replayable at another. */
205
+ private vapidAuth(endpoint: string, now = Date.now()): string {
206
+ const { publicKey, jwk } = this.vapid();
207
+ const header = b64u(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" })));
208
+ const claims = b64u(Buffer.from(JSON.stringify({
209
+ aud: new URL(endpoint).origin,
210
+ exp: Math.floor(now / 1000) + 12 * 3600,
211
+ sub: this.subject,
212
+ })));
213
+ const signingInput = `${header}.${claims}`;
214
+ const key = crypto.createPrivateKey({ key: jwk, format: "jwk" });
215
+ // ieee-p1363 = raw R‖S; JWS wants exactly that, DER would be rejected.
216
+ const sig = crypto.sign("sha256", Buffer.from(signingInput), { key, dsaEncoding: "ieee-p1363" });
217
+ return `vapid t=${signingInput}.${b64u(sig)}, k=${publicKey}`;
218
+ }
219
+
220
+ /** Hand one message to one push service. `urgency` maps to the RFC 8030 header the platforms honour:
221
+ * a lock-screen approval is `high` (deliver immediately, wake the device), a "finished" is `normal`,
222
+ * and taking a stale card back is `very-low` (do not wake anything for it). */
223
+ async send(
224
+ rec: PushSubscriptionRecord,
225
+ payload: unknown,
226
+ opts: { ttlSec?: number; urgency?: "very-low" | "low" | "normal" | "high"; timeoutMs?: number } = {},
227
+ ): Promise<SendResult> {
228
+ let body: Buffer;
229
+ try {
230
+ body = encryptPush(unb64u(rec.p256dh), unb64u(rec.auth), Buffer.from(JSON.stringify(payload), "utf8"));
231
+ } catch (e) {
232
+ return { ok: false, status: 0, error: `encrypt: ${(e as Error).message}` };
233
+ }
234
+ let auth: string;
235
+ try {
236
+ auth = this.vapidAuth(rec.endpoint);
237
+ } catch (e) {
238
+ return { ok: false, status: 0, error: `vapid: ${(e as Error).message}` };
239
+ }
240
+ const url = new URL(rec.endpoint);
241
+ const mod = url.protocol === "http:" ? http : https;
242
+ return await new Promise<SendResult>((resolve) => {
243
+ const req = mod.request({
244
+ protocol: url.protocol,
245
+ hostname: url.hostname,
246
+ port: url.port || (url.protocol === "http:" ? 80 : 443),
247
+ path: `${url.pathname}${url.search}`,
248
+ method: "POST",
249
+ headers: {
250
+ authorization: auth,
251
+ "content-encoding": "aes128gcm",
252
+ "content-type": "application/octet-stream",
253
+ "content-length": String(body.length),
254
+ ttl: String(opts.ttlSec ?? 3600),
255
+ urgency: opts.urgency ?? "normal",
256
+ },
257
+ timeout: opts.timeoutMs ?? 8000,
258
+ }, (res) => {
259
+ const chunks: Buffer[] = [];
260
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
261
+ res.on("end", () => {
262
+ const status = res.statusCode ?? 0;
263
+ const ok = status >= 200 && status < 300;
264
+ const text = Buffer.concat(chunks).toString("utf8").slice(0, 300);
265
+ const gone = status === 404 || status === 410;
266
+ if (ok) {
267
+ rec.lastOkAt = Date.now();
268
+ rec.fails = 0;
269
+ rec.lastError = undefined;
270
+ this.save();
271
+ } else {
272
+ rec.fails += 1;
273
+ rec.lastError = `${status} ${text}`.trim();
274
+ // 404/410 = the browser threw the subscription away (app uninstalled, permission reset).
275
+ // Keeping it would retry a dead endpoint forever and log a failure on every notification.
276
+ if (gone) this.unsubscribe(rec.endpoint);
277
+ else this.save();
278
+ }
279
+ resolve({ ok, status, error: ok ? undefined : `${status} ${text}`.trim(), gone });
280
+ });
281
+ });
282
+ req.on("timeout", () => { req.destroy(new Error("push service timed out")); });
283
+ req.on("error", (e) => resolve({ ok: false, status: 0, error: e.message }));
284
+ req.end(body);
285
+ });
286
+ }
287
+
288
+ /** Send the same payload to every subscription. Returns one result per device so the settings card can
289
+ * say "2 台设备都收到了" or which one is dead — a silent broadcast would hide a broken device. */
290
+ async broadcast(payload: unknown, opts: { ttlSec?: number; urgency?: "very-low" | "low" | "normal" | "high" } = {}): Promise<SendResult[]> {
291
+ const subs = this.subscriptions();
292
+ if (!subs.length) return [];
293
+ return await Promise.all(subs.map((s) => this.send(s, payload, opts)));
294
+ }
295
+ }