@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,18 +1,15 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { Buffer } from "node:buffer";
3
- import {
4
- chmodSync,
5
- existsSync,
6
- mkdirSync,
7
- readFileSync,
8
- renameSync,
9
- rmSync,
10
- writeFileSync,
11
- } from "node:fs";
12
- import { join } from "node:path";
3
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
13
5
  import { z } from "zod";
14
6
  import { readJsonFile } from "../lib/jsonFile";
15
7
  import { logger } from "./logger";
8
+ import {
9
+ ensurePrivateDirectorySync,
10
+ PRIVATE_FILE_MODE,
11
+ securePrivateFileSync,
12
+ } from "./privateDataPath";
16
13
 
17
14
  const SECRET_REFERENCE_PREFIX = "secret:provider:";
18
15
  const SECRET_FILE_VERSION = 1;
@@ -38,21 +35,32 @@ function emptySecretFile(): SecretFile {
38
35
  return { version: SECRET_FILE_VERSION, secrets: {} };
39
36
  }
40
37
 
41
- function readSecretFile(path: string): SecretFile {
38
+ function readSecretFile(path: string): SecretFile | null {
42
39
  if (!existsSync(path)) return emptySecretFile();
40
+ if (!securePrivateFileSync(path)) {
41
+ logger.error(`[provider-secrets] Refusing secret file with unsafe permissions at ${path}`);
42
+ return null;
43
+ }
43
44
  const result = readJsonFile(path, SecretFileSchema);
44
45
  if (result.ok) return result.data;
45
- logger.warn(`[provider-secrets] Ignoring invalid secret file ${path} (${result.reason})`);
46
- return emptySecretFile();
46
+ logger.error(`[provider-secrets] Refusing invalid secret file ${path} (${result.reason})`);
47
+ return null;
47
48
  }
48
49
 
49
50
  function writePrivateFile(path: string, content: string): boolean {
50
51
  const temporaryPath = `${path}.${String(process.pid)}.tmp`;
51
52
  try {
52
- writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
53
- chmodSync(temporaryPath, 0o600);
53
+ if (!ensurePrivateDirectorySync(dirname(path))) return false;
54
+ writeFileSync(temporaryPath, content, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
55
+ if (!securePrivateFileSync(temporaryPath, true)) {
56
+ rmSync(temporaryPath, { force: true });
57
+ return false;
58
+ }
54
59
  renameSync(temporaryPath, path);
55
- chmodSync(path, 0o600);
60
+ if (!securePrivateFileSync(path)) {
61
+ rmSync(path, { force: true });
62
+ return false;
63
+ }
56
64
  return true;
57
65
  } catch (error) {
58
66
  rmSync(temporaryPath, { force: true });
@@ -64,6 +72,10 @@ function writePrivateFile(path: string, content: string): boolean {
64
72
  function readKey(path: string): Buffer | null {
65
73
  try {
66
74
  if (!existsSync(path)) return null;
75
+ if (!securePrivateFileSync(path, true)) {
76
+ logger.error(`[provider-secrets] Refusing encryption key with unsafe permissions at ${path}`);
77
+ return null;
78
+ }
67
79
  const key = Buffer.from(readFileSync(path, "utf8").trim(), "base64");
68
80
  if (key.byteLength === KEY_BYTES) return key;
69
81
  logger.error(`[provider-secrets] Invalid encryption key at ${path}`);
@@ -126,9 +138,25 @@ function providerIdFromReference(reference: string): string | null {
126
138
  }
127
139
 
128
140
  export function createProviderSecretStore(directory: string): ProviderSecretStore {
129
- mkdirSync(directory, { recursive: true });
130
141
  const keyPath = join(directory, "provider-secrets.key");
131
142
  const secretsPath = join(directory, "provider-secrets.json");
143
+ let existingFilesSecured = false;
144
+
145
+ function storageDirectoryIsPrivate(): boolean {
146
+ if (!ensurePrivateDirectorySync(directory)) {
147
+ logger.error(`[provider-secrets] Could not secure provider secret directory ${directory}`);
148
+ return false;
149
+ }
150
+ if (existingFilesSecured) return true;
151
+ for (const path of [keyPath, secretsPath]) {
152
+ if (existsSync(path) && !securePrivateFileSync(path, true)) {
153
+ logger.error(`[provider-secrets] Could not secure existing provider secret file ${path}`);
154
+ return false;
155
+ }
156
+ }
157
+ existingFilesSecured = true;
158
+ return true;
159
+ }
132
160
 
133
161
  function writeSecrets(data: SecretFile): boolean {
134
162
  return writePrivateFile(secretsPath, JSON.stringify(data, null, 2));
@@ -137,29 +165,40 @@ export function createProviderSecretStore(directory: string): ProviderSecretStor
137
165
  return {
138
166
  isReference: (value) => providerIdFromReference(value) !== null,
139
167
  write: (providerId, secret) => {
168
+ if (!storageDirectoryIsPrivate()) return null;
140
169
  const key = readOrCreateKey(keyPath, secretsPath);
141
170
  if (key === null) return null;
142
171
  const encrypted = encrypt(secret, key);
143
172
  if (encrypted === null) return null;
144
173
  const data = readSecretFile(secretsPath);
174
+ if (data === null) return null;
145
175
  const next = {
146
176
  ...data,
147
177
  secrets: { ...data.secrets, [providerId]: encrypted },
148
178
  };
149
179
  if (!writeSecrets(next)) return null;
150
- return `${SECRET_REFERENCE_PREFIX}${providerId}`;
180
+ const reference = `${SECRET_REFERENCE_PREFIX}${providerId}`;
181
+ const persistedSecret = readSecretFile(secretsPath)?.secrets[providerId];
182
+ if (persistedSecret === undefined || decrypt(persistedSecret, key) !== secret) {
183
+ logger.error("[provider-secrets] Could not verify persisted provider secret");
184
+ return null;
185
+ }
186
+ return reference;
151
187
  },
152
188
  read: (reference) => {
189
+ if (!storageDirectoryIsPrivate()) return null;
153
190
  const providerId = providerIdFromReference(reference);
154
191
  if (providerId === null) return null;
155
192
  const key = readKey(keyPath);
156
193
  if (key === null) return null;
157
- const encrypted = readSecretFile(secretsPath).secrets[providerId];
194
+ const encrypted = readSecretFile(secretsPath)?.secrets[providerId];
158
195
  if (encrypted === undefined) return null;
159
196
  return decrypt(encrypted, key);
160
197
  },
161
198
  delete: (providerId) => {
199
+ if (!storageDirectoryIsPrivate()) return;
162
200
  const data = readSecretFile(secretsPath);
201
+ if (data === null) return;
163
202
  if (data.secrets[providerId] === undefined) return;
164
203
  const nextSecrets = { ...data.secrets };
165
204
  delete nextSecrets[providerId];
@@ -1,16 +1,21 @@
1
1
  import { z } from "zod";
2
2
  import Conf from "conf";
3
3
  import { randomUUID } from "crypto";
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { maskApiKey } from "../lib/mask";
7
7
  import { logger } from "./logger";
8
8
  import { getDataDir, hasExplicitDataDir } from "./dataDir";
9
9
  import {
10
10
  ProviderConfigSchema,
11
- ProviderModelMetadataSchema,
11
+ ProviderModelMetadataListSchema,
12
+ ProviderModelNameSchema,
13
+ ProviderModelsSchema,
14
+ toRedactedProviderDto,
15
+ toRedactedProviderDtos,
12
16
  type ProviderConfig,
13
17
  type ProviderModelMetadata,
18
+ type RedactedProviderDto,
14
19
  } from "../lib/providerContract";
15
20
  import { withBuiltinProviderModelMetadata } from "../lib/providerModelMetadata";
16
21
  import {
@@ -18,6 +23,11 @@ import {
18
23
  secretValuesEqual,
19
24
  type ProviderSecretStore,
20
25
  } from "./providerSecretStore";
26
+ import {
27
+ ensurePrivateDirectorySync,
28
+ PRIVATE_FILE_MODE,
29
+ securePrivateFileSync,
30
+ } from "./privateDataPath";
21
31
 
22
32
  export { ProviderConfigSchema };
23
33
  export type { ProviderConfig };
@@ -55,8 +65,8 @@ const LegacyProviderConfigSchema = z.object({
55
65
  type: z.enum(["anthropic", "openai"]).optional(),
56
66
  format: z.enum(["anthropic", "openai"]).optional(),
57
67
  apiKey: z.string().optional(),
58
- model: z.string().optional(),
59
- models: z.array(z.string()).optional(),
68
+ model: ProviderModelNameSchema.optional(),
69
+ models: ProviderModelsSchema.optional(),
60
70
  baseUrl: z.string().optional(),
61
71
  anthropicBaseUrl: z.string().optional(),
62
72
  openaiBaseUrl: z.string().optional(),
@@ -64,7 +74,7 @@ const LegacyProviderConfigSchema = z.object({
64
74
  authHeader: z.enum(["bearer", "x-api-key"]).optional(),
65
75
  apiDocsUrl: z.string().optional(),
66
76
  modelMetadataUrl: z.string().optional(),
67
- modelMetadata: z.array(ProviderModelMetadataSchema).optional(),
77
+ modelMetadata: ProviderModelMetadataListSchema.optional(),
68
78
  source: z.enum(["company", "personal"]).optional(),
69
79
  createdAt: z.string().optional(),
70
80
  updatedAt: z.string().optional(),
@@ -152,7 +162,10 @@ function migrateFromDataDirConfig(targetStore: Conf<ProvidersStore>, dataDirPath
152
162
  }
153
163
  if (migrated.length === 0) return;
154
164
 
155
- targetStore.set("providers", migrated);
165
+ if (!persistProviders(migrated)) {
166
+ logger.warn(`[providers] Data-dir config migration left ${targetStore.path} unchanged`);
167
+ return;
168
+ }
156
169
  logger.info(
157
170
  `[providers] Migrated ${migrated.length} provider(s) from ${legacyConfigPath} to ${targetStore.path}`,
158
171
  );
@@ -189,10 +202,13 @@ function migrateFromLegacyConfLocation(targetStore: Conf<ProvidersStore>): void
189
202
  const legacyProviders = legacyStore.get("providers", []);
190
203
  if (legacyProviders.length === 0) return;
191
204
  const now = new Date().toISOString();
192
- targetStore.set(
193
- "providers",
194
- legacyProviders.map((provider) => withBuiltinProviderModelMetadata(provider, now)),
205
+ const migrated = legacyProviders.map((provider) =>
206
+ withBuiltinProviderModelMetadata(provider, now),
195
207
  );
208
+ if (!persistProviders(migrated)) {
209
+ logger.warn(`[providers] Legacy migration left ${targetStore.path} unchanged`);
210
+ return;
211
+ }
196
212
  logger.info(
197
213
  `[providers] Migrated ${legacyProviders.length} provider(s) from ${legacyStore.path} to ${targetStore.path}`,
198
214
  );
@@ -210,11 +226,11 @@ const configPath = process.env["AGENT_INSPECTOR_CONFIG_PATH"];
210
226
  // We pass `cwd` so conf writes the file as `<cwd>/<configName>.<ext>`,
211
227
  // i.e. `<dataDir>/providers.json`. The runtime config lives at
212
228
  // `<dataDir>/config.json`; the different filenames keep the two stores
213
- // from overwriting each other. `mkdirSync(..., { recursive: true })` is
214
- // a no-op when the directory already exists.
229
+ // from overwriting each other.
215
230
  const dataDir = getDataDir();
216
- if (configPath === undefined) {
217
- mkdirSync(dataDir, { recursive: true });
231
+ const providerDataDir = configPath === undefined ? dataDir : dirname(configPath);
232
+ if (!ensurePrivateDirectorySync(providerDataDir)) {
233
+ logger.error(`[providers] Could not secure provider data directory ${providerDataDir}`);
218
234
  }
219
235
 
220
236
  export const store = new Conf<ProvidersStore>({
@@ -228,8 +244,39 @@ export const store = new Conf<ProvidersStore>({
228
244
 
229
245
  const providerSecretStore: ProviderSecretStore = createProviderSecretStore(dirname(store.path));
230
246
 
247
+ function providerMetadataStorageIsPrivate(forceFile = false): boolean {
248
+ if (!ensurePrivateDirectorySync(dirname(store.path))) return false;
249
+ if (!existsSync(store.path)) return true;
250
+ return securePrivateFileSync(store.path, forceFile);
251
+ }
252
+
253
+ function writeProviderMetadata(providers: readonly ProviderConfig[]): boolean {
254
+ if (!providerMetadataStorageIsPrivate()) {
255
+ logger.error(`[providers] Refusing provider metadata write to unsafe path ${store.path}`);
256
+ return false;
257
+ }
258
+ try {
259
+ store.set("providers", [...providers]);
260
+ } catch (error) {
261
+ logger.error(`[providers] Could not persist provider metadata: ${String(error)}`);
262
+ return false;
263
+ }
264
+ if (securePrivateFileSync(store.path, true)) return true;
265
+ logger.error(`[providers] Could not secure provider metadata at ${store.path}`);
266
+ return false;
267
+ }
268
+
269
+ if (!providerMetadataStorageIsPrivate(true)) {
270
+ logger.error(`[providers] Provider metadata path is not private: ${store.path}`);
271
+ }
272
+
231
273
  function providerWithResolvedSecret(provider: ProviderConfig): ProviderConfig | null {
232
- if (!providerSecretStore.isReference(provider.apiKey)) return provider;
274
+ if (!providerSecretStore.isReference(provider.apiKey)) {
275
+ logger.error(
276
+ `[providers] Refusing plaintext secret for provider ${provider.id}; secure migration is required`,
277
+ );
278
+ return null;
279
+ }
233
280
  const apiKey = providerSecretStore.read(provider.apiKey);
234
281
  if (apiKey !== null) return { ...provider, apiKey };
235
282
  logger.error(`[providers] Secret is missing or unreadable for provider "${provider.name}"`);
@@ -239,7 +286,7 @@ function providerWithResolvedSecret(provider: ProviderConfig): ProviderConfig |
239
286
  function persistProviders(
240
287
  providers: readonly ProviderConfig[],
241
288
  removedProviderIds: ReadonlySet<string> = new Set(),
242
- ): void {
289
+ ): boolean {
243
290
  const existingReferences = new Map<string, string>();
244
291
  const unresolvedRecords = new Map<string, ProviderConfig>();
245
292
  for (const candidate of store.get("providers", [])) {
@@ -252,41 +299,108 @@ function persistProviders(
252
299
  }
253
300
  }
254
301
 
255
- const persisted = providers.map((provider) => {
302
+ const persisted: ProviderConfig[] = [];
303
+ for (const provider of providers) {
256
304
  const existingReference = existingReferences.get(provider.id);
257
305
  if (existingReference !== undefined) {
258
306
  const existingSecret = providerSecretStore.read(existingReference);
259
307
  if (existingSecret !== null && secretValuesEqual(existingSecret, provider.apiKey)) {
260
- return { ...provider, apiKey: existingReference };
308
+ persisted.push({ ...provider, apiKey: existingReference });
309
+ continue;
261
310
  }
262
311
  }
263
312
 
264
313
  const reference = providerSecretStore.write(provider.id, provider.apiKey);
265
- if (reference !== null) return { ...provider, apiKey: reference };
314
+ if (reference !== null) {
315
+ persisted.push({ ...provider, apiKey: reference });
316
+ continue;
317
+ }
266
318
  logger.error(
267
- `[providers] SecretStore unavailable; provider "${provider.name}" remains in the legacy storage format`,
319
+ `[providers] Secret store unavailable; refusing to persist provider ${provider.id}`,
268
320
  );
269
- return provider;
270
- });
321
+ return false;
322
+ }
271
323
  const persistedIds = new Set(persisted.map((provider) => provider.id));
272
324
  for (const [id, provider] of unresolvedRecords) {
273
325
  if (!persistedIds.has(id) && !removedProviderIds.has(id)) persisted.push(provider);
274
326
  }
275
- store.set("providers", persisted);
327
+ return writeProviderMetadata(persisted);
276
328
  }
277
329
 
278
- function migratePlaintextProviderSecrets(): void {
330
+ function migratePlaintextProviderSecrets(): boolean {
279
331
  const raw = store.get("providers", []);
280
332
  let changed = false;
281
- const migrated = raw.map((candidate) => {
333
+ const migrated: ProviderConfig[] = [];
334
+ for (const candidate of raw) {
282
335
  const parsed = ProviderConfigSchema.safeParse(candidate);
283
- if (!parsed.success || providerSecretStore.isReference(parsed.data.apiKey)) return candidate;
336
+ if (!parsed.success) {
337
+ migrated.push(candidate);
338
+ continue;
339
+ }
340
+ if (providerSecretStore.isReference(parsed.data.apiKey)) {
341
+ migrated.push(parsed.data);
342
+ continue;
343
+ }
284
344
  const reference = providerSecretStore.write(parsed.data.id, parsed.data.apiKey);
285
- if (reference === null) return candidate;
345
+ if (reference === null) {
346
+ logger.error(
347
+ `[providers] Could not migrate provider ${parsed.data.id} to secure storage; leaving the existing file unchanged`,
348
+ );
349
+ return false;
350
+ }
286
351
  changed = true;
287
- return { ...parsed.data, apiKey: reference };
288
- });
289
- if (changed) store.set("providers", migrated);
352
+ migrated.push({ ...parsed.data, apiKey: reference });
353
+ }
354
+ if (!changed) return true;
355
+ if (writeProviderMetadata(migrated)) return true;
356
+ logger.error("[providers] Could not finish provider secret migration");
357
+ return false;
358
+ }
359
+
360
+ function sanitizeProviderBackupValue(value: unknown): unknown {
361
+ if (Array.isArray(value)) return value.map(sanitizeProviderBackupValue);
362
+ if (value === null || typeof value !== "object") return value;
363
+
364
+ return Object.fromEntries(
365
+ Object.entries(value).map(([key, entry]) => {
366
+ if (key === "apiKey" && typeof entry === "string") {
367
+ return [key, "redacted"];
368
+ }
369
+ return [key, sanitizeProviderBackupValue(entry)];
370
+ }),
371
+ );
372
+ }
373
+
374
+ function sanitizeProviderBackup(): void {
375
+ const backupPath = `${store.path}.bak`;
376
+ if (!existsSync(backupPath)) return;
377
+
378
+ const temporaryPath = `${backupPath}.${String(process.pid)}.tmp`;
379
+ try {
380
+ const parsed: unknown = JSON.parse(readFileSync(backupPath, "utf-8"));
381
+ const sanitized = sanitizeProviderBackupValue(parsed);
382
+ writeFileSync(temporaryPath, JSON.stringify(sanitized, null, 2), {
383
+ encoding: "utf8",
384
+ mode: PRIVATE_FILE_MODE,
385
+ });
386
+ if (!securePrivateFileSync(temporaryPath, true)) {
387
+ rmSync(temporaryPath, { force: true });
388
+ rmSync(backupPath, { force: true });
389
+ logger.error("[providers] Removed provider backup after temporary permission failure");
390
+ return;
391
+ }
392
+ renameSync(temporaryPath, backupPath);
393
+ if (!securePrivateFileSync(backupPath, true)) {
394
+ rmSync(backupPath, { force: true });
395
+ logger.error("[providers] Removed provider backup after permission failure");
396
+ }
397
+ } catch (error) {
398
+ rmSync(temporaryPath, { force: true });
399
+ rmSync(backupPath, { force: true });
400
+ logger.error(
401
+ `[providers] Removed provider backup that could not be sanitized: ${String(error)}`,
402
+ );
403
+ }
290
404
  }
291
405
 
292
406
  // Older builds stored providers in `<dataDir>/config.json`. Migrate that
@@ -297,6 +411,8 @@ migrateFromDataDirConfig(store, dataDir);
297
411
  // already has data, when the user has set AGENT_INSPECTOR_CONFIG_PATH or
298
412
  // an explicit data dir, or when the legacy file is empty.
299
413
  migrateFromLegacyConfLocation(store);
414
+ sanitizeProviderBackup();
415
+ const providerSecretsReady = migratePlaintextProviderSecrets();
300
416
 
301
417
  /**
302
418
  * Expands the old `{ format, baseUrl }` representation into the matching
@@ -325,17 +441,17 @@ export function migrateProvider(p: ProviderConfig): ProviderConfig {
325
441
  * Migrates existing provider configs to preserve Anthropic and OpenAI routing URLs.
326
442
  */
327
443
  function migrateProviders(): void {
328
- const providers = store.get("providers", []);
444
+ const providers = getProviders();
329
445
  const updated = providers.map(migrateProvider);
330
446
  const migrated = updated.some((p, i) => JSON.stringify(p) !== JSON.stringify(providers[i]));
331
447
 
332
448
  if (migrated) {
333
- store.set("providers", updated);
449
+ void persistProviders(updated);
334
450
  }
335
451
  }
336
452
 
337
453
  // Run migration on module load
338
- migrateProviders();
454
+ if (providerSecretsReady) migrateProviders();
339
455
 
340
456
  /**
341
457
  * Migrate: merge providers with identical API key and routing URLs.
@@ -343,7 +459,7 @@ migrateProviders();
343
459
  * value promoted to `models: [model]`.
344
460
  */
345
461
  function migrateMultiModel(): void {
346
- const providers = store.get("providers", []);
462
+ const providers = getProviders();
347
463
  if (providers.length === 0) return;
348
464
  let changed = false;
349
465
 
@@ -400,18 +516,21 @@ function migrateMultiModel(): void {
400
516
  if (changed && merged.length > 0) {
401
517
  // Safety backup before writing
402
518
  try {
403
- writeFileSync(`${store.path}.bak`, readFileSync(store.path, "utf-8"));
519
+ writeFileSync(`${store.path}.bak`, readFileSync(store.path, "utf-8"), {
520
+ mode: PRIVATE_FILE_MODE,
521
+ });
522
+ sanitizeProviderBackup();
404
523
  } catch {
405
524
  // best-effort, ignore
406
525
  }
407
- store.set("providers", merged);
526
+ void persistProviders(merged);
408
527
  }
409
528
  }
410
529
 
411
- migrateMultiModel();
530
+ if (providerSecretsReady) migrateMultiModel();
412
531
 
413
532
  function migrateBuiltinModelMetadata(): void {
414
- const providers = store.get("providers", []);
533
+ const providers = getProviders();
415
534
  if (providers.length === 0) return;
416
535
 
417
536
  const now = new Date().toISOString();
@@ -421,11 +540,11 @@ function migrateBuiltinModelMetadata(): void {
421
540
  );
422
541
 
423
542
  if (changed) {
424
- store.set("providers", updated);
543
+ void persistProviders(updated);
425
544
  }
426
545
  }
427
546
 
428
- migrateBuiltinModelMetadata();
547
+ if (providerSecretsReady) migrateBuiltinModelMetadata();
429
548
 
430
549
  // Override with JSON env var if provided (for testing)
431
550
  const providersJson = process.env["AGENT_INSPECTOR_PROVIDERS_JSON"];
@@ -438,17 +557,13 @@ if (providersJson !== undefined) {
438
557
  const migrated = parsed.data
439
558
  .map(migrateProvider)
440
559
  .map((provider) => withBuiltinProviderModelMetadata(provider, now));
441
- store.set("providers", migrated);
560
+ void persistProviders(migrated);
442
561
  }
443
562
  } catch {
444
563
  // Ignore invalid JSON
445
564
  }
446
565
  }
447
566
 
448
- // One-time, backwards-compatible migration. Existing plaintext keys are moved
449
- // out of providers.json; records retain only opaque references.
450
- migratePlaintextProviderSecrets();
451
-
452
567
  export function getProviders(): ProviderConfig[] {
453
568
  const raw = store.get("providers", []);
454
569
  // Validate the on-disk store entry-by-entry so a single corrupted record
@@ -480,15 +595,12 @@ export function getProvider(id: string): ProviderConfig | undefined {
480
595
  return providers.find((p) => p.id === id);
481
596
  }
482
597
 
483
- export function redactProvider(provider: ProviderConfig): ProviderConfig {
484
- return {
485
- ...provider,
486
- apiKey: "configured",
487
- };
598
+ export function redactProvider(provider: ProviderConfig): RedactedProviderDto {
599
+ return toRedactedProviderDto(provider);
488
600
  }
489
601
 
490
- export function redactProviders(providers: readonly ProviderConfig[]): ProviderConfig[] {
491
- return providers.map(redactProvider);
602
+ export function redactProviders(providers: readonly ProviderConfig[]): RedactedProviderDto[] {
603
+ return toRedactedProviderDtos(providers);
492
604
  }
493
605
 
494
606
  /**
@@ -512,7 +624,7 @@ export function addProvider(
512
624
  modelMetadataUrl?: string,
513
625
  modelMetadata?: ProviderModelMetadata[],
514
626
  openaiResponsesBaseUrl?: string,
515
- ): ProviderConfig {
627
+ ): ProviderConfig | null {
516
628
  const providers = getProviders();
517
629
  const normalizedKey = normalizeApiKey(apiKey);
518
630
  const resolvedAnthropicUrl =
@@ -546,7 +658,7 @@ export function addProvider(
546
658
  if (existingIndex >= 0) {
547
659
  providers[existingIndex] = enriched;
548
660
  }
549
- persistProviders(providers);
661
+ if (!persistProviders(providers)) return null;
550
662
  return enriched;
551
663
  }
552
664
 
@@ -578,7 +690,7 @@ export function addProvider(
578
690
  now,
579
691
  );
580
692
  providers.push(newProvider);
581
- persistProviders(providers);
693
+ if (!persistProviders(providers)) return null;
582
694
  return newProvider;
583
695
  }
584
696
 
@@ -626,7 +738,7 @@ export function updateProvider(
626
738
  );
627
739
  const index = providers.findIndex((p) => p.id === id);
628
740
  providers[index] = updated;
629
- persistProviders(providers);
741
+ if (!persistProviders(providers)) return null;
630
742
  return updated;
631
743
  }
632
744
 
@@ -635,7 +747,7 @@ export function deleteProvider(id: string): boolean {
635
747
  const filtered = providers.filter((p) => p.id !== id);
636
748
  if (filtered.length === providers.length) return false;
637
749
 
638
- persistProviders(filtered, new Set([id]));
750
+ if (!persistProviders(filtered, new Set([id]))) return false;
639
751
  providerSecretStore.delete(id);
640
752
  return true;
641
753
  }
@@ -733,12 +845,17 @@ export function importProviders(json: string): { imported: number; errors: strin
733
845
 
734
846
  newProviders.push(newProvider);
735
847
  existingKeys.add(key);
736
- imported++;
737
848
  }
738
849
 
739
850
  // Single write: append all new providers at once
740
851
  if (newProviders.length > 0) {
741
- persistProviders([...existingProviders, ...newProviders]);
852
+ if (!persistProviders([...existingProviders, ...newProviders])) {
853
+ return {
854
+ imported: 0,
855
+ errors: ["Secure provider storage is unavailable; no providers were imported"],
856
+ };
857
+ }
858
+ imported = newProviders.length;
742
859
  }
743
860
  } catch (err) {
744
861
  return {