@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
@@ -1,9 +1,31 @@
1
- import { readdir, stat, unlink, appendFile, mkdir } from "node:fs/promises";
2
- import { writeFileSync, mkdirSync } from "node:fs";
1
+ import { Buffer } from "node:buffer";
2
+ import { appendFileSync } from "node:fs";
3
+ import { appendFile, readdir, rename, stat, unlink } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { getDataDir } from "./dataDir";
6
+ import {
7
+ ensurePrivateDirectorySync,
8
+ PRIVATE_FILE_MODE,
9
+ securePrivateFileSync,
10
+ securePrivateTreeSync,
11
+ } from "./privateDataPath";
5
12
 
6
- const RETENTION_DAYS = Number(process.env["LOG_RETENTION_DAYS"] ?? "7");
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ const DEFAULT_RETENTION_DAYS = 7;
15
+ const DEFAULT_APP_LOG_MAX_BYTES = 10 * 1024 * 1024;
16
+ const DEFAULT_APP_LOG_MAX_FILES = 5;
17
+
18
+ type ApplicationLogPolicy = {
19
+ maxBytes: number;
20
+ maxFiles: number;
21
+ retentionMs: number;
22
+ };
23
+
24
+ type ApplicationLogFile = {
25
+ generation: number;
26
+ modifiedAt: number;
27
+ path: string;
28
+ };
7
29
 
8
30
  function writeStderrFallback(message: string, error: unknown): void {
9
31
  process.stderr.write(`${message} ${String(error)}\n`);
@@ -33,54 +55,283 @@ function getInspectorLogPath(): string {
33
55
  return path.join(getDataDir(), "logs", "inspector.log");
34
56
  }
35
57
 
58
+ function readBoundedInteger(
59
+ name: string,
60
+ fallback: number,
61
+ minimum: number,
62
+ maximum: number,
63
+ ): number {
64
+ const raw = process.env[name];
65
+ if (raw === undefined || raw === "") return fallback;
66
+ const parsed = Number(raw);
67
+ if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
68
+ return parsed;
69
+ }
70
+
71
+ function getApplicationLogPolicy(): ApplicationLogPolicy {
72
+ const retentionDays = readBoundedInteger("LOG_RETENTION_DAYS", DEFAULT_RETENTION_DAYS, 1, 3650);
73
+ return {
74
+ maxBytes: readBoundedInteger(
75
+ "AGENT_INSPECTOR_APP_LOG_MAX_BYTES",
76
+ DEFAULT_APP_LOG_MAX_BYTES,
77
+ 1,
78
+ 1024 * 1024 * 1024,
79
+ ),
80
+ maxFiles: readBoundedInteger(
81
+ "AGENT_INSPECTOR_APP_LOG_MAX_FILES",
82
+ DEFAULT_APP_LOG_MAX_FILES,
83
+ 1,
84
+ 100,
85
+ ),
86
+ retentionMs: retentionDays * DAY_MS,
87
+ };
88
+ }
89
+
90
+ function getRotatedGeneration(entry: string, baseName: string): number | null {
91
+ const prefix = `${baseName}.`;
92
+ if (!entry.startsWith(prefix)) return null;
93
+ const suffix = entry.slice(prefix.length);
94
+ if (!/^\d+$/.test(suffix)) return null;
95
+ const generation = Number(suffix);
96
+ if (!Number.isSafeInteger(generation) || generation < 1) return null;
97
+ return generation;
98
+ }
99
+
100
+ async function listApplicationLogFiles(logPath: string): Promise<ApplicationLogFile[]> {
101
+ const directory = path.dirname(logPath);
102
+ const baseName = path.basename(logPath);
103
+ const entries = await readdir(directory);
104
+ const files: ApplicationLogFile[] = [];
105
+
106
+ for (const entry of entries) {
107
+ const generation = entry === baseName ? 0 : getRotatedGeneration(entry, baseName);
108
+ if (generation === null) continue;
109
+ const filePath = path.join(directory, entry);
110
+ try {
111
+ const fileStat = await stat(filePath);
112
+ if (!fileStat.isFile()) continue;
113
+ files.push({ generation, modifiedAt: fileStat.mtimeMs, path: filePath });
114
+ } catch {
115
+ // The file may have been removed by another maintenance pass.
116
+ }
117
+ }
118
+
119
+ return files;
120
+ }
121
+
122
+ async function removeApplicationLog(filePath: string): Promise<boolean> {
123
+ try {
124
+ await stat(filePath);
125
+ } catch {
126
+ return true;
127
+ }
128
+ try {
129
+ await unlink(filePath);
130
+ return true;
131
+ } catch {
132
+ // Retention is best-effort; a later maintenance pass retries failed removals.
133
+ return false;
134
+ }
135
+ }
136
+
137
+ async function maintainApplicationLogs(
138
+ logPath: string,
139
+ policy: ApplicationLogPolicy,
140
+ ): Promise<boolean> {
141
+ const directory = path.dirname(logPath);
142
+ if (!ensurePrivateDirectorySync(directory)) return false;
143
+
144
+ let files: ApplicationLogFile[];
145
+ try {
146
+ files = await listApplicationLogFiles(logPath);
147
+ } catch {
148
+ return false;
149
+ }
150
+
151
+ for (const file of files) {
152
+ if (!securePrivateFileSync(file.path)) return false;
153
+ }
154
+
155
+ const cutoff = Date.now() - policy.retentionMs;
156
+ const retained = files.filter((file) => file.modifiedAt >= cutoff);
157
+ let maintained = true;
158
+ for (const file of files) {
159
+ if (file.modifiedAt < cutoff && !(await removeApplicationLog(file.path))) maintained = false;
160
+ }
161
+
162
+ const rotated = retained
163
+ .filter((file) => file.generation > 0)
164
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
165
+ const retainedRotatedCount = Math.max(0, policy.maxFiles - 1);
166
+ for (const file of rotated.slice(retainedRotatedCount)) {
167
+ if (!(await removeApplicationLog(file.path))) maintained = false;
168
+ }
169
+
170
+ return maintained;
171
+ }
172
+
173
+ async function moveApplicationLog(source: string, destination: string): Promise<boolean> {
174
+ try {
175
+ await stat(source);
176
+ } catch {
177
+ return true;
178
+ }
179
+ try {
180
+ await rename(source, destination);
181
+ if (!securePrivateFileSync(destination)) {
182
+ writeStderrFallback("[logger] Failed to secure rotated application log:", destination);
183
+ return false;
184
+ }
185
+ return true;
186
+ } catch (error) {
187
+ writeStderrFallback("[logger] Failed to rotate application log:", error);
188
+ return false;
189
+ }
190
+ }
191
+
192
+ async function rotateApplicationLog(logPath: string, maxFiles: number): Promise<boolean> {
193
+ if (maxFiles === 1) {
194
+ return await removeApplicationLog(logPath);
195
+ }
196
+
197
+ if (!(await removeApplicationLog(`${logPath}.${String(maxFiles - 1)}`))) return false;
198
+ for (let generation = maxFiles - 2; generation >= 1; generation -= 1) {
199
+ if (
200
+ !(await moveApplicationLog(
201
+ `${logPath}.${String(generation)}`,
202
+ `${logPath}.${String(generation + 1)}`,
203
+ ))
204
+ ) {
205
+ return false;
206
+ }
207
+ }
208
+ return await moveApplicationLog(logPath, `${logPath}.1`);
209
+ }
210
+
211
+ async function rotateApplicationLogIfNeeded(
212
+ logPath: string,
213
+ incomingBytes: number,
214
+ policy: ApplicationLogPolicy,
215
+ ): Promise<boolean> {
216
+ try {
217
+ const fileStat = await stat(logPath);
218
+ if (fileStat.size > 0 && fileStat.size + incomingBytes > policy.maxBytes) {
219
+ return await rotateApplicationLog(logPath, policy.maxFiles);
220
+ }
221
+ return true;
222
+ } catch {
223
+ // The active file does not exist yet.
224
+ return true;
225
+ }
226
+ }
227
+
36
228
  export async function initLogger(): Promise<void> {
37
229
  const dir = resolveLogDir();
38
- const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
39
- const cutoff = Date.now() - retentionMs;
230
+ const policy = getApplicationLogPolicy();
231
+ const cutoff = Date.now() - policy.retentionMs;
40
232
 
41
233
  try {
42
- await mkdir(dir, { recursive: true });
43
- const entries = await readdir(dir);
44
- for (const entry of entries) {
45
- if (!entry.endsWith(".jsonl")) continue;
46
- const fullPath = path.join(dir, entry);
47
- try {
48
- const s = await stat(fullPath);
49
- if (s.mtimeMs < cutoff) {
50
- await unlink(fullPath);
234
+ if (!ensurePrivateDirectorySync(dir)) {
235
+ writeStderrFallback("[logger] Failed to secure log directory:", dir);
236
+ } else if (!securePrivateTreeSync(dir)) {
237
+ writeStderrFallback("[logger] Failed to secure existing log files:", dir);
238
+ } else {
239
+ const entries = await readdir(dir);
240
+ for (const entry of entries) {
241
+ if (!entry.endsWith(".jsonl")) continue;
242
+ const fullPath = path.join(dir, entry);
243
+ try {
244
+ const s = await stat(fullPath);
245
+ if (s.mtimeMs < cutoff) {
246
+ await unlink(fullPath);
247
+ }
248
+ } catch {
249
+ // Skip files that can't be stat - not critical
51
250
  }
52
- } catch {
53
- // Skip files that can't be stat - not critical
54
251
  }
55
252
  }
56
253
  } catch (err) {
57
254
  // Log directory initialization errors but don't fail startup
58
255
  writeStderrFallback("[logger] Failed to initialize log directory:", err);
59
256
  }
257
+
258
+ const applicationLogPath = getInspectorLogPath();
259
+ if (!(await maintainApplicationLogs(applicationLogPath, policy))) {
260
+ writeStderrFallback(
261
+ "[logger] Failed to initialize application log lifecycle:",
262
+ applicationLogPath,
263
+ );
264
+ }
60
265
  }
61
266
 
62
267
  // File-based logger for application logs (not log entries)
63
- let loggerInitialized = false;
268
+ let loggerInitialization: Promise<void> | null = null;
269
+ let applicationLogWriteQueue: Promise<void> = Promise.resolve();
64
270
 
65
- async function writeAppLog(message: string): Promise<void> {
66
- // Auto-initialize on first use if not already initialized
67
- if (!loggerInitialized) {
68
- loggerInitialized = true;
69
- void initLogger();
70
- }
271
+ type ApplicationLogWriteResult = { ok: true } | { ok: false; reason: unknown };
71
272
 
72
- const logPath = getInspectorLogPath();
273
+ function ensureLoggerInitialized(): Promise<void> {
274
+ if (loggerInitialization === null) loggerInitialization = initLogger();
275
+ return loggerInitialization;
276
+ }
277
+
278
+ async function appendApplicationLogLine(
279
+ logPath: string,
280
+ line: string,
281
+ policy: ApplicationLogPolicy,
282
+ ): Promise<ApplicationLogWriteResult> {
283
+ const logDirPath = path.dirname(logPath);
284
+ if (!ensurePrivateDirectorySync(logDirPath)) {
285
+ return { ok: false, reason: "application log directory is not private" };
286
+ }
73
287
  try {
74
- const logDirPath = path.dirname(logPath);
75
- await mkdir(logDirPath, { recursive: true });
76
- const timestamp = new Date().toISOString();
77
- await appendFile(logPath, `[${timestamp}] ${message}\n`, "utf-8");
78
- } catch (err) {
79
- // Log to stderr since file logging failed
80
- writeStderrFallback(`[logger] Failed to write to ${logPath}:`, err);
288
+ if (!(await rotateApplicationLogIfNeeded(logPath, Buffer.byteLength(line, "utf-8"), policy))) {
289
+ return { ok: false, reason: "application log rotation failed" };
290
+ }
291
+ await appendFile(logPath, line, { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
292
+ if (!securePrivateFileSync(logPath)) {
293
+ return { ok: false, reason: "application log permissions could not be secured" };
294
+ }
295
+ return { ok: true };
296
+ } catch (error) {
297
+ return { ok: false, reason: error };
81
298
  }
82
299
  }
83
300
 
301
+ /** Isolated lifecycle hook used by the rotation tests without mutating process-wide environment. */
302
+ export async function appendApplicationLogLineForTests(
303
+ logPath: string,
304
+ line: string,
305
+ maxBytes: number,
306
+ maxFiles: number,
307
+ ): Promise<boolean> {
308
+ const result = await appendApplicationLogLine(logPath, line, {
309
+ maxBytes,
310
+ maxFiles,
311
+ retentionMs: DEFAULT_RETENTION_DAYS * DAY_MS,
312
+ });
313
+ return result.ok;
314
+ }
315
+
316
+ async function writeAppLog(message: string): Promise<void> {
317
+ const logPath = getInspectorLogPath();
318
+ const line = `[${new Date().toISOString()}] ${message}\n`;
319
+ const initialization = ensureLoggerInitialized();
320
+ const write = async (): Promise<void> => {
321
+ await initialization;
322
+ const result = await appendApplicationLogLine(logPath, line, getApplicationLogPolicy());
323
+ if (!result.ok) writeStderrFallback(`[logger] Failed to write to ${logPath}:`, result.reason);
324
+ };
325
+
326
+ applicationLogWriteQueue = applicationLogWriteQueue.then(write, write);
327
+ await applicationLogWriteQueue;
328
+ }
329
+
330
+ /** Wait for all queued application-log writes and rotations. */
331
+ export async function flushApplicationLog(): Promise<void> {
332
+ await applicationLogWriteQueue;
333
+ }
334
+
84
335
  // Application logger - writes to inspector.log
85
336
  export const logger = {
86
337
  debug(message: string, ...args: unknown[]): void {
@@ -137,8 +388,15 @@ async function flushWriteBufferUnlocked(): Promise<void> {
137
388
 
138
389
  try {
139
390
  const filePath = getLogFilePath();
140
- await mkdir(path.dirname(filePath), { recursive: true });
141
- await appendFile(filePath, toWrite, "utf-8");
391
+ if (!ensurePrivateDirectorySync(path.dirname(filePath))) {
392
+ writeBuffer.unshift(toWrite);
393
+ writeStderrFallback("[logger] Failed to secure captured-log directory:", filePath);
394
+ return;
395
+ }
396
+ await appendFile(filePath, toWrite, { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
397
+ if (!securePrivateFileSync(filePath)) {
398
+ writeStderrFallback("[logger] Failed to secure captured-log file:", filePath);
399
+ }
142
400
  } catch (err) {
143
401
  // On failure, re-queue the data so it is not lost
144
402
  writeBuffer.unshift(toWrite);
@@ -182,16 +440,11 @@ process.on("exit", () => {
182
440
  try {
183
441
  // Synchronous write required in 'exit' handler (async I/O not allowed)
184
442
  const filePath = getLogFilePath();
185
- mkdirSync(path.dirname(filePath), { recursive: true });
186
- writeFileSync(filePath, toWrite, "utf-8");
443
+ if (!ensurePrivateDirectorySync(path.dirname(filePath))) return;
444
+ appendFileSync(filePath, toWrite, { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
445
+ void securePrivateFileSync(filePath);
187
446
  } catch {
188
447
  // Best-effort: nothing we can do in an exit handler
189
448
  }
190
449
  }
191
450
  });
192
-
193
- for (const signal of ["SIGINT", "SIGTERM"] as const) {
194
- process.on(signal, () => {
195
- void flushLogBuffer().then(() => process.exit(0));
196
- });
197
- }
@@ -0,0 +1,183 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const PRIVATE_DIRECTORY_MODE = 0o700;
6
+ export const PRIVATE_FILE_MODE = 0o600;
7
+
8
+ export type PrivateDataPathKind = "directory" | "file";
9
+
10
+ type WindowsCommandResult = {
11
+ status: number | null;
12
+ stdout: string;
13
+ };
14
+
15
+ export type WindowsCommandRunner = (
16
+ executable: string,
17
+ args: readonly string[],
18
+ ) => WindowsCommandResult;
19
+
20
+ const securedPaths = new Set<string>();
21
+ const securedFileIdentities = new Map<string, string>();
22
+ let cachedWindowsSid: string | null = null;
23
+
24
+ function runWindowsCommand(executable: string, args: readonly string[]): WindowsCommandResult {
25
+ const result = spawnSync(executable, [...args], {
26
+ encoding: "utf8",
27
+ shell: false,
28
+ windowsHide: true,
29
+ });
30
+ return { status: result.status, stdout: result.stdout };
31
+ }
32
+
33
+ function windowsUserSid(output: string): string | null {
34
+ const match = /S-\d+(?:-\d+)+/.exec(output);
35
+ return match?.[0] ?? null;
36
+ }
37
+
38
+ function resolveWindowsUserSid(run: WindowsCommandRunner): string | null {
39
+ if (run === runWindowsCommand && cachedWindowsSid !== null) return cachedWindowsSid;
40
+ const identity = run("whoami.exe", ["/user", "/fo", "csv", "/nh"]);
41
+ if (identity.status !== 0) return null;
42
+ const sid = windowsUserSid(identity.stdout);
43
+ if (run === runWindowsCommand) cachedWindowsSid = sid;
44
+ return sid;
45
+ }
46
+
47
+ function windowsBroadGroupRemovalArgs(): readonly string[] {
48
+ return ["/remove:g", "*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"];
49
+ }
50
+
51
+ /**
52
+ * Remove inherited Windows permissions and grant full control only to the
53
+ * account that owns the current process. Commands are executed directly,
54
+ * never through a shell, and no path content is interpreted as command text.
55
+ */
56
+ export function applyWindowsUserOnlyAcl(
57
+ path: string,
58
+ kind: PrivateDataPathKind,
59
+ run: WindowsCommandRunner = runWindowsCommand,
60
+ ): boolean {
61
+ const sid = resolveWindowsUserSid(run);
62
+ if (sid === null) return false;
63
+
64
+ const access = kind === "directory" ? `(OI)(CI)F` : "F";
65
+ const result = run("icacls.exe", [
66
+ path,
67
+ "/inheritance:r",
68
+ "/grant:r",
69
+ `*${sid}:${access}`,
70
+ ...windowsBroadGroupRemovalArgs(),
71
+ "/q",
72
+ ]);
73
+ return result.status === 0;
74
+ }
75
+
76
+ /** Apply a user-only ACL to an existing Windows tree in one bounded process. */
77
+ export function applyWindowsUserOnlyTreeAcl(
78
+ root: string,
79
+ run: WindowsCommandRunner = runWindowsCommand,
80
+ ): boolean {
81
+ const sid = resolveWindowsUserSid(run);
82
+ if (sid === null) return false;
83
+ const result = run("icacls.exe", [
84
+ root,
85
+ "/inheritance:r",
86
+ "/grant:r",
87
+ `*${sid}:(OI)(CI)F`,
88
+ `*${sid}:F`,
89
+ ...windowsBroadGroupRemovalArgs(),
90
+ "/T",
91
+ "/C",
92
+ "/Q",
93
+ ]);
94
+ return result.status === 0;
95
+ }
96
+
97
+ function applyPrivatePermissionsSync(path: string, kind: PrivateDataPathKind): boolean {
98
+ if (process.platform === "win32") {
99
+ return applyWindowsUserOnlyAcl(path, kind);
100
+ }
101
+
102
+ try {
103
+ chmodSync(path, kind === "directory" ? PRIVATE_DIRECTORY_MODE : PRIVATE_FILE_MODE);
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function secureOnce(path: string, kind: PrivateDataPathKind): boolean {
111
+ const cacheKey = `${kind}:${path}`;
112
+ if (securedPaths.has(cacheKey)) return true;
113
+ if (!applyPrivatePermissionsSync(path, kind)) return false;
114
+ securedPaths.add(cacheKey);
115
+ return true;
116
+ }
117
+
118
+ export function ensurePrivateDirectorySync(path: string): boolean {
119
+ try {
120
+ mkdirSync(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
121
+ } catch {
122
+ return false;
123
+ }
124
+ return secureOnce(path, "directory");
125
+ }
126
+
127
+ export function securePrivateFileSync(path: string, forceAcl = false): boolean {
128
+ let identity: string;
129
+ try {
130
+ const stats = statSync(path);
131
+ identity = `${String(stats.dev)}:${String(stats.ino)}:${String(stats.birthtimeMs)}`;
132
+ } catch {
133
+ return false;
134
+ }
135
+ if (securedFileIdentities.get(path) === identity) return true;
136
+ if (process.platform === "win32" && !forceAcl && securedPaths.has(`directory:${dirname(path)}`)) {
137
+ securedFileIdentities.set(path, identity);
138
+ return true;
139
+ }
140
+ if (!applyPrivatePermissionsSync(path, "file")) return false;
141
+ securedFileIdentities.set(path, identity);
142
+ return true;
143
+ }
144
+
145
+ /** Secure an existing tree without following symbolic links. */
146
+ export function securePrivateTreeSync(root: string): boolean {
147
+ if (!existsSync(root)) return false;
148
+
149
+ let stats: ReturnType<typeof lstatSync>;
150
+ try {
151
+ stats = lstatSync(root);
152
+ } catch {
153
+ return false;
154
+ }
155
+ if (stats.isSymbolicLink()) return true;
156
+ if (!stats.isDirectory()) return securePrivateFileSync(root, true);
157
+ if (process.platform === "win32") {
158
+ if (!applyWindowsUserOnlyTreeAcl(root)) return false;
159
+ securedPaths.add(`directory:${root}`);
160
+ return true;
161
+ }
162
+ if (!ensurePrivateDirectorySync(root)) return false;
163
+
164
+ let entries: string[];
165
+ try {
166
+ entries = readdirSync(root);
167
+ } catch {
168
+ return false;
169
+ }
170
+
171
+ let secured = true;
172
+ for (const entry of entries) {
173
+ const childPath = join(root, entry);
174
+ if (!securePrivateTreeSync(childPath)) secured = false;
175
+ }
176
+ return secured;
177
+ }
178
+
179
+ export function _resetPrivateDataPathCacheForTests(): void {
180
+ securedPaths.clear();
181
+ securedFileIdentities.clear();
182
+ cachedWindowsSid = null;
183
+ }
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { maskApiKey } from "../lib/mask";
3
+ import type { ScannedProviderDto } from "../lib/providerImportContract";
4
+ import type { ExternalProvider } from "./providerImporters";
5
+
6
+ const SCAN_TTL_MS = 5 * 60 * 1000;
7
+ const MAX_ACTIVE_SCANS = 8;
8
+ const MAX_SCANNED_PROVIDERS = 100;
9
+
10
+ type ProviderScanSession = {
11
+ expiresAt: number;
12
+ providers: ReadonlyMap<string, ExternalProvider>;
13
+ };
14
+
15
+ const sessions = new Map<string, ProviderScanSession>();
16
+
17
+ function removeExpired(now: number): void {
18
+ for (const [scanId, session] of sessions) {
19
+ if (session.expiresAt <= now) sessions.delete(scanId);
20
+ }
21
+ }
22
+
23
+ function enforceSessionLimit(): void {
24
+ while (sessions.size >= MAX_ACTIVE_SCANS) {
25
+ const oldest = sessions.keys().next().value;
26
+ if (typeof oldest !== "string") return;
27
+ sessions.delete(oldest);
28
+ }
29
+ }
30
+
31
+ export function createProviderScan(
32
+ providers: readonly ExternalProvider[],
33
+ now = Date.now(),
34
+ ): { scanId: string; providers: ScannedProviderDto[] } {
35
+ removeExpired(now);
36
+ enforceSessionLimit();
37
+
38
+ const scanId = randomUUID();
39
+ const providerMap = new Map<string, ExternalProvider>();
40
+ const redacted: ScannedProviderDto[] = [];
41
+ for (const provider of providers.slice(0, MAX_SCANNED_PROVIDERS)) {
42
+ const selectionId = randomUUID();
43
+ providerMap.set(selectionId, provider);
44
+ redacted.push({
45
+ selectionId,
46
+ name: provider.name,
47
+ apiKeyHint: maskApiKey(provider.apiKey),
48
+ format: provider.format,
49
+ anthropicBaseUrl: provider.anthropicBaseUrl,
50
+ openaiBaseUrl: provider.openaiBaseUrl,
51
+ openaiResponsesBaseUrl: provider.openaiResponsesBaseUrl,
52
+ models: provider.models,
53
+ sourceTool: provider.sourceTool,
54
+ alreadyExists: provider.alreadyExists,
55
+ });
56
+ }
57
+
58
+ sessions.set(scanId, {
59
+ expiresAt: now + SCAN_TTL_MS,
60
+ providers: providerMap,
61
+ });
62
+ return { scanId, providers: redacted };
63
+ }
64
+
65
+ export function consumeProviderScan(
66
+ scanId: string,
67
+ selectionIds: readonly string[],
68
+ now = Date.now(),
69
+ ): ExternalProvider[] | null {
70
+ removeExpired(now);
71
+ const session = sessions.get(scanId);
72
+ if (session === undefined) return null;
73
+ sessions.delete(scanId);
74
+
75
+ const uniqueIds = new Set(selectionIds);
76
+ const selected: ExternalProvider[] = [];
77
+ for (const selectionId of uniqueIds) {
78
+ const provider = session.providers.get(selectionId);
79
+ if (provider === undefined) return null;
80
+ selected.push(provider);
81
+ }
82
+ return selected;
83
+ }
84
+
85
+ export function _resetProviderScanStoreForTests(): void {
86
+ sessions.clear();
87
+ }