@particle-academy/agent-integrations 0.45.0 → 0.46.0

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 CHANGED
@@ -323,9 +323,16 @@ to you:
323
323
  `confirm` argument that would apply it (revoking the last passkey is a lockout).
324
324
  `passkey_list` re-projects onto the public summary fields, so handing it your
325
325
  ORM model cannot leak a public key, a user handle, or a signature counter.
326
- - **The relay fans tool results + activity to all peers.** The terminal bridge
327
- redacts raw command bytes from broadcast meta, but any `structuredContent` you
328
- return is visible to every connected peer — don't return secrets.
326
+ - **Activity reaches every peer; tool results reach the caller only if clients
327
+ label themselves.** Since 0.44.0 a reply goes only to the client that asked
328
+ when clients send a `client` query parameter; unlabelled clients still get
329
+ every frame. Notifications are always broadcast. The terminal bridge redacts
330
+ raw command bytes from broadcast meta, but treat anything you return as
331
+ visible to every holder of the token — don't return secrets.
332
+ - **The relay serves SSE and long-poll.** Behind Cloudflare (whose HTTP/3 edge
333
+ resets long SSE streams) install `@particle-academy/fancy-cf-relay` so the
334
+ browser polls; see `docs/relay-server.md`. Pass `--cors` a list of your
335
+ sites' origins rather than leaving `*` on a public relay.
329
336
 
330
337
  ## Status
331
338
 
@@ -1,5 +1,5 @@
1
1
  import { randomBytes, createHash, timingSafeEqual } from 'crypto';
2
- import { URL } from 'url';
2
+ import { URL as URL$1 } from 'url';
3
3
 
4
4
  // src/relay-server/core.ts
5
5
  var MemoryStore = class {
@@ -22,6 +22,7 @@ var MemoryStore = class {
22
22
  }
23
23
  };
24
24
  var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;
25
+ var SUBSCRIBER_ID_PATTERN = /^[a-f0-9]{16}$/;
25
26
  var RelayBroker = class {
26
27
  constructor(opts = {}) {
27
28
  /** Per-session, per-direction subscriber list. */
@@ -29,6 +30,7 @@ var RelayBroker = class {
29
30
  /** Per-session map of JSON-RPC request id -> the client that asked. */
30
31
  this.callers = /* @__PURE__ */ new Map();
31
32
  this.ttlMs = opts.ttlMs ?? 4 * 60 * 60 * 1e3;
33
+ this.pollIdleMs = opts.pollIdleMs ?? 6e4;
32
34
  this.store = opts.store ?? new MemoryStore();
33
35
  const tick = opts.reapIntervalMs ?? 6e4;
34
36
  if (tick > 0) {
@@ -40,6 +42,7 @@ var RelayBroker = class {
40
42
  }
41
43
  dispose() {
42
44
  if (this.reaper) clearInterval(this.reaper);
45
+ for (const id of [...this.subs.keys()]) this.endSubscribers(id);
43
46
  this.subs.clear();
44
47
  this.callers.clear();
45
48
  }
@@ -64,6 +67,7 @@ var RelayBroker = class {
64
67
  unregister(id, token) {
65
68
  if (!this.validate(id, token)) return false;
66
69
  this.store.deleteSession(id);
70
+ this.endSubscribers(id);
67
71
  this.subs.delete(id);
68
72
  this.callers.delete(id);
69
73
  return true;
@@ -113,6 +117,7 @@ var RelayBroker = class {
113
117
  /** Drop a session — the page closed, or a test needs it gone. */
114
118
  dropSession(id) {
115
119
  this.store.deleteSession(id);
120
+ this.endSubscribers(id);
116
121
  this.subs.delete(id);
117
122
  this.callers.delete(id);
118
123
  }
@@ -143,7 +148,8 @@ var RelayBroker = class {
143
148
  * the caller (an HTTP handler) pumps as SSE.
144
149
  */
145
150
  subscribe(id, token, direction, opts = {}) {
146
- if (!this.validate(id, token)) return { ok: false, reason: "invalid_token" };
151
+ const auth = this.check(id, token);
152
+ if (!auth.ok) return auth;
147
153
  const subscriberId = randomBytes(8).toString("hex");
148
154
  const subscriber = {
149
155
  id: subscriberId,
@@ -153,31 +159,7 @@ var RelayBroker = class {
153
159
  client: opts.client
154
160
  };
155
161
  this.getDirSubs(id, direction).set(subscriberId, subscriber);
156
- if (direction === "outbound") {
157
- this.fanOut(
158
- id,
159
- "inbound",
160
- JSON.stringify({
161
- jsonrpc: "2.0",
162
- method: "notifications/peer_joined",
163
- params: { subscriberId, ts: Date.now() }
164
- })
165
- );
166
- }
167
- const unsubscribe = () => {
168
- this.getDirSubs(id, direction).delete(subscriberId);
169
- if (direction === "outbound") {
170
- this.fanOut(
171
- id,
172
- "inbound",
173
- JSON.stringify({
174
- jsonrpc: "2.0",
175
- method: "notifications/peer_left",
176
- params: { subscriberId, ts: Date.now() }
177
- })
178
- );
179
- }
180
- };
162
+ if (direction === "outbound") this.announcePeer(id, "notifications/peer_joined", subscriberId);
181
163
  const frames = async function* () {
182
164
  while (true) {
183
165
  if (this.queue.length > 0) {
@@ -185,6 +167,7 @@ var RelayBroker = class {
185
167
  if (next2 !== void 0) yield next2;
186
168
  continue;
187
169
  }
170
+ if (this.ended) return;
188
171
  const next = await new Promise((resolve) => {
189
172
  this.resolveNext = resolve;
190
173
  });
@@ -198,8 +181,88 @@ var RelayBroker = class {
198
181
  subscriberId,
199
182
  frames: frames(),
200
183
  unsubscribe: () => {
201
- subscriber.resolveNext?.(null);
202
- unsubscribe();
184
+ this.endSubscriber(subscriber);
185
+ this.removeSubscriber(id, subscriber);
186
+ }
187
+ };
188
+ }
189
+ /**
190
+ * Attach a LONG-POLL subscriber, or re-attach the one a previous poll created.
191
+ *
192
+ * The CDN-safe receive leg: a series of short requests instead of one open
193
+ * stream, because Cloudflare's HTTP/3 edge resets long-lived SSE. Same
194
+ * contract as the PHP relay in px-ui-sandbox and the one
195
+ * `@particle-academy/fancy-cf-relay` and `mcp-relay-client` speak:
196
+ *
197
+ * - The first poll gets a fresh 16-hex subscriber id; the client echoes it back
198
+ * as `subscriber` on every later poll and reads the same queue.
199
+ * - An id in that shape that this session does not know (pruned, say) is
200
+ * adopted as a NEW subscriber under that id, as the PHP relay does, so a
201
+ * client that went quiet and came back keeps its identity.
202
+ * - An id belonging to a STREAMING subscriber is never adopted: one queue read
203
+ * by two consumers splits its frames between them.
204
+ *
205
+ * Frames queue on the subscriber between polls, so nothing is lost in the gap
206
+ * between one request ending and the next arriving. A poller that stops is
207
+ * pruned after `pollIdleMs`.
208
+ */
209
+ poll(id, token, direction, opts = {}) {
210
+ const auth = this.check(id, token);
211
+ if (!auth.ok) return auth;
212
+ const dir = this.getDirSubs(id, direction);
213
+ const requested = opts.subscriber && SUBSCRIBER_ID_PATTERN.test(opts.subscriber) ? opts.subscriber : void 0;
214
+ const existing = requested ? dir.get(requested) : void 0;
215
+ let subscriber;
216
+ let created;
217
+ if (existing?.poll) {
218
+ subscriber = existing;
219
+ created = false;
220
+ } else {
221
+ const subscriberId = requested && !existing ? requested : randomBytes(8).toString("hex");
222
+ subscriber = {
223
+ id: subscriberId,
224
+ direction,
225
+ queue: [],
226
+ resolveNext: null,
227
+ client: opts.client,
228
+ poll: { lastSeen: Date.now(), parked: 0, wakers: /* @__PURE__ */ new Set() }
229
+ };
230
+ dir.set(subscriberId, subscriber);
231
+ created = true;
232
+ if (direction === "outbound") this.announcePeer(id, "notifications/peer_joined", subscriberId);
233
+ }
234
+ const state = subscriber.poll;
235
+ state.lastSeen = Date.now();
236
+ return {
237
+ ok: true,
238
+ subscriberId: subscriber.id,
239
+ created,
240
+ wait: async (ms, signal) => {
241
+ state.parked++;
242
+ state.lastSeen = Date.now();
243
+ try {
244
+ if (subscriber.queue.length === 0 && ms > 0 && !subscriber.ended && !signal?.aborted) {
245
+ await new Promise((resolve) => {
246
+ const done = () => {
247
+ clearTimeout(timer);
248
+ signal?.removeEventListener("abort", done);
249
+ state.wakers.delete(done);
250
+ resolve();
251
+ };
252
+ const timer = setTimeout(done, ms);
253
+ state.wakers.add(done);
254
+ signal?.addEventListener("abort", done, { once: true });
255
+ });
256
+ }
257
+ if (signal?.aborted) return [];
258
+ return subscriber.queue.splice(0);
259
+ } finally {
260
+ state.parked--;
261
+ state.lastSeen = Date.now();
262
+ }
263
+ },
264
+ requeue: (frames) => {
265
+ if (frames.length > 0 && !subscriber.ended) subscriber.queue.unshift(...frames);
203
266
  }
204
267
  };
205
268
  }
@@ -292,16 +355,78 @@ var RelayBroker = class {
292
355
  if (!dir) return;
293
356
  for (const sub of dir.values()) {
294
357
  if (sub.client !== client) continue;
295
- sub.queue.push(payload);
296
- sub.resolveNext?.(sub.queue.shift() ?? null);
358
+ this.deliver(sub, payload);
297
359
  }
298
360
  }
299
361
  fanOut(sessionId, direction, payload) {
300
362
  const dir = this.subs.get(sessionId)?.get(direction);
301
363
  if (!dir) return;
302
- for (const sub of dir.values()) {
303
- sub.queue.push(payload);
304
- sub.resolveNext?.(sub.queue.shift() ?? null);
364
+ for (const sub of dir.values()) this.deliver(sub, payload);
365
+ }
366
+ /**
367
+ * Hand one frame to one subscriber.
368
+ *
369
+ * The resolver is CLEARED before it is called. It used to stay set until the
370
+ * generator resumed on a later microtask, so a second frame in the same tick
371
+ * called an already-settled resolver — a no-op — after shifting its frame out
372
+ * of the queue, and that frame was lost. Cleared first, the second frame just
373
+ * waits in the queue for the generator to come back for it.
374
+ */
375
+ deliver(sub, payload) {
376
+ if (sub.ended) return;
377
+ sub.queue.push(payload);
378
+ const resolve = sub.resolveNext;
379
+ if (resolve) {
380
+ sub.resolveNext = null;
381
+ resolve(sub.queue.shift() ?? null);
382
+ return;
383
+ }
384
+ if (sub.poll) for (const wake of [...sub.poll.wakers]) wake();
385
+ }
386
+ /** Tell the page (the inbound side) that an agent arrived or left. */
387
+ announcePeer(sessionId, method, subscriberId) {
388
+ this.fanOut(sessionId, "inbound", JSON.stringify({ jsonrpc: "2.0", method, params: { subscriberId, ts: Date.now() } }));
389
+ }
390
+ /** End one subscriber: close its stream, or release its parked polls. Idempotent. */
391
+ endSubscriber(sub) {
392
+ if (sub.ended) return;
393
+ sub.ended = true;
394
+ const resolve = sub.resolveNext;
395
+ sub.resolveNext = null;
396
+ resolve?.(null);
397
+ if (sub.poll) for (const wake of [...sub.poll.wakers]) wake();
398
+ }
399
+ /**
400
+ * End every subscriber of a session whose session is going away.
401
+ *
402
+ * Streams used to be dropped from the map WITHOUT being woken, so an SSE leg
403
+ * stayed open, heartbeating, for a session that no longer existed — and its
404
+ * client could not learn the page had closed. Ended, the stream closes, the
405
+ * client reconnects, and `events` answers 410.
406
+ */
407
+ endSubscribers(sessionId) {
408
+ const dirs = this.subs.get(sessionId);
409
+ if (!dirs) return;
410
+ for (const dir of dirs.values()) for (const sub of dir.values()) this.endSubscriber(sub);
411
+ }
412
+ /** Remove a subscriber from its session, announcing an agent's departure once. */
413
+ removeSubscriber(sessionId, sub) {
414
+ const dir = this.subs.get(sessionId)?.get(sub.direction);
415
+ if (!dir || dir.get(sub.id) !== sub) return;
416
+ dir.delete(sub.id);
417
+ if (sub.direction === "outbound") this.announcePeer(sessionId, "notifications/peer_left", sub.id);
418
+ }
419
+ /** Drop long-poll subscribers that stopped polling. Never one with a poll parked. */
420
+ prunePollers(now) {
421
+ for (const [sessionId, dirs] of this.subs) {
422
+ for (const dir of dirs.values()) {
423
+ for (const sub of [...dir.values()]) {
424
+ if (!sub.poll || sub.poll.parked > 0) continue;
425
+ if (now - sub.poll.lastSeen <= this.pollIdleMs) continue;
426
+ this.endSubscriber(sub);
427
+ this.removeSubscriber(sessionId, sub);
428
+ }
429
+ }
305
430
  }
306
431
  }
307
432
  /**
@@ -337,18 +462,15 @@ var RelayBroker = class {
337
462
  return "id" in o && hasResult !== hasError;
338
463
  }
339
464
  reap() {
340
- const cutoff = Date.now() - this.ttlMs;
465
+ const now = Date.now();
466
+ const cutoff = now - this.ttlMs;
341
467
  for (const id of this.store.expiredSessionIds(cutoff)) {
342
- const dirs = this.subs.get(id);
343
- if (dirs) {
344
- for (const dir of dirs.values()) {
345
- for (const sub of dir.values()) sub.resolveNext?.(null);
346
- }
347
- }
468
+ this.endSubscribers(id);
348
469
  this.subs.delete(id);
349
470
  this.callers.delete(id);
350
471
  this.store.deleteSession(id);
351
472
  }
473
+ this.prunePollers(now);
352
474
  }
353
475
  };
354
476
  function sha256Hex(input) {
@@ -358,19 +480,69 @@ function timingSafeEqualHex(a, b) {
358
480
  if (a.length !== b.length) return false;
359
481
  return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
360
482
  }
483
+
484
+ // src/relay-server/cors.ts
485
+ function parseCorsOrigins(value) {
486
+ const entries = (typeof value === "string" ? value.split(",") : [...value]).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
487
+ if (entries.length === 0) {
488
+ throw new Error("CORS origin list is empty: pass `*` or one or more origins, e.g. https://example.com");
489
+ }
490
+ if (entries.includes("*")) {
491
+ if (entries.length === 1) return { kind: "any" };
492
+ throw new Error(
493
+ "CORS `*` cannot be combined with named origins \u2014 `*` already allows every origin, so the list would mean nothing. Pass either `*` or the origins."
494
+ );
495
+ }
496
+ const origins = [];
497
+ for (const entry of entries) {
498
+ const origin = normalizeOrigin(entry);
499
+ if (!origins.includes(origin)) origins.push(origin);
500
+ }
501
+ return { kind: "list", origins };
502
+ }
503
+ function normalizeOrigin(raw) {
504
+ if (raw.toLowerCase() === "null") {
505
+ throw new Error("CORS origin `null` is refused: it is what sandboxed iframes and file:// pages send.");
506
+ }
507
+ let url;
508
+ try {
509
+ url = new URL(raw);
510
+ } catch {
511
+ throw new Error(`CORS origin "${raw}" is not an origin \u2014 expected scheme://host[:port], e.g. https://example.com`);
512
+ }
513
+ if (url.pathname !== "/" && url.pathname !== "" || url.search || url.hash || url.username || url.password) {
514
+ throw new Error(`CORS origin "${raw}" has a path, query or credentials \u2014 an origin is only scheme://host[:port]`);
515
+ }
516
+ if (url.origin !== "null") return url.origin;
517
+ if (url.host) return `${url.protocol}//${url.host}`;
518
+ throw new Error(`CORS origin "${raw}" is not an origin \u2014 expected scheme://host[:port], e.g. https://example.com`);
519
+ }
520
+ function allowOriginFor(policy, requestOrigin) {
521
+ if (policy.kind === "any") return "*";
522
+ return requestOrigin !== void 0 && policy.origins.includes(requestOrigin) ? requestOrigin : null;
523
+ }
524
+ function describeCorsPolicy(policy) {
525
+ return policy.kind === "any" ? "*" : policy.origins.join(",");
526
+ }
527
+ var POLL_MAX_WAIT_MS = 25e3;
528
+ var POLL_DEFAULT_WAIT_MS = 2e4;
529
+ function pollWaitMs(raw) {
530
+ if (raw === null || raw === void 0) return POLL_DEFAULT_WAIT_MS;
531
+ const n = Number.parseInt(raw, 10);
532
+ if (!Number.isFinite(n)) return 0;
533
+ return Math.min(Math.max(n, 0), POLL_MAX_WAIT_MS);
534
+ }
361
535
  function createNodeRelay(opts = {}) {
362
536
  const broker = new RelayBroker(opts);
363
537
  const prefix = (opts.pathPrefix ?? "").replace(/\/$/, "");
364
- const cors = opts.corsAllowOrigin ?? "*";
365
- const allowedOrigins = opts.allowedOrigins;
538
+ const cors = opts.allowedOrigins && opts.allowedOrigins.length > 0 ? parseCorsOrigins(opts.allowedOrigins) : parseCorsOrigins(opts.corsAllowOrigin ?? "*");
366
539
  function setCorsHeaders(res, req) {
367
- if (allowedOrigins && allowedOrigins.length) {
368
- const origin = req?.headers.origin;
369
- res.setHeader("access-control-allow-origin", origin && allowedOrigins.includes(origin) ? origin : "null");
370
- res.setHeader("vary", "origin");
371
- } else {
372
- res.setHeader("access-control-allow-origin", cors);
540
+ if (cors.kind === "list") {
541
+ res.setHeader("vary", "Origin");
373
542
  }
543
+ const origin = req?.headers.origin;
544
+ const allow = allowOriginFor(cors, typeof origin === "string" ? origin : void 0);
545
+ if (allow !== null) res.setHeader("access-control-allow-origin", allow);
374
546
  res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
375
547
  res.setHeader("access-control-allow-headers", "content-type, x-csrf-token, accept, authorization");
376
548
  res.setHeader("access-control-max-age", "86400");
@@ -399,12 +571,12 @@ function createNodeRelay(opts = {}) {
399
571
  }
400
572
  function getQuery(req) {
401
573
  const host = req.headers.host || "x";
402
- const u = new URL(req.url || "/", `http://${host}`);
574
+ const u = new URL$1(req.url || "/", `http://${host}`);
403
575
  return u.searchParams;
404
576
  }
405
577
  function getPathname(req) {
406
578
  const host = req.headers.host || "x";
407
- const u = new URL(req.url || "/", `http://${host}`);
579
+ const u = new URL$1(req.url || "/", `http://${host}`);
408
580
  return u.pathname;
409
581
  }
410
582
  const register = async (req, res) => {
@@ -487,7 +659,7 @@ function createNodeRelay(opts = {}) {
487
659
  client: getQuery(req).get("client") ?? void 0
488
660
  });
489
661
  if (!sub.ok) {
490
- res.statusCode = 401;
662
+ res.statusCode = sub.reason === "session_gone" ? 410 : 401;
491
663
  res.setHeader("content-type", "text/event-stream");
492
664
  res.write(`event: error
493
665
  data: ${sub.reason}
@@ -532,6 +704,38 @@ data: ${frame}
532
704
  res.end();
533
705
  }
534
706
  };
707
+ const poll = async (req, res) => {
708
+ setCorsHeaders(res, req);
709
+ if (req.method === "OPTIONS") {
710
+ res.statusCode = 204;
711
+ return res.end();
712
+ }
713
+ if (req.method !== "GET") return json(res, 405, { error: "method_not_allowed" });
714
+ const session = extractSession(req, prefix);
715
+ if (!session) return json(res, 400, { error: "missing_session" });
716
+ const q = getQuery(req);
717
+ const direction = q.get("direction") === "outbound" ? "outbound" : "inbound";
718
+ const attached = broker.poll(session, q.get("token") ?? "", direction, {
719
+ subscriber: q.get("subscriber") ?? void 0,
720
+ client: q.get("client") ?? void 0
721
+ });
722
+ if (!attached.ok) {
723
+ return json(res, attached.reason === "session_gone" ? 410 : 401, { error: attached.reason });
724
+ }
725
+ const gone = new AbortController();
726
+ const onClose = () => {
727
+ if (!res.writableEnded) gone.abort();
728
+ };
729
+ res.on("close", onClose);
730
+ const frames = await attached.wait(pollWaitMs(q.get("wait")), gone.signal);
731
+ res.off("close", onClose);
732
+ if (gone.signal.aborted || res.destroyed) {
733
+ attached.requeue(frames);
734
+ return;
735
+ }
736
+ res.setHeader("cache-control", "no-store");
737
+ return json(res, 200, { subscriber: attached.subscriberId, frames });
738
+ };
535
739
  const handler = async (req, res) => {
536
740
  const pathname = getPathname(req);
537
741
  if (!pathname.startsWith(prefix + "/")) {
@@ -539,20 +743,21 @@ data: ${frame}
539
743
  }
540
744
  const rest = pathname.slice(prefix.length);
541
745
  if (rest === "/register") return register(req, res);
542
- const m = /^\/([A-Za-z0-9_-]{4,64})\/(inbox|outbox|events|unregister)$/.exec(rest);
746
+ const m = /^\/([A-Za-z0-9_-]{4,64})\/(inbox|outbox|events|poll|unregister)$/.exec(rest);
543
747
  if (!m) return json(res, 404, { error: "not_found" });
544
748
  const route = m[2];
545
749
  if (route === "inbox") return inbox(req, res);
546
750
  if (route === "outbox") return outbox(req, res);
547
751
  if (route === "events") return events(req, res);
752
+ if (route === "poll") return poll(req, res);
548
753
  if (route === "unregister") return unregister(req, res);
549
754
  return json(res, 404, { error: "not_found" });
550
755
  };
551
- return { broker, handler, register, inbox, outbox, events, unregister };
756
+ return { broker, handler, register, inbox, outbox, events, poll, unregister };
552
757
  }
553
758
  function extractSession(req, prefix) {
554
759
  const host = req.headers.host || "x";
555
- const u = new URL(req.url || "/", `http://${host}`);
760
+ const u = new URL$1(req.url || "/", `http://${host}`);
556
761
  const path = u.pathname;
557
762
  if (prefix && !path.startsWith(prefix + "/")) return null;
558
763
  const rest = prefix ? path.slice(prefix.length) : path;
@@ -564,6 +769,6 @@ function flush(res) {
564
769
  r.flush?.();
565
770
  }
566
771
 
567
- export { RelayBroker, createNodeRelay };
568
- //# sourceMappingURL=chunk-OZBUPO4K.js.map
569
- //# sourceMappingURL=chunk-OZBUPO4K.js.map
772
+ export { RelayBroker, createNodeRelay, describeCorsPolicy, parseCorsOrigins };
773
+ //# sourceMappingURL=chunk-5W34X4EY.js.map
774
+ //# sourceMappingURL=chunk-5W34X4EY.js.map