@tonyclaw/agent-inspector 3.1.19 → 3.1.21
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/backend/nitro.json +1 -1
- package/.output/cli.js +140 -20
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-0qlvqhVh.mjs → router-Be6BLuut.mjs} +2454 -1135
- package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
- package/.output/server/index.mjs +1 -1
- package/.output/ui/assets/{CompareDrawer-BOk9hd-Z.js → CompareDrawer-CGk2HFI1.js} +1 -1
- package/.output/ui/assets/{InspectorPet-DhHDBpk7.js → InspectorPet-CDM7SdOT.js} +1 -1
- package/.output/ui/assets/ProxyViewerContainer-CaWD78tw.js +60 -0
- package/.output/ui/assets/{ReplayDialog-DJhKtuNB.js → ReplayDialog-DqaeV1He.js} +1 -1
- package/.output/ui/assets/{RequestAnatomy-hP0tsqDC.js → RequestAnatomy-BtHl9P0k.js} +1 -1
- package/.output/ui/assets/{ResponseView-BSiUkwCb.js → ResponseView-C6t1vpxs.js} +1 -1
- package/.output/ui/assets/{StreamingChunkSequence-CzQqwv4X.js → StreamingChunkSequence-R4mgd3GQ.js} +1 -1
- package/.output/ui/assets/{_sessionId-BU-i6h0V.js → _sessionId-CL3hdLlQ.js} +1 -1
- package/.output/ui/assets/{_sessionId-C2Pq4Jb0.js → _sessionId-HS666S72.js} +1 -1
- package/.output/ui/assets/index-B-Qod-aA.css +1 -0
- package/.output/ui/assets/{index-D8C-t-2U.js → index-Bd9jPL4n.js} +1 -1
- package/.output/ui/assets/{index-zHpVF0jk.js → index-C4NHrclw.js} +1 -1
- package/.output/ui/assets/{index-UUnRwbOJ.js → index-UF1PJEcq.js} +1 -1
- package/.output/ui/assets/{index-B7NeIBDf.js → index-tX38BNV2.js} +2 -2
- package/.output/ui/assets/{json-viewer-DOzS5rUT.js → json-viewer-KSgzN_Ov.js} +1 -1
- package/.output/ui/assets/{jszip.min-CCCY7qNk.js → jszip.min-BiMDxghN.js} +1 -1
- package/.output/ui/index.html +2 -2
- package/.output/workers/logFinalizer.worker.js +261 -0
- package/.output/workers/sessionWorkerEntry.js +261 -0
- package/package.json +13 -6
- package/src/backend/routes/api/logs.$id.replay.ts +6 -1
- package/src/backend/routes/api/logs.stream.ts +7 -0
- package/src/backend/routes/api/providers.$providerId.model-metadata.ts +15 -4
- package/src/backend/routes/api/storage.ts +56 -0
- package/src/backend/routes/metrics.ts +14 -0
- package/src/cli/doctor.ts +135 -2
- package/src/cli.ts +7 -4
- package/src/components/ProxyViewerContainer.tsx +41 -24
- package/src/components/providers/SettingsDialog.tsx +261 -70
- package/src/components/proxy-viewer/LogEntry.tsx +19 -5
- package/src/components/proxy-viewer/bodyHydration.ts +67 -0
- package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +78 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/log.ts +5 -0
- package/src/knowledge/openclawClient.ts +11 -2
- package/src/knowledge/openclawGatewayClient.ts +6 -1
- package/src/lib/resourceLimits.ts +191 -0
- package/src/lib/safeFetch.ts +428 -20
- package/src/lib/stopReason.ts +66 -39
- package/src/proxy/ecosystemTasks.ts +20 -0
- package/src/proxy/handler.ts +17 -4
- package/src/proxy/identityProxy.ts +36 -16
- package/src/proxy/logIndex.ts +57 -6
- package/src/proxy/logger.ts +4 -0
- package/src/proxy/rawStreamCapture.ts +16 -1
- package/src/proxy/runtimeHealth.ts +4 -2
- package/src/proxy/runtimeMetrics.ts +149 -0
- package/src/proxy/schemas.ts +2 -0
- package/src/proxy/sessionRuntime.ts +38 -0
- package/src/proxy/sqliteLogIndex.ts +78 -2
- package/src/proxy/storageLifecycle.ts +432 -0
- package/src/proxy/store.ts +69 -13
- package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
- package/.output/ui/assets/ProxyViewerContainer-BRYf987G.js +0 -59
- package/.output/ui/assets/index-IBEDJoV_.css +0 -1
package/src/lib/safeFetch.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { request as requestHttp } from "node:http";
|
|
3
|
+
import { request as requestHttps } from "node:https";
|
|
2
4
|
import { isIP } from "node:net";
|
|
5
|
+
import type { IncomingMessage, RequestOptions } from "node:http";
|
|
3
6
|
import { evaluateUpstreamUrl, isInternalHost, type SsrfGuardOptions } from "./ssrfGuard";
|
|
4
7
|
|
|
5
8
|
export type ResolvedAddress = {
|
|
@@ -11,11 +14,18 @@ export type HostResolver = (hostname: string) => Promise<readonly ResolvedAddres
|
|
|
11
14
|
|
|
12
15
|
export type SafeFetchOptions = SsrfGuardOptions & {
|
|
13
16
|
resolver?: HostResolver;
|
|
17
|
+
deadlineMs?: number;
|
|
18
|
+
responseBytes?: number;
|
|
19
|
+
pinDns?: boolean;
|
|
14
20
|
};
|
|
15
21
|
|
|
16
22
|
export type SafeFetchResult =
|
|
17
23
|
| { ok: true; response: Response }
|
|
18
|
-
| {
|
|
24
|
+
| {
|
|
25
|
+
ok: false;
|
|
26
|
+
kind: "network-policy" | "redirect" | "resource-limit" | "timeout";
|
|
27
|
+
reason: string;
|
|
28
|
+
};
|
|
19
29
|
|
|
20
30
|
async function defaultResolver(hostname: string): Promise<readonly ResolvedAddress[]> {
|
|
21
31
|
return lookup(hostname, { all: true, verbatim: true });
|
|
@@ -35,12 +45,28 @@ export async function evaluateResolvedUpstreamUrl(
|
|
|
35
45
|
rawUrl: string,
|
|
36
46
|
options: SafeFetchOptions = {},
|
|
37
47
|
): Promise<{ allow: true } | { allow: false; reason: string }> {
|
|
48
|
+
const decision = await evaluateResolvedUpstreamUrlForFetch(rawUrl, options);
|
|
49
|
+
return decision.allow ? { allow: true } : { allow: false, reason: decision.reason };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ResolvedUpstreamUrlDecision =
|
|
53
|
+
| { allow: true; hostname: string; addresses: readonly ResolvedAddress[] }
|
|
54
|
+
| { allow: false; reason: string };
|
|
55
|
+
|
|
56
|
+
async function evaluateResolvedUpstreamUrlForFetch(
|
|
57
|
+
rawUrl: string,
|
|
58
|
+
options: SafeFetchOptions,
|
|
59
|
+
): Promise<ResolvedUpstreamUrlDecision> {
|
|
38
60
|
const initial = evaluateUpstreamUrl(rawUrl, options);
|
|
39
|
-
if (!initial.allow
|
|
61
|
+
if (!initial.allow) return initial;
|
|
40
62
|
|
|
41
63
|
const parsed = new URL(rawUrl);
|
|
42
64
|
const hostname = parsed.hostname.replace(/^\[|]$/g, "");
|
|
43
|
-
if (
|
|
65
|
+
if (options.bypass === true) return { allow: true, hostname, addresses: [] };
|
|
66
|
+
|
|
67
|
+
const ipFamily = isIP(hostname);
|
|
68
|
+
if (ipFamily !== 0)
|
|
69
|
+
return { allow: true, hostname, addresses: [{ address: hostname, family: ipFamily }] };
|
|
44
70
|
|
|
45
71
|
let addresses: readonly ResolvedAddress[];
|
|
46
72
|
try {
|
|
@@ -65,36 +91,418 @@ export async function evaluateResolvedUpstreamUrl(
|
|
|
65
91
|
};
|
|
66
92
|
}
|
|
67
93
|
|
|
68
|
-
return { allow: true };
|
|
94
|
+
return { allow: true, hostname, addresses };
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
function isRedirectStatus(status: number): boolean {
|
|
72
98
|
return status >= 300 && status < 400;
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
function formatByteLimit(limit: number): string {
|
|
102
|
+
return `${String(limit)} bytes`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseContentLength(value: string | null): number | null {
|
|
106
|
+
if (value === null) return null;
|
|
107
|
+
const parsed = Number(value);
|
|
108
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
|
|
109
|
+
return parsed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isPinnedDnsEnabled(options: SafeFetchOptions): boolean {
|
|
113
|
+
const disabledForTest =
|
|
114
|
+
typeof process !== "undefined" &&
|
|
115
|
+
process.env["NODE_ENV"] === "test" &&
|
|
116
|
+
process.env["AGENT_INSPECTOR_DISABLE_DNS_PINNING_FOR_TESTS"] === "1";
|
|
117
|
+
if (disabledForTest) return false;
|
|
118
|
+
return options.pinDns !== false && options.bypass !== true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function choosePinnedAddress(addresses: readonly ResolvedAddress[]): ResolvedAddress | null {
|
|
122
|
+
return addresses[0] ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hostHeaderValue(parsed: URL): string {
|
|
126
|
+
return parsed.port.length === 0 ? parsed.host : `${parsed.hostname}:${parsed.port}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function headersToRecord(headers: HeadersInit | undefined, parsed: URL): Record<string, string> {
|
|
130
|
+
const normalized = new Headers(headers);
|
|
131
|
+
if (!normalized.has("host")) {
|
|
132
|
+
normalized.set("host", hostHeaderValue(parsed));
|
|
133
|
+
}
|
|
134
|
+
const result: Record<string, string> = {};
|
|
135
|
+
normalized.forEach((value, key) => {
|
|
136
|
+
result[key] = value;
|
|
137
|
+
});
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function requestBodyToUint8Array(
|
|
142
|
+
body: BodyInit | null | undefined,
|
|
143
|
+
): Promise<
|
|
144
|
+
{ ok: true; body: Uint8Array | null; contentType: string | null } | { ok: false; reason: string }
|
|
145
|
+
> {
|
|
146
|
+
if (body === undefined || body === null) return { ok: true, body: null, contentType: null };
|
|
147
|
+
if (typeof body === "string") {
|
|
148
|
+
return { ok: true, body: new TextEncoder().encode(body), contentType: null };
|
|
149
|
+
}
|
|
150
|
+
if (body instanceof URLSearchParams) {
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
body: new TextEncoder().encode(body.toString()),
|
|
154
|
+
contentType: "application/x-www-form-urlencoded;charset=UTF-8",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (body instanceof Uint8Array) return { ok: true, body, contentType: null };
|
|
158
|
+
if (body instanceof ArrayBuffer) {
|
|
159
|
+
return { ok: true, body: new Uint8Array(body), contentType: null };
|
|
160
|
+
}
|
|
161
|
+
if (body instanceof Blob) {
|
|
162
|
+
return { ok: true, body: new Uint8Array(await body.arrayBuffer()), contentType: body.type };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: "Pinned upstream fetch only supports buffered request bodies",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pinnedLookup(
|
|
171
|
+
pinnedAddress: ResolvedAddress,
|
|
172
|
+
): (
|
|
173
|
+
hostname: string,
|
|
174
|
+
options: { all?: boolean },
|
|
175
|
+
callback: (error: Error | null, address: string | ResolvedAddress[], family?: number) => void,
|
|
176
|
+
) => void {
|
|
177
|
+
return (_hostname, options, callback) => {
|
|
178
|
+
if (options.all === true) {
|
|
179
|
+
callback(null, [pinnedAddress]);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
callback(null, pinnedAddress.address, pinnedAddress.family);
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function requestOptionsForPinnedFetch(
|
|
187
|
+
parsed: URL,
|
|
188
|
+
init: RequestInit,
|
|
189
|
+
pinnedAddress: ResolvedAddress,
|
|
190
|
+
contentType: string | null,
|
|
191
|
+
): RequestOptions {
|
|
192
|
+
const headers = headersToRecord(init.headers, parsed);
|
|
193
|
+
if (contentType !== null && !new Headers(headers).has("content-type")) {
|
|
194
|
+
headers["content-type"] = contentType;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
protocol: parsed.protocol,
|
|
199
|
+
hostname: parsed.hostname,
|
|
200
|
+
port: parsed.port,
|
|
201
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
202
|
+
method: init.method ?? "GET",
|
|
203
|
+
headers,
|
|
204
|
+
lookup: pinnedLookup(pinnedAddress),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function streamFromIncomingMessage(
|
|
209
|
+
incoming: IncomingMessage,
|
|
210
|
+
maxBytes: number | undefined,
|
|
211
|
+
): ReadableStream<Uint8Array> {
|
|
212
|
+
let seenBytes = 0;
|
|
213
|
+
return new ReadableStream<Uint8Array>({
|
|
214
|
+
start(controller) {
|
|
215
|
+
incoming.on("data", (chunk: Uint8Array) => {
|
|
216
|
+
seenBytes += chunk.byteLength;
|
|
217
|
+
if (maxBytes !== undefined && seenBytes > maxBytes) {
|
|
218
|
+
incoming.destroy(
|
|
219
|
+
new Error(
|
|
220
|
+
`Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
controller.error(
|
|
224
|
+
new Error(
|
|
225
|
+
`Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
controller.enqueue(chunk);
|
|
231
|
+
});
|
|
232
|
+
incoming.on("end", () => {
|
|
233
|
+
controller.close();
|
|
234
|
+
});
|
|
235
|
+
incoming.on("error", (error) => {
|
|
236
|
+
controller.error(error);
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
cancel() {
|
|
240
|
+
incoming.destroy();
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function cancelBody(response: Response): Promise<void> {
|
|
246
|
+
if (response.body === null) return;
|
|
247
|
+
await response.body.cancel().catch(() => undefined);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function responseWithByteLimit(response: Response, maxBytes: number): Response {
|
|
251
|
+
if (response.body === null) return response;
|
|
252
|
+
|
|
253
|
+
let seenBytes = 0;
|
|
254
|
+
const reader = response.body.getReader();
|
|
255
|
+
const body = new ReadableStream<Uint8Array>({
|
|
256
|
+
async pull(controller) {
|
|
257
|
+
const chunk = await reader.read();
|
|
258
|
+
if (chunk.done) {
|
|
259
|
+
controller.close();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
seenBytes += chunk.value.byteLength;
|
|
264
|
+
if (seenBytes > maxBytes) {
|
|
265
|
+
void reader.cancel().catch(() => undefined);
|
|
266
|
+
controller.error(
|
|
267
|
+
new Error(`Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`),
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
controller.enqueue(chunk.value);
|
|
273
|
+
},
|
|
274
|
+
cancel(reason) {
|
|
275
|
+
return reader.cancel(reason);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return new Response(body, {
|
|
280
|
+
status: response.status,
|
|
281
|
+
statusText: response.statusText,
|
|
282
|
+
headers: response.headers,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function shouldUseBudgetedSignal(init: RequestInit, deadlineMs: number | undefined): boolean {
|
|
287
|
+
return deadlineMs !== undefined || init.signal !== undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function pinnedNodeFetch(
|
|
291
|
+
rawUrl: string,
|
|
292
|
+
init: RequestInit,
|
|
293
|
+
options: SafeFetchOptions,
|
|
294
|
+
decision: Extract<ResolvedUpstreamUrlDecision, { allow: true }>,
|
|
295
|
+
): Promise<SafeFetchResult> {
|
|
296
|
+
const parsed = new URL(rawUrl);
|
|
297
|
+
const pinnedAddress = choosePinnedAddress(decision.addresses);
|
|
298
|
+
if (pinnedAddress === null) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
kind: "network-policy",
|
|
302
|
+
reason: `Could not pin a resolved upstream address for "${decision.hostname}"`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const encodedBody = await requestBodyToUint8Array(init.body);
|
|
307
|
+
if (!encodedBody.ok) {
|
|
308
|
+
return { ok: false, kind: "resource-limit", reason: encodedBody.reason };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return await new Promise<SafeFetchResult>((resolve, reject) => {
|
|
312
|
+
const linkedSignal = init.signal ?? undefined;
|
|
313
|
+
const transport = parsed.protocol === "https:" ? requestHttps : requestHttp;
|
|
314
|
+
const requestOptions = requestOptionsForPinnedFetch(
|
|
315
|
+
parsed,
|
|
316
|
+
init,
|
|
317
|
+
pinnedAddress,
|
|
318
|
+
encodedBody.contentType,
|
|
319
|
+
);
|
|
320
|
+
const request = transport(requestOptions, (incoming) => {
|
|
321
|
+
const status = incoming.statusCode ?? 0;
|
|
322
|
+
if (isRedirectStatus(status)) {
|
|
323
|
+
incoming.resume();
|
|
324
|
+
const location = incoming.headers.location;
|
|
325
|
+
resolve({
|
|
326
|
+
ok: false,
|
|
327
|
+
kind: "redirect",
|
|
328
|
+
reason:
|
|
329
|
+
location === undefined
|
|
330
|
+
? `Upstream returned blocked redirect HTTP ${String(status)}`
|
|
331
|
+
: `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const maxBytes = options.responseBytes;
|
|
337
|
+
const headers = new Headers();
|
|
338
|
+
for (const [key, value] of Object.entries(incoming.headers)) {
|
|
339
|
+
if (Array.isArray(value)) {
|
|
340
|
+
headers.set(key, value.join(", "));
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (value !== undefined) headers.set(key, value);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (maxBytes !== undefined) {
|
|
347
|
+
const declaredBytes = parseContentLength(headers.get("content-length"));
|
|
348
|
+
if (declaredBytes !== null && declaredBytes > maxBytes) {
|
|
349
|
+
incoming.destroy();
|
|
350
|
+
resolve({
|
|
351
|
+
ok: false,
|
|
352
|
+
kind: "resource-limit",
|
|
353
|
+
reason: `Upstream declared ${String(declaredBytes)} response bytes, exceeding configured limit of ${formatByteLimit(maxBytes)}`,
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
resolve({
|
|
360
|
+
ok: true,
|
|
361
|
+
response: new Response(streamFromIncomingMessage(incoming, maxBytes), {
|
|
362
|
+
status,
|
|
363
|
+
statusText: incoming.statusMessage,
|
|
364
|
+
headers,
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
let timedOut = false;
|
|
370
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
371
|
+
const finishAbort = (): void => {
|
|
372
|
+
request.destroy(new DOMException("Safe fetch aborted", "AbortError"));
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (linkedSignal !== undefined) {
|
|
376
|
+
if (linkedSignal.aborted) {
|
|
377
|
+
finishAbort();
|
|
378
|
+
} else {
|
|
379
|
+
linkedSignal.addEventListener("abort", finishAbort, { once: true });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (options.deadlineMs !== undefined) {
|
|
384
|
+
timeout = setTimeout(() => {
|
|
385
|
+
timedOut = true;
|
|
386
|
+
request.destroy(new DOMException("Safe fetch deadline exceeded", "TimeoutError"));
|
|
387
|
+
}, options.deadlineMs);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
request.on("error", (error) => {
|
|
391
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
392
|
+
if (linkedSignal !== undefined) {
|
|
393
|
+
linkedSignal.removeEventListener("abort", finishAbort);
|
|
394
|
+
}
|
|
395
|
+
if (timedOut) {
|
|
396
|
+
resolve({
|
|
397
|
+
ok: false,
|
|
398
|
+
kind: "timeout",
|
|
399
|
+
reason: `Upstream request exceeded configured deadline of ${String(options.deadlineMs)} ms`,
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
reject(error);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
request.on("close", () => {
|
|
407
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
408
|
+
if (linkedSignal !== undefined) {
|
|
409
|
+
linkedSignal.removeEventListener("abort", finishAbort);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (encodedBody.body !== null) request.write(encodedBody.body);
|
|
414
|
+
request.end();
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
75
418
|
export async function safeFetch(
|
|
76
419
|
rawUrl: string,
|
|
77
420
|
init: RequestInit = {},
|
|
78
421
|
options: SafeFetchOptions = {},
|
|
79
422
|
): Promise<SafeFetchResult> {
|
|
80
|
-
const
|
|
81
|
-
if (!
|
|
82
|
-
return { ok: false, kind: "network-policy", reason:
|
|
423
|
+
const resolvedDecision = await evaluateResolvedUpstreamUrlForFetch(rawUrl, options);
|
|
424
|
+
if (!resolvedDecision.allow) {
|
|
425
|
+
return { ok: false, kind: "network-policy", reason: resolvedDecision.reason };
|
|
83
426
|
}
|
|
84
427
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (response.body !== null) {
|
|
89
|
-
await response.body.cancel().catch(() => undefined);
|
|
428
|
+
if (isPinnedDnsEnabled(options)) {
|
|
429
|
+
return await pinnedNodeFetch(rawUrl, init, options, resolvedDecision);
|
|
90
430
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
431
|
+
|
|
432
|
+
const deadlineMs = options.deadlineMs;
|
|
433
|
+
const useBudgetedSignal = shouldUseBudgetedSignal(init, deadlineMs);
|
|
434
|
+
const controller = useBudgetedSignal ? new AbortController() : undefined;
|
|
435
|
+
let timedOut = false;
|
|
436
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
437
|
+
const linkedSignal = init.signal ?? undefined;
|
|
438
|
+
const forwardAbort = (): void => {
|
|
439
|
+
controller?.abort(linkedSignal?.reason);
|
|
99
440
|
};
|
|
441
|
+
|
|
442
|
+
if (controller !== undefined && linkedSignal !== undefined) {
|
|
443
|
+
if (linkedSignal.aborted) {
|
|
444
|
+
forwardAbort();
|
|
445
|
+
} else {
|
|
446
|
+
linkedSignal.addEventListener("abort", forwardAbort, { once: true });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (controller !== undefined && deadlineMs !== undefined) {
|
|
451
|
+
timeout = setTimeout(() => {
|
|
452
|
+
timedOut = true;
|
|
453
|
+
controller.abort(new DOMException("Safe fetch deadline exceeded", "TimeoutError"));
|
|
454
|
+
}, deadlineMs);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const response = await fetch(rawUrl, {
|
|
459
|
+
...init,
|
|
460
|
+
redirect: "manual",
|
|
461
|
+
signal: controller?.signal ?? init.signal,
|
|
462
|
+
});
|
|
463
|
+
if (!isRedirectStatus(response.status)) {
|
|
464
|
+
const maxBytes = options.responseBytes;
|
|
465
|
+
if (maxBytes === undefined) return { ok: true, response };
|
|
466
|
+
|
|
467
|
+
const declaredBytes = parseContentLength(response.headers.get("content-length"));
|
|
468
|
+
if (declaredBytes !== null && declaredBytes > maxBytes) {
|
|
469
|
+
await cancelBody(response);
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
kind: "resource-limit",
|
|
473
|
+
reason: `Upstream declared ${String(declaredBytes)} response bytes, exceeding configured limit of ${formatByteLimit(maxBytes)}`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { ok: true, response: responseWithByteLimit(response, maxBytes) };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
await cancelBody(response);
|
|
481
|
+
const location = response.headers.get("location");
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
kind: "redirect",
|
|
485
|
+
reason:
|
|
486
|
+
location === null
|
|
487
|
+
? `Upstream returned blocked redirect HTTP ${String(response.status)}`
|
|
488
|
+
: `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
|
|
489
|
+
};
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (timedOut) {
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
kind: "timeout",
|
|
495
|
+
reason:
|
|
496
|
+
deadlineMs === undefined
|
|
497
|
+
? "Upstream request exceeded its configured deadline"
|
|
498
|
+
: `Upstream request exceeded configured deadline of ${String(deadlineMs)} ms`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return Promise.reject(error);
|
|
502
|
+
} finally {
|
|
503
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
504
|
+
if (controller !== undefined && linkedSignal !== undefined) {
|
|
505
|
+
linkedSignal.removeEventListener("abort", forwardAbort);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
100
508
|
}
|
package/src/lib/stopReason.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { CapturedLog } from "../contracts";
|
|
1
|
+
import type { CapturedLog, StopReason } from "../contracts";
|
|
2
2
|
import { isRecord } from "./unknown";
|
|
3
3
|
|
|
4
|
-
export type StopReason
|
|
4
|
+
export type { StopReason } from "../contracts";
|
|
5
5
|
|
|
6
6
|
function normalizeAnthropicStopReason(value: string): StopReason {
|
|
7
7
|
switch (value) {
|
|
@@ -15,6 +15,67 @@ function normalizeAnthropicStopReason(value: string): StopReason {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function extractStopReasonFromJsonValue(value: unknown): StopReason {
|
|
19
|
+
if (!isRecord(value)) return null;
|
|
20
|
+
|
|
21
|
+
// Anthropic shape: { stop_reason: "end_turn" | "tool_use" | ... }
|
|
22
|
+
if (typeof value.stop_reason === "string") {
|
|
23
|
+
return normalizeAnthropicStopReason(value.stop_reason);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Anthropic SSE shape: { type: "message_delta", delta: { stop_reason: "..." } }
|
|
27
|
+
if (isRecord(value.delta) && typeof value.delta.stop_reason === "string") {
|
|
28
|
+
return normalizeAnthropicStopReason(value.delta.stop_reason);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// OpenAI Chat shape: { choices: [{ finish_reason: "stop" | "length" | ... }] }
|
|
32
|
+
if (Array.isArray(value.choices)) {
|
|
33
|
+
for (const choice of value.choices) {
|
|
34
|
+
if (!isRecord(choice) || typeof choice.finish_reason !== "string") continue;
|
|
35
|
+
const reason = normalizeOpenAIFinishReason(choice.finish_reason);
|
|
36
|
+
if (reason !== null) return reason;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// OpenAI Responses shape: { status: "completed", output: [...] }
|
|
41
|
+
if (Array.isArray(value.output)) {
|
|
42
|
+
for (const item of value.output) {
|
|
43
|
+
if (isRecord(item) && item.type === "function_call") {
|
|
44
|
+
return "tool_use";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (value.status === "completed") {
|
|
48
|
+
return "stop";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (value.status === "incomplete" && isRecord(value.incomplete_details)) {
|
|
53
|
+
const reason = value.incomplete_details.reason;
|
|
54
|
+
if (typeof reason === "string" && isResponsesLengthLimitReason(reason)) {
|
|
55
|
+
return "length";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractStopReasonFromSseText(text: string): StopReason {
|
|
63
|
+
let stopReason: StopReason = null;
|
|
64
|
+
for (const rawLine of text.split("\n")) {
|
|
65
|
+
const line = rawLine.trim();
|
|
66
|
+
if (!line.startsWith("data: ")) continue;
|
|
67
|
+
const data = line.slice(6).trim();
|
|
68
|
+
if (data === "[DONE]") break;
|
|
69
|
+
try {
|
|
70
|
+
const reason = extractStopReasonFromJsonValue(JSON.parse(data));
|
|
71
|
+
if (reason !== null) stopReason = reason;
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore malformed or non-JSON SSE data lines.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return stopReason;
|
|
77
|
+
}
|
|
78
|
+
|
|
18
79
|
function normalizeOpenAIFinishReason(value: string): StopReason {
|
|
19
80
|
switch (value) {
|
|
20
81
|
case "stop":
|
|
@@ -45,6 +106,7 @@ function isResponsesLengthLimitReason(value: string): boolean {
|
|
|
45
106
|
* while the request was classified as OpenAI).
|
|
46
107
|
*/
|
|
47
108
|
export function extractStopReason(log: CapturedLog): StopReason {
|
|
109
|
+
if (log.stopReason !== undefined) return log.stopReason;
|
|
48
110
|
if (log.responseText === null) return null;
|
|
49
111
|
|
|
50
112
|
try {
|
|
@@ -53,44 +115,9 @@ export function extractStopReason(log: CapturedLog): StopReason {
|
|
|
53
115
|
if (typeof json === "string") {
|
|
54
116
|
json = JSON.parse(json);
|
|
55
117
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Anthropic shape: { stop_reason: "end_turn" | "tool_use" | ... }
|
|
59
|
-
if (typeof json.stop_reason === "string") {
|
|
60
|
-
return normalizeAnthropicStopReason(json.stop_reason);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// OpenAI Chat shape: { choices: [{ finish_reason: "stop" | "length" | ... }] }
|
|
64
|
-
if (Array.isArray(json.choices)) {
|
|
65
|
-
for (const choice of json.choices) {
|
|
66
|
-
if (!isRecord(choice) || typeof choice.finish_reason !== "string") continue;
|
|
67
|
-
const reason = normalizeOpenAIFinishReason(choice.finish_reason);
|
|
68
|
-
if (reason !== null) return reason;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// OpenAI Responses shape: { status: "completed", output: [...] }
|
|
73
|
-
if (Array.isArray(json.output)) {
|
|
74
|
-
for (const item of json.output) {
|
|
75
|
-
if (isRecord(item) && item.type === "function_call") {
|
|
76
|
-
return "tool_use";
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
if (json.status === "completed") {
|
|
80
|
-
return "stop";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (json.status === "incomplete" && isRecord(json.incomplete_details)) {
|
|
85
|
-
const reason = json.incomplete_details.reason;
|
|
86
|
-
if (typeof reason === "string" && isResponsesLengthLimitReason(reason)) {
|
|
87
|
-
return "length";
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return null;
|
|
118
|
+
return extractStopReasonFromJsonValue(json);
|
|
92
119
|
} catch {
|
|
93
|
-
return
|
|
120
|
+
return extractStopReasonFromSseText(log.responseText);
|
|
94
121
|
}
|
|
95
122
|
}
|
|
96
123
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
signalProcessTree,
|
|
15
15
|
type CommandSpec,
|
|
16
16
|
} from "./platformCommands";
|
|
17
|
+
import { DEFAULT_RUNTIME_BUDGET_POLICY } from "../lib/resourceLimits";
|
|
17
18
|
|
|
18
19
|
const MAX_TASKS = 50;
|
|
19
20
|
const OUTPUT_LIMIT = 40_000;
|
|
@@ -34,6 +35,12 @@ const activeTasks = new Map<string, ActiveTask>();
|
|
|
34
35
|
let acceptingTasks = true;
|
|
35
36
|
let shutdownForceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
36
37
|
|
|
38
|
+
function activeTaskLimit(): number {
|
|
39
|
+
const configured = Number(process.env["ECOSYSTEM_TASK_ACTIVE_LIMIT"]);
|
|
40
|
+
if (Number.isSafeInteger(configured) && configured > 0) return configured;
|
|
41
|
+
return DEFAULT_RUNTIME_BUDGET_POLICY.queues.backgroundWorkers;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
function nowIso(): string {
|
|
38
45
|
return new Date().toISOString();
|
|
39
46
|
}
|
|
@@ -258,6 +265,7 @@ export function startEcosystemTask(
|
|
|
258
265
|
action: EcosystemTaskAction,
|
|
259
266
|
): EcosystemTask | null {
|
|
260
267
|
if (!acceptingTasks) return null;
|
|
268
|
+
if (activeTasks.size >= activeTaskLimit()) return null;
|
|
261
269
|
if (action === "recipe") return null;
|
|
262
270
|
const definition =
|
|
263
271
|
action === "runner-presets"
|
|
@@ -291,6 +299,7 @@ export function startEcosystemTask(
|
|
|
291
299
|
|
|
292
300
|
export function startEcosystemRecipeTask(recipeId: string): EcosystemTask | null {
|
|
293
301
|
if (!acceptingTasks) return null;
|
|
302
|
+
if (activeTasks.size >= activeTaskLimit()) return null;
|
|
294
303
|
const recipe = findEcosystemRecipe(recipeId);
|
|
295
304
|
if (recipe === null) return null;
|
|
296
305
|
if (!recipe.runnable) return null;
|
|
@@ -367,6 +376,7 @@ export function _startEcosystemTaskForTests(
|
|
|
367
376
|
timeoutMs = RECIPE_TIMEOUT_MS,
|
|
368
377
|
): EcosystemTask | null {
|
|
369
378
|
if (!acceptingTasks) return null;
|
|
379
|
+
if (activeTasks.size >= activeTaskLimit()) return null;
|
|
370
380
|
const task: EcosystemTask = {
|
|
371
381
|
id: randomUUID(),
|
|
372
382
|
packageId: "test-package",
|
|
@@ -393,3 +403,13 @@ export function _resetEcosystemTasksForTests(): void {
|
|
|
393
403
|
activeTasks.clear();
|
|
394
404
|
acceptingTasks = true;
|
|
395
405
|
}
|
|
406
|
+
|
|
407
|
+
export function _getEcosystemTaskAdmissionHealthForTests(): {
|
|
408
|
+
activeTasks: number;
|
|
409
|
+
activeTaskLimit: number;
|
|
410
|
+
} {
|
|
411
|
+
return {
|
|
412
|
+
activeTasks: activeTasks.size,
|
|
413
|
+
activeTaskLimit: activeTaskLimit(),
|
|
414
|
+
};
|
|
415
|
+
}
|