@tonyclaw/agent-inspector 3.0.19 → 3.0.20

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 (40) hide show
  1. package/.output/cli.js +2 -2
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DR3WgrId.js → CompareDrawer-CV7rBBty.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-B7PZaz5j.js → ProxyViewerContainer-CqRfBWJ3.js} +30 -30
  5. package/.output/public/assets/{ReplayDialog-Bt9jgdvJ.js → ReplayDialog-yDeF-z9y.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-C-4630lw.js → RequestAnatomy-BXxKNdgk.js} +1 -1
  7. package/.output/public/assets/{ResponseView-f_JrN1iQ.js → ResponseView-BIYNgUNB.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-D8dBm1d8.js → StreamingChunkSequence-C1faiChu.js} +1 -1
  9. package/.output/public/assets/{_sessionId-CtoSDXDC.js → _sessionId-B34PrfCQ.js} +1 -1
  10. package/.output/public/assets/{index-B-rVc2xu.js → index-BLwqIxup.js} +2 -2
  11. package/.output/public/assets/{index-BNCySZM-.js → index-DOspyaW6.js} +1 -1
  12. package/.output/public/assets/{index-BtCnkWve.js → index-DT476lno.js} +1 -1
  13. package/.output/public/assets/{json-viewer-DT6QQY81.js → json-viewer-C1S08J_2.js} +1 -1
  14. package/.output/server/{_sessionId-DbRqqzqz.mjs → _sessionId-CmoBbtKT.mjs} +2 -2
  15. package/.output/server/_ssr/{CompareDrawer-CVhUSZu1.mjs → CompareDrawer-BsXnVK4e.mjs} +2 -2
  16. package/.output/server/_ssr/{ProxyViewerContainer-BNnDYtc5.mjs → ProxyViewerContainer-D-_7pyfx.mjs} +8 -8
  17. package/.output/server/_ssr/{ReplayDialog-DVkGajZU.mjs → ReplayDialog-deDQA2M5.mjs} +3 -3
  18. package/.output/server/_ssr/{RequestAnatomy-_FIfVjDR.mjs → RequestAnatomy-CX-cCRdX.mjs} +2 -2
  19. package/.output/server/_ssr/{ResponseView-Bnv5ejVA.mjs → ResponseView-D0LJR1qj.mjs} +2 -2
  20. package/.output/server/_ssr/{StreamingChunkSequence-vtwzxw7A.mjs → StreamingChunkSequence-CJRECetE.mjs} +2 -2
  21. package/.output/server/_ssr/{index-DFJbRoyq.mjs → index-CYWOXS4d.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-BkweASh3.mjs → json-viewer-CCneYiCn.mjs} +2 -2
  24. package/.output/server/_ssr/{router-gFd3g7WM.mjs → router-D-PAN8IK.mjs} +175 -63
  25. package/.output/server/{_tanstack-start-manifest_v-DeLutIlq.mjs → _tanstack-start-manifest_v-CVZ9yh4R.mjs} +1 -1
  26. package/.output/server/index.mjs +76 -76
  27. package/README.md +4 -4
  28. package/package.json +1 -1
  29. package/src/cli/onboard.ts +1 -1
  30. package/src/cli.ts +2 -2
  31. package/src/contracts/json.ts +7 -2
  32. package/src/lib/providerContract.ts +25 -5
  33. package/src/mcp/server.ts +12 -3
  34. package/src/mcp/toolHandlers.ts +6 -1
  35. package/src/proxy/handler.ts +17 -7
  36. package/src/proxy/logFinalizer.ts +76 -8
  37. package/src/proxy/logIndex.ts +16 -32
  38. package/src/proxy/providers.ts +22 -1
  39. package/src/proxy/sessionProcess.ts +15 -1
  40. package/src/proxy/upstream.ts +4 -2
package/README.md CHANGED
@@ -130,13 +130,13 @@ The web UI runs at http://localhost:9527. The public proxy endpoint is also
130
130
  `http://localhost:9527/proxy`; internally Agent Inspector may run the app on a private helper port,
131
131
  but AI tools should use the public URL printed by the CLI.
132
132
 
133
- The legacy `http://localhost:25947` port is closed by default. If you need a temporary alias for
133
+ The legacy `http://localhost:9527` port is closed by default. If you need a temporary alias for
134
134
  older AI tool or MCP configs, start with `agent-inspector --legacy-port`.
135
135
 
136
136
  ## v3.0 Port Migration
137
137
 
138
- Agent Inspector v3.0 changes the default public port from `25947` to `9527`. The packaged CLI now
139
- opens only `9527` by default. Existing AI coding tool and MCP configs that still point at `25947`
138
+ Agent Inspector v3.0 changes the default public port from `9527` to `9527`. The packaged CLI now
139
+ opens only `9527` by default. Existing AI coding tool and MCP configs that still point at `9527`
140
140
  should be moved to `9527`, or you can temporarily start with `agent-inspector --legacy-port`.
141
141
 
142
142
  Use these endpoints for new configs:
@@ -266,7 +266,7 @@ configure `http://localhost:9527/api/mcp` directly. If it only supports stdio, c
266
266
  `agent-inspector-mcp stdio`; that command is only a bridge to `/api/mcp`, not a second Inspector
267
267
  server.
268
268
 
269
- Legacy MCP configs that still use `http://localhost:25947/api/mcp` require starting the CLI with
269
+ Legacy MCP configs that still use `http://localhost:9527/api/mcp` require starting the CLI with
270
270
  `agent-inspector --legacy-port`; new configs should use `http://localhost:9527/api/mcp`.
271
271
 
272
272
  Coding agents can use the MCP surface in three ways:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/agent-inspector",
3
- "version": "3.0.19",
3
+ "version": "3.0.20",
4
4
  "type": "module",
5
5
  "description": "Agent observability and knowledge capture layer for AI coding tools.",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@ const __dirname = dirname(__filename);
37
37
 
38
38
  const DEFAULT_PORT = 9527;
39
39
  const DEFAULT_MCP_URL = `http://localhost:${DEFAULT_PORT}/api/mcp`;
40
- const LEGACY_MCP_URL = "http://localhost:25947/api/mcp";
40
+ const LEGACY_MCP_URL = "http://localhost:9527/api/mcp";
41
41
  const SKILL_DIR_NAME = "agent-inspector-onboard";
42
42
  const SKILL_FILE_NAME = "SKILL.md";
43
43
  // Windows reserves `:` for NTFS alternate data streams, so a file named
package/src/cli.ts CHANGED
@@ -24,12 +24,12 @@ import { findPidsByPort, killPid, openUrlCommand } from "./proxy/platformCommand
24
24
  const __filename = fileURLToPath(import.meta.url);
25
25
  const __dirname = dirname(__filename);
26
26
 
27
- // v3 defaults the public entrypoint to 9527. The legacy 25947 alias can be
27
+ // v3 defaults the public entrypoint to 9527. The legacy 9527 alias can be
28
28
  // enabled explicitly for older AI-tool and MCP configs. The TanStack Start
29
29
  // runtime stays on a private upstream port and public ports reverse-proxy to
30
30
  // that same runtime, so logs, sessions, and MCP state remain unified.
31
31
  const DEFAULT_PORT = 9527;
32
- const LEGACY_ALIAS_PORT = 25947;
32
+ const LEGACY_ALIAS_PORT = 9527;
33
33
  const DEFAULT_UPSTREAM_PORT = 9529;
34
34
  const LOCAL_PROBE_TIMEOUT_MS = 2000;
35
35
  const BRANDED_WINDOWS_RUNTIME_EXE = "agent-inspector.exe";
@@ -23,10 +23,15 @@ export const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
23
23
  );
24
24
 
25
25
  /**
26
- * Re-types a value as `JsonValue` without re-walking it. Use this ONLY when
27
- * the value has already been validated and originated from `JSON.parse`.
26
+ * Re-types a value as `JsonValue` without a full recursive walk. A lightweight
27
+ * runtime guard rejects the most dangerous non-JSON types (undefined, bigint,
28
+ * functions) that would crash JSON.stringify downstream. Use when the value
29
+ * has already been validated by Zod or originated from JSON.parse.
28
30
  */
29
31
  export function trustAsJsonValue<T>(value: T): JsonValue {
32
+ if (value === undefined || typeof value === "bigint" || typeof value === "function") {
33
+ return null;
34
+ }
30
35
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
31
36
  return value as unknown as JsonValue;
32
37
  }
@@ -1,5 +1,25 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /**
4
+ * Validates that a string is a well-formed http(s) URL. Rejects non-HTTP
5
+ * schemes (file:, ftp:, etc.) that could be used for SSRF. Local/private
6
+ * addresses are allowed because this tool legitimately proxies to local LLM
7
+ * servers (Ollama, LM Studio, etc.).
8
+ */
9
+ function httpUrl(): z.ZodString {
10
+ return z.string().refine((val) => {
11
+ // Treat empty strings as "unset" so legacy/in-flight providers that
12
+ // store empty placeholders instead of null can still round-trip.
13
+ if (val.length === 0) return true;
14
+ try {
15
+ const parsed = new URL(val);
16
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
17
+ } catch {
18
+ return false;
19
+ }
20
+ }, "Must be a valid http(s) URL");
21
+ }
22
+
3
23
  export const ProviderModelMetadataSchema = z.object({
4
24
  model: z.string().min(1),
5
25
  contextWindow: z.number().int().positive().optional(),
@@ -20,12 +40,12 @@ export const ProviderConfigSchema = z.object({
20
40
  name: z.string(),
21
41
  apiKey: z.string(),
22
42
  model: z.string().optional(),
23
- models: z.array(z.string()).min(1),
43
+ models: z.array(z.string()).optional().default([]),
24
44
  format: z.enum(["anthropic", "openai"]).optional(),
25
- baseUrl: z.string().optional(),
26
- anthropicBaseUrl: z.string().optional(),
27
- openaiBaseUrl: z.string().optional(),
28
- openaiResponsesBaseUrl: z.string().optional(),
45
+ baseUrl: httpUrl().optional(),
46
+ anthropicBaseUrl: httpUrl().optional(),
47
+ openaiBaseUrl: httpUrl().optional(),
48
+ openaiResponsesBaseUrl: httpUrl().optional(),
29
49
  authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
30
50
  apiDocsUrl: z.string().optional(),
31
51
  modelMetadataUrl: z.string().optional(),
package/src/mcp/server.ts CHANGED
@@ -99,6 +99,7 @@ import {
99
99
  updateProviderImpl,
100
100
  type ToolResult,
101
101
  } from "./toolHandlers";
102
+ import { logger } from "../proxy/logger";
102
103
 
103
104
  // ---------------------------------------------------------------------------
104
105
  // Lazy singleton: build the McpServer + transport on first use.
@@ -122,9 +123,17 @@ function buildServer(): ServerPair {
122
123
  const transport = new WebStandardStreamableHTTPServerTransport({
123
124
  sessionIdGenerator: undefined, // stateless — see module docstring
124
125
  });
125
- // The connect() call is async but we don't need to await it here; the SDK
126
- // buffers the first request internally.
127
- void server.connect(transport);
126
+ // The connect() call is async handle rejection so a failed transport
127
+ // setup doesn't leave a broken singleton that silently fails all requests.
128
+ void server.connect(transport).then(
129
+ () => {},
130
+ (err) => {
131
+ logger.error(
132
+ "[mcp] server.connect failed:",
133
+ err instanceof Error ? err.message : String(err),
134
+ );
135
+ },
136
+ );
128
137
  return { server, transport };
129
138
  }
130
139
 
@@ -777,7 +777,12 @@ export async function diagnoseCaptureImpl(
777
777
  const runtime = await getRuntimeConfigImpl(callApi);
778
778
  if (runtime.isError === true) return runtime;
779
779
  const runtimeText = runtime.content[0]?.text ?? "{}";
780
- const runtimePayload: unknown = JSON.parse(runtimeText);
780
+ let runtimePayload: unknown = {};
781
+ try {
782
+ runtimePayload = JSON.parse(runtimeText);
783
+ } catch {
784
+ // Non-JSON runtime config text — treat as empty config and continue diagnostics
785
+ }
781
786
  const runtimeRecord = isRecord(runtimePayload) ? runtimePayload : {};
782
787
  const configRecord = isRecord(runtimeRecord["config"]) ? runtimeRecord["config"] : {};
783
788
  const captureMode = stringField(configRecord, "captureMode");
@@ -197,14 +197,24 @@ function handleStreamingResponse(
197
197
  return new Response("No response body", { status: STATUS_BAD_GATEWAY });
198
198
  }
199
199
 
200
- const loggedStream = upstreamRes.body.pipeThrough(transform);
201
-
202
- req.signal?.addEventListener("abort", () => {
203
- if (log.responseText === null) {
204
- logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
205
- scheduleStreamingFinalization("stream-abort");
206
- }
200
+ // pipeTo returns a promise that rejects when either side errors. Without
201
+ // this catch, an upstream connection drop skips flush() and leaves the log
202
+ // permanently un-finalized with a leaked temp file.
203
+ void upstreamRes.body.pipeTo(transform.writable).catch(() => {
204
+ scheduleStreamingFinalization("streaming");
207
205
  });
206
+ const loggedStream = transform.readable;
207
+
208
+ if (req.signal === undefined) {
209
+ logger.warn("[handler] req.signal is undefined; abort detection disabled for this request");
210
+ } else {
211
+ req.signal.addEventListener("abort", () => {
212
+ if (log.responseText === null) {
213
+ logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
214
+ scheduleStreamingFinalization("stream-abort");
215
+ }
216
+ });
217
+ }
208
218
 
209
219
  const responseHeaders = new Headers(upstreamRes.headers);
210
220
  responseHeaders.delete(HEADER_CONTENT_ENCODING);
@@ -10,6 +10,7 @@ import { getSessionProcess, isSessionProcessAvailable } from "./sessionProcess";
10
10
  import { finalizeLogUpdate } from "./store";
11
11
  import { computeTokensPerSecond } from "./streamTiming";
12
12
  import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
13
+ import { isPlainRecord, safeGetOwnProperty } from "../lib/objectUtils";
13
14
 
14
15
  type BaseFinalizeLogJob = {
15
16
  log: CapturedLog;
@@ -151,6 +152,11 @@ function applyOptionalStreamingTiming(log: CapturedLog, job: FinalizeLogJob): vo
151
152
  totalStreamMs: job.totalStreamMs,
152
153
  });
153
154
  return;
155
+ default: {
156
+ const _exhaustive: never = job;
157
+ logger.error(`[logFinalizer] Unhandled job type: ${JSON.stringify(_exhaustive)}`);
158
+ return;
159
+ }
154
160
  }
155
161
  }
156
162
 
@@ -241,6 +247,11 @@ function cleanupPathForRawStream(rawStream: string | RawStreamSource): string |
241
247
  return null;
242
248
  case "file":
243
249
  return rawStream.path;
250
+ default: {
251
+ const _exhaustive: never = rawStream;
252
+ logger.error(`[logFinalizer] Unhandled raw stream type: ${JSON.stringify(_exhaustive)}`);
253
+ return null;
254
+ }
244
255
  }
245
256
  }
246
257
 
@@ -263,6 +274,11 @@ function rawStreamByteLength(rawStream: string | RawStreamSource): number | null
263
274
  } catch {
264
275
  return null;
265
276
  }
277
+ default: {
278
+ const _exhaustive: never = rawStream;
279
+ logger.error(`[logFinalizer] Unhandled raw stream type: ${JSON.stringify(_exhaustive)}`);
280
+ return null;
281
+ }
266
282
  }
267
283
  }
268
284
 
@@ -304,6 +320,11 @@ function readRawStream(rawStream: string | RawStreamSource): {
304
320
  rawStreamText: readFileSync(rawStream.path, "utf-8"),
305
321
  cleanupRawStreamPath: rawStream.path,
306
322
  };
323
+ default: {
324
+ const _exhaustive: never = rawStream;
325
+ logger.error(`[logFinalizer] Unhandled raw stream type: ${JSON.stringify(_exhaustive)}`);
326
+ return { rawStreamText: "", cleanupRawStreamPath: null };
327
+ }
307
328
  }
308
329
  }
309
330
 
@@ -423,6 +444,7 @@ function finalizeStreamAbort(job: FinalizeStreamAbortLogJob, log: CapturedLog):
423
444
 
424
445
  export function buildFinalizeLogResult(job: FinalizeLogJob): FinalizeLogResult {
425
446
  const log = cloneLog(job.log);
447
+ const upstreamUrl = job.upstreamUrl;
426
448
 
427
449
  switch (job.type) {
428
450
  case "non-streaming":
@@ -431,6 +453,16 @@ export function buildFinalizeLogResult(job: FinalizeLogJob): FinalizeLogResult {
431
453
  return finalizeStreaming(job, log);
432
454
  case "stream-abort":
433
455
  return finalizeStreamAbort(job, log);
456
+ default: {
457
+ const _exhaustive: never = job;
458
+ logger.error(`[logFinalizer] Unhandled job type: ${JSON.stringify(_exhaustive)}`);
459
+ return {
460
+ log,
461
+ upstreamUrl,
462
+ error: "Unhandled job type",
463
+ cleanupRawStreamPath: null,
464
+ };
465
+ }
434
466
  }
435
467
  }
436
468
 
@@ -504,12 +536,25 @@ function resolveBuildPromise(job: FinalizeLogJob): Promise<FinalizeLogResult> {
504
536
  return executeBuildInWorker(job);
505
537
  case "inline":
506
538
  return Promise.resolve(buildFinalizeLogResult(job));
539
+ default: {
540
+ const _exhaustive: never = RUNTIME;
541
+ logger.error(`[logFinalizer] Unhandled runtime: ${JSON.stringify(_exhaustive)}`);
542
+ return Promise.resolve({
543
+ log: job.log,
544
+ upstreamUrl: job.upstreamUrl,
545
+ error: "Unhandled runtime",
546
+ cleanupRawStreamPath: null,
547
+ });
548
+ }
507
549
  }
508
550
  }
509
551
 
510
552
  const WORKER_COUNT = Math.max(1, Number(process.env["FINALIZER_WORKER_COUNT"]) || 4);
511
553
  let _workers: Worker[] | null = null;
512
- const _pending = new Map<string, (result: FinalizeLogResult) => void>();
554
+ const _pending = new Map<
555
+ string,
556
+ { resolve: (result: FinalizeLogResult) => void; reject: (err: Error) => void }
557
+ >();
513
558
  let _nextWorker = 0;
514
559
  let _jobSeq = 0;
515
560
 
@@ -518,11 +563,24 @@ function getWorkers(): Worker[] {
518
563
  _workers = [];
519
564
  for (let i = 0; i < WORKER_COUNT; i++) {
520
565
  const w = new Worker(new URL("./logFinalizer.worker.ts", import.meta.url));
521
- w.on("message", (msg: { id: string; result: FinalizeLogResult }) => {
522
- const resolve = _pending.get(msg.id);
523
- if (resolve !== undefined) {
566
+ w.on("message", (raw: unknown) => {
567
+ if (!isPlainRecord(raw)) {
568
+ logger.error("[logFinalizer] Received malformed IPC message from worker, discarding");
569
+ return;
570
+ }
571
+ const id = safeGetOwnProperty(raw, "id");
572
+ const result = safeGetOwnProperty(raw, "result");
573
+ if (typeof id !== "string" || !isPlainRecord(result)) {
574
+ logger.error("[logFinalizer] Received malformed IPC message from worker, discarding");
575
+ return;
576
+ }
577
+ // Runtime shape validated above; the cast bridges unknown → FinalizeLogResult.
578
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
579
+ const msg = { id, result: result as FinalizeLogResult };
580
+ const entry = _pending.get(msg.id);
581
+ if (entry !== undefined) {
524
582
  _pending.delete(msg.id);
525
- resolve(msg.result);
583
+ entry.resolve(msg.result);
526
584
  }
527
585
  });
528
586
  w.on("error", (err) => {
@@ -530,6 +588,13 @@ function getWorkers(): Worker[] {
530
588
  "[logFinalizer] Worker error:",
531
589
  err instanceof Error ? err.message : String(err),
532
590
  );
591
+ // Reject all pending jobs for this worker so callers don't hang forever
592
+ for (const [id, entry] of _pending) {
593
+ _pending.delete(id);
594
+ entry.reject(
595
+ new Error(`Worker crashed: ${err instanceof Error ? err.message : String(err)}`),
596
+ );
597
+ }
533
598
  });
534
599
  _workers.push(w);
535
600
  }
@@ -537,15 +602,18 @@ function getWorkers(): Worker[] {
537
602
  }
538
603
 
539
604
  export function executeBuildInWorker(job: FinalizeLogJob): Promise<FinalizeLogResult> {
540
- return new Promise((resolve) => {
605
+ return new Promise((resolve, reject) => {
541
606
  const list = getWorkers();
542
607
  // getWorkers() always returns at least 1 worker; the array index is safe
543
608
  const idx = _nextWorker % list.length;
544
609
  _nextWorker++;
545
610
  const w = list[idx];
546
- if (w === undefined) return; // unreachable, satisfies type-checker
611
+ if (w === undefined) {
612
+ reject(new Error("No workers available"));
613
+ return;
614
+ }
547
615
  const id = String(++_jobSeq);
548
- _pending.set(id, resolve);
616
+ _pending.set(id, { resolve, reject });
549
617
  w.postMessage({ id, job });
550
618
  });
551
619
  }
@@ -468,38 +468,24 @@ export async function rebuildIndex(): Promise<LogIndex> {
468
468
  return newIndex;
469
469
  }
470
470
 
471
- // Async mutex for atomic ID generation to prevent race conditions
472
- // Uses a promise queue instead of busy-waiting
473
- let idGenerationPromise: Promise<void> | null = null;
474
- let releaseLock: (() => void) | null = null;
475
-
476
- async function acquireLock(): Promise<void> {
477
- if (releaseLock === null) {
478
- idGenerationPromise = new Promise<void>((resolve) => {
479
- releaseLock = resolve;
480
- });
481
- } else {
482
- // Wait for the previous lock to be released
483
- await idGenerationPromise;
484
- // After waiting, we need to create a new promise for the next waiter
485
- idGenerationPromise = new Promise<void>((resolve) => {
486
- releaseLock = resolve;
487
- });
488
- }
489
- }
490
-
491
- function releaseLockFn(): void {
492
- if (releaseLock) {
493
- const resolve = releaseLock;
494
- releaseLock = null;
495
- idGenerationPromise = null;
496
- resolve();
497
- }
471
+ // Promise-chaining queue for atomic ID generation. Each caller chains on the
472
+ // previous promise, guaranteeing mutual exclusion without the broken
473
+ // releaseLock pattern that allowed concurrent entry when 3+ callers waited.
474
+ let idGenerationChain: Promise<void> = Promise.resolve();
475
+
476
+ async function withIdLock<T>(fn: () => Promise<T>): Promise<T> {
477
+ const run = idGenerationChain.then(fn);
478
+ // Keep the chain alive regardless of success/failure so the next caller
479
+ // never sees a rejected antecedent.
480
+ idGenerationChain = run.then(
481
+ () => {},
482
+ () => {},
483
+ );
484
+ return run;
498
485
  }
499
486
 
500
487
  export async function getNextLogId(): Promise<number> {
501
- await acquireLock();
502
- try {
488
+ return withIdLock(async () => {
503
489
  const index = await loadIndex();
504
490
  const sqliteMaxId = await getSqliteLogIndexMaxId();
505
491
  const nextId = Math.max(index.maxId, sqliteMaxId ?? 0) + 1;
@@ -507,9 +493,7 @@ export async function getNextLogId(): Promise<number> {
507
493
  // Synchronously update the index in memory (disk write is deferred via batching)
508
494
  cachedIndex = index;
509
495
  return nextId;
510
- } finally {
511
- releaseLockFn();
512
- }
496
+ });
513
497
  }
514
498
 
515
499
  export function getCurrentLogFile(): string {
@@ -379,7 +379,28 @@ if (providersJson !== undefined) {
379
379
  }
380
380
 
381
381
  export function getProviders(): ProviderConfig[] {
382
- return store.get("providers", []);
382
+ const raw = store.get("providers", []);
383
+ // Validate the on-disk store entry-by-entry so a single corrupted record
384
+ // cannot wipe out the entire provider list. The whole-store safeParse was
385
+ // too strict — once any legacy/in-flight provider failed validation the
386
+ // whole list was discarded, which broke `updateProvider` and routing.
387
+ const valid: ProviderConfig[] = [];
388
+ for (const candidate of raw) {
389
+ const entry = ProviderConfigSchema.safeParse(candidate);
390
+ if (entry.success) {
391
+ valid.push(entry.data);
392
+ continue;
393
+ }
394
+ let candidateName = "<unknown>";
395
+ if (typeof candidate === "object" && candidate !== null) {
396
+ const nameResult = z.object({ name: z.string() }).safeParse(candidate);
397
+ if (nameResult.success) candidateName = nameResult.data.name;
398
+ }
399
+ logger.warn(
400
+ `[providers] Skipping invalid on-disk provider "${candidateName}": ${entry.error.message}`,
401
+ );
402
+ }
403
+ return valid;
383
404
  }
384
405
 
385
406
  export function getProvider(id: string): ProviderConfig | undefined {
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { logger } from "./logger";
5
5
  import type { FinalizeLogJob, FinalizeLogResult } from "./logFinalizer";
6
+ import { isPlainRecord, safeGetOwnProperty } from "../lib/objectUtils";
6
7
 
7
8
  const IDLE_TIMEOUT_MS = Number(process.env["SESSION_PROCESS_IDLE_MS"]) || 5 * 60 * 1000; // 5 min default
8
9
  const MAX_RESTARTS = 3;
@@ -53,7 +54,20 @@ export class SessionProcess {
53
54
 
54
55
  this.restartCount += 1;
55
56
 
56
- this.child.on("message", (msg: { id: string; result: FinalizeLogResult }) => {
57
+ this.child.on("message", (raw: unknown) => {
58
+ if (!isPlainRecord(raw)) {
59
+ logger.error("[sessionProcess] Received malformed IPC message from worker, discarding");
60
+ return;
61
+ }
62
+ const id = safeGetOwnProperty(raw, "id");
63
+ const result = safeGetOwnProperty(raw, "result");
64
+ if (typeof id !== "string" || !isPlainRecord(result)) {
65
+ logger.error("[sessionProcess] Received malformed IPC message from worker, discarding");
66
+ return;
67
+ }
68
+ // Runtime shape validated above; the cast bridges unknown → FinalizeLogResult.
69
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
70
+ const msg = { id, result: result as FinalizeLogResult };
57
71
  const pending = this.pending.get(msg.id);
58
72
  if (pending !== undefined) {
59
73
  this.pending.delete(msg.id);
@@ -1,4 +1,5 @@
1
1
  import type { ProviderConfig } from "./providers";
2
+ import { logger } from "./logger";
2
3
  import {
3
4
  AUTH_HEADER_X_API_KEY,
4
5
  DEFAULT_OPENAI_UPSTREAM,
@@ -77,8 +78,9 @@ export function setUpstreamHost(headers: Headers, upstreamBase: string): void {
77
78
  try {
78
79
  headers.set(HEADER_HOST, new URL(upstreamBase).host);
79
80
  } catch {
80
- // Invalid provider URLs are rejected by the UI. Retain the historical default
81
- // for imported or manually edited config that bypassed that validation.
81
+ logger.warn(
82
+ `[upstream] Invalid provider URL "${upstreamBase}" falling back to api.anthropic.com for Host header`,
83
+ );
82
84
  headers.set(HEADER_HOST, "api.anthropic.com");
83
85
  }
84
86
  }