@tonyclaw/agent-inspector 3.0.25 → 3.0.27

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 (81) hide show
  1. package/.output/cli.js +80 -11
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-BnvOPwpe.js → CompareDrawer-BVCAAbbu.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-CYOXB81n.js +4234 -0
  5. package/.output/public/assets/{ReplayDialog-BJgoQWdW.js → ReplayDialog-DMX23O5G.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CpBZEHnM.js → RequestAnatomy-S17pYDRc.js} +1 -1
  7. package/.output/public/assets/{ResponseView-CWmyRb2M.js → ResponseView-Cf1XrawD.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-BWhC8bwx.js → StreamingChunkSequence-BdOrVlCi.js} +1 -1
  9. package/.output/public/assets/{_sessionId-Cq-2OqUU.js → _sessionId-BnKGmJrR.js} +1 -1
  10. package/.output/public/assets/{index-Cf8O0Oos.js → index-9fMLCU4d.js} +1 -1
  11. package/.output/public/assets/{index-BKi9IRtV.js → index-B1atk19i.js} +3 -3
  12. package/.output/public/assets/index-CmeF5XXS.css +1 -0
  13. package/.output/public/assets/{index-DdgTJ4QJ.js → index-gY3I-7-D.js} +1 -1
  14. package/.output/public/assets/{json-viewer-Bqu_4hmm.js → json-viewer-CpyYpPjx.js} +1 -1
  15. package/.output/server/_libs/lucide-react.mjs +6 -6
  16. package/.output/server/_libs/three.mjs +26016 -0
  17. package/.output/server/{_sessionId-Ba7H3Gjw.mjs → _sessionId-eA_h2_s6.mjs} +9 -6
  18. package/.output/server/_ssr/{CompareDrawer-aH59Ab6x.mjs → CompareDrawer-K2k9G7r1.mjs} +10 -7
  19. package/.output/server/_ssr/{ProxyViewerContainer-By-I0tJv.mjs → ProxyViewerContainer-CbyBpCAj.mjs} +804 -363
  20. package/.output/server/_ssr/{ReplayDialog-BO4Vy0et.mjs → ReplayDialog-DacI2Iv0.mjs} +10 -7
  21. package/.output/server/_ssr/{RequestAnatomy-FtOr7vSS.mjs → RequestAnatomy-gh-pAdkO.mjs} +9 -6
  22. package/.output/server/_ssr/{ResponseView-cvw6ajue.mjs → ResponseView-BRJKN50-.mjs} +10 -7
  23. package/.output/server/_ssr/{StreamingChunkSequence-Oc8S2kD6.mjs → StreamingChunkSequence-BQLVEuWY.mjs} +9 -6
  24. package/.output/server/_ssr/{index-BDRlQheR.mjs → index-DuSWrjbH.mjs} +9 -6
  25. package/.output/server/_ssr/index.mjs +2 -2
  26. package/.output/server/_ssr/{json-viewer-Cd6trhQr.mjs → json-viewer-DNs1ZjKI.mjs} +9 -6
  27. package/.output/server/_ssr/{router-bofP-Ekl.mjs → router-Dgx6-ot3.mjs} +713 -218
  28. package/.output/server/{_tanstack-start-manifest_v-BPuf86qB.mjs → _tanstack-start-manifest_v-CoGjnkQD.mjs} +1 -1
  29. package/.output/server/index.mjs +65 -65
  30. package/README.md +8 -0
  31. package/package.json +6 -5
  32. package/src/cli.ts +43 -9
  33. package/src/components/inspector-pet/InspectorPet.tsx +66 -51
  34. package/src/components/inspector-pet/InspectorPetCrab3D.tsx +455 -0
  35. package/src/components/inspector-pet/inspectorPetLogic.ts +28 -43
  36. package/src/components/providers/ProviderCard.tsx +3 -36
  37. package/src/components/providers/ProviderForm.tsx +251 -175
  38. package/src/components/providers/ProvidersPanel.tsx +18 -8
  39. package/src/components/providers/SettingsDialog.tsx +3 -3
  40. package/src/components/providers/providerFormCapabilities.ts +36 -0
  41. package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
  42. package/src/contracts/anthropic.ts +0 -5
  43. package/src/contracts/index.ts +0 -4
  44. package/src/contracts/log.ts +0 -1
  45. package/src/contracts/openai.ts +0 -3
  46. package/src/knowledge/openclawClient.ts +22 -9
  47. package/src/knowledge/openclawGatewayClient.ts +20 -13
  48. package/src/lib/alertContract.ts +0 -3
  49. package/src/lib/ecosystemContract.ts +0 -3
  50. package/src/lib/export-logs.ts +1 -4
  51. package/src/lib/safeFetch.ts +100 -0
  52. package/src/lib/ssrfGuard.ts +133 -0
  53. package/src/lib/stopReason.ts +1 -4
  54. package/src/lib/unknown.ts +27 -0
  55. package/src/lib/useProviders.ts +1 -1
  56. package/src/mcp/mode.ts +3 -9
  57. package/src/mcp/server.ts +5 -14
  58. package/src/mcp/toolHandlers.ts +45 -54
  59. package/src/proxy/constants.ts +17 -0
  60. package/src/proxy/formats/anthropic/handler.ts +5 -1
  61. package/src/proxy/formats/anthropic/schemas.ts +1 -7
  62. package/src/proxy/formats/index.ts +1 -1
  63. package/src/proxy/formats/openai/schemas.ts +0 -3
  64. package/src/proxy/formats/registry.ts +0 -3
  65. package/src/proxy/handler.ts +91 -12
  66. package/src/proxy/identityProxy.ts +74 -1
  67. package/src/proxy/logImporter.ts +82 -12
  68. package/src/proxy/logIndex.ts +1 -4
  69. package/src/proxy/providerSecretStore.ts +181 -0
  70. package/src/proxy/providers.ts +84 -11
  71. package/src/proxy/schemas.ts +1 -8
  72. package/src/proxy/sqliteLogIndex.ts +0 -16
  73. package/src/proxy/store.ts +1 -5
  74. package/src/proxy/toolSchemaWarnings.ts +1 -9
  75. package/src/routes/api/logs.$id.replay.ts +17 -5
  76. package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
  77. package/src/routes/api/providers.$providerId.ts +18 -5
  78. package/src/routes/api/providers.ts +18 -7
  79. package/styles/globals.css +0 -61
  80. package/.output/public/assets/ProxyViewerContainer-DcqFqo1m.js +0 -127
  81. package/.output/public/assets/index-BMYuDL5X.css +0 -1
@@ -0,0 +1,181 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from "node:crypto";
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";
13
+ import { z } from "zod";
14
+ import { logger } from "./logger";
15
+
16
+ const SECRET_REFERENCE_PREFIX = "secret:provider:";
17
+ const SECRET_FILE_VERSION = 1;
18
+ const KEY_BYTES = 32;
19
+ const IV_BYTES = 12;
20
+ const AUTH_TAG_BYTES = 16;
21
+
22
+ const SecretFileSchema = z.object({
23
+ version: z.literal(SECRET_FILE_VERSION),
24
+ secrets: z.record(z.string(), z.string()),
25
+ });
26
+
27
+ type SecretFile = z.infer<typeof SecretFileSchema>;
28
+
29
+ export type ProviderSecretStore = {
30
+ delete: (providerId: string) => void;
31
+ isReference: (value: string) => boolean;
32
+ read: (reference: string) => string | null;
33
+ write: (providerId: string, secret: string) => string | null;
34
+ };
35
+
36
+ function emptySecretFile(): SecretFile {
37
+ return { version: SECRET_FILE_VERSION, secrets: {} };
38
+ }
39
+
40
+ function readSecretFile(path: string): SecretFile {
41
+ if (!existsSync(path)) return emptySecretFile();
42
+ try {
43
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
44
+ const result = SecretFileSchema.safeParse(parsed);
45
+ if (result.success) return result.data;
46
+ logger.warn(`[provider-secrets] Ignoring invalid secret file ${path}`);
47
+ } catch (error) {
48
+ logger.warn(`[provider-secrets] Could not read ${path}: ${String(error)}`);
49
+ }
50
+ return emptySecretFile();
51
+ }
52
+
53
+ function writePrivateFile(path: string, content: string): boolean {
54
+ const temporaryPath = `${path}.${String(process.pid)}.tmp`;
55
+ try {
56
+ writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
57
+ chmodSync(temporaryPath, 0o600);
58
+ renameSync(temporaryPath, path);
59
+ chmodSync(path, 0o600);
60
+ return true;
61
+ } catch (error) {
62
+ rmSync(temporaryPath, { force: true });
63
+ logger.error(`[provider-secrets] Could not write ${path}: ${String(error)}`);
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function readKey(path: string): Buffer | null {
69
+ try {
70
+ if (!existsSync(path)) return null;
71
+ const key = Buffer.from(readFileSync(path, "utf8").trim(), "base64");
72
+ if (key.byteLength === KEY_BYTES) return key;
73
+ logger.error(`[provider-secrets] Invalid encryption key at ${path}`);
74
+ return null;
75
+ } catch (error) {
76
+ logger.error(`[provider-secrets] Could not read encryption key: ${String(error)}`);
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function readOrCreateKey(keyPath: string, secretsPath: string): Buffer | null {
82
+ const existingKey = readKey(keyPath);
83
+ if (existingKey !== null) return existingKey;
84
+ if (existsSync(keyPath)) return null;
85
+
86
+ if (existsSync(secretsPath)) {
87
+ logger.error(
88
+ `[provider-secrets] Refusing to replace missing key while encrypted secrets exist at ${secretsPath}`,
89
+ );
90
+ return null;
91
+ }
92
+
93
+ const key = randomBytes(KEY_BYTES);
94
+ if (!writePrivateFile(keyPath, key.toString("base64"))) return null;
95
+ return key;
96
+ }
97
+
98
+ function encrypt(secret: string, key: Buffer): string | null {
99
+ try {
100
+ const iv = randomBytes(IV_BYTES);
101
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
102
+ const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
103
+ const tag = cipher.getAuthTag();
104
+ return Buffer.concat([iv, tag, ciphertext]).toString("base64");
105
+ } catch (error) {
106
+ logger.error(`[provider-secrets] Encryption failed: ${String(error)}`);
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function decrypt(payload: string, key: Buffer): string | null {
112
+ try {
113
+ const packed = Buffer.from(payload, "base64");
114
+ if (packed.byteLength <= IV_BYTES + AUTH_TAG_BYTES) return null;
115
+ const iv = packed.subarray(0, IV_BYTES);
116
+ const tag = packed.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
117
+ const ciphertext = packed.subarray(IV_BYTES + AUTH_TAG_BYTES);
118
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
119
+ decipher.setAuthTag(tag);
120
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function providerIdFromReference(reference: string): string | null {
127
+ if (!reference.startsWith(SECRET_REFERENCE_PREFIX)) return null;
128
+ const providerId = reference.slice(SECRET_REFERENCE_PREFIX.length);
129
+ return providerId === "" ? null : providerId;
130
+ }
131
+
132
+ export function createProviderSecretStore(directory: string): ProviderSecretStore {
133
+ mkdirSync(directory, { recursive: true });
134
+ const keyPath = join(directory, "provider-secrets.key");
135
+ const secretsPath = join(directory, "provider-secrets.json");
136
+
137
+ function writeSecrets(data: SecretFile): boolean {
138
+ return writePrivateFile(secretsPath, JSON.stringify(data, null, 2));
139
+ }
140
+
141
+ return {
142
+ isReference: (value) => providerIdFromReference(value) !== null,
143
+ write: (providerId, secret) => {
144
+ const key = readOrCreateKey(keyPath, secretsPath);
145
+ if (key === null) return null;
146
+ const encrypted = encrypt(secret, key);
147
+ if (encrypted === null) return null;
148
+ const data = readSecretFile(secretsPath);
149
+ const next = {
150
+ ...data,
151
+ secrets: { ...data.secrets, [providerId]: encrypted },
152
+ };
153
+ if (!writeSecrets(next)) return null;
154
+ return `${SECRET_REFERENCE_PREFIX}${providerId}`;
155
+ },
156
+ read: (reference) => {
157
+ const providerId = providerIdFromReference(reference);
158
+ if (providerId === null) return null;
159
+ const key = readKey(keyPath);
160
+ if (key === null) return null;
161
+ const encrypted = readSecretFile(secretsPath).secrets[providerId];
162
+ if (encrypted === undefined) return null;
163
+ return decrypt(encrypted, key);
164
+ },
165
+ delete: (providerId) => {
166
+ const data = readSecretFile(secretsPath);
167
+ if (data.secrets[providerId] === undefined) return;
168
+ const nextSecrets = { ...data.secrets };
169
+ delete nextSecrets[providerId];
170
+ void writeSecrets({ ...data, secrets: nextSecrets });
171
+ },
172
+ };
173
+ }
174
+
175
+ export function secretValuesEqual(left: string, right: string): boolean {
176
+ const leftBuffer = Buffer.from(left);
177
+ const rightBuffer = Buffer.from(right);
178
+ return (
179
+ leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer)
180
+ );
181
+ }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import Conf from "conf";
3
3
  import { randomUUID } from "crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
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";
@@ -13,6 +13,11 @@ import {
13
13
  type ProviderModelMetadata,
14
14
  } from "../lib/providerContract";
15
15
  import { withBuiltinProviderModelMetadata } from "../lib/providerModelMetadata";
16
+ import {
17
+ createProviderSecretStore,
18
+ secretValuesEqual,
19
+ type ProviderSecretStore,
20
+ } from "./providerSecretStore";
16
21
 
17
22
  export { ProviderConfigSchema };
18
23
  export type { ProviderConfig };
@@ -198,8 +203,7 @@ function migrateFromLegacyConfLocation(targetStore: Conf<ProvidersStore>): void
198
203
  }
199
204
  }
200
205
 
201
- // Using conf for storage - works in any Node.js environment without Electron
202
- // Note: conf stores data in plain JSON. For production, consider additional encryption.
206
+ // Provider metadata uses Conf; API keys are stored separately by ProviderSecretStore.
203
207
  const configPath = process.env["AGENT_INSPECTOR_CONFIG_PATH"];
204
208
 
205
209
  // The data directory is shared with the runtime config (`config.ts`).
@@ -222,6 +226,69 @@ export const store = new Conf<ProvidersStore>({
222
226
  ...(configPath !== undefined ? { path: configPath } : {}),
223
227
  });
224
228
 
229
+ const providerSecretStore: ProviderSecretStore = createProviderSecretStore(dirname(store.path));
230
+
231
+ function providerWithResolvedSecret(provider: ProviderConfig): ProviderConfig | null {
232
+ if (!providerSecretStore.isReference(provider.apiKey)) return provider;
233
+ const apiKey = providerSecretStore.read(provider.apiKey);
234
+ if (apiKey !== null) return { ...provider, apiKey };
235
+ logger.error(`[providers] Secret is missing or unreadable for provider "${provider.name}"`);
236
+ return null;
237
+ }
238
+
239
+ function persistProviders(
240
+ providers: readonly ProviderConfig[],
241
+ removedProviderIds: ReadonlySet<string> = new Set(),
242
+ ): void {
243
+ const existingReferences = new Map<string, string>();
244
+ const unresolvedRecords = new Map<string, ProviderConfig>();
245
+ for (const candidate of store.get("providers", [])) {
246
+ const parsed = ProviderConfigSchema.safeParse(candidate);
247
+ if (parsed.success && providerSecretStore.isReference(parsed.data.apiKey)) {
248
+ existingReferences.set(parsed.data.id, parsed.data.apiKey);
249
+ if (providerSecretStore.read(parsed.data.apiKey) === null) {
250
+ unresolvedRecords.set(parsed.data.id, parsed.data);
251
+ }
252
+ }
253
+ }
254
+
255
+ const persisted = providers.map((provider) => {
256
+ const existingReference = existingReferences.get(provider.id);
257
+ if (existingReference !== undefined) {
258
+ const existingSecret = providerSecretStore.read(existingReference);
259
+ if (existingSecret !== null && secretValuesEqual(existingSecret, provider.apiKey)) {
260
+ return { ...provider, apiKey: existingReference };
261
+ }
262
+ }
263
+
264
+ const reference = providerSecretStore.write(provider.id, provider.apiKey);
265
+ if (reference !== null) return { ...provider, apiKey: reference };
266
+ logger.error(
267
+ `[providers] SecretStore unavailable; provider "${provider.name}" remains in the legacy storage format`,
268
+ );
269
+ return provider;
270
+ });
271
+ const persistedIds = new Set(persisted.map((provider) => provider.id));
272
+ for (const [id, provider] of unresolvedRecords) {
273
+ if (!persistedIds.has(id) && !removedProviderIds.has(id)) persisted.push(provider);
274
+ }
275
+ store.set("providers", persisted);
276
+ }
277
+
278
+ function migratePlaintextProviderSecrets(): void {
279
+ const raw = store.get("providers", []);
280
+ let changed = false;
281
+ const migrated = raw.map((candidate) => {
282
+ const parsed = ProviderConfigSchema.safeParse(candidate);
283
+ if (!parsed.success || providerSecretStore.isReference(parsed.data.apiKey)) return candidate;
284
+ const reference = providerSecretStore.write(parsed.data.id, parsed.data.apiKey);
285
+ if (reference === null) return candidate;
286
+ changed = true;
287
+ return { ...parsed.data, apiKey: reference };
288
+ });
289
+ if (changed) store.set("providers", migrated);
290
+ }
291
+
225
292
  // Older builds stored providers in `<dataDir>/config.json`. Migrate that
226
293
  // shape before checking the older Conf-managed default location.
227
294
  migrateFromDataDirConfig(store, dataDir);
@@ -378,6 +445,10 @@ if (providersJson !== undefined) {
378
445
  }
379
446
  }
380
447
 
448
+ // One-time, backwards-compatible migration. Existing plaintext keys are moved
449
+ // out of providers.json; records retain only opaque references.
450
+ migratePlaintextProviderSecrets();
451
+
381
452
  export function getProviders(): ProviderConfig[] {
382
453
  const raw = store.get("providers", []);
383
454
  // Validate the on-disk store entry-by-entry so a single corrupted record
@@ -388,7 +459,8 @@ export function getProviders(): ProviderConfig[] {
388
459
  for (const candidate of raw) {
389
460
  const entry = ProviderConfigSchema.safeParse(candidate);
390
461
  if (entry.success) {
391
- valid.push(entry.data);
462
+ const resolved = providerWithResolvedSecret(entry.data);
463
+ if (resolved !== null) valid.push(resolved);
392
464
  continue;
393
465
  }
394
466
  let candidateName = "<unknown>";
@@ -411,7 +483,7 @@ export function getProvider(id: string): ProviderConfig | undefined {
411
483
  export function redactProvider(provider: ProviderConfig): ProviderConfig {
412
484
  return {
413
485
  ...provider,
414
- apiKey: maskApiKey(provider.apiKey),
486
+ apiKey: "configured",
415
487
  };
416
488
  }
417
489
 
@@ -454,7 +526,7 @@ export function addProvider(
454
526
  // merge the new models into it instead of creating a duplicate.
455
527
  const existing = providers.find(
456
528
  (p) =>
457
- p.apiKey === normalizedKey &&
529
+ secretValuesEqual(p.apiKey, normalizedKey) &&
458
530
  (p.anthropicBaseUrl ?? "") === (resolvedAnthropicUrl ?? "") &&
459
531
  (p.openaiBaseUrl ?? "") === (resolvedOpenaiUrl ?? "") &&
460
532
  (p.openaiResponsesBaseUrl ?? "") === (resolvedOpenaiResponsesUrl ?? ""),
@@ -474,7 +546,7 @@ export function addProvider(
474
546
  if (existingIndex >= 0) {
475
547
  providers[existingIndex] = enriched;
476
548
  }
477
- store.set("providers", providers);
549
+ persistProviders(providers);
478
550
  return enriched;
479
551
  }
480
552
 
@@ -506,7 +578,7 @@ export function addProvider(
506
578
  now,
507
579
  );
508
580
  providers.push(newProvider);
509
- store.set("providers", providers);
581
+ persistProviders(providers);
510
582
  return newProvider;
511
583
  }
512
584
 
@@ -554,7 +626,7 @@ export function updateProvider(
554
626
  );
555
627
  const index = providers.findIndex((p) => p.id === id);
556
628
  providers[index] = updated;
557
- store.set("providers", providers);
629
+ persistProviders(providers);
558
630
  return updated;
559
631
  }
560
632
 
@@ -563,7 +635,8 @@ export function deleteProvider(id: string): boolean {
563
635
  const filtered = providers.filter((p) => p.id !== id);
564
636
  if (filtered.length === providers.length) return false;
565
637
 
566
- store.set("providers", filtered);
638
+ persistProviders(filtered, new Set([id]));
639
+ providerSecretStore.delete(id);
567
640
  return true;
568
641
  }
569
642
 
@@ -665,7 +738,7 @@ export function importProviders(json: string): { imported: number; errors: strin
665
738
 
666
739
  // Single write: append all new providers at once
667
740
  if (newProviders.length > 0) {
668
- store.set("providers", [...existingProviders, ...newProviders]);
741
+ persistProviders([...existingProviders, ...newProviders]);
669
742
  }
670
743
  } catch (err) {
671
744
  return {
@@ -5,7 +5,6 @@ export type {
5
5
  CapturedLog,
6
6
  ApiFormat,
7
7
  JsonValue,
8
- RequestFormat,
9
8
  StreamingChunk,
10
9
  TokenUsage,
11
10
  } from "../contracts";
@@ -18,15 +17,9 @@ export type {
18
17
  export {
19
18
  AnthropicRequestSchema,
20
19
  AnthropicResponseSchema,
21
- InspectorRequestSchema,
22
- InspectorResponseSchema,
23
20
  SseEventSchema,
24
21
  } from "../contracts/anthropic";
25
- export type {
26
- AnthropicResponse,
27
- InspectorResponse,
28
- ResponseContentBlockType,
29
- } from "../contracts/anthropic";
22
+ export type { AnthropicResponse, ResponseContentBlockType } from "../contracts/anthropic";
30
23
 
31
24
  export {
32
25
  OpenAIRequestSchema,
@@ -8,12 +8,6 @@ type SqliteState =
8
8
  | { status: "ready"; db: unknown; path: string }
9
9
  | { status: "unavailable"; path: string; reason: string };
10
10
 
11
- type SqliteStatus = {
12
- available: boolean;
13
- path: string;
14
- reason: string | null;
15
- };
16
-
17
11
  const SQLITE_INDEX_FILE = "inspector.sqlite";
18
12
  const SQLITE_PACKAGE_NAME = "better-sqlite3";
19
13
  const BUN_SQLITE_MODULE_NAME = "bun:sqlite";
@@ -493,16 +487,6 @@ function entryFromRow(row: unknown): LogIndexEntry | null {
493
487
  };
494
488
  }
495
489
 
496
- export async function getSqliteLogIndexStatus(): Promise<SqliteStatus> {
497
- const state = await getSqliteState();
498
- switch (state.status) {
499
- case "ready":
500
- return { available: true, path: state.path, reason: null };
501
- case "unavailable":
502
- return { available: false, path: state.path, reason: state.reason };
503
- }
504
- }
505
-
506
490
  export async function getSqliteLogIndexMaxId(): Promise<number | null> {
507
491
  const state = await getSqliteState();
508
492
  if (state.status !== "ready") return null;
@@ -35,6 +35,7 @@ import {
35
35
  import { writeChunks } from "./chunkStorage";
36
36
  import { closeSqliteLogIndex } from "./sqliteLogIndex";
37
37
  import type { SessionInfo, SessionLogSummary } from "../lib/sessionInfoContract";
38
+ import { textByteLength } from "../lib/unknown";
38
39
  import { buildSessionInfo, buildSessionLogSummary } from "./sessionInfo";
39
40
  import type { CapturedLog } from "./schemas";
40
41
  import { CapturedLogSchema } from "./schemas";
@@ -263,11 +264,6 @@ function removeFromCache(id: number): void {
263
264
  removeLogsFromCursorPageIndexes(new Set([id]));
264
265
  }
265
266
 
266
- function textByteLength(value: string | null): number | null {
267
- if (value === null) return null;
268
- return Buffer.byteLength(value, "utf8");
269
- }
270
-
271
267
  export function compactLogForList(log: CapturedLog): CapturedLog {
272
268
  return {
273
269
  ...log,
@@ -1,4 +1,5 @@
1
1
  import type { ApiFormat } from "./schemas";
2
+ import { isRecord, readProperty } from "../lib/unknown";
2
3
 
3
4
  type JsonObject = Record<string, unknown>;
4
5
 
@@ -33,15 +34,6 @@ function parseJson(raw: string | null): ParsedJson {
33
34
  }
34
35
  }
35
36
 
36
- function isRecord(value: unknown): value is JsonObject {
37
- return typeof value === "object" && value !== null && !Array.isArray(value);
38
- }
39
-
40
- function readProperty(value: unknown, key: string): unknown {
41
- if (!isRecord(value)) return undefined;
42
- return Object.getOwnPropertyDescriptor(value, key)?.value;
43
- }
44
-
45
37
  function hasOwnProperty(value: JsonObject, key: string): boolean {
46
38
  return Object.getOwnPropertyDescriptor(value, key) !== undefined;
47
39
  }
@@ -22,6 +22,7 @@ import {
22
22
  setUpstreamHost,
23
23
  } from "../../proxy/upstream";
24
24
  import { computeTokensPerSecond } from "../../proxy/streamTiming";
25
+ import { safeFetch } from "../../lib/safeFetch";
25
26
 
26
27
  type ReplayRequest = {
27
28
  modifiedBody: string;
@@ -127,11 +128,22 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
127
128
  // Send request
128
129
  let upstreamRes: Response;
129
130
  try {
130
- upstreamRes = await fetch(upstreamUrl, {
131
- method: "POST",
132
- headers,
133
- body: modifiedBody,
134
- });
131
+ const fetchResult = await safeFetch(
132
+ upstreamUrl,
133
+ {
134
+ method: "POST",
135
+ headers,
136
+ body: modifiedBody,
137
+ },
138
+ { allowLoopback: process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1" },
139
+ );
140
+ if (!fetchResult.ok) {
141
+ return Response.json({
142
+ success: false,
143
+ error: fetchResult.reason,
144
+ } satisfies ReplayResponse);
145
+ }
146
+ upstreamRes = fetchResult.response;
135
147
  } catch (err) {
136
148
  return Response.json({
137
149
  success: false,
@@ -7,6 +7,7 @@ import {
7
7
  BUILTIN_MODEL_METADATA_SOURCE_URL,
8
8
  ProviderModelRegistrySchema,
9
9
  } from "../../lib/providerModelMetadata";
10
+ import { safeFetch } from "../../lib/safeFetch";
10
11
 
11
12
  const RefreshModelMetadataInputSchema = z.object({
12
13
  url: z.string().optional(),
@@ -79,7 +80,16 @@ export const Route = createFileRoute("/api/providers/$providerId/model-metadata"
79
80
 
80
81
  let response: Response;
81
82
  try {
82
- response = await fetch(sourceUrl, { headers: { Accept: "application/json" } });
83
+ const fetchResult = await safeFetch(sourceUrl, {
84
+ headers: { Accept: "application/json" },
85
+ });
86
+ if (!fetchResult.ok) {
87
+ return Response.json(
88
+ { error: `Model metadata URL blocked: ${fetchResult.reason}` },
89
+ { status: 400 },
90
+ );
91
+ }
92
+ response = fetchResult.response;
83
93
  } catch (err) {
84
94
  return Response.json(
85
95
  { error: `Failed to fetch model metadata: ${String(err)}` },
@@ -7,6 +7,9 @@ import {
7
7
  redactProvider,
8
8
  } from "../../proxy/providers";
9
9
  import { ProviderModelMetadataSchema } from "../../lib/providerContract";
10
+ import { evaluateUpstreamUrl } from "../../lib/ssrfGuard";
11
+
12
+ const ALLOW_LOOPBACK = process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1";
10
13
 
11
14
  const ProviderUpdateSchema = z.object({
12
15
  name: z.string().min(1, "Name is required").optional(),
@@ -28,26 +31,36 @@ const ProviderUpdateSchema = z.object({
28
31
  export const Route = createFileRoute("/api/providers/$providerId")({
29
32
  server: {
30
33
  handlers: {
31
- GET: ({ params, request }: { params: { providerId: string }; request: Request }) => {
32
- const url = new URL(request.url);
33
- const includeSecrets = url.searchParams.get("includeSecrets") === "1";
34
+ GET: ({ params }: { params: { providerId: string } }) => {
34
35
  const providers = getProviders();
35
36
  const provider = providers.find((p) => p.id === params.providerId);
36
37
  if (!provider) {
37
38
  return Response.json({ error: "Provider not found" }, { status: 404 });
38
39
  }
39
- return Response.json(includeSecrets ? provider : redactProvider(provider));
40
+ return Response.json(redactProvider(provider));
40
41
  },
41
42
  PUT: async ({ params, request }: { params: { providerId: string }; request: Request }) => {
42
43
  const parsed = ProviderUpdateSchema.safeParse(await request.json());
43
44
  if (!parsed.success) {
44
45
  return Response.json({ error: parsed.error.message }, { status: 400 });
45
46
  }
47
+ for (const candidate of [
48
+ parsed.data.baseUrl,
49
+ parsed.data.anthropicBaseUrl,
50
+ parsed.data.openaiBaseUrl,
51
+ parsed.data.openaiResponsesBaseUrl,
52
+ ]) {
53
+ if (candidate === undefined || candidate === "") continue;
54
+ const decision = evaluateUpstreamUrl(candidate, { allowLoopback: ALLOW_LOOPBACK });
55
+ if (!decision.allow) {
56
+ return Response.json({ error: decision.reason }, { status: 400 });
57
+ }
58
+ }
46
59
  const updated = updateProvider(params.providerId, parsed.data);
47
60
  if (!updated) {
48
61
  return Response.json({ error: "Provider not found" }, { status: 404 });
49
62
  }
50
- return Response.json(updated);
63
+ return Response.json(redactProvider(updated));
51
64
  },
52
65
  DELETE: ({ params }: { params: { providerId: string } }) => {
53
66
  const deleted = deleteProvider(params.providerId);
@@ -1,7 +1,10 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { z } from "zod";
3
- import { getProviders, addProvider, redactProviders } from "../../proxy/providers";
3
+ import { getProviders, addProvider, redactProvider, redactProviders } from "../../proxy/providers";
4
4
  import { ProviderModelMetadataSchema } from "../../lib/providerContract";
5
+ import { evaluateUpstreamUrl } from "../../lib/ssrfGuard";
6
+
7
+ const ALLOW_LOOPBACK = process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1";
5
8
 
6
9
  const ProviderInputSchema = z.object({
7
10
  name: z.string().min(1, "Name is required"),
@@ -22,17 +25,25 @@ const ProviderInputSchema = z.object({
22
25
  export const Route = createFileRoute("/api/providers")({
23
26
  server: {
24
27
  handlers: {
25
- GET: ({ request }: { request: Request }) => {
26
- const url = new URL(request.url);
27
- const includeSecrets = url.searchParams.get("includeSecrets") === "1";
28
- const providers = getProviders();
29
- return Response.json(includeSecrets ? providers : redactProviders(providers));
28
+ GET: () => {
29
+ return Response.json(redactProviders(getProviders()));
30
30
  },
31
31
  POST: async ({ request }: { request: Request }) => {
32
32
  const parsed = ProviderInputSchema.safeParse(await request.json());
33
33
  if (!parsed.success) {
34
34
  return Response.json({ error: parsed.error.message }, { status: 400 });
35
35
  }
36
+ for (const candidate of [
37
+ parsed.data.anthropicBaseUrl,
38
+ parsed.data.openaiBaseUrl,
39
+ parsed.data.openaiResponsesBaseUrl,
40
+ ]) {
41
+ if (candidate === undefined || candidate === "") continue;
42
+ const decision = evaluateUpstreamUrl(candidate, { allowLoopback: ALLOW_LOOPBACK });
43
+ if (!decision.allow) {
44
+ return Response.json({ error: decision.reason }, { status: 400 });
45
+ }
46
+ }
36
47
  const newProvider = addProvider(
37
48
  parsed.data.name,
38
49
  parsed.data.apiKey,
@@ -48,7 +59,7 @@ export const Route = createFileRoute("/api/providers")({
48
59
  parsed.data.modelMetadata,
49
60
  parsed.data.openaiResponsesBaseUrl,
50
61
  );
51
- return Response.json(newProvider, { status: 201 });
62
+ return Response.json(redactProvider(newProvider), { status: 201 });
52
63
  },
53
64
  },
54
65
  },