@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.
- package/CHANGELOG.md +34 -0
- package/dist/agent-executor/codex/home-config.d.ts +50 -0
- package/dist/agent-executor/codex/home-config.js +209 -0
- package/dist/agent-executor/codex/home-staging.d.ts +60 -0
- package/dist/agent-executor/codex/home-staging.js +195 -0
- package/dist/agent-executor/codex/isolation.d.ts +27 -0
- package/dist/agent-executor/codex/isolation.js +67 -0
- package/dist/agent-executor/contracts.d.ts +4 -1
- package/dist/agent-executor/event-translation.d.ts +7 -7
- package/dist/agent-executor/event-translation.js +17 -17
- package/dist/agent-executor/events.js +1 -1
- package/dist/agent-executor/index.d.ts +3 -1
- package/dist/agent-executor/index.js +3 -1
- package/dist/agent-executor/launch.d.ts +3 -2
- package/dist/agent-executor/launch.js +68 -17
- package/dist/agent-executor/mcp-bridge-probe.d.ts +2 -1
- package/dist/agent-executor/mcp-bridge-probe.js +1 -1
- package/dist/agent-executor/mcp-bridge.d.ts +9 -86
- package/dist/agent-executor/mcp-bridge.js +4 -355
- package/dist/ai-trace-lifecycle.d.ts +48 -0
- package/dist/ai-trace-lifecycle.js +116 -0
- package/dist/core/read-only-tools.js +3 -1
- package/dist/delegated-tool-bridge.js +19 -1
- package/dist/http/attachment-lookup-routes.d.ts +44 -0
- package/dist/http/attachment-lookup-routes.js +71 -0
- package/dist/http/attachments.d.ts +112 -7
- package/dist/http/attachments.js +58 -17
- package/dist/http/connectors.js +23 -18
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +3 -0
- package/dist/http/tool-execution-routes.d.ts +69 -0
- package/dist/http/tool-execution-routes.js +181 -0
- package/dist/http/xai.js +19 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/remote-attachment-lookup.d.ts +29 -0
- package/dist/remote-attachment-lookup.js +71 -0
- package/dist/remote-tool-executor.d.ts +85 -0
- package/dist/remote-tool-executor.js +250 -0
- package/dist/routing-tool-executor.d.ts +29 -0
- package/dist/routing-tool-executor.js +16 -0
- package/dist/run-lifecycle.d.ts +4 -1
- package/dist/run-lifecycle.js +35 -5
- package/dist/store/event-log/sqlite-kernel.d.ts +18 -0
- package/dist/store/event-log/sqlite-kernel.js +135 -0
- package/dist/store/event-log/sqlite-policy.d.ts +93 -0
- package/dist/store/event-log/sqlite-policy.js +74 -0
- package/dist/store/event-log/sqlite.d.ts +7 -31
- package/dist/store/event-log/sqlite.js +32 -95
- package/dist/tool-recovery/analytics.d.ts +27 -0
- package/dist/tool-recovery/analytics.js +38 -0
- package/dist/tool-recovery/index.d.ts +2 -0
- package/dist/tool-recovery/index.js +2 -0
- package/dist/tool-recovery/observability.d.ts +82 -0
- package/dist/tool-recovery/observability.js +88 -0
- 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:
|
|
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
|
-
|
|
558
|
+
handle?: FileHandle;
|
|
559
|
+
}): Promise<{
|
|
455
560
|
size: number;
|
|
456
561
|
signature: Uint8Array;
|
|
457
562
|
}>;
|
package/dist/http/attachments.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 =
|
|
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
|
|
1005
|
-
|
|
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' }) });
|
package/dist/http/connectors.js
CHANGED
|
@@ -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,
|
|
59
|
-
|
|
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 });
|
package/dist/http/index.d.ts
CHANGED
|
@@ -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
|
package/dist/http/index.js
CHANGED
|
@@ -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
|