talon-agent 5.24.0 → 5.25.0
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/package.json +2 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +1 -1
- package/src/core/agents/prompt.ts +29 -2
- package/src/core/agents/runner.ts +7 -1
- package/src/core/agents/types.ts +6 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +82 -0
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/agents/control.ts +9 -0
- package/src/core/engine/gateway-actions/agents/index.ts +4 -0
- package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +5 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/index.ts +2 -1
- package/src/core/tools/ops/agents.ts +28 -0
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- package/src/core/background/triggers/pid.ts +0 -28
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FetchLadder — fetch a URL through an ordered list of rungs so a bot wall
|
|
3
|
+
* on one path doesn't end the job:
|
|
4
|
+
*
|
|
5
|
+
* a. browser-TLS impersonation, direct (impersonate:<profile>)
|
|
6
|
+
* b. the same through each SOCKS exit (impersonate:<profile>@<exit>)
|
|
7
|
+
* c. plain runtime fetch (plain)
|
|
8
|
+
* d. anti-detect browser (Camoufox) (camoufox) opt-in
|
|
9
|
+
* e. curl on a mesh egress device (egress:<device>) opt-in
|
|
10
|
+
*
|
|
11
|
+
* The climb stops at the first usable answer AND at the first definitive
|
|
12
|
+
* one: a 404 is a wrong URL, not a block, and is reported as such rather
|
|
13
|
+
* than retried through every exit. Only 403/429/503/999, challenge pages
|
|
14
|
+
* and network failures move on to the next rung.
|
|
15
|
+
*
|
|
16
|
+
* Redirects are followed here, hop by hop, for every rung that doesn't
|
|
17
|
+
* follow them itself, so the SSRF guard (fetchUrl.allowPrivateNetworks:
|
|
18
|
+
* false) sees each hop exactly as it does for the plain path.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { lookup } from "node:dns/promises";
|
|
22
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
23
|
+
import { isIP } from "node:net";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { ResponseTooLargeError } from "../../util/http-body.js";
|
|
27
|
+
import { logDebug } from "../../util/log.js";
|
|
28
|
+
import {
|
|
29
|
+
assertPublicUrl,
|
|
30
|
+
BlockedUrlError,
|
|
31
|
+
isBlockedAddress,
|
|
32
|
+
type Resolver,
|
|
33
|
+
} from "../engine/gateway-actions/fetch-url/guard.js";
|
|
34
|
+
import { classifyResponse, isDefinitive } from "./classify.js";
|
|
35
|
+
import type {
|
|
36
|
+
LadderResult,
|
|
37
|
+
RawResponse,
|
|
38
|
+
Rung,
|
|
39
|
+
RungOutcome,
|
|
40
|
+
Verdict,
|
|
41
|
+
} from "./types.js";
|
|
42
|
+
|
|
43
|
+
const MAX_REDIRECTS = 5;
|
|
44
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
45
|
+
const DEFAULT_RUNG_TIMEOUT_MS = 20_000;
|
|
46
|
+
/** Whole-ladder budget; sits under the tool bridge's fetch_url timeout. */
|
|
47
|
+
const DEFAULT_BUDGET_MS = 150_000;
|
|
48
|
+
/** Don't start a rung with less than this left. */
|
|
49
|
+
const MIN_RUNG_MS = 3_000;
|
|
50
|
+
|
|
51
|
+
// ── daily byte budget for relayed rungs ─────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** In-memory, per-UTC-day byte counter for relayed rungs. */
|
|
54
|
+
export class DailyByteBudget {
|
|
55
|
+
private day = "";
|
|
56
|
+
private used = 0;
|
|
57
|
+
constructor(
|
|
58
|
+
readonly cap: number,
|
|
59
|
+
private readonly now: () => number = Date.now,
|
|
60
|
+
) {}
|
|
61
|
+
private roll(): void {
|
|
62
|
+
const today = new Date(this.now()).toISOString().slice(0, 10);
|
|
63
|
+
if (today !== this.day) {
|
|
64
|
+
this.day = today;
|
|
65
|
+
this.used = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exhausted(): boolean {
|
|
69
|
+
this.roll();
|
|
70
|
+
return this.used >= this.cap;
|
|
71
|
+
}
|
|
72
|
+
add(bytes: number): void {
|
|
73
|
+
this.roll();
|
|
74
|
+
this.used += bytes;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── target classification ───────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const defaultResolver: Resolver = async (host) =>
|
|
81
|
+
(await lookup(host, { all: true, verbatim: true })).map((r) => r.address);
|
|
82
|
+
|
|
83
|
+
const LOCAL_SUFFIXES = [
|
|
84
|
+
".localhost",
|
|
85
|
+
".local",
|
|
86
|
+
".internal",
|
|
87
|
+
".lan",
|
|
88
|
+
".home.arpa",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when the host is loopback/private/link-local (literal, by name, or
|
|
93
|
+
* by resolution). Such targets only get the plain rung: they have no bot
|
|
94
|
+
* wall, and an exit or a remote device can't reach them.
|
|
95
|
+
*/
|
|
96
|
+
export async function isPrivateTarget(
|
|
97
|
+
url: URL,
|
|
98
|
+
resolve: Resolver = defaultResolver,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
101
|
+
if (isIP(host)) return isBlockedAddress(host);
|
|
102
|
+
if (host === "localhost" || LOCAL_SUFFIXES.some((s) => host.endsWith(s))) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const addrs = await resolve(host);
|
|
107
|
+
return addrs.some(isBlockedAddress);
|
|
108
|
+
} catch {
|
|
109
|
+
return false; // unresolvable here — maybe an exit can resolve it
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── the ladder ──────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export type FetchLadderOptions = {
|
|
116
|
+
rungs: readonly Rung[];
|
|
117
|
+
/** false = SSRF guard on every hop (fetchUrl.allowPrivateNetworks). */
|
|
118
|
+
allowPrivateNetworks: boolean;
|
|
119
|
+
/** Body cap per response. */
|
|
120
|
+
maxBytes: number;
|
|
121
|
+
/** Byte budget shared by relayed rungs; undefined = uncapped. */
|
|
122
|
+
byteBudget?: DailyByteBudget;
|
|
123
|
+
/** Extra request headers for every rung. */
|
|
124
|
+
headers?: Record<string, string>;
|
|
125
|
+
rungTimeoutMs?: number;
|
|
126
|
+
budgetMs?: number;
|
|
127
|
+
resolve?: Resolver;
|
|
128
|
+
now?: () => number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
type Candidate = { rung: Rung; res: RawResponse };
|
|
132
|
+
|
|
133
|
+
function describe(o: RungOutcome): string {
|
|
134
|
+
if (o.verdict === "skipped") return `${o.via} skipped (${o.detail})`;
|
|
135
|
+
if (o.status) return `${o.via} HTTP ${o.status}`;
|
|
136
|
+
return `${o.via} ${o.detail ?? o.verdict}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mutable state of one climb. */
|
|
140
|
+
type Climb = {
|
|
141
|
+
attempts: RungOutcome[];
|
|
142
|
+
lastVerdict: Verdict | "skipped";
|
|
143
|
+
/**
|
|
144
|
+
* A 2xx page that only *looked* like a challenge: kept, so a false
|
|
145
|
+
* positive in the marker scan costs extra rungs, never the content.
|
|
146
|
+
*/
|
|
147
|
+
fallback?: Candidate;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** The result once every rung has been tried without a definitive answer. */
|
|
151
|
+
function exhausted(run: Climb): LadderResult {
|
|
152
|
+
const { attempts, fallback } = run;
|
|
153
|
+
if (fallback) {
|
|
154
|
+
const { rung, res } = fallback;
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
via: rung.name,
|
|
158
|
+
status: res.status,
|
|
159
|
+
headers: res.headers,
|
|
160
|
+
body: res.body,
|
|
161
|
+
url: res.url,
|
|
162
|
+
attempts,
|
|
163
|
+
note: "every rung got what looks like a bot-check page; this may be the challenge, not the content",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const tried = attempts.map(describe).join(", ");
|
|
167
|
+
const anyAnswer = attempts.some((a) => a.status);
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
verdict: run.lastVerdict,
|
|
171
|
+
error: anyAnswer
|
|
172
|
+
? `Blocked on every rung (${tried}). This host is being refused as automated traffic — a bot wall, not a bad URL.`
|
|
173
|
+
: `Could not reach the site on any rung (${tried}).`,
|
|
174
|
+
attempts,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class FetchLadder {
|
|
179
|
+
constructor(private readonly opts: FetchLadderOptions) {}
|
|
180
|
+
|
|
181
|
+
/** Names of the configured rungs, in climb order. */
|
|
182
|
+
get rungNames(): string[] {
|
|
183
|
+
return this.opts.rungs.map((r) => r.name);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async fetch(input: string): Promise<LadderResult> {
|
|
187
|
+
const now = this.opts.now ?? Date.now;
|
|
188
|
+
const deadline = now() + (this.opts.budgetMs ?? DEFAULT_BUDGET_MS);
|
|
189
|
+
const resolve = this.opts.resolve ?? defaultResolver;
|
|
190
|
+
const start = new URL(input);
|
|
191
|
+
const run: Climb = { attempts: [], lastVerdict: "skipped" };
|
|
192
|
+
|
|
193
|
+
const rungs = await this.rungsFor(start, resolve);
|
|
194
|
+
if (typeof rungs === "string") {
|
|
195
|
+
return { ok: false, verdict: "http-error", error: rungs, attempts: [] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let jarDir: string | undefined;
|
|
199
|
+
try {
|
|
200
|
+
for (const [index, rung] of rungs.entries()) {
|
|
201
|
+
const remaining = deadline - now();
|
|
202
|
+
const skip = await this.skipReason(rung, remaining);
|
|
203
|
+
if (skip) {
|
|
204
|
+
run.attempts.push({
|
|
205
|
+
via: rung.name,
|
|
206
|
+
ok: false,
|
|
207
|
+
verdict: "skipped",
|
|
208
|
+
detail: skip,
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
jarDir ??= await mkdtemp(join(tmpdir(), "talon-jar-"));
|
|
213
|
+
const timeoutMs = Math.min(
|
|
214
|
+
rung.timeoutMs ?? this.opts.rungTimeoutMs ?? DEFAULT_RUNG_TIMEOUT_MS,
|
|
215
|
+
remaining,
|
|
216
|
+
);
|
|
217
|
+
const done = await this.attempt(run, rung, start, {
|
|
218
|
+
timeoutMs,
|
|
219
|
+
cookieJar: join(jarDir, `${index}.jar`),
|
|
220
|
+
resolve,
|
|
221
|
+
});
|
|
222
|
+
if (done) return done;
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
if (jarDir)
|
|
226
|
+
await rm(jarDir, { recursive: true, force: true }).catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
return exhausted(run);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The rungs worth trying for this target, or a refusal message when the
|
|
233
|
+
* SSRF guard rejects it outright.
|
|
234
|
+
*/
|
|
235
|
+
private async rungsFor(
|
|
236
|
+
start: URL,
|
|
237
|
+
resolve: Resolver,
|
|
238
|
+
): Promise<readonly Rung[] | string> {
|
|
239
|
+
try {
|
|
240
|
+
if (!this.opts.allowPrivateNetworks) {
|
|
241
|
+
await assertPublicUrl(start, resolve);
|
|
242
|
+
} else if (await isPrivateTarget(start, resolve)) {
|
|
243
|
+
return this.opts.rungs.filter((r) => !r.publicOnly);
|
|
244
|
+
}
|
|
245
|
+
return this.opts.rungs;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err instanceof BlockedUrlError) return err.message;
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Why a rung can't run now, or undefined when it can. */
|
|
253
|
+
private async skipReason(
|
|
254
|
+
rung: Rung,
|
|
255
|
+
remaining: number,
|
|
256
|
+
): Promise<string | undefined> {
|
|
257
|
+
if (remaining < MIN_RUNG_MS) return "time budget used up";
|
|
258
|
+
if (rung.relayed && this.opts.byteBudget?.exhausted())
|
|
259
|
+
return "daily byte cap reached";
|
|
260
|
+
const ready = await rung.available();
|
|
261
|
+
return ready === true ? undefined : ready;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run one rung and record the outcome. Returns the ladder's result when
|
|
266
|
+
* this rung ends the climb (content, a definitive answer, a refusal),
|
|
267
|
+
* undefined to climb on.
|
|
268
|
+
*/
|
|
269
|
+
private async attempt(
|
|
270
|
+
run: Climb,
|
|
271
|
+
rung: Rung,
|
|
272
|
+
start: URL,
|
|
273
|
+
hop: { timeoutMs: number; cookieJar: string; resolve: Resolver },
|
|
274
|
+
): Promise<LadderResult | undefined> {
|
|
275
|
+
const { attempts } = run;
|
|
276
|
+
let res: RawResponse;
|
|
277
|
+
try {
|
|
278
|
+
res = await this.climbOne(
|
|
279
|
+
rung,
|
|
280
|
+
start,
|
|
281
|
+
hop.timeoutMs,
|
|
282
|
+
hop.cookieJar,
|
|
283
|
+
hop.resolve,
|
|
284
|
+
);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
const refusal =
|
|
287
|
+
err instanceof BlockedUrlError
|
|
288
|
+
? err.message
|
|
289
|
+
: err instanceof ResponseTooLargeError
|
|
290
|
+
? `Response too large (max ${Math.round(this.opts.maxBytes / 1024 / 1024)}MB)`
|
|
291
|
+
: undefined;
|
|
292
|
+
if (refusal) {
|
|
293
|
+
attempts.push({
|
|
294
|
+
via: rung.name,
|
|
295
|
+
ok: false,
|
|
296
|
+
verdict: "http-error",
|
|
297
|
+
detail: refusal,
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
verdict: "http-error",
|
|
302
|
+
via: rung.name,
|
|
303
|
+
error: refusal,
|
|
304
|
+
attempts,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
308
|
+
attempts.push({ via: rung.name, ok: false, verdict: "network", detail });
|
|
309
|
+
run.lastVerdict = "network";
|
|
310
|
+
logDebug("fetch", `fetch via ${rung.name} failed: ${detail}`);
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (rung.relayed) this.opts.byteBudget?.add(res.body.length);
|
|
315
|
+
const verdict = classifyResponse(res.status, res.body);
|
|
316
|
+
run.lastVerdict = verdict;
|
|
317
|
+
attempts.push({
|
|
318
|
+
via: rung.name,
|
|
319
|
+
ok: verdict === "ok",
|
|
320
|
+
verdict,
|
|
321
|
+
status: res.status,
|
|
322
|
+
bytes: res.body.length,
|
|
323
|
+
});
|
|
324
|
+
logDebug(
|
|
325
|
+
"fetch",
|
|
326
|
+
`fetch via ${rung.name}: HTTP ${res.status} ${res.body.length}B → ${verdict} (${start.host})`,
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
if (verdict === "blocked" && res.status >= 200 && res.status < 300) {
|
|
330
|
+
run.fallback ??= { rung, res };
|
|
331
|
+
}
|
|
332
|
+
if (!isDefinitive(verdict)) return undefined;
|
|
333
|
+
if (verdict === "ok") {
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
via: rung.name,
|
|
337
|
+
status: res.status,
|
|
338
|
+
headers: res.headers,
|
|
339
|
+
body: res.body,
|
|
340
|
+
url: res.url,
|
|
341
|
+
attempts,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
verdict,
|
|
347
|
+
status: res.status,
|
|
348
|
+
via: rung.name,
|
|
349
|
+
error:
|
|
350
|
+
verdict === "not-found"
|
|
351
|
+
? `HTTP ${res.status} via ${rung.name} — the server says this page doesn't exist. That is a wrong URL, not a block; find the right address instead of retrying.`
|
|
352
|
+
: `HTTP ${res.status} via ${rung.name}`,
|
|
353
|
+
attempts,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** One rung, following redirects (guarded) unless the rung does. */
|
|
358
|
+
private async climbOne(
|
|
359
|
+
rung: Rung,
|
|
360
|
+
start: URL,
|
|
361
|
+
timeoutMs: number,
|
|
362
|
+
cookieJar: string,
|
|
363
|
+
resolve: Resolver,
|
|
364
|
+
): Promise<RawResponse> {
|
|
365
|
+
const guard = !this.opts.allowPrivateNetworks;
|
|
366
|
+
const headers = this.opts.headers ?? {};
|
|
367
|
+
const maxBytes = this.opts.maxBytes;
|
|
368
|
+
if (rung.followsRedirects) {
|
|
369
|
+
return await rung.request({
|
|
370
|
+
url: start,
|
|
371
|
+
headers,
|
|
372
|
+
timeoutMs,
|
|
373
|
+
maxBytes,
|
|
374
|
+
cookieJar,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
let url = start;
|
|
378
|
+
for (let hop = 0; ; hop++) {
|
|
379
|
+
const pinnedAddresses = guard
|
|
380
|
+
? await assertPublicUrl(url, resolve)
|
|
381
|
+
: undefined;
|
|
382
|
+
const res = await rung.request({
|
|
383
|
+
url,
|
|
384
|
+
headers,
|
|
385
|
+
timeoutMs,
|
|
386
|
+
maxBytes,
|
|
387
|
+
cookieJar,
|
|
388
|
+
pinnedAddresses,
|
|
389
|
+
});
|
|
390
|
+
const location = res.headers.get("location");
|
|
391
|
+
if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
|
|
392
|
+
if (hop >= MAX_REDIRECTS) {
|
|
393
|
+
throw new BlockedUrlError(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
394
|
+
}
|
|
395
|
+
url = new URL(location, url);
|
|
396
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
397
|
+
throw new BlockedUrlError(`Refusing redirect to ${url.protocol} URL`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|