@jini-ai/daemon 0.6.7 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
  3. package/dist/agent-executor/antigravity-permissions.js +64 -0
  4. package/dist/agent-executor/contracts.d.ts +31 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +1 -1
  7. package/dist/agent-executor/events.d.ts +1 -0
  8. package/dist/agent-executor/events.js +85 -74
  9. package/dist/agent-executor/launch.d.ts +4 -2
  10. package/dist/agent-executor/launch.js +306 -217
  11. package/dist/agent-executor/mcp-bridge-probe.js +51 -35
  12. package/dist/agent-executor/mcp-bridge.d.ts +18 -8
  13. package/dist/agent-executor/mcp-bridge.js +105 -65
  14. package/dist/agent-executor/run-output.d.ts +20 -0
  15. package/dist/agent-executor/run-output.js +80 -0
  16. package/dist/attachment-content.js +18 -15
  17. package/dist/core/read-only-tools.d.ts +26 -3
  18. package/dist/core/read-only-tools.js +38 -5
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-candidates.d.ts +8 -0
  22. package/dist/core/tool-candidates.js +6 -0
  23. package/dist/core/tool-gaps.d.ts +60 -0
  24. package/dist/core/tool-gaps.js +199 -0
  25. package/dist/delegated-tool-bridge.d.ts +1 -1
  26. package/dist/delegated-tool-bridge.js +161 -122
  27. package/dist/event-log.d.ts +45 -2
  28. package/dist/event-log.js +83 -4
  29. package/dist/http/attachments.d.ts +26 -1
  30. package/dist/http/attachments.js +129 -65
  31. package/dist/http/connectors.d.ts +1 -32
  32. package/dist/http/connectors.js +13 -68
  33. package/dist/http/delegated-tools.d.ts +5 -1
  34. package/dist/http/delegated-tools.js +29 -29
  35. package/dist/http/host-tools.d.ts +1 -0
  36. package/dist/http/host-tools.js +64 -59
  37. package/dist/http/index.d.ts +2 -2
  38. package/dist/http/index.js +1 -1
  39. package/dist/http/media.js +48 -69
  40. package/dist/http/memory.js +45 -36
  41. package/dist/http/model-proxy-fields.d.ts +23 -0
  42. package/dist/http/model-proxy-fields.js +29 -0
  43. package/dist/http/model-proxy.js +193 -395
  44. package/dist/http/research-tavily.adapter.js +72 -49
  45. package/dist/http/research.js +33 -23
  46. package/dist/http/resource-request.d.ts +37 -0
  47. package/dist/http/resource-request.js +59 -0
  48. package/dist/http/routines.js +49 -90
  49. package/dist/http/runs.d.ts +7 -5
  50. package/dist/http/runs.js +21 -7
  51. package/dist/http/terminals.js +23 -48
  52. package/dist/http/tool-catalog.d.ts +5 -0
  53. package/dist/http/xai.d.ts +11 -1
  54. package/dist/http/xai.js +125 -124
  55. package/dist/legacy-data-migration.js +41 -24
  56. package/dist/run/core/retry.js +51 -35
  57. package/dist/run/diagnostics/diagnostics.js +5 -17
  58. package/dist/run-credentials.js +49 -27
  59. package/dist/run-lifecycle.d.ts +12 -2
  60. package/dist/run-lifecycle.js +47 -26
  61. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  62. package/dist/store/agent-sessions/sqlite.js +6 -4
  63. package/dist/surface-exchanges.js +2 -1
  64. package/dist/terminal-session.d.ts +1 -1
  65. package/dist/terminal-session.js +26 -33
  66. package/dist/tool-audit.d.ts +1 -0
  67. package/dist/tool-audit.js +1 -0
  68. package/dist/tool-executor.d.ts +24 -3
  69. package/dist/tool-executor.js +277 -139
  70. package/dist/tool-recovery/recovery.js +64 -35
  71. package/dist/tool-recovery/stack.d.ts +2 -1
  72. package/dist/tool-recovery/stack.js +1 -1
  73. package/package.json +10 -10
@@ -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` match
28
- * what was recorded at registration, and `realpath` must equal the recorded path (so neither the
29
- * file nor any parent directory was swapped for a symlink between upload and run start). Every
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
- function ftypBoxEnd(body) {
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
- async function isAdoptableAttachment(record) {
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 false;
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 sidecarPath = resolve(sidecarDirectory, entry);
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
- await rm(record.filePath, { force: true });
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 writeFile(sidecarPathFor(record), JSON.stringify(persisted), { mode: 0o600 });
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
- if (state.activeUploads >= maxConcurrentUploads) {
923
- respondToUploadFailure(res, deps, new AttachmentRejectedError({ reason: 'too-many-concurrent-uploads', message: 'Too many attachment uploads are in progress' }), batchId, 'attachment-upload');
924
- return;
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 deps.store.pruneExpired({});
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' | 'payments' | 'db' | 'realtime';
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;
@@ -1,20 +1,29 @@
1
1
  /**
2
2
  * @module connectors
3
3
  *
4
- * `POST/GET /api/connectors/{auth,storage,payments,db,realtime}/...` — JSON transport over five
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
- * **SEC-005 throughout**: every provider call is wrapped so a raw Stripe/SQL/JWT/WebSocket error
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.payments`/`deps.db`/`deps.realtime`) is even
17
- * supplied — all five are independently optional on {@link ConnectorsHttpDeps}, and
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, source, error, runId, toolId, status) {
69
+ function reportInternalError(deps, report) {
72
70
  const correlationId = randomUUID();
73
- const context = {
74
- source,
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: message }, { requestId: correlationId });
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, kept consistent here rather than reinvented: `completed` → `200 {result}`,
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
- return ok({ value: { result } });
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
- if (result.errorKind === 'validation') {
151
- return err({ error: createApiError({ code: 'BAD_REQUEST', message: result.error ?? 'invalid tool input' }) });
152
- }
153
- if (result.error && deps.isModelSafeToolFailure?.({ result }) === true) {
154
- return err({ error: createApiError({ code: 'TOOL_EXECUTION_FAILED', message: result.error }) });
155
- }
156
- return err({ error: reportInternalError(deps, 'delegated-tool-execute', result.error ?? result.status, runId, toolId, 'failed') });
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 });