@juspay/neurolink 13.0.0 → 13.0.2

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.
@@ -7,12 +7,16 @@ import type { ApiKeyAuthOptions, AuthResult, AuthenticatedUser, BearerAuthOption
7
7
  * Check if request is from development playground.
8
8
  * Detects playground requests via special headers for development mode.
9
9
  *
10
+ * This only inspects the headers. `createAuthMiddleware` honours the result
11
+ * solely when `process.env.NODE_ENV` is exactly `"development"`, so a caller
12
+ * that acts on this on its own must apply that gate itself.
13
+ *
10
14
  * @param headers - Request headers to check
11
15
  * @returns True if request is from dev playground
12
16
  *
13
17
  * @example
14
18
  * ```typescript
15
- * if (isDevPlayground(ctx.headers)) {
19
+ * if (process.env.NODE_ENV === "development" && isDevPlayground(ctx.headers)) {
16
20
  * // Skip authentication for playground
17
21
  * }
18
22
  * ```
@@ -7,12 +7,16 @@ import { AuthenticationError, AuthorizationError, InvalidAuthenticationError, }
7
7
  * Check if request is from development playground.
8
8
  * Detects playground requests via special headers for development mode.
9
9
  *
10
+ * This only inspects the headers. `createAuthMiddleware` honours the result
11
+ * solely when `process.env.NODE_ENV` is exactly `"development"`, so a caller
12
+ * that acts on this on its own must apply that gate itself.
13
+ *
10
14
  * @param headers - Request headers to check
11
15
  * @returns True if request is from dev playground
12
16
  *
13
17
  * @example
14
18
  * ```typescript
15
- * if (isDevPlayground(ctx.headers)) {
19
+ * if (process.env.NODE_ENV === "development" && isDevPlayground(ctx.headers)) {
16
20
  * // Skip authentication for playground
17
21
  * }
18
22
  * ```
@@ -61,9 +65,12 @@ export function createAuthMiddleware(config) {
61
65
  order: 10, // Run early but after request ID
62
66
  excludePaths: skipPaths,
63
67
  handler: async (ctx, next) => {
64
- // Skip auth for dev playground in non-production
68
+ // The playground header is a development convenience, so it is honoured
69
+ // only when NODE_ENV is exactly "development". An unset NODE_ENV (the
70
+ // default for most deployments), "test", "staging" or a misspelling must
71
+ // not let an unauthenticated caller claim the developer identity.
65
72
  if (skipDevPlayground &&
66
- process.env.NODE_ENV !== "production" &&
73
+ process.env.NODE_ENV === "development" &&
67
74
  isDevPlayground(ctx.headers)) {
68
75
  ctx.user = {
69
76
  id: DEV_PLAYGROUND_USER.id,
@@ -117,12 +117,12 @@ export const AgentInputSchema = {
117
117
  images: {
118
118
  type: "array",
119
119
  items: { type: "string" },
120
- description: "Array of image URLs or base64 data",
120
+ description: "Array of images, each a data: URI, an https URL the server downloads (public hosts only, no redirects) or a path under a directory the server allows (allowedInputRoots; with none set every local path is refused with 403). Bare base64 is not accepted, and http:// or any other scheme is refused with 400. A request is limited in attachment count and total bytes (413).",
121
121
  },
122
122
  files: {
123
123
  type: "array",
124
124
  items: { type: "string" },
125
- description: "Array of file paths or URLs",
125
+ description: "Array of files, each a data: URI, an https URL the server downloads (public hosts only, no redirects) or a path under a directory the server allows (allowedInputRoots; with none set every local path is refused with 403). http:// or any other scheme is refused with 400. A request is limited in attachment count and total bytes (413), images and files together.",
126
126
  },
127
127
  },
128
128
  },
@@ -2,8 +2,15 @@
2
2
  * Agent Routes
3
3
  * Endpoints for agent execution and streaming
4
4
  */
5
- import type { RouteGroup } from "../../types/index.js";
5
+ import type { RouteGroup, ServerAgentRouteOptions } from "../../types/index.js";
6
6
  /**
7
7
  * Create agent routes
8
+ *
9
+ * @param options - Limits for `input.images` / `input.files`:
10
+ * `allowedInputRoots` (default: none, every local-path attachment is
11
+ * refused), `maxInputBytes` per attachment (default 50 MiB),
12
+ * `maxInputAttachments` per request (default 20, images and files together)
13
+ * and `maxInputTotalBytes` per request (default 100 MiB, one budget shared by
14
+ * images and files).
8
15
  */
9
- export declare function createAgentRoutes(basePath?: string): RouteGroup;
16
+ export declare function createAgentRoutes(basePath?: string, options?: ServerAgentRouteOptions): RouteGroup;
@@ -27,6 +27,7 @@ async function resolveRequestToolFilter(ctx, requestedTools) {
27
27
  logger.warn("[agentRoutes] Ignoring request.tools — no entries match registered tool names (legacy fail-open)", { requested: requestedTools });
28
28
  return undefined;
29
29
  }
30
+ import { AgentInputError, resolveAgentInput } from "../utils/agentInput.js";
30
31
  import { createStreamRedactor } from "../utils/redaction.js";
31
32
  import { AgentExecuteRequestSchema, createErrorResponse as createError, EmbedManyRequestSchema, EmbedRequestSchema, SkillCreateRequestSchema, SkillUpdateRequestSchema, validateRequest, } from "../utils/validation.js";
32
33
  /**
@@ -56,10 +57,35 @@ function resolveMutableSkillsManager(ctx) {
56
57
  }
57
58
  return manager;
58
59
  }
60
+ /**
61
+ * The request's `input` with its attachments vetted (see `agentInput.ts`), or
62
+ * the error response for the first one the server refuses.
63
+ */
64
+ async function resolveRequestInput(ctx, requestInput, options) {
65
+ if (typeof requestInput === "string") {
66
+ return { text: requestInput };
67
+ }
68
+ try {
69
+ return await resolveAgentInput(requestInput, options);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof AgentInputError) {
73
+ return createError(error.code, error.message, undefined, ctx.requestId, error.status);
74
+ }
75
+ throw error;
76
+ }
77
+ }
59
78
  /**
60
79
  * Create agent routes
80
+ *
81
+ * @param options - Limits for `input.images` / `input.files`:
82
+ * `allowedInputRoots` (default: none, every local-path attachment is
83
+ * refused), `maxInputBytes` per attachment (default 50 MiB),
84
+ * `maxInputAttachments` per request (default 20, images and files together)
85
+ * and `maxInputTotalBytes` per request (default 100 MiB, one budget shared by
86
+ * images and files).
61
87
  */
62
- export function createAgentRoutes(basePath = "/api") {
88
+ export function createAgentRoutes(basePath = "/api", options) {
63
89
  return {
64
90
  prefix: `${basePath}/agent`,
65
91
  routes: [
@@ -82,10 +108,10 @@ export function createAgentRoutes(basePath = "/api") {
82
108
  "ai.model": request.model || "default",
83
109
  },
84
110
  }, async () => {
85
- // Normalize input
86
- const input = typeof request.input === "string"
87
- ? { text: request.input }
88
- : request.input;
111
+ const input = await resolveRequestInput(ctx, request.input, options);
112
+ if ("error" in input) {
113
+ return input;
114
+ }
89
115
  const requestToolFilter = await resolveRequestToolFilter(ctx, request.tools);
90
116
  const result = await ctx.neurolink.generate({
91
117
  input,
@@ -135,10 +161,10 @@ export function createAgentRoutes(basePath = "/api") {
135
161
  return validation.error;
136
162
  }
137
163
  const request = validation.data;
138
- // Normalize input
139
- const input = typeof request.input === "string"
140
- ? { text: request.input }
141
- : request.input;
164
+ const input = await resolveRequestInput(ctx, request.input, options);
165
+ if ("error" in input) {
166
+ return input;
167
+ }
142
168
  const streamToolFilter = await resolveRequestToolFilter(ctx, request.tools);
143
169
  const result = await ctx.neurolink.stream({
144
170
  input,
@@ -41,7 +41,7 @@ export { createTranscribeRoutes } from "./transcribeRoutes.js";
41
41
  */
42
42
  export function createAllRoutes(basePath = "/api", options) {
43
43
  const routes = [
44
- createAgentRoutes(basePath),
44
+ createAgentRoutes(basePath, options?.agent),
45
45
  createToolRoutes(basePath),
46
46
  createMCPRoutes(basePath),
47
47
  createMemoryRoutes(basePath),
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Agent request attachments, vetted at the HTTP boundary.
3
+ *
4
+ * `input.images` and `input.files` reach the SDK as strings, and the SDK
5
+ * resolves a string itself: an `http(s)` URL is fetched with redirects followed
6
+ * and no address check, and anything else is read from the local disk. Both
7
+ * are right for a program that names its own files. Neither is right when the
8
+ * string came from a caller of a network server, who could name
9
+ * `https://169.254.169.254/...`, a redirect to it, or `/etc/passwd`.
10
+ *
11
+ * So the route resolves every attachment itself and hands the SDK bytes:
12
+ * - `data:` URIs pass through, they carry their own content;
13
+ * - `https` URLs are downloaded with `safeDownload`: redirects are refused and
14
+ * the size is capped. The address is checked and the connection pinned to
15
+ * the checked addresses only for a direct download. Behind `HTTPS_PROXY` /
16
+ * `ALL_PROXY` the proxy resolves the host, so only the URL text is checked
17
+ * (IP literals, single-label and internal host names) and what a
18
+ * public-looking name resolves to is the proxy's egress policy;
19
+ * - local paths are read only from the operator's `allowedInputRoots`, after
20
+ * symlinks are resolved;
21
+ * - everything else is refused rather than guessed at, so this classifier
22
+ * can never disagree with the SDK's about what a string means.
23
+ *
24
+ * One request is also bounded as a whole: at most `maxInputAttachments`
25
+ * entries, each at most `maxInputBytes`, all together at most
26
+ * `maxInputTotalBytes`. The count and every `data:` URI are judged before
27
+ * anything is read; the rest are resolved one at a time, each allowed only what
28
+ * is left of the shared budget, so a transfer that would overflow it stops
29
+ * mid-stream.
30
+ *
31
+ * The SDK itself is untouched: a program calling `generate()` directly keeps
32
+ * reading the paths and URLs it names.
33
+ *
34
+ * @module server/utils/agentInput
35
+ */
36
+ import type { ServerAgentResolvedInput, ServerAgentRouteOptions } from "../../types/index.js";
37
+ /** An attachment the server refuses, with the HTTP status that says why. */
38
+ export declare class AgentInputError extends Error {
39
+ readonly status: number;
40
+ readonly code: string;
41
+ constructor(message: string, status: number, code: string);
42
+ }
43
+ /**
44
+ * Replace every attachment of an agent request's `input` with vetted content.
45
+ *
46
+ * @throws {AgentInputError} for the first attachment that is refused, or for a
47
+ * request that carries too many attachments or too many bytes.
48
+ */
49
+ export declare function resolveAgentInput(input: {
50
+ text: string;
51
+ images?: string[];
52
+ files?: string[];
53
+ }, options?: ServerAgentRouteOptions): Promise<ServerAgentResolvedInput>;
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Agent request attachments, vetted at the HTTP boundary.
3
+ *
4
+ * `input.images` and `input.files` reach the SDK as strings, and the SDK
5
+ * resolves a string itself: an `http(s)` URL is fetched with redirects followed
6
+ * and no address check, and anything else is read from the local disk. Both
7
+ * are right for a program that names its own files. Neither is right when the
8
+ * string came from a caller of a network server, who could name
9
+ * `https://169.254.169.254/...`, a redirect to it, or `/etc/passwd`.
10
+ *
11
+ * So the route resolves every attachment itself and hands the SDK bytes:
12
+ * - `data:` URIs pass through, they carry their own content;
13
+ * - `https` URLs are downloaded with `safeDownload`: redirects are refused and
14
+ * the size is capped. The address is checked and the connection pinned to
15
+ * the checked addresses only for a direct download. Behind `HTTPS_PROXY` /
16
+ * `ALL_PROXY` the proxy resolves the host, so only the URL text is checked
17
+ * (IP literals, single-label and internal host names) and what a
18
+ * public-looking name resolves to is the proxy's egress policy;
19
+ * - local paths are read only from the operator's `allowedInputRoots`, after
20
+ * symlinks are resolved;
21
+ * - everything else is refused rather than guessed at, so this classifier
22
+ * can never disagree with the SDK's about what a string means.
23
+ *
24
+ * One request is also bounded as a whole: at most `maxInputAttachments`
25
+ * entries, each at most `maxInputBytes`, all together at most
26
+ * `maxInputTotalBytes`. The count and every `data:` URI are judged before
27
+ * anything is read; the rest are resolved one at a time, each allowed only what
28
+ * is left of the shared budget, so a transfer that would overflow it stops
29
+ * mid-stream.
30
+ *
31
+ * The SDK itself is untouched: a program calling `generate()` directly keeps
32
+ * reading the paths and URLs it names.
33
+ *
34
+ * @module server/utils/agentInput
35
+ */
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { logger } from "../../utils/logger.js";
39
+ const DEFAULT_MAX_INPUT_BYTES = 50 * 1024 * 1024;
40
+ const DEFAULT_MAX_INPUT_TOTAL_BYTES = 100 * 1024 * 1024;
41
+ const DEFAULT_MAX_INPUT_ATTACHMENTS = 20;
42
+ const READ_CHUNK_BYTES = 64 * 1024;
43
+ /** Two or more scheme characters, so a Windows drive letter (`C:\`) is not one. */
44
+ const URL_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]+:/;
45
+ /** An attachment the server refuses, with the HTTP status that says why. */
46
+ export class AgentInputError extends Error {
47
+ status;
48
+ code;
49
+ constructor(message, status, code) {
50
+ super(message);
51
+ this.status = status;
52
+ this.code = code;
53
+ this.name = "AgentInputError";
54
+ }
55
+ }
56
+ const isUnder = (child, root) => child === root ||
57
+ child.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
58
+ const forbidden = () => new AgentInputError("attachment path is outside the directories this server allows (or local paths are disabled)", 403, "FORBIDDEN");
59
+ const notFound = () => new AgentInputError("attachment path does not exist", 404, "NOT_FOUND");
60
+ const notReadable = () => new AgentInputError("attachment path is not readable", 403, "FORBIDDEN");
61
+ const notAFile = () => new AgentInputError("attachment path is not a file", 400, "VALIDATION_ERROR");
62
+ const tooLarge = (limit) => new AgentInputError(`attachment exceeds the ${limit}-byte limit`, 413, "PAYLOAD_TOO_LARGE");
63
+ const budgetExceeded = (total) => new AgentInputError(`attachments together exceed the ${total}-byte limit`, 413, "PAYLOAD_TOO_LARGE");
64
+ const tooMany = (max) => new AgentInputError(`a request may carry at most ${max} attachments`, 413, "PAYLOAD_TOO_LARGE");
65
+ /** A configured limit, or the default when it is missing or not a number >= 0. */
66
+ const limitOr = (value, fallback) => typeof value === "number" && value >= 0 ? value : fallback;
67
+ const isDataUri = (entry) => entry.startsWith("data:");
68
+ const fsErrorCode = (error) => typeof error === "object" &&
69
+ error !== null &&
70
+ "code" in error &&
71
+ typeof error.code === "string"
72
+ ? error.code
73
+ : undefined;
74
+ /**
75
+ * Turn whatever the file system threw into the refusal the caller gets. Nothing
76
+ * raw gets through: a Node error message carries the server's path, and an
77
+ * unmapped code is logged for the operator (the code only) and answered like an
78
+ * unreadable file instead of surfacing as a 500.
79
+ */
80
+ function asAgentInputError(error) {
81
+ if (error instanceof AgentInputError) {
82
+ return error;
83
+ }
84
+ const code = fsErrorCode(error);
85
+ switch (code) {
86
+ case "ENOENT":
87
+ case "ENOTDIR":
88
+ return notFound();
89
+ case "EACCES":
90
+ case "EPERM":
91
+ case "ELOOP":
92
+ return notReadable();
93
+ case "ENXIO":
94
+ case "EOPNOTSUPP":
95
+ case "ENOTSUP":
96
+ case "EISDIR":
97
+ return notAFile();
98
+ case "ENAMETOOLONG":
99
+ case "EINVAL":
100
+ case "ERR_INVALID_ARG_VALUE":
101
+ return new AgentInputError("attachment path is not a usable path", 400, "VALIDATION_ERROR");
102
+ default:
103
+ logger.warn("[agentInput] unmapped file-system error on an attachment", {
104
+ code,
105
+ name: error instanceof Error ? error.name : typeof error,
106
+ });
107
+ return notReadable();
108
+ }
109
+ }
110
+ /**
111
+ * A file under one of the allowed roots, after symlinks are resolved.
112
+ *
113
+ * A path outside every root is refused before the file system is touched, so
114
+ * the answer for `/etc/passwd` and for `/etc/nothing-here` is the same 403 and
115
+ * the route is not an oracle for which files exist.
116
+ */
117
+ async function readAllowedPath(requested, roots, limit) {
118
+ if (!roots || roots.length === 0) {
119
+ throw forbidden();
120
+ }
121
+ const lexical = path.resolve(requested);
122
+ const realRoots = (await Promise.all(roots.map(async (root) => {
123
+ try {
124
+ return [path.resolve(root), await fs.promises.realpath(root)];
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }))).flat();
130
+ if (!realRoots.some((root) => isUnder(lexical, root))) {
131
+ throw forbidden();
132
+ }
133
+ let real;
134
+ try {
135
+ real = await fs.promises.realpath(lexical);
136
+ }
137
+ catch (error) {
138
+ throw asAgentInputError(error);
139
+ }
140
+ if (!realRoots.some((root) => isUnder(real, root))) {
141
+ throw forbidden();
142
+ }
143
+ // Open once and judge the descriptor, not the path: a path can be swapped for
144
+ // a symlink between the checks above and a later read. O_NOFOLLOW refuses a
145
+ // symlink in the last component; a directory swapped higher up is not caught,
146
+ // which matters only if something untrusted can write under an allowed root.
147
+ let handle;
148
+ try {
149
+ // O_NONBLOCK: opening a FIFO for reading otherwise waits for a writer, and
150
+ // each such request would hold a libuv worker until one appears. With it
151
+ // the open returns at once and the stat below refuses the FIFO as not a
152
+ // file. Regular files read the same either way. Windows has no constant.
153
+ handle = await fs.promises.open(real, fs.constants.O_RDONLY |
154
+ fs.constants.O_NOFOLLOW |
155
+ (fs.constants.O_NONBLOCK ?? 0));
156
+ }
157
+ catch (error) {
158
+ throw asAgentInputError(error);
159
+ }
160
+ let content;
161
+ try {
162
+ const stat = await handle.stat();
163
+ if (!stat.isFile()) {
164
+ throw notAFile();
165
+ }
166
+ if (stat.size > limit) {
167
+ throw tooLarge(limit);
168
+ }
169
+ content = await readWithinLimit(handle, limit);
170
+ }
171
+ catch (error) {
172
+ await handle.close().catch(() => undefined);
173
+ throw asAgentInputError(error);
174
+ }
175
+ try {
176
+ await handle.close();
177
+ }
178
+ catch (error) {
179
+ throw asAgentInputError(error);
180
+ }
181
+ return content;
182
+ }
183
+ /**
184
+ * Read the whole file, refusing one that outgrew `limit` since it was measured:
185
+ * reads one byte past the limit, so growth is seen rather than buffered.
186
+ */
187
+ async function readWithinLimit(handle, limit) {
188
+ const chunks = [];
189
+ let total = 0;
190
+ while (total <= limit) {
191
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, limit + 1 - total));
192
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
193
+ if (bytesRead === 0) {
194
+ break;
195
+ }
196
+ chunks.push(chunk.subarray(0, bytesRead));
197
+ total += bytesRead;
198
+ }
199
+ if (total > limit) {
200
+ throw tooLarge(limit);
201
+ }
202
+ return Buffer.concat(chunks, total);
203
+ }
204
+ async function downloadAttachment(url, limit) {
205
+ // Dynamic import keeps undici off the static import graph; see
206
+ // test/continuous-test-suite-import-cost.ts.
207
+ const { safeDownload } = await import("../../utils/safeFetch.js");
208
+ try {
209
+ return await safeDownload(url, {
210
+ maxBytes: limit,
211
+ label: "agent attachment",
212
+ });
213
+ }
214
+ catch (error) {
215
+ // The reason can name the address a host resolved to; the caller gets none
216
+ // of it, the operator gets it in the log.
217
+ const reason = error instanceof Error ? error.message : String(error);
218
+ logger.warn("[agentInput] refused an attachment URL", {
219
+ reason: reason.replaceAll(url, "<url>"),
220
+ });
221
+ throw new AgentInputError("attachment URL could not be fetched (it must be a public https URL, answer without a redirect and fit the size limit)", 400, "VALIDATION_ERROR");
222
+ }
223
+ }
224
+ /** The bytes of one attachment that is not a `data:` URI, read within `limit`. */
225
+ async function resolveAttachment(entry, roots, limit) {
226
+ if (/^https?:\/\//i.test(entry)) {
227
+ return downloadAttachment(entry, limit);
228
+ }
229
+ if (URL_SCHEME.test(entry)) {
230
+ throw new AgentInputError("attachment must be a data: URI, an https URL, or a path under an allowed directory", 400, "VALIDATION_ERROR");
231
+ }
232
+ return readAllowedPath(entry, roots, limit);
233
+ }
234
+ /**
235
+ * Replace every attachment of an agent request's `input` with vetted content.
236
+ *
237
+ * @throws {AgentInputError} for the first attachment that is refused, or for a
238
+ * request that carries too many attachments or too many bytes.
239
+ */
240
+ export async function resolveAgentInput(input, options) {
241
+ const maxAttachments = limitOr(options?.maxInputAttachments, DEFAULT_MAX_INPUT_ATTACHMENTS);
242
+ const totalBytes = limitOr(options?.maxInputTotalBytes, DEFAULT_MAX_INPUT_TOTAL_BYTES);
243
+ const perAttachment = Math.min(limitOr(options?.maxInputBytes, DEFAULT_MAX_INPUT_BYTES), totalBytes);
244
+ const roots = options?.allowedInputRoots;
245
+ const entries = [...(input.images ?? []), ...(input.files ?? [])];
246
+ // Everything that needs no I/O is judged first, so a request that is already
247
+ // too big costs the server nothing: the count, then each data: URI against
248
+ // its own cap and against the shared budget.
249
+ if (entries.length > maxAttachments) {
250
+ throw tooMany(maxAttachments);
251
+ }
252
+ let remaining = totalBytes;
253
+ for (const entry of entries) {
254
+ if (!isDataUri(entry)) {
255
+ continue;
256
+ }
257
+ if (entry.length > perAttachment) {
258
+ throw tooLarge(perAttachment);
259
+ }
260
+ remaining -= entry.length;
261
+ if (remaining < 0) {
262
+ throw budgetExceeded(totalBytes);
263
+ }
264
+ }
265
+ // One at a time: a request naming many URLs must not open them all at once,
266
+ // and each entry may use only what the ones before it left of the budget.
267
+ const resolveList = async (list) => {
268
+ if (list === undefined) {
269
+ return undefined;
270
+ }
271
+ const resolved = [];
272
+ for (const entry of list) {
273
+ // Exactly the prefix the SDK keys on, so nothing that reaches it as a
274
+ // string is anything but a data URI.
275
+ if (isDataUri(entry)) {
276
+ resolved.push(entry);
277
+ continue;
278
+ }
279
+ const limit = Math.min(perAttachment, remaining);
280
+ let bytes;
281
+ try {
282
+ bytes = await resolveAttachment(entry, roots, limit);
283
+ }
284
+ catch (error) {
285
+ if (limit < perAttachment &&
286
+ error instanceof AgentInputError &&
287
+ error.status === 413) {
288
+ throw budgetExceeded(totalBytes);
289
+ }
290
+ throw error;
291
+ }
292
+ remaining -= bytes.length;
293
+ resolved.push(bytes);
294
+ }
295
+ return resolved;
296
+ };
297
+ const images = await resolveList(input.images);
298
+ const files = await resolveList(input.files);
299
+ return {
300
+ text: input.text,
301
+ ...(images ? { images } : {}),
302
+ ...(files ? { files } : {}),
303
+ };
304
+ }
@@ -434,6 +434,45 @@ export type AgentExecuteRequest = {
434
434
  /** User ID for context */
435
435
  userId?: string;
436
436
  };
437
+ /** Options for `createAgentRoutes()`. */
438
+ export type ServerAgentRouteOptions = {
439
+ /**
440
+ * Directories a request's `input.images` / `input.files` entries may name as
441
+ * local paths. Empty or omitted (the default) refuses every local path: a
442
+ * server must not read its own disk on a caller's say-so unless the operator
443
+ * names where.
444
+ */
445
+ allowedInputRoots?: string[];
446
+ /**
447
+ * Largest single attachment accepted, in bytes. Default 50 MiB. A `data:` URI
448
+ * is measured by its string length. When `maxInputTotalBytes` is set lower,
449
+ * the smaller of the two is the per-attachment cap.
450
+ */
451
+ maxInputBytes?: number;
452
+ /**
453
+ * Most attachments one request may carry, `input.images` and `input.files`
454
+ * counted together. Default 20. A longer list is refused with 413 before any
455
+ * attachment is read or downloaded.
456
+ */
457
+ maxInputAttachments?: number;
458
+ /**
459
+ * Largest sum of all attachments of one request, in bytes: images and files
460
+ * share this one budget, and a `data:` URI is charged its string length.
461
+ * Default 100 MiB. A request that overflows it is refused with 413, and a
462
+ * file or download that would overflow it stops mid-transfer.
463
+ */
464
+ maxInputTotalBytes?: number;
465
+ };
466
+ /**
467
+ * An agent request's `input` once every attachment has been vetted: URLs and
468
+ * allowed local paths are replaced by the bytes the server read itself, `data:`
469
+ * URIs pass through untouched.
470
+ */
471
+ export type ServerAgentResolvedInput = {
472
+ text: string;
473
+ images?: Array<Buffer | string>;
474
+ files?: Array<Buffer | string>;
475
+ };
437
476
  /**
438
477
  * Agent execution response
439
478
  */
@@ -941,12 +980,16 @@ export type ServerServerAuthConfig = {
941
980
  */
942
981
  extractToken?: (ctx: ServerContext) => string | null;
943
982
  /**
944
- * Skip authentication for dev playground requests in non-production.
945
- * When true (default), requests with x-neurolink-dev-playground or
946
- * x-neurolink-playground header set to "true" will bypass authentication
947
- * and receive a default developer user context.
983
+ * Skip authentication for dev playground requests during development.
984
+ * When true (default), a request with an x-neurolink-dev-playground or
985
+ * x-neurolink-playground header set to "true" bypasses authentication and
986
+ * receives a default developer user context (`id: "playground"`,
987
+ * `roles: ["developer"]`).
948
988
  *
949
- * Only applies when NODE_ENV is not "production".
989
+ * Only applies when `process.env.NODE_ENV` is exactly `"development"`,
990
+ * read on every request. With NODE_ENV unset, `"production"`, `"test"` or
991
+ * any other value the header is ignored and the request must authenticate
992
+ * as usual. Set this to `false` to refuse the header even in development.
950
993
  *
951
994
  * @default true
952
995
  */
@@ -1106,6 +1149,12 @@ export type CreateRoutesOptions = {
1106
1149
  * always mounted.
1107
1150
  */
1108
1151
  transcribe?: ServerTranscribeRouteOptions;
1152
+ /**
1153
+ * Options for the agent routes (`POST <basePath>/agent/execute` and
1154
+ * `/agent/stream`): where local-path attachments may be read from, and how
1155
+ * large an attachment may be.
1156
+ */
1157
+ agent?: ServerAgentRouteOptions;
1109
1158
  };
1110
1159
  /** Options for `createTranscribeRoutes()`. */
1111
1160
  export type ServerTranscribeRouteOptions = {