@juspay/neurolink 9.94.3 → 9.94.5

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.
@@ -15,6 +15,7 @@
15
15
  * jina_ (Jina), fish- (Fish Audio)
16
16
  * - Generic key=value pairs: api_key=…, access_token: …, secret_key=…
17
17
  */
18
+ import { basename, resolve as resolvePath } from "path";
18
19
  const TOKEN_PREFIXES = [
19
20
  "sk",
20
21
  "pk",
@@ -156,12 +157,161 @@ export function safeDebugSerialize(value, maxLen = 10_000) {
156
157
  * for diagnostics. Use this for any `baseURL`/endpoint a caller may have
157
158
  * supplied with inline credentials. The match is global, so every `//…@`
158
159
  * authority in the string is redacted (e.g. a proxy chain or a URL embedded
159
- * in a query parameter), not just the first.
160
+ * in a query parameter), not just the first. IPv6 literal hosts (`//u:p@[::1]/x`)
161
+ * and percent-encoded userinfo (`//u:p%40x@host/x`) are handled naturally —
162
+ * neither `[`/`]` nor `%` stop either pass below, so the bracketed host or
163
+ * encoded byte just rides along as part of the (non-redacted) remainder.
164
+ *
165
+ * This is best-effort defense-in-depth for free-form logging, deliberately
166
+ * regex-based so it can scrub *multiple* embedded authorities out of
167
+ * arbitrary text (a real URL parser only ever parses one URL at a time).
168
+ * It is NOT the primary protection for user-facing errors — that's
169
+ * {@link redactUrlForError}, which parses the single input via `new URL()`
170
+ * and reconstructs from protocol+host+pathname, never touching userinfo at
171
+ * all. Prefer that function whenever the input is known to be exactly one
172
+ * URL.
173
+ *
174
+ * Two passes, deliberately:
175
+ * 1. Well-formed authorities — credentials with no raw `/` — bounded at the
176
+ * authority's first `/` (or string end). Greedy backtracking naturally
177
+ * absorbs multiple `@` segments within one authority (e.g. `//a@b@host`
178
+ * → `//***@host`) while leaving a second, separate `//...@` authority
179
+ * elsewhere in the string (e.g. one embedded in a query parameter)
180
+ * untouched, because pass 1's own exclusion of `/` stops it at the first
181
+ * `/`, before it can ever reach a nested authority.
182
+ * 2. Malformed authorities whose credentials contain an unescaped `/`
183
+ * (e.g. `//user:sec/ret@host/path`) — pass 1 can't find an `@` before
184
+ * hitting that `/`, so this pass allows `/` and redacts up to the last
185
+ * remaining `@`. It stops before any *nested* `//` (via a negative
186
+ * lookahead) rather than excluding a specific character like `*` —
187
+ * excluding a literal character is what let a password containing both
188
+ * `/` and `*` (e.g. `//user:pa*ss/word@host/path`, legal under RFC 3986)
189
+ * slip through both passes entirely in an earlier version of this
190
+ * function.
160
191
  *
161
192
  * @param url - The URL (or URL-shaped string) to redact.
162
193
  */
163
194
  export function redactUrlCredentials(url) {
164
- return url.replace(/\/\/[^/@]+@/g, "//***@");
195
+ const wellFormed = url.replace(/\/\/[^/\s"'<>]*@/g, "//***@");
196
+ return wellFormed.replace(/\/\/(?:(?!\/\/)[^\s"'<>])*@/g, "//***@");
197
+ }
198
+ /**
199
+ * Reduce a URL to its origin + pathname for safe inclusion in error messages
200
+ * and logs, dropping the query string and fragment — where presigned-URL
201
+ * signatures, SAS tokens, and other secrets commonly live — while keeping
202
+ * enough of the URL (scheme, host, path) for the error to stay diagnostic.
203
+ *
204
+ * Falls back to a best-effort redacted string (query/fragment and
205
+ * `user:pass@` stripped) when the input isn't a parseable absolute URL.
206
+ * Either way the result is capped at 200 chars so a very long host/path
207
+ * can't blow up the error message.
208
+ *
209
+ * @param url - The URL (or URL-shaped string) to redact.
210
+ */
211
+ export function redactUrlForError(url) {
212
+ const truncate = (s) => s.length > 200 ? `${s.slice(0, 200)}…` : s;
213
+ try {
214
+ const parsed = new URL(url);
215
+ // Reconstruct explicitly from protocol + host + pathname — never
216
+ // parsed.username/parsed.password, and never parsed.href (which
217
+ // re-serializes any embedded userinfo) — so a `user:pass@host` in the
218
+ // input can't leak into a diagnostic error message.
219
+ return truncate(`${parsed.protocol}//${parsed.host}${parsed.pathname}`);
220
+ }
221
+ catch {
222
+ // Unparseable input: `new URL()` gave us nothing to work with, so
223
+ // best-effort drop the query/fragment (where secrets like tokens live)
224
+ // and strip any `//user:pass@` segment before falling back to the raw
225
+ // string — never echo the input completely unredacted.
226
+ const withoutQueryOrFragment = url.split(/[?#]/)[0];
227
+ return truncate(redactUrlCredentials(withoutQueryOrFragment));
228
+ }
229
+ }
230
+ /**
231
+ * Scrub any embedded absolute URLs out of free-form text — typically the
232
+ * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
233
+ * undici's `request()`, or Node's resolver, all of which frequently embed
234
+ * the full request URL (query string, credentials, and all) verbatim.
235
+ * Interpolating that message raw into a thrown or logged error would leak a
236
+ * presigned URL's token even after the explicit URL argument was already
237
+ * redacted via {@link redactUrlForError}. Finds each `scheme://…` run in the
238
+ * text and replaces it with its redacted form.
239
+ *
240
+ * @param text - Free-form text (typically `Error.message`) to scrub.
241
+ */
242
+ export function redactUrlsInText(text) {
243
+ return text.replace(/[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'<>)]+/g, (match) => redactUrlForError(match));
244
+ }
245
+ /**
246
+ * Redact every occurrence of `filePath` from an error message, checking both
247
+ * the path exactly as given and its `path.resolve()`'d form.
248
+ *
249
+ * Node's own fs errors (ENOENT/EACCES) embed the exact string passed to the
250
+ * fs call, so a plain substring match usually suffices — but wrapped or
251
+ * lower-level errors sometimes normalize the path first (relative→absolute,
252
+ * trailing-slash variations), which an exact-match-only redaction would miss.
253
+ * This is defense-in-depth, not a guarantee: a `realpath`-resolved (symlink-
254
+ * following) variant can still differ from both forms and would slip through.
255
+ *
256
+ * @param message - The error message to scrub.
257
+ * @param filePath - The path to redact (both as given and resolved).
258
+ */
259
+ export function redactPathFromMessage(message, filePath) {
260
+ const safeName = basename(filePath);
261
+ // Longest-first: the resolved (absolute) form always contains the relative
262
+ // form as a substring, so redacting the relative form first would only
263
+ // strip the trailing segment of an absolute path and leave its parent
264
+ // directory behind (e.g. "/Users/me/project/foo/bar.png" redacting
265
+ // "foo/bar.png" first leaves "/Users/me/project/bar.png"). Redacting the
266
+ // longer resolved variant first removes the whole absolute path in one
267
+ // pass, so the shorter relative pass below has nothing left to match.
268
+ return message
269
+ .split(resolvePath(filePath))
270
+ .join(safeName)
271
+ .split(filePath)
272
+ .join(safeName);
273
+ }
274
+ /**
275
+ * Build a sanitized copy of a network/URL/file error, safe to attach as
276
+ * `Error.cause` on a rethrow.
277
+ *
278
+ * Redacting only the OUTER thrown error's message isn't enough: if the RAW
279
+ * underlying error is attached as `cause`, anything that walks the cause
280
+ * chain (cause-aware logging, telemetry spans) can still recover an
281
+ * unredacted URL/path/secret via `error.cause.message`. This returns a NEW
282
+ * `Error` whose message has been through {@link redactUrlsInText} — and,
283
+ * when `options.filePath` is supplied, {@link redactPathFromMessage} too, so
284
+ * a filesystem error's ENOENT/EACCES text doesn't leak the host's directory
285
+ * layout via the cause chain the way the outer message already avoids.
286
+ * `.name` and `.code` (`NodeJS.ErrnoException`) are copied over so retry
287
+ * classification (e.g. `isRetryableNetworkError`) still works when it
288
+ * inspects the cause. The raw original is never referenced by the result.
289
+ *
290
+ * Handles non-`Error` thrown values too (a raw string/object can just as
291
+ * easily carry an unredacted URL or path) by running their `String()` form
292
+ * through the same redaction before wrapping.
293
+ *
294
+ * @param error - The raw underlying error (or thrown value) to sanitize.
295
+ * @param options - `filePath`: a known filesystem path to redact to its
296
+ * basename, in addition to URL redaction.
297
+ */
298
+ export function sanitizeErrorCause(error, options) {
299
+ const redact = (text) => {
300
+ const urlSafe = redactUrlsInText(text);
301
+ return options?.filePath
302
+ ? redactPathFromMessage(urlSafe, options.filePath)
303
+ : urlSafe;
304
+ };
305
+ if (!(error instanceof Error)) {
306
+ return new Error(redact(String(error)));
307
+ }
308
+ const sanitized = new Error(redact(error.message));
309
+ sanitized.name = error.name;
310
+ const code = error.code;
311
+ if (code !== undefined) {
312
+ sanitized.code = code;
313
+ }
314
+ return sanitized;
165
315
  }
166
316
  /**
167
317
  * Recursively sanitize a record/array, returning a structurally identical
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
+ import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
2
3
  import { getGlobalDispatcher, interceptors, request } from "undici";
3
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
4
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
@@ -6,8 +7,10 @@ import { getAvailableInputTokens } from "../constants/contextWindows.js";
6
7
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
7
8
  import { SIZE_TIER_THRESHOLDS } from "../types/index.js";
8
9
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
10
+ import { NeuroLinkError } from "./errorHandling.js";
9
11
  import { FileDetector } from "./fileDetector.js";
10
12
  import { getImageCache } from "./imageCache.js";
13
+ import { ImageProcessor } from "./imageProcessor.js";
11
14
  import { logger } from "./logger.js";
12
15
  import { PDFImageConverter, PDFProcessor } from "./pdfProcessor.js";
13
16
  import { urlDownloadRateLimiter } from "./rateLimiter.js";
@@ -1396,13 +1399,38 @@ function detectMimeTypeFromBuffer(buffer) {
1396
1399
  /**
1397
1400
  * Convert file path to raw base64 string.
1398
1401
  * Returns raw base64 (not a data: URI) to avoid SSRF validation in AI SDK v6.
1402
+ * Uses async fs so a large/slow-filesystem image never blocks the event loop
1403
+ * while the size guard (below) or the read itself is in flight.
1399
1404
  */
1400
- function convertFilePathToBase64(filePath) {
1401
- if (!existsSync(filePath)) {
1402
- throw new Error(`Image file not found: ${filePath}`);
1405
+ async function convertFilePathToBase64(filePath) {
1406
+ let stats;
1407
+ try {
1408
+ stats = await statAsync(filePath);
1403
1409
  }
1404
- const buffer = readFileSync(filePath);
1405
- return buffer.toString("base64");
1410
+ catch (error) {
1411
+ // Only ENOENT means "not found" — EACCES/ELOOP/etc. are real access or
1412
+ // filesystem problems that must not be misreported as a missing file.
1413
+ const code = error instanceof Error
1414
+ ? error.code
1415
+ : undefined;
1416
+ if (code === "ENOENT") {
1417
+ throw new Error(`Image file not found: ${filePath}`, { cause: error });
1418
+ }
1419
+ throw new Error(`Cannot access image file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1420
+ }
1421
+ if (!stats.isFile()) {
1422
+ throw new Error(`Image path is not a file: ${filePath}`);
1423
+ }
1424
+ // #257: check the file size via stat BEFORE reading it into memory, so a
1425
+ // huge image path never triggers an unbounded read + base64 allocation.
1426
+ // Delegates to ImageProcessor's shared guard (no local reimplementation).
1427
+ const context = `image file "${filePath}"`;
1428
+ ImageProcessor.validateSize(stats.size, context);
1429
+ const buffer = await readFileAsync(filePath);
1430
+ // TOCTOU: the file can grow, or a symlink can be swapped, between the stat
1431
+ // above and this read completing. Re-validate the bytes actually read
1432
+ // (not just the pre-read stat) before the base64 allocation.
1433
+ return ImageProcessor.safeBase64Convert(buffer, context);
1406
1434
  }
1407
1435
  /**
1408
1436
  * Process a single image input and convert to raw base64 format.
@@ -1413,7 +1441,7 @@ function convertFilePathToBase64(filePath) {
1413
1441
  * Passing raw base64 avoids this because `new URL(base64string)` throws and
1414
1442
  * the SDK treats the string as inline base64 data instead.
1415
1443
  */
1416
- function processImageToBase64(image, index) {
1444
+ async function processImageToBase64(image, index) {
1417
1445
  let imageData;
1418
1446
  let mimeType = "image/jpeg"; // Default mime type
1419
1447
  if (typeof image === "string") {
@@ -1444,7 +1472,7 @@ function processImageToBase64(image, index) {
1444
1472
  else {
1445
1473
  // File path string - convert to raw base64
1446
1474
  try {
1447
- imageData = convertFilePathToBase64(image);
1475
+ imageData = await convertFilePathToBase64(image);
1448
1476
  mimeType = getMimeTypeFromExtension(image);
1449
1477
  }
1450
1478
  catch (error) {
@@ -1452,6 +1480,11 @@ function processImageToBase64(image, index) {
1452
1480
  index,
1453
1481
  filePath: image,
1454
1482
  });
1483
+ // Preserve typed errors (e.g. IMAGE_TOO_LARGE) as-is — don't flatten
1484
+ // them into a generic Error and lose the error `code` for callers.
1485
+ if (error instanceof NeuroLinkError) {
1486
+ throw error;
1487
+ }
1455
1488
  throw new Error(`Failed to convert file path to base64: ${image}. ${error}`, { cause: error });
1456
1489
  }
1457
1490
  }
@@ -1462,6 +1495,9 @@ function processImageToBase64(image, index) {
1462
1495
  if (detectedMimeType) {
1463
1496
  mimeType = detectedMimeType;
1464
1497
  }
1498
+ // #257: guard the buffer size before the unbounded base64 conversion.
1499
+ // Delegates to ImageProcessor's shared guard (no local reimplementation).
1500
+ ImageProcessor.validateBufferSize(image, `image input at index ${index}`);
1465
1501
  imageData = image.toString("base64");
1466
1502
  }
1467
1503
  return { imageData, mimeType };
@@ -1524,11 +1560,13 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1524
1560
  const content = [
1525
1561
  { type: "text", text: enhancedText },
1526
1562
  ];
1527
- // Process all images (including downloaded URLs) for Vercel AI SDK
1528
- actualImages.forEach(({ data: image }, index) => {
1563
+ // Process all images (including downloaded URLs) for Vercel AI SDK.
1564
+ // Sequential for...of (not Promise.all) to preserve image ordering and
1565
+ // keep the original forEach's one-at-a-time error semantics.
1566
+ for (const [index, { data: image }] of actualImages.entries()) {
1529
1567
  try {
1530
1568
  // Use helper function to process image and reduce nesting depth
1531
- const { imageData, mimeType } = processImageToBase64(image, index);
1569
+ const { imageData, mimeType } = await processImageToBase64(image, index);
1532
1570
  content.push({
1533
1571
  type: "image",
1534
1572
  image: imageData,
@@ -1542,7 +1580,7 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1542
1580
  });
1543
1581
  throw error;
1544
1582
  }
1545
- });
1583
+ }
1546
1584
  return content;
1547
1585
  }
1548
1586
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.94.3",
3
+ "version": "9.94.5",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {