@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.
- package/README.md +32 -10
- package/package.json +13 -8
- 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 +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -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 +170 -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 +2003 -274
- 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 +504 -108
- 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 +562 -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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
292
|
-
return arrayAt(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
324
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
/**
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
477
|
-
|
|
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<
|
|
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
|
-
/**
|
|
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<
|
|
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
|
|
512
|
-
*
|
|
513
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* vision
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
245
|
+
bytesToBase64(data),
|
|
246
|
+
'base64',
|
|
240
247
|
);
|
|
241
248
|
if ('error' in write) return write.error;
|
|
242
249
|
|