@juspay/neurolink 10.12.4 → 10.12.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +420 -420
  3. package/dist/cli/commands/proxy.js +13 -17
  4. package/dist/cli/commands/proxyAnalyze.js +8 -3
  5. package/dist/lib/processors/archive/ArchiveProcessor.js +13 -67
  6. package/dist/lib/processors/archive/zipEntryReader.d.ts +51 -0
  7. package/dist/lib/processors/archive/zipEntryReader.js +81 -0
  8. package/dist/lib/processors/document/OpenDocumentProcessor.js +13 -1
  9. package/dist/lib/processors/document/PptxProcessor.js +58 -12
  10. package/dist/lib/proxy/logCleanupScheduler.d.ts +12 -0
  11. package/dist/lib/proxy/logCleanupScheduler.js +74 -0
  12. package/dist/lib/proxy/logCleanupWorkerEntry.d.ts +1 -0
  13. package/dist/lib/proxy/logCleanupWorkerEntry.js +15 -0
  14. package/dist/lib/proxy/proxyAnalysis.js +6 -0
  15. package/dist/lib/proxy/requestLogger.d.ts +6 -0
  16. package/dist/lib/proxy/requestLogger.js +57 -47
  17. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +2 -0
  18. package/dist/lib/proxy/rollingWorkerSupervisor.js +40 -2
  19. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -0
  20. package/dist/lib/server/routes/claudeProxyRoutes.js +33 -2
  21. package/dist/lib/types/processor.d.ts +15 -0
  22. package/dist/lib/types/proxy.d.ts +32 -1
  23. package/dist/processors/archive/ArchiveProcessor.js +13 -67
  24. package/dist/processors/archive/zipEntryReader.d.ts +51 -0
  25. package/dist/processors/archive/zipEntryReader.js +80 -0
  26. package/dist/processors/document/OpenDocumentProcessor.js +13 -1
  27. package/dist/processors/document/PptxProcessor.js +58 -12
  28. package/dist/proxy/logCleanupScheduler.d.ts +12 -0
  29. package/dist/proxy/logCleanupScheduler.js +73 -0
  30. package/dist/proxy/logCleanupWorkerEntry.d.ts +1 -0
  31. package/dist/proxy/logCleanupWorkerEntry.js +14 -0
  32. package/dist/proxy/proxyAnalysis.js +6 -0
  33. package/dist/proxy/requestLogger.d.ts +6 -0
  34. package/dist/proxy/requestLogger.js +57 -47
  35. package/dist/proxy/rollingWorkerSupervisor.d.ts +2 -0
  36. package/dist/proxy/rollingWorkerSupervisor.js +40 -2
  37. package/dist/server/routes/claudeProxyRoutes.d.ts +2 -0
  38. package/dist/server/routes/claudeProxyRoutes.js +33 -2
  39. package/dist/types/processor.d.ts +15 -0
  40. package/dist/types/proxy.d.ts +32 -1
  41. package/package.json +5 -3
@@ -700,61 +700,71 @@ export async function logStreamError(entry) {
700
700
  * Non-fatal — proxy keeps working even if cleanup fails.
701
701
  */
702
702
  export function cleanupLogs(maxAgeDays = 7, maxSizeMb = 500) {
703
- if (!logDir || !existsSync(logDir)) {
703
+ if (!logDir) {
704
704
  return;
705
705
  }
706
706
  try {
707
- const activeLogDir = logDir;
708
- const files = collectManagedLogFiles(activeLogDir).sort((a, b) => a.mtime - b.mtime); // oldest first
709
- const currentDate = new Date().toISOString().split("T")[0];
710
- const currentMetadataLogs = new Set(["proxy", "proxy-attempts", "proxy-debug", "proxy-lifecycle"].map((prefix) => join(activeLogDir, `${prefix}-${currentDate}.jsonl`)));
711
- const canDelete = (file) => !currentMetadataLogs.has(file.path);
712
- const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
713
- let deletedCount = 0;
714
- let freedBytes = 0;
715
- // Pass 1: delete files older than maxAgeDays
716
- const remaining = [];
717
- for (const file of files) {
718
- if (file.mtime < cutoff && canDelete(file)) {
719
- unlinkSync(file.path);
720
- deletedCount++;
721
- freedBytes += file.size;
722
- }
723
- else {
724
- remaining.push(file);
725
- }
726
- }
727
- const bodiesDir = join(logDir, "bodies");
728
- if (existsSync(bodiesDir)) {
729
- pruneEmptyDirectories(bodiesDir, bodiesDir);
730
- }
731
- // Pass 2: if total size exceeds maxSizeMb, delete oldest until under limit
732
- const maxBytes = maxSizeMb * 1024 * 1024;
733
- let totalSize = remaining.reduce((sum, f) => sum + f.size, 0);
734
- const deletionCandidates = remaining.filter(canDelete);
735
- // Current-day metadata is the only reliable source for final-request,
736
- // attempt, lifecycle, and body-index reconciliation. Keep those indexes
737
- // intact during size cleanup; body artifacts and older indexes remain
738
- // eligible for eviction.
739
- while (totalSize > maxBytes && deletionCandidates.length > 0) {
740
- const oldest = deletionCandidates.shift();
741
- if (!oldest) {
742
- break;
743
- }
744
- unlinkSync(oldest.path);
745
- totalSize -= oldest.size;
707
+ cleanupLogsAt(logDir, maxAgeDays, maxSizeMb);
708
+ }
709
+ catch {
710
+ // Non-fatal for legacy in-process callers.
711
+ }
712
+ }
713
+ /**
714
+ * Path-scoped retention implementation used by the proxy cleanup worker.
715
+ * This function is intentionally synchronous: callers must run it outside the
716
+ * request-serving process when the directory can contain many artifacts.
717
+ */
718
+ export function cleanupLogsAt(activeLogDir, maxAgeDays = 7, maxSizeMb = 500) {
719
+ if (!existsSync(activeLogDir)) {
720
+ return;
721
+ }
722
+ const files = collectManagedLogFiles(activeLogDir).sort((a, b) => a.mtime - b.mtime); // oldest first
723
+ const currentDate = new Date().toISOString().split("T")[0];
724
+ const currentMetadataLogs = new Set(["proxy", "proxy-attempts", "proxy-debug", "proxy-lifecycle"].map((prefix) => join(activeLogDir, `${prefix}-${currentDate}.jsonl`)));
725
+ const canDelete = (file) => !currentMetadataLogs.has(file.path);
726
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
727
+ let deletedCount = 0;
728
+ let freedBytes = 0;
729
+ // Pass 1: delete files older than maxAgeDays
730
+ const remaining = [];
731
+ for (const file of files) {
732
+ if (file.mtime < cutoff && canDelete(file)) {
733
+ unlinkSync(file.path);
746
734
  deletedCount++;
747
- freedBytes += oldest.size;
735
+ freedBytes += file.size;
748
736
  }
749
- if (existsSync(bodiesDir)) {
750
- pruneEmptyDirectories(bodiesDir, bodiesDir);
737
+ else {
738
+ remaining.push(file);
751
739
  }
752
- if (deletedCount > 0) {
753
- logger.info(`[proxy] log cleanup: deleted ${deletedCount} file(s), freed ${(freedBytes / 1024 / 1024).toFixed(1)} MB`);
740
+ }
741
+ const bodiesDir = join(activeLogDir, "bodies");
742
+ if (existsSync(bodiesDir)) {
743
+ pruneEmptyDirectories(bodiesDir, bodiesDir);
744
+ }
745
+ // Pass 2: if total size exceeds maxSizeMb, delete oldest until under limit
746
+ const maxBytes = maxSizeMb * 1024 * 1024;
747
+ let totalSize = remaining.reduce((sum, f) => sum + f.size, 0);
748
+ const deletionCandidates = remaining.filter(canDelete);
749
+ // Current-day metadata is the only reliable source for final-request,
750
+ // attempt, lifecycle, and body-index reconciliation. Keep those indexes
751
+ // intact during size cleanup; body artifacts and older indexes remain
752
+ // eligible for eviction.
753
+ while (totalSize > maxBytes && deletionCandidates.length > 0) {
754
+ const oldest = deletionCandidates.shift();
755
+ if (!oldest) {
756
+ break;
754
757
  }
758
+ unlinkSync(oldest.path);
759
+ totalSize -= oldest.size;
760
+ deletedCount++;
761
+ freedBytes += oldest.size;
755
762
  }
756
- catch {
757
- // Non-fatal
763
+ if (existsSync(bodiesDir)) {
764
+ pruneEmptyDirectories(bodiesDir, bodiesDir);
765
+ }
766
+ if (deletedCount > 0) {
767
+ logger.info(`[proxy] log cleanup: deleted ${deletedCount} file(s), freed ${(freedBytes / 1024 / 1024).toFixed(1)} MB`);
758
768
  }
759
769
  }
760
770
  //# sourceMappingURL=requestLogger.js.map
@@ -14,6 +14,7 @@ export declare class RollingWorkerSupervisor {
14
14
  private replacement;
15
15
  private rejectedSockets;
16
16
  private failedTransfers;
17
+ private readonly recentEvents;
17
18
  private lastFailure;
18
19
  private closed;
19
20
  private shutdownPromise;
@@ -38,5 +39,6 @@ export declare class RollingWorkerSupervisor {
38
39
  private describeTransferError;
39
40
  private extractLifecycleFailureDetails;
40
41
  private recordFailure;
42
+ private recordEvent;
41
43
  private publishState;
42
44
  }
@@ -3,6 +3,7 @@ const DEFAULT_READY_TIMEOUT_MS = 30_000;
3
3
  const DEFAULT_SOCKET_QUEUE_LIMIT = 1_024;
4
4
  const DEFAULT_SOCKET_QUEUE_TIMEOUT_MS = 30_000;
5
5
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
6
+ const MAX_RECENT_SUPERVISOR_EVENTS = 100;
6
7
  /**
7
8
  * Owns worker generations while the caller owns the public listening socket.
8
9
  * Sockets are transferred once to the active worker, so response bytes never
@@ -18,6 +19,7 @@ export class RollingWorkerSupervisor {
18
19
  replacement = null;
19
20
  rejectedSockets = 0;
20
21
  failedTransfers = 0;
22
+ recentEvents = [];
21
23
  lastFailure = null;
22
24
  closed = false;
23
25
  shutdownPromise = null;
@@ -56,6 +58,7 @@ export class RollingWorkerSupervisor {
56
58
  queuedSockets: this.queuedSockets.length,
57
59
  rejectedSockets: this.rejectedSockets,
58
60
  failedTransfers: this.failedTransfers,
61
+ recentEvents: [...this.recentEvents],
59
62
  lastFailure: this.lastFailure,
60
63
  };
61
64
  }
@@ -302,6 +305,11 @@ export class RollingWorkerSupervisor {
302
305
  this.maybeDrainWorker(previous);
303
306
  }
304
307
  this.options.log?.(`[proxy-supervisor] activated generation=${generation} pid=${handle.pid} version=${expectedVersion}`);
308
+ this.recordEvent({
309
+ type: "activated",
310
+ generation,
311
+ version: expectedVersion,
312
+ });
305
313
  this.publishState();
306
314
  finish();
307
315
  });
@@ -410,6 +418,13 @@ export class RollingWorkerSupervisor {
410
418
  handleTransferFailure(worker, socket, error) {
411
419
  this.failedTransfers += 1;
412
420
  const detail = this.describeTransferError(error);
421
+ this.recordEvent({
422
+ type: "failed_transfer",
423
+ generation: worker.generation,
424
+ version: worker.version,
425
+ phase: "transfer",
426
+ reason: detail,
427
+ });
413
428
  const lifecycle = this.extractLifecycleFailureDetails(error, worker.handle.pid);
414
429
  this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket: ${detail}`, {
415
430
  ...lifecycle.details,
@@ -432,10 +447,16 @@ export class RollingWorkerSupervisor {
432
447
  }
433
448
  this.publishState();
434
449
  }
435
- this.rejectSocket(socket);
450
+ this.rejectSocket(socket, worker.generation, worker.version, "transfer_failure");
436
451
  }
437
- rejectSocket(socket) {
452
+ rejectSocket(socket, generation = this.active?.generation ?? null, version = this.active?.version ?? null, reason = "unavailable") {
438
453
  this.rejectedSockets += 1;
454
+ this.recordEvent({
455
+ type: "rejected_socket",
456
+ generation,
457
+ version,
458
+ reason,
459
+ });
439
460
  socket.destroy();
440
461
  this.publishState();
441
462
  }
@@ -479,6 +500,23 @@ export class RollingWorkerSupervisor {
479
500
  message: message.slice(0, 1_000),
480
501
  ...details,
481
502
  };
503
+ this.recordEvent({
504
+ type: "failure",
505
+ generation,
506
+ version,
507
+ phase,
508
+ reason: message,
509
+ });
510
+ }
511
+ recordEvent(event) {
512
+ this.recentEvents.push({
513
+ at: new Date().toISOString(),
514
+ ...event,
515
+ ...(event.reason ? { reason: event.reason.slice(0, 1_000) } : {}),
516
+ });
517
+ if (this.recentEvents.length > MAX_RECENT_SUPERVISOR_EVENTS) {
518
+ this.recentEvents.splice(0, this.recentEvents.length - MAX_RECENT_SUPERVISOR_EVENTS);
519
+ }
482
520
  }
483
521
  publishState() {
484
522
  try {
@@ -310,6 +310,7 @@ declare function shouldAttemptClaudeFallback(loopState: AnthropicLoopState): boo
310
310
  export declare function createClaudeProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, accountStrategy?: "round-robin" | "fill-first", passthroughMode?: boolean, primaryAccountKey?: string, accountAllowlistOrRuntimeOptions?: AccountAllowlist | ClaudeProxyRouteRuntimeOptions): RouteGroup;
311
311
  declare function reconcileEligibleAccountRuntimeState(account: ProxyPassthroughAccount): void;
312
312
  export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
313
+ export declare function getOverloadRotationDelayMs(attemptNumber: number): number;
313
314
  declare function describeTransportError(error: unknown): string;
314
315
  /**
315
316
  * Determine whether a POST can be retried without risking duplicate provider
@@ -400,6 +401,7 @@ export declare const __testHooks: {
400
401
  describeTransportError: typeof describeTransportError;
401
402
  redactProviderErrorMessage: typeof redactProviderErrorMessage;
402
403
  isUpstreamOverload: typeof isUpstreamOverload;
404
+ getOverloadRotationDelayMs: typeof getOverloadRotationDelayMs;
403
405
  shouldAttemptClaudeFallback: typeof shouldAttemptClaudeFallback;
404
406
  executeClaudeFallbackWithRetry: typeof executeClaudeFallbackWithRetry;
405
407
  buildClaudeAnthropicFailureResponse: typeof buildClaudeAnthropicFailureResponse;
@@ -63,6 +63,7 @@ let lastKnownAccountCount = 0;
63
63
  const MAX_AUTH_RETRIES = 5;
64
64
  const MAX_TRANSIENT_SAME_ACCOUNT_RETRIES = 2;
65
65
  const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000];
66
+ const OVERLOAD_ACCOUNT_ROTATION_DELAYS_MS = [250, 500, 1_000, 2_000];
66
67
  const MAX_FALLBACK_NETWORK_RETRIES = 1;
67
68
  const FALLBACK_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
68
69
  /** Maximum upstream 429 attempts per account before rotating — for a TRANSIENT
@@ -2883,7 +2884,13 @@ async function handleAnthropicStreamingSuccessResponse(args) {
2883
2884
  });
2884
2885
  return {
2885
2886
  retryNextAccount: true,
2886
- failure: { message: preflight.message, rateLimit: isRateLimit },
2887
+ failure: {
2888
+ message: preflight.message,
2889
+ rateLimit: isRateLimit,
2890
+ ...(preflight.errorType === "overloaded_error"
2891
+ ? { retryDelayMs: getOverloadRotationDelayMs(attemptNumber) }
2892
+ : {}),
2893
+ },
2887
2894
  };
2888
2895
  }
2889
2896
  logAttempt(response.status, undefined, undefined, {
@@ -3484,6 +3491,9 @@ async function handleAnthropicAuthRetry(args) {
3484
3491
  const failure = successResult.failure;
3485
3492
  return {
3486
3493
  continueLoop: true,
3494
+ ...(failure?.retryDelayMs
3495
+ ? { retryDelayMs: failure.retryDelayMs }
3496
+ : {}),
3487
3497
  lastError: failure?.message ?? currentLastError,
3488
3498
  authFailureMessage: currentAuthFailureMessage,
3489
3499
  sawRateLimit: currentSawRateLimit || Boolean(failure?.rateLimit),
@@ -3941,6 +3951,9 @@ async function handleAnthropicNonOkResponse(args) {
3941
3951
  return {
3942
3952
  continueLoop: true,
3943
3953
  retrySameAccount: !upstreamOverload,
3954
+ ...(upstreamOverload
3955
+ ? { retryDelayMs: getOverloadRotationDelayMs(attemptNumber) }
3956
+ : {}),
3944
3957
  lastError: currentLastError,
3945
3958
  authFailureMessage: currentAuthFailureMessage,
3946
3959
  sawTransientFailure: currentSawTransientFailure,
@@ -4553,7 +4566,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4553
4566
  effectiveAccounts.every((account) => !isAccountAdmissionAvailable(account.key, accountAdmissionCapacity))) {
4554
4567
  queuedAccountAdmission = await acquireFirstAvailableAccountAdmission(effectiveAccounts.map((account) => account.key), accountAdmissionCapacity, ctx.abortSignal);
4555
4568
  }
4556
- accountLoop: for (const account of effectiveAccounts) {
4569
+ accountLoop: for (const [accountIndex, account,] of effectiveAccounts.entries()) {
4570
+ const hasNextAccount = accountIndex < effectiveAccounts.length - 1;
4557
4571
  const accountState = getOrCreateRuntimeState(account.key);
4558
4572
  let transientSameAccountRetries = 0;
4559
4573
  let rateLimitSameAccountRetries = 0;
@@ -4763,6 +4777,10 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4763
4777
  return authRetryResult.response;
4764
4778
  }
4765
4779
  if (authRetryResult.continueLoop) {
4780
+ if (hasNextAccount && authRetryResult.retryDelayMs) {
4781
+ logger.always(`[proxy] pacing cross-account SSE overload rotation for ${authRetryResult.retryDelayMs}ms after auth refresh`);
4782
+ await sleep(authRetryResult.retryDelayMs);
4783
+ }
4766
4784
  continue accountLoop;
4767
4785
  }
4768
4786
  }
@@ -4804,6 +4822,10 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4804
4822
  if (nonOkResult.retrySameAccount) {
4805
4823
  logger.always(`[proxy] exhausted transient same-account retries for account=${account.label}; rotating`);
4806
4824
  }
4825
+ if (hasNextAccount && nonOkResult.retryDelayMs) {
4826
+ logger.always(`[proxy] pacing cross-account overload rotation for ${nonOkResult.retryDelayMs}ms`);
4827
+ await sleep(nonOkResult.retryDelayMs);
4828
+ }
4807
4829
  continue accountLoop;
4808
4830
  }
4809
4831
  break accountLoop;
@@ -4845,6 +4867,10 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4845
4867
  loopState.lastError = successResult.failure.message;
4846
4868
  loopState.sawRateLimit ||= successResult.failure.rateLimit;
4847
4869
  loopState.sawTransientFailure ||= !successResult.failure.rateLimit;
4870
+ if (hasNextAccount && successResult.failure.retryDelayMs) {
4871
+ logger.always(`[proxy] pacing cross-account SSE overload rotation for ${successResult.failure.retryDelayMs}ms`);
4872
+ await sleep(successResult.failure.retryDelayMs);
4873
+ }
4848
4874
  }
4849
4875
  continue accountLoop;
4850
4876
  }
@@ -5315,6 +5341,10 @@ export function getTransientSameAccountRetryDelayMs(retryNumber) {
5315
5341
  const index = Math.min(Math.max(retryNumber - 1, 0), TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS.length - 1);
5316
5342
  return TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS[index] ?? 0;
5317
5343
  }
5344
+ export function getOverloadRotationDelayMs(attemptNumber) {
5345
+ const index = Math.min(Math.max(attemptNumber - 1, 0), OVERLOAD_ACCOUNT_ROTATION_DELAYS_MS.length - 1);
5346
+ return jitteredDelay(OVERLOAD_ACCOUNT_ROTATION_DELAYS_MS[index] ?? 250);
5347
+ }
5318
5348
  async function sleep(ms) {
5319
5349
  await new Promise((resolve) => setTimeout(resolve, ms));
5320
5350
  }
@@ -5586,6 +5616,7 @@ export const __testHooks = {
5586
5616
  describeTransportError,
5587
5617
  redactProviderErrorMessage,
5588
5618
  isUpstreamOverload,
5619
+ getOverloadRotationDelayMs,
5589
5620
  shouldAttemptClaudeFallback,
5590
5621
  executeClaudeFallbackWithRetry,
5591
5622
  buildClaudeAnthropicFailureResponse,
@@ -774,6 +774,21 @@ export type ArchiveDecompressionResult = {
774
774
  * perfectly well-formed, and telling the user it is damaged would send them to
775
775
  * re-create a file that was never broken.
776
776
  */
777
+ /**
778
+ * The slice of an adm-zip entry the bounded reader depends on.
779
+ *
780
+ * Structural rather than adm-zip's own `IZipEntry` so the reader states what it
781
+ * actually needs — the compressed bytes and the header fields it refuses to
782
+ * trust — instead of importing a library type it would then have to satisfy in
783
+ * full when building a test double.
784
+ */
785
+ export type BoundedZipEntry = {
786
+ getCompressedData: () => Buffer;
787
+ header: {
788
+ method: number;
789
+ crc: number;
790
+ };
791
+ };
777
792
  export type ArchiveEntryReadResult = {
778
793
  readonly status: "ok";
779
794
  readonly buffer: Buffer;
@@ -646,6 +646,7 @@ export type AnthropicSuccessResult = {
646
646
  failure?: {
647
647
  message: string;
648
648
  rateLimit: boolean;
649
+ retryDelayMs?: number;
649
650
  };
650
651
  } | {
651
652
  response: Response | unknown;
@@ -702,6 +703,8 @@ export type AnthropicAuthRetryResult = {
702
703
  response?: Response | unknown;
703
704
  holdsAccountAdmission?: boolean;
704
705
  continueLoop: boolean;
706
+ /** Failure-path pacing before rotating after provider-wide overload. */
707
+ retryDelayMs?: number;
705
708
  lastError: unknown;
706
709
  authFailureMessage: string | null;
707
710
  sawRateLimit: boolean;
@@ -713,6 +716,8 @@ export type AnthropicNonOkResult = {
713
716
  response?: Response | unknown;
714
717
  continueLoop: boolean;
715
718
  retrySameAccount?: boolean;
719
+ /** Failure-path pacing before rotating after provider-wide overload. */
720
+ retryDelayMs?: number;
716
721
  lastError: unknown;
717
722
  authFailureMessage: string | null;
718
723
  sawTransientFailure: boolean;
@@ -1457,6 +1462,9 @@ export type ProxyAnalysisReport = {
1457
1462
  attemptLatency: boolean;
1458
1463
  cacheUsage: boolean;
1459
1464
  routingDecisions: boolean;
1465
+ /** True only when every stream needed for cross-stream request/attempt
1466
+ * reconciliation begins at or before the requested analysis window. */
1467
+ comparableRequestAttempts: boolean;
1460
1468
  };
1461
1469
  dataQuality: {
1462
1470
  linesRead: number;
@@ -1468,6 +1476,8 @@ export type ProxyAnalysisReport = {
1468
1476
  observedFrom: string | null;
1469
1477
  observedTo: string | null;
1470
1478
  startsAtOrBeforeRequestedWindow: boolean;
1479
+ /** Whether this stream can support claims covering the full window. */
1480
+ completeWindow: boolean;
1471
1481
  }>;
1472
1482
  bodyArtifacts: {
1473
1483
  capturesIndexed: number;
@@ -2073,6 +2083,14 @@ export type RollingWorkerFailureDetails = {
2073
2083
  workerExitSignal?: string | null;
2074
2084
  supervisorAction?: "none" | "sigkill_after_transfer_failure";
2075
2085
  };
2086
+ export type RollingWorkerSupervisorEvent = {
2087
+ at: string;
2088
+ type: "activated" | "failure" | "failed_transfer" | "rejected_socket";
2089
+ generation: number | null;
2090
+ version: string | null;
2091
+ phase?: "startup" | "activation" | "runtime" | "transfer";
2092
+ reason?: string;
2093
+ };
2076
2094
  export type RollingWorkerSupervisorSnapshot = {
2077
2095
  generation: number;
2078
2096
  active: {
@@ -2093,6 +2111,8 @@ export type RollingWorkerSupervisorSnapshot = {
2093
2111
  queuedSockets: number;
2094
2112
  rejectedSockets: number;
2095
2113
  failedTransfers: number;
2114
+ /** Bounded generation-scoped evidence for attributing lifetime counters. */
2115
+ recentEvents: RollingWorkerSupervisorEvent[];
2096
2116
  lastFailure: ({
2097
2117
  at: string;
2098
2118
  generation: number;
@@ -2305,7 +2325,18 @@ export type ProxyNeurolinkRuntime = {
2305
2325
  neurolink: {
2306
2326
  getToolRegistry(): MCPToolRegistry;
2307
2327
  };
2308
- cleanupLogs: (daysToKeep?: number, maxFiles?: number) => void;
2328
+ logsDir: string;
2329
+ };
2330
+ /** Data passed to the isolated proxy log-retention worker. */
2331
+ export type ProxyLogCleanupWorkerData = {
2332
+ logsDir: string;
2333
+ maxAgeDays: number;
2334
+ maxSizeMb: number;
2335
+ };
2336
+ /** Lifecycle handle for non-blocking proxy log retention. */
2337
+ export type ProxyLogCleanupScheduler = {
2338
+ trigger: () => boolean;
2339
+ stop: () => Promise<void>;
2309
2340
  };
2310
2341
  /** Hono app + readiness state created by the proxy start command. */
2311
2342
  export type ProxyStartApp = {
@@ -37,6 +37,7 @@
37
37
  */
38
38
  import * as path from "path";
39
39
  import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
40
+ import { isDecompressionBoundExceeded, readZipEntryWithinLimit, } from "./zipEntryReader.js";
40
41
  import { SIZE_LIMITS_MB } from "../config/index.js";
41
42
  import { FileErrorCode } from "../errors/index.js";
42
43
  // =============================================================================
@@ -159,71 +160,6 @@ const SINGLE_STREAM_TOOLS = {
159
160
  xz: "xz",
160
161
  zst: "zstd",
161
162
  };
162
- /**
163
- * Read one ZIP entry's bytes without trusting the size it declares.
164
- *
165
- * `entry.getData()` cannot be used for this. It sizes its output buffer from
166
- * the central-directory `size` field, which the archive author chooses, and
167
- * adm-zip only arms its own guard when that field is positive:
168
- *
169
- * const option = version >= 15 && expectedLength > 0
170
- * ? { maxOutputLength: expectedLength } : {};
171
- *
172
- * So an entry declaring 0 disables the bound and the caller's `size > maxSize`
173
- * check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
174
- * The declared size is the attack, so nothing here may depend on it: the cap
175
- * comes from our own limit and is handed to the decoder.
176
- *
177
- * CRC is verified on both paths rather than dropped, so bypassing `getData()`
178
- * does not also quietly lose its corruption check — a STORED entry is copied
179
- * out rather than decoded, but it can be damaged just the same. It detects
180
- * damage, not malice — the CRC field is attacker-controlled too.
181
- */
182
- function readZipEntryWithinLimit(entry, maxBytes, zlibModule) {
183
- const compressed = entry.getCompressedData();
184
- const matchesCrc = (data) => (zlibModule.crc32(data) >>> 0) === (entry.header.crc >>> 0);
185
- // STORED: the bytes are already the payload, so its own length is the bound.
186
- if (entry.header.method === ZIP_METHOD_STORED) {
187
- if (compressed.length > maxBytes) {
188
- return { status: "too-large" };
189
- }
190
- return matchesCrc(compressed)
191
- ? { status: "ok", buffer: compressed }
192
- : { status: "corrupt" };
193
- }
194
- if (entry.header.method !== ZIP_METHOD_DEFLATED) {
195
- return { status: "unsupported-method" };
196
- }
197
- let inflated;
198
- try {
199
- inflated = zlibModule.inflateRawSync(compressed, {
200
- maxOutputLength: maxBytes,
201
- });
202
- }
203
- catch (error) {
204
- if (isDecompressionBoundExceeded(error)) {
205
- return { status: "too-large" };
206
- }
207
- return { status: "corrupt" };
208
- }
209
- return matchesCrc(inflated)
210
- ? { status: "ok", buffer: inflated }
211
- : { status: "corrupt" };
212
- }
213
- /** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
214
- const ZIP_METHOD_STORED = 0;
215
- const ZIP_METHOD_DEFLATED = 8;
216
- /**
217
- * Whether a zlib rejection is the output bound firing rather than bad input.
218
- *
219
- * `maxOutputLength` aborts an inflate the moment its output would pass the cap,
220
- * which is the whole point — but it surfaces as a plain `RangeError`, and a
221
- * bomb reported as "failed to decompress" reads as a corrupt upload and invites
222
- * the user to send it again. It will fail identically every time.
223
- *
224
- * Keyed on `code`, not the message: the message embeds a byte count.
225
- */
226
- const isDecompressionBoundExceeded = (error) => error?.code === "ERR_BUFFER_TOO_LARGE";
227
163
  /** File extensions recognized as archive formats */
228
164
  const SUPPORTED_ARCHIVE_EXTENSIONS = [".zip", ".tar", ".gz", ".tgz", ".bz2", ".tbz2", ".jar", ".xz", ".txz", ".zst", ".tzst"];
229
165
  // =============================================================================
@@ -1419,6 +1355,7 @@ export class ArchiveProcessor extends BaseFileProcessor {
1419
1355
  .sort((a, b) => a.uncompressedSize - b.uncompressedSize);
1420
1356
  let totalExtracted = 0;
1421
1357
  let extractCount = 0;
1358
+ const zlibModule = await import("zlib");
1422
1359
  for (const entry of candidates) {
1423
1360
  if (extractCount >= ARCHIVE_CONFIG.MAX_EXTRACT_ENTRIES) {
1424
1361
  break;
@@ -1431,8 +1368,17 @@ export class ArchiveProcessor extends BaseFileProcessor {
1431
1368
  if (!zipEntry) {
1432
1369
  continue;
1433
1370
  }
1434
- const data = zipEntry.getData();
1435
- if (!data || data.length === 0) {
1371
+ // This path was already bounded, but only by coincidence: entries
1372
+ // declaring 0 are dropped above, oversized declarations are dropped
1373
+ // above, and adm-zip caps everything else at the size it declares.
1374
+ // That leaves the ceiling resting on library internals we do not
1375
+ // trust anywhere else in this file, so it is spelled out here.
1376
+ const read = readZipEntryWithinLimit(zipEntry, Math.min(ARCHIVE_CONFIG.MAX_EXTRACT_ENTRY_SIZE, ARCHIVE_CONFIG.MAX_TOTAL_EXTRACT_SIZE - totalExtracted), zlibModule);
1377
+ if (read.status !== "ok") {
1378
+ continue;
1379
+ }
1380
+ const data = read.buffer;
1381
+ if (data.length === 0) {
1436
1382
  continue;
1437
1383
  }
1438
1384
  // Simple binary detection: check for null bytes in first 512 bytes
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Bounded ZIP entry reading, shared by every processor that opens a ZIP.
3
+ *
4
+ * Extracted from ArchiveProcessor because the Office formats are ZIPs too:
5
+ * .pptx and .odt read their entries directly and so can be handed the same
6
+ * bomb, and need the same refusal. One implementation rather than three means
7
+ * a correction to the guard lands everywhere at once.
8
+ *
9
+ * .docx and .xlsx deliberately do NOT use this. mammoth and exceljs unzip for
10
+ * themselves and were measured refusing a 400MB bomb at 46MB and 53MB peak,
11
+ * so wrapping them in a pre-scan bought no safety and roughly tripled the cost
12
+ * of every ordinary document.
13
+ *
14
+ * @module processors/archive/zipEntryReader
15
+ */
16
+ import type { ArchiveEntryReadResult, BoundedZipEntry } from "../../types/index.js";
17
+ /** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
18
+ export declare const ZIP_METHOD_STORED = 0;
19
+ export declare const ZIP_METHOD_DEFLATED = 8;
20
+ /**
21
+ * Whether a zlib rejection is the output bound firing rather than bad input.
22
+ *
23
+ * `maxOutputLength` aborts an inflate the moment its output would pass the cap,
24
+ * which is the whole point — but it surfaces as a plain `RangeError`, and a
25
+ * bomb reported as "failed to decompress" reads as a corrupt upload and invites
26
+ * the user to send it again. It will fail identically every time.
27
+ *
28
+ * Keyed on `code`, not the message: the message embeds a byte count.
29
+ */
30
+ export declare const isDecompressionBoundExceeded: (error: unknown) => boolean;
31
+ /**
32
+ * Read one ZIP entry's bytes without trusting the size it declares.
33
+ *
34
+ * `entry.getData()` cannot be used for this. It sizes its output buffer from
35
+ * the central-directory `size` field, which the archive author chooses, and
36
+ * adm-zip only arms its own guard when that field is positive:
37
+ *
38
+ * const option = version >= 15 && expectedLength > 0
39
+ * ? { maxOutputLength: expectedLength } : {};
40
+ *
41
+ * So an entry declaring 0 disables the bound and the caller's `size > maxSize`
42
+ * check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
43
+ * The declared size is the attack, so nothing here may depend on it: the cap
44
+ * comes from our own limit and is handed to the decoder.
45
+ *
46
+ * CRC is verified on both paths rather than dropped, so bypassing `getData()`
47
+ * does not also quietly lose its corruption check — a STORED entry is copied
48
+ * out rather than decoded, but it can be damaged just the same. It detects
49
+ * damage, not malice — the CRC field is attacker-controlled too.
50
+ */
51
+ export declare function readZipEntryWithinLimit(entry: BoundedZipEntry, maxBytes: number, zlibModule: typeof import("zlib")): ArchiveEntryReadResult;