@tonyclaw/agent-inspector 3.0.26 → 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.
- package/.output/cli.js +80 -11
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-SZRUDQc9.js → CompareDrawer-BVCAAbbu.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-BJvwOrSi.js → ProxyViewerContainer-CYOXB81n.js} +200 -200
- package/.output/public/assets/{ReplayDialog-DgfmqiM3.js → ReplayDialog-DMX23O5G.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CS6c7Qfn.js → RequestAnatomy-S17pYDRc.js} +1 -1
- package/.output/public/assets/{ResponseView-C0Ri_5gm.js → ResponseView-Cf1XrawD.js} +2 -2
- package/.output/public/assets/{StreamingChunkSequence-7dYcHWyZ.js → StreamingChunkSequence-BdOrVlCi.js} +1 -1
- package/.output/public/assets/{_sessionId-D-V84cgw.js → _sessionId-BnKGmJrR.js} +1 -1
- package/.output/public/assets/{index-DgFWCxOs.js → index-9fMLCU4d.js} +1 -1
- package/.output/public/assets/{index-CSIL7-MK.js → index-B1atk19i.js} +2 -2
- package/.output/public/assets/{index-DrVJsks2.js → index-gY3I-7-D.js} +1 -1
- package/.output/public/assets/{json-viewer-C9Ji3gYN.js → json-viewer-CpyYpPjx.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +6 -6
- package/.output/server/{_sessionId-BQ6YQOO1.mjs → _sessionId-eA_h2_s6.mjs} +4 -2
- package/.output/server/_ssr/{CompareDrawer-CqNzIeMR.mjs → CompareDrawer-K2k9G7r1.mjs} +5 -3
- package/.output/server/_ssr/{ProxyViewerContainer-CMC7Vsbw.mjs → ProxyViewerContainer-CbyBpCAj.mjs} +27 -60
- package/.output/server/_ssr/{ReplayDialog-D9p_LaTr.mjs → ReplayDialog-DacI2Iv0.mjs} +5 -3
- package/.output/server/_ssr/{RequestAnatomy-B4ge3qNZ.mjs → RequestAnatomy-gh-pAdkO.mjs} +4 -2
- package/.output/server/_ssr/{ResponseView-BCqMKApI.mjs → ResponseView-BRJKN50-.mjs} +5 -3
- package/.output/server/_ssr/{StreamingChunkSequence-BA4zTLE4.mjs → StreamingChunkSequence-BQLVEuWY.mjs} +4 -2
- package/.output/server/_ssr/{index-CzxhDjlq.mjs → index-DuSWrjbH.mjs} +4 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-B3qV6iEJ.mjs → json-viewer-DNs1ZjKI.mjs} +4 -2
- package/.output/server/_ssr/{router-CZe_R31M.mjs → router-Dgx6-ot3.mjs} +712 -217
- package/.output/server/{_tanstack-start-manifest_v-B50VTLrH.mjs → _tanstack-start-manifest_v-CoGjnkQD.mjs} +1 -1
- package/.output/server/index.mjs +64 -64
- package/README.md +8 -0
- package/package.json +4 -5
- package/src/cli.ts +43 -9
- package/src/components/providers/ProviderCard.tsx +3 -36
- package/src/components/providers/ProviderForm.tsx +13 -12
- package/src/components/providers/ProvidersPanel.tsx +2 -2
- package/src/components/providers/SettingsDialog.tsx +3 -3
- package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
- package/src/contracts/anthropic.ts +0 -5
- package/src/contracts/index.ts +0 -4
- package/src/contracts/log.ts +0 -1
- package/src/contracts/openai.ts +0 -3
- package/src/knowledge/openclawClient.ts +22 -9
- package/src/knowledge/openclawGatewayClient.ts +20 -13
- package/src/lib/alertContract.ts +0 -3
- package/src/lib/ecosystemContract.ts +0 -3
- package/src/lib/export-logs.ts +1 -4
- package/src/lib/safeFetch.ts +100 -0
- package/src/lib/ssrfGuard.ts +133 -0
- package/src/lib/stopReason.ts +1 -4
- package/src/lib/unknown.ts +27 -0
- package/src/lib/useProviders.ts +1 -1
- package/src/mcp/mode.ts +3 -9
- package/src/mcp/server.ts +5 -14
- package/src/mcp/toolHandlers.ts +45 -54
- package/src/proxy/constants.ts +17 -0
- package/src/proxy/formats/anthropic/handler.ts +5 -1
- package/src/proxy/formats/anthropic/schemas.ts +1 -7
- package/src/proxy/formats/index.ts +1 -1
- package/src/proxy/formats/openai/schemas.ts +0 -3
- package/src/proxy/formats/registry.ts +0 -3
- package/src/proxy/handler.ts +91 -12
- package/src/proxy/identityProxy.ts +74 -1
- package/src/proxy/logImporter.ts +82 -12
- package/src/proxy/logIndex.ts +1 -4
- package/src/proxy/providerSecretStore.ts +181 -0
- package/src/proxy/providers.ts +84 -11
- package/src/proxy/schemas.ts +1 -8
- package/src/proxy/sqliteLogIndex.ts +0 -16
- package/src/proxy/store.ts +1 -5
- package/src/proxy/toolSchemaWarnings.ts +1 -9
- package/src/routes/api/logs.$id.replay.ts +17 -5
- package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
- package/src/routes/api/providers.$providerId.ts +18 -5
- package/src/routes/api/providers.ts +18 -7
|
@@ -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
|
+
}
|
package/src/proxy/providers.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
741
|
+
persistProviders([...existingProviders, ...newProviders]);
|
|
669
742
|
}
|
|
670
743
|
} catch (err) {
|
|
671
744
|
return {
|
package/src/proxy/schemas.ts
CHANGED
|
@@ -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;
|
package/src/proxy/store.ts
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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: (
|
|
26
|
-
|
|
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
|
},
|