@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.
- package/CHANGELOG.md +3 -14
- package/dist/browser/neurolink.min.js +419 -419
- package/dist/server/middleware/auth.d.ts +5 -1
- package/dist/server/middleware/auth.js +10 -3
- package/dist/server/openapi/schemas.js +2 -2
- package/dist/server/routes/agentRoutes.d.ts +9 -2
- package/dist/server/routes/agentRoutes.js +35 -9
- package/dist/server/routes/index.js +1 -1
- package/dist/server/utils/agentInput.d.ts +53 -0
- package/dist/server/utils/agentInput.js +304 -0
- package/dist/types/server.d.ts +54 -5
- package/docs-site/static/search-index.json +3 -2
- package/package.json +2 -1
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|
package/dist/types/server.d.ts
CHANGED
|
@@ -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
|
|
945
|
-
* When true (default),
|
|
946
|
-
* x-neurolink-playground header set to "true"
|
|
947
|
-
*
|
|
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
|
|
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 = {
|