@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.
- package/.output/cli.js +2 -2
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-DR3WgrId.js → CompareDrawer-CV7rBBty.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-B7PZaz5j.js → ProxyViewerContainer-CqRfBWJ3.js} +30 -30
- package/.output/public/assets/{ReplayDialog-Bt9jgdvJ.js → ReplayDialog-yDeF-z9y.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-C-4630lw.js → RequestAnatomy-BXxKNdgk.js} +1 -1
- package/.output/public/assets/{ResponseView-f_JrN1iQ.js → ResponseView-BIYNgUNB.js} +2 -2
- package/.output/public/assets/{StreamingChunkSequence-D8dBm1d8.js → StreamingChunkSequence-C1faiChu.js} +1 -1
- package/.output/public/assets/{_sessionId-CtoSDXDC.js → _sessionId-B34PrfCQ.js} +1 -1
- package/.output/public/assets/{index-B-rVc2xu.js → index-BLwqIxup.js} +2 -2
- package/.output/public/assets/{index-BNCySZM-.js → index-DOspyaW6.js} +1 -1
- package/.output/public/assets/{index-BtCnkWve.js → index-DT476lno.js} +1 -1
- package/.output/public/assets/{json-viewer-DT6QQY81.js → json-viewer-C1S08J_2.js} +1 -1
- package/.output/server/{_sessionId-DbRqqzqz.mjs → _sessionId-CmoBbtKT.mjs} +2 -2
- package/.output/server/_ssr/{CompareDrawer-CVhUSZu1.mjs → CompareDrawer-BsXnVK4e.mjs} +2 -2
- package/.output/server/_ssr/{ProxyViewerContainer-BNnDYtc5.mjs → ProxyViewerContainer-D-_7pyfx.mjs} +8 -8
- package/.output/server/_ssr/{ReplayDialog-DVkGajZU.mjs → ReplayDialog-deDQA2M5.mjs} +3 -3
- package/.output/server/_ssr/{RequestAnatomy-_FIfVjDR.mjs → RequestAnatomy-CX-cCRdX.mjs} +2 -2
- package/.output/server/_ssr/{ResponseView-Bnv5ejVA.mjs → ResponseView-D0LJR1qj.mjs} +2 -2
- package/.output/server/_ssr/{StreamingChunkSequence-vtwzxw7A.mjs → StreamingChunkSequence-CJRECetE.mjs} +2 -2
- package/.output/server/_ssr/{index-DFJbRoyq.mjs → index-CYWOXS4d.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-BkweASh3.mjs → json-viewer-CCneYiCn.mjs} +2 -2
- package/.output/server/_ssr/{router-gFd3g7WM.mjs → router-D-PAN8IK.mjs} +175 -63
- package/.output/server/{_tanstack-start-manifest_v-DeLutIlq.mjs → _tanstack-start-manifest_v-CVZ9yh4R.mjs} +1 -1
- package/.output/server/index.mjs +76 -76
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/cli/onboard.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/contracts/json.ts +7 -2
- package/src/lib/providerContract.ts +25 -5
- package/src/mcp/server.ts +12 -3
- package/src/mcp/toolHandlers.ts +6 -1
- package/src/proxy/handler.ts +17 -7
- package/src/proxy/logFinalizer.ts +76 -8
- package/src/proxy/logIndex.ts +16 -32
- package/src/proxy/providers.ts +22 -1
- package/src/proxy/sessionProcess.ts +15 -1
- 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:
|
|
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 `
|
|
139
|
-
opens only `9527` by default. Existing AI coding tool and MCP configs that still point at `
|
|
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:
|
|
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
package/src/cli/onboard.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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 =
|
|
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";
|
package/src/contracts/json.ts
CHANGED
|
@@ -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
|
|
27
|
-
*
|
|
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()).
|
|
43
|
+
models: z.array(z.string()).optional().default([]),
|
|
24
44
|
format: z.enum(["anthropic", "openai"]).optional(),
|
|
25
|
-
baseUrl:
|
|
26
|
-
anthropicBaseUrl:
|
|
27
|
-
openaiBaseUrl:
|
|
28
|
-
openaiResponsesBaseUrl:
|
|
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
|
|
126
|
-
//
|
|
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
|
|
package/src/mcp/toolHandlers.ts
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/src/proxy/handler.ts
CHANGED
|
@@ -197,14 +197,24 @@ function handleStreamingResponse(
|
|
|
197
197
|
return new Response("No response body", { status: STATUS_BAD_GATEWAY });
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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<
|
|
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", (
|
|
522
|
-
|
|
523
|
-
|
|
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)
|
|
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
|
}
|
package/src/proxy/logIndex.ts
CHANGED
|
@@ -468,38 +468,24 @@ export async function rebuildIndex(): Promise<LogIndex> {
|
|
|
468
468
|
return newIndex;
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
let
|
|
475
|
-
|
|
476
|
-
async function
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
511
|
-
releaseLockFn();
|
|
512
|
-
}
|
|
496
|
+
});
|
|
513
497
|
}
|
|
514
498
|
|
|
515
499
|
export function getCurrentLogFile(): string {
|
package/src/proxy/providers.ts
CHANGED
|
@@ -379,7 +379,28 @@ if (providersJson !== undefined) {
|
|
|
379
379
|
}
|
|
380
380
|
|
|
381
381
|
export function getProviders(): ProviderConfig[] {
|
|
382
|
-
|
|
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", (
|
|
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);
|
package/src/proxy/upstream.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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
|
}
|