@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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  VfsAuthError,
3
+ VfsContentTooLargeError,
3
4
  VfsHttpError,
4
5
  type VfsAbility,
5
6
  type VfsAuthErrorKind,
@@ -279,28 +280,71 @@ export class VfsClient {
279
280
  // Read / search
280
281
  // -------------------------------------------------------------------------
281
282
 
282
- /** Resolve a path to its file via `/glob` (exact-path match). */
283
+ /**
284
+ * Resolve a path to exactly that file, or `null` when no file has that
285
+ * path. The worker has no metadata-by-path endpoint, so this asks `/glob`
286
+ * — which treats `*` and `?` as wildcards — and keeps only the entry whose
287
+ * path equals `path` character for character. A wildcard path therefore
288
+ * never resolves to some other file it happens to match, while a real file
289
+ * whose name contains `*` or `?` still resolves: its matches are read in
290
+ * worker-maximum pages, in the worker's path order, until the page holding
291
+ * it or a short page, at most {@link GLOB_MAX_PAGES} pages. When every
292
+ * page is full and the file was not seen, the path is refused instead of
293
+ * reported missing. Folders never resolve: `/glob` returns files only. More
294
+ * than one exact match is refused rather than guessed between.
295
+ */
283
296
  async statByPath(path: string): Promise<VfsFileStat | null> {
284
- const res = await this.get('fs/list', `/glob?pattern=${enc(path)}`);
285
- const first = arrayAt(await this.json(res), 'files')[0];
286
- return first === undefined ? null : parseFile(first, path);
297
+ if (!hasGlobWildcard(path)) {
298
+ return this.exactMatch(
299
+ path,
300
+ await this.get('fs/list', `/glob?pattern=${enc(path)}`, readJson),
301
+ );
302
+ }
303
+ for (let pageNo = 0; pageNo < GLOB_MAX_PAGES; pageNo += 1) {
304
+ const body = await this.get(
305
+ 'fs/list',
306
+ `/glob?pattern=${enc(path)}&limit=${GLOB_MAX_PAGE}&offset=${pageNo * GLOB_MAX_PAGE}`,
307
+ readJson,
308
+ );
309
+ const found = this.exactMatch(path, body);
310
+ if (found || arrayAt(body, 'files').length < GLOB_MAX_PAGE) return found;
311
+ }
312
+ throw new VfsHttpError({
313
+ status: 400,
314
+ message: `\`${path}\` matches more than ${GLOB_MAX_PAGES * GLOB_MAX_PAGE} files as a pattern, so the file with exactly that name cannot be looked up; rename it without \`*\` or \`?\`.`,
315
+ raw: '',
316
+ });
317
+ }
318
+
319
+ /** The `/glob` entry whose path is exactly `path`; refuses two of them. */
320
+ private exactMatch(path: string, body: unknown): VfsFileStat | null {
321
+ const exact = arrayAt(body, 'files')
322
+ .map((v) => parseFile(v))
323
+ .filter(nonNull)
324
+ .filter((f) => f.path === path);
325
+ if (exact.length > 1) {
326
+ throw new VfsHttpError({
327
+ status: 400,
328
+ message: `\`${path}\` matches more than one file; refusing to act on an ambiguous path.`,
329
+ raw: '',
330
+ });
331
+ }
332
+ return exact[0] ?? null;
287
333
  }
288
334
 
289
335
  /** List the direct children of a folder (`/tree`). */
290
336
  async list(path: string): Promise<VfsTreeEntry[]> {
291
- const res = await this.get('fs/list', `/tree?path=${enc(path)}`);
292
- return arrayAt(await this.json(res), 'nodes')
293
- .map(parseTreeEntry)
294
- .filter(nonNull);
337
+ const body = await this.get('fs/list', `/tree?path=${enc(path)}`, readJson);
338
+ return arrayAt(body, 'nodes').map(parseTreeEntry).filter(nonNull);
295
339
  }
296
340
 
297
341
  /** Hybrid lexical + semantic search over indexed content (`/search`). */
298
342
  async search(q: string, path: string): Promise<VfsSearchResult> {
299
- const res = await this.get(
343
+ const body = await this.get(
300
344
  'fs/read',
301
345
  `/search?q=${enc(q)}&path=${enc(path)}`,
346
+ readJson,
302
347
  );
303
- const body = await this.json(res);
304
348
  return {
305
349
  results: arrayAt(body, 'hits').map(parseSearchHit).filter(nonNull),
306
350
  semantic: (isRecord(body) && bool(body.semantic)) ?? true,
@@ -309,19 +353,22 @@ export class VfsClient {
309
353
 
310
354
  /** Literal term search inside files (`/grep`). */
311
355
  async grep(q: string, path: string): Promise<VfsSearchHit[]> {
312
- const res = await this.get(
356
+ const body = await this.get(
313
357
  'fs/read',
314
358
  `/grep?q=${enc(q)}&path=${enc(path)}`,
359
+ readJson,
315
360
  );
316
- return arrayAt(await this.json(res), 'matches')
317
- .map(parseGrepMatch)
318
- .filter(nonNull);
361
+ return arrayAt(body, 'matches').map(parseGrepMatch).filter(nonNull);
319
362
  }
320
363
 
321
364
  /** Match files by path pattern (`/glob`). */
322
365
  async glob(pattern: string): Promise<VfsGlobMatch[]> {
323
- const res = await this.get('fs/list', `/glob?pattern=${enc(pattern)}`);
324
- return arrayAt(await this.json(res), 'files').flatMap((v) => {
366
+ const body = await this.get(
367
+ 'fs/list',
368
+ `/glob?pattern=${enc(pattern)}`,
369
+ readJson,
370
+ );
371
+ return arrayAt(body, 'files').flatMap((v) => {
325
372
  const f = parseFile(v);
326
373
  return f ? [{ path: f.path, id: f.id }] : [];
327
374
  });
@@ -336,11 +383,13 @@ export class VfsClient {
336
383
  offset: number,
337
384
  limit: number,
338
385
  ): Promise<VfsReadWindow> {
339
- const res = await this.get(
340
- 'fs/read',
341
- `/files/${enc(id)}/read?offset=${offset}&limit=${limit}`,
386
+ const r = asRecord(
387
+ await this.get(
388
+ 'fs/read',
389
+ `/files/${enc(id)}/read?offset=${offset}&limit=${limit}`,
390
+ readJson,
391
+ ),
342
392
  );
343
- const r = asRecord(await this.json(res));
344
393
  return {
345
394
  text: str(r.text) ?? '',
346
395
  offset: num(r.offset) ?? offset,
@@ -350,14 +399,25 @@ export class VfsClient {
350
399
  };
351
400
  }
352
401
 
353
- /** Download a file's raw bytes (`/files/:id/content`) — for binaries. */
354
- async contentBytes(id: string): Promise<VfsContentBytes> {
355
- const res = await this.get('fs/read', `/files/${enc(id)}/content`, {});
356
- const bytes = await res.arrayBuffer();
357
- const mimeType =
358
- res.headers.get('content-type')?.split(';')[0]?.trim() ||
359
- 'application/octet-stream';
360
- return { bytes, mimeType, size: bytes.byteLength };
402
+ /**
403
+ * Download a file's raw bytes (`/files/:id/content`) — for binaries. Throws
404
+ * {@link VfsContentTooLargeError} once the body is known to exceed
405
+ * `maxBytes` — from `content-length` before reading, or while streaming —
406
+ * so an oversized file is never fully buffered in the isolate.
407
+ */
408
+ async contentBytes(id: string, maxBytes: number): Promise<VfsContentBytes> {
409
+ return this.get(
410
+ 'fs/read',
411
+ `/files/${enc(id)}/content`,
412
+ async (res) => {
413
+ const bytes = await readBodyCapped(res, maxBytes);
414
+ const mimeType =
415
+ res.headers.get('content-type')?.split(';')[0]?.trim() ||
416
+ 'application/octet-stream';
417
+ return { bytes, mimeType, size: bytes.byteLength };
418
+ },
419
+ {},
420
+ );
361
421
  }
362
422
 
363
423
  // -------------------------------------------------------------------------
@@ -380,7 +440,7 @@ export class VfsClient {
380
440
  accept: 'application/json',
381
441
  },
382
442
  );
383
- return parseFile(await this.json(res), path) ?? emptyStat(path);
443
+ return parseFile(res, path) ?? emptyStat(path);
384
444
  }
385
445
 
386
446
  /** Replace a file's whole content (`PUT /files/:id`). */
@@ -394,7 +454,7 @@ export class VfsClient {
394
454
  contentType: mime,
395
455
  accept: 'application/json',
396
456
  });
397
- return parseFile(await this.json(res)) ?? emptyStat('');
457
+ return parseFile(res) ?? emptyStat('');
398
458
  }
399
459
 
400
460
  /** Exact-string edit (`PATCH /files/:id/edit`). */
@@ -409,7 +469,7 @@ export class VfsClient {
409
469
  contentType: 'application/json',
410
470
  accept: 'application/json',
411
471
  });
412
- return { replacements: num(asRecord(await this.json(res)).replacements) };
472
+ return { replacements: num(asRecord(res).replacements) };
413
473
  }
414
474
 
415
475
  /**
@@ -425,7 +485,7 @@ export class VfsClient {
425
485
  contentType: 'application/json',
426
486
  accept: 'application/json',
427
487
  });
428
- return parseBatchResults(await this.json(res));
488
+ return parseBatchResults(res);
429
489
  }
430
490
 
431
491
  /** Move files to trash by id (`POST /batch/delete`). */
@@ -435,7 +495,7 @@ export class VfsClient {
435
495
  contentType: 'application/json',
436
496
  accept: 'application/json',
437
497
  });
438
- return parseBatchResults(await this.json(res));
498
+ return parseBatchResults(res);
439
499
  }
440
500
 
441
501
  /**
@@ -449,7 +509,7 @@ export class VfsClient {
449
509
  `/files/${enc(id)}/public?public=${pub}`,
450
510
  { accept: 'application/json' },
451
511
  );
452
- const r = asRecord(await this.json(res));
512
+ const r = asRecord(res);
453
513
  return { public: bool(r.public) ?? pub, publicUrl: str(r.publicUrl) };
454
514
  }
455
515
 
@@ -465,7 +525,7 @@ export class VfsClient {
465
525
  `/folders/public?path=${enc(path)}&public=${pub}`,
466
526
  { accept: 'application/json' },
467
527
  );
468
- const r = asRecord(await this.json(res));
528
+ const r = asRecord(res);
469
529
  return { public: bool(r.public) ?? pub, publicUrl: str(r.publicUrl) };
470
530
  }
471
531
 
@@ -473,31 +533,30 @@ export class VfsClient {
473
533
  // Transport core
474
534
  // -------------------------------------------------------------------------
475
535
 
476
- /** Idempotent GET (retried once on 429/5xx/network). */
477
- private get(
536
+ /**
537
+ * Idempotent GET (retried once on 429/5xx/network). `consume` reads the 2xx
538
+ * body inside the request's timeout and abort window.
539
+ */
540
+ private get<T>(
478
541
  ability: VfsAbility,
479
542
  pathAndQuery: string,
543
+ consume: (res: Response) => Promise<T>,
480
544
  opts: RequestInitLite = { accept: 'application/json' },
481
- ): Promise<Response> {
482
- return this.request(ability, 'GET', pathAndQuery, opts, true);
545
+ ): Promise<T> {
546
+ return this.request(ability, 'GET', pathAndQuery, opts, true, consume);
483
547
  }
484
548
 
485
- /** Non-idempotent write (retried only once on 401, to re-mint). */
549
+ /**
550
+ * Non-idempotent write (retried only once on 401, to re-mint). Resolves to
551
+ * the parsed JSON body (`null` when it is empty or not JSON).
552
+ */
486
553
  private send(
487
554
  ability: VfsAbility,
488
555
  method: string,
489
556
  pathAndQuery: string,
490
557
  opts: RequestInitLite,
491
- ): Promise<Response> {
492
- return this.request(ability, method, pathAndQuery, opts, false);
493
- }
494
-
495
- private async json(res: Response): Promise<unknown> {
496
- try {
497
- return await res.json();
498
- } catch {
499
- return null;
500
- }
558
+ ): Promise<unknown> {
559
+ return this.request(ability, method, pathAndQuery, opts, false, readJson);
501
560
  }
502
561
 
503
562
  private delay(): Promise<void> {
@@ -508,17 +567,20 @@ export class VfsClient {
508
567
 
509
568
  /**
510
569
  * One fetch with a fresh bearer, an accept/content-type header set, and a
511
- * combined caller-abort + timeout signal. Every non-2xx that isn't retried
512
- * becomes a {@link VfsHttpError}; an unresolved auth mint becomes a
513
- * {@link VfsAuthError}.
570
+ * combined caller-abort + timeout signal that stays armed until the body
571
+ * has been read — a stalled body times out and honours cancellation just
572
+ * like stalled headers. Every non-2xx that isn't retried becomes a
573
+ * {@link VfsHttpError}; an unresolved auth mint becomes a
574
+ * {@link VfsAuthError}. A body read that times out or fails is not retried.
514
575
  */
515
- private async request(
576
+ private async request<T>(
516
577
  ability: VfsAbility,
517
578
  method: string,
518
579
  pathAndQuery: string,
519
580
  opts: RequestInitLite,
520
581
  idempotent: boolean,
521
- ): Promise<Response> {
582
+ consume: (res: Response) => Promise<T>,
583
+ ): Promise<T> {
522
584
  const url = `${this.baseUrl}${pathAndQuery}`;
523
585
  let didGetRetry = false;
524
586
  let didAuthRetry = false;
@@ -554,6 +616,11 @@ export class VfsClient {
554
616
  });
555
617
  }
556
618
 
619
+ const disarm = (): void => {
620
+ clearTimeout(timer);
621
+ this.callerSignal?.removeEventListener('abort', onCallerAbort);
622
+ };
623
+
557
624
  let res: Response;
558
625
  try {
559
626
  res = await this.fetchImpl(url, {
@@ -568,6 +635,7 @@ export class VfsClient {
568
635
  signal: controller.signal,
569
636
  });
570
637
  } catch (err) {
638
+ disarm();
571
639
  // A caller-initiated abort is terminal — surface it, don't retry.
572
640
  if (this.callerSignal?.aborted && !timedOut) throw err;
573
641
  if (idempotent && !didGetRetry) {
@@ -580,27 +648,43 @@ export class VfsClient {
580
648
  message: 'Filesystem request failed.',
581
649
  raw: err instanceof Error ? err.message : String(err),
582
650
  });
583
- } finally {
584
- clearTimeout(timer);
585
- this.callerSignal?.removeEventListener('abort', onCallerAbort);
586
651
  }
587
652
 
588
- if (res.ok) return res;
589
-
590
- if (res.status === 401 && !didAuthRetry) {
591
- didAuthRetry = true;
653
+ const retryAuth = res.status === 401 && !didAuthRetry;
654
+ const retryTransient =
655
+ (res.status === 429 || res.status >= 500) && idempotent && !didGetRetry;
656
+ if (retryAuth || retryTransient) {
657
+ disarm();
658
+ discardBody(res);
659
+ if (retryAuth) {
660
+ didAuthRetry = true;
661
+ } else {
662
+ didGetRetry = true;
663
+ await this.delay();
664
+ }
592
665
  continue;
593
666
  }
594
- if (
595
- (res.status === 429 || res.status >= 500) &&
596
- idempotent &&
597
- !didGetRetry
598
- ) {
599
- didGetRetry = true;
600
- await this.delay();
601
- continue;
667
+
668
+ try {
669
+ const read = res.ok
670
+ ? consume(res)
671
+ : this.toHttpError(res).then((e): never => {
672
+ throw e;
673
+ });
674
+ return await raceAbort(read, controller.signal, () => discardBody(res));
675
+ } catch (err) {
676
+ if (err instanceof VfsHttpError) throw err;
677
+ if (this.callerSignal?.aborted && !timedOut) throw err;
678
+ throw new VfsHttpError({
679
+ status: 0,
680
+ message: timedOut
681
+ ? 'Filesystem request timed out.'
682
+ : 'Filesystem request failed.',
683
+ raw: err instanceof Error ? err.message : String(err),
684
+ });
685
+ } finally {
686
+ disarm();
602
687
  }
603
- throw await this.toHttpError(res);
604
688
  }
605
689
  }
606
690
 
@@ -639,6 +723,101 @@ export class VfsClient {
639
723
  }
640
724
  }
641
725
 
726
+ /** The worker's largest `/glob` page (`limit` is validated as 1..200). */
727
+ const GLOB_MAX_PAGE = 200;
728
+
729
+ /**
730
+ * Most `/glob` pages one path lookup reads: 5,000 matches, the most the
731
+ * worker scans for one pattern.
732
+ */
733
+ const GLOB_MAX_PAGES = 25;
734
+
735
+ /** `true` when the worker's glob would treat a character of `path` as a wildcard. */
736
+ export function hasGlobWildcard(path: string): boolean {
737
+ return path.includes('*') || path.includes('?');
738
+ }
739
+
740
+ /** Parse a body as JSON; empty or malformed JSON is `null`, a read failure throws. */
741
+ async function readJson(res: Response): Promise<unknown> {
742
+ const text = await res.text();
743
+ if (text.length === 0) return null;
744
+ try {
745
+ return JSON.parse(text);
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Read a body into an `ArrayBuffer`, refusing with
753
+ * {@link VfsContentTooLargeError} as soon as it is known to exceed
754
+ * `maxBytes`: from `content-length` before any byte is read, otherwise
755
+ * while streaming (the stream is cancelled at the first chunk past the cap).
756
+ */
757
+ async function readBodyCapped(
758
+ res: Response,
759
+ maxBytes: number,
760
+ ): Promise<ArrayBuffer> {
761
+ const declared = Number(res.headers.get('content-length'));
762
+ if (Number.isFinite(declared) && declared > maxBytes) {
763
+ discardBody(res);
764
+ throw new VfsContentTooLargeError(maxBytes, declared);
765
+ }
766
+ if (!res.body) return new ArrayBuffer(0);
767
+ const reader = res.body.getReader();
768
+ const chunks: Uint8Array[] = [];
769
+ let total = 0;
770
+ for (;;) {
771
+ const { done, value } = await reader.read();
772
+ if (done) break;
773
+ total += value.byteLength;
774
+ if (total > maxBytes) {
775
+ void reader.cancel().catch(() => undefined);
776
+ throw new VfsContentTooLargeError(maxBytes);
777
+ }
778
+ chunks.push(value);
779
+ }
780
+ const out = new Uint8Array(total);
781
+ let at = 0;
782
+ for (const chunk of chunks) {
783
+ out.set(chunk, at);
784
+ at += chunk.byteLength;
785
+ }
786
+ return out.buffer;
787
+ }
788
+
789
+ /** Release an unread body so its connection is not held open. */
790
+ function discardBody(res: Response): void {
791
+ if (res.body && !res.body.locked) {
792
+ void res.body.cancel().catch(() => undefined);
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Settle with `work`, or reject with the signal's reason as soon as it
798
+ * aborts (running `onAbort` first). Bounds a body read even when the
799
+ * transport does not tie the body stream to the request signal.
800
+ */
801
+ function raceAbort<T>(
802
+ work: Promise<T>,
803
+ signal: AbortSignal,
804
+ onAbort: () => void,
805
+ ): Promise<T> {
806
+ let abort = (): void => undefined;
807
+ const aborted = new Promise<never>((_, reject) => {
808
+ abort = () => {
809
+ onAbort();
810
+ const reason: unknown = signal.reason;
811
+ reject(reason instanceof Error ? reason : new Error(String(reason)));
812
+ };
813
+ });
814
+ if (signal.aborted) abort();
815
+ else signal.addEventListener('abort', abort, { once: true });
816
+ return Promise.race([work, aborted]).finally(() => {
817
+ signal.removeEventListener('abort', abort);
818
+ });
819
+ }
820
+
642
821
  /** URL-encode a path/query value. */
643
822
  function enc(v: string): string {
644
823
  return encodeURIComponent(v);
@@ -3,10 +3,10 @@ import type { MessageContent } from '@langchain/core/messages';
3
3
  import type { RuntimeContext } from '../../plugin-api/types';
4
4
  import { bytesToBase64 } from '../base64';
5
5
  import type { VfsClient, VfsFileStat } from './vfs-client';
6
- import { VfsHttpError } from './vfs-errors';
6
+ import { VfsContentTooLargeError, VfsHttpError } from './vfs-errors';
7
7
 
8
- /** Cap on the bytes we base64 into the vision model. */
9
- const MAX_VISION_BYTES = 10 * 1024 * 1024;
8
+ /** Cap on the bytes we download and base64 into the vision model. */
9
+ export const MAX_VISION_BYTES = 10 * 1024 * 1024;
10
10
 
11
11
  /** MIME types that are text even though they don't start with `text/`. */
12
12
  const TEXT_MIME_EXACT = new Set<string>([
@@ -65,27 +65,46 @@ function renderTextWindow(
65
65
  return `${text}\n… more lines below (${totalLines} total). Call vfs_read again with offset=${nextOffset} to continue.`;
66
66
  }
67
67
 
68
+ function isRenderableMime(mime: string): boolean {
69
+ return mime.startsWith('image/') || isDocMime(mime);
70
+ }
71
+
68
72
  /**
69
- * Read a non-text file for the agent. Fetches the bytes, then either returns a
70
- * size stub (too large / unsupported binary) or routes the bytes through the
71
- * vision model and returns its description/transcription. Never throws — a
72
- * vision failure degrades to a metadata stub.
73
+ * Read a non-text file for the agent. Returns a metadata stub without
74
+ * downloading when the stored size or type already rules rendering out;
75
+ * otherwise fetches at most {@link MAX_VISION_BYTES} and routes the bytes
76
+ * through the vision model, returning its description/transcription. A vision
77
+ * failure degrades to a metadata stub.
73
78
  */
74
79
  async function renderBinary(
75
80
  client: VfsClient,
76
81
  rtCtx: RuntimeContext,
77
82
  stat: VfsFileStat,
78
83
  ): Promise<string> {
79
- const { bytes, mimeType, size } = await client.contentBytes(stat.id);
80
84
  const name = basename(stat.path) || stat.id;
81
- const mime = mimeType || stat.mimeType || 'application/octet-stream';
82
85
  const publicSuffix = stat.publicUrl
83
86
  ? ` — public link: ${stat.publicUrl}`
84
87
  : '';
88
+ const tooLarge = (mime: string, size: number | string): string =>
89
+ `[binary file "${name}" — ${mime}, ${size} bytes — too large to render (limit ${MAX_VISION_BYTES} bytes)${publicSuffix}]`;
90
+ const storedMime = stat.mimeType || 'application/octet-stream';
91
+
92
+ if (stat.size > MAX_VISION_BYTES) return tooLarge(storedMime, stat.size);
93
+ if (stat.mimeType && !isRenderableMime(stat.mimeType)) {
94
+ return `[binary file "${name}" — ${stat.mimeType}, ${stat.size} bytes — not rendered${publicSuffix}]`;
95
+ }
85
96
 
86
- if (size > MAX_VISION_BYTES) {
87
- return `[binary file "${name}" — ${mime}, ${size} bytes — too large to render${publicSuffix}]`;
97
+ let downloaded: Awaited<ReturnType<VfsClient['contentBytes']>>;
98
+ try {
99
+ downloaded = await client.contentBytes(stat.id, MAX_VISION_BYTES);
100
+ } catch (err) {
101
+ if (err instanceof VfsContentTooLargeError) {
102
+ return tooLarge(storedMime, `more than ${MAX_VISION_BYTES}`);
103
+ }
104
+ throw err;
88
105
  }
106
+ const { bytes, mimeType, size } = downloaded;
107
+ const mime = mimeType || storedMime;
89
108
 
90
109
  const isImage = mime.startsWith('image/');
91
110
  const isDoc = isDocMime(mime);
@@ -55,6 +55,26 @@ export class VfsHttpError extends Error {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * A download refused because the file is larger than the caller's in-memory
60
+ * cap. Raised from the advertised `content-length` (or while streaming, when
61
+ * the header is absent) so the bytes are never fully buffered.
62
+ */
63
+ export class VfsContentTooLargeError extends VfsHttpError {
64
+ readonly limitBytes: number;
65
+
66
+ constructor(limitBytes: number, sizeBytes?: number) {
67
+ const size = sizeBytes !== undefined ? `${sizeBytes} bytes` : 'too large';
68
+ super({
69
+ status: 413,
70
+ message: `The file is ${size}; the limit for this operation is ${limitBytes} bytes.`,
71
+ raw: '',
72
+ });
73
+ this.name = 'VfsContentTooLargeError';
74
+ this.limitBytes = limitBytes;
75
+ }
76
+ }
77
+
58
78
  /** A UCAN auth failure raised before (or instead of) an HTTP round-trip. */
59
79
  export class VfsAuthError extends Error {
60
80
  readonly kind: VfsAuthErrorKind;
@@ -134,6 +154,8 @@ export function mapVfsError(
134
154
  : "Couldn't get filesystem access right now.";
135
155
  }
136
156
 
157
+ if (err instanceof VfsContentTooLargeError) return err.message;
158
+
137
159
  if (err instanceof VfsHttpError) {
138
160
  switch (err.status) {
139
161
  case 401:
@@ -7,6 +7,7 @@ import {
7
7
  hasShellUnsafeChars,
8
8
  inferMimeFromPath,
9
9
  isUnderWorkspaceData,
10
+ MAX_SANDBOX_TRANSFER_BYTES,
10
11
  readSandboxFile,
11
12
  readSandboxResult,
12
13
  writeSandboxFile,
@@ -14,7 +15,6 @@ import {
14
15
  } from '../sandbox/sandbox-bridge';
15
16
  import type { SandboxMcpClientFactory } from '../sandbox/sandbox.plugin';
16
17
  import type { VfsFileStat } from './vfs-client';
17
- import { isTextMime } from './vfs-content';
18
18
  import {
19
19
  isAlreadyExistsConflict,
20
20
  mapVfsError,
@@ -227,16 +227,23 @@ export function createVfsSandboxTools(
227
227
  const client = vfsClient(ctx);
228
228
  const stat = await client.statByPath(vfsPath);
229
229
  if (!stat) return `No such file at \`${vfsPath}\`.`;
230
+ if (stat.size > MAX_SANDBOX_TRANSFER_BYTES) {
231
+ return `\`${vfsPath}\` is ${stat.size} bytes; files moved into the sandbox are limited to ${MAX_SANDBOX_TRANSFER_BYTES} bytes.`;
232
+ }
230
233
 
231
- const { bytes, mimeType } = await client.contentBytes(stat.id);
234
+ const { bytes } = await client.contentBytes(
235
+ stat.id,
236
+ MAX_SANDBOX_TRANSFER_BYTES,
237
+ );
232
238
  const data = new Uint8Array(bytes);
233
- const isText = isTextMime(mimeType || stat.mimeType || '');
234
239
 
240
+ // Always base64: decoding as UTF-8 would silently replace the bytes
241
+ // of a text file in any other encoding (Latin-1, Windows-1252).
235
242
  const write = await writeSandboxFile(
236
243
  bridge,
237
244
  sandboxPath,
238
- isText ? new TextDecoder().decode(data) : bytesToBase64(data),
239
- isText ? 'utf8' : 'base64',
245
+ bytesToBase64(data),
246
+ 'base64',
240
247
  );
241
248
  if ('error' in write) return write.error;
242
249