@jini-ai/daemon 0.6.12 → 0.6.15-rc.1

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 (56) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/agent-executor/codex/home-config.d.ts +50 -0
  3. package/dist/agent-executor/codex/home-config.js +209 -0
  4. package/dist/agent-executor/codex/home-staging.d.ts +60 -0
  5. package/dist/agent-executor/codex/home-staging.js +195 -0
  6. package/dist/agent-executor/codex/isolation.d.ts +27 -0
  7. package/dist/agent-executor/codex/isolation.js +67 -0
  8. package/dist/agent-executor/contracts.d.ts +4 -1
  9. package/dist/agent-executor/event-translation.d.ts +7 -7
  10. package/dist/agent-executor/event-translation.js +17 -17
  11. package/dist/agent-executor/events.js +1 -1
  12. package/dist/agent-executor/index.d.ts +3 -1
  13. package/dist/agent-executor/index.js +3 -1
  14. package/dist/agent-executor/launch.d.ts +3 -2
  15. package/dist/agent-executor/launch.js +68 -17
  16. package/dist/agent-executor/mcp-bridge-probe.d.ts +2 -1
  17. package/dist/agent-executor/mcp-bridge-probe.js +1 -1
  18. package/dist/agent-executor/mcp-bridge.d.ts +9 -86
  19. package/dist/agent-executor/mcp-bridge.js +4 -355
  20. package/dist/ai-trace-lifecycle.d.ts +48 -0
  21. package/dist/ai-trace-lifecycle.js +116 -0
  22. package/dist/core/read-only-tools.js +3 -1
  23. package/dist/delegated-tool-bridge.js +19 -1
  24. package/dist/http/attachment-lookup-routes.d.ts +44 -0
  25. package/dist/http/attachment-lookup-routes.js +71 -0
  26. package/dist/http/attachments.d.ts +112 -7
  27. package/dist/http/attachments.js +58 -17
  28. package/dist/http/connectors.js +23 -18
  29. package/dist/http/index.d.ts +6 -0
  30. package/dist/http/index.js +3 -0
  31. package/dist/http/tool-execution-routes.d.ts +69 -0
  32. package/dist/http/tool-execution-routes.js +181 -0
  33. package/dist/http/xai.js +19 -9
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.js +3 -0
  36. package/dist/remote-attachment-lookup.d.ts +29 -0
  37. package/dist/remote-attachment-lookup.js +71 -0
  38. package/dist/remote-tool-executor.d.ts +85 -0
  39. package/dist/remote-tool-executor.js +250 -0
  40. package/dist/routing-tool-executor.d.ts +29 -0
  41. package/dist/routing-tool-executor.js +16 -0
  42. package/dist/run-lifecycle.d.ts +4 -1
  43. package/dist/run-lifecycle.js +35 -5
  44. package/dist/store/event-log/sqlite-kernel.d.ts +18 -0
  45. package/dist/store/event-log/sqlite-kernel.js +135 -0
  46. package/dist/store/event-log/sqlite-policy.d.ts +93 -0
  47. package/dist/store/event-log/sqlite-policy.js +74 -0
  48. package/dist/store/event-log/sqlite.d.ts +7 -31
  49. package/dist/store/event-log/sqlite.js +32 -95
  50. package/dist/tool-recovery/analytics.d.ts +27 -0
  51. package/dist/tool-recovery/analytics.js +38 -0
  52. package/dist/tool-recovery/index.d.ts +2 -0
  53. package/dist/tool-recovery/index.js +2 -0
  54. package/dist/tool-recovery/observability.d.ts +82 -0
  55. package/dist/tool-recovery/observability.js +88 -0
  56. package/package.json +15 -14
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @module attachment-lookup-routes
3
+ *
4
+ * `POST /internal/attachment-lookup` — lets another local process (a site server running the
5
+ * attachment tools) read this daemon's `AttachmentStore` without owning a second copy of it. The
6
+ * store stays the sole owner of upload records; the caller gets exactly what the store's own two
7
+ * read methods return, including the store's non-disclosing rejection for a ref another run owns.
8
+ *
9
+ * Body: `{ op: "resolveForRun", ref, runId }` or `{ op: "listPendingForOwner", ownerId, runId? }`.
10
+ * Answer: `200 { value }` (`value: null` for an unknown ref), `400 ATTACHMENT_REJECTED` with
11
+ * `details.reason` for a store rejection, `400 BAD_REQUEST` for a malformed body, and a redacted
12
+ * `500` for anything else.
13
+ *
14
+ * Authentication is the host's: mount this behind the daemon's own bearer middleware. Run and owner
15
+ * ids come from the authenticated caller's execution context, never from model input — the store
16
+ * scopes by them (see `AttachmentStore.listPendingForOwner`).
17
+ */
18
+ import express, {} from 'express';
19
+ import { createApiError } from '@jini-ai/protocol';
20
+ import { sendApiError, sendJson } from '@jini-ai/http-kit';
21
+ import { AttachmentRejectedError } from './attachments.js';
22
+ export const ATTACHMENT_LOOKUP_ROUTE_PATH = '/internal/attachment-lookup';
23
+ function isRecord(value) {
24
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ }
26
+ function nonEmpty(value) {
27
+ return typeof value === 'string' && value.length > 0;
28
+ }
29
+ function parseLookup(body) {
30
+ if (!isRecord(body))
31
+ return null;
32
+ if (body.op === 'resolveForRun' && nonEmpty(body.ref) && nonEmpty(body.runId))
33
+ return { op: 'resolveForRun', ref: body.ref, runId: body.runId };
34
+ if (body.op !== 'listPendingForOwner' || !nonEmpty(body.ownerId))
35
+ return null;
36
+ if (body.runId !== undefined && !nonEmpty(body.runId))
37
+ return null;
38
+ return { op: 'listPendingForOwner', ownerId: body.ownerId, ...(body.runId === undefined ? {} : { runId: body.runId }) };
39
+ }
40
+ async function runLookup(store, lookup) {
41
+ if (lookup.op === 'resolveForRun')
42
+ return (await store.resolveForRun({ ref: lookup.ref, runId: lookup.runId })) ?? null;
43
+ return store.listPendingForOwner({ ownerId: lookup.ownerId }, lookup.runId === undefined ? {} : { runId: lookup.runId });
44
+ }
45
+ function sendLookupFailure(res, error) {
46
+ if (error instanceof AttachmentRejectedError) {
47
+ sendApiError({ res, status: 400, error: createApiError({ code: 'ATTACHMENT_REJECTED', message: error.message }, { details: { reason: error.reason } }) });
48
+ return;
49
+ }
50
+ sendApiError({ res, status: 500, error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }) });
51
+ }
52
+ /**
53
+ * Mounts the lookup route. It brings a small JSON parser of its own, which is a no-op when the host
54
+ * already parsed the body upstream.
55
+ */
56
+ export function registerAttachmentLookupRoutes({ app, store }, _optional = {}) {
57
+ app.post(ATTACHMENT_LOOKUP_ROUTE_PATH, express.json({ limit: '64kb' }), async (req, res) => {
58
+ const lookup = parseLookup(req.body);
59
+ if (lookup === null) {
60
+ sendApiError({ res, status: 400, error: createApiError({ code: 'BAD_REQUEST', message: 'body must be { op: "resolveForRun", ref, runId } or { op: "listPendingForOwner", ownerId, runId? }' }) });
61
+ return;
62
+ }
63
+ try {
64
+ sendJson({ res, status: 200, body: { value: await runLookup(store, lookup) } });
65
+ }
66
+ catch (error) {
67
+ sendLookupFailure(res, error);
68
+ }
69
+ });
70
+ }
71
+ //# sourceMappingURL=attachment-lookup-routes.js.map
@@ -1,3 +1,105 @@
1
+ /**
2
+ * @module attachments
3
+ *
4
+ * `POST /api/attachments`, `DELETE /api/attachments` — an HTTP route pack for user file/image
5
+ * uploads staged for one agent run, over a narrow, transport-owned `AttachmentStore` port plus a
6
+ * batteries-included disk implementation (`createDiskAttachmentStore`).
7
+ *
8
+ * **The capability this closes.** A chat composer that accepts drag-and-drop needs somewhere to put
9
+ * the bytes, a way to hand the agent a path it is actually allowed to read, and a guarantee that
10
+ * those bytes disappear when the run ends. That is three separate concerns (an upload endpoint, a
11
+ * trusted-metadata registry, and a run-scoped lifetime) which every host would otherwise hand-roll.
12
+ * This module owns all three; `@jini-ai/chat-react`'s `createDaemonAttachmentUploader` is the
13
+ * matching client half.
14
+ *
15
+ * **Trust model.** A renderer supplies bytes, a filename, and a batch id. None of that is trusted:
16
+ * - the filename is reduced to a basename over a conservative character allowlist
17
+ * (`sanitizeAttachmentName`) and is only ever used as *display* text plus a short extension
18
+ * suffix — never as the stored filename, which is a fresh `randomUUID()`;
19
+ * - the `kind` (`'image' | 'file'`) is sniffed from the leading bytes (`detectAttachmentKind`),
20
+ * never taken from a renderer-controlled MIME type or file extension;
21
+ * - the byte count is measured while streaming, never read from a client-supplied length;
22
+ * - the returned `path` is an opaque `attachment:<uuid>` capability id, not a filesystem path, so a
23
+ * renderer never learns where the upload root is and cannot name a file it did not upload.
24
+ * `claim()` is what exchanges those ids for real paths, server-side.
25
+ *
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` 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
31
+ * claimed attachment must belong to one batch, so the single `batchDirectory` a host grants the
32
+ * agent read access to cannot be widened by mixing batches.
33
+ *
34
+ * **Ownership is opt-in and host-attached, not self-asserted.** `register()`'s optional `ownerId`
35
+ * (and `listPendingForOwner`, the discovery method it enables) is never read from anything a
36
+ * renderer sends — a host wires `AttachmentsHttpDeps.resolveOwnerId` to pull it from a channel IT
37
+ * already trusts (its own session-verified principal header, say), never from `req` unauthenticated.
38
+ * A store with no `resolveOwnerId` wired registers every attachment ownerless, and
39
+ * `listPendingForOwner` never returns an ownerless record for any caller — see that method's own doc
40
+ * for why an absent `ownerId` must never become a wildcard match.
41
+ *
42
+ * **Storage lifetime is daemon-lifetime by default, and opt-in restart-surviving.** With
43
+ * `retainAcrossRestarts` left off (the default, and this pack's only behavior before that option
44
+ * existed), `createDiskAttachmentStore` empties its upload directory on construction: files left
45
+ * behind by an interrupted previous process cannot be authenticated against an in-memory registry
46
+ * that no longer exists, so they are removed rather than adopted. Unclaimed uploads also expire by
47
+ * TTL (`pruneExpired`), and a run's claimed files are deleted by `cleanupRun`.
48
+ *
49
+ * That default is wrong for any host whose process restarts while a person is still using the
50
+ * composer. A file-watching dev server restarting the daemon on an unrelated source edit is not a
51
+ * crash, but the construction wipe cannot tell the two apart, so it destroys uploads the user is
52
+ * about to send — real data loss, not a tidy-up. `retainAcrossRestarts: true` replaces "wipe
53
+ * everything, always" with "adopt exactly what can still be authenticated": see
54
+ * {@link prepareAttachmentStorage} for the whole mechanism and {@link loadPersistedAttachments} for
55
+ * why an adopted record is no less trusted than one this process registered itself.
56
+ *
57
+ * **This pack does not auto-wire itself into a run's lifecycle**, because no generic hook for that
58
+ * exists — the same deliberate choice `@jini-ai/daemon`'s `createRunScopedContextStore` makes. A
59
+ * host claims in its own `onRunStarted` and cleans up in a `finally`, roughly:
60
+ *
61
+ * ```ts
62
+ * const store = await createDiskAttachmentStore({ uploadDirectory });
63
+ * // ... httpExtensions: [(app, { adapter }) => registerAttachmentRoutes({ app, deps: { store }, adapter })]
64
+ * onRunStarted: (context) => {
65
+ * void (async () => {
66
+ * try {
67
+ * const claimed = await store.claim({ attachments: attachmentRefsFrom(context.request), runId: context.run.id });
68
+ * await executor.run({
69
+ * runId: context.run.id,
70
+ * // ... prompt, cwd, agentId
71
+ * }, {
72
+ * ...(claimed.batchDirectory === undefined ? {} : {
73
+ * imagePaths: claimed.attachments.filter((a) => a.kind === 'image').map((a) => a.path),
74
+ * extraAllowedDirs: [claimed.batchDirectory],
75
+ * uploadRoot: claimed.batchDirectory,
76
+ * }),
77
+ * });
78
+ * } finally {
79
+ * await store.cleanupRun({ runId: context.run.id });
80
+ * }
81
+ * })();
82
+ * }
83
+ * ```
84
+ *
85
+ * `imagePaths`/`extraAllowedDirs`/`uploadRoot` are pre-existing `AgentExecutor.run()` options; this
86
+ * module only produces real values for them.
87
+ *
88
+ * **Body-parser ordering (`POST` reads the raw request stream).** The upload route streams
89
+ * `request` straight to disk, so any body-parsing middleware that has already consumed the stream
90
+ * leaves nothing to write. The classic way to hit this is a JSON body parser mounted app-wide: a
91
+ * user drops a `.json` file, the browser sets `content-type: application/json`, and
92
+ * `express.json()` eats the body — which without a guard shows up as the deeply unhelpful
93
+ * "attachment is empty". `registerAttachmentRoutes` detects an already-consumed stream up front and
94
+ * reports it as a host misconfiguration (`'attachment-body-consumed'`) instead of failing silently.
95
+ * Mount this pack before any global body parser, or scope the parser so it skips this path.
96
+ *
97
+ * **SEC-005 redaction**: a store failure can carry filesystem paths a caller must not see, so
98
+ * anything that is not an explicitly-classified `AttachmentRejectedError` becomes a
99
+ * correlation-id-bearing generic `INTERNAL_ERROR`, with the real error reaching a host-owned sink —
100
+ * the same `reportInternalError` shape `media.ts`/`delegated-tools.ts` use.
101
+ */
102
+ import { type FileHandle } from 'node:fs/promises';
1
103
  import type { Express, Request, Response } from 'express';
2
104
  import type { AdapterContext } from '@jini-ai/http-kit';
3
105
  /**
@@ -36,6 +138,7 @@ export interface AttachmentClaim {
36
138
  /** Copy an already authenticated claim into the host's filesystem boundary. Keep the original
37
139
  * store records intact for promotion/read-back; the caller owns cleanup after the run ends.
38
140
  * Only claimed files are copied, never batch sidecars or another upload's capability ids.
141
+ * Exact staged paths alias the original store record until cleanup revokes them.
39
142
  * O(b) I/O for b claimed bytes, bounded by the attachment store's batch limits. Partial copies
40
143
  * are removed before rejection. The temporary directory is private and directly under real root.
41
144
  */
@@ -138,6 +241,9 @@ export interface AttachmentStore {
138
241
  * `image-prompt-delivery.ts` narrates the resolved PATH into the run's prompt text for an
139
242
  * already-claimed attachment — never the id — so a caller built from that prompt has the path, not
140
243
  * the id; a caller with the id (from the original upload response) can still use it directly.
244
+ * Exact paths issued by `stageAttachmentClaim()` for this store's claim also resolve to the
245
+ * original record, with the same run-ownership and integrity checks. Staging cleanup revokes
246
+ * those aliases. No basename inference or caller-supplied filesystem path is read.
141
247
  *
142
248
  * Ownership is enforced here, which is new: an attachment nobody has claimed yet is claimed for
143
249
  * `runId` on this call (identical effect to `claim()`, same integrity re-check); an attachment
@@ -440,18 +546,17 @@ export declare function detectAttachmentKind({ body }: {
440
546
  * Opened `wx`, so this can never overwrite an existing file. A partial write is removed before the
441
547
  * rejection propagates: the cap is enforced *during* the stream, which necessarily means some bytes
442
548
  * already reached disk by the time it trips.
549
+ * An optional already-open `handle` transfers ownership here: it is closed on success or failure.
550
+ * This lets the upload route recover a directory/open race before consuming any request bytes.
443
551
  */
444
- export declare function writeBoundedAttachmentBody(requiredArgs: Omit<{
445
- request: AsyncIterable<unknown>;
446
- filePath: string;
447
- maxBytes: number;
448
- mode?: number;
449
- }, 'mode'>, optionalArgs?: Pick<{
552
+ export declare function writeBoundedAttachmentBody(requiredArgs: {
450
553
  request: AsyncIterable<unknown>;
451
554
  filePath: string;
452
555
  maxBytes: number;
556
+ }, optionalArgs?: {
453
557
  mode?: number;
454
- }, 'mode'>): Promise<{
558
+ handle?: FileHandle;
559
+ }): Promise<{
455
560
  size: number;
456
561
  signature: Uint8Array;
457
562
  }>;
@@ -99,23 +99,32 @@
99
99
  * correlation-id-bearing generic `INTERNAL_ERROR`, with the real error reaching a host-owned sink —
100
100
  * the same `reportInternalError` shape `media.ts`/`delegated-tools.ts` use.
101
101
  */
102
- import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, rmdir, writeFile } from 'node:fs/promises';
102
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, rmdir } from 'node:fs/promises';
103
103
  import { randomUUID } from 'node:crypto';
104
104
  import { basename, dirname, extname, resolve } from 'node:path';
105
105
  import { createApiError } from '@jini-ai/protocol';
106
106
  import { guardSameOrigin } from '@jini-ai/http-kit';
107
107
  import { sendApiError, sendJson } from '@jini-ai/http-kit';
108
108
  import { writeJsonFileAtomicAsync } from '@jini-ai/platform/fs';
109
+ // Only objects returned by this store's claim() carry aliases. Weak keys keep this staging bridge
110
+ // private without extending the wire/persisted formats or retaining completed claims indefinitely.
111
+ const stagedPathsByAttachment = new WeakMap();
109
112
  /** Copy an already authenticated claim into the host's filesystem boundary. Keep the original
110
113
  * store records intact for promotion/read-back; the caller owns cleanup after the run ends.
111
114
  * Only claimed files are copied, never batch sidecars or another upload's capability ids.
115
+ * Exact staged paths alias the original store record until cleanup revokes them.
112
116
  * O(b) I/O for b claimed bytes, bounded by the attachment store's batch limits. Partial copies
113
117
  * are removed before rejection. The temporary directory is private and directly under real root.
114
118
  */
115
119
  export async function stageAttachmentClaim(required, _optional = {}) {
116
120
  const root = await realpath(required.filesystemRoot);
117
121
  const directory = await mkdtemp(resolve(root, '.agent-attachments-'));
118
- const cleanup = () => rm(directory, { recursive: true, force: true });
122
+ const stagedAliases = new Map();
123
+ const cleanup = async () => {
124
+ for (const [path, aliases] of stagedAliases)
125
+ aliases.delete(path);
126
+ await rm(directory, { recursive: true, force: true });
127
+ };
119
128
  try {
120
129
  await chmod(directory, 0o700);
121
130
  const attachments = [];
@@ -124,6 +133,11 @@ export async function stageAttachmentClaim(required, _optional = {}) {
124
133
  await copyFile(attachment.path, target);
125
134
  await chmod(target, 0o600);
126
135
  attachments.push({ ...attachment, path: target });
136
+ const aliases = stagedPathsByAttachment.get(attachment);
137
+ if (aliases) {
138
+ aliases.add(target);
139
+ stagedAliases.set(target, aliases);
140
+ }
127
141
  }
128
142
  return { claim: { attachments, batchDirectory: directory }, cleanup };
129
143
  }
@@ -424,10 +438,12 @@ export function detectAttachmentKind({ body }, _optional = {}) {
424
438
  * Opened `wx`, so this can never overwrite an existing file. A partial write is removed before the
425
439
  * rejection propagates: the cap is enforced *during* the stream, which necessarily means some bytes
426
440
  * already reached disk by the time it trips.
441
+ * An optional already-open `handle` transfers ownership here: it is closed on success or failure.
442
+ * This lets the upload route recover a directory/open race before consuming any request bytes.
427
443
  */
428
444
  export async function writeBoundedAttachmentBody(requiredArgs, optionalArgs = {}) {
429
445
  const { request, filePath, maxBytes, mode = 0o600, } = { ...requiredArgs, ...optionalArgs };
430
- const handle = await open(filePath, 'wx', mode);
446
+ const handle = optionalArgs.handle ?? await open(filePath, 'wx', mode);
431
447
  let total = 0;
432
448
  let signature = Buffer.alloc(0);
433
449
  try {
@@ -713,6 +729,10 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
713
729
  // no record to authenticate it against) or adopts the previous process's authenticated survivors.
714
730
  // See `prepareAttachmentStorage`.
715
731
  const records = await prepareAttachmentStorage({ canonicalUploadDirectory, retainAcrossRestarts, retentionMs });
732
+ const stagedPathsByRecord = new WeakMap();
733
+ /** Only issued refs match; no caller-supplied path is opened. O(n) time for n stored records,
734
+ * bounded by maxStoredAttachments, with no filesystem I/O. */
735
+ const findRecord = ({ ref }, _optional = {}) => records.get(ref) ?? [...records.values()].find((candidate) => candidate.filePath === ref || stagedPathsByRecord.get(candidate)?.has(ref));
716
736
  const sidecarDirectory = resolve(canonicalUploadDirectory, SIDECAR_DIRECTORY_NAME);
717
737
  const sidecarPathFor = (record) => resolve(sidecarDirectory, attachmentSidecarFileName({ id: record.id }));
718
738
  /**
@@ -867,12 +887,13 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
867
887
  try {
868
888
  const batchDirectory = await verifyClaimedAttachments({ claimed });
869
889
  return {
870
- attachments: claimed.map((record) => ({
871
- path: record.filePath,
872
- name: record.name,
873
- kind: record.kind,
874
- size: record.size,
875
- })),
890
+ attachments: claimed.map((record) => {
891
+ const attachment = { path: record.filePath, name: record.name, kind: record.kind, size: record.size };
892
+ const aliases = new Set();
893
+ stagedPathsByAttachment.set(attachment, aliases);
894
+ stagedPathsByRecord.set(record, aliases);
895
+ return attachment;
896
+ }),
876
897
  batchDirectory,
877
898
  };
878
899
  }
@@ -886,7 +907,7 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
886
907
  // Synchronous check-then-reserve, same reasoning as `reserveAttachmentRecords`: nothing may
887
908
  // `await` between reading `claimedRunId` and writing it, or a concurrent call could observe
888
909
  // the same unclaimed record and both believe they reserved it.
889
- const record = records.get(ref) ?? [...records.values()].find((candidate) => candidate.filePath === ref);
910
+ const record = findRecord({ ref });
890
911
  if (!record)
891
912
  return undefined;
892
913
  if (record.claimedRunId !== undefined && record.claimedRunId !== runId) {
@@ -995,18 +1016,38 @@ function refusalBeforeUpload({ req, state, maxConcurrentUploads }) {
995
1016
  }
996
1017
  return undefined;
997
1018
  }
1019
+ /**
1020
+ * Creates and exclusively opens one upload before its stream is consumed. Batch cleanup may
1021
+ * remove an empty directory between mkdir/chmod/open, so retry that preparation once on ENOENT.
1022
+ * Other failures propagate unchanged; the body writer owns the returned handle and partial file.
1023
+ * @complexity O(1) filesystem operations and memory; at most two preparation attempts.
1024
+ */
1025
+ async function openAttachmentUpload({ store, batchId, name }, _optional = {}) {
1026
+ // A fresh UUID, never the client's filename. The extension is carried over (bounded) only
1027
+ // because some agent runtimes decide how to read a file from its suffix.
1028
+ const fileName = `${randomUUID()}${extname(name).slice(0, 12)}`;
1029
+ const prepare = async (_required, _optional = {}) => {
1030
+ const batchDirectory = await store.createBatchDirectory({ batchId });
1031
+ const path = resolve(batchDirectory, fileName);
1032
+ return { path, handle: await open(path, 'wx', 0o600) };
1033
+ };
1034
+ try {
1035
+ return await prepare({});
1036
+ }
1037
+ catch (error) {
1038
+ if (error.code !== 'ENOENT')
1039
+ throw error;
1040
+ return prepare({});
1041
+ }
1042
+ }
998
1043
  /**
999
1044
  * The body of one admitted upload: prune, batch directory, bounded stream to disk, signature sniff,
1000
1045
  * register, answer. Throws for the caller to map; the caller owns the slot and the `finally` tidy.
1001
1046
  */
1002
- async function storeAttachmentUpload({ req, res, deps, batchId, name, maxAttachmentBytes }) {
1047
+ async function storeAttachmentUpload({ req, res, deps, batchId, name, maxAttachmentBytes }, _optional = {}) {
1003
1048
  await deps.store.pruneExpired({});
1004
- const batchDirectory = await deps.store.createBatchDirectory({ batchId });
1005
- // A fresh UUID, never the client's filename. The extension is carried over (bounded) only
1006
- // because some agent runtimes decide how to read a file from its suffix.
1007
- const suffix = extname(name).slice(0, 12);
1008
- const path = resolve(batchDirectory, `${randomUUID()}${suffix}`);
1009
- const upload = await writeBoundedAttachmentBody({ request: req, filePath: path, maxBytes: maxAttachmentBytes });
1049
+ const { path, handle } = await openAttachmentUpload({ store: deps.store, batchId, name });
1050
+ const upload = await writeBoundedAttachmentBody({ request: req, filePath: path, maxBytes: maxAttachmentBytes }, { handle });
1010
1051
  if (upload.size === 0) {
1011
1052
  await rm(path, { force: true });
1012
1053
  sendApiError({ res, status: 400, error: createApiError({ code: 'BAD_REQUEST', message: 'Attachment is empty' }) });
@@ -19,7 +19,8 @@
19
19
  * **SEC-005 throughout**: every provider call is wrapped so a raw storage/SQL/JWT/WebSocket error
20
20
  * never reaches the HTTP caller verbatim — matching `media.ts`/`delegated-tools.ts`'s
21
21
  * `reportInternalError` precedent exactly (a correlation-id-bearing generic `INTERNAL_ERROR`, the
22
- * real error only reaching a host-owned sink).
22
+ * real error only reaching a host-owned sink). Explicit `ClientFacingError` rejections retain
23
+ * their caller-safe API error without triggering that sink.
23
24
  *
24
25
  * **`NOT_CONFIGURED` (503)**: every route's very first check is whether its one required
25
26
  * capability slot (`deps.auth`/`deps.storage`/`deps.db`/`deps.realtime`) is even
@@ -45,7 +46,7 @@
45
46
  */
46
47
  import { randomUUID } from 'node:crypto';
47
48
  import { createApiError } from '@jini-ai/protocol';
48
- import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
49
+ import { ClientFacingError, defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
49
50
  import { validationError } from '@jini-ai/http-kit';
50
51
  import { err, ok } from '@jini-ai/http-kit';
51
52
  function defaultInternalErrorSink(context) {
@@ -55,12 +56,15 @@ function defaultInternalErrorSink(context) {
55
56
  function notConfiguredError(capability) {
56
57
  return createApiError({ code: 'NOT_CONFIGURED', message: `${capability} provider not configured` });
57
58
  }
58
- /** Runs one provider call, converting any thrown error into a redacted, correlation-id-bearing `INTERNAL_ERROR` (SEC-005) instead of letting it reach the HTTP caller verbatim. */
59
- async function guardedCall(deps, source, operation, fn) {
59
+ /** Runs one provider call, preserving explicit caller-safe rejections and converting unexpected
60
+ * errors into a redacted, correlation-id-bearing `INTERNAL_ERROR` (SEC-005). */
61
+ async function guardedCall({ deps, source, operation, fn }, _optional = {}) {
60
62
  try {
61
63
  return ok({ value: await fn() });
62
64
  }
63
65
  catch (error) {
66
+ if (error instanceof ClientFacingError)
67
+ return err({ error: error.apiError });
64
68
  const correlationId = randomUUID();
65
69
  const sink = deps.onInternalError ?? defaultInternalErrorSink;
66
70
  sink({ source, operation, correlationId, error });
@@ -103,7 +107,7 @@ export const connectorsAuthSignUpRoute = defineJsonRoute({
103
107
  method: 'post', path: '/api/connectors/auth/signup', parse: parseAuthCredentials, handle: async ({ input: credentials, deps }) => {
104
108
  if (!deps.auth)
105
109
  return err({ error: notConfiguredError('auth') });
106
- const result = await guardedCall(deps, 'auth', 'signUp', () => deps.auth.signUp(credentials));
110
+ const result = await guardedCall({ deps, source: 'auth', operation: 'signUp', fn: () => deps.auth.signUp(credentials) });
107
111
  return result.ok ? ok({ value: { user: result.value } }) : result;
108
112
  }
109
113
  }, { requireSameOrigin: true, successStatus: 201 });
@@ -111,7 +115,7 @@ export const connectorsAuthSignInRoute = defineJsonRoute({
111
115
  method: 'post', path: '/api/connectors/auth/signin', parse: parseAuthCredentials, handle: async ({ input: credentials, deps }) => {
112
116
  if (!deps.auth)
113
117
  return err({ error: notConfiguredError('auth') });
114
- const result = await guardedCall(deps, 'auth', 'signIn', () => deps.auth.signIn(credentials));
118
+ const result = await guardedCall({ deps, source: 'auth', operation: 'signIn', fn: () => deps.auth.signIn(credentials) });
115
119
  return result.ok ? ok({ value: { session: result.value } }) : result;
116
120
  }
117
121
  }, { requireSameOrigin: true });
@@ -119,7 +123,7 @@ export const connectorsAuthSignOutRoute = defineJsonRoute({
119
123
  method: 'post', path: '/api/connectors/auth/signout', parse: parseAuthToken, handle: async ({ input: token, deps }) => {
120
124
  if (!deps.auth)
121
125
  return err({ error: notConfiguredError('auth') });
122
- const result = await guardedCall(deps, 'auth', 'signOut', () => deps.auth.signOut({ token }));
126
+ const result = await guardedCall({ deps, source: 'auth', operation: 'signOut', fn: () => deps.auth.signOut({ token }) });
123
127
  return result.ok ? ok({ value: { ok: true } }) : result;
124
128
  }
125
129
  }, { requireSameOrigin: true });
@@ -127,7 +131,7 @@ export const connectorsAuthSessionRoute = defineJsonRoute({
127
131
  method: 'post', path: '/api/connectors/auth/session', parse: parseAuthToken, handle: async ({ input: token, deps }) => {
128
132
  if (!deps.auth)
129
133
  return err({ error: notConfiguredError('auth') });
130
- const result = await guardedCall(deps, 'auth', 'verifySession', () => deps.auth.verifySession({ token }));
134
+ const result = await guardedCall({ deps, source: 'auth', operation: 'verifySession', fn: () => deps.auth.verifySession({ token }) });
131
135
  return result.ok ? ok({ value: { user: result.value } }) : result;
132
136
  }
133
137
  }, { requireSameOrigin: true });
@@ -180,7 +184,8 @@ export const connectorsStoragePutRoute = defineJsonRoute({
180
184
  if (!deps.storage)
181
185
  return err({ error: notConfiguredError('storage') });
182
186
  const options = contentType !== undefined ? { contentType } : undefined;
183
- const result = await guardedCall(deps, 'storage', 'put', () => deps.storage.put({ key, data: Buffer.from(dataBase64, 'base64') }, options));
187
+ const result = await guardedCall({ deps, source: 'storage', operation: 'put', fn: () => deps.storage.put({ key, data: Buffer.from(dataBase64, 'base64') }, options),
188
+ });
184
189
  return result.ok ? ok({ value: { object: result.value } }) : result;
185
190
  }
186
191
  }, { requireSameOrigin: true });
@@ -188,7 +193,7 @@ export const connectorsStorageGetRoute = defineJsonRoute({
188
193
  method: 'get', path: '/api/connectors/storage/:key', parse: parseStorageKey, handle: async ({ input: key, deps }) => {
189
194
  if (!deps.storage)
190
195
  return err({ error: notConfiguredError('storage') });
191
- const result = await guardedCall(deps, 'storage', 'get', () => deps.storage.get({ key }));
196
+ const result = await guardedCall({ deps, source: 'storage', operation: 'get', fn: () => deps.storage.get({ key }) });
192
197
  if (!result.ok)
193
198
  return result;
194
199
  if (result.value === null)
@@ -200,7 +205,7 @@ export const connectorsStorageDeleteRoute = defineJsonRoute({
200
205
  method: 'delete', path: '/api/connectors/storage/:key', parse: parseStorageKey, handle: async ({ input: key, deps }) => {
201
206
  if (!deps.storage)
202
207
  return err({ error: notConfiguredError('storage') });
203
- const result = await guardedCall(deps, 'storage', 'delete', () => deps.storage.delete({ key }));
208
+ const result = await guardedCall({ deps, source: 'storage', operation: 'delete', fn: () => deps.storage.delete({ key }) });
204
209
  return result.ok ? ok({ value: { ok: true } }) : result;
205
210
  }
206
211
  }, { requireSameOrigin: true });
@@ -208,7 +213,7 @@ export const connectorsStorageListRoute = defineJsonRoute({
208
213
  method: 'get', path: '/api/connectors/storage', parse: parseStorageListQuery, handle: async ({ input: prefix, deps }) => {
209
214
  if (!deps.storage)
210
215
  return err({ error: notConfiguredError('storage') });
211
- const result = await guardedCall(deps, 'storage', 'list', () => deps.storage.list({}, { prefix }));
216
+ const result = await guardedCall({ deps, source: 'storage', operation: 'list', fn: () => deps.storage.list({}, { prefix }) });
212
217
  return result.ok ? ok({ value: { objects: result.value } }) : result;
213
218
  }
214
219
  });
@@ -272,7 +277,7 @@ export const connectorsDbInsertRoute = defineJsonRoute({
272
277
  method: 'post', path: '/api/connectors/db/:collection', parse: parseDbInsert, handle: async ({ input: { collection, record }, deps }) => {
273
278
  if (!deps.db)
274
279
  return err({ error: notConfiguredError('db') });
275
- const result = await guardedCall(deps, 'db', 'insert', () => deps.db.insert({ collection, record }));
280
+ const result = await guardedCall({ deps, source: 'db', operation: 'insert', fn: () => deps.db.insert({ collection, record }) });
276
281
  return result.ok ? ok({ value: { record: result.value } }) : result;
277
282
  }
278
283
  }, { requireSameOrigin: true, successStatus: 201 });
@@ -280,7 +285,7 @@ export const connectorsDbGetRoute = defineJsonRoute({
280
285
  method: 'get', path: '/api/connectors/db/:collection/:id', parse: parseDbCollectionId, handle: async ({ input: { collection, id }, deps }) => {
281
286
  if (!deps.db)
282
287
  return err({ error: notConfiguredError('db') });
283
- const result = await guardedCall(deps, 'db', 'get', () => deps.db.get({ collection, id }));
288
+ const result = await guardedCall({ deps, source: 'db', operation: 'get', fn: () => deps.db.get({ collection, id }) });
284
289
  if (!result.ok)
285
290
  return result;
286
291
  if (result.value === null)
@@ -292,7 +297,7 @@ export const connectorsDbUpdateRoute = defineJsonRoute({
292
297
  method: 'patch', path: '/api/connectors/db/:collection/:id', parse: parseDbUpdate, handle: async ({ input: { collection, id, patch }, deps }) => {
293
298
  if (!deps.db)
294
299
  return err({ error: notConfiguredError('db') });
295
- const result = await guardedCall(deps, 'db', 'update', () => deps.db.update({ collection, id, patch }));
300
+ const result = await guardedCall({ deps, source: 'db', operation: 'update', fn: () => deps.db.update({ collection, id, patch }) });
296
301
  if (!result.ok)
297
302
  return result;
298
303
  if (result.value === null)
@@ -304,7 +309,7 @@ export const connectorsDbDeleteRoute = defineJsonRoute({
304
309
  method: 'delete', path: '/api/connectors/db/:collection/:id', parse: parseDbCollectionId, handle: async ({ input: { collection, id }, deps }) => {
305
310
  if (!deps.db)
306
311
  return err({ error: notConfiguredError('db') });
307
- const result = await guardedCall(deps, 'db', 'delete', () => deps.db.delete({ collection, id }));
312
+ const result = await guardedCall({ deps, source: 'db', operation: 'delete', fn: () => deps.db.delete({ collection, id }) });
308
313
  return result.ok ? ok({ value: { ok: true } }) : result;
309
314
  }
310
315
  }, { requireSameOrigin: true });
@@ -312,7 +317,7 @@ export const connectorsDbQueryRoute = defineJsonRoute({
312
317
  method: 'get', path: '/api/connectors/db/:collection', parse: parseDbQueryRequest, handle: async ({ input: { collection, query }, deps }) => {
313
318
  if (!deps.db)
314
319
  return err({ error: notConfiguredError('db') });
315
- const result = await guardedCall(deps, 'db', 'query', () => deps.db.query({ collection }, query));
320
+ const result = await guardedCall({ deps, source: 'db', operation: 'query', fn: () => deps.db.query({ collection }, query) });
316
321
  return result.ok ? ok({ value: { records: result.value } }) : result;
317
322
  }
318
323
  });
@@ -330,7 +335,7 @@ export const connectorsRealtimePublishRoute = defineJsonRoute({
330
335
  method: 'post', path: '/api/connectors/realtime/:channel/publish', parse: parseRealtimePublish, handle: async ({ input: { channel, event }, deps }) => {
331
336
  if (!deps.realtime)
332
337
  return err({ error: notConfiguredError('realtime') });
333
- const result = await guardedCall(deps, 'realtime', 'publish', () => deps.realtime.publish({ channel, event }));
338
+ const result = await guardedCall({ deps, source: 'realtime', operation: 'publish', fn: () => deps.realtime.publish({ channel, event }) });
334
339
  return result.ok ? ok({ value: { ok: true } }) : result;
335
340
  }
336
341
  }, { requireSameOrigin: true });
@@ -50,5 +50,11 @@ export { ATTACHMENTS_ROUTE_PATH, AttachmentRejectedError, createDiskAttachmentSt
50
50
  export type { XaiAuthStatusResponse, XaiHttpDeps, XaiInternalErrorContext, XaiOauthStartResponse, XaiOkResponse, XaiSearchResponse, } from './xai.js';
51
51
  export { registerXaiRoutes, xaiAuthStatusRoute, xaiOauthCancelRoute, xaiOauthCompleteRoute, xaiOauthDisconnectRoute, xaiOauthStartRoute, xaiSearchRoute, } from './xai.js';
52
52
  export * from './run-credentials.js';
53
+ export type { RunWithToolContext, ToolExecutionRouteTarget, ToolExecutionRoutesOptional, ToolExecutionRoutesRequired, } from './tool-execution-routes.js';
54
+ export { DEFAULT_TOOL_EXEC_MAX_BODY_BYTES, DEFAULT_TOOL_EXEC_TOKEN_ENV_VAR, registerToolExecutionRoutes, requireToolExecutionCaller, TOOL_CATALOG_ROUTE_PATH, TOOL_EXECUTIONS_ROUTE_PATH, } from './tool-execution-routes.js';
55
+ export type { AttachmentLookup, AttachmentLookupRequest, AttachmentLookupRoutesRequired } from './attachment-lookup-routes.js';
56
+ export { ATTACHMENT_LOOKUP_ROUTE_PATH, registerAttachmentLookupRoutes } from './attachment-lookup-routes.js';
57
+ export type { RemoteAttachmentLookupOptional, RemoteAttachmentLookupRequired } from '../remote-attachment-lookup.js';
58
+ export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
53
59
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
54
60
  //# sourceMappingURL=index.d.ts.map
@@ -26,5 +26,8 @@ export { mediaGenerateRoute, mediaTaskDeleteRoute, mediaTaskGetRoute, mediaTaskL
26
26
  export { ATTACHMENTS_ROUTE_PATH, AttachmentRejectedError, createDiskAttachmentStore, detectAttachmentKind, handleAttachmentCleanup, handleAttachmentUpload, isUnchangedAttachment, registerAttachmentRoutes, sanitizeAttachmentName, stageAttachmentClaim, writeBoundedAttachmentBody, } from './attachments.js';
27
27
  export { registerXaiRoutes, xaiAuthStatusRoute, xaiOauthCancelRoute, xaiOauthCompleteRoute, xaiOauthDisconnectRoute, xaiOauthStartRoute, xaiSearchRoute, } from './xai.js';
28
28
  export * from './run-credentials.js';
29
+ export { DEFAULT_TOOL_EXEC_MAX_BODY_BYTES, DEFAULT_TOOL_EXEC_TOKEN_ENV_VAR, registerToolExecutionRoutes, requireToolExecutionCaller, TOOL_CATALOG_ROUTE_PATH, TOOL_EXECUTIONS_ROUTE_PATH, } from './tool-execution-routes.js';
30
+ export { ATTACHMENT_LOOKUP_ROUTE_PATH, registerAttachmentLookupRoutes } from './attachment-lookup-routes.js';
31
+ export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
29
32
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
30
33
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @module tool-execution-routes
3
+ *
4
+ * `POST /internal/tool-executions` and `GET /internal/tool-catalog` — the server end of
5
+ * `createRemoteToolExecutor`. A host whose agent runtime runs in another local process mounts these
6
+ * over its own gated `ToolExecutor`, so that process can run tools without loading them. The wire
7
+ * protocol and the at-most-once rules are documented on `remote-tool-executor.ts`.
8
+ *
9
+ * Trust boundary, checked before any body byte is parsed:
10
+ * - a dedicated per-boot secret read from `tokenEnvVar`, compared in constant time; unset → 503 (the
11
+ * route fails closed, there is no "open" mode), wrong or missing → 401;
12
+ * - the TCP peer (`req.socket.remoteAddress`, never a forwarded header) must be loopback → else 403.
13
+ * Only then does the route's own `express.json({ limit: maxBodyBytes })` run, so an unauthenticated
14
+ * caller cannot make the host buffer a large body, and an app-level parser with a smaller limit
15
+ * mounted after this route never sees these requests. Over the limit → 413.
16
+ *
17
+ * The executor below is the host's full gate (authorize, confirm, audit); this route adds no
18
+ * authority of its own and the serialized principal is passed through untouched for it to judge.
19
+ *
20
+ * Cancellation: the 200 headers are flushed before the executor runs, and the executor's signal
21
+ * aborts when the response closes before it finished (the caller destroyed its request). A request
22
+ * body that never completed never reaches the executor. Listeners are detached once the call
23
+ * settles, so a long-lived keep-alive socket never accumulates them.
24
+ */
25
+ import { type ErrorRequestHandler, type RequestHandler } from 'express';
26
+ import type { ToolDescriptor } from '@jini-ai/core';
27
+ import type { ToolExecutor } from '../tool-executor.js';
28
+ import { TOOL_EXECUTIONS_ROUTE_PATH } from '../remote-tool-executor.js';
29
+ export { TOOL_EXECUTIONS_ROUTE_PATH };
30
+ export declare const TOOL_CATALOG_ROUTE_PATH = "/internal/tool-catalog";
31
+ export declare const DEFAULT_TOOL_EXEC_TOKEN_ENV_VAR = "JINI_TOOL_EXEC_TOKEN";
32
+ /** Tool inputs carry whole documents; 16 MiB matches the largest the agent side sends. */
33
+ export declare const DEFAULT_TOOL_EXEC_MAX_BODY_BYTES: number;
34
+ /** Anything routes can be mounted on: an Express app or a `Router`. */
35
+ export interface ToolExecutionRouteTarget {
36
+ post(path: string, ...handlers: Array<RequestHandler | ErrorRequestHandler>): unknown;
37
+ get(path: string, ...handlers: RequestHandler[]): unknown;
38
+ }
39
+ /** Runs one call inside host context (e.g. an AsyncLocalStorage scope) built from the wire `context`. */
40
+ export type RunWithToolContext = <T>(required: {
41
+ readonly context: unknown;
42
+ readonly fn: () => Promise<T>;
43
+ }, optional?: Record<string, never>) => Promise<T>;
44
+ export interface ToolExecutionRoutesRequired {
45
+ readonly app: ToolExecutionRouteTarget;
46
+ readonly executor: ToolExecutor;
47
+ /** Built fresh on every catalog request; the route never caches it. */
48
+ readonly listToolsFor: (required: {
49
+ readonly principalId: string;
50
+ }, optional?: Record<string, never>) => readonly ToolDescriptor[] | Promise<readonly ToolDescriptor[]>;
51
+ }
52
+ export interface ToolExecutionRoutesOptional {
53
+ /** Env var holding the shared secret. Defaults to {@link DEFAULT_TOOL_EXEC_TOKEN_ENV_VAR}. */
54
+ readonly tokenEnvVar?: string;
55
+ /** Defaults to `process.env`; injectable so tests never mutate the real env. */
56
+ readonly env?: NodeJS.ProcessEnv;
57
+ /** Defaults to {@link DEFAULT_TOOL_EXEC_MAX_BODY_BYTES}. */
58
+ readonly maxBodyBytes?: number;
59
+ readonly runWithContext?: RunWithToolContext;
60
+ }
61
+ /** The token + loopback gate; mounted before any body parser. Exported for hosts that need it on another path. */
62
+ export declare function requireToolExecutionCaller(_required: Record<string, never>, { tokenEnvVar, env }?: Pick<ToolExecutionRoutesOptional, 'tokenEnvVar' | 'env'>): RequestHandler;
63
+ /**
64
+ * Mounts both routes on `app` (an Express app or a `Router`). Mount it BEFORE any app-level
65
+ * `express.json()` with a smaller limit: this route parses its own body after its gate, and an
66
+ * earlier parser would both buffer unauthenticated bodies and cap tool inputs at its own limit.
67
+ */
68
+ export declare function registerToolExecutionRoutes({ app, executor, listToolsFor }: ToolExecutionRoutesRequired, { tokenEnvVar, env, maxBodyBytes, runWithContext }?: ToolExecutionRoutesOptional): void;
69
+ //# sourceMappingURL=tool-execution-routes.d.ts.map