@jini-ai/daemon 0.6.8 → 0.6.9
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/CHANGELOG.md +4 -0
- package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
- package/dist/agent-executor/antigravity-permissions.js +64 -0
- package/dist/agent-executor/contracts.d.ts +31 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +1 -1
- package/dist/agent-executor/events.d.ts +1 -0
- package/dist/agent-executor/events.js +85 -74
- package/dist/agent-executor/launch.d.ts +4 -2
- package/dist/agent-executor/launch.js +306 -217
- package/dist/agent-executor/mcp-bridge-probe.js +51 -35
- package/dist/agent-executor/mcp-bridge.d.ts +18 -8
- package/dist/agent-executor/mcp-bridge.js +105 -65
- package/dist/agent-executor/run-output.d.ts +20 -0
- package/dist/agent-executor/run-output.js +80 -0
- package/dist/attachment-content.js +18 -15
- package/dist/core/read-only-tools.d.ts +15 -0
- package/dist/core/read-only-tools.js +29 -1
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-tool-bridge.d.ts +1 -1
- package/dist/delegated-tool-bridge.js +161 -122
- package/dist/event-log.d.ts +45 -2
- package/dist/event-log.js +83 -4
- package/dist/http/attachments.d.ts +26 -1
- package/dist/http/attachments.js +129 -65
- package/dist/http/connectors.d.ts +1 -32
- package/dist/http/connectors.js +13 -68
- package/dist/http/delegated-tools.d.ts +5 -1
- package/dist/http/delegated-tools.js +29 -29
- package/dist/http/host-tools.d.ts +1 -0
- package/dist/http/host-tools.js +64 -59
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.js +1 -1
- package/dist/http/media.js +48 -69
- package/dist/http/memory.js +45 -36
- package/dist/http/model-proxy-fields.d.ts +23 -0
- package/dist/http/model-proxy-fields.js +29 -0
- package/dist/http/model-proxy.js +193 -395
- package/dist/http/research-tavily.adapter.js +72 -49
- package/dist/http/research.js +33 -23
- package/dist/http/resource-request.d.ts +37 -0
- package/dist/http/resource-request.js +59 -0
- package/dist/http/routines.js +49 -90
- package/dist/http/runs.d.ts +7 -5
- package/dist/http/runs.js +21 -7
- package/dist/http/terminals.js +23 -48
- package/dist/http/tool-catalog.d.ts +5 -0
- package/dist/http/xai.d.ts +11 -1
- package/dist/http/xai.js +125 -124
- package/dist/legacy-data-migration.js +41 -24
- package/dist/run/core/retry.js +51 -35
- package/dist/run/diagnostics/diagnostics.js +5 -17
- package/dist/run-credentials.js +49 -27
- package/dist/run-lifecycle.d.ts +12 -2
- package/dist/run-lifecycle.js +47 -26
- package/dist/store/agent-sessions/sqlite.d.ts +6 -0
- package/dist/store/agent-sessions/sqlite.js +6 -4
- package/dist/surface-exchanges.js +2 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.js +26 -33
- package/dist/tool-audit.d.ts +1 -0
- package/dist/tool-audit.js +1 -0
- package/dist/tool-executor.d.ts +24 -3
- package/dist/tool-executor.js +276 -139
- package/dist/tool-recovery/recovery.js +64 -35
- package/dist/tool-recovery/stack.d.ts +2 -1
- package/dist/tool-recovery/stack.js +1 -1
- package/package.json +10 -10
package/dist/http/attachments.js
CHANGED
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
* `claim()` is what exchanges those ids for real paths, server-side.
|
|
25
25
|
*
|
|
26
26
|
* **What `claim()` guarantees.** Exactly once per registered attachment, and only if the file is
|
|
27
|
-
* still the same file: `lstat` must report a regular non-symlink whose `dev`/`ino`/`size`
|
|
28
|
-
*
|
|
29
|
-
* file nor any parent directory was swapped for a symlink between upload and run
|
|
27
|
+
* still the same file: `lstat` must report a regular non-symlink whose `dev`/`ino`/`size` and
|
|
28
|
+
* recorded modification/change timestamps match, and `realpath` must equal the recorded path
|
|
29
|
+
* (so neither the file nor any parent directory was swapped for a symlink between upload and run
|
|
30
|
+
* start). Legacy sidecars establish their timestamp baseline on first verified read. Every
|
|
30
31
|
* claimed attachment must belong to one batch, so the single `batchDirectory` a host grants the
|
|
31
32
|
* agent read access to cannot be widened by mixing batches.
|
|
32
33
|
*
|
|
@@ -104,6 +105,7 @@ import { basename, dirname, extname, resolve } from 'node:path';
|
|
|
104
105
|
import { createApiError } from '@jini-ai/protocol';
|
|
105
106
|
import { guardSameOrigin } from '@jini-ai/http-kit';
|
|
106
107
|
import { sendApiError, sendJson } from '@jini-ai/http-kit';
|
|
108
|
+
import { writeJsonFileAtomicAsync } from '@jini-ai/platform/fs';
|
|
107
109
|
/**
|
|
108
110
|
* A refusal this module classified itself, as opposed to an unexpected filesystem/programming
|
|
109
111
|
* error. The route pack turns the `reason` into a status code and lets the `message` through only
|
|
@@ -179,8 +181,12 @@ function hasAvifBrandAt(body, offset) {
|
|
|
179
181
|
}
|
|
180
182
|
/** The `ftyp` box's declared end offset (big-endian size at bytes 0-3), clamped to the bytes
|
|
181
183
|
* actually present so a truncated or size-inflating file can never push the brand scan past the
|
|
182
|
-
* captured signature window.
|
|
183
|
-
|
|
184
|
+
* captured signature window.
|
|
185
|
+
*
|
|
186
|
+
* Exported so its short-body bounds guard can be exercised directly: {@link hasAvifSignature}, its
|
|
187
|
+
* only caller, already requires 8 bytes before calling it, so no call through that path can reach
|
|
188
|
+
* the guard — but the guard is what keeps this function safe on its own and stays. */
|
|
189
|
+
export function ftypBoxEnd({ body }, _optional = {}) {
|
|
184
190
|
if (body.length < FTYP_MAJOR_BRAND_OFFSET)
|
|
185
191
|
return 0;
|
|
186
192
|
const declared = ((body[0] << 24) | (body[1] << 16) | (body[2] << 8) | body[3]) >>> 0;
|
|
@@ -205,7 +211,7 @@ export function hasAvifSignature({ body }, _optional = {}) {
|
|
|
205
211
|
return false;
|
|
206
212
|
if (hasAvifBrandAt(body, FTYP_MAJOR_BRAND_OFFSET))
|
|
207
213
|
return true;
|
|
208
|
-
const boxEnd = ftypBoxEnd(body);
|
|
214
|
+
const boxEnd = ftypBoxEnd({ body });
|
|
209
215
|
for (let index = 0; index < MAX_SCANNED_COMPATIBLE_BRANDS; index++) {
|
|
210
216
|
const offset = FTYP_COMPATIBLE_BRANDS_OFFSET + index * 4;
|
|
211
217
|
if (offset + 4 > boxEnd)
|
|
@@ -229,7 +235,9 @@ const SIGNATURE_BYTES = FTYP_COMPATIBLE_BRANDS_OFFSET + MAX_SCANNED_COMPATIBLE_B
|
|
|
229
235
|
* Every condition is a distinct real attack: a regular file swapped for a symlink or directory, a
|
|
230
236
|
* parent directory swapped for a symlink pointing elsewhere, the file replaced by a different file
|
|
231
237
|
* at the same path (new inode), the same inode truncated or appended to (new size), or the path
|
|
232
|
-
* now resolving onto a different device.
|
|
238
|
+
* now resolving onto a different device. Modification/change timestamps also detect same-size
|
|
239
|
+
* rewrites and replacement files whose inode was recycled. Legacy records lack timestamps and
|
|
240
|
+
* use their original identity checks until the first verified read establishes that baseline.
|
|
233
241
|
*
|
|
234
242
|
* Pure and exported on purpose. Staging a *device* change for a file that keeps its path is not
|
|
235
243
|
* something a test can do on a real filesystem, and a check that can only be exercised in
|
|
@@ -240,7 +248,9 @@ export function isUnchangedAttachment({ recorded, observed }, _optional = {}) {
|
|
|
240
248
|
&& observed.canonicalPath === recorded.filePath
|
|
241
249
|
&& observed.dev === recorded.dev
|
|
242
250
|
&& observed.ino === recorded.ino
|
|
243
|
-
&& observed.size === recorded.size
|
|
251
|
+
&& observed.size === recorded.size
|
|
252
|
+
&& (recorded.mtimeMs === undefined || observed.mtimeMs === recorded.mtimeMs)
|
|
253
|
+
&& (recorded.ctimeMs === undefined || observed.ctimeMs === recorded.ctimeMs);
|
|
244
254
|
}
|
|
245
255
|
/**
|
|
246
256
|
* Phase 1 of `claim()`: synchronously reserves `attachments` for `runId`, or throws (releasing
|
|
@@ -288,6 +298,7 @@ export function reserveAttachmentRecords({ attachments, records, runId, maxAttac
|
|
|
288
298
|
* Throws without releasing `claimed`'s reservations; releasing is the caller's job (`claim`'s own
|
|
289
299
|
* `catch`), so a rejected claim still leaves nothing half-claimed and the caller can retry with a
|
|
290
300
|
* corrected set.
|
|
301
|
+
* Missing legacy timestamps are filled from the verified stat; adoption persists that baseline.
|
|
291
302
|
*
|
|
292
303
|
* Exported so the integrity and mixed-batch checks can be exercised directly against fabricated
|
|
293
304
|
* records over real files on disk, without a full `createDiskAttachmentStore` around them.
|
|
@@ -303,6 +314,8 @@ export async function verifyClaimedAttachments({ claimed }, _optional = {}) {
|
|
|
303
314
|
dev: info.dev,
|
|
304
315
|
ino: info.ino,
|
|
305
316
|
size: info.size,
|
|
317
|
+
mtimeMs: info.mtimeMs,
|
|
318
|
+
ctimeMs: info.ctimeMs,
|
|
306
319
|
canonicalPath,
|
|
307
320
|
}
|
|
308
321
|
})) {
|
|
@@ -311,6 +324,8 @@ export async function verifyClaimedAttachments({ claimed }, _optional = {}) {
|
|
|
311
324
|
if (index > 0 && record.batchDirectory !== batchDirectory) {
|
|
312
325
|
throw new AttachmentRejectedError({ reason: 'mixed-batch', message: 'Attachments must belong to one batch' });
|
|
313
326
|
}
|
|
327
|
+
record.mtimeMs ??= info.mtimeMs;
|
|
328
|
+
record.ctimeMs ??= info.ctimeMs;
|
|
314
329
|
batchDirectory = record.batchDirectory;
|
|
315
330
|
}
|
|
316
331
|
return batchDirectory;
|
|
@@ -450,7 +465,8 @@ const PERSISTED_FIELD_TYPES = {
|
|
|
450
465
|
};
|
|
451
466
|
function hasPersistedFieldTypes(candidate) {
|
|
452
467
|
return Object.entries(PERSISTED_FIELD_TYPES)
|
|
453
|
-
.every(([field, type]) => typeof candidate[field] === type)
|
|
468
|
+
.every(([field, type]) => typeof candidate[field] === type)
|
|
469
|
+
&& ['mtimeMs', 'ctimeMs'].every((field) => candidate[field] === undefined || typeof candidate[field] === 'number');
|
|
454
470
|
}
|
|
455
471
|
function isPersistedAttachmentShape(raw) {
|
|
456
472
|
if (typeof raw !== 'object' || raw === null)
|
|
@@ -465,6 +481,7 @@ function isPersistedAttachmentShape(raw) {
|
|
|
465
481
|
/**
|
|
466
482
|
* Turns one sidecar's parsed JSON back into an `AttachmentRecord`, or `undefined` for anything that
|
|
467
483
|
* is not a well-formed record naming a file inside its own batch directory.
|
|
484
|
+
* Legacy sidecars may lack timestamps; adoption verifies their recorded identity before backfill.
|
|
468
485
|
*
|
|
469
486
|
* `batchDirectory` is re-derived here and `filePath`'s containment is re-checked against it with
|
|
470
487
|
* the same parent-equality test `register` uses — the two checks that make an adopted record no
|
|
@@ -490,6 +507,8 @@ export function parsePersistedAttachment({ raw, canonicalUploadDirectory }, _opt
|
|
|
490
507
|
batchDirectory,
|
|
491
508
|
dev: raw.dev,
|
|
492
509
|
ino: raw.ino,
|
|
510
|
+
...(raw.mtimeMs === undefined ? {} : { mtimeMs: raw.mtimeMs }),
|
|
511
|
+
...(raw.ctimeMs === undefined ? {} : { ctimeMs: raw.ctimeMs }),
|
|
493
512
|
createdAt: raw.createdAt,
|
|
494
513
|
...(raw.ownerId === undefined ? {} : { ownerId: raw.ownerId }),
|
|
495
514
|
};
|
|
@@ -504,6 +523,23 @@ export function parsePersistedAttachment({ raw, canonicalUploadDirectory }, _opt
|
|
|
504
523
|
export function attachmentSidecarFileName({ id }, _optional = {}) {
|
|
505
524
|
return `${id.replaceAll(/[^a-zA-Z0-9-]/gu, '_')}.json`;
|
|
506
525
|
}
|
|
526
|
+
async function writePersistedAttachment({ record, sidecarPath }, _optional = {}) {
|
|
527
|
+
const persisted = {
|
|
528
|
+
id: record.id,
|
|
529
|
+
filePath: record.filePath,
|
|
530
|
+
name: record.name,
|
|
531
|
+
kind: record.kind,
|
|
532
|
+
size: record.size,
|
|
533
|
+
batchId: record.batchId,
|
|
534
|
+
dev: record.dev,
|
|
535
|
+
ino: record.ino,
|
|
536
|
+
mtimeMs: record.mtimeMs,
|
|
537
|
+
ctimeMs: record.ctimeMs,
|
|
538
|
+
createdAt: record.createdAt,
|
|
539
|
+
...(record.ownerId === undefined ? {} : { ownerId: record.ownerId }),
|
|
540
|
+
};
|
|
541
|
+
await writeJsonFileAtomicAsync({ filePath: sidecarPath, data: persisted }, { mode: 0o600 });
|
|
542
|
+
}
|
|
507
543
|
async function readPersistedAttachment(sidecarPath, canonicalUploadDirectory) {
|
|
508
544
|
try {
|
|
509
545
|
const raw = JSON.parse(await readFile(sidecarPath, 'utf8'));
|
|
@@ -522,14 +558,19 @@ async function readPersistedAttachment(sidecarPath, canonicalUploadDirectory) {
|
|
|
522
558
|
* the SAME `dev`/`ino`/`size`/`realpath` gate `claim()` applies, or a restart would become the one
|
|
523
559
|
* moment at which a file swapped underneath the store gets handed to an agent anyway.
|
|
524
560
|
*/
|
|
525
|
-
|
|
561
|
+
/** The record, now timestamped, when its file is still the one registration accepted; else `undefined`. */
|
|
562
|
+
async function adoptableAttachment(record) {
|
|
526
563
|
try {
|
|
527
564
|
await verifyClaimedAttachments({ claimed: [record] });
|
|
528
|
-
return true;
|
|
529
565
|
}
|
|
530
566
|
catch {
|
|
531
|
-
return
|
|
567
|
+
return undefined;
|
|
532
568
|
}
|
|
569
|
+
// A successful verification backfills both timestamps in place (`record.mtimeMs ??= ...`).
|
|
570
|
+
return record;
|
|
571
|
+
}
|
|
572
|
+
function needsAttachmentTimestampBackfill({ record }, _options = {}) {
|
|
573
|
+
return record?.mtimeMs === undefined || record?.ctimeMs === undefined;
|
|
533
574
|
}
|
|
534
575
|
/**
|
|
535
576
|
* Rebuilds the record map from the sidecar directory, adopting only records that are (a) well
|
|
@@ -537,6 +578,8 @@ async function isAdoptableAttachment(record) {
|
|
|
537
578
|
* file registration accepted. Anything failing any of those is deleted — sidecar first, then the
|
|
538
579
|
* file it named, and the file ONLY when containment passed, so an unparseable sidecar can never
|
|
539
580
|
* make this an arbitrary-file-delete primitive for whatever the daemon can unlink.
|
|
581
|
+
* Legacy records use their original identity checks before timestamps are backfilled atomically.
|
|
582
|
+
* A backfill write failure propagates before cleanup, preserving the attachment for a later retry.
|
|
540
583
|
*
|
|
541
584
|
* **The retention consequence, stated rather than assumed harmless.** Because `claimedRunId` is not
|
|
542
585
|
* persisted (see {@link PersistedAttachmentRecord}), a file claimed by a run that died with the
|
|
@@ -553,18 +596,31 @@ export async function loadPersistedAttachments(requiredArgs, optionalArgs = {})
|
|
|
553
596
|
await mkdir(sidecarDirectory, { recursive: true, mode: 0o700 });
|
|
554
597
|
const adopted = new Map();
|
|
555
598
|
for (const entry of await readdir(sidecarDirectory)) {
|
|
556
|
-
const
|
|
557
|
-
const record = await readPersistedAttachment(sidecarPath, canonicalUploadDirectory);
|
|
558
|
-
if (record && now - record.createdAt < retentionMs && await isAdoptableAttachment(record)) {
|
|
559
|
-
adopted.set(record.id, record);
|
|
560
|
-
continue;
|
|
561
|
-
}
|
|
562
|
-
await rm(sidecarPath, { force: true });
|
|
599
|
+
const record = await adoptOrDiscardSidecar({ sidecarPath: resolve(sidecarDirectory, entry), canonicalUploadDirectory, retentionMs, now });
|
|
563
600
|
if (record)
|
|
564
|
-
|
|
601
|
+
adopted.set(record.id, record);
|
|
565
602
|
}
|
|
566
603
|
return adopted;
|
|
567
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* One sidecar of {@link loadPersistedAttachments}: the adopted (timestamp-backfilled) record, or
|
|
607
|
+
* `undefined` after deleting the sidecar and — only when its record parsed, i.e. passed
|
|
608
|
+
* containment — the file it named. @complexity O(1) file operations.
|
|
609
|
+
*/
|
|
610
|
+
async function adoptOrDiscardSidecar({ sidecarPath, canonicalUploadDirectory, retentionMs, now }) {
|
|
611
|
+
const record = await readPersistedAttachment(sidecarPath, canonicalUploadDirectory);
|
|
612
|
+
const needsBackfill = needsAttachmentTimestampBackfill({ record });
|
|
613
|
+
const adoptable = record && now - record.createdAt < retentionMs ? await adoptableAttachment(record) : undefined;
|
|
614
|
+
if (adoptable) {
|
|
615
|
+
if (needsBackfill)
|
|
616
|
+
await writePersistedAttachment({ record: adoptable, sidecarPath });
|
|
617
|
+
return adoptable;
|
|
618
|
+
}
|
|
619
|
+
await rm(sidecarPath, { force: true });
|
|
620
|
+
if (record)
|
|
621
|
+
await rm(record.filePath, { force: true });
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
568
624
|
/** Deletes everything in one batch directory that no adopted record names, then tidies the
|
|
569
625
|
* directory away if that emptied it. A root entry that is not a directory at all is removed
|
|
570
626
|
* outright — the store never creates one, so it can only be debris. */
|
|
@@ -643,20 +699,8 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
|
|
|
643
699
|
const persistRecord = async (record) => {
|
|
644
700
|
if (!retainAcrossRestarts)
|
|
645
701
|
return;
|
|
646
|
-
const persisted = {
|
|
647
|
-
id: record.id,
|
|
648
|
-
filePath: record.filePath,
|
|
649
|
-
name: record.name,
|
|
650
|
-
kind: record.kind,
|
|
651
|
-
size: record.size,
|
|
652
|
-
batchId: record.batchId,
|
|
653
|
-
dev: record.dev,
|
|
654
|
-
ino: record.ino,
|
|
655
|
-
createdAt: record.createdAt,
|
|
656
|
-
...(record.ownerId === undefined ? {} : { ownerId: record.ownerId }),
|
|
657
|
-
};
|
|
658
702
|
try {
|
|
659
|
-
await
|
|
703
|
+
await writePersistedAttachment({ record, sidecarPath: sidecarPathFor(record) });
|
|
660
704
|
}
|
|
661
705
|
catch (error) {
|
|
662
706
|
records.delete(record.id);
|
|
@@ -764,6 +808,8 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
|
|
|
764
808
|
batchDirectory,
|
|
765
809
|
dev: info.dev,
|
|
766
810
|
ino: info.ino,
|
|
811
|
+
mtimeMs: info.mtimeMs,
|
|
812
|
+
ctimeMs: info.ctimeMs,
|
|
767
813
|
createdAt: Date.now(),
|
|
768
814
|
...(input.ownerId === undefined ? {} : { ownerId: input.ownerId }),
|
|
769
815
|
};
|
|
@@ -907,6 +953,52 @@ function passesOriginGuard(req, res, deps, adapter) {
|
|
|
907
953
|
sendApiError({ res, status: 403, error: origin.error });
|
|
908
954
|
return false;
|
|
909
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* The refusal for an upload that must not start, or `undefined` when it may. Both checks run before
|
|
958
|
+
* a slot is taken or a byte is written, and in this order: the limiter answers first, so a host at
|
|
959
|
+
* its upload budget never pays for diagnosing a misconfigured body parser.
|
|
960
|
+
*/
|
|
961
|
+
function refusalBeforeUpload({ req, state, maxConcurrentUploads }) {
|
|
962
|
+
if (state.activeUploads >= maxConcurrentUploads) {
|
|
963
|
+
return new AttachmentRejectedError({ reason: 'too-many-concurrent-uploads', message: 'Too many attachment uploads are in progress' });
|
|
964
|
+
}
|
|
965
|
+
// Checked before a single byte is written: a drained stream would otherwise produce a zero-byte
|
|
966
|
+
// file and the misleading "attachment is empty". See this module's body-parser doc.
|
|
967
|
+
if (req.readableEnded) {
|
|
968
|
+
return new AttachmentRejectedError({ reason: 'attachment-body-consumed', message: `the request body was already consumed before ${ATTACHMENTS_ROUTE_PATH} received it — mount this route pack before any global body parser, or scope that parser to skip this path` });
|
|
969
|
+
}
|
|
970
|
+
return undefined;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* The body of one admitted upload: prune, batch directory, bounded stream to disk, signature sniff,
|
|
974
|
+
* register, answer. Throws for the caller to map; the caller owns the slot and the `finally` tidy.
|
|
975
|
+
*/
|
|
976
|
+
async function storeAttachmentUpload({ req, res, deps, batchId, name, maxAttachmentBytes }) {
|
|
977
|
+
await deps.store.pruneExpired({});
|
|
978
|
+
const batchDirectory = await deps.store.createBatchDirectory({ batchId });
|
|
979
|
+
// A fresh UUID, never the client's filename. The extension is carried over (bounded) only
|
|
980
|
+
// because some agent runtimes decide how to read a file from its suffix.
|
|
981
|
+
const suffix = extname(name).slice(0, 12);
|
|
982
|
+
const path = resolve(batchDirectory, `${randomUUID()}${suffix}`);
|
|
983
|
+
const upload = await writeBoundedAttachmentBody({ request: req, filePath: path, maxBytes: maxAttachmentBytes });
|
|
984
|
+
if (upload.size === 0) {
|
|
985
|
+
await rm(path, { force: true });
|
|
986
|
+
sendApiError({ res, status: 400, error: createApiError({ code: 'BAD_REQUEST', message: 'Attachment is empty' }) });
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const ownerId = deps.resolveOwnerId?.({ req });
|
|
990
|
+
const attachment = await deps.store.register({
|
|
991
|
+
input: {
|
|
992
|
+
batchId,
|
|
993
|
+
path,
|
|
994
|
+
name,
|
|
995
|
+
kind: detectAttachmentKind({ body: upload.signature }),
|
|
996
|
+
size: upload.size,
|
|
997
|
+
...(ownerId === undefined ? {} : { ownerId }),
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
sendJson({ res, status: 201, body: { attachment } });
|
|
1001
|
+
}
|
|
910
1002
|
/**
|
|
911
1003
|
* Handles one upload: rate-limit, batch directory, bounded stream to disk, signature sniff,
|
|
912
1004
|
* register. Exported so a host mounting its own path (or its own framework) can reuse the whole
|
|
@@ -919,42 +1011,14 @@ export async function handleAttachmentUpload({ req, res, deps, state }, _optiona
|
|
|
919
1011
|
const batchId = typeof req.query.batch === 'string' ? req.query.batch : '';
|
|
920
1012
|
// Every refusal below goes through `respondToUploadFailure`, so the reason -> status/code mapping
|
|
921
1013
|
// lives in exactly one place rather than being partly inlined here.
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
926
|
-
// Checked before a single byte is written: a drained stream would otherwise produce a zero-byte
|
|
927
|
-
// file and the misleading "attachment is empty". See this module's body-parser doc.
|
|
928
|
-
if (req.readableEnded) {
|
|
929
|
-
respondToUploadFailure(res, deps, new AttachmentRejectedError({ reason: 'attachment-body-consumed', message: `the request body was already consumed before ${ATTACHMENTS_ROUTE_PATH} received it — mount this route pack before any global body parser, or scope that parser to skip this path` }), batchId, 'attachment-upload');
|
|
1014
|
+
const refusal = refusalBeforeUpload({ req, state, maxConcurrentUploads });
|
|
1015
|
+
if (refusal !== undefined) {
|
|
1016
|
+
respondToUploadFailure(res, deps, refusal, batchId, 'attachment-upload');
|
|
930
1017
|
return;
|
|
931
1018
|
}
|
|
932
1019
|
state.activeUploads += 1;
|
|
933
1020
|
try {
|
|
934
|
-
await
|
|
935
|
-
const batchDirectory = await deps.store.createBatchDirectory({ batchId });
|
|
936
|
-
// A fresh UUID, never the client's filename. The extension is carried over (bounded) only
|
|
937
|
-
// because some agent runtimes decide how to read a file from its suffix.
|
|
938
|
-
const suffix = extname(name).slice(0, 12);
|
|
939
|
-
const path = resolve(batchDirectory, `${randomUUID()}${suffix}`);
|
|
940
|
-
const upload = await writeBoundedAttachmentBody({ request: req, filePath: path, maxBytes: maxAttachmentBytes });
|
|
941
|
-
if (upload.size === 0) {
|
|
942
|
-
await rm(path, { force: true });
|
|
943
|
-
sendApiError({ res, status: 400, error: createApiError({ code: 'BAD_REQUEST', message: 'Attachment is empty' }) });
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
const ownerId = deps.resolveOwnerId?.({ req });
|
|
947
|
-
const attachment = await deps.store.register({
|
|
948
|
-
input: {
|
|
949
|
-
batchId,
|
|
950
|
-
path,
|
|
951
|
-
name,
|
|
952
|
-
kind: detectAttachmentKind({ body: upload.signature }),
|
|
953
|
-
size: upload.size,
|
|
954
|
-
...(ownerId === undefined ? {} : { ownerId }),
|
|
955
|
-
}
|
|
956
|
-
});
|
|
957
|
-
sendJson({ res, status: 201, body: { attachment } });
|
|
1021
|
+
await storeAttachmentUpload({ req, res, deps, batchId, name, maxAttachmentBytes });
|
|
958
1022
|
}
|
|
959
1023
|
catch (error) {
|
|
960
1024
|
respondToUploadFailure(res, deps, error, batchId, 'attachment-upload');
|
|
@@ -48,30 +48,6 @@ export interface StorageProvider {
|
|
|
48
48
|
readonly prefix?: string | undefined;
|
|
49
49
|
}): Promise<StorageObjectMeta[]>;
|
|
50
50
|
}
|
|
51
|
-
export interface ChargeInput {
|
|
52
|
-
readonly amountCents: number;
|
|
53
|
-
readonly currency: string;
|
|
54
|
-
readonly customerRef: string;
|
|
55
|
-
readonly description?: string;
|
|
56
|
-
}
|
|
57
|
-
export type ChargeStatus = 'pending' | 'succeeded' | 'failed' | 'refunded';
|
|
58
|
-
export interface Charge {
|
|
59
|
-
readonly id: string;
|
|
60
|
-
readonly status: ChargeStatus;
|
|
61
|
-
readonly amountCents: number;
|
|
62
|
-
readonly currency: string;
|
|
63
|
-
readonly customerRef: string;
|
|
64
|
-
readonly createdAt: number;
|
|
65
|
-
}
|
|
66
|
-
export interface PaymentsProvider {
|
|
67
|
-
charge(requiredArgs: Omit<ChargeInput, 'description'>, optionalArgs?: Pick<ChargeInput, 'description'>): Promise<Charge>;
|
|
68
|
-
getCharge({ id }: {
|
|
69
|
-
readonly id: string;
|
|
70
|
-
}, _optional?: Record<string, never>): Promise<Charge | null>;
|
|
71
|
-
refund({ id }: {
|
|
72
|
-
readonly id: string;
|
|
73
|
-
}, _optional?: Record<string, never>): Promise<Charge>;
|
|
74
|
-
}
|
|
75
51
|
export interface DbRecord {
|
|
76
52
|
readonly id: string;
|
|
77
53
|
readonly [field: string]: unknown;
|
|
@@ -114,7 +90,7 @@ export interface RealtimeProvider {
|
|
|
114
90
|
}, _optional?: Record<string, never>): () => void;
|
|
115
91
|
}
|
|
116
92
|
export interface ConnectorsInternalErrorContext {
|
|
117
|
-
readonly source: 'auth' | 'storage' | '
|
|
93
|
+
readonly source: 'auth' | 'storage' | 'db' | 'realtime';
|
|
118
94
|
readonly operation: string;
|
|
119
95
|
readonly correlationId: string;
|
|
120
96
|
readonly error: unknown;
|
|
@@ -122,7 +98,6 @@ export interface ConnectorsInternalErrorContext {
|
|
|
122
98
|
export interface ConnectorsHttpDeps {
|
|
123
99
|
readonly auth?: AuthProvider;
|
|
124
100
|
readonly storage?: StorageProvider;
|
|
125
|
-
readonly payments?: PaymentsProvider;
|
|
126
101
|
readonly db?: DbProvider;
|
|
127
102
|
readonly realtime?: RealtimeProvider;
|
|
128
103
|
/** Host-owned sink for the real exception behind a generic `INTERNAL_ERROR` response (SEC-005). Defaults to `console.error`. */
|
|
@@ -162,12 +137,6 @@ export declare const connectorsStoragePutRoute: import("@jini-ai/http-kit").Json
|
|
|
162
137
|
export declare const connectorsStorageGetRoute: import("@jini-ai/http-kit").JsonRouteSpec<string, ConnectorsStorageGetResponse, ConnectorsHttpDeps, undefined>;
|
|
163
138
|
export declare const connectorsStorageDeleteRoute: import("@jini-ai/http-kit").JsonRouteSpec<string, ConnectorsOkResponse, ConnectorsHttpDeps, undefined>;
|
|
164
139
|
export declare const connectorsStorageListRoute: import("@jini-ai/http-kit").JsonRouteSpec<string | undefined, ConnectorsStorageListResponse, ConnectorsHttpDeps, undefined>;
|
|
165
|
-
export interface ConnectorsChargeResponse {
|
|
166
|
-
readonly charge: Charge;
|
|
167
|
-
}
|
|
168
|
-
export declare const connectorsPaymentsChargeRoute: import("@jini-ai/http-kit").JsonRouteSpec<ChargeInput, ConnectorsChargeResponse, ConnectorsHttpDeps, undefined>;
|
|
169
|
-
export declare const connectorsPaymentsGetRoute: import("@jini-ai/http-kit").JsonRouteSpec<string, ConnectorsChargeResponse, ConnectorsHttpDeps, undefined>;
|
|
170
|
-
export declare const connectorsPaymentsRefundRoute: import("@jini-ai/http-kit").JsonRouteSpec<string, ConnectorsChargeResponse, ConnectorsHttpDeps, undefined>;
|
|
171
140
|
interface DbInsertRequest {
|
|
172
141
|
readonly collection: string;
|
|
173
142
|
readonly record: DbRecord;
|
package/dist/http/connectors.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module connectors
|
|
3
3
|
*
|
|
4
|
-
* `POST/GET /api/connectors/{auth,storage,
|
|
4
|
+
* `POST/GET /api/connectors/{auth,storage,db,realtime}/...` — JSON transport over four
|
|
5
5
|
* narrow provider ports. The ports live here because they are the exact behavior this transport
|
|
6
6
|
* consumes; concrete adapters such as `@jini-ai/capability-providers` satisfy them structurally at a
|
|
7
7
|
* product composition root. That dependency direction is deliberate: the daemon route pack must not
|
|
8
8
|
* point down into an incubating implementation package.
|
|
9
9
|
*
|
|
10
|
-
* **
|
|
10
|
+
* **No payments slot (removed 2026-10-21).** This pack used to mount `POST /payments/charge`,
|
|
11
|
+
* `GET /payments/:id` and `POST /payments/:id/refund` over a `PaymentsProvider` port. The two
|
|
12
|
+
* POSTs moved money with no approval and no idempotency key (a retried request could charge
|
|
13
|
+
* twice), which made them a second, ungated way to move money beside `@jini-ai/commerce`. Money
|
|
14
|
+
* now moves only through commerce's `PaymentsPort`, behind approval binding; payment reads belong
|
|
15
|
+
* to commerce's own read models. The GET was dropped with them rather than kept on its own: its
|
|
16
|
+
* port type still required `charge`/`refund`, so keeping it would have kept hosts wiring a
|
|
17
|
+
* money-moving object into these deps. Do not re-add a payments slot here.
|
|
18
|
+
*
|
|
19
|
+
* **SEC-005 throughout**: every provider call is wrapped so a raw storage/SQL/JWT/WebSocket error
|
|
11
20
|
* never reaches the HTTP caller verbatim — matching `media.ts`/`delegated-tools.ts`'s
|
|
12
21
|
* `reportInternalError` precedent exactly (a correlation-id-bearing generic `INTERNAL_ERROR`, the
|
|
13
22
|
* real error only reaching a host-owned sink).
|
|
14
23
|
*
|
|
15
24
|
* **`NOT_CONFIGURED` (503)**: every route's very first check is whether its one required
|
|
16
|
-
* capability slot (`deps.auth`/`deps.storage`/`deps.
|
|
17
|
-
* supplied — all
|
|
25
|
+
* capability slot (`deps.auth`/`deps.storage`/`deps.db`/`deps.realtime`) is even
|
|
26
|
+
* supplied — all four are independently optional on {@link ConnectorsHttpDeps}, and
|
|
18
27
|
* `@jini-ai/server`'s zero-config default (see that package's `create-local-node-daemon.ts` wiring)
|
|
19
28
|
* leaves every slot unconfigured. A request against an unconfigured capability gets a clean 503
|
|
20
29
|
* before touching anything else, never a crash or a fabricated result — the same "capability exists
|
|
@@ -204,67 +213,6 @@ export const connectorsStorageListRoute = defineJsonRoute({
|
|
|
204
213
|
}
|
|
205
214
|
});
|
|
206
215
|
// ---------------------------------------------------------------------------
|
|
207
|
-
// payments
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
function parseChargeInput(input) {
|
|
210
|
-
if (!isRecord(input.body))
|
|
211
|
-
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
212
|
-
const amountCents = input.body.amountCents;
|
|
213
|
-
// Cents are a whole, positive unit. `isFinite` alone let `-125.5` reach `payments.charge()`,
|
|
214
|
-
// where a negative amount is a refund nobody requested and a fractional one is rounded by the
|
|
215
|
-
// provider in a way the caller cannot predict — and neither is recoverable once the charge
|
|
216
|
-
// exists. `isSafeInteger` also rejects magnitudes past the point where JSON round-trips stop
|
|
217
|
-
// being exact, which at a money boundary is a value nobody should be allowed to guess at.
|
|
218
|
-
if (typeof amountCents !== 'number' || !Number.isSafeInteger(amountCents) || amountCents <= 0) {
|
|
219
|
-
return err({ error: validationError({ message: 'amountCents must be a positive whole number of cents' }, { issues: [{ path: 'amountCents', message: 'required positive integer' }] }) });
|
|
220
|
-
}
|
|
221
|
-
const currency = nonEmptyString(input.body.currency);
|
|
222
|
-
if (!currency)
|
|
223
|
-
return err({ error: validationError({ message: 'currency must be a non-empty string' }, { issues: [{ path: 'currency', message: 'required non-empty string' }] }) });
|
|
224
|
-
const customerRef = nonEmptyString(input.body.customerRef);
|
|
225
|
-
if (!customerRef) {
|
|
226
|
-
return err({ error: validationError({ message: 'customerRef must be a non-empty string' }, { issues: [{ path: 'customerRef', message: 'required non-empty string' }] }) });
|
|
227
|
-
}
|
|
228
|
-
const description = input.body.description;
|
|
229
|
-
if (description !== undefined && typeof description !== 'string') {
|
|
230
|
-
return err({ error: validationError({ message: 'description must be a string when provided' }) });
|
|
231
|
-
}
|
|
232
|
-
return ok({ value: { amountCents, currency, customerRef, ...(description !== undefined ? { description: description } : {}) } });
|
|
233
|
-
}
|
|
234
|
-
function parseChargeId(input) {
|
|
235
|
-
const id = input.params.id;
|
|
236
|
-
return typeof id === 'string' && id.length > 0 ? ok({ value: id }) : err({ error: validationError({ message: 'id must be a non-empty path parameter' }) });
|
|
237
|
-
}
|
|
238
|
-
export const connectorsPaymentsChargeRoute = defineJsonRoute({
|
|
239
|
-
method: 'post', path: '/api/connectors/payments/charge', parse: parseChargeInput, handle: async ({ input, deps }) => {
|
|
240
|
-
if (!deps.payments)
|
|
241
|
-
return err({ error: notConfiguredError('payments') });
|
|
242
|
-
const { description, ...required } = input;
|
|
243
|
-
const result = await guardedCall(deps, 'payments', 'charge', () => deps.payments.charge(required, description === undefined ? {} : { description }));
|
|
244
|
-
return result.ok ? ok({ value: { charge: result.value } }) : result;
|
|
245
|
-
}
|
|
246
|
-
}, { requireSameOrigin: true, successStatus: 201 });
|
|
247
|
-
export const connectorsPaymentsGetRoute = defineJsonRoute({
|
|
248
|
-
method: 'get', path: '/api/connectors/payments/:id', parse: parseChargeId, handle: async ({ input: id, deps }) => {
|
|
249
|
-
if (!deps.payments)
|
|
250
|
-
return err({ error: notConfiguredError('payments') });
|
|
251
|
-
const result = await guardedCall(deps, 'payments', 'getCharge', () => deps.payments.getCharge({ id }));
|
|
252
|
-
if (!result.ok)
|
|
253
|
-
return result;
|
|
254
|
-
if (result.value === null)
|
|
255
|
-
return err({ error: createApiError({ code: 'NOT_FOUND', message: 'charge not found' }) });
|
|
256
|
-
return ok({ value: { charge: result.value } });
|
|
257
|
-
}
|
|
258
|
-
});
|
|
259
|
-
export const connectorsPaymentsRefundRoute = defineJsonRoute({
|
|
260
|
-
method: 'post', path: '/api/connectors/payments/:id/refund', parse: parseChargeId, handle: async ({ input: id, deps }) => {
|
|
261
|
-
if (!deps.payments)
|
|
262
|
-
return err({ error: notConfiguredError('payments') });
|
|
263
|
-
const result = await guardedCall(deps, 'payments', 'refund', () => deps.payments.refund({ id }));
|
|
264
|
-
return result.ok ? ok({ value: { charge: result.value } }) : result;
|
|
265
|
-
}
|
|
266
|
-
}, { requireSameOrigin: true });
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
216
|
// db
|
|
269
217
|
// ---------------------------------------------------------------------------
|
|
270
218
|
function parseDbCollection(input) {
|
|
@@ -396,9 +344,6 @@ export function registerConnectorsRoutes({ app, deps, adapter }, _optional = {})
|
|
|
396
344
|
mountJsonRoute({ app, spec: connectorsStorageGetRoute, deps, adapter });
|
|
397
345
|
mountJsonRoute({ app, spec: connectorsStorageDeleteRoute, deps, adapter });
|
|
398
346
|
mountJsonRoute({ app, spec: connectorsStorageListRoute, deps, adapter });
|
|
399
|
-
mountJsonRoute({ app, spec: connectorsPaymentsChargeRoute, deps, adapter });
|
|
400
|
-
mountJsonRoute({ app, spec: connectorsPaymentsGetRoute, deps, adapter });
|
|
401
|
-
mountJsonRoute({ app, spec: connectorsPaymentsRefundRoute, deps, adapter });
|
|
402
347
|
mountJsonRoute({ app, spec: connectorsDbInsertRoute, deps, adapter });
|
|
403
348
|
mountJsonRoute({ app, spec: connectorsDbGetRoute, deps, adapter });
|
|
404
349
|
mountJsonRoute({ app, spec: connectorsDbUpdateRoute, deps, adapter });
|
|
@@ -23,7 +23,11 @@ export interface DelegatedToolExecuteRequest {
|
|
|
23
23
|
readonly requireReadOnly?: boolean;
|
|
24
24
|
}
|
|
25
25
|
export interface DelegatedToolExecuteResponse {
|
|
26
|
-
readonly result: ToolExecutionResult
|
|
26
|
+
readonly result: Omit<ToolExecutionResult, 'status'> & {
|
|
27
|
+
readonly status: ToolExecutionResult['status'] | 'partial_failure' | 'error';
|
|
28
|
+
readonly outcome?: 'completed' | 'partial_failure' | 'error';
|
|
29
|
+
readonly isError?: true;
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
/**
|
|
29
33
|
* Diagnostic detail for an internal-error response the public API deliberately does not
|
|
@@ -27,11 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { randomUUID } from 'node:crypto';
|
|
29
29
|
import { createApiError } from '@jini-ai/protocol';
|
|
30
|
-
import { ToolInputError } from '@jini-ai/core';
|
|
30
|
+
import { batchOutcomeFor, ToolInputError } from '@jini-ai/core';
|
|
31
31
|
import { createDelegatedToolBridge } from '../index.js';
|
|
32
|
-
import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
|
|
33
|
-
import { validationError } from '@jini-ai/http-kit';
|
|
34
|
-
import { err, ok } from '@jini-ai/http-kit';
|
|
32
|
+
import { defineJsonRoute, err, mountJsonRoute, ok, validationError } from '@jini-ai/http-kit';
|
|
35
33
|
import * as readOnlyTools from '../core/read-only-tools.js';
|
|
36
34
|
/** Route preflight shares the same registration decision as nested dispatch. */
|
|
37
35
|
function checkReadOnlyConstraint(deps, input) {
|
|
@@ -68,20 +66,14 @@ function describeSafely(deps, context) {
|
|
|
68
66
|
return undefined;
|
|
69
67
|
}
|
|
70
68
|
}
|
|
71
|
-
function reportInternalError(deps,
|
|
69
|
+
function reportInternalError(deps, report) {
|
|
72
70
|
const correlationId = randomUUID();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
runId,
|
|
76
|
-
toolId,
|
|
77
|
-
correlationId,
|
|
78
|
-
error,
|
|
79
|
-
...(status === undefined ? {} : { status }),
|
|
80
|
-
};
|
|
71
|
+
// Every caller either omits `status` or sets it, so spreading keeps "absent" absent.
|
|
72
|
+
const context = { ...report, correlationId };
|
|
81
73
|
const sink = deps.onInternalError ?? defaultInternalErrorSink;
|
|
82
74
|
sink(context);
|
|
83
75
|
const message = describeSafely(deps, context) ?? 'an internal error occurred';
|
|
84
|
-
return createApiError({ code: 'INTERNAL_ERROR', message
|
|
76
|
+
return createApiError({ code: 'INTERNAL_ERROR', message }, { requestId: correlationId });
|
|
85
77
|
}
|
|
86
78
|
function isRecord(value) {
|
|
87
79
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -118,7 +110,8 @@ function parseDelegatedToolExecute(input) {
|
|
|
118
110
|
/**
|
|
119
111
|
* Maps a settled `ToolExecutionResult` to the wire `Result` — the same status mapping
|
|
120
112
|
* `db-ops.ts`'s `toolResultToApiResult` establishes for this identical `ToolExecutionResult`
|
|
121
|
-
* union
|
|
113
|
+
* union: `completed` → `200 {result}`; a batch with failed items carries
|
|
114
|
+
* `partial_failure` or `error` plus its per-item output and isError. Other states map as follows:
|
|
122
115
|
* `denied`/`confirmation-denied` → `403 TOOL_OPERATION_DENIED`, `timed-out`/`cancelled` → a
|
|
123
116
|
* SEC-005-redacted `500 INTERNAL_ERROR` (the real status/error goes to `onInternalError`, never the
|
|
124
117
|
* wire — unless the host supplies `deps.describeInternalError`, whose text then replaces the generic
|
|
@@ -135,26 +128,33 @@ function parseDelegatedToolExecute(input) {
|
|
|
135
128
|
* vouches for this exact result via `deps.isModelSafeToolFailure` (it has already redacted the text
|
|
136
129
|
* itself), in which case it is a `422 TOOL_EXECUTION_FAILED` carrying the host's text verbatim.
|
|
137
130
|
*/
|
|
138
|
-
function toolExecutionResultToApiResult(deps, runId, toolId, result) {
|
|
131
|
+
function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
|
|
139
132
|
switch (result.status) {
|
|
140
|
-
case 'completed':
|
|
141
|
-
|
|
133
|
+
case 'completed': {
|
|
134
|
+
const outcome = batchOutcomeFor({ output: result.output });
|
|
135
|
+
return ok({ value: { result: outcome === undefined || outcome === 'completed' ? result
|
|
136
|
+
: { ...result, status: outcome, outcome, isError: true } } });
|
|
137
|
+
}
|
|
142
138
|
case 'denied':
|
|
143
139
|
return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied by policy' }) });
|
|
144
140
|
case 'confirmation-denied':
|
|
145
141
|
return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied during confirmation' }) });
|
|
146
142
|
case 'timed-out':
|
|
147
143
|
case 'cancelled':
|
|
148
|
-
return err({ error: reportInternalError(deps, 'delegated-tool-execute', result.status, runId, toolId, result.status) });
|
|
144
|
+
return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, status: result.status }) });
|
|
149
145
|
case 'failed':
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
146
|
+
return err({ error: failedResultToApiError(deps, { runId, toolId, result }) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The `failed` arm of {@link toolExecutionResultToApiResult} — see its doc for the three-way split. */
|
|
150
|
+
function failedResultToApiError(deps, { runId, toolId, result }) {
|
|
151
|
+
if (result.errorKind === 'validation') {
|
|
152
|
+
return createApiError({ code: 'BAD_REQUEST', message: result.error ?? 'invalid tool input' });
|
|
153
|
+
}
|
|
154
|
+
if (result.error && deps.isModelSafeToolFailure?.({ result }) === true) {
|
|
155
|
+
return createApiError({ code: 'TOOL_EXECUTION_FAILED', message: result.error });
|
|
157
156
|
}
|
|
157
|
+
return reportInternalError(deps, { source: 'delegated-tool-execute', error: result.error ?? result.status, runId, toolId, status: 'failed' });
|
|
158
158
|
}
|
|
159
159
|
/**
|
|
160
160
|
* `POST /api/delegated-tool-calls` — executes one delegated tool call against an already-started
|
|
@@ -183,7 +183,7 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
|
|
|
183
183
|
principal = await deps.resolvePrincipal({ request: input });
|
|
184
184
|
}
|
|
185
185
|
catch (error) {
|
|
186
|
-
return err({ error: reportInternalError(deps, 'resolve-principal', error, input.runId, input.toolId) });
|
|
186
|
+
return err({ error: reportInternalError(deps, { source: 'resolve-principal', error, runId: input.runId, toolId: input.toolId }) });
|
|
187
187
|
}
|
|
188
188
|
// Attenuates the resolved principal itself for a `requireReadOnly` call, so the constraint
|
|
189
189
|
// survives past this route: a consumer's own `ToolExecutor` composition may dispatch a tool id
|
|
@@ -217,13 +217,13 @@ export const delegatedToolExecuteRoute = defineJsonRoute({
|
|
|
217
217
|
input: input.input,
|
|
218
218
|
...(signal !== undefined ? { signal } : {}),
|
|
219
219
|
});
|
|
220
|
-
return toolExecutionResultToApiResult(deps, input.runId, input.toolId, result);
|
|
220
|
+
return toolExecutionResultToApiResult(deps, { runId: input.runId, toolId: input.toolId, result });
|
|
221
221
|
}
|
|
222
222
|
catch (error) {
|
|
223
223
|
if (error instanceof ToolInputError) {
|
|
224
224
|
return err({ error: createApiError({ code: 'BAD_REQUEST', message: error.message }) });
|
|
225
225
|
}
|
|
226
|
-
return err({ error: reportInternalError(deps, 'delegated-tool-execute', error, input.runId, input.toolId) });
|
|
226
|
+
return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error, runId: input.runId, toolId: input.toolId }) });
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
}, { requireSameOrigin: true });
|