@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
@@ -20,11 +20,14 @@
20
20
  * that reconnects can ask for `?after=<id>`.
21
21
  */
22
22
  import {
23
+ BYO_FALLBACK_KIND,
23
24
  classifyLlmError,
24
25
  isOperatorFault,
25
26
  redactOperatorFault,
26
27
  } from '../llm/provider-error';
28
+ import { reportsUnknownOutcome } from '@ixo/common/ai/frontend-bridge';
27
29
  import { type AIMessageChunk, ToolMessage } from '@langchain/core/messages';
30
+ import { harnessLimitOf, type HarnessLimitError } from '../core/turn-budget';
28
31
  import { RunBuffer, type RunFrame } from './run-buffer';
29
32
 
30
33
  /** Where the producer puts frames — a `RunBuffer`, or a test sink. */
@@ -303,6 +306,32 @@ export async function runTurnFrames(
303
306
  ...(input.runId ? { runId: input.runId } : {}),
304
307
  });
305
308
 
309
+ // The turn hit its budget (tokens, tool attempts, deadline): the abort
310
+ // was the harness's, not the user's, so the client gets a terminal
311
+ // `error` naming the limit before `done` — not a silent "aborted".
312
+ const limitFailure = (limit: HarnessLimitError) => {
313
+ flushOrphans();
314
+ write('reasoning', {
315
+ sessionId,
316
+ requestId,
317
+ reasoning: '',
318
+ isComplete: true,
319
+ timestamp: new Date().toISOString(),
320
+ });
321
+ write('error', {
322
+ error: limit.message,
323
+ kind: limit.kind,
324
+ limit: limit.limit,
325
+ source: 'platform',
326
+ retryable: false,
327
+ sessionId,
328
+ requestId,
329
+ timestamp: new Date().toISOString(),
330
+ });
331
+ doneFrame({ failed: true });
332
+ return { status: 'failed' as const, fullText: fullContent, error: limit };
333
+ };
334
+
306
335
  const flushOrphans = () => {
307
336
  for (const evt of actionCallMap.values()) {
308
337
  write('action_call', {
@@ -362,6 +391,15 @@ export async function runTurnFrames(
362
391
  metadata?: Record<string, unknown>;
363
392
  tags?: string[];
364
393
  };
394
+ if (evt.event === 'on_custom_event' && evt.name === BYO_FALLBACK_KIND) {
395
+ // A BYO model the provider refused mid-turn (byo-model-fallback.ts):
396
+ // the same `error`-channel notice the pre-turn fallbacks send, in
397
+ // stream order — ahead of the platform model's reply to that call.
398
+ const notice = evt.data;
399
+ if (notice && typeof notice === 'object')
400
+ write('error', { ...notice, sessionId, requestId });
401
+ continue;
402
+ }
365
403
  if (evt.event === 'on_chat_model_end') {
366
404
  if (isInternalModelEvent(evt)) continue;
367
405
  const output = (evt.data as { output?: AIMessageChunk })?.output;
@@ -441,7 +479,12 @@ export async function runTurnFrames(
441
479
  const action = actionCallMap.get(evt.run_id);
442
480
  if (action) {
443
481
  const parsed = safeParseToolContent(output?.content);
444
- const failed = parsed?.success === false || Boolean(parsed?.error);
482
+ // An unknown outcome (FRONTEND_OUTCOME_UNKNOWN) is not a failure:
483
+ // the action may have run, so the client must not offer a retry.
484
+ // It ends as `done` with the unknown result as its output.
485
+ const failed =
486
+ !reportsUnknownOutcome(parsed) &&
487
+ (parsed?.success === false || Boolean(parsed?.error));
445
488
  write('action_call', {
446
489
  ...action,
447
490
  output: text,
@@ -579,19 +622,25 @@ export async function runTurnFrames(
579
622
  doneFrame();
580
623
  return { status: 'completed', fullText: fullContent };
581
624
  }
625
+ const reasonLimit = harnessLimitOf(abortController.signal.reason);
626
+ if (reasonLimit) return limitFailure(reasonLimit);
582
627
  // Aborted (POST /messages/abort, or superseded): close the stream
583
628
  // cleanly so the UI leaves its "thinking" state.
584
629
  flushOrphans();
585
630
  doneFrame({ aborted: true });
586
631
  return { status: 'aborted', fullText: fullContent };
587
632
  } catch (error) {
588
- // The run's signal is the authority: LangGraph rethrows the signal's
589
- // reason (whatever the aborter passed), not always an `AbortError`.
590
- const aborted =
591
- abortController.signal.aborted ||
592
- (error instanceof Error &&
593
- (error.name === 'AbortError' || /abort/i.test(error.message)));
594
- if (aborted) {
633
+ // A limit reaches here wrapped by LangChain's middleware layers; the
634
+ // original carries the `limit` the client is told about.
635
+ const limit =
636
+ harnessLimitOf(error) ?? harnessLimitOf(abortController.signal.reason);
637
+ if (limit) return limitFailure(limit);
638
+ // The run's signal is the only authority: LangGraph rethrows the
639
+ // signal's reason (whatever the aborter passed), not always an
640
+ // `AbortError`, while an `AbortError` or "aborted" message with the
641
+ // signal untouched is a provider or tool timing out — a failure the
642
+ // client must see as one.
643
+ if (abortController.signal.aborted) {
595
644
  flushOrphans();
596
645
  doneFrame({ aborted: true });
597
646
  return { status: 'aborted', fullText: fullContent };
@@ -686,7 +735,9 @@ export function createSseSubscriberStream(
686
735
  for (const frame of input.replay) write(frame);
687
736
  if (closed) return;
688
737
  if (input.buffer && !input.buffer.isClosed) {
689
- unsubscribe = input.buffer.subscribe(write);
738
+ // A buffer that closes without a `done` (it never should) still
739
+ // ends the response instead of leaving it on heartbeats.
740
+ unsubscribe = input.buffer.subscribe(write, finish);
690
741
  } else {
691
742
  if (input.trailer)
692
743
  write({
@@ -13,6 +13,13 @@ import {
13
13
  isAttachmentViewMessage,
14
14
  } from '../attachments/retention';
15
15
  import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
16
+ import {
17
+ stripTurnTimeNote,
18
+ TURN_TIME_NOTE_KWARG,
19
+ } from '../core/turn-time-note';
20
+ import { ChannelOrigin } from '../channels/contract';
21
+ import { CREATE_ARTIFACT_TOOL, createArtifactParts } from '../artifacts/tool';
22
+ import { planText } from '../delivery/schema';
16
23
  import type {
17
24
  ThreadMessageAnchor,
18
25
  ThreadMessageRow,
@@ -45,6 +52,7 @@ export interface MessageDto {
45
52
  isReasoning?: boolean;
46
53
  attachment?: AttachmentMeta;
47
54
  attachments?: AttachmentMeta[];
55
+ metadata?: { 'org.ixo.qi.origin': ChannelOrigin };
48
56
  }
49
57
 
50
58
  export interface ListMessagesResponse {
@@ -108,6 +116,25 @@ export function contentToText(content: BaseMessage['content']): string {
108
116
  return String(content);
109
117
  }
110
118
 
119
+ /**
120
+ * A stored message's content as a person (or a service acting for one)
121
+ * reads it: a user message loses the "current time" note the model was
122
+ * given with it, when it still starts with exactly the note recorded on it.
123
+ * Every other message is returned as stored.
124
+ */
125
+ export function visibleContent(message: BaseMessage): BaseMessage['content'] {
126
+ if (message.type !== 'human') return message.content;
127
+ return stripTurnTimeNote(
128
+ message.content,
129
+ message.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
130
+ );
131
+ }
132
+
133
+ /** `visibleContent` as text (titles, mirrors, indexing). */
134
+ export function visibleText(message: BaseMessage): string {
135
+ return contentToText(visibleContent(message));
136
+ }
137
+
111
138
  /**
112
139
  * The summarization middleware's bookkeeping message — never shown to users.
113
140
  * LangChain 1.4 writes it as a HUMAN message tagged
@@ -132,6 +159,9 @@ export async function transformTranscript(
132
159
  messages: BaseMessage[],
133
160
  ): Promise<ListMessagesResponse> {
134
161
  const acc: MessageDto[] = [];
162
+ // The first listed reply that made each tool call: a tool result folds
163
+ // into it (looked up, not searched, so long transcripts stay linear).
164
+ const callers = new Map<string, MessageDto>();
135
165
  for (const message of messages) {
136
166
  const toolMsg = message.type === 'tool' ? (message as ToolMessage) : null;
137
167
  const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
@@ -152,7 +182,7 @@ export async function transformTranscript(
152
182
  (kw.attachment ? [kw.attachment as AttachmentMeta] : undefined))
153
183
  : undefined;
154
184
  const attachment = attachments?.[0];
155
- const textContent = contentToText(message.content);
185
+ const textContent = visibleText(message);
156
186
  const toolCalls = (message as AIMessage).tool_calls;
157
187
  const dto: MessageDto = {
158
188
  type: message.type === 'ai' ? 'ai' : 'human',
@@ -175,7 +205,13 @@ export async function transformTranscript(
175
205
  if (reasoning) dto.reasoning = reasoning;
176
206
  if (attachment) dto.attachment = attachment;
177
207
  if (attachments?.length) dto.attachments = attachments;
208
+ if (message.type === 'human') {
209
+ const origin = ChannelOrigin.safeParse(kw['org.ixo.qi.origin']);
210
+ if (origin.success) dto.metadata = { 'org.ixo.qi.origin': origin.data };
211
+ }
178
212
  acc.push(dto);
213
+ for (const call of dto.toolCalls ?? [])
214
+ if (!callers.has(call.id)) callers.set(call.id, dto);
179
215
  }
180
216
 
181
217
  if (toolMsg) {
@@ -188,11 +224,18 @@ export async function transformTranscript(
188
224
  toolMsg.tool_call_id ??
189
225
  kwargs?.tool_call_id ??
190
226
  (await uuidFromString(JSON.stringify(kwargs?.args)));
191
- const idx = acc.findIndex((m) =>
192
- m.toolCalls?.some((t) => t.id === toolCallId),
193
- );
194
- const el = idx !== -1 ? acc[idx] : undefined;
227
+ const el = callers.get(toolCallId);
195
228
  if (el) {
229
+ // A chat reply that ended in `create_artifact` wrote no text of its
230
+ // own: list what the user received, the message, link and question.
231
+ const call = el.toolCalls?.find((t) => t.id === toolCallId);
232
+ if (call?.name === CREATE_ARTIFACT_TOOL && !el.content.trim())
233
+ el.content = planText({
234
+ parts: createArtifactParts(
235
+ call.args,
236
+ contentToText(toolMsg.content),
237
+ ),
238
+ });
196
239
  el.toolCalls = el.toolCalls?.map((t) =>
197
240
  t.id === toolCallId
198
241
  ? {
@@ -64,7 +64,34 @@ const reject = (message: string): ParsedTurnBody => ({
64
64
  message,
65
65
  });
66
66
 
67
- /** Parse and validate a raw request body. Never throws. */
67
+ type ToolDeclaration = NonNullable<TurnBody['tools']>[number];
68
+
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
71
+ }
72
+
73
+ /** A client-declared tool (or AG-UI action): name, description, schema. */
74
+ function toolDeclaration(value: unknown): ToolDeclaration | null {
75
+ if (
76
+ !isRecord(value) ||
77
+ typeof value.name !== 'string' ||
78
+ typeof value.description !== 'string' ||
79
+ !isRecord(value.schema)
80
+ )
81
+ return null;
82
+ return {
83
+ name: value.name,
84
+ description: value.description,
85
+ schema: value.schema,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Parse and validate a raw request body. Never throws. Every field the
91
+ * runtime reads is checked for its type here, so a wrong one is a 400 and
92
+ * not a failed turn later; the accepted fields the runtime ignores
93
+ * (`homeServer`, `mcpInvocations`, `requestId`) are not passed on.
94
+ */
68
95
  export function parseTurnBody(raw: string): ParsedTurnBody {
69
96
  let parsed: unknown;
70
97
  try {
@@ -72,17 +99,23 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
72
99
  } catch {
73
100
  return reject('Invalid JSON body');
74
101
  }
75
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
102
+ if (!isRecord(parsed)) {
76
103
  return reject('Request body must be a JSON object');
77
104
  }
78
- const record = parsed as Record<string, unknown>;
79
- const unknownProp = Object.keys(record).find((k) => !TURN_BODY_KEYS.has(k));
105
+ const unknownProp = Object.keys(parsed).find((k) => !TURN_BODY_KEYS.has(k));
80
106
  if (unknownProp !== undefined) {
81
107
  return reject(`property ${unknownProp} should not exist`);
82
108
  }
109
+ // A field sent as `null` is a field not sent (clients serialise unset
110
+ // optionals either way).
111
+ const record = Object.fromEntries(
112
+ Object.entries(parsed).filter(([, value]) => value !== null),
113
+ );
83
114
  if (typeof record.message !== 'string' || record.message.length === 0) {
84
115
  return reject('message is required');
85
116
  }
117
+ const { stream, returnAllMessages, timezone, model, metadata, attachments } =
118
+ record;
86
119
  if (
87
120
  record.multitask !== undefined &&
88
121
  record.multitask !== 'interrupt' &&
@@ -90,5 +123,72 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
90
123
  ) {
91
124
  return reject('multitask must be one of: interrupt, enqueue');
92
125
  }
93
- return { ok: true, body: record as unknown as TurnBody };
126
+ for (const [name, value] of [
127
+ ['stream', stream],
128
+ ['returnAllMessages', returnAllMessages],
129
+ ] as const)
130
+ if (value !== undefined && typeof value !== 'boolean')
131
+ return reject(`${name} must be a boolean`);
132
+ for (const [name, value] of [
133
+ ['timezone', timezone],
134
+ ['model', model],
135
+ ['homeServer', record.homeServer],
136
+ ['requestId', record.requestId],
137
+ ] as const)
138
+ if (value !== undefined && typeof value !== 'string')
139
+ return reject(`${name} must be a string`);
140
+ if (metadata !== undefined && !isRecord(metadata))
141
+ return reject('metadata must be an object');
142
+ if (attachments !== undefined && !Array.isArray(attachments))
143
+ return reject('attachments must be an array');
144
+ let tools: ToolDeclaration[] | undefined;
145
+ if (record.tools !== undefined) {
146
+ if (!Array.isArray(record.tools)) return reject('tools must be an array');
147
+ tools = [];
148
+ for (const item of record.tools) {
149
+ const tool = toolDeclaration(item);
150
+ if (!tool)
151
+ return reject(
152
+ 'each tool needs a string name, description and an object schema',
153
+ );
154
+ tools.push(tool);
155
+ }
156
+ }
157
+ let agActions: NonNullable<TurnBody['agActions']> | undefined;
158
+ if (record.agActions !== undefined) {
159
+ if (!Array.isArray(record.agActions))
160
+ return reject('agActions must be an array');
161
+ agActions = [];
162
+ for (const item of record.agActions) {
163
+ const action = toolDeclaration(item);
164
+ const hasRender = isRecord(item) ? item.hasRender : undefined;
165
+ if (
166
+ !action ||
167
+ (hasRender !== undefined && typeof hasRender !== 'boolean')
168
+ )
169
+ return reject(
170
+ 'each agAction needs a string name, description and an object schema',
171
+ );
172
+ agActions.push(
173
+ hasRender === undefined ? action : { ...action, hasRender },
174
+ );
175
+ }
176
+ }
177
+ return {
178
+ ok: true,
179
+ body: {
180
+ message: record.message,
181
+ ...(typeof stream === 'boolean' ? { stream } : {}),
182
+ ...(record.multitask === 'interrupt' || record.multitask === 'enqueue'
183
+ ? { multitask: record.multitask }
184
+ : {}),
185
+ ...(typeof returnAllMessages === 'boolean' ? { returnAllMessages } : {}),
186
+ ...(typeof timezone === 'string' ? { timezone } : {}),
187
+ ...(typeof model === 'string' ? { model } : {}),
188
+ ...(isRecord(metadata) ? { metadata } : {}),
189
+ ...(tools ? { tools } : {}),
190
+ ...(agActions ? { agActions } : {}),
191
+ ...(Array.isArray(attachments) ? { attachments } : {}),
192
+ },
193
+ };
94
194
  }
@@ -17,6 +17,11 @@ import {
17
17
  signerFromMnemonic,
18
18
  type SupportedDID,
19
19
  } from '@ixo/ucan';
20
+ import {
21
+ abilityCovers,
22
+ delegationHasCapability,
23
+ resourceCovers,
24
+ } from '../core/runtime-context';
20
25
  import type { RuntimeContext, UcanDelegation } from '../plugin-api/types';
21
26
 
22
27
  type MintResult = { invocation: string } | { error: string };
@@ -28,24 +33,18 @@ export interface UcanServiceOptions {
28
33
  oracleDid: string;
29
34
  signingMnemonic?: string;
30
35
  logger?: { warn(msg: string): void; debug?(msg: string): void };
36
+ /** Longest a did.json or UCAN store request may take (default 10 s). */
37
+ fetchTimeoutMs?: number;
31
38
  }
32
39
 
40
+ const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
41
+
33
42
  function toSupportedDid(did: string): SupportedDID {
34
43
  if (did.startsWith('did:ixo:') || did.startsWith('did:key:'))
35
44
  return did as SupportedDID;
36
45
  throw new Error(`Unsupported oracle DID method: ${did}`);
37
46
  }
38
47
 
39
- /** Does a granted ability (`*`, `ns/*`, or an exact ability) cover `required`? */
40
- export function abilityCovers(granted: string, required: string): boolean {
41
- if (granted === '*' || granted === required) return true;
42
- if (granted.endsWith('/*')) {
43
- const ns = granted.slice(0, -2);
44
- return required === ns || required.startsWith(`${ns}/`);
45
- }
46
- return false;
47
- }
48
-
49
48
  /** One capability of a delegation, as the wire carries it. */
50
49
  export interface DelegatedCapability {
51
50
  can: string;
@@ -61,14 +60,86 @@ export interface DelegatedCapability {
61
60
  export async function listDelegationCapabilities(
62
61
  delegationCar: string,
63
62
  ): Promise<DelegatedCapability[]> {
63
+ return (await readDelegation(delegationCar)).capabilities;
64
+ }
65
+
66
+ /** What a serialized delegation grants and the window in which it grants it. */
67
+ export interface DelegationGrant {
68
+ capabilities: DelegatedCapability[];
69
+ /** Unix seconds: the earliest expiry along the proof chain; absent = none declared. */
70
+ expiration?: number;
71
+ /** Unix seconds: the latest `nbf` along the proof chain; absent = none declared. */
72
+ notBefore?: number;
73
+ }
74
+
75
+ /** A finite number read off a parsed UCAN field, else undefined. */
76
+ function finiteField(node: object, field: 'expiration' | 'notBefore') {
77
+ const value: unknown = Reflect.get(node, field);
78
+ return typeof value === 'number' && Number.isFinite(value)
79
+ ? value
80
+ : undefined;
81
+ }
82
+
83
+ /**
84
+ * The validity window of a delegation and the proofs it carries, along the
85
+ * first proof at each level — the chain the UCAN validator's effective expiry
86
+ * follows. A proof carried only as a link (not embedded) has no fields to read.
87
+ */
88
+ function chainWindow(node: unknown): {
89
+ expiration?: number;
90
+ notBefore?: number;
91
+ } {
92
+ if (typeof node !== 'object' || node === null) return {};
93
+ const own = {
94
+ expiration: finiteField(node, 'expiration'),
95
+ notBefore: finiteField(node, 'notBefore'),
96
+ };
97
+ const proofs: unknown = Reflect.get(node, 'proofs');
98
+ const parent = Array.isArray(proofs) ? chainWindow(proofs[0]) : {};
99
+ const pick = (
100
+ a: number | undefined,
101
+ b: number | undefined,
102
+ f: (x: number, y: number) => number,
103
+ ) => (a === undefined ? b : b === undefined ? a : f(a, b));
104
+ const expiration = pick(own.expiration, parent.expiration, Math.min);
105
+ const notBefore = pick(own.notBefore, parent.notBefore, Math.max);
106
+ return {
107
+ ...(expiration !== undefined ? { expiration } : {}),
108
+ ...(notBefore !== undefined ? { notBefore } : {}),
109
+ };
110
+ }
111
+
112
+ /** Parse a serialized delegation into its grant (no signature checks). */
113
+ export async function readDelegation(
114
+ delegationCar: string,
115
+ ): Promise<DelegationGrant> {
64
116
  const delegation = await parseDelegation(delegationCar);
65
- return delegation.capabilities.map((c) => ({
66
- can: c.can,
67
- with: c.with,
68
- ...(c.nb !== undefined && c.nb !== null
69
- ? { nb: c.nb as Record<string, unknown> }
70
- : {}),
71
- }));
117
+ return {
118
+ capabilities: delegation.capabilities.map((c) => ({
119
+ can: c.can,
120
+ with: c.with,
121
+ ...(c.nb !== undefined && c.nb !== null
122
+ ? { nb: c.nb as Record<string, unknown> }
123
+ : {}),
124
+ })),
125
+ ...chainWindow(delegation),
126
+ };
127
+ }
128
+
129
+ /**
130
+ * The delegation a turn runs under: the one the request carried, else the
131
+ * user's stored one (a Matrix turn carries none), with the capabilities it
132
+ * grants (`withCapabilities`). Every capability check of the turn reads it:
133
+ * `requires`, admin-plane tools, the plugins' own mints. Without a UCAN
134
+ * service it carries no capabilities and grants nothing.
135
+ */
136
+ export async function resolveTurnDelegation(
137
+ requestDelegation: string | undefined,
138
+ storedDelegation: string | undefined,
139
+ ucan: Pick<WorkersUcanService, 'withCapabilities'> | null,
140
+ ): Promise<UcanDelegation> {
141
+ const raw = requestDelegation ?? storedDelegation ?? '';
142
+ return ucan ? ucan.withCapabilities(raw) : { raw };
72
143
  }
73
144
 
74
145
  /** Upper bound on a delegated invocation's lifetime (mirrors the Node runtime). */
@@ -85,27 +156,107 @@ export class WorkersUcanService {
85
156
  string,
86
157
  { value: { token: string; with: string }; expiresAt: number }
87
158
  >();
88
- private readonly capabilitiesCache = new Map<string, DelegatedCapability[]>();
159
+ /**
160
+ * Store look-ups in progress, keyed like `storeDelegationCache`: callers
161
+ * that miss the cache at the same moment share one fetch. Dropped when it
162
+ * settles, so a failed look-up is retried by the next call.
163
+ */
164
+ private readonly storeDelegationInFlight = new Map<
165
+ string,
166
+ Promise<ServiceDelegationResult>
167
+ >();
168
+ /** Parsed grants per raw delegation; the validity window is checked at use, never cached away. */
169
+ private readonly grantCache = new Map<string, DelegationGrant>();
89
170
 
90
171
  constructor(private readonly opts: UcanServiceOptions) {}
91
172
 
173
+ /**
174
+ * A raw delegation with the capabilities it grants, the shape
175
+ * `ctx.ucan.hasCapability` and plugin `requires` read. A token that cannot
176
+ * be parsed grants nothing, and neither does one outside its validity
177
+ * window right now: expired, or not yet valid (`nbf`). The window is
178
+ * compared with the clock on every call — the parse is cached, the
179
+ * verdict is not — and the result carries `expiration`, so a delegation
180
+ * that lapses during the turn stops granting at that moment too. The
181
+ * stored delegation a Matrix turn runs under was validated only when it
182
+ * was deposited; this is what ends it.
183
+ */
184
+ async withCapabilities(raw: string): Promise<UcanDelegation> {
185
+ if (!raw) return { raw };
186
+ let grant: DelegationGrant;
187
+ try {
188
+ grant = await this.delegationGrant(raw);
189
+ } catch (err) {
190
+ this.opts.logger?.warn(
191
+ `[UCAN] cannot read a delegation's capabilities: ${err instanceof Error ? err.message : String(err)}`,
192
+ );
193
+ return { raw, capabilities: [] };
194
+ }
195
+ const nowSeconds = Date.now() / 1000;
196
+ const lapsed =
197
+ grant.expiration !== undefined && grant.expiration <= nowSeconds;
198
+ const early = grant.notBefore !== undefined && grant.notBefore > nowSeconds;
199
+ if (lapsed || early) {
200
+ this.opts.logger?.warn(
201
+ lapsed
202
+ ? `[UCAN] the delegation expired at ${new Date((grant.expiration ?? 0) * 1000).toISOString()}; it grants nothing`
203
+ : `[UCAN] the delegation is not valid before ${new Date((grant.notBefore ?? 0) * 1000).toISOString()}; it grants nothing yet`,
204
+ );
205
+ return {
206
+ raw,
207
+ capabilities: [],
208
+ ...(grant.expiration !== undefined
209
+ ? { expiration: grant.expiration }
210
+ : {}),
211
+ };
212
+ }
213
+ return {
214
+ raw,
215
+ capabilities: grant.capabilities.map((c) => ({
216
+ resource: c.with,
217
+ action: c.can,
218
+ })),
219
+ ...(grant.expiration !== undefined
220
+ ? { expiration: grant.expiration }
221
+ : {}),
222
+ };
223
+ }
224
+
92
225
  hasSigningKey(): boolean {
93
226
  return Boolean(this.opts.signingMnemonic);
94
227
  }
95
228
 
96
- /** `listDelegationCapabilities`, memoised on the raw token. */
229
+ /**
230
+ * `listDelegationCapabilities`, memoised on the raw token. No validity
231
+ * window: the owner store picks the grant to mint from with it, and the
232
+ * mint refuses an expired delegation itself.
233
+ */
97
234
  async delegationCapabilities(
98
235
  delegationCar: string,
99
236
  ): Promise<DelegatedCapability[]> {
100
- const cached = this.capabilitiesCache.get(delegationCar);
237
+ return (await this.delegationGrant(delegationCar)).capabilities;
238
+ }
239
+
240
+ /** `readDelegation`, memoised on the raw token. */
241
+ private async delegationGrant(
242
+ delegationCar: string,
243
+ ): Promise<DelegationGrant> {
244
+ const cached = this.grantCache.get(delegationCar);
101
245
  if (cached) return cached;
102
- const capabilities = await listDelegationCapabilities(delegationCar);
103
- if (this.capabilitiesCache.size >= DELEGATION_CAPABILITIES_CACHE_SIZE) {
104
- const oldest = this.capabilitiesCache.keys().next().value;
105
- if (oldest !== undefined) this.capabilitiesCache.delete(oldest);
246
+ const grant = await readDelegation(delegationCar);
247
+ if (this.grantCache.size >= DELEGATION_CAPABILITIES_CACHE_SIZE) {
248
+ const oldest = this.grantCache.keys().next().value;
249
+ if (oldest !== undefined) this.grantCache.delete(oldest);
106
250
  }
107
- this.capabilitiesCache.set(delegationCar, capabilities);
108
- return capabilities;
251
+ this.grantCache.set(delegationCar, grant);
252
+ return grant;
253
+ }
254
+
255
+ /** Bounds one outbound request (headers and body). */
256
+ private fetchSignal(): AbortSignal {
257
+ return AbortSignal.timeout(
258
+ this.opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
259
+ );
109
260
  }
110
261
 
111
262
  async resolveServiceDid(serviceUrl: string): Promise<string | null> {
@@ -113,7 +264,9 @@ export class WorkersUcanService {
113
264
  const origin = new URL(serviceUrl).origin;
114
265
  const cached = this.serviceDidCache.get(origin);
115
266
  if (cached && cached.expiresAt > Date.now()) return cached.did;
116
- const res = await fetch(`${origin}/.well-known/did.json`);
267
+ const res = await fetch(`${origin}/.well-known/did.json`, {
268
+ signal: this.fetchSignal(),
269
+ });
117
270
  if (!res.ok) {
118
271
  this.opts.logger?.warn(`[UCAN] did.json ${origin}: HTTP ${res.status}`);
119
272
  return null;
@@ -265,7 +418,23 @@ export class WorkersUcanService {
265
418
  const cacheKey = `${userDid}:${opts.resource}:${opts.requiredAbility}`;
266
419
  const cached = this.storeDelegationCache.get(cacheKey);
267
420
  if (cached && cached.expiresAt > Date.now()) return cached.value;
421
+ const pending = this.storeDelegationInFlight.get(cacheKey);
422
+ if (pending) return pending;
423
+ const lookup = this.fetchServiceDelegation(userDid, opts, cacheKey).finally(
424
+ () => {
425
+ if (this.storeDelegationInFlight.get(cacheKey) === lookup)
426
+ this.storeDelegationInFlight.delete(cacheKey);
427
+ },
428
+ );
429
+ this.storeDelegationInFlight.set(cacheKey, lookup);
430
+ return lookup;
431
+ }
268
432
 
433
+ private async fetchServiceDelegation(
434
+ userDid: string,
435
+ opts: { storeUrl: string; resource: string; requiredAbility: string },
436
+ cacheKey: string,
437
+ ): Promise<ServiceDelegationResult> {
269
438
  const inv = await this.mintSelfSignedInvocation(
270
439
  opts.storeUrl,
271
440
  { can: 'store/get', with: 'ixo:ucan-store' },
@@ -288,6 +457,8 @@ export class WorkersUcanService {
288
457
  authorization: `Bearer ${inv.invocation}`,
289
458
  'x-auth-type': 'ucan',
290
459
  },
460
+ // Every caller waiting on this look-up waits on this request.
461
+ signal: this.fetchSignal(),
291
462
  },
292
463
  );
293
464
  } catch (error) {
@@ -313,9 +484,13 @@ export class WorkersUcanService {
313
484
  for (const row of body.delegations ?? []) {
314
485
  if (row.lifecycleState !== 'active') continue;
315
486
  if (row.expiresAt != null && row.expiresAt <= nowSeconds) continue;
487
+ // The stored grant must cover what was asked for: the same resource
488
+ // or a parent of it (never a sibling sharing a prefix, never a
489
+ // narrower grant standing in for the broader resource), with an
490
+ // ability that covers the required one.
316
491
  const match = (row.capabilities ?? []).find(
317
492
  (c) =>
318
- (c.with === opts.resource || c.with.startsWith(opts.resource)) &&
493
+ resourceCovers(c.with, opts.resource) &&
319
494
  abilityCovers(c.can, opts.requiredAbility),
320
495
  );
321
496
  if (!match) continue;
@@ -341,13 +516,8 @@ export class WorkersUcanService {
341
516
  userDid: string,
342
517
  delegation: UcanDelegation | undefined,
343
518
  ): RuntimeContext['ucan'] {
344
- const caps = delegation?.capabilities ?? [];
345
519
  const has = (resource: string, action: string) =>
346
- caps.some(
347
- (c) =>
348
- (c.resource === resource || c.resource.startsWith(resource)) &&
349
- abilityCovers(c.action, action),
350
- );
520
+ delegationHasCapability(delegation, resource, action);
351
521
  return {
352
522
  hasCapability: has,
353
523
  requireCapability: (resource, action) => {