@tonyclaw/agent-inspector 3.0.29 → 3.0.31

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 (156) hide show
  1. package/.output/cli.js +791 -191
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/CompareDrawer-DB2KggLE.js +1 -0
  4. package/.output/public/assets/InspectorPet-CfwDw6kD.js +4108 -0
  5. package/.output/public/assets/ProxyViewerContainer-PcrO96_F.js +126 -0
  6. package/.output/public/assets/{ReplayDialog-CctnITbt.js → ReplayDialog-3Pl7EKjp.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-BrN0RBbL.js → RequestAnatomy-BcPjcHLX.js} +1 -1
  8. package/.output/public/assets/ResponseView-DOx51wV3.js +2 -0
  9. package/.output/public/assets/StreamingChunkSequence-LkYJWM1A.js +1 -0
  10. package/.output/public/assets/_sessionId-BSg32vC6.js +1 -0
  11. package/.output/public/assets/{_sessionId-DKM91udC.js → _sessionId-C0CA68QV.js} +1 -1
  12. package/.output/public/assets/{index-Bq-nqolG.js → index-BpBUqdAB.js} +1 -1
  13. package/.output/public/assets/index-D93dCgAQ.js +1 -0
  14. package/.output/public/assets/{index-DPiESBo2.js → index-FV5sxqx3.js} +6 -6
  15. package/.output/public/assets/index-VUsF4nhf.js +76 -0
  16. package/.output/public/assets/index-hyQHZeQI.css +1 -0
  17. package/.output/public/assets/{json-viewer-CfaDJwx8.js → json-viewer-WctIbAh_.js} +1 -1
  18. package/.output/public/assets/jszip.min-DnSIoTix.js +2 -0
  19. package/.output/public/assets/qwen-mMn3f5ul.webp +0 -0
  20. package/.output/server/_libs/jszip.mjs +35 -9
  21. package/.output/server/_libs/lucide-react.mjs +80 -80
  22. package/.output/server/_libs/radix-ui__react-dialog.mjs +3 -3
  23. package/.output/server/_libs/tanstack__react-router.mjs +1 -1
  24. package/.output/server/{_sessionId-CwGXPD4C.mjs → _sessionId-BHBK3VbF.mjs} +15 -15
  25. package/.output/server/_sessionId-CyuJ6Kfl.mjs +81 -0
  26. package/.output/server/_ssr/{CompareDrawer-R_P1-VNr.mjs → CompareDrawer-27amlv_a.mjs} +138 -103
  27. package/.output/server/_ssr/InspectorPet-CQnGXfLs.mjs +925 -0
  28. package/.output/server/_ssr/{ProxyViewerContainer-DUgxRNbj.mjs → ProxyViewerContainer-DlAl0uF-.mjs} +1569 -1646
  29. package/.output/server/_ssr/{ReplayDialog-De9wKKzS.mjs → ReplayDialog-5m2jtlLa.mjs} +18 -18
  30. package/.output/server/_ssr/{RequestAnatomy-Db4MxUYo.mjs → RequestAnatomy--1WtQNY9.mjs} +58 -58
  31. package/.output/server/_ssr/{ResponseView-8rDXOiNB.mjs → ResponseView-CgbqS8sr.mjs} +60 -71
  32. package/.output/server/_ssr/{StreamingChunkSequence-N9teZHkA.mjs → StreamingChunkSequence-BwpnopWx.mjs} +16 -16
  33. package/.output/server/_ssr/{index-D4DvByuW.mjs → index-CNVi1TzT.mjs} +15 -15
  34. package/.output/server/_ssr/index-DMCjH_4V.mjs +81 -0
  35. package/.output/server/_ssr/index.mjs +2 -2
  36. package/.output/server/_ssr/{json-viewer-BjoHRbjS.mjs → json-viewer-DNGN2uoe.mjs} +58 -58
  37. package/.output/server/_ssr/{router-BYlePDj8.mjs → router-CgsqLnk5.mjs} +16007 -12632
  38. package/.output/server/_tanstack-start-manifest_v-BH5f_kWV.mjs +4 -0
  39. package/.output/server/index.mjs +99 -71
  40. package/.output/workers/logFinalizer.worker.js +16908 -0
  41. package/.output/workers/sessionWorkerEntry.js +16904 -0
  42. package/README.md +21 -5
  43. package/package.json +6 -2
  44. package/src/assets/logos/qwen.webp +0 -0
  45. package/src/cli.ts +85 -10
  46. package/src/components/OnboardingBanner.tsx +41 -21
  47. package/src/components/ProxyViewer.tsx +198 -269
  48. package/src/components/ProxyViewerContainer.tsx +128 -17
  49. package/src/components/ecosystem/AgentLabDialog.tsx +166 -19
  50. package/src/components/errors/SafeErrorBoundary.tsx +201 -0
  51. package/src/components/pi-agent/PiAgentPanel.tsx +209 -35
  52. package/src/components/pi-agent/piAgentChatLogic.ts +263 -0
  53. package/src/components/providers/ImportWizardDialog.tsx +18 -42
  54. package/src/components/providers/ProviderLogo.tsx +1 -1
  55. package/src/components/providers/ProvidersPanel.tsx +8 -5
  56. package/src/components/providers/SettingsDialog.tsx +34 -11
  57. package/src/components/proxy-viewer/ApplicationBar.tsx +63 -0
  58. package/src/components/proxy-viewer/CompareDrawer.tsx +92 -49
  59. package/src/components/proxy-viewer/LiveConnectionStatus.tsx +186 -0
  60. package/src/components/proxy-viewer/LogEntry.tsx +283 -275
  61. package/src/components/proxy-viewer/RawExportWarning.tsx +77 -0
  62. package/src/components/proxy-viewer/SessionLoadProgressBar.tsx +61 -0
  63. package/src/components/proxy-viewer/ThreadConnector.tsx +16 -1
  64. package/src/components/proxy-viewer/TurnGroup.tsx +13 -2
  65. package/src/components/proxy-viewer/accessibility.ts +8 -0
  66. package/src/components/proxy-viewer/lazy.ts +4 -0
  67. package/src/components/proxy-viewer/liveConnectionState.ts +158 -0
  68. package/src/components/proxy-viewer/proxyViewerLogic.ts +10 -0
  69. package/src/components/proxy-viewer/viewerState.ts +2 -6
  70. package/src/components/ui/select.tsx +1 -1
  71. package/src/components/ui/tabs.tsx +3 -3
  72. package/src/components/ui/transient-toast.tsx +7 -6
  73. package/src/lib/apiClient.ts +17 -2
  74. package/src/lib/ecosystemContract.ts +34 -0
  75. package/src/lib/export-logs.ts +1 -1
  76. package/src/lib/piAgentContract.ts +80 -0
  77. package/src/lib/providerContract.ts +70 -4
  78. package/src/lib/providerImportContract.ts +27 -0
  79. package/src/lib/providerModelMetadata.ts +16 -7
  80. package/src/lib/resourceLimits.ts +152 -0
  81. package/src/lib/safeDiagnostic.ts +38 -0
  82. package/src/lib/useProviders.ts +4 -4
  83. package/src/mcp/server.ts +39 -6
  84. package/src/mcp/toolHandlers.ts +131 -4
  85. package/src/proxy/chunkStorage.ts +20 -6
  86. package/src/proxy/config.ts +20 -6
  87. package/src/proxy/dataDir.ts +3 -0
  88. package/src/proxy/ecosystemExecutionConfirmation.ts +114 -0
  89. package/src/proxy/ecosystemExecutionRoute.ts +116 -0
  90. package/src/proxy/ecosystemTasks.ts +48 -0
  91. package/src/proxy/evidenceExporter.ts +23 -9
  92. package/src/proxy/groupEvidenceExporter.ts +26 -5
  93. package/src/proxy/groupStore.ts +13 -3
  94. package/src/proxy/handler.ts +103 -20
  95. package/src/proxy/identityProxy.ts +333 -14
  96. package/src/proxy/logFinalizer.ts +73 -6
  97. package/src/proxy/logImportUpload.ts +128 -0
  98. package/src/proxy/logImporter.ts +321 -70
  99. package/src/proxy/logIndex.ts +16 -6
  100. package/src/proxy/logger.ts +294 -41
  101. package/src/proxy/privateDataPath.ts +183 -0
  102. package/src/proxy/providerScanStore.ts +87 -0
  103. package/src/proxy/providerSecretStore.ts +58 -19
  104. package/src/proxy/providers.ts +175 -58
  105. package/src/proxy/rawStreamCapture.ts +66 -5
  106. package/src/proxy/runStore.ts +13 -3
  107. package/src/proxy/runtimeAdmission.ts +52 -0
  108. package/src/proxy/runtimeHealth.ts +206 -0
  109. package/src/proxy/runtimeShutdown.ts +75 -0
  110. package/src/proxy/sessionArchive.ts +15 -2
  111. package/src/proxy/sessionProcess.ts +19 -0
  112. package/src/proxy/sessionRuntime.ts +7 -0
  113. package/src/proxy/shutdownCoordinator.ts +90 -0
  114. package/src/proxy/sqliteLogIndex.ts +18 -2
  115. package/src/proxy/store.ts +18 -2
  116. package/src/routes/__root.tsx +18 -1
  117. package/src/routes/api/alerts.summary.ts +12 -8
  118. package/src/routes/api/alerts.ts +27 -9
  119. package/src/routes/api/ecosystem.packages.$packageId.help.ts +3 -8
  120. package/src/routes/api/ecosystem.packages.$packageId.install.ts +3 -8
  121. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +3 -11
  122. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +3 -8
  123. package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +3 -8
  124. package/src/routes/api/health.ts +9 -2
  125. package/src/routes/api/knowledge.project-context.ts +11 -0
  126. package/src/routes/api/knowledge.search.ts +33 -2
  127. package/src/routes/api/logs.$id.body.ts +16 -2
  128. package/src/routes/api/logs.import.ts +7 -18
  129. package/src/routes/api/logs.stream.ts +147 -69
  130. package/src/routes/api/logs.ts +55 -14
  131. package/src/routes/api/providers.$providerId.model-metadata.ts +22 -4
  132. package/src/routes/api/providers.$providerId.ts +37 -6
  133. package/src/routes/api/providers.export.ts +34 -16
  134. package/src/routes/api/providers.import.ts +42 -8
  135. package/src/routes/api/providers.scan.ts +13 -8
  136. package/src/routes/api/providers.ts +36 -5
  137. package/src/routes/api/runs.ts +12 -12
  138. package/src/routes/api/sessions.ts +15 -8
  139. package/src/routes/index.tsx +6 -0
  140. package/src/routes/livez.ts +13 -0
  141. package/src/routes/readyz.ts +18 -0
  142. package/src/routes/session/$sessionId.tsx +6 -0
  143. package/src/services/piAgent.ts +131 -102
  144. package/src/services/piAgentRetrieval.ts +1180 -0
  145. package/styles/globals.css +19 -3
  146. package/.output/public/assets/CompareDrawer-Cb5ccPeX.js +0 -1
  147. package/.output/public/assets/ProxyViewerContainer-COBe5GTT.js +0 -4234
  148. package/.output/public/assets/ResponseView-Cqq87lX_.js +0 -3
  149. package/.output/public/assets/StreamingChunkSequence-CnTa9mCr.js +0 -1
  150. package/.output/public/assets/index-DTLNVcgc.js +0 -76
  151. package/.output/public/assets/index-DliqmwUw.css +0 -1
  152. package/.output/public/assets/qwen-CONDcHqt.png +0 -0
  153. package/.output/server/_tanstack-start-manifest_v-DMCA8KK3.mjs +0 -4
  154. package/src/assets/logos/mcp.png +0 -0
  155. package/src/assets/logos/qwen.png +0 -0
  156. package/src/components/ui/mcp-logo.tsx +0 -20
@@ -0,0 +1,128 @@
1
+ import { DEFAULT_LOG_IMPORT_LIMITS } from "./logImporter";
2
+
3
+ const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024;
4
+
5
+ export const MAX_LOG_IMPORT_REQUEST_BYTES =
6
+ DEFAULT_LOG_IMPORT_LIMITS.maxCompressedBytes + MAX_MULTIPART_OVERHEAD_BYTES;
7
+
8
+ export type BoundedLogImportUpload =
9
+ | {
10
+ ok: true;
11
+ fileName: string;
12
+ data: Uint8Array;
13
+ }
14
+ | {
15
+ ok: false;
16
+ status: 400 | 413;
17
+ message: string;
18
+ };
19
+
20
+ type BoundedBody =
21
+ | { ok: true; data: ArrayBuffer }
22
+ | { ok: false; status: 400 | 413; message: string };
23
+
24
+ function declaredContentLength(value: string | null): number | null | "invalid" {
25
+ if (value === null) return null;
26
+ if (!/^[0-9]+$/.test(value)) return "invalid";
27
+ const parsed = Number.parseInt(value, 10);
28
+ return Number.isSafeInteger(parsed) ? parsed : "invalid";
29
+ }
30
+
31
+ async function readBoundedBody(request: Request, maxBytes: number): Promise<BoundedBody> {
32
+ const declaredBytes = declaredContentLength(request.headers.get("content-length"));
33
+ if (declaredBytes === "invalid") {
34
+ return { ok: false, status: 400, message: "Invalid Content-Length header" };
35
+ }
36
+ if (declaredBytes !== null && declaredBytes > maxBytes) {
37
+ return {
38
+ ok: false,
39
+ status: 413,
40
+ message: `Import request exceeds maximum size of ${String(maxBytes)} bytes.`,
41
+ };
42
+ }
43
+
44
+ const body = request.body;
45
+ if (body === null) {
46
+ return { ok: false, status: 400, message: "Expected multipart form data" };
47
+ }
48
+
49
+ const reader = body.getReader();
50
+ const chunks: Uint8Array[] = [];
51
+ let totalBytes = 0;
52
+ while (true) {
53
+ try {
54
+ const result = await reader.read();
55
+ if (result.done) break;
56
+
57
+ totalBytes += result.value.byteLength;
58
+ if (totalBytes > maxBytes) {
59
+ await reader.cancel();
60
+ return {
61
+ ok: false,
62
+ status: 413,
63
+ message: `Import request exceeds maximum size of ${String(maxBytes)} bytes.`,
64
+ };
65
+ }
66
+ chunks.push(result.value);
67
+ } catch {
68
+ return { ok: false, status: 400, message: "Failed to read import request" };
69
+ }
70
+ }
71
+
72
+ const data = new Uint8Array(totalBytes);
73
+ let offset = 0;
74
+ for (const chunk of chunks) {
75
+ data.set(chunk, offset);
76
+ offset += chunk.byteLength;
77
+ }
78
+ return { ok: true, data: data.buffer };
79
+ }
80
+
81
+ function isUploadedFile(value: FormDataEntryValue | null): value is File {
82
+ return value instanceof File;
83
+ }
84
+
85
+ export async function readBoundedLogImportUpload(
86
+ request: Request,
87
+ options: {
88
+ maxFileBytes?: number;
89
+ maxRequestBytes?: number;
90
+ } = {},
91
+ ): Promise<BoundedLogImportUpload> {
92
+ const contentType = request.headers.get("content-type");
93
+ if (contentType === null || !contentType.toLowerCase().startsWith("multipart/form-data")) {
94
+ return { ok: false, status: 400, message: "Expected multipart form data" };
95
+ }
96
+
97
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_LOG_IMPORT_LIMITS.maxCompressedBytes;
98
+ const maxRequestBytes = options.maxRequestBytes ?? MAX_LOG_IMPORT_REQUEST_BYTES;
99
+ const body = await readBoundedBody(request, maxRequestBytes);
100
+ if (!body.ok) return body;
101
+
102
+ let formData: FormData;
103
+ try {
104
+ formData = await new Response(body.data, {
105
+ headers: { "content-type": contentType },
106
+ }).formData();
107
+ } catch {
108
+ return { ok: false, status: 400, message: "Expected multipart form data" };
109
+ }
110
+
111
+ const upload = formData.get("file");
112
+ if (!isUploadedFile(upload)) {
113
+ return { ok: false, status: 400, message: "Missing import file" };
114
+ }
115
+ if (upload.size > maxFileBytes) {
116
+ return {
117
+ ok: false,
118
+ status: 413,
119
+ message: `Import file exceeds maximum size of ${String(maxFileBytes)} bytes.`,
120
+ };
121
+ }
122
+
123
+ return {
124
+ ok: true,
125
+ fileName: upload.name,
126
+ data: new Uint8Array(await upload.arrayBuffer()),
127
+ };
128
+ }
@@ -15,16 +15,29 @@ import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
15
15
 
16
16
  export type ImportedLogDraft = Omit<CapturedLog, "id">;
17
17
 
18
- // Maximum compressed size for a ZIP import (200 MB). Legitimate exports are
19
- // well under this; anything larger is almost certainly malicious.
20
- const MAX_ZIP_COMPRESSED_BYTES = 200 * 1024 * 1024;
21
-
22
- // Maximum total decompressed bytes within a single ZIP (500 MB). Prevents a
23
- // small "ZIP bomb" archive (a few MB compressed → hundreds of GB decompressed)
24
- // from exhausting memory. The ratio is checked against the compressed size of
25
- // the *archive*, not per-entry, since the archive size is the only hard cap
26
- // available before decompression begins.
27
- const MAX_ZIP_DECOMPRESSED_BYTES = 500 * 1024 * 1024;
18
+ export type LogImportLimits = {
19
+ maxCompressedBytes: number;
20
+ maxEntries: number;
21
+ maxEntryUncompressedBytes: number;
22
+ maxTotalUncompressedBytes: number;
23
+ maxExpansionRatio: number;
24
+ };
25
+
26
+ export const DEFAULT_LOG_IMPORT_LIMITS: Readonly<LogImportLimits> = {
27
+ maxCompressedBytes: 200 * 1024 * 1024,
28
+ maxEntries: 10_000,
29
+ maxEntryUncompressedBytes: 128 * 1024 * 1024,
30
+ maxTotalUncompressedBytes: 500 * 1024 * 1024,
31
+ maxExpansionRatio: 100,
32
+ };
33
+
34
+ const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
35
+ const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
36
+ const ZIP_END_OF_CENTRAL_DIRECTORY_BYTES = 22;
37
+ const ZIP_CENTRAL_DIRECTORY_HEADER_BYTES = 46;
38
+ const ZIP_MAX_COMMENT_BYTES = 0xffff;
39
+ const ZIP_16_BIT_SENTINEL = 0xffff;
40
+ const ZIP_32_BIT_SENTINEL = 0xffffffff;
28
41
 
29
42
  export type ParsedLogImport =
30
43
  | {
@@ -58,6 +71,17 @@ const StreamingChunksImportSchema = z.object({
58
71
 
59
72
  type ParsedManifest = z.infer<typeof ManifestSchema>;
60
73
 
74
+ type ZipBudget = {
75
+ remainingBytes: number;
76
+ decompressedBytes: number;
77
+ compressedBytes: number;
78
+ limits: Readonly<LogImportLimits>;
79
+ };
80
+
81
+ type ZipTextResult = { ok: true; text: string | null } | { ok: false; message: string };
82
+
83
+ type ZipDirectoryInspection = { ok: true } | { ok: false; message: string };
84
+
61
85
  const SEMVER_MAJOR_PATTERN = /^([0-9]+)\.[0-9]+\.[0-9]+(?:[-+].*)?$/;
62
86
 
63
87
  function readRecordProperty(value: unknown, name: string): unknown {
@@ -300,33 +324,249 @@ function legacyDraftFromParts(input: {
300
324
  });
301
325
  }
302
326
 
327
+ function zipEntryLimitMessage(path: string, maxBytes: number): string {
328
+ return `Archive entry "${path}" exceeds maximum uncompressed size of ${String(maxBytes)} bytes.`;
329
+ }
330
+
331
+ function zipTotalLimitMessage(maxBytes: number): string {
332
+ return `Archive exceeds maximum total uncompressed size of ${String(maxBytes)} bytes.`;
333
+ }
334
+
335
+ function zipExpansionLimitMessage(maxRatio: number): string {
336
+ return `Archive exceeds maximum expansion ratio of ${String(maxRatio)}:1.`;
337
+ }
338
+
339
+ function exceedsExpansionRatio(
340
+ uncompressedBytes: number,
341
+ compressedBytes: number,
342
+ maxRatio: number,
343
+ ): boolean {
344
+ if (uncompressedBytes === 0) return false;
345
+ if (compressedBytes === 0) return true;
346
+ return uncompressedBytes > compressedBytes * maxRatio;
347
+ }
348
+
349
+ function findEndOfCentralDirectory(data: Uint8Array, view: DataView): number | null {
350
+ const firstCandidate = Math.max(
351
+ 0,
352
+ data.byteLength - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES - ZIP_MAX_COMMENT_BYTES,
353
+ );
354
+ for (
355
+ let offset = data.byteLength - ZIP_END_OF_CENTRAL_DIRECTORY_BYTES;
356
+ offset >= firstCandidate;
357
+ offset -= 1
358
+ ) {
359
+ if (view.getUint32(offset, true) !== ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
360
+ const commentBytes = view.getUint16(offset + 20, true);
361
+ if (offset + ZIP_END_OF_CENTRAL_DIRECTORY_BYTES + commentBytes === data.byteLength) {
362
+ return offset;
363
+ }
364
+ }
365
+ return null;
366
+ }
367
+
303
368
  /**
304
- * Reads a file from the ZIP as UTF-8 text. Decrements `remaining.bytes` by the
305
- * decoded byte size; callers check `remaining.bytes < 0` after the call to
306
- * detect that the cumulative decompressed size has blown the budget (a
307
- * zip-bomb signal). We do NOT throw on budget exceeded because the project's
308
- * lint rules forbid throwing inside non-CLI modules — using a mutable
309
- * counter keeps the budget check declarative at every call site.
369
+ * JSZip does not expose declared entry sizes. Inspect the central directory so
370
+ * hostile archives are rejected before an inflater or JSON parser runs.
310
371
  */
311
- async function readZipText(
312
- zip: JSZip,
313
- path: string,
314
- remaining: { bytes: number },
315
- ): Promise<string | null> {
372
+ function inspectZipDirectory(
373
+ data: Uint8Array,
374
+ limits: Readonly<LogImportLimits>,
375
+ ): ZipDirectoryInspection {
376
+ if (data.byteLength < ZIP_END_OF_CENTRAL_DIRECTORY_BYTES) {
377
+ return { ok: false, message: "Invalid ZIP archive: central directory was not found." };
378
+ }
379
+
380
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
381
+ const endOffset = findEndOfCentralDirectory(data, view);
382
+ if (endOffset === null) {
383
+ return { ok: false, message: "Invalid ZIP archive: central directory was not found." };
384
+ }
385
+
386
+ const diskNumber = view.getUint16(endOffset + 4, true);
387
+ const centralDirectoryDisk = view.getUint16(endOffset + 6, true);
388
+ const entriesOnDisk = view.getUint16(endOffset + 8, true);
389
+ const entryCount = view.getUint16(endOffset + 10, true);
390
+ const directoryBytes = view.getUint32(endOffset + 12, true);
391
+ const directoryOffset = view.getUint32(endOffset + 16, true);
392
+
393
+ if (
394
+ diskNumber !== 0 ||
395
+ centralDirectoryDisk !== 0 ||
396
+ entriesOnDisk !== entryCount ||
397
+ entryCount === ZIP_16_BIT_SENTINEL ||
398
+ directoryBytes === ZIP_32_BIT_SENTINEL ||
399
+ directoryOffset === ZIP_32_BIT_SENTINEL
400
+ ) {
401
+ return { ok: false, message: "ZIP64 and split archives are not supported for log import." };
402
+ }
403
+ if (entryCount > limits.maxEntries) {
404
+ return {
405
+ ok: false,
406
+ message: `Archive contains more than ${String(limits.maxEntries)} entries.`,
407
+ };
408
+ }
409
+
410
+ const directoryEnd = directoryOffset + directoryBytes;
411
+ if (directoryEnd > endOffset || directoryEnd > data.byteLength) {
412
+ return { ok: false, message: "Invalid ZIP archive: central directory is out of bounds." };
413
+ }
414
+
415
+ let offset = directoryOffset;
416
+ let totalCompressedBytes = 0;
417
+ let totalUncompressedBytes = 0;
418
+ for (let index = 0; index < entryCount; index += 1) {
419
+ if (
420
+ offset + ZIP_CENTRAL_DIRECTORY_HEADER_BYTES > directoryEnd ||
421
+ view.getUint32(offset, true) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE
422
+ ) {
423
+ return { ok: false, message: "Invalid ZIP archive: malformed central directory entry." };
424
+ }
425
+
426
+ const flags = view.getUint16(offset + 8, true);
427
+ const compressedBytes = view.getUint32(offset + 20, true);
428
+ const uncompressedBytes = view.getUint32(offset + 24, true);
429
+ const fileNameBytes = view.getUint16(offset + 28, true);
430
+ const extraBytes = view.getUint16(offset + 30, true);
431
+ const commentBytes = view.getUint16(offset + 32, true);
432
+ const localHeaderOffset = view.getUint32(offset + 42, true);
433
+ const entryEnd =
434
+ offset + ZIP_CENTRAL_DIRECTORY_HEADER_BYTES + fileNameBytes + extraBytes + commentBytes;
435
+
436
+ if (
437
+ compressedBytes === ZIP_32_BIT_SENTINEL ||
438
+ uncompressedBytes === ZIP_32_BIT_SENTINEL ||
439
+ localHeaderOffset === ZIP_32_BIT_SENTINEL
440
+ ) {
441
+ return { ok: false, message: "ZIP64 archives are not supported for log import." };
442
+ }
443
+ if ((flags & 0x1) !== 0) {
444
+ return { ok: false, message: "Encrypted ZIP entries are not supported for log import." };
445
+ }
446
+ if (entryEnd > directoryEnd || localHeaderOffset >= directoryOffset) {
447
+ return { ok: false, message: "Invalid ZIP archive: entry metadata is out of bounds." };
448
+ }
449
+ if (uncompressedBytes > limits.maxEntryUncompressedBytes) {
450
+ return {
451
+ ok: false,
452
+ message: zipEntryLimitMessage(
453
+ `entry ${String(index + 1)}`,
454
+ limits.maxEntryUncompressedBytes,
455
+ ),
456
+ };
457
+ }
458
+ if (exceedsExpansionRatio(uncompressedBytes, compressedBytes, limits.maxExpansionRatio)) {
459
+ return { ok: false, message: zipExpansionLimitMessage(limits.maxExpansionRatio) };
460
+ }
461
+
462
+ totalCompressedBytes += compressedBytes;
463
+ totalUncompressedBytes += uncompressedBytes;
464
+ if (totalUncompressedBytes > limits.maxTotalUncompressedBytes) {
465
+ return { ok: false, message: zipTotalLimitMessage(limits.maxTotalUncompressedBytes) };
466
+ }
467
+ if (
468
+ exceedsExpansionRatio(totalUncompressedBytes, totalCompressedBytes, limits.maxExpansionRatio)
469
+ ) {
470
+ return { ok: false, message: zipExpansionLimitMessage(limits.maxExpansionRatio) };
471
+ }
472
+
473
+ offset = entryEnd;
474
+ }
475
+
476
+ if (offset !== directoryEnd) {
477
+ return { ok: false, message: "Invalid ZIP archive: central directory size is inconsistent." };
478
+ }
479
+ return { ok: true };
480
+ }
481
+
482
+ function bufferFromZipChunk(chunk: unknown): Buffer | null {
483
+ if (Buffer.isBuffer(chunk)) return chunk;
484
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk);
485
+ return null;
486
+ }
487
+
488
+ async function readZipText(zip: JSZip, path: string, budget: ZipBudget): Promise<ZipTextResult> {
316
489
  const file = zip.file(path);
317
- if (file === null) return null;
318
- const text = await file.async("text");
319
- remaining.bytes -= Buffer.byteLength(text, "utf8");
320
- return text;
490
+ if (file === null) return { ok: true, text: null };
491
+
492
+ return await new Promise((resolve) => {
493
+ const chunks: Buffer[] = [];
494
+ const stream = file.nodeStream("nodebuffer");
495
+ let entryBytes = 0;
496
+ let settled = false;
497
+
498
+ const settle = (result: ZipTextResult): void => {
499
+ if (settled) return;
500
+ settled = true;
501
+ resolve(result);
502
+ };
503
+
504
+ stream.on("data", (chunk: unknown) => {
505
+ if (settled) return;
506
+ const buffer = bufferFromZipChunk(chunk);
507
+ if (buffer === null) {
508
+ stream.pause();
509
+ settle({ ok: false, message: `Archive entry "${path}" produced invalid data.` });
510
+ return;
511
+ }
512
+
513
+ entryBytes += buffer.byteLength;
514
+ if (entryBytes > budget.limits.maxEntryUncompressedBytes) {
515
+ stream.pause();
516
+ settle({
517
+ ok: false,
518
+ message: zipEntryLimitMessage(path, budget.limits.maxEntryUncompressedBytes),
519
+ });
520
+ return;
521
+ }
522
+ if (buffer.byteLength > budget.remainingBytes) {
523
+ stream.pause();
524
+ settle({
525
+ ok: false,
526
+ message: zipTotalLimitMessage(budget.limits.maxTotalUncompressedBytes),
527
+ });
528
+ return;
529
+ }
530
+ const decompressedBytes = budget.decompressedBytes + buffer.byteLength;
531
+ if (
532
+ exceedsExpansionRatio(
533
+ decompressedBytes,
534
+ budget.compressedBytes,
535
+ budget.limits.maxExpansionRatio,
536
+ )
537
+ ) {
538
+ stream.pause();
539
+ settle({
540
+ ok: false,
541
+ message: zipExpansionLimitMessage(budget.limits.maxExpansionRatio),
542
+ });
543
+ return;
544
+ }
545
+
546
+ budget.remainingBytes -= buffer.byteLength;
547
+ budget.decompressedBytes = decompressedBytes;
548
+ chunks.push(buffer);
549
+ });
550
+ stream.on("error", (error: unknown) => {
551
+ settle({
552
+ ok: false,
553
+ message: error instanceof Error ? error.message : `Failed to decompress "${path}".`,
554
+ });
555
+ });
556
+ stream.on("end", () => {
557
+ settle({ ok: true, text: Buffer.concat(chunks, entryBytes).toString("utf8") });
558
+ });
559
+ });
321
560
  }
322
561
 
323
562
  async function readManifest(
324
563
  zip: JSZip,
325
- remaining: { bytes: number },
326
- ): Promise<ParsedManifest | null> {
327
- const text = await readZipText(zip, "manifest.json", remaining);
328
- const parsed = ManifestSchema.safeParse(parseJson(text));
329
- return parsed.success ? parsed.data : null;
564
+ budget: ZipBudget,
565
+ ): Promise<{ ok: true; manifest: ParsedManifest | null } | { ok: false; message: string }> {
566
+ const result = await readZipText(zip, "manifest.json", budget);
567
+ if (!result.ok) return result;
568
+ const parsed = ManifestSchema.safeParse(parseJson(result.text));
569
+ return { ok: true, manifest: parsed.success ? parsed.data : null };
330
570
  }
331
571
 
332
572
  function versionMajor(version: string): number | null {
@@ -387,24 +627,25 @@ function validateManifestVersion(manifest: ParsedManifest | null):
387
627
  return { ok: true, warning: null };
388
628
  }
389
629
 
390
- function bombError(warnings: string[] = []): ParsedLogImport {
391
- return {
392
- ok: false,
393
- message: `Archive decompresses to more than ${MAX_ZIP_DECOMPRESSED_BYTES} bytes.`,
394
- warnings,
395
- };
396
- }
397
-
398
- async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
630
+ async function parseZipLogImport(
631
+ data: Uint8Array,
632
+ limits: Readonly<LogImportLimits>,
633
+ ): Promise<ParsedLogImport> {
399
634
  const warnings: string[] = [];
400
- const zip = await JSZip.loadAsync(data);
635
+ const inspection = inspectZipDirectory(data, limits);
636
+ if (!inspection.ok) return { ...inspection, warnings };
401
637
 
402
- // Tracks remaining budget for decompressed bytes. Decremented on every read;
403
- // any read that pushes it below zero marks the archive as a zip bomb.
404
- const remaining = { bytes: MAX_ZIP_DECOMPRESSED_BYTES };
638
+ const zip = await JSZip.loadAsync(data);
639
+ const budget: ZipBudget = {
640
+ remainingBytes: limits.maxTotalUncompressedBytes,
641
+ decompressedBytes: 0,
642
+ compressedBytes: data.byteLength,
643
+ limits,
644
+ };
405
645
 
406
- const manifest = await readManifest(zip, remaining);
407
- if (remaining.bytes < 0) return bombError();
646
+ const manifestResult = await readManifest(zip, budget);
647
+ if (!manifestResult.ok) return { ...manifestResult, warnings };
648
+ const manifest = manifestResult.manifest;
408
649
  const versionCheck = validateManifestVersion(manifest);
409
650
  if (!versionCheck.ok) {
410
651
  return {
@@ -422,28 +663,30 @@ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
422
663
  warnings.push("Imported redacted export. Secrets and some replay details may be unavailable.");
423
664
  }
424
665
 
425
- const metadataText = await readZipText(zip, "logs.json", remaining);
426
- if (remaining.bytes < 0) return bombError(warnings);
427
- const metadataLogs = metadataText === null ? null : parseJsonLogs(metadataText);
666
+ const metadataResult = await readZipText(zip, "logs.json", budget);
667
+ if (!metadataResult.ok) return { ...metadataResult, warnings };
668
+ const metadataLogs = metadataResult.text === null ? null : parseJsonLogs(metadataResult.text);
428
669
  const logs: ImportedLogDraft[] = [];
429
670
  let skipped = 0;
430
671
 
431
672
  if (metadataLogs !== null) {
432
673
  for (const log of metadataLogs) {
433
- const requestText = await readZipText(zip, `#${String(log.id)}.Request.json`, remaining);
434
- const responseText = await readZipText(zip, `#${String(log.id)}.Response.json`, remaining);
435
- const streamingText = await readZipText(
674
+ const requestResult = await readZipText(zip, `#${String(log.id)}.Request.json`, budget);
675
+ if (!requestResult.ok) return { ...requestResult, warnings };
676
+ const responseResult = await readZipText(zip, `#${String(log.id)}.Response.json`, budget);
677
+ if (!responseResult.ok) return { ...responseResult, warnings };
678
+ const streamingResult = await readZipText(
436
679
  zip,
437
680
  `#${String(log.id)}.SSE.Response.json`,
438
- remaining,
681
+ budget,
439
682
  );
440
- if (remaining.bytes < 0) return bombError(warnings);
683
+ if (!streamingResult.ok) return { ...streamingResult, warnings };
441
684
  logs.push(
442
685
  draftFromCapturedLog(
443
686
  log,
444
- requestText,
445
- responseText,
446
- streamingChunksFromText(streamingText),
687
+ requestResult.text,
688
+ responseResult.text,
689
+ streamingChunksFromText(streamingResult.text),
447
690
  ),
448
691
  );
449
692
  }
@@ -451,24 +694,30 @@ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
451
694
  }
452
695
 
453
696
  for (const originalId of manifest?.logIds ?? []) {
454
- const requestText = await readZipText(zip, `#${String(originalId)}.Request.json`, remaining);
455
- const responseText = await readZipText(zip, `#${String(originalId)}.Response.json`, remaining);
456
- const streamingText = await readZipText(
697
+ const requestResult = await readZipText(zip, `#${String(originalId)}.Request.json`, budget);
698
+ if (!requestResult.ok) return { ...requestResult, warnings };
699
+ const responseResult = await readZipText(zip, `#${String(originalId)}.Response.json`, budget);
700
+ if (!responseResult.ok) return { ...responseResult, warnings };
701
+ const streamingResult = await readZipText(
457
702
  zip,
458
703
  `#${String(originalId)}.SSE.Response.json`,
459
- remaining,
704
+ budget,
460
705
  );
461
- if (remaining.bytes < 0) return bombError(warnings);
462
- if (requestText === null && responseText === null && streamingText === null) {
706
+ if (!streamingResult.ok) return { ...streamingResult, warnings };
707
+ if (
708
+ requestResult.text === null &&
709
+ responseResult.text === null &&
710
+ streamingResult.text === null
711
+ ) {
463
712
  skipped += 1;
464
713
  continue;
465
714
  }
466
715
  logs.push(
467
716
  legacyDraftFromParts({
468
717
  exportedAt,
469
- requestText,
470
- responseText,
471
- streamingChunks: streamingChunksFromText(streamingText),
718
+ requestText: requestResult.text,
719
+ responseText: responseResult.text,
720
+ streamingChunks: streamingChunksFromText(streamingResult.text),
472
721
  }),
473
722
  );
474
723
  }
@@ -521,23 +770,25 @@ function parseTextLogImport(text: string): ParsedLogImport {
521
770
  export async function parseImportedLogFile(input: {
522
771
  fileName: string;
523
772
  data: Uint8Array;
773
+ limits?: Readonly<LogImportLimits>;
524
774
  }): Promise<ParsedLogImport> {
775
+ const limits = input.limits ?? DEFAULT_LOG_IMPORT_LIMITS;
525
776
  try {
526
777
  if (looksLikeZip(input.data, input.fileName)) {
527
- if (input.data.byteLength > MAX_ZIP_COMPRESSED_BYTES) {
778
+ if (input.data.byteLength > limits.maxCompressedBytes) {
528
779
  return {
529
780
  ok: false,
530
- message: `Archive exceeds maximum size of ${MAX_ZIP_COMPRESSED_BYTES} bytes.`,
781
+ message: `Archive exceeds maximum compressed size of ${String(limits.maxCompressedBytes)} bytes.`,
531
782
  warnings: [],
532
783
  };
533
784
  }
534
- return await parseZipLogImport(input.data);
785
+ return await parseZipLogImport(input.data, limits);
535
786
  }
536
787
 
537
- if (input.data.byteLength > MAX_ZIP_COMPRESSED_BYTES) {
788
+ if (input.data.byteLength > limits.maxCompressedBytes) {
538
789
  return {
539
790
  ok: false,
540
- message: `Import file exceeds maximum size of ${MAX_ZIP_COMPRESSED_BYTES} bytes.`,
791
+ message: `Import file exceeds maximum size of ${String(limits.maxCompressedBytes)} bytes.`,
541
792
  warnings: [],
542
793
  };
543
794
  }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, stat, readdir, mkdir } from "node:fs/promises";
1
+ import { readFile, writeFile, stat, readdir } from "node:fs/promises";
2
2
  import { createReadStream, existsSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { createInterface } from "node:readline";
@@ -14,6 +14,11 @@ import {
14
14
  syncSqliteLogIndexEntries,
15
15
  upsertSqliteLogIndexEntry,
16
16
  } from "./sqliteLogIndex";
17
+ import {
18
+ ensurePrivateDirectorySync,
19
+ PRIVATE_FILE_MODE,
20
+ securePrivateFileSync,
21
+ } from "./privateDataPath";
17
22
 
18
23
  export type LogIndexSummary = {
19
24
  id: number;
@@ -176,14 +181,19 @@ export async function saveIndex(index: LogIndex): Promise<void> {
176
181
  const indexPath = getIndexPath();
177
182
  const dir = dirname(indexPath);
178
183
 
179
- try {
180
- await mkdir(dir, { recursive: true });
181
- } catch {
182
- // Ignore
184
+ if (!ensurePrivateDirectorySync(dir)) {
185
+ logger.error("[logIndex] Failed to secure index directory:", dir);
186
+ return;
183
187
  }
184
188
 
185
189
  try {
186
- await writeFile(indexPath, JSON.stringify(index), "utf-8");
190
+ await writeFile(indexPath, JSON.stringify(index), {
191
+ encoding: "utf-8",
192
+ mode: PRIVATE_FILE_MODE,
193
+ });
194
+ if (!securePrivateFileSync(indexPath)) {
195
+ logger.error("[logIndex] Failed to secure index file:", indexPath);
196
+ }
187
197
  } catch (err) {
188
198
  logger.error("[logIndex] Failed to save index:", String(err));
189
199
  }