@ixo/oracle-runtime-workers 0.13.0 → 0.16.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.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +2003 -274
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +562 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -16,12 +16,21 @@
16
16
  * 42["ping"] / 42["status"] / 42["list-events"]
17
17
  * → Node's diagnostic events, same payloads.
18
18
  * 42["tool_result",{toolCallId,result,error?}] / 42["action_call_result",…]
19
- * → settle the pending browser-tool / AG-UI call with that id.
19
+ * → settle the browser-tool / AG-UI invocation with that id, when this
20
+ * is the socket it was sent to (see "Frontend calls" below).
20
21
  *
21
22
  * Outbound, every event the runtime emits for a session (`ctx.emit.*`,
22
- * `browser_tool_call`, `action_call`, …) is fanned out to the session's
23
+ * `tool_call`, `render_component`, …) is fanned out to the session's
23
24
  * authenticated sockets — the router's tap.
24
25
  *
26
+ * Frontend calls (`ctx.frontend.callBrowserTool` / `callAgAction`) are not
27
+ * fanned out: each invocation gets its own id, is sent to ONE socket of its
28
+ * session (the one most recently active), and only that socket's answer
29
+ * settles it. When the
30
+ * answer cannot come — deadline, or that socket went — the call resolves
31
+ * with `FRONTEND_OUTCOME_UNKNOWN` and is never re-sent. See
32
+ * docs/frontend-bridge.md.
33
+ *
25
34
  * Heartbeat: engine.io's server-initiated ping, driven by the OBJECT'S
26
35
  * ALARM rather than a timer. A timer would keep the object resident (and
27
36
  * billed) for as long as a browser tab is open; with the alarm the object
@@ -40,9 +49,12 @@ import type {
40
49
  FrontendCallSurface,
41
50
  } from '../plugin-api/types';
42
51
  import type { RawEventPayload } from '../core/runtime-context';
52
+ import { frontendInvocationId } from '@ixo/common/ai/frontend-bridge';
43
53
  import {
54
+ FRONTEND_CALL_LABEL,
44
55
  FrontendCallRegistry,
45
56
  type FrontendCallKind,
57
+ type FrontendResultRejection,
46
58
  type PendingFrontendCall,
47
59
  } from './frontend-call-registry';
48
60
  import { SessionSocketHub, type SocketAttachment } from './session-socket-hub';
@@ -99,16 +111,38 @@ const SERVER_EVENTS = [
99
111
 
100
112
  /** Socket event every runtime event travels under, carrying `{ eventName, payload }` (Node's wire). */
101
113
  const ENVELOPE_EVENT = 'event';
102
- /** Frontend calls the client SDK listens for by name with the raw payload. */
103
- const RAW_NAMED_EVENTS: ReadonlySet<string> = new Set([
114
+ /**
115
+ * Frontend calls: the client SDK listens for them by name with the raw
116
+ * payload and EXECUTES them, so they reach a socket only as a dispatched
117
+ * invocation (`call`), never through the fan-out.
118
+ */
119
+ const FRONTEND_CALL_EVENTS: ReadonlySet<string> = new Set([
104
120
  'browser_tool_call',
105
121
  'action_call',
106
122
  ]);
107
123
 
124
+ /** Warning text per rejected result (identifiers only, never the payload). */
125
+ const REJECTION_TEXT: Record<FrontendResultRejection, string> = {
126
+ 'not-issued': 'no such invocation was issued (or its record expired)',
127
+ 'already-settled': 'the invocation already settled (duplicate or late)',
128
+ 'wrong-socket': 'this is not the socket it was sent to',
129
+ 'wrong-kind': 'the invocation is of the other kind',
130
+ };
131
+
108
132
  /** Close codes in the 4000–4999 application range. */
109
133
  const CLOSE_UNAUTHORIZED = 4401;
110
134
  const CLOSE_HANDSHAKE_TIMEOUT = 4408;
111
135
  const CLOSE_PROTOCOL = 4400;
136
+ /** RFC 6455 "message too big". */
137
+ const CLOSE_TOO_LARGE = 1009;
138
+
139
+ /** Whether a text frame is over `MAX_PAYLOAD_BYTES` in UTF-8 bytes (encoded only when its length leaves it in doubt). */
140
+ function exceedsPayloadLimit(message: string): boolean {
141
+ // A UTF-16 unit is at least one UTF-8 byte and at most three.
142
+ if (message.length > MAX_PAYLOAD_BYTES) return true;
143
+ if (message.length * 3 <= MAX_PAYLOAD_BYTES) return false;
144
+ return new TextEncoder().encode(message).byteLength > MAX_PAYLOAD_BYTES;
145
+ }
112
146
 
113
147
  export interface RealtimeEndpointDeps {
114
148
  /** `ctx.acceptWebSocket` / `ctx.getWebSockets` of the owning object. */
@@ -156,6 +190,8 @@ export interface RealtimeStatus {
156
190
  authenticated: number;
157
191
  sessions: number;
158
192
  pendingCalls: PendingFrontendCall[];
193
+ /** Finished invocations remembered to reject replays (bounded). */
194
+ completedCalls: number;
159
195
  pingIntervalMs: number;
160
196
  pingTimeoutMs: number;
161
197
  /** When the next heartbeat round is due (null with no sockets). */
@@ -189,7 +225,7 @@ function readString(
189
225
  export class RealtimeEndpoint {
190
226
  readonly hub: SessionSocketHub<WebSocket>;
191
227
 
192
- readonly calls = new FrontendCallRegistry();
228
+ readonly calls: FrontendCallRegistry;
193
229
 
194
230
  readonly frontend: FrontendCallSurface;
195
231
 
@@ -198,11 +234,18 @@ export class RealtimeEndpoint {
198
234
  ReturnType<typeof setTimeout>
199
235
  >();
200
236
 
237
+ /** Sockets whose CONNECT is being validated: a second CONNECT meanwhile is ignored. */
238
+ private readonly authenticating = new Set<WebSocket>();
239
+
201
240
  private readonly now: () => number;
202
241
 
203
242
  constructor(private readonly deps: RealtimeEndpointDeps) {
204
243
  this.now = deps.now ?? (() => Date.now());
205
- this.hub = new SessionSocketHub<WebSocket>({ now: this.now });
244
+ this.calls = new FrontendCallRegistry({ now: this.now });
245
+ this.hub = new SessionSocketHub<WebSocket>({
246
+ now: this.now,
247
+ onDropped: (attachment) => this.executorGone(attachment.sid),
248
+ });
206
249
  const tap: EventSink = {
207
250
  emit: (eventName, payload) => this.fanOut(eventName, payload),
208
251
  };
@@ -223,6 +266,14 @@ export class RealtimeEndpoint {
223
266
  const restored = this.hub.restore(deps.ctx.getWebSockets());
224
267
  if (restored > 0) {
225
268
  deps.logger.log(`[realtime] re-adopted ${restored} hibernated socket(s)`);
269
+ // A socket that had not authenticated lost its handshake timer with
270
+ // the old instance: it gets what is left of its window.
271
+ for (const { socket, attachment } of this.hub.entries())
272
+ if (!attachment.userDid)
273
+ this.armHandshakeDeadline(
274
+ socket,
275
+ attachment.openedAt + HANDSHAKE_DEADLINE_MS - this.now(),
276
+ );
226
277
  this.scheduleHeartbeat();
227
278
  }
228
279
  }
@@ -272,16 +323,27 @@ export class RealtimeEndpoint {
272
323
  maxPayloadBytes: MAX_PAYLOAD_BYTES,
273
324
  }),
274
325
  );
326
+ // Until CONNECT succeeds the handshake timer is the socket's only clock:
327
+ // no heartbeat alarm is armed for a caller who has not authenticated.
328
+ this.armHandshakeDeadline(server, HANDSHAKE_DEADLINE_MS);
329
+ return new Response(null, { status: 101, webSocket: client });
330
+ }
331
+
332
+ /** Close the socket unless it has authenticated within `ms`. */
333
+ private armHandshakeDeadline(ws: WebSocket, ms: number): void {
334
+ const existing = this.handshakeTimers.get(ws);
335
+ if (existing) clearTimeout(existing);
275
336
  this.handshakeTimers.set(
276
- server,
277
- setTimeout(() => {
278
- this.handshakeTimers.delete(server);
279
- if (this.hub.get(server)?.userDid) return;
280
- this.drop(server, CLOSE_HANDSHAKE_TIMEOUT, 'handshake timeout');
281
- }, HANDSHAKE_DEADLINE_MS),
337
+ ws,
338
+ setTimeout(
339
+ () => {
340
+ this.handshakeTimers.delete(ws);
341
+ if (this.hub.get(ws)?.userDid) return;
342
+ this.drop(ws, CLOSE_HANDSHAKE_TIMEOUT, 'handshake timeout');
343
+ },
344
+ Math.max(0, ms),
345
+ ),
282
346
  );
283
- this.scheduleHeartbeat();
284
- return new Response(null, { status: 101, webSocket: client });
285
347
  }
286
348
 
287
349
  // ── Hibernatable WebSocket handlers ─────────────────────────────────────
@@ -296,6 +358,11 @@ export class RealtimeEndpoint {
296
358
  this.drop(ws, CLOSE_PROTOCOL, 'binary frames are not supported');
297
359
  return;
298
360
  }
361
+ if (exceedsPayloadLimit(message)) {
362
+ // The limit the OPEN handshake advertised; nothing larger is parsed.
363
+ this.drop(ws, CLOSE_TOO_LARGE, 'frame exceeds maxPayload');
364
+ return;
365
+ }
299
366
  const frame = decodeClientFrame(message);
300
367
  switch (frame.kind) {
301
368
  case 'pong':
@@ -317,6 +384,8 @@ export class RealtimeEndpoint {
317
384
  this.drop(ws, CLOSE_UNAUTHORIZED, 'event before CONNECT');
318
385
  return;
319
386
  }
387
+ // Client activity picks the tab frontend calls run on.
388
+ this.hub.noteActivity(ws);
320
389
  this.handleEvent(ws, meta, frame.name, frame.args[0]);
321
390
  if (frame.ackId !== undefined)
322
391
  this.hub.send(ws, encodeAck(frame.ackId));
@@ -351,6 +420,7 @@ export class RealtimeEndpoint {
351
420
  authenticated: this.hub.connectionCount(),
352
421
  sessions: this.hub.sessionCount(),
353
422
  pendingCalls: this.calls.list(),
423
+ completedCalls: this.calls.completedCount,
354
424
  pingIntervalMs: PING_INTERVAL_MS,
355
425
  pingTimeoutMs: PING_TIMEOUT_MS,
356
426
  nextPingAt: this.nextPingAt(),
@@ -400,6 +470,22 @@ export class RealtimeEndpoint {
400
470
  this.hub.send(ws, encodeConnectAck(meta.sid));
401
471
  return;
402
472
  }
473
+ // One validation per socket at a time: each one may resolve keys from
474
+ // Blocksync, and a client has no reason to send CONNECT twice.
475
+ if (this.authenticating.has(ws)) return;
476
+ this.authenticating.add(ws);
477
+ try {
478
+ await this.authenticateConnect(ws, meta, auth);
479
+ } finally {
480
+ this.authenticating.delete(ws);
481
+ }
482
+ }
483
+
484
+ private async authenticateConnect(
485
+ ws: WebSocket,
486
+ meta: SocketAttachment,
487
+ auth: unknown,
488
+ ): Promise<void> {
403
489
  const record = isRecord(auth) ? auth : {};
404
490
  const invocation = readString(record, 'invocation');
405
491
  const ucanDelegation = readString(record, 'ucanDelegation');
@@ -434,9 +520,27 @@ export class RealtimeEndpoint {
434
520
  this.refuse(ws, 'Unauthorized: token does not belong to the routed user');
435
521
  return;
436
522
  }
437
- if (
438
- !(await this.deps.sessionExists(outcome.auth.userDid, meta.sessionId))
439
- ) {
523
+ if (outcome.auth.via === 'delegation')
524
+ // The shell logs the same line per HTTP request: operators find the
525
+ // clients that still need UCAN_ALLOW_BARE_DELEGATION_AUTH by it.
526
+ this.deps.logger.warn(
527
+ `[auth] socket CONNECT ${meta.sessionId}: ${outcome.auth.userDid} authenticated with a bare delegation (UCAN_ALLOW_BARE_DELEGATION_AUTH); the client must send a UCAN invocation before the fallback is turned off`,
528
+ );
529
+ let owned: boolean;
530
+ try {
531
+ owned = await this.deps.sessionExists(
532
+ outcome.auth.userDid,
533
+ meta.sessionId,
534
+ );
535
+ } catch (err) {
536
+ // Ownership that cannot be checked is not granted.
537
+ this.deps.logger.warn(
538
+ `[realtime] session check for ${meta.sessionId} failed: ${err instanceof Error ? err.message : String(err)}`,
539
+ );
540
+ this.refuse(ws, 'Unauthorized: session check failed');
541
+ return;
542
+ }
543
+ if (!owned) {
440
544
  this.refuse(ws, `Session ${meta.sessionId} not found`);
441
545
  return;
442
546
  }
@@ -460,6 +564,8 @@ export class RealtimeEndpoint {
460
564
  this.deps.logger.log(
461
565
  `[realtime] ${outcome.auth.userDid} connected to session ${meta.sessionId} (${meta.sid})`,
462
566
  );
567
+ // From here the heartbeat (the object's alarm) keeps the socket honest.
568
+ this.scheduleHeartbeat();
463
569
  }
464
570
 
465
571
  private refuse(ws: WebSocket, message: string): void {
@@ -534,21 +640,33 @@ export class RealtimeEndpoint {
534
640
  ): void {
535
641
  const record = isRecord(data) ? data : {};
536
642
  const toolCallId = readString(record, 'toolCallId');
537
- if (!toolCallId) {
643
+ const userDid = meta.userDid;
644
+ if (!toolCallId || !userDid) {
538
645
  this.deps.logger.warn(
539
646
  `[realtime] ${kind} result without toolCallId from ${meta.sid}`,
540
647
  );
541
648
  return;
542
649
  }
650
+ // Routing comes from the socket alone. A client that names another
651
+ // session is making a claim it cannot make; the result is dropped and
652
+ // the call keeps waiting for its own socket's answer.
653
+ const claimed = record.sessionId;
654
+ if (claimed !== undefined && claimed !== meta.sessionId) {
655
+ this.deps.logger.warn(
656
+ `[realtime] ${kind} result for ${toolCallId} rejected: socket ${meta.sid} of session ${meta.sessionId} names session ${typeof claimed === 'string' ? claimed : typeof claimed}`,
657
+ );
658
+ return;
659
+ }
543
660
  const error = readString(record, 'error');
544
- const settled = this.calls.settle(kind, {
661
+ const outcome = this.calls.settle(kind, {
545
662
  toolCallId,
663
+ from: { sid: meta.sid, sessionId: meta.sessionId, userDid },
546
664
  result: record.result,
547
665
  ...(error ? { error } : {}),
548
666
  });
549
- if (!settled) {
667
+ if (!outcome.settled) {
550
668
  this.deps.logger.warn(
551
- `[realtime] ${kind} result for ${toolCallId} had no pending call (late or duplicate)`,
669
+ `[realtime] ${kind} result for ${toolCallId} from socket ${meta.sid} (session ${meta.sessionId}) rejected: ${REJECTION_TEXT[outcome.reason]}`,
552
670
  );
553
671
  }
554
672
  }
@@ -562,51 +680,102 @@ export class RealtimeEndpoint {
562
680
  * SDK's catch-all listener validates exactly that (`payload.sessionId` and
563
681
  * `payload.requestId`) and drops a bare payload, while its named
564
682
  * `tool_call` / `render_component` listeners crash on one. The two calls
565
- * the SDK answers (`browser_tool_call`, `action_call`) are the exception:
566
- * it listens for them by name with the raw payload, on both runtimes.
683
+ * the SDK EXECUTES (`browser_tool_call`, `action_call`) never travel this
684
+ * way: a frame of theirs here is the SSE stream's mirror of a sub-agent
685
+ * tool call or a plugin's one-way emit — not an invocation — and a browser
686
+ * that ran it would act again. Invocations go out through `call`.
567
687
  */
568
688
  private fanOut(eventName: string, payload: RawEventPayload): void {
569
689
  const sessionId =
570
690
  typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
571
- if (!sessionId) return;
572
- if (RAW_NAMED_EVENTS.has(eventName)) {
573
- this.hub.emitToSession(sessionId, eventName, payload);
574
- return;
575
- }
691
+ if (!sessionId || FRONTEND_CALL_EVENTS.has(eventName)) return;
576
692
  this.hub.emitToSession(sessionId, ENVELOPE_EVENT, { eventName, payload });
577
693
  }
578
694
 
695
+ /**
696
+ * One frontend invocation: a fresh id, registered before it is sent (an
697
+ * immediate answer cannot be missed), sent to the most recently active
698
+ * authenticated socket of the session (`executorForSession`) and bound to
699
+ * it. A call that reaches no socket is
700
+ * a definite failure; one whose answer never comes is an unknown outcome.
701
+ */
579
702
  private call(
580
703
  kind: FrontendCallKind,
581
704
  eventName: 'browser_tool_call' | 'action_call',
582
705
  params: FrontendCallParams,
583
706
  defaultTimeoutMs: number,
584
707
  ): Promise<unknown> {
585
- const timeoutMs = params.timeoutMs ?? defaultTimeoutMs;
586
- // Register BEFORE emitting so a synchronous answer cannot be missed.
587
- const pending = this.calls.wait(
588
- {
589
- kind,
590
- toolCallId: params.toolCallId,
591
- toolName: params.toolName,
592
- sessionId: params.sessionId,
593
- },
594
- { timeoutMs },
595
- );
708
+ const toolCallId = frontendInvocationId(params.toolCallId);
709
+ const label = FRONTEND_CALL_LABEL[kind];
710
+ let pending: Promise<unknown>;
711
+ try {
712
+ pending = this.calls.open(
713
+ {
714
+ kind,
715
+ toolCallId,
716
+ toolName: params.toolName,
717
+ sessionId: params.sessionId,
718
+ },
719
+ {
720
+ timeoutMs: params.timeoutMs ?? defaultTimeoutMs,
721
+ ...(params.signal ? { signal: params.signal } : {}),
722
+ },
723
+ );
724
+ } catch (err) {
725
+ return Promise.reject(
726
+ err instanceof Error ? err : new Error(String(err)),
727
+ );
728
+ }
729
+ // A turn already aborted rejected the call as not sent: send nothing.
730
+ if (!this.calls.isPending(toolCallId)) return pending;
731
+ params.onInvocation?.(toolCallId);
596
732
  const payload: RawEventPayload = {
597
733
  sessionId: params.sessionId,
598
734
  requestId: params.toolCallId,
599
- toolCallId: params.toolCallId,
735
+ toolCallId,
600
736
  toolName: params.toolName,
601
737
  args: params.args,
602
738
  ...(kind === 'agui' ? { status: 'isRunning' } : {}),
603
739
  };
604
- // Through the router: the session's SSE stream sees it too (Node emits
605
- // it on the root emitter, which feeds both channels).
606
- this.deps.router.emit(eventName, payload);
740
+ const target = this.hub.executorForSession(params.sessionId);
741
+ const userDid = target?.attachment.userDid;
742
+ if (
743
+ !target ||
744
+ !userDid ||
745
+ !this.hub.send(target.socket, encodeEvent(eventName, payload))
746
+ ) {
747
+ this.calls.fail(
748
+ toolCallId,
749
+ new Error(
750
+ `${label} ${params.toolName} was not sent: no browser is connected to session ${params.sessionId}`,
751
+ ),
752
+ );
753
+ return pending;
754
+ }
755
+ this.calls.dispatched(toolCallId, {
756
+ sid: target.attachment.sid,
757
+ sessionId: params.sessionId,
758
+ userDid,
759
+ });
760
+ // A browser tool call is shown on the session's SSE stream too (session
761
+ // sinks; the socket tap skips frontend calls, see `fanOut`). An AG-UI
762
+ // invocation is not: the SSE stream already reports the action as an
763
+ // `action_call` under the model's call id and closes it when the tool
764
+ // ends, and a second frame under the invocation id would never be
765
+ // closed — the client would show a card spinning forever.
766
+ if (kind === 'browser') this.deps.router.emit(eventName, payload);
607
767
  return pending;
608
768
  }
609
769
 
770
+ /** The socket `sid` is gone: its invocations end as unknown, never re-sent. */
771
+ private executorGone(sid: string): void {
772
+ const ended = this.calls.executorGone(sid);
773
+ if (ended > 0)
774
+ this.deps.logger.warn(
775
+ `[realtime] socket ${sid} went with ${ended} frontend call(s) in flight; reported as unknown outcome, not re-sent`,
776
+ );
777
+ }
778
+
610
779
  // ── housekeeping ────────────────────────────────────────────────────────
611
780
 
612
781
  private drop(ws: WebSocket, code: number, reason: string): void {
@@ -624,8 +793,10 @@ export class RealtimeEndpoint {
624
793
  clearTimeout(timer);
625
794
  this.handshakeTimers.delete(ws);
626
795
  }
796
+ this.authenticating.delete(ws);
627
797
  const attachment = this.hub.get(ws);
628
798
  this.hub.remove(ws);
799
+ if (attachment) this.executorGone(attachment.sid);
629
800
  // Only an authenticated socket ever counted for its session; a socket
630
801
  // that never passed CONNECT leaves nothing to drain.
631
802
  if (attachment?.userDid && !this.hub.hasSession(attachment.sessionId)) {
@@ -633,7 +804,7 @@ export class RealtimeEndpoint {
633
804
  }
634
805
  }
635
806
 
636
- /** Make sure an alarm is set for the next heartbeat round. */
807
+ /** Make sure an alarm is set for the next heartbeat round (authenticated sockets only). */
637
808
  private scheduleHeartbeat(): void {
638
809
  const at = this.nextPingAt();
639
810
  if (at !== null) this.deps.requestAlarm?.(at);
@@ -27,6 +27,11 @@ export interface SocketAttachment {
27
27
  lastPongAt: number;
28
28
  /** Last engine.io ping (`2`) sent; unset until the first heartbeat round. */
29
29
  lastPingAt?: number;
30
+ /**
31
+ * Last client-originated socket.io event (a result, `ping`, `status`, …);
32
+ * engine.io pongs do not count — a background tab answers those too.
33
+ */
34
+ lastActiveAt?: number;
30
35
  }
31
36
 
32
37
  /** The subset of the Workers `WebSocket` the hub needs (tests use fakes). */
@@ -40,6 +45,11 @@ export interface HubSocket {
40
45
  export interface SessionSocketHubOptions {
41
46
  logger?: { warn: (message: string) => void };
42
47
  now?: () => number;
48
+ /**
49
+ * A socket the hub dropped on its own — its send threw, or the heartbeat
50
+ * timed it out. (`remove` by the owner does not call it.)
51
+ */
52
+ onDropped?: (attachment: SocketAttachment) => void;
43
53
  }
44
54
 
45
55
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -74,9 +84,12 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
74
84
 
75
85
  private readonly now: () => number;
76
86
 
87
+ private readonly onDropped: (attachment: SocketAttachment) => void;
88
+
77
89
  constructor(options: SessionSocketHubOptions = {}) {
78
90
  this.logger = options.logger ?? { warn: () => undefined };
79
91
  this.now = options.now ?? (() => Date.now());
92
+ this.onDropped = options.onDropped ?? (() => undefined);
80
93
  }
81
94
 
82
95
  /** Register a freshly accepted socket and persist its identity on it. */
@@ -155,6 +168,33 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
155
168
  return this.forSession(sessionId).length > 0;
156
169
  }
157
170
 
171
+ /** A client event arrived on the socket: persist the time (see `executorForSession`). */
172
+ noteActivity(socket: S): void {
173
+ if (this.sockets.has(socket))
174
+ this.update(socket, { lastActiveAt: this.now() });
175
+ }
176
+
177
+ /**
178
+ * The authenticated socket of `sessionId` a frontend call runs on: the one
179
+ * with the most recent client event, else the one opened last. The tab the
180
+ * user acted in last is the likeliest to be in front; the tab that
181
+ * connected last is often just a background tab that reconnected after a
182
+ * laptop woke. Ties go to the socket registered later.
183
+ */
184
+ executorForSession(
185
+ sessionId: string,
186
+ ): { socket: S; attachment: SocketAttachment } | undefined {
187
+ const recency = (attachment: SocketAttachment): number =>
188
+ attachment.lastActiveAt ?? attachment.openedAt;
189
+ let chosen: { socket: S; attachment: SocketAttachment } | undefined;
190
+ for (const [socket, attachment] of this.sockets) {
191
+ if (!attachment.userDid || attachment.sessionId !== sessionId) continue;
192
+ if (!chosen || recency(attachment) >= recency(chosen.attachment))
193
+ chosen = { socket, attachment };
194
+ }
195
+ return chosen;
196
+ }
197
+
158
198
  /** Fan an event out to every authenticated socket of the session. */
159
199
  emitToSession(
160
200
  sessionId: string,
@@ -192,6 +232,7 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
192
232
  // already gone
193
233
  }
194
234
  closed.push(socket);
235
+ this.onDropped(meta);
195
236
  continue;
196
237
  }
197
238
  if (this.trySend(socket, ENGINE_PING))
@@ -202,12 +243,14 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
202
243
 
203
244
  /**
204
245
  * When the next heartbeat round is due: `pingIntervalMs` after the most
205
- * overdue socket's last ping (its open time before the first round), or
206
- * null with no sockets attached.
246
+ * overdue authenticated socket's last ping (its open time before the first
247
+ * round), or null with none attached. A socket still in its handshake is
248
+ * on the handshake deadline, not the heartbeat: it never arms an alarm.
207
249
  */
208
250
  nextPingAt(pingIntervalMs: number): number | null {
209
251
  let next: number | null = null;
210
252
  for (const meta of this.sockets.values()) {
253
+ if (!meta.userDid) continue;
211
254
  const due = (meta.lastPingAt ?? meta.openedAt) + pingIntervalMs;
212
255
  if (next === null || due < next) next = due;
213
256
  }
@@ -242,7 +285,9 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
242
285
  this.logger.warn(
243
286
  `socket send failed, dropping socket: ${err instanceof Error ? err.message : String(err)}`,
244
287
  );
288
+ const attachment = this.sockets.get(socket);
245
289
  this.remove(socket);
290
+ if (attachment) this.onDropped(attachment);
246
291
  return false;
247
292
  }
248
293
  }
@@ -165,12 +165,18 @@ export class WorkersSecretsService {
165
165
  const index = (await this.getIndex(roomId)).filter((entry) =>
166
166
  requested.has(entry.name),
167
167
  );
168
- return this.loadValues(roomId, index);
168
+ return this.getValuesFor(roomId, index);
169
169
  }
170
170
 
171
- private async loadValues(
171
+ /**
172
+ * Decrypted values for index entries the caller already read with
173
+ * {@link getIndex} — the values of exactly those entries, without reading
174
+ * room state again. The entries' eventIds decide cache hits, so a secret
175
+ * rotated after that read is still fetched fresh by the next one.
176
+ */
177
+ async getValuesFor(
172
178
  roomId: string,
173
- index: SecretIndexEntry[],
179
+ index: ReadonlyArray<Pick<SecretIndexEntry, 'name' | 'eventId'>>,
174
180
  ): Promise<Record<string, string>> {
175
181
  const result: Record<string, string> = {};
176
182
  const now = Date.now();