@tonyclaw/agent-inspector 3.1.20 → 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 +137 -20
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DpXchCXm.mjs → router-Be6BLuut.mjs} +2371 -1171
- package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
- package/.output/server/index.mjs +1 -1
- package/.output/ui/assets/{CompareDrawer-4i1QHaQf.js → CompareDrawer-CGk2HFI1.js} +1 -1
- package/.output/ui/assets/{InspectorPet-Cw6VJLr7.js → InspectorPet-CDM7SdOT.js} +1 -1
- package/.output/ui/assets/ProxyViewerContainer-CaWD78tw.js +60 -0
- package/.output/ui/assets/{ReplayDialog-DTs575iW.js → ReplayDialog-DqaeV1He.js} +1 -1
- package/.output/ui/assets/{RequestAnatomy-Cl6Es3gv.js → RequestAnatomy-BtHl9P0k.js} +1 -1
- package/.output/ui/assets/{ResponseView-DNCCLkzG.js → ResponseView-C6t1vpxs.js} +1 -1
- package/.output/ui/assets/{StreamingChunkSequence-DKaxRL9U.js → StreamingChunkSequence-R4mgd3GQ.js} +1 -1
- package/.output/ui/assets/{_sessionId-C-YjWz50.js → _sessionId-CL3hdLlQ.js} +1 -1
- package/.output/ui/assets/{_sessionId-rkR3WgRq.js → _sessionId-HS666S72.js} +1 -1
- package/.output/ui/assets/index-B-Qod-aA.css +1 -0
- package/.output/ui/assets/{index-DxiecWHw.js → index-Bd9jPL4n.js} +1 -1
- package/.output/ui/assets/{index-DYbzZTDM.js → index-C4NHrclw.js} +1 -1
- package/.output/ui/assets/{index-D0d-V67x.js → index-UF1PJEcq.js} +1 -1
- package/.output/ui/assets/{index-B48RD1-5.js → index-tX38BNV2.js} +2 -2
- package/.output/ui/assets/{json-viewer-CjAwZhHA.js → json-viewer-KSgzN_Ov.js} +1 -1
- package/.output/ui/assets/{jszip.min-DEGEN1HP.js → jszip.min-BiMDxghN.js} +1 -1
- package/.output/ui/index.html +2 -2
- package/.output/workers/logFinalizer.worker.js +258 -0
- package/.output/workers/sessionWorkerEntry.js +258 -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/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/proxy/ecosystemTasks.ts +20 -0
- package/src/proxy/handler.ts +17 -4
- package/src/proxy/identityProxy.ts +36 -16
- package/src/proxy/logIndex.ts +38 -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/sessionRuntime.ts +38 -0
- package/src/proxy/sqliteLogIndex.ts +51 -0
- package/src/proxy/storageLifecycle.ts +432 -0
- package/src/proxy/store.ts +62 -12
- package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
- package/.output/ui/assets/ProxyViewerContainer-BST0B7lY.js +0 -60
- package/.output/ui/assets/index-IBEDJoV_.css +0 -1
|
@@ -11,6 +11,197 @@ export const MAX_ACTIVE_PROVIDER_TESTS = 2;
|
|
|
11
11
|
export const MAX_PROVIDER_TEST_CONCURRENCY = 4;
|
|
12
12
|
export const MAX_ACTIVE_PROXY_REQUESTS = 16;
|
|
13
13
|
|
|
14
|
+
const positiveInteger = z.number().int().positive();
|
|
15
|
+
|
|
16
|
+
export const RuntimeBudgetPolicySchema = z
|
|
17
|
+
.object({
|
|
18
|
+
proxy: z.object({
|
|
19
|
+
requestBytes: positiveInteger,
|
|
20
|
+
responseBytes: positiveInteger,
|
|
21
|
+
headerBytes: positiveInteger,
|
|
22
|
+
deadlineMs: positiveInteger,
|
|
23
|
+
idleTimeoutMs: positiveInteger,
|
|
24
|
+
activeRequests: positiveInteger,
|
|
25
|
+
}),
|
|
26
|
+
control: z.object({
|
|
27
|
+
requestBytes: positiveInteger,
|
|
28
|
+
responseBytes: positiveInteger,
|
|
29
|
+
headerBytes: positiveInteger,
|
|
30
|
+
deadlineMs: positiveInteger,
|
|
31
|
+
idleTimeoutMs: positiveInteger,
|
|
32
|
+
activeRequests: positiveInteger,
|
|
33
|
+
}),
|
|
34
|
+
upstream: z.object({
|
|
35
|
+
fetchResponseBytes: positiveInteger,
|
|
36
|
+
fetchDeadlineMs: positiveInteger,
|
|
37
|
+
replayResponseBytes: positiveInteger,
|
|
38
|
+
replayDeadlineMs: positiveInteger,
|
|
39
|
+
metadataResponseBytes: positiveInteger,
|
|
40
|
+
metadataDeadlineMs: positiveInteger,
|
|
41
|
+
connectTimeoutMs: positiveInteger,
|
|
42
|
+
idleTimeoutMs: positiveInteger,
|
|
43
|
+
}),
|
|
44
|
+
pagination: z.object({
|
|
45
|
+
restPageSize: positiveInteger,
|
|
46
|
+
searchQueryChars: positiveInteger,
|
|
47
|
+
searchProjectChars: positiveInteger,
|
|
48
|
+
}),
|
|
49
|
+
sse: z.object({
|
|
50
|
+
clients: positiveInteger,
|
|
51
|
+
initLogs: positiveInteger,
|
|
52
|
+
heartbeatMs: positiveInteger,
|
|
53
|
+
}),
|
|
54
|
+
imports: z.object({
|
|
55
|
+
archiveBytes: positiveInteger,
|
|
56
|
+
entries: positiveInteger,
|
|
57
|
+
entryBytes: positiveInteger,
|
|
58
|
+
uncompressedBytes: positiveInteger,
|
|
59
|
+
expansionRatio: positiveInteger,
|
|
60
|
+
}),
|
|
61
|
+
fanOut: z.object({
|
|
62
|
+
providerModels: positiveInteger,
|
|
63
|
+
providerModelMetadata: positiveInteger,
|
|
64
|
+
providerTestConcurrency: positiveInteger,
|
|
65
|
+
activeProviderTests: positiveInteger,
|
|
66
|
+
}),
|
|
67
|
+
queues: z.object({
|
|
68
|
+
proxyRequests: positiveInteger,
|
|
69
|
+
finalizerJobs: positiveInteger,
|
|
70
|
+
rawStreamMessages: positiveInteger,
|
|
71
|
+
rawStreamBytes: positiveInteger,
|
|
72
|
+
backgroundWorkers: positiveInteger,
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
.superRefine((policy, context) => {
|
|
76
|
+
if (policy.control.requestBytes > policy.proxy.requestBytes) {
|
|
77
|
+
context.addIssue({
|
|
78
|
+
code: "custom",
|
|
79
|
+
path: ["control", "requestBytes"],
|
|
80
|
+
message: "control request budget must not exceed proxy request budget",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (policy.control.responseBytes > policy.proxy.responseBytes) {
|
|
84
|
+
context.addIssue({
|
|
85
|
+
code: "custom",
|
|
86
|
+
path: ["control", "responseBytes"],
|
|
87
|
+
message: "control response budget must not exceed proxy response budget",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (policy.control.activeRequests > policy.proxy.activeRequests) {
|
|
91
|
+
context.addIssue({
|
|
92
|
+
code: "custom",
|
|
93
|
+
path: ["control", "activeRequests"],
|
|
94
|
+
message: "control active request budget must not exceed proxy active request budget",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (policy.upstream.fetchResponseBytes > policy.proxy.responseBytes) {
|
|
98
|
+
context.addIssue({
|
|
99
|
+
code: "custom",
|
|
100
|
+
path: ["upstream", "fetchResponseBytes"],
|
|
101
|
+
message: "upstream fetch response budget must not exceed proxy response budget",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (policy.upstream.replayResponseBytes > policy.proxy.responseBytes) {
|
|
105
|
+
context.addIssue({
|
|
106
|
+
code: "custom",
|
|
107
|
+
path: ["upstream", "replayResponseBytes"],
|
|
108
|
+
message: "upstream replay response budget must not exceed proxy response budget",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (policy.upstream.metadataResponseBytes > policy.control.responseBytes) {
|
|
112
|
+
context.addIssue({
|
|
113
|
+
code: "custom",
|
|
114
|
+
path: ["upstream", "metadataResponseBytes"],
|
|
115
|
+
message: "upstream metadata response budget must not exceed control response budget",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (policy.upstream.metadataDeadlineMs > policy.control.deadlineMs) {
|
|
119
|
+
context.addIssue({
|
|
120
|
+
code: "custom",
|
|
121
|
+
path: ["upstream", "metadataDeadlineMs"],
|
|
122
|
+
message: "upstream metadata deadline must not exceed control deadline",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (policy.queues.proxyRequests < policy.proxy.activeRequests) {
|
|
126
|
+
context.addIssue({
|
|
127
|
+
code: "custom",
|
|
128
|
+
path: ["queues", "proxyRequests"],
|
|
129
|
+
message: "proxy request queue budget must cover active proxy requests",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export type RuntimeBudgetPolicy = z.infer<typeof RuntimeBudgetPolicySchema>;
|
|
135
|
+
|
|
136
|
+
export const DEFAULT_RUNTIME_BUDGET_POLICY = RuntimeBudgetPolicySchema.parse({
|
|
137
|
+
proxy: {
|
|
138
|
+
requestBytes: 64 * 1024 * 1024,
|
|
139
|
+
responseBytes: 256 * 1024 * 1024,
|
|
140
|
+
headerBytes: 64 * 1024,
|
|
141
|
+
deadlineMs: 120_000,
|
|
142
|
+
idleTimeoutMs: 30_000,
|
|
143
|
+
activeRequests: MAX_ACTIVE_PROXY_REQUESTS,
|
|
144
|
+
},
|
|
145
|
+
control: {
|
|
146
|
+
requestBytes: 8 * 1024 * 1024,
|
|
147
|
+
responseBytes: 64 * 1024 * 1024,
|
|
148
|
+
headerBytes: 32 * 1024,
|
|
149
|
+
deadlineMs: 30_000,
|
|
150
|
+
idleTimeoutMs: 10_000,
|
|
151
|
+
activeRequests: 8,
|
|
152
|
+
},
|
|
153
|
+
upstream: {
|
|
154
|
+
fetchResponseBytes: 256 * 1024 * 1024,
|
|
155
|
+
fetchDeadlineMs: 120_000,
|
|
156
|
+
replayResponseBytes: 256 * 1024 * 1024,
|
|
157
|
+
replayDeadlineMs: 120_000,
|
|
158
|
+
metadataResponseBytes: 10 * 1024 * 1024,
|
|
159
|
+
metadataDeadlineMs: 30_000,
|
|
160
|
+
connectTimeoutMs: 10_000,
|
|
161
|
+
idleTimeoutMs: 30_000,
|
|
162
|
+
},
|
|
163
|
+
pagination: {
|
|
164
|
+
restPageSize: MAX_REST_LOG_PAGE_SIZE,
|
|
165
|
+
searchQueryChars: MAX_SEARCH_QUERY_CHARS,
|
|
166
|
+
searchProjectChars: MAX_SEARCH_PROJECT_CHARS,
|
|
167
|
+
},
|
|
168
|
+
sse: {
|
|
169
|
+
clients: MAX_SSE_CLIENTS,
|
|
170
|
+
initLogs: 500,
|
|
171
|
+
heartbeatMs: 15_000,
|
|
172
|
+
},
|
|
173
|
+
imports: {
|
|
174
|
+
archiveBytes: 64 * 1024 * 1024,
|
|
175
|
+
entries: 5_000,
|
|
176
|
+
entryBytes: 32 * 1024 * 1024,
|
|
177
|
+
uncompressedBytes: 256 * 1024 * 1024,
|
|
178
|
+
expansionRatio: 20,
|
|
179
|
+
},
|
|
180
|
+
fanOut: {
|
|
181
|
+
providerModels: MAX_PROVIDER_MODELS,
|
|
182
|
+
providerModelMetadata: MAX_PROVIDER_MODEL_METADATA,
|
|
183
|
+
providerTestConcurrency: MAX_PROVIDER_TEST_CONCURRENCY,
|
|
184
|
+
activeProviderTests: MAX_ACTIVE_PROVIDER_TESTS,
|
|
185
|
+
},
|
|
186
|
+
queues: {
|
|
187
|
+
proxyRequests: MAX_ACTIVE_PROXY_REQUESTS,
|
|
188
|
+
finalizerJobs: 1_000,
|
|
189
|
+
rawStreamMessages: 16_384,
|
|
190
|
+
rawStreamBytes: 64 * 1024 * 1024,
|
|
191
|
+
backgroundWorkers: 4,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
export function validateRuntimeBudgetPolicy(input: unknown): RuntimeBudgetPolicy {
|
|
196
|
+
return RuntimeBudgetPolicySchema.parse(input);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function runtimeBudgetPolicySnapshot(
|
|
200
|
+
policy: RuntimeBudgetPolicy = DEFAULT_RUNTIME_BUDGET_POLICY,
|
|
201
|
+
): RuntimeBudgetPolicy {
|
|
202
|
+
return RuntimeBudgetPolicySchema.parse(policy);
|
|
203
|
+
}
|
|
204
|
+
|
|
14
205
|
export const ResourceLimitErrorCodeSchema = z.enum([
|
|
15
206
|
"invalid-page-request",
|
|
16
207
|
"search-limit-exceeded",
|
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
|
}
|