@tonyclaw/agent-inspector 3.0.28 → 3.0.30

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-B6cXJohL.js +1 -0
  4. package/.output/public/assets/InspectorPet-mAzeGmEE.js +4108 -0
  5. package/.output/public/assets/ProxyViewerContainer-CkFWv_Nm.js +126 -0
  6. package/.output/public/assets/{ReplayDialog-CZMApaF4.js → ReplayDialog-DodiTL6E.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-DNWccR7O.js → RequestAnatomy-B3IauToI.js} +1 -1
  8. package/.output/public/assets/ResponseView-Hn-5ordK.js +3 -0
  9. package/.output/public/assets/StreamingChunkSequence-vLDOVQM_.js +1 -0
  10. package/.output/public/assets/_sessionId-BFH4TOaI.js +1 -0
  11. package/.output/public/assets/{_sessionId-DRfvuE2k.js → _sessionId-Cj_HIFZx.js} +1 -1
  12. package/.output/public/assets/{index-D4HkS7Ct.js → index-Bmj2lcWE.js} +1 -1
  13. package/.output/public/assets/index-CgJj-HBC.css +1 -0
  14. package/.output/public/assets/index-Ch7uqnCT.js +1 -0
  15. package/.output/public/assets/index-DkDdKvLl.js +76 -0
  16. package/.output/public/assets/{index-B3aAuqWq.js → index-ZlhqCrSg.js} +6 -6
  17. package/.output/public/assets/{json-viewer-wuJSKaed.js → json-viewer-BdrnVQu-.js} +1 -1
  18. package/.output/public/assets/jszip.min-CS_nt_o1.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 +86 -86
  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-BJTNiP_O.mjs → _sessionId-C5S3pGZd.mjs} +15 -15
  25. package/.output/server/_sessionId-g6D69lKq.mjs +81 -0
  26. package/.output/server/_ssr/{CompareDrawer-BMRH3N97.mjs → CompareDrawer-CqUmGHal.mjs} +138 -103
  27. package/.output/server/_ssr/InspectorPet-DI0UJd01.mjs +925 -0
  28. package/.output/server/_ssr/{ProxyViewerContainer-AFwcHAIR.mjs → ProxyViewerContainer-CvsUmbJ3.mjs} +1216 -1659
  29. package/.output/server/_ssr/{ReplayDialog-CbWvRHiN.mjs → ReplayDialog-DS48dpFG.mjs} +18 -18
  30. package/.output/server/_ssr/{RequestAnatomy-C4kcPyqN.mjs → RequestAnatomy-DxYVQU1C.mjs} +58 -58
  31. package/.output/server/_ssr/{ResponseView-D8VYt-gZ.mjs → ResponseView-BLXW73eq.mjs} +60 -60
  32. package/.output/server/_ssr/{StreamingChunkSequence-BUUo6vU0.mjs → StreamingChunkSequence-DD0iFuy0.mjs} +16 -16
  33. package/.output/server/_ssr/{index-DTwMEEXO.mjs → index-B10LIaK0.mjs} +15 -15
  34. package/.output/server/_ssr/index-RHVJnU2p.mjs +81 -0
  35. package/.output/server/_ssr/index.mjs +2 -2
  36. package/.output/server/_ssr/{json-viewer-BqF1pSQD.mjs → json-viewer-LDu2XBaK.mjs} +58 -58
  37. package/.output/server/_ssr/{router-C8BOns7y.mjs → router-GKvrwifJ.mjs} +9005 -6488
  38. package/.output/server/_tanstack-start-manifest_v-Df2n4mbT.mjs +4 -0
  39. package/.output/server/index.mjs +105 -77
  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/clients/ClientLogo.tsx +9 -6
  50. package/src/components/ecosystem/AgentLabDialog.tsx +166 -19
  51. package/src/components/errors/SafeErrorBoundary.tsx +201 -0
  52. package/src/components/pi-agent/PiAgentPanel.tsx +4 -1
  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/ConversationGroupList.tsx +49 -34
  60. package/src/components/proxy-viewer/ConversationHeader.tsx +22 -18
  61. package/src/components/proxy-viewer/LiveConnectionStatus.tsx +186 -0
  62. package/src/components/proxy-viewer/LogEntry.tsx +283 -275
  63. package/src/components/proxy-viewer/RawExportWarning.tsx +77 -0
  64. package/src/components/proxy-viewer/SessionLoadProgressBar.tsx +61 -0
  65. package/src/components/proxy-viewer/ThreadConnector.tsx +16 -1
  66. package/src/components/proxy-viewer/TurnGroup.tsx +13 -2
  67. package/src/components/proxy-viewer/accessibility.ts +8 -0
  68. package/src/components/proxy-viewer/lazy.ts +4 -0
  69. package/src/components/proxy-viewer/liveConnectionState.ts +158 -0
  70. package/src/components/proxy-viewer/proxyViewerLogic.ts +10 -0
  71. package/src/components/proxy-viewer/viewerState.ts +2 -6
  72. package/src/components/ui/select.tsx +1 -1
  73. package/src/components/ui/tabs.tsx +3 -3
  74. package/src/components/ui/transient-toast.tsx +1 -1
  75. package/src/lib/apiClient.ts +17 -2
  76. package/src/lib/ecosystemContract.ts +34 -0
  77. package/src/lib/export-logs.ts +1 -1
  78. package/src/lib/providerContract.ts +70 -4
  79. package/src/lib/providerImportContract.ts +27 -0
  80. package/src/lib/providerModelMetadata.ts +16 -7
  81. package/src/lib/resourceLimits.ts +152 -0
  82. package/src/lib/safeDiagnostic.ts +38 -0
  83. package/src/lib/useProviders.ts +4 -4
  84. package/src/lib/utils.ts +5 -1
  85. package/src/mcp/server.ts +39 -6
  86. package/src/mcp/toolHandlers.ts +131 -4
  87. package/src/proxy/chunkStorage.ts +20 -6
  88. package/src/proxy/config.ts +20 -6
  89. package/src/proxy/dataDir.ts +3 -0
  90. package/src/proxy/ecosystemExecutionConfirmation.ts +114 -0
  91. package/src/proxy/ecosystemExecutionRoute.ts +116 -0
  92. package/src/proxy/ecosystemTasks.ts +48 -0
  93. package/src/proxy/evidenceExporter.ts +23 -9
  94. package/src/proxy/groupEvidenceExporter.ts +26 -5
  95. package/src/proxy/groupStore.ts +13 -3
  96. package/src/proxy/handler.ts +103 -20
  97. package/src/proxy/identityProxy.ts +333 -14
  98. package/src/proxy/logFinalizer.ts +73 -6
  99. package/src/proxy/logImportUpload.ts +128 -0
  100. package/src/proxy/logImporter.ts +321 -70
  101. package/src/proxy/logIndex.ts +16 -6
  102. package/src/proxy/logger.ts +294 -41
  103. package/src/proxy/privateDataPath.ts +183 -0
  104. package/src/proxy/providerScanStore.ts +87 -0
  105. package/src/proxy/providerSecretStore.ts +58 -19
  106. package/src/proxy/providers.ts +175 -58
  107. package/src/proxy/rawStreamCapture.ts +66 -5
  108. package/src/proxy/runStore.ts +13 -3
  109. package/src/proxy/runtimeAdmission.ts +52 -0
  110. package/src/proxy/runtimeHealth.ts +206 -0
  111. package/src/proxy/runtimeShutdown.ts +75 -0
  112. package/src/proxy/sessionArchive.ts +15 -2
  113. package/src/proxy/sessionProcess.ts +19 -0
  114. package/src/proxy/sessionRuntime.ts +7 -0
  115. package/src/proxy/shutdownCoordinator.ts +90 -0
  116. package/src/proxy/sqliteLogIndex.ts +18 -2
  117. package/src/proxy/store.ts +18 -2
  118. package/src/routes/__root.tsx +18 -1
  119. package/src/routes/api/alerts.summary.ts +12 -8
  120. package/src/routes/api/alerts.ts +27 -9
  121. package/src/routes/api/ecosystem.packages.$packageId.help.ts +3 -8
  122. package/src/routes/api/ecosystem.packages.$packageId.install.ts +3 -8
  123. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +3 -11
  124. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +3 -8
  125. package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +3 -8
  126. package/src/routes/api/health.ts +9 -2
  127. package/src/routes/api/knowledge.project-context.ts +11 -0
  128. package/src/routes/api/knowledge.search.ts +33 -2
  129. package/src/routes/api/logs.$id.body.ts +16 -2
  130. package/src/routes/api/logs.import.ts +7 -18
  131. package/src/routes/api/logs.stream.ts +147 -69
  132. package/src/routes/api/logs.ts +55 -14
  133. package/src/routes/api/providers.$providerId.model-metadata.ts +22 -4
  134. package/src/routes/api/providers.$providerId.ts +37 -6
  135. package/src/routes/api/providers.export.ts +34 -16
  136. package/src/routes/api/providers.import.ts +42 -8
  137. package/src/routes/api/providers.scan.ts +13 -8
  138. package/src/routes/api/providers.ts +36 -5
  139. package/src/routes/api/runs.ts +12 -12
  140. package/src/routes/api/sessions.ts +15 -8
  141. package/src/routes/index.tsx +6 -0
  142. package/src/routes/livez.ts +13 -0
  143. package/src/routes/readyz.ts +18 -0
  144. package/src/routes/session/$sessionId.tsx +6 -0
  145. package/styles/globals.css +19 -3
  146. package/.output/public/assets/CompareDrawer-TghZr5t1.js +0 -1
  147. package/.output/public/assets/ProxyViewerContainer-fShBAZjj.js +0 -4234
  148. package/.output/public/assets/ResponseView-d25MdFhY.js +0 -3
  149. package/.output/public/assets/StreamingChunkSequence-DIFZEnWe.js +0 -1
  150. package/.output/public/assets/index-BsxAGqMA.css +0 -1
  151. package/.output/public/assets/index-DzARL2Fi.js +0 -76
  152. package/.output/public/assets/qwen-CONDcHqt.png +0 -0
  153. package/.output/server/_tanstack-start-manifest_v-BBf2nRhb.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,85 +1,163 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { compactLogForList, onLogUpdate, getLogSessionId, listLogsPage } from "../../proxy/store";
2
+ import {
3
+ compactLogForList,
4
+ onLogUpdate,
5
+ getLogSessionId,
6
+ listLogsCursorPage,
7
+ } from "../../proxy/store";
3
8
  import { type CapturedLog } from "../../contracts";
9
+ import {
10
+ createCapacityLimiter,
11
+ MAX_SSE_CLIENTS,
12
+ resourceLimitResponse,
13
+ type CapacityLimiter,
14
+ } from "../../lib/resourceLimits";
4
15
 
5
16
  const INITIAL_STREAM_LOG_LIMIT = 100;
17
+ const UPDATE_COALESCE_MS = 50;
18
+ const MAX_BUFFERED_UPDATE_EVENTS = 16;
19
+ const sseClientLimiter = createCapacityLimiter(MAX_SSE_CLIENTS);
6
20
 
7
- export const Route = createFileRoute("/api/logs/stream")({
8
- server: {
9
- handlers: {
10
- GET: ({ request }: { request: Request }) => {
11
- const url = new URL(request.url);
12
- const sessionId = url.searchParams.get("sessionId") ?? undefined;
13
- const model = url.searchParams.get("model") ?? undefined;
14
- const includeBodies = url.searchParams.get("compact") !== "1";
21
+ export function streamIncludesBodies(url: URL): boolean {
22
+ return url.searchParams.get("compact") === "0";
23
+ }
15
24
 
16
- let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
17
- let cleanedUp = false;
25
+ export function parseLastEventLogId(value: string | null): number | undefined {
26
+ if (value === null || !/^\d+$/.test(value)) return undefined;
27
+ const parsed = Number(value);
28
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
29
+ }
18
30
 
19
- const unsubscribe = onLogUpdate((log: CapturedLog) => {
20
- if (controllerRef === null) return;
21
- // Filter by session/model if specified
22
- if (sessionId !== undefined && getLogSessionId(log) !== sessionId) return;
23
- if (model !== undefined && log.model !== model) return;
24
- try {
25
- const payloadLog = includeBodies ? log : compactLogForList(log);
26
- const data = `data: ${JSON.stringify({ type: "update", log: payloadLog })}\n\n`;
27
- controllerRef.enqueue(new TextEncoder().encode(data));
28
- } catch {
29
- cleanup();
30
- }
31
- });
31
+ function encodeSse(value: unknown, id?: number): Uint8Array {
32
+ const idLine = id === undefined ? "" : `id: ${String(id)}\n`;
33
+ return new TextEncoder().encode(`${idLine}data: ${JSON.stringify(value)}\n\n`);
34
+ }
32
35
 
33
- const cleanup = (): void => {
34
- if (cleanedUp) return;
35
- cleanedUp = true;
36
- clearInterval(heartbeat);
37
- unsubscribe();
38
- controllerRef = null;
39
- request.signal.removeEventListener("abort", cleanup);
40
- };
36
+ export function createLogsStreamResponse(
37
+ request: Request,
38
+ clientLimiter: CapacityLimiter = sseClientLimiter,
39
+ ): Response {
40
+ const lease = clientLimiter.tryAcquire();
41
+ if (lease === null) {
42
+ return resourceLimitResponse({
43
+ error: `SSE client capacity reached. At most ${String(clientLimiter.capacity)} live clients are allowed; retry after a connection closes.`,
44
+ code: "sse-capacity-exceeded",
45
+ status: 503,
46
+ resource: "sse-clients",
47
+ limit: clientLimiter.capacity,
48
+ retryable: true,
49
+ retryAfterSeconds: 1,
50
+ });
51
+ }
52
+ const url = new URL(request.url);
53
+ const sessionId = url.searchParams.get("sessionId") ?? undefined;
54
+ const model = url.searchParams.get("model") ?? undefined;
55
+ const includeBodies = streamIncludesBodies(url);
56
+ const lastEventLogId = parseLastEventLogId(request.headers.get("last-event-id"));
41
57
 
42
- // Send heartbeat comment every 30s to keep connection alive
43
- const heartbeat = setInterval(() => {
44
- if (controllerRef !== null) {
45
- try {
46
- controllerRef.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
47
- } catch {
48
- cleanup();
49
- }
50
- }
51
- }, 30000);
58
+ let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
59
+ let cleanedUp = false;
60
+ let updateTimer: ReturnType<typeof setTimeout> | null = null;
61
+ const pendingUpdates = new Map<number, CapturedLog>();
52
62
 
53
- request.signal.addEventListener("abort", cleanup, { once: true });
63
+ const flushPendingUpdates = (): void => {
64
+ updateTimer = null;
65
+ const controller = controllerRef;
66
+ if (controller === null) return;
67
+ if (controller.desiredSize !== null && controller.desiredSize <= -MAX_BUFFERED_UPDATE_EVENTS) {
68
+ controller.close();
69
+ cleanup();
70
+ return;
71
+ }
72
+ for (const log of pendingUpdates.values()) {
73
+ if (
74
+ controller.desiredSize !== null &&
75
+ controller.desiredSize <= -MAX_BUFFERED_UPDATE_EVENTS
76
+ ) {
77
+ controller.close();
78
+ cleanup();
79
+ return;
80
+ }
81
+ const payloadLog = includeBodies ? log : compactLogForList(log);
82
+ controller.enqueue(encodeSse({ type: "update", log: payloadLog }, log.id));
83
+ }
84
+ pendingUpdates.clear();
85
+ };
54
86
 
55
- const stream = new ReadableStream<Uint8Array>({
56
- async start(controller) {
57
- controllerRef = controller;
58
- // Send initial batch of logs immediately
59
- const result = await listLogsPage({
60
- sessionId,
61
- model,
62
- offset: 0,
63
- limit: INITIAL_STREAM_LOG_LIMIT,
64
- includeBodies,
65
- });
66
- const logs = result.logs;
67
- const initData = `data: ${JSON.stringify({ type: "init", logs })}\n\n`;
68
- controller.enqueue(new TextEncoder().encode(initData));
69
- },
70
- cancel() {
71
- cleanup();
72
- },
73
- });
87
+ const unsubscribe = onLogUpdate((log: CapturedLog) => {
88
+ if (controllerRef === null) return;
89
+ // Filter by session/model if specified
90
+ if (sessionId !== undefined && getLogSessionId(log) !== sessionId) return;
91
+ if (model !== undefined && log.model !== model) return;
92
+ pendingUpdates.set(log.id, log);
93
+ if (updateTimer === null) updateTimer = setTimeout(flushPendingUpdates, UPDATE_COALESCE_MS);
94
+ });
95
+
96
+ const cleanup = (): void => {
97
+ if (cleanedUp) return;
98
+ cleanedUp = true;
99
+ clearInterval(heartbeat);
100
+ if (updateTimer !== null) clearTimeout(updateTimer);
101
+ updateTimer = null;
102
+ pendingUpdates.clear();
103
+ unsubscribe();
104
+ lease.release();
105
+ controllerRef = null;
106
+ request.signal.removeEventListener("abort", cleanup);
107
+ };
108
+
109
+ // Send heartbeat comment every 30s to keep connection alive
110
+ const heartbeat = setInterval(() => {
111
+ if (controllerRef !== null) {
112
+ try {
113
+ controllerRef.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
114
+ } catch {
115
+ cleanup();
116
+ }
117
+ }
118
+ }, 30000);
119
+
120
+ request.signal.addEventListener("abort", cleanup, { once: true });
74
121
 
75
- return new Response(stream, {
76
- headers: {
77
- "Content-Type": "text/event-stream",
78
- "Cache-Control": "no-cache",
79
- Connection: "keep-alive",
80
- },
122
+ const stream = new ReadableStream<Uint8Array>({
123
+ async start(controller) {
124
+ controllerRef = controller;
125
+ try {
126
+ const result = await listLogsCursorPage({
127
+ sessionId,
128
+ model,
129
+ limit: INITIAL_STREAM_LOG_LIMIT,
130
+ includeBodies,
131
+ afterLogId: lastEventLogId,
132
+ anchor: lastEventLogId === undefined ? "newest" : undefined,
81
133
  });
82
- },
134
+ if (cleanedUp) return;
135
+ const logs = result.logs;
136
+ const newestLogId = logs.at(-1)?.id;
137
+ controller.enqueue(encodeSse({ type: "init", logs }, newestLogId));
138
+ } catch (error) {
139
+ cleanup();
140
+ controller.error(error);
141
+ }
142
+ },
143
+ cancel() {
144
+ cleanup();
145
+ },
146
+ });
147
+
148
+ return new Response(stream, {
149
+ headers: {
150
+ "Content-Type": "text/event-stream",
151
+ "Cache-Control": "no-cache",
152
+ Connection: "keep-alive",
153
+ },
154
+ });
155
+ }
156
+
157
+ export const Route = createFileRoute("/api/logs/stream")({
158
+ server: {
159
+ handlers: {
160
+ GET: ({ request }: { request: Request }) => createLogsStreamResponse(request),
83
161
  },
84
162
  },
85
163
  });
@@ -11,8 +11,16 @@ import {
11
11
  import {
12
12
  LOG_SEARCH_DEFAULT_LIMIT,
13
13
  LOG_SEARCH_DEFAULT_SCAN_LIMIT,
14
+ LOG_SEARCH_MAX_LIMIT,
15
+ LOG_SEARCH_MAX_SCAN_LIMIT,
14
16
  searchLogsPage,
15
17
  } from "../../proxy/logSearch";
18
+ import {
19
+ MAX_REST_LOG_PAGE_SIZE,
20
+ MAX_SEARCH_QUERY_CHARS,
21
+ parseBoundedIntegerParameter,
22
+ resourceLimitResponse,
23
+ } from "../../lib/resourceLimits";
16
24
 
17
25
  export const DeleteLogsBodySchema = z
18
26
  .object({
@@ -27,13 +35,6 @@ function parseNonNegativeInt(value: string | null, fallback: number): number {
27
35
  return parsed;
28
36
  }
29
37
 
30
- function parsePositiveInt(value: string | null, fallback: number): number {
31
- if (value === null) return fallback;
32
- const parsed = Number(value);
33
- if (!Number.isInteger(parsed) || parsed < 1) return fallback;
34
- return parsed;
35
- }
36
-
37
38
  type OptionalPositiveIntResult = { ok: true; value: number | null } | { ok: false };
38
39
 
39
40
  function parseOptionalPositiveInt(value: string | null): OptionalPositiveIntResult {
@@ -70,16 +71,56 @@ export const Route = createFileRoute("/api/logs")({
70
71
  const query = url.searchParams.get("q") ?? url.searchParams.get("search");
71
72
  const offset = parseNonNegativeInt(url.searchParams.get("offset"), 0);
72
73
  if (query !== null) {
73
- const limit = parsePositiveInt(url.searchParams.get("limit"), LOG_SEARCH_DEFAULT_LIMIT);
74
- const scanLimit = parsePositiveInt(
75
- url.searchParams.get("scanLimit"),
76
- LOG_SEARCH_DEFAULT_SCAN_LIMIT,
77
- );
74
+ if (query.length > MAX_SEARCH_QUERY_CHARS) {
75
+ return resourceLimitResponse({
76
+ error: `Search queries may contain at most ${String(MAX_SEARCH_QUERY_CHARS)} characters.`,
77
+ code: "search-limit-exceeded",
78
+ status: 400,
79
+ resource: "log-search-query",
80
+ limit: MAX_SEARCH_QUERY_CHARS,
81
+ retryable: false,
82
+ });
83
+ }
84
+ const limitResult = parseBoundedIntegerParameter({
85
+ value: url.searchParams.get("limit"),
86
+ parameter: "limit",
87
+ fallback: LOG_SEARCH_DEFAULT_LIMIT,
88
+ minimum: 1,
89
+ maximum: LOG_SEARCH_MAX_LIMIT,
90
+ resource: "log-search-page-size",
91
+ });
92
+ if (!limitResult.ok) return limitResult.response;
93
+ const scanLimitResult = parseBoundedIntegerParameter({
94
+ value: url.searchParams.get("scanLimit"),
95
+ parameter: "scanLimit",
96
+ fallback: LOG_SEARCH_DEFAULT_SCAN_LIMIT,
97
+ minimum: 1,
98
+ maximum: LOG_SEARCH_MAX_SCAN_LIMIT,
99
+ resource: "log-search-scan-size",
100
+ });
101
+ if (!scanLimitResult.ok) return scanLimitResult.response;
78
102
  return Response.json(
79
- await searchLogsPage({ query, sessionId, model, clientPid, offset, limit, scanLimit }),
103
+ await searchLogsPage({
104
+ query,
105
+ sessionId,
106
+ model,
107
+ clientPid,
108
+ offset,
109
+ limit: limitResult.value,
110
+ scanLimit: scanLimitResult.value,
111
+ }),
80
112
  );
81
113
  }
82
- const limit = parsePositiveInt(url.searchParams.get("limit"), 50);
114
+ const limitResult = parseBoundedIntegerParameter({
115
+ value: url.searchParams.get("limit"),
116
+ parameter: "limit",
117
+ fallback: 50,
118
+ minimum: 1,
119
+ maximum: MAX_REST_LOG_PAGE_SIZE,
120
+ resource: "logs-page-size",
121
+ });
122
+ if (!limitResult.ok) return limitResult.response;
123
+ const limit = limitResult.value;
83
124
  const includeBodies = url.searchParams.get("compact") !== "1";
84
125
 
85
126
  if (url.searchParams.get("cursor") === "1" || url.searchParams.get("cursor") === "true") {
@@ -1,6 +1,7 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { z } from "zod";
3
3
  import { getProvider, updateProvider } from "../../proxy/providers";
4
+ import { toRedactedProviderDto } from "../../lib/providerContract";
4
5
  import {
5
6
  applyBuiltinProviderModelMetadata,
6
7
  applyProviderModelRegistry,
@@ -8,6 +9,7 @@ import {
8
9
  ProviderModelRegistrySchema,
9
10
  } from "../../lib/providerModelMetadata";
10
11
  import { safeFetch } from "../../lib/safeFetch";
12
+ import { MAX_PROVIDER_MODELS, resourceLimitResponse } from "../../lib/resourceLimits";
11
13
 
12
14
  const RefreshModelMetadataInputSchema = z.object({
13
15
  url: z.string().optional(),
@@ -61,10 +63,13 @@ export const Route = createFileRoute("/api/providers/$providerId/model-metadata"
61
63
  modelMetadata: result.modelMetadata,
62
64
  });
63
65
  if (updated === null) {
64
- return Response.json({ error: "Provider not found" }, { status: 404 });
66
+ return Response.json(
67
+ { error: "Secure provider storage is unavailable; metadata was not updated" },
68
+ { status: 503 },
69
+ );
65
70
  }
66
71
  return Response.json({
67
- provider: updated,
72
+ provider: toRedactedProviderDto(updated),
68
73
  imported: result.imported,
69
74
  matchedModels: result.matchedModels,
70
75
  missingModels: result.missingModels,
@@ -114,6 +119,16 @@ export const Route = createFileRoute("/api/providers/$providerId/model-metadata"
114
119
 
115
120
  const registry = ProviderModelRegistrySchema.safeParse(registryJson);
116
121
  if (!registry.success) {
122
+ if (registry.error.issues.some((issue) => issue.code === "too_big")) {
123
+ return resourceLimitResponse({
124
+ error: `Provider metadata model collections may contain at most ${String(MAX_PROVIDER_MODELS)} entries.`,
125
+ code: "provider-model-limit-exceeded",
126
+ status: 413,
127
+ resource: "provider-model-metadata",
128
+ limit: MAX_PROVIDER_MODELS,
129
+ retryable: false,
130
+ });
131
+ }
117
132
  return Response.json({ error: registry.error.message }, { status: 400 });
118
133
  }
119
134
 
@@ -128,11 +143,14 @@ export const Route = createFileRoute("/api/providers/$providerId/model-metadata"
128
143
  modelMetadataUrl: sourceUrl,
129
144
  });
130
145
  if (updated === null) {
131
- return Response.json({ error: "Provider not found" }, { status: 404 });
146
+ return Response.json(
147
+ { error: "Secure provider storage is unavailable; metadata was not updated" },
148
+ { status: 503 },
149
+ );
132
150
  }
133
151
 
134
152
  return Response.json({
135
- provider: updated,
153
+ provider: toRedactedProviderDto(updated),
136
154
  imported: result.imported,
137
155
  matchedModels: result.matchedModels,
138
156
  missingModels: result.missingModels,
@@ -6,8 +6,14 @@ import {
6
6
  deleteProvider,
7
7
  redactProvider,
8
8
  } from "../../proxy/providers";
9
- import { ProviderModelMetadataSchema } from "../../lib/providerContract";
9
+ import {
10
+ getProviderModelLimitViolation,
11
+ ProviderModelMetadataListSchema,
12
+ ProviderModelNameSchema,
13
+ ProviderModelsSchema,
14
+ } from "../../lib/providerContract";
10
15
  import { evaluateUpstreamUrl } from "../../lib/ssrfGuard";
16
+ import { resourceLimitResponse } from "../../lib/resourceLimits";
11
17
 
12
18
  const ALLOW_LOOPBACK = process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1";
13
19
 
@@ -16,15 +22,15 @@ const ProviderUpdateSchema = z.object({
16
22
  apiKey: z.string().min(1, "API key is required").optional(),
17
23
  format: z.enum(["anthropic", "openai"]).optional(),
18
24
  baseUrl: z.string().min(1, "Base URL is required").optional(),
19
- model: z.string().optional(),
20
- models: z.array(z.string()).optional(),
25
+ model: ProviderModelNameSchema.optional(),
26
+ models: ProviderModelsSchema.optional(),
21
27
  authHeader: z.enum(["bearer", "x-api-key"]).optional(),
22
28
  anthropicBaseUrl: z.string().optional(),
23
29
  openaiBaseUrl: z.string().optional(),
24
30
  openaiResponsesBaseUrl: z.string().optional(),
25
31
  apiDocsUrl: z.string().optional(),
26
32
  modelMetadataUrl: z.string().optional(),
27
- modelMetadata: z.array(ProviderModelMetadataSchema).optional(),
33
+ modelMetadata: ProviderModelMetadataListSchema.optional(),
28
34
  source: z.enum(["company", "personal"]).optional(),
29
35
  });
30
36
 
@@ -40,7 +46,26 @@ export const Route = createFileRoute("/api/providers/$providerId")({
40
46
  return Response.json(redactProvider(provider));
41
47
  },
42
48
  PUT: async ({ params, request }: { params: { providerId: string }; request: Request }) => {
43
- const parsed = ProviderUpdateSchema.safeParse(await request.json());
49
+ let body: unknown;
50
+ try {
51
+ body = await request.json();
52
+ } catch {
53
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
54
+ }
55
+
56
+ const violation = getProviderModelLimitViolation(body);
57
+ if (violation !== null) {
58
+ return resourceLimitResponse({
59
+ error: `Provider model collections may contain at most ${String(violation.limit)} entries.`,
60
+ code: "provider-model-limit-exceeded",
61
+ status: 413,
62
+ resource: violation.resource,
63
+ limit: violation.limit,
64
+ retryable: false,
65
+ });
66
+ }
67
+
68
+ const parsed = ProviderUpdateSchema.safeParse(body);
44
69
  if (!parsed.success) {
45
70
  return Response.json({ error: parsed.error.message }, { status: 400 });
46
71
  }
@@ -56,9 +81,15 @@ export const Route = createFileRoute("/api/providers/$providerId")({
56
81
  return Response.json({ error: decision.reason }, { status: 400 });
57
82
  }
58
83
  }
84
+ const providerExists = getProviders().some((provider) => provider.id === params.providerId);
59
85
  const updated = updateProvider(params.providerId, parsed.data);
60
86
  if (!updated) {
61
- return Response.json({ error: "Provider not found" }, { status: 404 });
87
+ return providerExists
88
+ ? Response.json(
89
+ { error: "Secure provider storage is unavailable; provider was not updated" },
90
+ { status: 503 },
91
+ )
92
+ : Response.json({ error: "Provider not found" }, { status: 404 });
62
93
  }
63
94
  return Response.json(redactProvider(updated));
64
95
  },
@@ -1,25 +1,43 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { z } from "zod";
2
3
  import { exportProviders, exportProvidersWithKeys } from "../../proxy/providers";
3
4
 
5
+ const SecretExportRequestSchema = z.object({
6
+ confirmation: z.literal("EXPORT_PROVIDER_KEYS"),
7
+ });
8
+
9
+ function exportResponse(json: string, filename: string): Response {
10
+ return new Response(json, {
11
+ headers: {
12
+ "Content-Type": "application/json",
13
+ "Content-Disposition": `attachment; filename="${filename}"`,
14
+ "Cache-Control": "no-store",
15
+ },
16
+ });
17
+ }
18
+
4
19
  export const Route = createFileRoute("/api/providers/export")({
5
20
  server: {
6
21
  handlers: {
7
- GET: ({ request }: { request: Request }) => {
8
- const url = new URL(request.url);
9
- const includeKeys = url.searchParams.get("includeKeys") === "true";
10
-
11
- const json = includeKeys ? exportProvidersWithKeys() : exportProviders();
12
-
13
- const filename = includeKeys
14
- ? `agent-inspector-providers-${Date.now()}.json`
15
- : `agent-inspector-providers-safe-${Date.now()}.json`;
16
-
17
- return new Response(json, {
18
- headers: {
19
- "Content-Type": "application/json",
20
- "Content-Disposition": `attachment; filename="${filename}"`,
21
- },
22
- });
22
+ GET: () => {
23
+ return exportResponse(
24
+ exportProviders(),
25
+ `agent-inspector-providers-safe-${Date.now()}.json`,
26
+ );
27
+ },
28
+ POST: async ({ request }: { request: Request }) => {
29
+ const body: unknown = await request.json().catch(() => null);
30
+ const parsed = SecretExportRequestSchema.safeParse(body);
31
+ if (!parsed.success) {
32
+ return Response.json(
33
+ { error: "Plaintext key export requires explicit confirmation" },
34
+ { status: 400, headers: { "Cache-Control": "no-store" } },
35
+ );
36
+ }
37
+ return exportResponse(
38
+ exportProvidersWithKeys(),
39
+ `agent-inspector-providers-sensitive-${Date.now()}.json`,
40
+ );
23
41
  },
24
42
  },
25
43
  },
@@ -1,8 +1,11 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { z } from "zod";
3
+ import { randomUUID } from "node:crypto";
4
+ import { ProviderScanImportRequestSchema } from "../../lib/providerImportContract";
5
+ import { consumeProviderScan } from "../../proxy/providerScanStore";
3
6
  import { importProviders } from "../../proxy/providers";
4
7
 
5
- const ImportRequestSchema = z.union([z.string(), z.object({ providers: z.unknown() })]);
8
+ const ProviderFileImportSchema = z.union([z.string(), z.object({ providers: z.unknown() })]);
6
9
 
7
10
  export const Route = createFileRoute("/api/providers/import")({
8
11
  server: {
@@ -13,12 +16,46 @@ export const Route = createFileRoute("/api/providers/import")({
13
16
  let jsonContent: string;
14
17
  try {
15
18
  const parsedBody: unknown = JSON.parse(rawBody);
16
- jsonContent = typeof parsedBody === "string" ? parsedBody : JSON.stringify(parsedBody);
19
+ const scanRequest = ProviderScanImportRequestSchema.safeParse(parsedBody);
20
+ if (scanRequest.success) {
21
+ const selected = consumeProviderScan(
22
+ scanRequest.data.scanId,
23
+ scanRequest.data.selectionIds,
24
+ );
25
+ if (selected === null) {
26
+ return Response.json(
27
+ { error: "Provider scan expired or selection is invalid" },
28
+ { status: 410, headers: { "cache-control": "no-store" } },
29
+ );
30
+ }
31
+ const now = new Date().toISOString();
32
+ jsonContent = JSON.stringify({
33
+ providers: selected.map((provider) => ({
34
+ id: randomUUID(),
35
+ name: provider.name,
36
+ apiKey: provider.apiKey,
37
+ format: provider.format,
38
+ anthropicBaseUrl: provider.anthropicBaseUrl,
39
+ openaiBaseUrl: provider.openaiBaseUrl,
40
+ openaiResponsesBaseUrl: provider.openaiResponsesBaseUrl,
41
+ models: provider.models,
42
+ createdAt: now,
43
+ updatedAt: now,
44
+ })),
45
+ });
46
+ } else {
47
+ const fileImport = ProviderFileImportSchema.safeParse(parsedBody);
48
+ jsonContent = fileImport.success
49
+ ? typeof fileImport.data === "string"
50
+ ? fileImport.data
51
+ : JSON.stringify(fileImport.data)
52
+ : rawBody;
53
+ }
17
54
  } catch {
18
55
  jsonContent = rawBody;
19
56
  }
20
57
 
21
- if (!jsonContent || jsonContent.trim() === "") {
58
+ if (jsonContent.trim() === "") {
22
59
  return Response.json({ error: "No JSON content provided" }, { status: 400 });
23
60
  }
24
61
 
@@ -35,11 +72,8 @@ export const Route = createFileRoute("/api/providers/import")({
35
72
  ? `Import completed with ${result.errors.length} error(s)`
36
73
  : "No providers imported",
37
74
  });
38
- } catch (err) {
39
- return Response.json(
40
- { error: `Failed to import: ${err instanceof Error ? err.message : String(err)}` },
41
- { status: 400 },
42
- );
75
+ } catch {
76
+ return Response.json({ error: "Provider import failed" }, { status: 400 });
43
77
  }
44
78
  },
45
79
  },
@@ -1,21 +1,26 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { scanExternalProviders } from "../../proxy/providerImporters";
3
+ import { createProviderScan } from "../../proxy/providerScanStore";
3
4
 
4
5
  export const Route = createFileRoute("/api/providers/scan")({
5
6
  server: {
6
7
  handlers: {
7
- GET: () => {
8
+ POST: () => {
8
9
  try {
9
10
  const result = scanExternalProviders();
10
- return Response.json({
11
- providers: result.providers,
12
- warnings: result.warnings,
13
- });
14
- } catch (err) {
11
+ const scan = createProviderScan(result.providers);
15
12
  return Response.json(
16
- { error: `Scan failed: ${err instanceof Error ? err.message : String(err)}` },
17
- { status: 500 },
13
+ {
14
+ scanId: scan.scanId,
15
+ providers: scan.providers,
16
+ warnings: result.warnings,
17
+ },
18
+ {
19
+ headers: { "cache-control": "no-store" },
20
+ },
18
21
  );
22
+ } catch {
23
+ return Response.json({ error: "Provider scan failed" }, { status: 500 });
19
24
  }
20
25
  },
21
26
  },