@lelouchhe/webagent 0.3.0 → 0.5.1
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/README.md +59 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +105 -3
- package/dist/fonts/temml/Temml.woff2 +0 -0
- package/dist/index.html +64 -41
- package/dist/js/app.3OEVQXHK.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.IJM5DBCO.js +173 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.FCSSTUVY.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +54 -0
- package/dist/styles.00xfh3e6.css +1848 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +105 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge-event-config.js +29 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +149 -0
- package/lib/config.js +130 -9
- package/lib/daemon.js +175 -41
- package/lib/event-handler.js +209 -91
- package/lib/image-dimensions.js +64 -0
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/model-picker.js +17 -0
- package/lib/preflight.js +214 -0
- package/lib/push-service.js +297 -52
- package/lib/routes.js +1315 -145
- package/lib/server.js +149 -37
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +95 -0
- package/lib/store.js +636 -30
- package/lib/title-service.js +26 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +39 -4
- package/dist/js/app.2562YGRO.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/preflight.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Startup preflight checks. Each check prints a uniform `[check] <name> ✓`
|
|
2
|
+
// line on success, or `✗` with an actionable hint and exits 78 (sysexits
|
|
3
|
+
// EX_CONFIG) on failure. Runs synchronously before any heavy init so
|
|
4
|
+
// failures land on the operator's terminal first thing, not buried under
|
|
5
|
+
// noise.
|
|
6
|
+
//
|
|
7
|
+
// Scope is intentionally narrow: things we can answer before binding the
|
|
8
|
+
// port. Network reachability, agent login state, etc. are runtime
|
|
9
|
+
// concerns and surface as warnings via the bridge / UI later.
|
|
10
|
+
import { accessSync, constants, mkdirSync, statSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { createServer } from "node:net";
|
|
14
|
+
import { detectAgent, formatDetectionFailure } from "./agent-detect.js";
|
|
15
|
+
const PAD = 36;
|
|
16
|
+
function pad(s) {
|
|
17
|
+
return s.length >= PAD ? s + " " : s + " ".repeat(PAD - s.length);
|
|
18
|
+
}
|
|
19
|
+
function printOk(c) {
|
|
20
|
+
console.log(`[check] ${pad(`${c.name}: ${c.detail}`)} ✓`);
|
|
21
|
+
}
|
|
22
|
+
function printFail(c) {
|
|
23
|
+
console.error(`[check] ${pad(`${c.name}: ${c.detail}`)} ✗`);
|
|
24
|
+
for (const line of c.hint.split("\n"))
|
|
25
|
+
console.error(` ${line}`);
|
|
26
|
+
}
|
|
27
|
+
function checkNodeVersion() {
|
|
28
|
+
const v = process.versions.node;
|
|
29
|
+
const [maj, min] = v.split(".").map((n) => parseInt(n, 10));
|
|
30
|
+
// package.json declares engines.node >= 22.6.0; mirror that here so the
|
|
31
|
+
// diagnostic is friendly rather than a stack trace from a missing API.
|
|
32
|
+
const ok = maj > 22 || (maj === 22 && min >= 6);
|
|
33
|
+
if (ok)
|
|
34
|
+
return { ok: true, name: "node", detail: `v${v}` };
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
name: "node",
|
|
38
|
+
detail: `v${v}`,
|
|
39
|
+
hint: "webagent requires Node.js v22.6.0 or newer (for --experimental-strip-types).\nInstall via: nvm install 22 && nvm use 22",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function checkDataDir(dir) {
|
|
43
|
+
const abs = resolve(dir);
|
|
44
|
+
// Create the directory if it doesn't exist (Store does this lazily but
|
|
45
|
+
// we want the failure surface here, before SQLite tries to open).
|
|
46
|
+
try {
|
|
47
|
+
if (!safeStat(abs))
|
|
48
|
+
mkdirSync(abs, { recursive: true });
|
|
49
|
+
accessSync(abs, constants.W_OK);
|
|
50
|
+
return { ok: true, name: "data_dir", detail: abs };
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const code = err.code ?? "unknown";
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
name: "data_dir",
|
|
57
|
+
detail: abs,
|
|
58
|
+
hint: `cannot create or write to ${abs}: ${code}\nfix permissions or set data_dir in config.toml to a writable path.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function safeStat(p) {
|
|
62
|
+
try {
|
|
63
|
+
return statSync(p);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve `agent_cmd`. If it's the "auto" sentinel, run PATH detection;
|
|
72
|
+
* otherwise verify the first token (the binary) exists in PATH so we
|
|
73
|
+
* fail at preflight instead of after `server.listen` with a cryptic
|
|
74
|
+
* ENOENT in the bridge stderr.
|
|
75
|
+
*/
|
|
76
|
+
function checkAgent(agentCmd) {
|
|
77
|
+
if (agentCmd === "auto") {
|
|
78
|
+
const r = detectAgent();
|
|
79
|
+
if (r.ok) {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
name: "acp agent",
|
|
83
|
+
detail: `${r.label} (${r.cmd})`,
|
|
84
|
+
resolved: r.cmd,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
name: "acp agent",
|
|
90
|
+
detail: "no ACP-ready binary in PATH",
|
|
91
|
+
hint: formatDetectionFailure(r).replace(/^\[bridge\] [^\n]*\n\n?/, ""),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Explicit agent_cmd. Best-effort sanity: check first token's binary.
|
|
95
|
+
const bin = agentCmd.trim().split(/\s+/)[0];
|
|
96
|
+
if (!bin) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
name: "acp agent",
|
|
100
|
+
detail: agentCmd,
|
|
101
|
+
hint: "agent_cmd is empty.",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// Use the same detection helper logic via a one-off PATH probe.
|
|
105
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
106
|
+
const r = spawnSync(which, [bin], { stdio: "ignore" });
|
|
107
|
+
if (r.status === 0) {
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
name: "acp agent",
|
|
111
|
+
detail: agentCmd,
|
|
112
|
+
resolved: agentCmd,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
name: "acp agent",
|
|
118
|
+
detail: agentCmd,
|
|
119
|
+
hint: `'${bin}' not found in PATH.\nverify the binary is installed, or set agent_cmd to "auto" for automatic detection.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Probe whether `host:port` can be bound (matching what `server.listen`
|
|
124
|
+
* actually uses). Listens, then closes immediately. There's a tiny
|
|
125
|
+
* race window between close and the real server.listen() — that's
|
|
126
|
+
* fine for diagnostics: the goal is a friendly hint, not a hard
|
|
127
|
+
* guarantee.
|
|
128
|
+
*
|
|
129
|
+
* Port 0 means "let the OS pick"; we treat it as always-free.
|
|
130
|
+
*
|
|
131
|
+
* Probing the same host as the real server matters: a foreign
|
|
132
|
+
* listener on 0.0.0.0:PORT occupies 127.0.0.1:PORT too (more-specific
|
|
133
|
+
* bind fails when wildcard already bound), so binding the configured
|
|
134
|
+
* host catches conflicts and surfaces EADDRNOTAVAIL when the user
|
|
135
|
+
* typo'd an IP that isn't on any local interface.
|
|
136
|
+
*/
|
|
137
|
+
async function checkPort(port, host) {
|
|
138
|
+
const label = `${host}:${port}`;
|
|
139
|
+
// port=0 still gets probed: the OS picks any free port, but bind can
|
|
140
|
+
// still fail with EADDRNOTAVAIL if `host` isn't on any local
|
|
141
|
+
// interface — that's exactly the typo we want to catch.
|
|
142
|
+
const result = await new Promise((settle) => {
|
|
143
|
+
const probe = createServer();
|
|
144
|
+
probe.once("error", (err) => {
|
|
145
|
+
settle({ code: err.code ?? "unknown" });
|
|
146
|
+
});
|
|
147
|
+
probe.listen(port, host, () => {
|
|
148
|
+
const addr = probe.address();
|
|
149
|
+
const assigned = typeof addr === "object" && addr ? addr.port : port;
|
|
150
|
+
probe.close(() => {
|
|
151
|
+
settle({ assigned });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
if (!result.code) {
|
|
156
|
+
if (port === 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
name: "port",
|
|
160
|
+
detail: `${host}:0 (OS-assigned → ${result.assigned})`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, name: "port", detail: label };
|
|
164
|
+
}
|
|
165
|
+
if (result.code === "EADDRINUSE") {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
name: "port",
|
|
169
|
+
detail: `${label} (in use)`,
|
|
170
|
+
hint: `${label} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
|
|
171
|
+
? `netstat -ano | findstr :${port}`
|
|
172
|
+
: `lsof -nP -iTCP:${port} -sTCP:LISTEN`}\nor change \`port\` in config.toml to a free port.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (result.code === "EADDRNOTAVAIL") {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
name: "port",
|
|
179
|
+
detail: `${label} (${result.code})`,
|
|
180
|
+
hint: `cannot bind ${label}: ${result.code}\nthe host '${host}' is not assigned to any local interface.\ncheck \`host\` in config.toml — typical values are "127.0.0.1" (loopback) or "0.0.0.0" (all interfaces).`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
name: "port",
|
|
186
|
+
detail: `${label} (${result.code})`,
|
|
187
|
+
hint: `cannot bind ${label}: ${result.code}\ncheck firewall / permissions, or change \`host\`/\`port\` in config.toml.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Run all preflight checks in order. Prints each result; exits process
|
|
192
|
+
* (78) on first failure. On success, returns the resolved agent command
|
|
193
|
+
* so the caller doesn't need to re-detect.
|
|
194
|
+
*/
|
|
195
|
+
export async function runPreflight(opts) {
|
|
196
|
+
const checks = [];
|
|
197
|
+
checks.push(checkNodeVersion());
|
|
198
|
+
checks.push(checkDataDir(opts.data_dir));
|
|
199
|
+
const agent = checkAgent(opts.agent_cmd);
|
|
200
|
+
checks.push(agent);
|
|
201
|
+
checks.push(await checkPort(opts.port, opts.host));
|
|
202
|
+
for (const c of checks) {
|
|
203
|
+
if (c.ok)
|
|
204
|
+
printOk(c);
|
|
205
|
+
else {
|
|
206
|
+
printFail(c);
|
|
207
|
+
process.exit(78);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// After the loop above all checks are ok — narrow the agent result.
|
|
211
|
+
return {
|
|
212
|
+
agentCmd: agent.resolved,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/lib/push-service.js
CHANGED
|
@@ -1,21 +1,76 @@
|
|
|
1
1
|
import webpush from "web-push";
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { log } from "./log.js";
|
|
6
|
+
const slog = log.scope("push");
|
|
7
|
+
const elog = log.scope("egress");
|
|
4
8
|
const VAPID_FILE = "vapid.json";
|
|
5
9
|
/** Remove a subscription after this many consecutive send failures. */
|
|
6
10
|
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
11
|
+
/** Derive the push tag for an ACP event. Kept module-level so it can be
|
|
12
|
+
* reused by the close-on-handle path without instantiating the service. */
|
|
13
|
+
export function pushTagForEvent(sessionId, event) {
|
|
14
|
+
switch (event.type) {
|
|
15
|
+
case "prompt_done":
|
|
16
|
+
return `sess-${sessionId}-done`;
|
|
17
|
+
case "permission_request":
|
|
18
|
+
return `sess-${sessionId}-perm-${event.eventId ?? "0"}`;
|
|
19
|
+
case "bash_done":
|
|
20
|
+
return `sess-${sessionId}-bash-${event.eventId ?? "0"}`;
|
|
21
|
+
default:
|
|
22
|
+
return `sess-${sessionId}-${event.type}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* True iff the push endpoint is an Apple (APNs / Web Push on Apple) host.
|
|
27
|
+
*
|
|
28
|
+
* We deliberately filter silent `kind:"close"` pushes to these endpoints in
|
|
29
|
+
* `sendToAll` — iOS Safari PWA enforces an undocumented silent-push budget
|
|
30
|
+
* that, once exhausted, causes WebKit to drop ALL subsequent pushes for the
|
|
31
|
+
* subscription (including user-visible `kind:"notify"`). APNs returns 201
|
|
32
|
+
* throughout, so the server has zero visibility.
|
|
33
|
+
*
|
|
34
|
+
* macOS Safari shares the same host but is not subject to that budget; the
|
|
35
|
+
* conflation here is accepted collateral damage until we add a `platform`
|
|
36
|
+
* column to `push_subscriptions`.
|
|
37
|
+
*/
|
|
38
|
+
export function isAppleEndpoint(endpoint) {
|
|
39
|
+
let host;
|
|
40
|
+
try {
|
|
41
|
+
host = new URL(endpoint).hostname.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
|
|
47
|
+
}
|
|
48
|
+
function emptyClientState() {
|
|
49
|
+
return { endpoint: null };
|
|
50
|
+
}
|
|
7
51
|
export class PushService {
|
|
8
52
|
store;
|
|
9
53
|
vapidKeys;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Per-client transport state — only the push endpoint. Visibility lives
|
|
56
|
+
* in ClientRegistry as of Plan C Step 4.
|
|
57
|
+
*/
|
|
58
|
+
clients = new Map();
|
|
13
59
|
/** endpoint → consecutive failure count (absent or 0 = healthy) */
|
|
14
60
|
failureCounts = new Map();
|
|
15
|
-
|
|
61
|
+
globalVisibilitySuppression;
|
|
62
|
+
visibilityTtlMs;
|
|
63
|
+
now;
|
|
64
|
+
clientRegistry;
|
|
65
|
+
constructor(store, dataDir, vapidSubject, options) {
|
|
16
66
|
this.store = store;
|
|
17
67
|
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
18
68
|
webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
|
|
69
|
+
this.globalVisibilitySuppression =
|
|
70
|
+
options.globalVisibilitySuppression ?? true;
|
|
71
|
+
this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
|
|
72
|
+
this.now = options.now ?? (() => Date.now());
|
|
73
|
+
this.clientRegistry = options.clientRegistry;
|
|
19
74
|
}
|
|
20
75
|
// ---------------------------------------------------------------------------
|
|
21
76
|
// VAPID keys
|
|
@@ -25,12 +80,14 @@ export class PushService {
|
|
|
25
80
|
if (existsSync(filePath)) {
|
|
26
81
|
chmodSync(filePath, 0o600);
|
|
27
82
|
const keys = JSON.parse(readFileSync(filePath, "utf8"));
|
|
28
|
-
|
|
83
|
+
slog.info("loaded VAPID keys");
|
|
29
84
|
return keys;
|
|
30
85
|
}
|
|
31
86
|
const keys = webpush.generateVAPIDKeys();
|
|
32
|
-
writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", {
|
|
33
|
-
|
|
87
|
+
writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", {
|
|
88
|
+
mode: 0o600,
|
|
89
|
+
});
|
|
90
|
+
slog.info("generated new VAPID keys");
|
|
34
91
|
return keys;
|
|
35
92
|
}
|
|
36
93
|
getPublicKey() {
|
|
@@ -39,81 +96,107 @@ export class PushService {
|
|
|
39
96
|
// ---------------------------------------------------------------------------
|
|
40
97
|
// Notification formatting
|
|
41
98
|
// ---------------------------------------------------------------------------
|
|
42
|
-
formatNotification(sessionId, sessionTitle, eventType, eventData) {
|
|
43
|
-
const title = sessionTitle
|
|
99
|
+
formatNotification(sessionId, sessionTitle, eventType, eventData, tag) {
|
|
100
|
+
const title = sessionTitle ?? "WebAgent";
|
|
44
101
|
let body;
|
|
45
102
|
switch (eventType) {
|
|
46
103
|
case "permission_request":
|
|
47
|
-
body = `⚿ ${eventData.description
|
|
104
|
+
body = `⚿ ${typeof eventData.description === "string" ? eventData.description : "Permission requested"}`;
|
|
48
105
|
break;
|
|
49
106
|
case "prompt_done":
|
|
50
107
|
body = "✓ Task complete";
|
|
51
108
|
break;
|
|
52
109
|
case "bash_done": {
|
|
53
|
-
const cmd = eventData.command
|
|
54
|
-
const code = eventData.exitCode
|
|
110
|
+
const cmd = typeof eventData.command === "string" ? eventData.command : "command";
|
|
111
|
+
const code = typeof eventData.exitCode === "number" ||
|
|
112
|
+
typeof eventData.exitCode === "string"
|
|
113
|
+
? String(eventData.exitCode)
|
|
114
|
+
: "?";
|
|
55
115
|
body = `$ ${cmd} — exit ${code}`;
|
|
56
116
|
break;
|
|
57
117
|
}
|
|
58
118
|
default:
|
|
59
119
|
body = eventType;
|
|
60
120
|
}
|
|
61
|
-
return { title, body, data: { sessionId } };
|
|
121
|
+
return { kind: "notify", title, body, tag, data: { sessionId } };
|
|
62
122
|
}
|
|
63
123
|
// ---------------------------------------------------------------------------
|
|
64
|
-
//
|
|
124
|
+
// Endpoint mapping (transport-only as of Plan C Step 4)
|
|
65
125
|
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Set the push endpoint for a client. Identity-layer state (visibility,
|
|
128
|
+
* active session) goes through ClientRegistry.setVisibility, not here.
|
|
129
|
+
*/
|
|
130
|
+
updateClient(clientId, patch) {
|
|
131
|
+
const prev = this.clients.get(clientId) ?? emptyClientState();
|
|
132
|
+
const next = { ...prev };
|
|
133
|
+
if (patch.endpoint !== undefined)
|
|
134
|
+
next.endpoint = patch.endpoint;
|
|
135
|
+
this.clients.set(clientId, next);
|
|
68
136
|
}
|
|
69
|
-
|
|
70
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Read-only snapshot for tests and diagnostics. Do NOT mutate the
|
|
139
|
+
* returned object.
|
|
140
|
+
*/
|
|
141
|
+
getClientState(clientId) {
|
|
142
|
+
return this.clients.get(clientId) ?? null;
|
|
71
143
|
}
|
|
72
144
|
registerClient(clientId, endpoint) {
|
|
73
|
-
this.
|
|
145
|
+
this.updateClient(clientId, { endpoint });
|
|
74
146
|
}
|
|
75
147
|
removeClient(clientId) {
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
|
|
148
|
+
this.clients.delete(clientId);
|
|
149
|
+
// Disconnect also wipes identity-layer state so visibility queries don't
|
|
150
|
+
// leak past the SSE lifetime. Production calls removeClient on SSE close
|
|
151
|
+
// (see sse-manager); tests expect the same.
|
|
152
|
+
this.clientRegistry.remove(clientId);
|
|
79
153
|
}
|
|
80
154
|
hasVisibleClient() {
|
|
81
|
-
|
|
82
|
-
if (visible)
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
155
|
+
return this.clientRegistry.hasAnyVisibleClient();
|
|
86
156
|
}
|
|
87
|
-
/** Check if a specific endpoint has at least one visible client. */
|
|
157
|
+
/** Check if a specific endpoint has at least one visible (non-stale) client. */
|
|
88
158
|
isEndpointVisible(endpoint) {
|
|
89
|
-
|
|
90
|
-
|
|
159
|
+
// Identity (visible) comes from registry, transport (endpoint↔clientId)
|
|
160
|
+
// stays with pushService.clients.
|
|
161
|
+
const reg = this.clientRegistry;
|
|
162
|
+
for (const [clientId, s] of this.clients) {
|
|
163
|
+
if (s.endpoint !== endpoint)
|
|
164
|
+
continue;
|
|
165
|
+
if (reg.isClientVisible(clientId))
|
|
91
166
|
return true;
|
|
92
167
|
}
|
|
93
168
|
return false;
|
|
94
169
|
}
|
|
95
170
|
/**
|
|
96
|
-
* Check if any client (across all endpoints) is visible and viewing the
|
|
97
|
-
* A client with no session set does not suppress any
|
|
98
|
-
*
|
|
171
|
+
* Check if any client (across all endpoints) is visible and viewing the
|
|
172
|
+
* given session. A client with no session set does not suppress any
|
|
173
|
+
* session's push. Stale records (older than `visibilityTtlMs` since the
|
|
174
|
+
* last heartbeat refresh) are ignored — this is the server-side safety
|
|
175
|
+
* net for iOS PWA suspension, where the client's `visible:false` POST may
|
|
176
|
+
* never leave the device.
|
|
177
|
+
*
|
|
178
|
+
* Returns false unconditionally when global suppression is disabled via
|
|
179
|
+
* the `globalVisibilitySuppression` option (kill switch).
|
|
99
180
|
*/
|
|
100
181
|
isSessionVisibleToAnyClient(sessionId) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
return false;
|
|
182
|
+
if (!this.globalVisibilitySuppression)
|
|
183
|
+
return false;
|
|
184
|
+
return this.clientRegistry.isSessionVisibleToAnyClient(sessionId);
|
|
106
185
|
}
|
|
107
186
|
// ---------------------------------------------------------------------------
|
|
108
187
|
// High-level: decide whether to push, and if so, send
|
|
109
188
|
// ---------------------------------------------------------------------------
|
|
110
|
-
static NOTIFIABLE = new Set([
|
|
189
|
+
static NOTIFIABLE = new Set([
|
|
190
|
+
"permission_request",
|
|
191
|
+
"prompt_done",
|
|
192
|
+
"bash_done",
|
|
193
|
+
]);
|
|
111
194
|
/**
|
|
112
195
|
* Check if this event should trigger a push notification.
|
|
113
196
|
* Returns true if a notification should be sent (caller should then call sendToAll).
|
|
114
197
|
* Global session visibility suppression happens inside sendToAll.
|
|
115
198
|
*/
|
|
116
|
-
maybeNotify(sessionId, sessionTitle, eventType,
|
|
199
|
+
maybeNotify(sessionId, sessionTitle, eventType, _eventData) {
|
|
117
200
|
if (!PushService.NOTIFIABLE.has(eventType))
|
|
118
201
|
return false;
|
|
119
202
|
return true;
|
|
@@ -122,45 +205,207 @@ export class PushService {
|
|
|
122
205
|
// Send push to all subscriptions
|
|
123
206
|
// ---------------------------------------------------------------------------
|
|
124
207
|
async sendToAll(notification) {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
208
|
+
const allSubs = this.store.getAllSubscriptions();
|
|
209
|
+
if (allSubs.length === 0)
|
|
127
210
|
return;
|
|
211
|
+
// Global visibility: if any client is viewing this session, suppress notify pushes.
|
|
212
|
+
// Close pushes are never suppressed — they're silent and are the mechanism
|
|
213
|
+
// by which cross-device recall actually closes banners on the "losing" devices.
|
|
214
|
+
if (notification.kind === "notify") {
|
|
215
|
+
const targetSession = notification.data.sessionId;
|
|
216
|
+
if (targetSession && this.isSessionVisibleToAnyClient(targetSession))
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Skip Apple endpoints for silent close pushes to preserve iOS PWA's
|
|
220
|
+
// silent-push budget. See isAppleEndpoint() for rationale.
|
|
221
|
+
let subs = allSubs;
|
|
222
|
+
let filteredApple = 0;
|
|
223
|
+
if (notification.kind === "close") {
|
|
224
|
+
subs = allSubs.filter((s) => {
|
|
225
|
+
if (isAppleEndpoint(s.endpoint)) {
|
|
226
|
+
filteredApple++;
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
});
|
|
231
|
+
if (subs.length === 0) {
|
|
232
|
+
elog.info("sendClose", {
|
|
233
|
+
tag: notification.tag,
|
|
234
|
+
endpoints: 0,
|
|
235
|
+
ok: 0,
|
|
236
|
+
fail: 0,
|
|
237
|
+
fail_410: 0,
|
|
238
|
+
filtered_apple: filteredApple,
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
128
243
|
const payload = JSON.stringify(notification);
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
244
|
+
// Derive an RFC 8030 Topic so push services can collapse undelivered
|
|
245
|
+
// pushes on the wire. FCM (Chrome/Firefox desktop + Android) honors
|
|
246
|
+
// this and collapses correctly. APNs (iOS Safari PWA) does NOT — we
|
|
247
|
+
// verified in dogfood that two same-Topic pushes still surface as
|
|
248
|
+
// two stacked banners on iOS 17, even combined with the SW-side
|
|
249
|
+
// close-before-show workaround (see public/sw.js). Keep the header
|
|
250
|
+
// anyway: it's spec-compliant, cheap, and benefits every non-Apple
|
|
251
|
+
// client. iOS banner stacking is a platform limitation we accept.
|
|
252
|
+
const topic = tagToTopic(notification.tag);
|
|
253
|
+
const results = await Promise.allSettled(subs.map((sub) => this.sendOne({
|
|
254
|
+
endpoint: sub.endpoint,
|
|
255
|
+
keys: { auth: sub.auth, p256dh: sub.p256dh },
|
|
256
|
+
}, payload, { topic })));
|
|
257
|
+
let ok = 0;
|
|
258
|
+
let fail = 0;
|
|
259
|
+
let fail410 = 0;
|
|
133
260
|
for (let i = 0; i < results.length; i++) {
|
|
134
261
|
const result = results[i];
|
|
135
262
|
const endpoint = subs[i].endpoint;
|
|
136
263
|
if (result.status === "fulfilled") {
|
|
137
264
|
this.failureCounts.delete(endpoint);
|
|
265
|
+
ok++;
|
|
138
266
|
}
|
|
139
267
|
else {
|
|
268
|
+
fail++;
|
|
140
269
|
const err = result.reason;
|
|
141
270
|
if (err.statusCode === 410) {
|
|
142
|
-
|
|
271
|
+
fail410++;
|
|
143
272
|
this.store.removeSubscription(endpoint);
|
|
144
273
|
this.failureCounts.delete(endpoint);
|
|
145
|
-
|
|
274
|
+
slog.info("removed expired subscription (410)", {
|
|
275
|
+
endpoint: endpoint.slice(0, 60) + "…",
|
|
276
|
+
});
|
|
146
277
|
}
|
|
147
278
|
else {
|
|
148
279
|
const count = (this.failureCounts.get(endpoint) ?? 0) + 1;
|
|
149
280
|
if (count >= MAX_CONSECUTIVE_FAILURES) {
|
|
150
281
|
this.store.removeSubscription(endpoint);
|
|
151
282
|
this.failureCounts.delete(endpoint);
|
|
152
|
-
|
|
283
|
+
slog.info("removed subscription after consecutive failures", {
|
|
284
|
+
count,
|
|
285
|
+
endpoint: endpoint.slice(0, 60) + "…",
|
|
286
|
+
});
|
|
153
287
|
}
|
|
154
288
|
else {
|
|
155
289
|
this.failureCounts.set(endpoint, count);
|
|
156
|
-
|
|
290
|
+
slog.error("send failed", {
|
|
291
|
+
count,
|
|
292
|
+
max: MAX_CONSECUTIVE_FAILURES,
|
|
293
|
+
endpoint: endpoint.slice(0, 60) + "…",
|
|
294
|
+
error: result.reason,
|
|
295
|
+
});
|
|
157
296
|
}
|
|
158
297
|
}
|
|
159
298
|
}
|
|
160
299
|
}
|
|
300
|
+
if (notification.kind === "close") {
|
|
301
|
+
// Observability signal #6 — aggregated per-call close outcome.
|
|
302
|
+
elog.info("sendClose", {
|
|
303
|
+
tag: notification.tag,
|
|
304
|
+
endpoints: subs.length,
|
|
305
|
+
ok,
|
|
306
|
+
fail,
|
|
307
|
+
fail_410: fail410,
|
|
308
|
+
filtered_apple: filteredApple,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Send a silent close push for the given tag. Never visibility-suppressed. */
|
|
313
|
+
async sendClose(tag) {
|
|
314
|
+
await this.sendToAll({ kind: "close", tag });
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Send a push for an external message. Respects the message's `deliver`
|
|
318
|
+
* intent: `silent` sends nothing, `inapp` and `push` both send through
|
|
319
|
+
* web-push (the `inapp` vs `push` distinction is enforced by the SW /
|
|
320
|
+
* frontend rendering, not by the server).
|
|
321
|
+
*
|
|
322
|
+
* Tag = `msg-<id>` for unbound messages; bound messages get the
|
|
323
|
+
* `sess-<sid>-msg-<eid>` tag by taking a different code path in the
|
|
324
|
+
* consume handler.
|
|
325
|
+
*/
|
|
326
|
+
async sendForMessage(msg) {
|
|
327
|
+
const deliver = msg.deliver ?? "push";
|
|
328
|
+
if (deliver === "silent")
|
|
329
|
+
return false;
|
|
330
|
+
const subs = this.store.getAllSubscriptions();
|
|
331
|
+
const title = msg.from_label ?? msg.from_ref ?? "Message";
|
|
332
|
+
const body = msg.body.length > 140 ? msg.body.slice(0, 137) + "…" : msg.body;
|
|
333
|
+
const tag = msg.dedup_key ? `dedup-${msg.to}-${msg.dedup_key}` : msg.id;
|
|
334
|
+
// If this message targets a specific session, surface the sid in the
|
|
335
|
+
// push data so SW notificationclick can route the user there. Without
|
|
336
|
+
// this, clicks fall back to "/" and land on whatever session was last
|
|
337
|
+
// open — a confusing UX when multiple sessions get background pushes.
|
|
338
|
+
const sessionId = msg.to.startsWith("session:")
|
|
339
|
+
? msg.to.slice("session:".length)
|
|
340
|
+
: undefined;
|
|
341
|
+
// Observability signal #7 — sendForMessage entry.
|
|
342
|
+
elog.info("sendForMessage", {
|
|
343
|
+
msg_id: msg.id,
|
|
344
|
+
tag,
|
|
345
|
+
deliver,
|
|
346
|
+
endpoints: subs.length,
|
|
347
|
+
suppressed_by_visibility: false,
|
|
348
|
+
});
|
|
349
|
+
await this.sendToAll({
|
|
350
|
+
kind: "notify",
|
|
351
|
+
title,
|
|
352
|
+
body,
|
|
353
|
+
tag,
|
|
354
|
+
data: sessionId
|
|
355
|
+
? { messageId: msg.id, sessionId }
|
|
356
|
+
: { messageId: msg.id },
|
|
357
|
+
});
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Send a push for an ACP session event (permission_request / prompt_done /
|
|
362
|
+
* bash_done). Handles tag derivation, visibility suppression, and session-
|
|
363
|
+
* title lookup. Returns `true` if a push attempt was made.
|
|
364
|
+
*/
|
|
365
|
+
async sendForEvent(sessionId, event) {
|
|
366
|
+
if (!PushService.NOTIFIABLE.has(event.type))
|
|
367
|
+
return false;
|
|
368
|
+
const session = this.store.getSession(sessionId);
|
|
369
|
+
const sessionTitle = session?.title ?? null;
|
|
370
|
+
const tag = pushTagForEvent(sessionId, event);
|
|
371
|
+
const eventData = {};
|
|
372
|
+
if (event.type === "permission_request" && event.title !== undefined) {
|
|
373
|
+
eventData.description = event.title;
|
|
374
|
+
}
|
|
375
|
+
if (event.type === "bash_done") {
|
|
376
|
+
if (event.command !== undefined)
|
|
377
|
+
eventData.command = event.command;
|
|
378
|
+
if (event.exitCode !== undefined)
|
|
379
|
+
eventData.exitCode = event.exitCode;
|
|
380
|
+
}
|
|
381
|
+
const suppressed = this.isSessionVisibleToAnyClient(sessionId);
|
|
382
|
+
const subs = this.store.getAllSubscriptions();
|
|
383
|
+
elog.info("sendForEvent", {
|
|
384
|
+
sess_id: sessionId.slice(0, 8),
|
|
385
|
+
type: event.type,
|
|
386
|
+
tag,
|
|
387
|
+
endpoints: subs.length,
|
|
388
|
+
suppressed_by_visibility: suppressed,
|
|
389
|
+
});
|
|
390
|
+
const notification = this.formatNotification(sessionId, sessionTitle, event.type, eventData, tag);
|
|
391
|
+
await this.sendToAll(notification);
|
|
392
|
+
return true;
|
|
161
393
|
}
|
|
162
394
|
/** Send a single push notification. Extracted for testability. */
|
|
163
|
-
sendOne(sub, payload) {
|
|
164
|
-
return webpush.sendNotification(sub, payload);
|
|
395
|
+
sendOne(sub, payload, options) {
|
|
396
|
+
return webpush.sendNotification(sub, payload, options);
|
|
165
397
|
}
|
|
166
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Derive an RFC 8030 `Topic` header value from a notification tag. The
|
|
401
|
+
* spec limits topic to ≤32 chars of URL-safe Base64, so we hash and
|
|
402
|
+
* truncate. FCM maps this to its on-wire collapse key and works
|
|
403
|
+
* correctly. APNs is documented to map Topic → `apns-collapse-id`,
|
|
404
|
+
* but in practice iOS Safari PWA (≤17 at least) still surfaces
|
|
405
|
+
* stacked banners for same-Topic pushes — confirmed in dogfood.
|
|
406
|
+
* We keep the header for the platforms where it does work and for
|
|
407
|
+
* spec compliance; don't expect it to fix iOS.
|
|
408
|
+
*/
|
|
409
|
+
function tagToTopic(tag) {
|
|
410
|
+
return createHash("sha256").update(tag).digest("base64url").slice(0, 22);
|
|
411
|
+
}
|