@tonyclaw/agent-inspector 3.0.29 → 3.0.30

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 (152) hide show
  1. package/.output/cli.js +791 -191
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/CompareDrawer-B6cXJohL.js +1 -0
  4. package/.output/public/assets/InspectorPet-mAzeGmEE.js +4108 -0
  5. package/.output/public/assets/ProxyViewerContainer-CkFWv_Nm.js +126 -0
  6. package/.output/public/assets/{ReplayDialog-CctnITbt.js → ReplayDialog-DodiTL6E.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-BrN0RBbL.js → RequestAnatomy-B3IauToI.js} +1 -1
  8. package/.output/public/assets/ResponseView-Hn-5ordK.js +3 -0
  9. package/.output/public/assets/StreamingChunkSequence-vLDOVQM_.js +1 -0
  10. package/.output/public/assets/_sessionId-BFH4TOaI.js +1 -0
  11. package/.output/public/assets/{_sessionId-DKM91udC.js → _sessionId-Cj_HIFZx.js} +1 -1
  12. package/.output/public/assets/{index-Bq-nqolG.js → index-Bmj2lcWE.js} +1 -1
  13. package/.output/public/assets/index-CgJj-HBC.css +1 -0
  14. package/.output/public/assets/index-Ch7uqnCT.js +1 -0
  15. package/.output/public/assets/index-DkDdKvLl.js +76 -0
  16. package/.output/public/assets/{index-DPiESBo2.js → index-ZlhqCrSg.js} +6 -6
  17. package/.output/public/assets/{json-viewer-CfaDJwx8.js → json-viewer-BdrnVQu-.js} +1 -1
  18. package/.output/public/assets/jszip.min-CS_nt_o1.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 +86 -86
  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-C5S3pGZd.mjs} +15 -15
  25. package/.output/server/_sessionId-g6D69lKq.mjs +81 -0
  26. package/.output/server/_ssr/{CompareDrawer-R_P1-VNr.mjs → CompareDrawer-CqUmGHal.mjs} +138 -103
  27. package/.output/server/_ssr/InspectorPet-DI0UJd01.mjs +925 -0
  28. package/.output/server/_ssr/{ProxyViewerContainer-DUgxRNbj.mjs → ProxyViewerContainer-CvsUmbJ3.mjs} +1157 -1613
  29. package/.output/server/_ssr/{ReplayDialog-De9wKKzS.mjs → ReplayDialog-DS48dpFG.mjs} +18 -18
  30. package/.output/server/_ssr/{RequestAnatomy-Db4MxUYo.mjs → RequestAnatomy-DxYVQU1C.mjs} +58 -58
  31. package/.output/server/_ssr/{ResponseView-8rDXOiNB.mjs → ResponseView-BLXW73eq.mjs} +60 -60
  32. package/.output/server/_ssr/{StreamingChunkSequence-N9teZHkA.mjs → StreamingChunkSequence-DD0iFuy0.mjs} +16 -16
  33. package/.output/server/_ssr/{index-D4DvByuW.mjs → index-B10LIaK0.mjs} +15 -15
  34. package/.output/server/_ssr/index-RHVJnU2p.mjs +81 -0
  35. package/.output/server/_ssr/index.mjs +2 -2
  36. package/.output/server/_ssr/{json-viewer-BjoHRbjS.mjs → json-viewer-LDu2XBaK.mjs} +58 -58
  37. package/.output/server/_ssr/{router-BYlePDj8.mjs → router-GKvrwifJ.mjs} +9005 -6488
  38. package/.output/server/_tanstack-start-manifest_v-Df2n4mbT.mjs +4 -0
  39. package/.output/server/index.mjs +103 -75
  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 +4 -1
  52. package/src/components/providers/ImportWizardDialog.tsx +18 -42
  53. package/src/components/providers/ProviderLogo.tsx +1 -1
  54. package/src/components/providers/ProvidersPanel.tsx +8 -5
  55. package/src/components/providers/SettingsDialog.tsx +34 -11
  56. package/src/components/proxy-viewer/ApplicationBar.tsx +63 -0
  57. package/src/components/proxy-viewer/CompareDrawer.tsx +92 -49
  58. package/src/components/proxy-viewer/LiveConnectionStatus.tsx +186 -0
  59. package/src/components/proxy-viewer/LogEntry.tsx +283 -275
  60. package/src/components/proxy-viewer/RawExportWarning.tsx +77 -0
  61. package/src/components/proxy-viewer/SessionLoadProgressBar.tsx +61 -0
  62. package/src/components/proxy-viewer/ThreadConnector.tsx +16 -1
  63. package/src/components/proxy-viewer/TurnGroup.tsx +13 -2
  64. package/src/components/proxy-viewer/accessibility.ts +8 -0
  65. package/src/components/proxy-viewer/lazy.ts +4 -0
  66. package/src/components/proxy-viewer/liveConnectionState.ts +158 -0
  67. package/src/components/proxy-viewer/proxyViewerLogic.ts +10 -0
  68. package/src/components/proxy-viewer/viewerState.ts +2 -6
  69. package/src/components/ui/select.tsx +1 -1
  70. package/src/components/ui/tabs.tsx +3 -3
  71. package/src/components/ui/transient-toast.tsx +1 -1
  72. package/src/lib/apiClient.ts +17 -2
  73. package/src/lib/ecosystemContract.ts +34 -0
  74. package/src/lib/export-logs.ts +1 -1
  75. package/src/lib/providerContract.ts +70 -4
  76. package/src/lib/providerImportContract.ts +27 -0
  77. package/src/lib/providerModelMetadata.ts +16 -7
  78. package/src/lib/resourceLimits.ts +152 -0
  79. package/src/lib/safeDiagnostic.ts +38 -0
  80. package/src/lib/useProviders.ts +4 -4
  81. package/src/mcp/server.ts +39 -6
  82. package/src/mcp/toolHandlers.ts +131 -4
  83. package/src/proxy/chunkStorage.ts +20 -6
  84. package/src/proxy/config.ts +20 -6
  85. package/src/proxy/dataDir.ts +3 -0
  86. package/src/proxy/ecosystemExecutionConfirmation.ts +114 -0
  87. package/src/proxy/ecosystemExecutionRoute.ts +116 -0
  88. package/src/proxy/ecosystemTasks.ts +48 -0
  89. package/src/proxy/evidenceExporter.ts +23 -9
  90. package/src/proxy/groupEvidenceExporter.ts +26 -5
  91. package/src/proxy/groupStore.ts +13 -3
  92. package/src/proxy/handler.ts +103 -20
  93. package/src/proxy/identityProxy.ts +333 -14
  94. package/src/proxy/logFinalizer.ts +73 -6
  95. package/src/proxy/logImportUpload.ts +128 -0
  96. package/src/proxy/logImporter.ts +321 -70
  97. package/src/proxy/logIndex.ts +16 -6
  98. package/src/proxy/logger.ts +294 -41
  99. package/src/proxy/privateDataPath.ts +183 -0
  100. package/src/proxy/providerScanStore.ts +87 -0
  101. package/src/proxy/providerSecretStore.ts +58 -19
  102. package/src/proxy/providers.ts +175 -58
  103. package/src/proxy/rawStreamCapture.ts +66 -5
  104. package/src/proxy/runStore.ts +13 -3
  105. package/src/proxy/runtimeAdmission.ts +52 -0
  106. package/src/proxy/runtimeHealth.ts +206 -0
  107. package/src/proxy/runtimeShutdown.ts +75 -0
  108. package/src/proxy/sessionArchive.ts +15 -2
  109. package/src/proxy/sessionProcess.ts +19 -0
  110. package/src/proxy/sessionRuntime.ts +7 -0
  111. package/src/proxy/shutdownCoordinator.ts +90 -0
  112. package/src/proxy/sqliteLogIndex.ts +18 -2
  113. package/src/proxy/store.ts +18 -2
  114. package/src/routes/__root.tsx +18 -1
  115. package/src/routes/api/alerts.summary.ts +12 -8
  116. package/src/routes/api/alerts.ts +27 -9
  117. package/src/routes/api/ecosystem.packages.$packageId.help.ts +3 -8
  118. package/src/routes/api/ecosystem.packages.$packageId.install.ts +3 -8
  119. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +3 -11
  120. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +3 -8
  121. package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +3 -8
  122. package/src/routes/api/health.ts +9 -2
  123. package/src/routes/api/knowledge.project-context.ts +11 -0
  124. package/src/routes/api/knowledge.search.ts +33 -2
  125. package/src/routes/api/logs.$id.body.ts +16 -2
  126. package/src/routes/api/logs.import.ts +7 -18
  127. package/src/routes/api/logs.stream.ts +147 -69
  128. package/src/routes/api/logs.ts +55 -14
  129. package/src/routes/api/providers.$providerId.model-metadata.ts +22 -4
  130. package/src/routes/api/providers.$providerId.ts +37 -6
  131. package/src/routes/api/providers.export.ts +34 -16
  132. package/src/routes/api/providers.import.ts +42 -8
  133. package/src/routes/api/providers.scan.ts +13 -8
  134. package/src/routes/api/providers.ts +36 -5
  135. package/src/routes/api/runs.ts +12 -12
  136. package/src/routes/api/sessions.ts +15 -8
  137. package/src/routes/index.tsx +6 -0
  138. package/src/routes/livez.ts +13 -0
  139. package/src/routes/readyz.ts +18 -0
  140. package/src/routes/session/$sessionId.tsx +6 -0
  141. package/styles/globals.css +19 -3
  142. package/.output/public/assets/CompareDrawer-Cb5ccPeX.js +0 -1
  143. package/.output/public/assets/ProxyViewerContainer-COBe5GTT.js +0 -4234
  144. package/.output/public/assets/ResponseView-Cqq87lX_.js +0 -3
  145. package/.output/public/assets/StreamingChunkSequence-CnTa9mCr.js +0 -1
  146. package/.output/public/assets/index-DTLNVcgc.js +0 -76
  147. package/.output/public/assets/index-DliqmwUw.css +0 -1
  148. package/.output/public/assets/qwen-CONDcHqt.png +0 -0
  149. package/.output/server/_tanstack-start-manifest_v-DMCA8KK3.mjs +0 -4
  150. package/src/assets/logos/mcp.png +0 -0
  151. package/src/assets/logos/qwen.png +0 -0
  152. package/src/components/ui/mcp-logo.tsx +0 -20
@@ -36,8 +36,20 @@ import {
36
36
  } from "../lib/groupContract";
37
37
  import { type CreateInspectorRunInput, type UpdateInspectorRunInput } from "../lib/runContract";
38
38
  import { SessionInfoSchema } from "../lib/sessionInfoContract";
39
+ import {
40
+ ProviderConfigSchema,
41
+ toRedactedProviderDto,
42
+ toRedactedProviderDtos,
43
+ } from "../lib/providerContract";
39
44
  import { isRecord, textByteLength } from "../lib/unknown";
40
45
  import { CapturedLogSchema, type CapturedLog } from "../contracts";
46
+ import {
47
+ createResourceLimitError,
48
+ MAX_PROVIDER_MODELS,
49
+ MAX_SEARCH_PROJECT_CHARS,
50
+ MAX_SEARCH_QUERY_CHARS,
51
+ type ResourceLimitErrorInput,
52
+ } from "../lib/resourceLimits";
41
53
  import {
42
54
  addInspectorGroupSession,
43
55
  createInspectorGroup,
@@ -109,6 +121,22 @@ function toolError(message: string): ToolResult {
109
121
  return { content: [{ type: "text", text: message }], isError: true };
110
122
  }
111
123
 
124
+ function resourceLimitToolError(input: ResourceLimitErrorInput): ToolResult {
125
+ return toolError(JSON.stringify(createResourceLimitError(input), null, 2));
126
+ }
127
+
128
+ function redactedProviderResult(value: unknown): ToolResult {
129
+ const parsed = ProviderConfigSchema.safeParse(value);
130
+ if (!parsed.success) return toolError("Provider API returned an invalid provider response");
131
+ return textJson(toRedactedProviderDto(parsed.data));
132
+ }
133
+
134
+ function redactedProvidersResult(value: unknown): ToolResult {
135
+ const parsed = ProviderConfigSchema.array().safeParse(value);
136
+ if (!parsed.success) return toolError("Provider API returned an invalid providers response");
137
+ return textJson(toRedactedProviderDtos(parsed.data));
138
+ }
139
+
112
140
  function inspectorBaseUrl(): string {
113
141
  return `http://127.0.0.1:${String(getCurrentPort())}`;
114
142
  }
@@ -327,6 +355,19 @@ export type ListLogsArgs = {
327
355
  };
328
356
 
329
357
  export async function listLogsImpl(callApi: CallApiFn, args: ListLogsArgs): Promise<ToolResult> {
358
+ if (
359
+ args.limit !== undefined &&
360
+ (!Number.isSafeInteger(args.limit) || args.limit < 1 || args.limit > LIMIT_HARD_CAP)
361
+ ) {
362
+ return resourceLimitToolError({
363
+ error: `MCP log pages may contain at most ${String(LIMIT_HARD_CAP)} entries.`,
364
+ code: "invalid-page-request",
365
+ status: 400,
366
+ resource: "mcp-log-page-size",
367
+ limit: LIMIT_HARD_CAP,
368
+ retryable: false,
369
+ });
370
+ }
330
371
  const offset = args.offset ?? PAGINATION_DEFAULTS.offset;
331
372
  const limit = clampLimit(args.limit);
332
373
  const params = new URLSearchParams({
@@ -366,6 +407,42 @@ export async function searchLogsImpl(
366
407
  callApi: CallApiFn,
367
408
  args: SearchLogsArgs,
368
409
  ): Promise<ToolResult> {
410
+ if ((args.query ?? "").length > MAX_SEARCH_QUERY_CHARS) {
411
+ return resourceLimitToolError({
412
+ error: `MCP search queries may contain at most ${String(MAX_SEARCH_QUERY_CHARS)} characters.`,
413
+ code: "search-limit-exceeded",
414
+ status: 400,
415
+ resource: "mcp-log-search-query",
416
+ limit: MAX_SEARCH_QUERY_CHARS,
417
+ retryable: false,
418
+ });
419
+ }
420
+ if (
421
+ args.limit !== undefined &&
422
+ (!Number.isSafeInteger(args.limit) || args.limit < 1 || args.limit > LIMIT_HARD_CAP)
423
+ ) {
424
+ return resourceLimitToolError({
425
+ error: `MCP search pages may contain at most ${String(LIMIT_HARD_CAP)} entries.`,
426
+ code: "invalid-page-request",
427
+ status: 400,
428
+ resource: "mcp-log-search-page-size",
429
+ limit: LIMIT_HARD_CAP,
430
+ retryable: false,
431
+ });
432
+ }
433
+ if (
434
+ args.scanLimit !== undefined &&
435
+ (!Number.isSafeInteger(args.scanLimit) || args.scanLimit < 1 || args.scanLimit > 1_000)
436
+ ) {
437
+ return resourceLimitToolError({
438
+ error: "MCP log searches may scan at most 1000 entries.",
439
+ code: "search-limit-exceeded",
440
+ status: 400,
441
+ resource: "mcp-log-search-scan-size",
442
+ limit: 1_000,
443
+ retryable: false,
444
+ });
445
+ }
369
446
  const offset = args.offset ?? PAGINATION_DEFAULTS.offset;
370
447
  const limit = clampLimit(args.limit);
371
448
  const params = new URLSearchParams({
@@ -1060,13 +1137,13 @@ export async function listModelsImpl(callApi: CallApiFn): Promise<ToolResult> {
1060
1137
  export async function listProvidersImpl(callApi: CallApiFn): Promise<ToolResult> {
1061
1138
  const res = await callApi("/api/providers");
1062
1139
  if (!res.ok) return toolError(`GET /api/providers returned ${res.status}`);
1063
- return textJson(await res.json());
1140
+ return redactedProvidersResult(await res.json());
1064
1141
  }
1065
1142
 
1066
1143
  export async function getProviderImpl(callApi: CallApiFn, id: string): Promise<ToolResult> {
1067
1144
  const res = await callApi(`/api/providers/${encodeURIComponent(id)}`);
1068
1145
  if (!res.ok) return toolError(`GET /api/providers/${id} returned ${res.status}`);
1069
- return textJson(await res.json());
1146
+ return redactedProviderResult(await res.json());
1070
1147
  }
1071
1148
 
1072
1149
  export type ReplayLogArgs = { id: number };
@@ -1118,13 +1195,23 @@ export async function addProviderImpl(
1118
1195
  provider: AddProviderInput,
1119
1196
  ): Promise<ToolResult> {
1120
1197
  const models = normalizedProviderModels(provider);
1198
+ if (models.length > MAX_PROVIDER_MODELS) {
1199
+ return resourceLimitToolError({
1200
+ error: `Provider model lists may contain at most ${String(MAX_PROVIDER_MODELS)} entries.`,
1201
+ code: "provider-model-limit-exceeded",
1202
+ status: 413,
1203
+ resource: "provider-models",
1204
+ limit: MAX_PROVIDER_MODELS,
1205
+ retryable: false,
1206
+ });
1207
+ }
1121
1208
  const res = await callApi("/api/providers", {
1122
1209
  method: "POST",
1123
1210
  headers: { "content-type": "application/json" },
1124
1211
  body: JSON.stringify({ ...provider, models }),
1125
1212
  });
1126
1213
  if (!res.ok) return toolError(`POST /api/providers returned ${res.status}`);
1127
- return textJson(await res.json());
1214
+ return redactedProviderResult(await res.json());
1128
1215
  }
1129
1216
 
1130
1217
  export type UpdateProviderInput = {
@@ -1151,6 +1238,16 @@ export async function updateProviderImpl(
1151
1238
  patch.models !== undefined || patch.model !== undefined
1152
1239
  ? normalizedProviderModels(patch)
1153
1240
  : undefined;
1241
+ if (models !== undefined && models.length > MAX_PROVIDER_MODELS) {
1242
+ return resourceLimitToolError({
1243
+ error: `Provider model lists may contain at most ${String(MAX_PROVIDER_MODELS)} entries.`,
1244
+ code: "provider-model-limit-exceeded",
1245
+ status: 413,
1246
+ resource: "provider-models",
1247
+ limit: MAX_PROVIDER_MODELS,
1248
+ retryable: false,
1249
+ });
1250
+ }
1154
1251
  const body = models === undefined ? patch : { ...patch, models };
1155
1252
  const res = await callApi(`/api/providers/${encodeURIComponent(id)}`, {
1156
1253
  method: "PUT",
@@ -1158,7 +1255,7 @@ export async function updateProviderImpl(
1158
1255
  body: JSON.stringify(body),
1159
1256
  });
1160
1257
  if (!res.ok) return toolError(`PUT /api/providers/${id} returned ${res.status}`);
1161
- return textJson(await res.json());
1258
+ return redactedProviderResult(await res.json());
1162
1259
  }
1163
1260
 
1164
1261
  export async function testProviderImpl(callApi: CallApiFn, id: string): Promise<ToolResult> {
@@ -1365,6 +1462,26 @@ export async function searchKnowledgeImpl(
1365
1462
  callApi: CallApiFn,
1366
1463
  args: SearchKnowledgeArgs,
1367
1464
  ): Promise<ToolResult> {
1465
+ if (args.query.length > MAX_SEARCH_QUERY_CHARS) {
1466
+ return resourceLimitToolError({
1467
+ error: `MCP search queries may contain at most ${String(MAX_SEARCH_QUERY_CHARS)} characters.`,
1468
+ code: "search-limit-exceeded",
1469
+ status: 400,
1470
+ resource: "mcp-knowledge-search-query",
1471
+ limit: MAX_SEARCH_QUERY_CHARS,
1472
+ retryable: false,
1473
+ });
1474
+ }
1475
+ if (args.project !== undefined && args.project.length > MAX_SEARCH_PROJECT_CHARS) {
1476
+ return resourceLimitToolError({
1477
+ error: `MCP search project filters may contain at most ${String(MAX_SEARCH_PROJECT_CHARS)} characters.`,
1478
+ code: "search-limit-exceeded",
1479
+ status: 400,
1480
+ resource: "mcp-knowledge-search-project",
1481
+ limit: MAX_SEARCH_PROJECT_CHARS,
1482
+ retryable: false,
1483
+ });
1484
+ }
1368
1485
  const params = new URLSearchParams({ q: args.query });
1369
1486
  if (args.project !== undefined && args.project.length > 0) {
1370
1487
  params.set("project", args.project);
@@ -1382,6 +1499,16 @@ export async function getProjectContextImpl(
1382
1499
  callApi: CallApiFn,
1383
1500
  args: GetProjectContextArgs,
1384
1501
  ): Promise<ToolResult> {
1502
+ if (args.project.length > MAX_SEARCH_PROJECT_CHARS) {
1503
+ return resourceLimitToolError({
1504
+ error: `MCP search project filters may contain at most ${String(MAX_SEARCH_PROJECT_CHARS)} characters.`,
1505
+ code: "search-limit-exceeded",
1506
+ status: 400,
1507
+ resource: "mcp-knowledge-search-project",
1508
+ limit: MAX_SEARCH_PROJECT_CHARS,
1509
+ retryable: false,
1510
+ });
1511
+ }
1385
1512
  const params = new URLSearchParams({ project: args.project });
1386
1513
  const res = await callApi(`/api/knowledge/project-context?${params.toString()}`);
1387
1514
  if (!res.ok) return toolError(`GET /api/knowledge/project-context returned ${res.status}`);
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  writeFileSync,
3
- mkdirSync,
4
3
  readFileSync,
5
4
  unlinkSync,
6
5
  existsSync,
@@ -12,6 +11,11 @@ import { logger } from "./logger";
12
11
  import { z } from "zod";
13
12
  import { JsonValueSchema, type StreamingChunk } from "./schemas";
14
13
  import { getDataDir } from "./dataDir";
14
+ import {
15
+ ensurePrivateDirectorySync,
16
+ PRIVATE_FILE_MODE,
17
+ securePrivateFileSync,
18
+ } from "./privateDataPath";
15
19
 
16
20
  export type StreamingChunksData = {
17
21
  chunks: StreamingChunk[];
@@ -56,17 +60,24 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
56
60
  const tempPath = getTempFilePath(logId);
57
61
 
58
62
  // Ensure directory exists
59
- try {
60
- mkdirSync(dir, { recursive: true });
61
- } catch (err) {
62
- logger.error("[chunkStorage] Failed to create chunks directory:", String(err));
63
+ if (!ensurePrivateDirectorySync(dir)) {
64
+ logger.error("[chunkStorage] Failed to secure chunks directory:", dir);
65
+ return targetPath;
63
66
  }
64
67
 
65
68
  const data: StreamingChunksData = { chunks, truncated };
66
69
 
67
70
  // Write to temp file first
68
71
  try {
69
- writeFileSync(tempPath, JSON.stringify(data), "utf-8");
72
+ writeFileSync(tempPath, JSON.stringify(data), {
73
+ encoding: "utf-8",
74
+ mode: PRIVATE_FILE_MODE,
75
+ });
76
+ if (!securePrivateFileSync(tempPath)) {
77
+ unlinkSync(tempPath);
78
+ logger.error("[chunkStorage] Failed to secure chunks temp file:", tempPath);
79
+ return targetPath;
80
+ }
70
81
  } catch (err) {
71
82
  logger.error("[chunkStorage] Failed to write chunks temp file:", String(err));
72
83
  return targetPath;
@@ -80,6 +91,9 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
80
91
  // If rename fails, try copy + delete
81
92
  try {
82
93
  copyFileSync(tempPath, targetPath);
94
+ if (!securePrivateFileSync(targetPath)) {
95
+ logger.error("[chunkStorage] Failed to secure chunks file:", targetPath);
96
+ }
83
97
  unlinkSync(tempPath);
84
98
  } catch (copyErr) {
85
99
  logger.error("[chunkStorage] Failed to copy chunks file:", String(copyErr));
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  writeFileSync,
3
- mkdirSync,
4
3
  readFileSync,
5
4
  unlinkSync,
6
5
  existsSync,
@@ -10,6 +9,11 @@ import {
10
9
  import { join } from "node:path";
11
10
  import { logger } from "./logger";
12
11
  import { getDataDir } from "./dataDir";
12
+ import {
13
+ ensurePrivateDirectorySync,
14
+ PRIVATE_FILE_MODE,
15
+ securePrivateFileSync,
16
+ } from "./privateDataPath";
13
17
  import {
14
18
  CaptureModeSchema,
15
19
  DEFAULT_CAPTURE_MODE,
@@ -168,15 +172,21 @@ function persistConfig(value: RuntimeConfig): boolean {
168
172
  const targetPath = getConfigFilePath();
169
173
  const tempPath = getConfigTempPath();
170
174
 
171
- try {
172
- mkdirSync(dir, { recursive: true });
173
- } catch (err) {
174
- logger.error(`[config] Failed to create data directory ${dir}: ${String(err)}`);
175
+ if (!ensurePrivateDirectorySync(dir)) {
176
+ logger.error(`[config] Failed to secure data directory ${dir}`);
175
177
  return false;
176
178
  }
177
179
 
178
180
  try {
179
- writeFileSync(tempPath, JSON.stringify(toPersistedConfig(value)), "utf-8");
181
+ writeFileSync(tempPath, JSON.stringify(toPersistedConfig(value)), {
182
+ encoding: "utf-8",
183
+ mode: PRIVATE_FILE_MODE,
184
+ });
185
+ if (!securePrivateFileSync(tempPath)) {
186
+ unlinkSync(tempPath);
187
+ logger.error(`[config] Failed to secure config temp file ${tempPath}`);
188
+ return false;
189
+ }
180
190
  } catch (err) {
181
191
  logger.error(`[config] Failed to write config temp file: ${String(err)}`);
182
192
  return false;
@@ -189,6 +199,10 @@ function persistConfig(value: RuntimeConfig): boolean {
189
199
  logger.warn(`[config] Rename failed, falling back to copy+delete: ${String(err)}`);
190
200
  try {
191
201
  copyFileSync(tempPath, targetPath);
202
+ if (!securePrivateFileSync(targetPath)) {
203
+ logger.error(`[config] Failed to secure config file ${targetPath}`);
204
+ return false;
205
+ }
192
206
  unlinkSync(tempPath);
193
207
  return true;
194
208
  } catch (copyErr) {
@@ -1,5 +1,6 @@
1
1
  import { cpSync, existsSync, rmSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
+ import { ensurePrivateDirectorySync, securePrivateTreeSync } from "./privateDataPath";
3
4
 
4
5
  const CURRENT_DATA_DIR_NAME = ".agent-inspector";
5
6
  const LEGACY_DATA_DIR_NAMES = [".llm-inspector", ".llm-agent"];
@@ -98,7 +99,9 @@ export function migrateLegacyDataDirs(): void {
98
99
  if (!existsSync(legacyDir)) continue;
99
100
 
100
101
  try {
102
+ if (!ensurePrivateDirectorySync(currentDir)) return;
101
103
  cpSync(legacyDir, currentDir, { recursive: true, force: true });
104
+ if (!securePrivateTreeSync(currentDir)) return;
102
105
  rmSync(legacyDir, { recursive: true, force: true });
103
106
  } catch {
104
107
  // Best-effort migration: startup must continue even when the old
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ ECOSYSTEM_EXECUTION_CONFIRMATION_PHRASE,
4
+ type EcosystemExecutionConfirmation,
5
+ type EcosystemExecutionPlan,
6
+ type EcosystemTaskAction,
7
+ } from "../lib/ecosystemContract";
8
+ import { logger } from "./logger";
9
+
10
+ const CONFIRMATION_TTL_MS = 2 * 60 * 1000;
11
+ const MAX_PENDING_CONFIRMATIONS = 100;
12
+
13
+ type ConfirmationRecord = {
14
+ confirmation: EcosystemExecutionConfirmation;
15
+ expiresAtMs: number;
16
+ };
17
+
18
+ type ConfirmationFailureReason =
19
+ | "missing_or_invalid_confirmation"
20
+ | "expired_confirmation"
21
+ | "mismatched_confirmation";
22
+
23
+ type ConsumeEcosystemConfirmationResult =
24
+ | { ok: true; plan: EcosystemExecutionPlan }
25
+ | { ok: false; reason: ConfirmationFailureReason };
26
+
27
+ type EcosystemAuditEvent = "confirmation_issued" | "confirmation_rejected" | "execution_started";
28
+ type EcosystemAuditReason =
29
+ | "none"
30
+ | "invalid_request"
31
+ | "unknown_target"
32
+ | "missing_or_invalid_confirmation"
33
+ | "expired_confirmation"
34
+ | "mismatched_confirmation"
35
+ | "task_unavailable";
36
+
37
+ const confirmations = new Map<string, ConfirmationRecord>();
38
+
39
+ function pruneConfirmations(nowMs: number): void {
40
+ for (const [nonce, record] of confirmations) {
41
+ if (record.expiresAtMs <= nowMs) confirmations.delete(nonce);
42
+ }
43
+ while (confirmations.size >= MAX_PENDING_CONFIRMATIONS) {
44
+ const oldestNonce = confirmations.keys().next().value;
45
+ if (typeof oldestNonce !== "string") return;
46
+ confirmations.delete(oldestNonce);
47
+ }
48
+ }
49
+
50
+ function safeAuditTarget(targetId: string | null): string {
51
+ if (targetId === null || !/^[a-z0-9-]{1,64}$/.test(targetId)) return "unrecognized";
52
+ return targetId;
53
+ }
54
+
55
+ export function auditEcosystemExecution(
56
+ event: EcosystemAuditEvent,
57
+ action: EcosystemTaskAction,
58
+ targetId: string | null,
59
+ reason: EcosystemAuditReason,
60
+ ): void {
61
+ logger.info(
62
+ `[security-audit] event=${event} action=${action} target=${safeAuditTarget(targetId)} reason=${reason}`,
63
+ );
64
+ }
65
+
66
+ export function createEcosystemExecutionConfirmation(
67
+ plan: EcosystemExecutionPlan,
68
+ nowMs = Date.now(),
69
+ ): EcosystemExecutionConfirmation {
70
+ pruneConfirmations(nowMs);
71
+ const expiresAtMs = nowMs + CONFIRMATION_TTL_MS;
72
+ const confirmation: EcosystemExecutionConfirmation = {
73
+ nonce: randomUUID(),
74
+ phrase: ECOSYSTEM_EXECUTION_CONFIRMATION_PHRASE,
75
+ expiresAt: new Date(expiresAtMs).toISOString(),
76
+ plan,
77
+ };
78
+ confirmations.set(confirmation.nonce, { confirmation, expiresAtMs });
79
+ return confirmation;
80
+ }
81
+
82
+ export function consumeEcosystemExecutionConfirmation(
83
+ nonce: string,
84
+ phrase: string,
85
+ expectedPlan: EcosystemExecutionPlan,
86
+ nowMs = Date.now(),
87
+ ): ConsumeEcosystemConfirmationResult {
88
+ const record = confirmations.get(nonce);
89
+ if (record === undefined) {
90
+ return { ok: false, reason: "missing_or_invalid_confirmation" };
91
+ }
92
+
93
+ confirmations.delete(nonce);
94
+ if (record.expiresAtMs <= nowMs) return { ok: false, reason: "expired_confirmation" };
95
+ const reviewedPlan = record.confirmation.plan;
96
+ if (
97
+ phrase !== ECOSYSTEM_EXECUTION_CONFIRMATION_PHRASE ||
98
+ reviewedPlan.action !== expectedPlan.action ||
99
+ reviewedPlan.targetId !== expectedPlan.targetId ||
100
+ reviewedPlan.targetName !== expectedPlan.targetName ||
101
+ reviewedPlan.command !== expectedPlan.command ||
102
+ reviewedPlan.source !== expectedPlan.source ||
103
+ reviewedPlan.timeoutMs !== expectedPlan.timeoutMs ||
104
+ reviewedPlan.scope !== expectedPlan.scope
105
+ ) {
106
+ return { ok: false, reason: "mismatched_confirmation" };
107
+ }
108
+
109
+ return { ok: true, plan: reviewedPlan };
110
+ }
111
+
112
+ export function resetEcosystemExecutionConfirmationsForTests(): void {
113
+ confirmations.clear();
114
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ EcosystemExecutionRequestSchema,
3
+ type EcosystemTask,
4
+ type EcosystemTaskAction,
5
+ } from "../lib/ecosystemContract";
6
+ import {
7
+ auditEcosystemExecution,
8
+ consumeEcosystemExecutionConfirmation,
9
+ createEcosystemExecutionConfirmation,
10
+ } from "./ecosystemExecutionConfirmation";
11
+ import {
12
+ getEcosystemPackageExecutionPlan,
13
+ getEcosystemRecipeExecutionPlan,
14
+ startEcosystemRecipeTask,
15
+ startEcosystemTask,
16
+ } from "./ecosystemTasks";
17
+
18
+ const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
19
+
20
+ type StartPackageTask = (packageId: string, action: EcosystemTaskAction) => EcosystemTask | null;
21
+ type StartRecipeTask = (recipeId: string) => EcosystemTask | null;
22
+
23
+ function noStoreJson(body: unknown, status = 200): Response {
24
+ return Response.json(body, { status, headers: NO_STORE_HEADERS });
25
+ }
26
+
27
+ async function readExecutionRequest(request: Request) {
28
+ const body: unknown = await request.json().catch(() => null);
29
+ return EcosystemExecutionRequestSchema.safeParse(body);
30
+ }
31
+
32
+ export async function handleEcosystemPackageExecution(
33
+ request: Request,
34
+ packageId: string,
35
+ action: Exclude<EcosystemTaskAction, "recipe">,
36
+ startTask: StartPackageTask = startEcosystemTask,
37
+ ): Promise<Response> {
38
+ const parsed = await readExecutionRequest(request);
39
+ if (!parsed.success) {
40
+ auditEcosystemExecution("confirmation_rejected", action, null, "invalid_request");
41
+ return noStoreJson({ error: "Explicit local command confirmation is required" }, 400);
42
+ }
43
+
44
+ const plan = getEcosystemPackageExecutionPlan(packageId, action);
45
+ if (plan === null) {
46
+ auditEcosystemExecution("confirmation_rejected", action, null, "unknown_target");
47
+ return noStoreJson({ error: "Unknown or unavailable ecosystem action" }, 404);
48
+ }
49
+
50
+ if (parsed.data.intent === "prepare") {
51
+ const confirmation = createEcosystemExecutionConfirmation(plan);
52
+ auditEcosystemExecution("confirmation_issued", action, plan.targetId, "none");
53
+ return noStoreJson({ confirmation });
54
+ }
55
+
56
+ const consumed = consumeEcosystemExecutionConfirmation(
57
+ parsed.data.nonce,
58
+ parsed.data.confirmation,
59
+ plan,
60
+ );
61
+ if (!consumed.ok) {
62
+ auditEcosystemExecution("confirmation_rejected", action, plan.targetId, consumed.reason);
63
+ return noStoreJson({ error: "Confirmation is invalid, expired, or already used" }, 409);
64
+ }
65
+
66
+ const task = startTask(packageId, action);
67
+ if (task === null) {
68
+ auditEcosystemExecution("confirmation_rejected", action, plan.targetId, "task_unavailable");
69
+ return noStoreJson({ error: "Ecosystem action is no longer available" }, 409);
70
+ }
71
+ auditEcosystemExecution("execution_started", action, plan.targetId, "none");
72
+ return noStoreJson({ task });
73
+ }
74
+
75
+ export async function handleEcosystemRecipeExecution(
76
+ request: Request,
77
+ recipeId: string,
78
+ startTask: StartRecipeTask = startEcosystemRecipeTask,
79
+ ): Promise<Response> {
80
+ const action = "recipe";
81
+ const parsed = await readExecutionRequest(request);
82
+ if (!parsed.success) {
83
+ auditEcosystemExecution("confirmation_rejected", action, null, "invalid_request");
84
+ return noStoreJson({ error: "Explicit local command confirmation is required" }, 400);
85
+ }
86
+
87
+ const plan = getEcosystemRecipeExecutionPlan(recipeId);
88
+ if (plan === null) {
89
+ auditEcosystemExecution("confirmation_rejected", action, null, "unknown_target");
90
+ return noStoreJson({ error: "Unknown or unavailable ecosystem recipe" }, 404);
91
+ }
92
+
93
+ if (parsed.data.intent === "prepare") {
94
+ const confirmation = createEcosystemExecutionConfirmation(plan);
95
+ auditEcosystemExecution("confirmation_issued", action, plan.targetId, "none");
96
+ return noStoreJson({ confirmation });
97
+ }
98
+
99
+ const consumed = consumeEcosystemExecutionConfirmation(
100
+ parsed.data.nonce,
101
+ parsed.data.confirmation,
102
+ plan,
103
+ );
104
+ if (!consumed.ok) {
105
+ auditEcosystemExecution("confirmation_rejected", action, plan.targetId, consumed.reason);
106
+ return noStoreJson({ error: "Confirmation is invalid, expired, or already used" }, 409);
107
+ }
108
+
109
+ const task = startTask(recipeId);
110
+ if (task === null) {
111
+ auditEcosystemExecution("confirmation_rejected", action, plan.targetId, "task_unavailable");
112
+ return noStoreJson({ error: "Ecosystem recipe is no longer available" }, 409);
113
+ }
114
+ auditEcosystemExecution("execution_started", action, plan.targetId, "none");
115
+ return noStoreJson({ task });
116
+ }
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type {
5
+ EcosystemExecutionPlan,
5
6
  EcosystemTask,
6
7
  EcosystemTaskAction,
7
8
  EcosystemTaskStatus,
@@ -93,6 +94,52 @@ function taskCommand(args: readonly string[]): string {
93
94
  return `${npmCommand()} ${args.join(" ")}`;
94
95
  }
95
96
 
97
+ export function getEcosystemPackageExecutionPlan(
98
+ packageId: string,
99
+ action: EcosystemTaskAction,
100
+ ): EcosystemExecutionPlan | null {
101
+ if (action === "recipe") return null;
102
+ const definition =
103
+ action === "runner-presets"
104
+ ? findEcosystemPackage("eval-harness")
105
+ : findEcosystemPackage(packageId);
106
+ if (definition === null) return null;
107
+ if (action === "runner-presets" && packageId !== "eval-harness") return null;
108
+
109
+ const args = packageActionArgs(action, definition.npmName);
110
+ const mutatesGlobalPackages = action === "install" || action === "upgrade";
111
+ return {
112
+ action,
113
+ targetId: definition.id,
114
+ targetName: definition.name,
115
+ command: taskCommand(args),
116
+ source: `npm registry: ${definition.npmName}`,
117
+ timeoutMs: packageActionTimeout(action),
118
+ scope: mutatesGlobalPackages
119
+ ? "Global npm package installation"
120
+ : "Ephemeral npm exec process in the Inspector service account",
121
+ };
122
+ }
123
+
124
+ export function getEcosystemRecipeExecutionPlan(recipeId: string): EcosystemExecutionPlan | null {
125
+ const recipe = findEcosystemRecipe(recipeId);
126
+ if (recipe === null || !recipe.runnable) return null;
127
+ const definition = findEcosystemPackage(recipe.packageId);
128
+ if (definition === null) return null;
129
+ const args = recipeActionArgs(recipe.id);
130
+ if (args === null) return null;
131
+
132
+ return {
133
+ action: "recipe",
134
+ targetId: recipe.id,
135
+ targetName: recipe.title,
136
+ command: taskCommand(args),
137
+ source: `Built-in allowlisted recipe using ${definition.npmName}`,
138
+ timeoutMs: RECIPE_TIMEOUT_MS,
139
+ scope: "Ephemeral npm exec process in the Inspector service account",
140
+ };
141
+ }
142
+
96
143
  function completeTask(
97
144
  task: EcosystemTask,
98
145
  status: Exclude<EcosystemTaskStatus, "queued" | "running">,
@@ -117,6 +164,7 @@ export function startEcosystemTask(
117
164
  packageId: string,
118
165
  action: EcosystemTaskAction,
119
166
  ): EcosystemTask | null {
167
+ if (action === "recipe") return null;
120
168
  const definition =
121
169
  action === "runner-presets"
122
170
  ? findEcosystemPackage("eval-harness")