@tonyclaw/agent-inspector 3.0.29 → 3.0.31

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 (156) hide show
  1. package/.output/cli.js +791 -191
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/CompareDrawer-DB2KggLE.js +1 -0
  4. package/.output/public/assets/InspectorPet-CfwDw6kD.js +4108 -0
  5. package/.output/public/assets/ProxyViewerContainer-PcrO96_F.js +126 -0
  6. package/.output/public/assets/{ReplayDialog-CctnITbt.js → ReplayDialog-3Pl7EKjp.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-BrN0RBbL.js → RequestAnatomy-BcPjcHLX.js} +1 -1
  8. package/.output/public/assets/ResponseView-DOx51wV3.js +2 -0
  9. package/.output/public/assets/StreamingChunkSequence-LkYJWM1A.js +1 -0
  10. package/.output/public/assets/_sessionId-BSg32vC6.js +1 -0
  11. package/.output/public/assets/{_sessionId-DKM91udC.js → _sessionId-C0CA68QV.js} +1 -1
  12. package/.output/public/assets/{index-Bq-nqolG.js → index-BpBUqdAB.js} +1 -1
  13. package/.output/public/assets/index-D93dCgAQ.js +1 -0
  14. package/.output/public/assets/{index-DPiESBo2.js → index-FV5sxqx3.js} +6 -6
  15. package/.output/public/assets/index-VUsF4nhf.js +76 -0
  16. package/.output/public/assets/index-hyQHZeQI.css +1 -0
  17. package/.output/public/assets/{json-viewer-CfaDJwx8.js → json-viewer-WctIbAh_.js} +1 -1
  18. package/.output/public/assets/jszip.min-DnSIoTix.js +2 -0
  19. package/.output/public/assets/qwen-mMn3f5ul.webp +0 -0
  20. package/.output/server/_libs/jszip.mjs +35 -9
  21. package/.output/server/_libs/lucide-react.mjs +80 -80
  22. package/.output/server/_libs/radix-ui__react-dialog.mjs +3 -3
  23. package/.output/server/_libs/tanstack__react-router.mjs +1 -1
  24. package/.output/server/{_sessionId-CwGXPD4C.mjs → _sessionId-BHBK3VbF.mjs} +15 -15
  25. package/.output/server/_sessionId-CyuJ6Kfl.mjs +81 -0
  26. package/.output/server/_ssr/{CompareDrawer-R_P1-VNr.mjs → CompareDrawer-27amlv_a.mjs} +138 -103
  27. package/.output/server/_ssr/InspectorPet-CQnGXfLs.mjs +925 -0
  28. package/.output/server/_ssr/{ProxyViewerContainer-DUgxRNbj.mjs → ProxyViewerContainer-DlAl0uF-.mjs} +1569 -1646
  29. package/.output/server/_ssr/{ReplayDialog-De9wKKzS.mjs → ReplayDialog-5m2jtlLa.mjs} +18 -18
  30. package/.output/server/_ssr/{RequestAnatomy-Db4MxUYo.mjs → RequestAnatomy--1WtQNY9.mjs} +58 -58
  31. package/.output/server/_ssr/{ResponseView-8rDXOiNB.mjs → ResponseView-CgbqS8sr.mjs} +60 -71
  32. package/.output/server/_ssr/{StreamingChunkSequence-N9teZHkA.mjs → StreamingChunkSequence-BwpnopWx.mjs} +16 -16
  33. package/.output/server/_ssr/{index-D4DvByuW.mjs → index-CNVi1TzT.mjs} +15 -15
  34. package/.output/server/_ssr/index-DMCjH_4V.mjs +81 -0
  35. package/.output/server/_ssr/index.mjs +2 -2
  36. package/.output/server/_ssr/{json-viewer-BjoHRbjS.mjs → json-viewer-DNGN2uoe.mjs} +58 -58
  37. package/.output/server/_ssr/{router-BYlePDj8.mjs → router-CgsqLnk5.mjs} +16007 -12632
  38. package/.output/server/_tanstack-start-manifest_v-BH5f_kWV.mjs +4 -0
  39. package/.output/server/index.mjs +99 -71
  40. package/.output/workers/logFinalizer.worker.js +16908 -0
  41. package/.output/workers/sessionWorkerEntry.js +16904 -0
  42. package/README.md +21 -5
  43. package/package.json +6 -2
  44. package/src/assets/logos/qwen.webp +0 -0
  45. package/src/cli.ts +85 -10
  46. package/src/components/OnboardingBanner.tsx +41 -21
  47. package/src/components/ProxyViewer.tsx +198 -269
  48. package/src/components/ProxyViewerContainer.tsx +128 -17
  49. package/src/components/ecosystem/AgentLabDialog.tsx +166 -19
  50. package/src/components/errors/SafeErrorBoundary.tsx +201 -0
  51. package/src/components/pi-agent/PiAgentPanel.tsx +209 -35
  52. package/src/components/pi-agent/piAgentChatLogic.ts +263 -0
  53. package/src/components/providers/ImportWizardDialog.tsx +18 -42
  54. package/src/components/providers/ProviderLogo.tsx +1 -1
  55. package/src/components/providers/ProvidersPanel.tsx +8 -5
  56. package/src/components/providers/SettingsDialog.tsx +34 -11
  57. package/src/components/proxy-viewer/ApplicationBar.tsx +63 -0
  58. package/src/components/proxy-viewer/CompareDrawer.tsx +92 -49
  59. package/src/components/proxy-viewer/LiveConnectionStatus.tsx +186 -0
  60. package/src/components/proxy-viewer/LogEntry.tsx +283 -275
  61. package/src/components/proxy-viewer/RawExportWarning.tsx +77 -0
  62. package/src/components/proxy-viewer/SessionLoadProgressBar.tsx +61 -0
  63. package/src/components/proxy-viewer/ThreadConnector.tsx +16 -1
  64. package/src/components/proxy-viewer/TurnGroup.tsx +13 -2
  65. package/src/components/proxy-viewer/accessibility.ts +8 -0
  66. package/src/components/proxy-viewer/lazy.ts +4 -0
  67. package/src/components/proxy-viewer/liveConnectionState.ts +158 -0
  68. package/src/components/proxy-viewer/proxyViewerLogic.ts +10 -0
  69. package/src/components/proxy-viewer/viewerState.ts +2 -6
  70. package/src/components/ui/select.tsx +1 -1
  71. package/src/components/ui/tabs.tsx +3 -3
  72. package/src/components/ui/transient-toast.tsx +7 -6
  73. package/src/lib/apiClient.ts +17 -2
  74. package/src/lib/ecosystemContract.ts +34 -0
  75. package/src/lib/export-logs.ts +1 -1
  76. package/src/lib/piAgentContract.ts +80 -0
  77. package/src/lib/providerContract.ts +70 -4
  78. package/src/lib/providerImportContract.ts +27 -0
  79. package/src/lib/providerModelMetadata.ts +16 -7
  80. package/src/lib/resourceLimits.ts +152 -0
  81. package/src/lib/safeDiagnostic.ts +38 -0
  82. package/src/lib/useProviders.ts +4 -4
  83. package/src/mcp/server.ts +39 -6
  84. package/src/mcp/toolHandlers.ts +131 -4
  85. package/src/proxy/chunkStorage.ts +20 -6
  86. package/src/proxy/config.ts +20 -6
  87. package/src/proxy/dataDir.ts +3 -0
  88. package/src/proxy/ecosystemExecutionConfirmation.ts +114 -0
  89. package/src/proxy/ecosystemExecutionRoute.ts +116 -0
  90. package/src/proxy/ecosystemTasks.ts +48 -0
  91. package/src/proxy/evidenceExporter.ts +23 -9
  92. package/src/proxy/groupEvidenceExporter.ts +26 -5
  93. package/src/proxy/groupStore.ts +13 -3
  94. package/src/proxy/handler.ts +103 -20
  95. package/src/proxy/identityProxy.ts +333 -14
  96. package/src/proxy/logFinalizer.ts +73 -6
  97. package/src/proxy/logImportUpload.ts +128 -0
  98. package/src/proxy/logImporter.ts +321 -70
  99. package/src/proxy/logIndex.ts +16 -6
  100. package/src/proxy/logger.ts +294 -41
  101. package/src/proxy/privateDataPath.ts +183 -0
  102. package/src/proxy/providerScanStore.ts +87 -0
  103. package/src/proxy/providerSecretStore.ts +58 -19
  104. package/src/proxy/providers.ts +175 -58
  105. package/src/proxy/rawStreamCapture.ts +66 -5
  106. package/src/proxy/runStore.ts +13 -3
  107. package/src/proxy/runtimeAdmission.ts +52 -0
  108. package/src/proxy/runtimeHealth.ts +206 -0
  109. package/src/proxy/runtimeShutdown.ts +75 -0
  110. package/src/proxy/sessionArchive.ts +15 -2
  111. package/src/proxy/sessionProcess.ts +19 -0
  112. package/src/proxy/sessionRuntime.ts +7 -0
  113. package/src/proxy/shutdownCoordinator.ts +90 -0
  114. package/src/proxy/sqliteLogIndex.ts +18 -2
  115. package/src/proxy/store.ts +18 -2
  116. package/src/routes/__root.tsx +18 -1
  117. package/src/routes/api/alerts.summary.ts +12 -8
  118. package/src/routes/api/alerts.ts +27 -9
  119. package/src/routes/api/ecosystem.packages.$packageId.help.ts +3 -8
  120. package/src/routes/api/ecosystem.packages.$packageId.install.ts +3 -8
  121. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +3 -11
  122. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +3 -8
  123. package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +3 -8
  124. package/src/routes/api/health.ts +9 -2
  125. package/src/routes/api/knowledge.project-context.ts +11 -0
  126. package/src/routes/api/knowledge.search.ts +33 -2
  127. package/src/routes/api/logs.$id.body.ts +16 -2
  128. package/src/routes/api/logs.import.ts +7 -18
  129. package/src/routes/api/logs.stream.ts +147 -69
  130. package/src/routes/api/logs.ts +55 -14
  131. package/src/routes/api/providers.$providerId.model-metadata.ts +22 -4
  132. package/src/routes/api/providers.$providerId.ts +37 -6
  133. package/src/routes/api/providers.export.ts +34 -16
  134. package/src/routes/api/providers.import.ts +42 -8
  135. package/src/routes/api/providers.scan.ts +13 -8
  136. package/src/routes/api/providers.ts +36 -5
  137. package/src/routes/api/runs.ts +12 -12
  138. package/src/routes/api/sessions.ts +15 -8
  139. package/src/routes/index.tsx +6 -0
  140. package/src/routes/livez.ts +13 -0
  141. package/src/routes/readyz.ts +18 -0
  142. package/src/routes/session/$sessionId.tsx +6 -0
  143. package/src/services/piAgent.ts +131 -102
  144. package/src/services/piAgentRetrieval.ts +1180 -0
  145. package/styles/globals.css +19 -3
  146. package/.output/public/assets/CompareDrawer-Cb5ccPeX.js +0 -1
  147. package/.output/public/assets/ProxyViewerContainer-COBe5GTT.js +0 -4234
  148. package/.output/public/assets/ResponseView-Cqq87lX_.js +0 -3
  149. package/.output/public/assets/StreamingChunkSequence-CnTa9mCr.js +0 -1
  150. package/.output/public/assets/index-DTLNVcgc.js +0 -76
  151. package/.output/public/assets/index-DliqmwUw.css +0 -1
  152. package/.output/public/assets/qwen-CONDcHqt.png +0 -0
  153. package/.output/server/_tanstack-start-manifest_v-DMCA8KK3.mjs +0 -4
  154. package/src/assets/logos/mcp.png +0 -0
  155. package/src/assets/logos/qwen.png +0 -0
  156. package/src/components/ui/mcp-logo.tsx +0 -20
@@ -1,6 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { Buffer } from "node:buffer";
3
3
  import { timingSafeEqual } from "node:crypto";
4
+ import { isIP } from "node:net";
4
5
  import { lookupClientByPort } from "./socketTracker";
5
6
 
6
7
  const IDENTITY_HEADERS = {
@@ -10,6 +11,20 @@ const IDENTITY_HEADERS = {
10
11
  projectFolder: "x-agent-inspector-client-project-folder",
11
12
  } as const;
12
13
 
14
+ const IDENTITY_HEADER_PREFIX = "x-agent-inspector-";
15
+ const PROXY_TOKEN_HEADER = "x-agent-inspector-proxy-token";
16
+ const CSRF_HEADER = "x-agent-inspector-csrf";
17
+ const DEFAULT_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
18
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
19
+
20
+ type IdentityProxyRuntimeState = {
21
+ acceptingRequests: boolean;
22
+ activeRequests: number;
23
+ drainWaiters: Set<() => void>;
24
+ };
25
+
26
+ const identityProxyRuntimeStates = new WeakMap<http.Server, IdentityProxyRuntimeState>();
27
+
13
28
  const REQUEST_HOP_BY_HOP = new Set([
14
29
  "connection",
15
30
  "keep-alive",
@@ -33,6 +48,26 @@ const RESPONSE_HOP_BY_HOP = new Set([
33
48
  "upgrade",
34
49
  ]);
35
50
 
51
+ const BASE_SECURITY_HEADERS = {
52
+ "permissions-policy": "camera=(), microphone=(), geolocation=()",
53
+ "referrer-policy": "no-referrer",
54
+ "x-content-type-options": "nosniff",
55
+ "x-frame-options": "DENY",
56
+ } as const;
57
+
58
+ const DOCUMENT_CONTENT_SECURITY_POLICY = [
59
+ "default-src 'self'",
60
+ "base-uri 'self'",
61
+ "connect-src 'self'",
62
+ "font-src 'self' data:",
63
+ "frame-ancestors 'none'",
64
+ "img-src 'self' data: blob:",
65
+ "object-src 'none'",
66
+ "script-src 'self' 'unsafe-inline'",
67
+ "style-src 'self' 'unsafe-inline'",
68
+ "worker-src 'self' blob:",
69
+ ].join("; ");
70
+
36
71
  export type IdentityProxyOptions = {
37
72
  /** Public port to listen on. Clients point at this. */
38
73
  listenPort: number;
@@ -48,6 +83,16 @@ export type IdentityProxyOptions = {
48
83
  allowRemoteControl?: boolean;
49
84
  /** Optional Bearer token for remote control-plane API clients. */
50
85
  controlToken?: string;
86
+ /** Dedicated credential for non-loopback model-proxy clients. */
87
+ proxyToken?: string;
88
+ /** Temporary compatibility escape hatch for legacy unauthenticated remote proxy clients. */
89
+ allowUnauthenticatedRemoteProxy?: boolean;
90
+ /** Additional public hostnames accepted by the ingress Host boundary. */
91
+ trustedHosts?: readonly string[];
92
+ /** Maximum declared or streamed request body size. */
93
+ maxRequestBytes?: number;
94
+ /** Deadline for receiving and forwarding one request body. */
95
+ requestTimeoutMs?: number;
51
96
  };
52
97
 
53
98
  function isLoopbackPeer(address: string | undefined): boolean {
@@ -74,13 +119,100 @@ function tokensMatch(received: string | null, expected: string | undefined): boo
74
119
  );
75
120
  }
76
121
 
122
+ function firstHeader(value: string | readonly string[] | undefined): string | undefined {
123
+ return typeof value === "string" ? value : value?.[0];
124
+ }
125
+
126
+ function normalizedHostname(value: string): string | null {
127
+ const candidate = value.trim();
128
+ if (candidate.length === 0) return null;
129
+ try {
130
+ return new URL(`http://${candidate}`).hostname.toLowerCase().replace(/^\[|]$/g, "");
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function isLoopbackHostname(hostname: string): boolean {
137
+ return hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.");
138
+ }
139
+
140
+ export function isTrustedIdentityProxyHost(
141
+ host: string | undefined,
142
+ options: Pick<IdentityProxyOptions, "listenHost" | "trustedHosts">,
143
+ ): boolean {
144
+ if (host === undefined) return false;
145
+ const hostname = normalizedHostname(host);
146
+ if (hostname === null) return false;
147
+ if (isLoopbackHostname(hostname) || isIP(hostname) !== 0) return true;
148
+
149
+ const listenHostname = normalizedHostname(options.listenHost);
150
+ if (listenHostname !== null && hostname === listenHostname) return true;
151
+ return (options.trustedHosts ?? []).some(
152
+ (trustedHost) => normalizedHostname(trustedHost) === hostname,
153
+ );
154
+ }
155
+
156
+ function isSafeMethod(method: string | undefined): boolean {
157
+ const normalized = (method ?? "GET").toUpperCase();
158
+ return normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS";
159
+ }
160
+
161
+ function isPublicHealthPath(pathname: string): boolean {
162
+ return pathname === "/livez" || pathname === "/readyz" || pathname === "/api/health";
163
+ }
164
+
165
+ function configuredPositiveInteger(value: number | undefined, fallback: number): number {
166
+ return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback;
167
+ }
168
+
169
+ function declaredContentLength(value: string | undefined): number | null {
170
+ if (value === undefined) return null;
171
+ if (!/^\d+$/.test(value)) return Number.NaN;
172
+ const parsed = Number(value);
173
+ return Number.isSafeInteger(parsed) ? parsed : Number.NaN;
174
+ }
175
+
176
+ export function isBrowserMutationAllowed(input: {
177
+ path: string;
178
+ method?: string;
179
+ host?: string;
180
+ origin?: string;
181
+ secFetchSite?: string;
182
+ csrfToken?: string;
183
+ }): boolean {
184
+ if (isSafeMethod(input.method)) return true;
185
+ const pathname = new URL(input.path, "http://localhost").pathname;
186
+ if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
187
+
188
+ const fetchSite = input.secFetchSite?.toLowerCase();
189
+ const browserRequest = input.origin !== undefined || fetchSite !== undefined;
190
+ if (!browserRequest) return true;
191
+ if (fetchSite !== undefined && fetchSite !== "same-origin") return false;
192
+ if (input.origin !== undefined) {
193
+ try {
194
+ const originHost = new URL(input.origin).host.toLowerCase();
195
+ if (originHost !== input.host?.toLowerCase()) return false;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+ return input.csrfToken === "1";
201
+ }
202
+
77
203
  export function isIdentityProxyRequestAllowed(input: {
78
204
  path: string;
79
205
  remoteAddress?: string;
80
206
  authorization?: string;
207
+ proxyAuthorization?: string;
208
+ proxyTokenHeader?: string;
81
209
  options: Pick<
82
210
  IdentityProxyOptions,
83
- "restrictRemoteControl" | "allowRemoteControl" | "controlToken"
211
+ | "restrictRemoteControl"
212
+ | "allowRemoteControl"
213
+ | "controlToken"
214
+ | "proxyToken"
215
+ | "allowUnauthenticatedRemoteProxy"
84
216
  >;
85
217
  }): boolean {
86
218
  if (input.options.restrictRemoteControl !== true) return true;
@@ -88,8 +220,12 @@ export function isIdentityProxyRequestAllowed(input: {
88
220
  if (isLoopbackPeer(input.remoteAddress)) return true;
89
221
 
90
222
  const pathname = new URL(input.path, "http://localhost").pathname;
91
- if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
92
- if (pathname === "/api/health") return true;
223
+ if (pathname === "/proxy" || pathname.startsWith("/proxy/")) {
224
+ if (input.options.allowUnauthenticatedRemoteProxy === true) return true;
225
+ const proxyCredential = bearerToken(input.proxyAuthorization) ?? input.proxyTokenHeader ?? null;
226
+ return tokensMatch(proxyCredential, input.options.proxyToken);
227
+ }
228
+ if (isPublicHealthPath(pathname)) return true;
93
229
  return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
94
230
  }
95
231
 
@@ -119,6 +255,12 @@ export function isIdentityProxyRequestAllowed(input: {
119
255
  export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
120
256
  return new Promise((resolve, reject) => {
121
257
  const server = http.createServer();
258
+ const runtimeState: IdentityProxyRuntimeState = {
259
+ acceptingRequests: true,
260
+ activeRequests: 0,
261
+ drainWaiters: new Set(),
262
+ };
263
+ identityProxyRuntimeStates.set(server, runtimeState);
122
264
  // Eagerly resolve client identity on `connection` — at that point the
123
265
  // socket is fresh and the OS connection table still lists it as
124
266
  // ESTABLISHED. By the time `request` fires (let alone after we finish
@@ -131,6 +273,30 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
131
273
  }
132
274
  });
133
275
  server.on("request", (req, res) => {
276
+ if (!runtimeState.acceptingRequests) {
277
+ res.writeHead(503, {
278
+ "content-type": "application/json; charset=utf-8",
279
+ "cache-control": "no-store",
280
+ connection: "close",
281
+ "retry-after": "5",
282
+ });
283
+ res.end(JSON.stringify({ error: "Agent Inspector is shutting down" }));
284
+ req.resume();
285
+ return;
286
+ }
287
+
288
+ runtimeState.activeRequests += 1;
289
+ let released = false;
290
+ const release = (): void => {
291
+ if (released) return;
292
+ released = true;
293
+ runtimeState.activeRequests = Math.max(0, runtimeState.activeRequests - 1);
294
+ if (runtimeState.activeRequests !== 0) return;
295
+ for (const resolveDrain of runtimeState.drainWaiters) resolveDrain();
296
+ runtimeState.drainWaiters.clear();
297
+ };
298
+ res.once("finish", release);
299
+ res.once("close", release);
134
300
  void handleRequest(req, res, options).catch((err: unknown) => {
135
301
  const message = err instanceof Error ? err.message : String(err);
136
302
  if (!res.headersSent) {
@@ -147,16 +313,78 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
147
313
  });
148
314
  }
149
315
 
316
+ export function stopIdentityProxyAdmission(server: http.Server): void {
317
+ const state = identityProxyRuntimeStates.get(server);
318
+ if (state !== undefined) state.acceptingRequests = false;
319
+ }
320
+
321
+ export async function waitForIdentityProxyDrain(server: http.Server): Promise<void> {
322
+ const state = identityProxyRuntimeStates.get(server);
323
+ if (state === undefined || state.activeRequests === 0) return;
324
+ await new Promise<void>((resolve) => {
325
+ state.drainWaiters.add(resolve);
326
+ });
327
+ }
328
+
329
+ export async function closeIdentityProxyGracefully(server: http.Server): Promise<void> {
330
+ stopIdentityProxyAdmission(server);
331
+ const closed = new Promise<void>((resolve) => {
332
+ if (!server.listening) {
333
+ resolve();
334
+ return;
335
+ }
336
+ server.close(() => resolve());
337
+ });
338
+ await Promise.all([closed, waitForIdentityProxyDrain(server)]);
339
+ }
340
+
341
+ export function forceCloseIdentityProxy(server: http.Server): void {
342
+ stopIdentityProxyAdmission(server);
343
+ server.closeAllConnections();
344
+ server.close();
345
+ }
346
+
150
347
  async function handleRequest(
151
348
  req: http.IncomingMessage,
152
349
  res: http.ServerResponse,
153
350
  options: IdentityProxyOptions,
154
351
  ): Promise<void> {
352
+ if (!isTrustedIdentityProxyHost(req.headers.host, options)) {
353
+ res.writeHead(400, {
354
+ "content-type": "application/json; charset=utf-8",
355
+ "cache-control": "no-store",
356
+ });
357
+ res.end(JSON.stringify({ error: "Untrusted Host header" }));
358
+ req.resume();
359
+ return;
360
+ }
361
+
362
+ if (
363
+ !isBrowserMutationAllowed({
364
+ path: req.url ?? "/",
365
+ method: req.method,
366
+ host: req.headers.host,
367
+ origin: req.headers.origin,
368
+ secFetchSite: firstHeader(req.headers["sec-fetch-site"]),
369
+ csrfToken: firstHeader(req.headers[CSRF_HEADER]),
370
+ })
371
+ ) {
372
+ res.writeHead(403, {
373
+ "content-type": "application/json; charset=utf-8",
374
+ "cache-control": "no-store",
375
+ });
376
+ res.end(JSON.stringify({ error: "Browser mutation requires same-origin CSRF proof" }));
377
+ req.resume();
378
+ return;
379
+ }
380
+
155
381
  if (
156
382
  !isIdentityProxyRequestAllowed({
157
383
  path: req.url ?? "/",
158
384
  remoteAddress: req.socket.remoteAddress,
159
385
  authorization: req.headers.authorization,
386
+ proxyAuthorization: req.headers["proxy-authorization"],
387
+ proxyTokenHeader: firstHeader(req.headers[PROXY_TOKEN_HEADER]),
160
388
  options,
161
389
  })
162
390
  ) {
@@ -166,10 +394,10 @@ async function handleRequest(
166
394
  });
167
395
  res.end(
168
396
  JSON.stringify({
169
- error:
170
- "Remote control-plane access is disabled. Use the proxy endpoint or connect locally.",
397
+ error: "Remote access requires a credential scoped to this endpoint.",
171
398
  }),
172
399
  );
400
+ req.resume();
173
401
  return;
174
402
  }
175
403
 
@@ -185,17 +413,37 @@ async function handleRequest(
185
413
  }
186
414
  }
187
415
 
188
- // Buffer the request body TanStack Start needs the raw bytes intact
189
- // for SSE/streaming endpoints, so we can't decode-and-re-encode.
190
- const chunks: Buffer[] = [];
191
- for await (const chunk of req) {
192
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : toBuffer(chunk));
416
+ // Validate declared size before any body allocation, then forward the raw
417
+ // stream with backpressure while enforcing the actual-byte budget below.
418
+ const maxRequestBytes = configuredPositiveInteger(
419
+ options.maxRequestBytes,
420
+ DEFAULT_MAX_REQUEST_BYTES,
421
+ );
422
+ const contentLength = declaredContentLength(req.headers["content-length"]);
423
+ if (contentLength !== null && (!Number.isFinite(contentLength) || contentLength < 0)) {
424
+ res.writeHead(400, {
425
+ "content-type": "application/json; charset=utf-8",
426
+ "cache-control": "no-store",
427
+ });
428
+ res.end(JSON.stringify({ error: "Invalid Content-Length header" }));
429
+ req.resume();
430
+ return;
431
+ }
432
+ if (contentLength !== null && contentLength > maxRequestBytes) {
433
+ res.writeHead(413, {
434
+ "content-type": "application/json; charset=utf-8",
435
+ "cache-control": "no-store",
436
+ });
437
+ res.end(JSON.stringify({ error: `Request body exceeds ${String(maxRequestBytes)} bytes` }));
438
+ req.resume();
439
+ return;
193
440
  }
194
- const body = Buffer.concat(chunks);
195
441
 
196
442
  const upstreamHeaders: Record<string, string | string[]> = {};
197
443
  for (const [name, value] of Object.entries(req.headers)) {
198
- if (REQUEST_HOP_BY_HOP.has(name.toLowerCase())) continue;
444
+ const lowerName = name.toLowerCase();
445
+ if (REQUEST_HOP_BY_HOP.has(lowerName)) continue;
446
+ if (lowerName.startsWith(IDENTITY_HEADER_PREFIX)) continue;
199
447
  if (value === undefined) continue;
200
448
  upstreamHeaders[name] = value;
201
449
  }
@@ -212,6 +460,11 @@ async function handleRequest(
212
460
  }
213
461
  }
214
462
 
463
+ let terminal = false;
464
+ const requestTimeoutMs = configuredPositiveInteger(
465
+ options.requestTimeoutMs,
466
+ DEFAULT_REQUEST_TIMEOUT_MS,
467
+ );
215
468
  const proxyReq = http.request(
216
469
  {
217
470
  hostname: options.upstreamHost,
@@ -221,24 +474,90 @@ async function handleRequest(
221
474
  headers: upstreamHeaders,
222
475
  },
223
476
  (proxyRes) => {
477
+ if (terminal) {
478
+ proxyRes.resume();
479
+ return;
480
+ }
224
481
  const outHeaders: Record<string, string | string[]> = {};
225
482
  for (const [name, value] of Object.entries(proxyRes.headers)) {
226
483
  if (RESPONSE_HOP_BY_HOP.has(name.toLowerCase())) continue;
227
484
  if (value === undefined) continue;
228
485
  outHeaders[name] = value;
229
486
  }
487
+ for (const [name, value] of Object.entries(BASE_SECURITY_HEADERS)) {
488
+ outHeaders[name] = value;
489
+ }
490
+ const contentType = firstHeader(proxyRes.headers["content-type"]);
491
+ if (contentType?.toLowerCase().includes("text/html") === true) {
492
+ outHeaders["content-security-policy"] = DOCUMENT_CONTENT_SECURITY_POLICY;
493
+ }
230
494
  res.writeHead(proxyRes.statusCode ?? 502, outHeaders);
231
495
  proxyRes.pipe(res);
232
496
  },
233
497
  );
498
+ const deadline = setTimeout(() => {
499
+ failRequest(408, "Request body deadline exceeded");
500
+ }, requestTimeoutMs);
501
+ deadline.unref();
502
+
503
+ function clearDeadline(): void {
504
+ clearTimeout(deadline);
505
+ }
506
+
507
+ function failRequest(status: number, error: string): void {
508
+ if (terminal) return;
509
+ terminal = true;
510
+ clearDeadline();
511
+ proxyReq.destroy();
512
+ if (res.headersSent) {
513
+ res.destroy();
514
+ } else {
515
+ res.writeHead(status, {
516
+ "content-type": "application/json; charset=utf-8",
517
+ "cache-control": "no-store",
518
+ });
519
+ res.end(JSON.stringify({ error }));
520
+ }
521
+ req.resume();
522
+ }
523
+
234
524
  proxyReq.on("error", (err) => {
525
+ if (terminal) return;
526
+ terminal = true;
527
+ clearDeadline();
235
528
  if (!res.headersSent) {
236
529
  res.writeHead(502, { "content-type": "text/plain" });
237
530
  }
238
531
  res.end(`Upstream error: ${err.message}`);
239
532
  });
240
- if (body.length > 0) proxyReq.write(body);
241
- proxyReq.end();
533
+ proxyReq.on("drain", () => req.resume());
534
+ res.once("close", () => {
535
+ clearDeadline();
536
+ if (!res.writableEnded) proxyReq.destroy();
537
+ });
538
+
539
+ let receivedBytes = 0;
540
+ req.on("data", (chunk: unknown) => {
541
+ if (terminal) return;
542
+ const buffer = toBuffer(chunk);
543
+ receivedBytes += buffer.byteLength;
544
+ if (receivedBytes > maxRequestBytes) {
545
+ failRequest(413, `Request body exceeds ${String(maxRequestBytes)} bytes`);
546
+ return;
547
+ }
548
+ if (buffer.byteLength > 0 && !proxyReq.write(buffer)) req.pause();
549
+ });
550
+ req.on("end", () => {
551
+ if (terminal) return;
552
+ clearDeadline();
553
+ proxyReq.end();
554
+ });
555
+ req.on("aborted", () => {
556
+ failRequest(400, "Client aborted request body");
557
+ });
558
+ req.on("error", () => {
559
+ failRequest(400, "Request body stream failed");
560
+ });
242
561
  }
243
562
 
244
563
  // `Readable` chunks in Node are typed as `any` (they can be Buffer | string
@@ -1,12 +1,18 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { readFileSync, rmSync, statSync } from "node:fs";
2
+ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
5
  import { Worker } from "node:worker_threads";
4
6
  import { writeChunks } from "./chunkStorage";
5
7
  import { formatForPath } from "./formats";
6
8
  import { appendLogEntry, logger } from "./logger";
7
9
  import type { RawStreamSource } from "./rawStreamCapture";
8
10
  import type { CapturedLog } from "./schemas";
9
- import { getSessionProcess, isSessionProcessAvailable } from "./sessionProcess";
11
+ import {
12
+ getSessionProcess,
13
+ getSessionProcessPendingCount,
14
+ isSessionProcessAvailable,
15
+ } from "./sessionProcess";
10
16
  import { finalizeLogUpdate } from "./store";
11
17
  import { computeTokensPerSecond } from "./streamTiming";
12
18
  import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
@@ -500,9 +506,8 @@ export function commitFinalizeLogResult(result: FinalizeLogResult): void {
500
506
  // "process" → per-session child process (max isolation)
501
507
  // "worker" → shared Worker Thread pool
502
508
  // "inline" → synchronous in-process (debug / fallback)
503
- // The default is "process" only when its worker entry exists. Production
504
- // bundles that do not emit the standalone worker entry fall back to "inline"
505
- // instead of repeatedly spawning a failing child process.
509
+ // The default is the bounded shared Worker pool when its entry exists. The
510
+ // per-session process backend remains an explicit isolation option only.
506
511
  // For backward compatibility, FINALIZER_USE_WORKER=0 forces "inline".
507
512
 
508
513
  const RUNTIME: "process" | "worker" | "inline" = (() => {
@@ -511,6 +516,7 @@ const RUNTIME: "process" | "worker" | "inline" = (() => {
511
516
  if (mode === "process") return "process";
512
517
  if (mode === "worker") return "worker";
513
518
  if (mode === "inline") return "inline";
519
+ if (isLogFinalizerWorkerAvailable()) return "worker";
514
520
  return isSessionProcessAvailable() ? "process" : "inline";
515
521
  })();
516
522
 
@@ -558,11 +564,51 @@ const _pending = new Map<
558
564
  let _nextWorker = 0;
559
565
  let _jobSeq = 0;
560
566
 
567
+ function resolveLogFinalizerWorkerUrl(): URL {
568
+ const workerDir = process.env["AGENT_INSPECTOR_WORKER_DIR"];
569
+ if (workerDir !== undefined && workerDir.trim().length > 0) {
570
+ const builtWorkerPath = join(workerDir, "logFinalizer.worker.js");
571
+ if (existsSync(builtWorkerPath)) return pathToFileURL(builtWorkerPath);
572
+ }
573
+ return new URL("./logFinalizer.worker.ts", import.meta.url);
574
+ }
575
+
576
+ export function isLogFinalizerWorkerAvailable(): boolean {
577
+ return existsSync(fileURLToPath(resolveLogFinalizerWorkerUrl()));
578
+ }
579
+
580
+ export type FinalizerRuntimeHealth = {
581
+ runtime: "process" | "worker" | "inline";
582
+ artifactAvailable: boolean;
583
+ activeWorkers: number | null;
584
+ configuredWorkers: number;
585
+ pendingJobs: number;
586
+ };
587
+
588
+ /** Safe aggregate state used by readiness checks; it contains no job or session data. */
589
+ export function getFinalizerRuntimeHealth(): FinalizerRuntimeHealth {
590
+ const artifactAvailable =
591
+ RUNTIME === "worker"
592
+ ? isLogFinalizerWorkerAvailable()
593
+ : RUNTIME === "process"
594
+ ? isSessionProcessAvailable()
595
+ : isLogFinalizerWorkerAvailable() || isSessionProcessAvailable();
596
+ const activeWorkers =
597
+ _workers === null ? null : _workers.filter((worker) => worker.threadId > 0).length;
598
+ return {
599
+ runtime: RUNTIME,
600
+ artifactAvailable,
601
+ activeWorkers,
602
+ configuredWorkers: WORKER_COUNT,
603
+ pendingJobs: RUNTIME === "process" ? getSessionProcessPendingCount() : _pending.size,
604
+ };
605
+ }
606
+
561
607
  function getWorkers(): Worker[] {
562
608
  if (_workers !== null) return _workers;
563
609
  _workers = [];
564
610
  for (let i = 0; i < WORKER_COUNT; i++) {
565
- const w = new Worker(new URL("./logFinalizer.worker.ts", import.meta.url));
611
+ const w = new Worker(resolveLogFinalizerWorkerUrl());
566
612
  w.on("message", (raw: unknown) => {
567
613
  if (!isPlainRecord(raw)) {
568
614
  logger.error("[logFinalizer] Received malformed IPC message from worker, discarding");
@@ -627,3 +673,24 @@ export function executeFinalizeLogJob(job: FinalizeLogJob): Promise<void> {
627
673
  return undefined;
628
674
  });
629
675
  }
676
+
677
+ function detachLogFinalizerWorkers(): Worker[] {
678
+ const workers = _workers ?? [];
679
+ _workers = null;
680
+ for (const [id, entry] of _pending) {
681
+ _pending.delete(id);
682
+ entry.reject(new Error("Log finalizer worker pool is shutting down"));
683
+ }
684
+ return workers;
685
+ }
686
+
687
+ /** Close the shared worker pool after queued session tasks have drained. */
688
+ export async function closeLogFinalizerWorkers(): Promise<void> {
689
+ const workers = detachLogFinalizerWorkers();
690
+ await Promise.all(workers.map((worker) => worker.terminate()));
691
+ }
692
+
693
+ /** Best-effort immediate worker teardown used when the shutdown deadline expires. */
694
+ export function forceCloseLogFinalizerWorkers(): void {
695
+ for (const worker of detachLogFinalizerWorkers()) void worker.terminate();
696
+ }