@juspay/neurolink 9.94.4 → 9.94.6
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 +12 -0
- package/dist/browser/neurolink.min.js +401 -401
- package/dist/core/modules/MessageBuilder.js +32 -3
- package/dist/lib/core/modules/MessageBuilder.js +32 -3
- package/dist/lib/types/generate.d.ts +1 -0
- package/dist/lib/types/multimodal.d.ts +34 -3
- package/dist/lib/types/stream.d.ts +1 -0
- package/dist/lib/utils/errorHandling.d.ts +22 -0
- package/dist/lib/utils/errorHandling.js +55 -0
- package/dist/lib/utils/fileDetector.js +210 -31
- package/dist/lib/utils/imageCache.js +11 -8
- package/dist/lib/utils/imageProcessor.d.ts +37 -0
- package/dist/lib/utils/imageProcessor.js +222 -16
- package/dist/lib/utils/logSanitize.d.ts +100 -1
- package/dist/lib/utils/logSanitize.js +152 -2
- package/dist/lib/utils/messageBuilder.js +80 -9
- package/dist/types/generate.d.ts +1 -0
- package/dist/types/multimodal.d.ts +34 -3
- package/dist/types/stream.d.ts +1 -0
- package/dist/utils/errorHandling.d.ts +22 -0
- package/dist/utils/errorHandling.js +55 -0
- package/dist/utils/fileDetector.js +210 -31
- package/dist/utils/imageCache.js +11 -8
- package/dist/utils/imageProcessor.d.ts +37 -0
- package/dist/utils/imageProcessor.js +222 -16
- package/dist/utils/logSanitize.d.ts +100 -1
- package/dist/utils/logSanitize.js +152 -2
- package/dist/utils/messageBuilder.js +80 -9
- package/package.json +1 -1
|
@@ -22,7 +22,18 @@ function computeTotalContentLength(messages) {
|
|
|
22
22
|
return total;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* Check whether input contains multimodal content (images, files, PDFs, CSVs
|
|
25
|
+
* Check whether input contains multimodal content (images, files, PDFs, CSVs,
|
|
26
|
+
* audio, video). #284: audioFiles/videoFiles were previously ignored here, so a
|
|
27
|
+
* request carrying only audio or video was misrouted to the text-only path.
|
|
28
|
+
*
|
|
29
|
+
* Intentional (round-2 review): routing audio/video through `isMultimodal`
|
|
30
|
+
* does NOT mean vision is required. `buildMultimodalMessagesArray` folds
|
|
31
|
+
* `audioFiles`/`videoFiles` into `inp.files` for auto-detection, and
|
|
32
|
+
* `appendDetectedFileResult` injects their content as plain text markers
|
|
33
|
+
* (`## Audio File:` / `## Video File:`), not image/vision blocks — see
|
|
34
|
+
* `test/continuous-test-suite-bugfixes.ts` ("MessageBuilder #284: ...").
|
|
35
|
+
* "Multimodal" here means "needs the multimodal builder so the payload isn't
|
|
36
|
+
* dropped", not "needs a vision model".
|
|
26
37
|
*/
|
|
27
38
|
function detectMultimodal(opts) {
|
|
28
39
|
const input = opts.input;
|
|
@@ -31,10 +42,18 @@ function detectMultimodal(opts) {
|
|
|
31
42
|
const hasCSVFiles = !!input?.csvFiles?.length;
|
|
32
43
|
const hasPdfFiles = !!input?.pdfFiles?.length;
|
|
33
44
|
const hasFiles = !!input?.files?.length;
|
|
45
|
+
const hasAudioFiles = !!input?.audioFiles?.length;
|
|
46
|
+
const hasVideoFiles = !!input?.videoFiles?.length;
|
|
34
47
|
return {
|
|
35
|
-
isMultimodal: hasImages ||
|
|
48
|
+
isMultimodal: hasImages ||
|
|
49
|
+
hasContent ||
|
|
50
|
+
hasCSVFiles ||
|
|
51
|
+
hasPdfFiles ||
|
|
52
|
+
hasFiles ||
|
|
53
|
+
hasAudioFiles ||
|
|
54
|
+
hasVideoFiles,
|
|
36
55
|
hasImages,
|
|
37
|
-
hasFiles: hasCSVFiles || hasPdfFiles || hasFiles,
|
|
56
|
+
hasFiles: hasCSVFiles || hasPdfFiles || hasFiles || hasAudioFiles || hasVideoFiles,
|
|
38
57
|
};
|
|
39
58
|
}
|
|
40
59
|
/**
|
|
@@ -75,6 +94,12 @@ export class MessageBuilder {
|
|
|
75
94
|
content: input?.content,
|
|
76
95
|
csvFiles: input?.csvFiles,
|
|
77
96
|
pdfFiles: input?.pdfFiles,
|
|
97
|
+
// #284: audioFiles/videoFiles must be forwarded too, or the
|
|
98
|
+
// multimodal builder receives an empty payload despite
|
|
99
|
+
// detectMultimodal() correctly routing audio/video-only
|
|
100
|
+
// requests here.
|
|
101
|
+
audioFiles: input?.audioFiles,
|
|
102
|
+
videoFiles: input?.videoFiles,
|
|
78
103
|
files: input?.files,
|
|
79
104
|
},
|
|
80
105
|
csvOptions: options.csvOptions,
|
|
@@ -192,6 +217,10 @@ export class MessageBuilder {
|
|
|
192
217
|
content: input?.content,
|
|
193
218
|
csvFiles: input?.csvFiles,
|
|
194
219
|
pdfFiles: input?.pdfFiles,
|
|
220
|
+
// #284: forward audioFiles/videoFiles here too — see the
|
|
221
|
+
// matching comment in buildMessages() above.
|
|
222
|
+
audioFiles: input?.audioFiles,
|
|
223
|
+
videoFiles: input?.videoFiles,
|
|
195
224
|
files: input?.files,
|
|
196
225
|
},
|
|
197
226
|
csvOptions: options.csvOptions,
|
|
@@ -22,7 +22,18 @@ function computeTotalContentLength(messages) {
|
|
|
22
22
|
return total;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* Check whether input contains multimodal content (images, files, PDFs, CSVs
|
|
25
|
+
* Check whether input contains multimodal content (images, files, PDFs, CSVs,
|
|
26
|
+
* audio, video). #284: audioFiles/videoFiles were previously ignored here, so a
|
|
27
|
+
* request carrying only audio or video was misrouted to the text-only path.
|
|
28
|
+
*
|
|
29
|
+
* Intentional (round-2 review): routing audio/video through `isMultimodal`
|
|
30
|
+
* does NOT mean vision is required. `buildMultimodalMessagesArray` folds
|
|
31
|
+
* `audioFiles`/`videoFiles` into `inp.files` for auto-detection, and
|
|
32
|
+
* `appendDetectedFileResult` injects their content as plain text markers
|
|
33
|
+
* (`## Audio File:` / `## Video File:`), not image/vision blocks — see
|
|
34
|
+
* `test/continuous-test-suite-bugfixes.ts` ("MessageBuilder #284: ...").
|
|
35
|
+
* "Multimodal" here means "needs the multimodal builder so the payload isn't
|
|
36
|
+
* dropped", not "needs a vision model".
|
|
26
37
|
*/
|
|
27
38
|
function detectMultimodal(opts) {
|
|
28
39
|
const input = opts.input;
|
|
@@ -31,10 +42,18 @@ function detectMultimodal(opts) {
|
|
|
31
42
|
const hasCSVFiles = !!input?.csvFiles?.length;
|
|
32
43
|
const hasPdfFiles = !!input?.pdfFiles?.length;
|
|
33
44
|
const hasFiles = !!input?.files?.length;
|
|
45
|
+
const hasAudioFiles = !!input?.audioFiles?.length;
|
|
46
|
+
const hasVideoFiles = !!input?.videoFiles?.length;
|
|
34
47
|
return {
|
|
35
|
-
isMultimodal: hasImages ||
|
|
48
|
+
isMultimodal: hasImages ||
|
|
49
|
+
hasContent ||
|
|
50
|
+
hasCSVFiles ||
|
|
51
|
+
hasPdfFiles ||
|
|
52
|
+
hasFiles ||
|
|
53
|
+
hasAudioFiles ||
|
|
54
|
+
hasVideoFiles,
|
|
36
55
|
hasImages,
|
|
37
|
-
hasFiles: hasCSVFiles || hasPdfFiles || hasFiles,
|
|
56
|
+
hasFiles: hasCSVFiles || hasPdfFiles || hasFiles || hasAudioFiles || hasVideoFiles,
|
|
38
57
|
};
|
|
39
58
|
}
|
|
40
59
|
/**
|
|
@@ -75,6 +94,12 @@ export class MessageBuilder {
|
|
|
75
94
|
content: input?.content,
|
|
76
95
|
csvFiles: input?.csvFiles,
|
|
77
96
|
pdfFiles: input?.pdfFiles,
|
|
97
|
+
// #284: audioFiles/videoFiles must be forwarded too, or the
|
|
98
|
+
// multimodal builder receives an empty payload despite
|
|
99
|
+
// detectMultimodal() correctly routing audio/video-only
|
|
100
|
+
// requests here.
|
|
101
|
+
audioFiles: input?.audioFiles,
|
|
102
|
+
videoFiles: input?.videoFiles,
|
|
78
103
|
files: input?.files,
|
|
79
104
|
},
|
|
80
105
|
csvOptions: options.csvOptions,
|
|
@@ -192,6 +217,10 @@ export class MessageBuilder {
|
|
|
192
217
|
content: input?.content,
|
|
193
218
|
csvFiles: input?.csvFiles,
|
|
194
219
|
pdfFiles: input?.pdfFiles,
|
|
220
|
+
// #284: forward audioFiles/videoFiles here too — see the
|
|
221
|
+
// matching comment in buildMessages() above.
|
|
222
|
+
audioFiles: input?.audioFiles,
|
|
223
|
+
videoFiles: input?.videoFiles,
|
|
195
224
|
files: input?.files,
|
|
196
225
|
},
|
|
197
226
|
csvOptions: options.csvOptions,
|
|
@@ -53,6 +53,7 @@ export type GenerateOptions = {
|
|
|
53
53
|
images?: Array<Buffer | string | ImageWithAltText>;
|
|
54
54
|
csvFiles?: Array<Buffer | string>;
|
|
55
55
|
pdfFiles?: Array<Buffer | string>;
|
|
56
|
+
audioFiles?: Array<Buffer | string>;
|
|
56
57
|
videoFiles?: Array<Buffer | string>;
|
|
57
58
|
files?: Array<Buffer | string | FileWithMetadata>;
|
|
58
59
|
content?: Content[];
|
|
@@ -419,15 +419,46 @@ export type MultimodalInput = {
|
|
|
419
419
|
segments?: DirectorSegment[];
|
|
420
420
|
};
|
|
421
421
|
/**
|
|
422
|
-
* Content format for multimodal messages (used internally)
|
|
423
|
-
*
|
|
422
|
+
* Content format for multimodal messages (used internally).
|
|
423
|
+
*
|
|
424
|
+
* #325: the loose `[key: string]: unknown` index signature has been replaced
|
|
425
|
+
* with the concrete fields the codebase actually reads/writes across the
|
|
426
|
+
* text / image / file / tool-call / tool-result shapes. This keeps the broad
|
|
427
|
+
* structural compatibility the internal pipeline relies on (a single object
|
|
428
|
+
* type, not a strict discriminated union that would force narrowing at every
|
|
429
|
+
* consumer) while removing the "any key is allowed" hole that let typos and
|
|
430
|
+
* unrelated keys through unchecked.
|
|
424
431
|
*/
|
|
425
432
|
export type MessageContent = {
|
|
426
433
|
type: string;
|
|
434
|
+
/** Text content (`type: "text"`). */
|
|
427
435
|
text?: string;
|
|
436
|
+
/** Base64 / data-URI image (`type: "image"`). */
|
|
428
437
|
image?: string;
|
|
438
|
+
/** MIME type for image/file parts. */
|
|
429
439
|
mimeType?: string;
|
|
430
|
-
|
|
440
|
+
/** Raw file bytes or base64 (`type: "file"`/document parts). */
|
|
441
|
+
data?: string | Buffer;
|
|
442
|
+
/** File name for document/file parts. */
|
|
443
|
+
name?: string;
|
|
444
|
+
/** File name (alias used by some file parts). */
|
|
445
|
+
filename?: string;
|
|
446
|
+
/** Tool-call identifier (`type: "tool-call"`/`"tool-result"`). */
|
|
447
|
+
toolCallId?: string;
|
|
448
|
+
/** Tool name (`type: "tool-call"`/`"tool-result"`). */
|
|
449
|
+
toolName?: string;
|
|
450
|
+
/** Tool-call arguments (`type: "tool-call"`). */
|
|
451
|
+
args?: Record<string, unknown>;
|
|
452
|
+
/** Tool-result payload (`type: "tool-result"`). */
|
|
453
|
+
result?: unknown;
|
|
454
|
+
/** Whether a tool-result represents an error (`type: "tool-result"`). */
|
|
455
|
+
isError?: boolean;
|
|
456
|
+
/**
|
|
457
|
+
* Provider-specific per-block options (e.g. Anthropic cache_control).
|
|
458
|
+
* Read as `item.providerOptions` when converting `MessageContent[]` to
|
|
459
|
+
* `ModelMessage[]` in `MessageBuilder.ts`.
|
|
460
|
+
*/
|
|
461
|
+
providerOptions?: Record<string, unknown>;
|
|
431
462
|
};
|
|
432
463
|
/**
|
|
433
464
|
* Extended chat message for multimodal support (internal use)
|
|
@@ -193,6 +193,7 @@ export type StreamOptions = {
|
|
|
193
193
|
images?: Array<Buffer | string | ImageWithAltText>;
|
|
194
194
|
csvFiles?: Array<Buffer | string>;
|
|
195
195
|
pdfFiles?: Array<Buffer | string>;
|
|
196
|
+
audioFiles?: Array<Buffer | string>;
|
|
196
197
|
videoFiles?: Array<Buffer | string>;
|
|
197
198
|
files?: Array<Buffer | string | FileWithMetadata>;
|
|
198
199
|
content?: Content[];
|
|
@@ -35,6 +35,9 @@ export declare const ERROR_CODES: {
|
|
|
35
35
|
readonly IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL";
|
|
36
36
|
readonly INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT";
|
|
37
37
|
readonly INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE";
|
|
38
|
+
readonly IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID";
|
|
39
|
+
readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
|
|
40
|
+
readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
|
|
38
41
|
readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
|
|
39
42
|
readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
|
|
40
43
|
readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
|
|
@@ -216,6 +219,13 @@ export declare class ErrorFactory {
|
|
|
216
219
|
* Create an invalid image format error
|
|
217
220
|
*/
|
|
218
221
|
static invalidImageFormat(): NeuroLinkError;
|
|
222
|
+
/**
|
|
223
|
+
* Create an image buffer validation error: empty, undersized, or a
|
|
224
|
+
* truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
|
|
225
|
+
* Takes the fully-formed message so call sites keep their specific
|
|
226
|
+
* byte-count detail instead of a fixed generic message.
|
|
227
|
+
*/
|
|
228
|
+
static imageBufferInvalid(message: string): NeuroLinkError;
|
|
219
229
|
/**
|
|
220
230
|
* Create a rate limiter queue full error
|
|
221
231
|
*/
|
|
@@ -287,6 +297,18 @@ export declare class ErrorFactory {
|
|
|
287
297
|
* generic `Error` instances.
|
|
288
298
|
*/
|
|
289
299
|
static proxyWorkerLifecycle(message: string, context?: Record<string, unknown>): NeuroLinkError;
|
|
300
|
+
/**
|
|
301
|
+
* Create a generic file-processing-failed error (e.g. an unrecognized or
|
|
302
|
+
* corrupt file in `processUnifiedFilesArray`). Preserves the original error
|
|
303
|
+
* as `originalError` (stack + message copied onto the new error's context).
|
|
304
|
+
*/
|
|
305
|
+
static fileProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
|
|
306
|
+
/**
|
|
307
|
+
* Create a generic CSV-processing-failed error (explicit `csvFiles` path,
|
|
308
|
+
* distinct from `fileProcessingFailed` so callers can classify CSV-specific
|
|
309
|
+
* failures separately). Preserves the original error as `originalError`.
|
|
310
|
+
*/
|
|
311
|
+
static csvProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
|
|
290
312
|
}
|
|
291
313
|
/**
|
|
292
314
|
* Timeout wrapper for async operations
|
|
@@ -46,6 +46,10 @@ export const ERROR_CODES = {
|
|
|
46
46
|
IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL",
|
|
47
47
|
INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT",
|
|
48
48
|
INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE",
|
|
49
|
+
IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID",
|
|
50
|
+
// Generic file/CSV processing errors
|
|
51
|
+
FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED",
|
|
52
|
+
CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
|
|
49
53
|
// PDF validation errors
|
|
50
54
|
PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
|
|
51
55
|
// Rate limiter errors
|
|
@@ -627,6 +631,22 @@ export class ErrorFactory {
|
|
|
627
631
|
},
|
|
628
632
|
});
|
|
629
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Create an image buffer validation error: empty, undersized, or a
|
|
636
|
+
* truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
|
|
637
|
+
* Takes the fully-formed message so call sites keep their specific
|
|
638
|
+
* byte-count detail instead of a fixed generic message.
|
|
639
|
+
*/
|
|
640
|
+
static imageBufferInvalid(message) {
|
|
641
|
+
return new NeuroLinkError({
|
|
642
|
+
code: ERROR_CODES.IMAGE_BUFFER_INVALID,
|
|
643
|
+
message,
|
|
644
|
+
category: ErrorCategory.VALIDATION,
|
|
645
|
+
severity: ErrorSeverity.MEDIUM,
|
|
646
|
+
retriable: false,
|
|
647
|
+
context: { field: "input.images" },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
630
650
|
// ============================================================================
|
|
631
651
|
// RATE LIMITER ERRORS
|
|
632
652
|
// ============================================================================
|
|
@@ -924,6 +944,41 @@ export class ErrorFactory {
|
|
|
924
944
|
context: context || {},
|
|
925
945
|
});
|
|
926
946
|
}
|
|
947
|
+
// ============================================================================
|
|
948
|
+
// GENERIC FILE / CSV PROCESSING ERRORS
|
|
949
|
+
// ============================================================================
|
|
950
|
+
/**
|
|
951
|
+
* Create a generic file-processing-failed error (e.g. an unrecognized or
|
|
952
|
+
* corrupt file in `processUnifiedFilesArray`). Preserves the original error
|
|
953
|
+
* as `originalError` (stack + message copied onto the new error's context).
|
|
954
|
+
*/
|
|
955
|
+
static fileProcessingFailed(filename, originalError) {
|
|
956
|
+
return new NeuroLinkError({
|
|
957
|
+
code: ERROR_CODES.FILE_PROCESSING_FAILED,
|
|
958
|
+
message: `Failed to process file "${filename}": ${originalError.message}`,
|
|
959
|
+
category: ErrorCategory.EXECUTION,
|
|
960
|
+
severity: ErrorSeverity.HIGH,
|
|
961
|
+
retriable: false,
|
|
962
|
+
context: { filename },
|
|
963
|
+
originalError,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Create a generic CSV-processing-failed error (explicit `csvFiles` path,
|
|
968
|
+
* distinct from `fileProcessingFailed` so callers can classify CSV-specific
|
|
969
|
+
* failures separately). Preserves the original error as `originalError`.
|
|
970
|
+
*/
|
|
971
|
+
static csvProcessingFailed(filename, originalError) {
|
|
972
|
+
return new NeuroLinkError({
|
|
973
|
+
code: ERROR_CODES.CSV_PROCESSING_FAILED,
|
|
974
|
+
message: `Failed to process CSV file "${filename}": ${originalError.message}`,
|
|
975
|
+
category: ErrorCategory.EXECUTION,
|
|
976
|
+
severity: ErrorSeverity.HIGH,
|
|
977
|
+
retriable: false,
|
|
978
|
+
context: { filename },
|
|
979
|
+
originalError,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
927
982
|
}
|
|
928
983
|
/**
|
|
929
984
|
* Timeout wrapper for async operations
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses multi-strategy approach for reliable type identification
|
|
5
5
|
*/
|
|
6
6
|
import { open, readFile, realpath } from "fs/promises";
|
|
7
|
-
import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
|
|
7
|
+
import { basename, isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
|
|
8
8
|
import { getGlobalDispatcher, interceptors, request } from "undici";
|
|
9
9
|
// Lazy-loaded processor singletons — avoids loading heavy media deps
|
|
10
10
|
// (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
|
|
@@ -24,6 +24,8 @@ import { tracers, ATTR, withSpan } from "../telemetry/index.js";
|
|
|
24
24
|
import { CSVProcessor } from "./csvProcessor.js";
|
|
25
25
|
import { ImageProcessor } from "./imageProcessor.js";
|
|
26
26
|
import { logger } from "./logger.js";
|
|
27
|
+
import { withTimeout } from "./errorHandling.js";
|
|
28
|
+
import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
|
|
27
29
|
import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
|
|
28
30
|
import { PDFProcessor } from "./pdfProcessor.js";
|
|
29
31
|
/**
|
|
@@ -31,6 +33,80 @@ import { PDFProcessor } from "./pdfProcessor.js";
|
|
|
31
33
|
*/
|
|
32
34
|
const DEFAULT_MAX_RETRIES = 3;
|
|
33
35
|
const DEFAULT_RETRY_DELAY = 1000; // milliseconds
|
|
36
|
+
/**
|
|
37
|
+
* Short-TTL cache of URL → Content-Type (#323). A URL is commonly detected more
|
|
38
|
+
* than once (repeated multimodal prompts reuse the same asset URL); caching the
|
|
39
|
+
* HEAD's content-type avoids re-issuing the HEAD each time. `loadFromURL` also
|
|
40
|
+
* populates it from its GET response, so once a URL's body has been fetched a
|
|
41
|
+
* subsequent detection needs no network round-trip at all.
|
|
42
|
+
*
|
|
43
|
+
* Trade-off (round-2 review): this is a module-level cache shared by every
|
|
44
|
+
* request in the process, and correctness relies solely on the 60s TTL — a
|
|
45
|
+
* signed URL whose response changes at the same path within that window would
|
|
46
|
+
* read stale. That is an intentional, bounded trade-off (60s of possible
|
|
47
|
+
* staleness for far fewer HEAD round-trips), not a freshness guarantee.
|
|
48
|
+
* Expired entries are removed lazily on their next `get()` (see
|
|
49
|
+
* `getCachedUrlContentType`); `setCachedUrlContentType` additionally sweeps
|
|
50
|
+
* expired entries opportunistically once the cache hits its size cap, so a
|
|
51
|
+
* URL that is cached once and never looked up again doesn't linger until the
|
|
52
|
+
* FIFO eviction below forces it out.
|
|
53
|
+
*/
|
|
54
|
+
const URL_CONTENT_TYPE_TTL_MS = 60_000;
|
|
55
|
+
const URL_CONTENT_TYPE_CACHE_MAX_SIZE = 512;
|
|
56
|
+
const urlContentTypeCache = new Map();
|
|
57
|
+
function getCachedUrlContentType(url, now) {
|
|
58
|
+
const hit = urlContentTypeCache.get(url);
|
|
59
|
+
if (hit && hit.expiresAt > now) {
|
|
60
|
+
// Bump recency: Map iteration order follows insertion order, and the
|
|
61
|
+
// eviction below deletes the *first* key, so a plain `get` on a hot
|
|
62
|
+
// entry would leave it first in line for eviction despite being the
|
|
63
|
+
// most recently used. Re-inserting turns the size-bounded FIFO below
|
|
64
|
+
// into an actual LRU.
|
|
65
|
+
urlContentTypeCache.delete(url);
|
|
66
|
+
urlContentTypeCache.set(url, hit);
|
|
67
|
+
return hit.contentType;
|
|
68
|
+
}
|
|
69
|
+
if (hit) {
|
|
70
|
+
// Entry exists but its TTL has passed — treat as a miss and evict it
|
|
71
|
+
// immediately rather than serving (or retaining) stale data.
|
|
72
|
+
urlContentTypeCache.delete(url);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Opportunistically remove already-expired entries. Only called once the
|
|
78
|
+
* cache is at its size cap (see `setCachedUrlContentType`) so it doesn't add
|
|
79
|
+
* an O(n) scan to the common-case hot path.
|
|
80
|
+
*/
|
|
81
|
+
function pruneExpiredUrlContentTypeEntries(now) {
|
|
82
|
+
for (const [key, entry] of urlContentTypeCache) {
|
|
83
|
+
if (entry.expiresAt <= now) {
|
|
84
|
+
urlContentTypeCache.delete(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function setCachedUrlContentType(url, contentType, now) {
|
|
89
|
+
if (!contentType) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
urlContentTypeCache.set(url, {
|
|
93
|
+
contentType,
|
|
94
|
+
expiresAt: now + URL_CONTENT_TYPE_TTL_MS,
|
|
95
|
+
});
|
|
96
|
+
// Bound the cache so a long-lived process can't grow it unbounded. Prefer
|
|
97
|
+
// reclaiming already-expired entries first; only fall back to evicting the
|
|
98
|
+
// oldest still-live entry (FIFO/LRU-ish, see getCachedUrlContentType) if
|
|
99
|
+
// the cache is still over the cap after pruning.
|
|
100
|
+
if (urlContentTypeCache.size > URL_CONTENT_TYPE_CACHE_MAX_SIZE) {
|
|
101
|
+
pruneExpiredUrlContentTypeEntries(now);
|
|
102
|
+
}
|
|
103
|
+
if (urlContentTypeCache.size > URL_CONTENT_TYPE_CACHE_MAX_SIZE) {
|
|
104
|
+
const oldest = urlContentTypeCache.keys().next().value;
|
|
105
|
+
if (oldest !== undefined) {
|
|
106
|
+
urlContentTypeCache.delete(oldest);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
34
110
|
/**
|
|
35
111
|
* Retryable network error codes (Node.js/undici network errors)
|
|
36
112
|
*/
|
|
@@ -1338,25 +1414,58 @@ export class FileDetector {
|
|
|
1338
1414
|
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1339
1415
|
const retryDelay = options?.retryDelay ?? DEFAULT_RETRY_DELAY;
|
|
1340
1416
|
return withRetry(async () => {
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1417
|
+
try {
|
|
1418
|
+
const response = await request(url, {
|
|
1419
|
+
dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
|
|
1420
|
+
method: "GET",
|
|
1421
|
+
headersTimeout: timeout,
|
|
1422
|
+
bodyTimeout: timeout,
|
|
1423
|
+
});
|
|
1424
|
+
if (response.statusCode !== 200) {
|
|
1425
|
+
// Query string / fragment stripped — a presigned URL's token must
|
|
1426
|
+
// not be echoed into a thrown error.
|
|
1427
|
+
throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
|
|
1428
|
+
}
|
|
1429
|
+
// #323: cache the Content-Type from this GET so a subsequent detection
|
|
1430
|
+
// of the same URL needs no HEAD.
|
|
1431
|
+
setCachedUrlContentType(url, response.headers["content-type"] || "", Date.now());
|
|
1432
|
+
const chunks = [];
|
|
1433
|
+
let totalSize = 0;
|
|
1434
|
+
for await (const chunk of response.body) {
|
|
1435
|
+
totalSize += chunk.length;
|
|
1436
|
+
if (totalSize > maxSize) {
|
|
1437
|
+
throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
|
|
1438
|
+
}
|
|
1439
|
+
chunks.push(chunk);
|
|
1440
|
+
}
|
|
1441
|
+
return Buffer.concat(chunks);
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
// Node/undici DNS, TLS, and connect-timeout errors embed the full
|
|
1445
|
+
// request URL (including a presigned query token) in
|
|
1446
|
+
// `error.message`. Redact into a NEW error instead of mutating the
|
|
1447
|
+
// original in place, so anything that still holds a reference to
|
|
1448
|
+
// the original — debug logs, telemetry spans, upstream callers —
|
|
1449
|
+
// keeps seeing the real message. `.code` is copied onto the new
|
|
1450
|
+
// error so `isRetryableNetworkError`'s retry check in the outer
|
|
1451
|
+
// `withRetry` catch still classifies it correctly. The raw
|
|
1452
|
+
// original error is NEVER attached as `cause` — that would leave
|
|
1453
|
+
// the unredacted URL reachable via `error.cause.message` for
|
|
1454
|
+
// anything that walks the cause chain (cause-aware logging,
|
|
1455
|
+
// telemetry). `cause` instead gets its own sanitized copy.
|
|
1456
|
+
// `sanitizeErrorCause` handles non-`Error` thrown values too (a raw
|
|
1457
|
+
// string/object can just as easily carry the full URL), so there is
|
|
1458
|
+
// no unconditional `throw error` fallback that would bypass
|
|
1459
|
+
// redaction for that case.
|
|
1460
|
+
const cause = sanitizeErrorCause(error);
|
|
1461
|
+
const redacted = new Error(cause.message, { cause });
|
|
1462
|
+
redacted.name = cause.name;
|
|
1463
|
+
const code = cause.code;
|
|
1464
|
+
if (code !== undefined) {
|
|
1465
|
+
redacted.code = code;
|
|
1356
1466
|
}
|
|
1357
|
-
|
|
1467
|
+
throw redacted;
|
|
1358
1468
|
}
|
|
1359
|
-
return Buffer.concat(chunks);
|
|
1360
1469
|
}, { maxRetries, retryDelay });
|
|
1361
1470
|
}
|
|
1362
1471
|
/**
|
|
@@ -1374,6 +1483,13 @@ export class FileDetector {
|
|
|
1374
1483
|
// the base dir pointing outside cannot bypass containment. The
|
|
1375
1484
|
// path.relative check (not a string prefix) correctly handles the root dir
|
|
1376
1485
|
// ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
|
|
1486
|
+
//
|
|
1487
|
+
// The actual open() below MUST target this validated `real` path, not the
|
|
1488
|
+
// original `filePath` — otherwise a symlink swapped between the realpath()
|
|
1489
|
+
// check and the open() call routes the read outside the sandbox even
|
|
1490
|
+
// though validation passed (TOCTOU). With no sandbox configured there's no
|
|
1491
|
+
// boundary to defend, so the original path is used as given.
|
|
1492
|
+
let pathToOpen = filePath;
|
|
1377
1493
|
if (options?.allowedBaseDir) {
|
|
1378
1494
|
let base;
|
|
1379
1495
|
let real;
|
|
@@ -1381,24 +1497,66 @@ export class FileDetector {
|
|
|
1381
1497
|
base = await realpath(resolvePath(options.allowedBaseDir));
|
|
1382
1498
|
real = await realpath(filePath);
|
|
1383
1499
|
}
|
|
1384
|
-
catch {
|
|
1385
|
-
|
|
1500
|
+
catch (error) {
|
|
1501
|
+
// Full path stays in the debug log; the thrown (potentially
|
|
1502
|
+
// client-facing) error only gets the basename to avoid leaking the
|
|
1503
|
+
// host's directory layout to an untrusted caller. The cause is
|
|
1504
|
+
// sanitized too — Node's realpath ENOENT/EACCES messages embed the
|
|
1505
|
+
// full path verbatim, which would otherwise survive on the cause
|
|
1506
|
+
// chain (cause-aware logging, telemetry) even though the outer
|
|
1507
|
+
// message is already redacted.
|
|
1508
|
+
logger.debug("loadFromPath: realpath resolution failed", {
|
|
1509
|
+
filePath,
|
|
1510
|
+
error,
|
|
1511
|
+
});
|
|
1512
|
+
// Assigned to a variable before the throw (rather than an inline
|
|
1513
|
+
// `{ cause: sanitizeErrorCause(...) }`) so the sanitized, path-redacted
|
|
1514
|
+
// copy is unambiguously the attached cause — the raw `error`, whose
|
|
1515
|
+
// message still embeds the full path, is never reachable from the
|
|
1516
|
+
// thrown result.
|
|
1517
|
+
const cause = sanitizeErrorCause(error, { filePath });
|
|
1518
|
+
const denied = new Error(`Access denied: "${basename(filePath)}" could not be resolved within the allowed base directory`, { cause });
|
|
1519
|
+
throw denied;
|
|
1386
1520
|
}
|
|
1387
1521
|
const rel = relativePath(base, real);
|
|
1388
1522
|
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
|
|
1389
|
-
|
|
1523
|
+
logger.debug("loadFromPath: path resolves outside allowed base dir", {
|
|
1524
|
+
filePath,
|
|
1525
|
+
real,
|
|
1526
|
+
});
|
|
1527
|
+
throw new Error(`Access denied: "${basename(filePath)}" resolves outside the allowed base directory`);
|
|
1390
1528
|
}
|
|
1529
|
+
pathToOpen = real;
|
|
1391
1530
|
}
|
|
1392
1531
|
// Open a handle and stat/read through the SAME descriptor so a symlink
|
|
1393
1532
|
// swap between the size check and the read cannot occur (TOCTOU).
|
|
1394
|
-
|
|
1533
|
+
let handle;
|
|
1534
|
+
try {
|
|
1535
|
+
handle = await open(pathToOpen, "r");
|
|
1536
|
+
}
|
|
1537
|
+
catch (error) {
|
|
1538
|
+
// A failed open (ENOENT/EACCES/…) embeds the opened path verbatim in
|
|
1539
|
+
// its message. When a sandbox is configured `pathToOpen` is the
|
|
1540
|
+
// realpath-resolved target (`real`), which differs from both `filePath`
|
|
1541
|
+
// and its resolved form — so redact `pathToOpen` specifically, or the
|
|
1542
|
+
// full host path would survive on both the thrown message and the cause
|
|
1543
|
+
// chain despite this PR's path-redaction hardening.
|
|
1544
|
+
const cause = sanitizeErrorCause(error, { filePath: pathToOpen });
|
|
1545
|
+
const failed = new Error(cause.message, { cause });
|
|
1546
|
+
failed.name = cause.name;
|
|
1547
|
+
const code = cause.code;
|
|
1548
|
+
if (code !== undefined) {
|
|
1549
|
+
failed.code = code;
|
|
1550
|
+
}
|
|
1551
|
+
throw failed;
|
|
1552
|
+
}
|
|
1395
1553
|
try {
|
|
1396
1554
|
const statInfo = await handle.stat();
|
|
1397
1555
|
if (!statInfo.isFile()) {
|
|
1398
|
-
throw new Error(`Not a file: ${filePath}`);
|
|
1556
|
+
throw new Error(`Not a file: ${basename(filePath)}`);
|
|
1399
1557
|
}
|
|
1400
1558
|
if (statInfo.size > maxSize) {
|
|
1401
|
-
throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
|
|
1559
|
+
throw new Error(`File too large: ${basename(filePath)} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
|
|
1402
1560
|
}
|
|
1403
1561
|
return await handle.readFile();
|
|
1404
1562
|
}
|
|
@@ -1450,6 +1608,15 @@ class MagicBytesStrategy {
|
|
|
1450
1608
|
input[6] === 0x79 &&
|
|
1451
1609
|
input[7] === 0x70) {
|
|
1452
1610
|
const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
|
|
1611
|
+
// AVIF images share the ISO-BMFF ftyp box with MP4/MOV; the major brand
|
|
1612
|
+
// ('avif' still, 'avis' sequence, 'avio' intra-only AV1 image/sequence
|
|
1613
|
+
// — spec-listed under compatible_brands but also emitted as
|
|
1614
|
+
// major_brand by real encoders) distinguishes them. Detect before the
|
|
1615
|
+
// audio/video branches so an AVIF buffer isn't misrouted to the video
|
|
1616
|
+
// pipeline (#286).
|
|
1617
|
+
if (/^(avif|avis|avio)/.test(brand)) {
|
|
1618
|
+
return this.result("image", "image/avif", 95);
|
|
1619
|
+
}
|
|
1453
1620
|
if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
|
|
1454
1621
|
return this.result("audio", "audio/mp4", 95);
|
|
1455
1622
|
}
|
|
@@ -1609,13 +1776,25 @@ class MimeTypeStrategy {
|
|
|
1609
1776
|
return this.unknown();
|
|
1610
1777
|
}
|
|
1611
1778
|
try {
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1779
|
+
// #323: reuse a recently-seen Content-Type for this URL instead of
|
|
1780
|
+
// re-issuing a HEAD (populated here and by loadFromURL's GET).
|
|
1781
|
+
const now = Date.now();
|
|
1782
|
+
let contentType = getCachedUrlContentType(input, now);
|
|
1783
|
+
if (contentType === undefined) {
|
|
1784
|
+
// Wrap the whole HEAD (request + body drain) in withTimeout so a stalled
|
|
1785
|
+
// dump() can't hang detection, per the project's async-timeout guideline.
|
|
1786
|
+
contentType = await withTimeout((async () => {
|
|
1787
|
+
const response = await request(input, {
|
|
1788
|
+
dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
|
|
1789
|
+
method: "HEAD",
|
|
1790
|
+
headersTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
|
|
1791
|
+
bodyTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
|
|
1792
|
+
});
|
|
1793
|
+
await response.body.dump();
|
|
1794
|
+
return response.headers["content-type"] || "";
|
|
1795
|
+
})(), FileDetector.DEFAULT_HEAD_TIMEOUT);
|
|
1796
|
+
setCachedUrlContentType(input, contentType, now);
|
|
1797
|
+
}
|
|
1619
1798
|
const type = this.mimeToFileType(contentType);
|
|
1620
1799
|
return {
|
|
1621
1800
|
type,
|