@ixo/oracle-runtime-workers 0.12.0 → 0.15.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 +32 -10
- package/package.json +14 -9
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +198 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +523 -111
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- 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
|
|
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
|
-
* `
|
|
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
|
-
/**
|
|
103
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
277
|
-
setTimeout(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
566
|
-
*
|
|
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
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
735
|
+
toolCallId,
|
|
600
736
|
toolName: params.toolName,
|
|
601
737
|
args: params.args,
|
|
602
738
|
...(kind === 'agui' ? { status: 'isRunning' } : {}),
|
|
603
739
|
};
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
206
|
-
* null with
|
|
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.
|
|
168
|
+
return this.getValuesFor(roomId, index);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
|
|
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();
|