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,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-TLS impersonation via curl-impersonate
|
|
3
|
+
* (https://github.com/lexiforest/curl-impersonate).
|
|
4
|
+
*
|
|
5
|
+
* Why this and not the alternatives (see docs/fetch-ladder.md):
|
|
6
|
+
* - Most bot walls judge the TLS/HTTP2 fingerprint (JA3/JA4, SETTINGS
|
|
7
|
+
* frames, header order) before they look at the IP. Node's and Bun's
|
|
8
|
+
* fetch have a fixed, well-known fingerprint and neither runtime lets
|
|
9
|
+
* you change it, so impersonation has to happen outside the JS runtime.
|
|
10
|
+
* - curl-impersonate is one static binary per platform (BoringSSL build,
|
|
11
|
+
* `--impersonate <browser>`), no Python, no pip, no native addon. It is
|
|
12
|
+
* the same engine curl_cffi wraps, so it gets the same results.
|
|
13
|
+
* - The release is pinned by version AND per-asset sha256 below, then
|
|
14
|
+
* downloaded into ~/.talon/bin on first use. A digest mismatch refuses
|
|
15
|
+
* the binary. `fetch.curlImpersonatePath` points at a system copy
|
|
16
|
+
* instead (packagers, air-gapped hosts) and skips the download.
|
|
17
|
+
*
|
|
18
|
+
* Bumping: change CURL_IMPERSONATE_VERSION, recompute every sha256 from the
|
|
19
|
+
* release assets (`sha256sum curl-impersonate-<v>.<triple>.tar.gz`), and
|
|
20
|
+
* check the DEFAULT_TARGETS still exist (`curl-impersonate --help all`
|
|
21
|
+
* lists them in the wrapper script names of the tarball).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import {
|
|
28
|
+
chmod,
|
|
29
|
+
mkdir,
|
|
30
|
+
mkdtemp,
|
|
31
|
+
readFile,
|
|
32
|
+
rename,
|
|
33
|
+
rm,
|
|
34
|
+
writeFile,
|
|
35
|
+
} from "node:fs/promises";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { gunzipSync } from "node:zlib";
|
|
39
|
+
import { dirs } from "../../util/paths.js";
|
|
40
|
+
import {
|
|
41
|
+
readBodyLimited,
|
|
42
|
+
ResponseTooLargeError,
|
|
43
|
+
} from "../../util/http-body.js";
|
|
44
|
+
import { log } from "../../util/log.js";
|
|
45
|
+
import { fetchError } from "./errors.js";
|
|
46
|
+
import type { RawResponse, Rung, RungRequest } from "./types.js";
|
|
47
|
+
|
|
48
|
+
const CURL_IMPERSONATE_VERSION = "v2.2.3";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Browser profiles tried on the direct rung, in order. All four were
|
|
52
|
+
* verified against a Cloudflare-fronted page from a datacenter IP on
|
|
53
|
+
* 2026-09-30 (plain curl: 403; each of these: 200).
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_TARGETS: readonly string[] = [
|
|
56
|
+
"safari184",
|
|
57
|
+
"chrome146",
|
|
58
|
+
"firefox147",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
type Asset = { triple: string; sha256: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pinned release assets, keyed `${process.platform}-${process.arch}`.
|
|
65
|
+
* Linux uses the static musl builds so glibc version and Alpine/Docker
|
|
66
|
+
* don't matter (32-bit ARM has no musl build; gnueabihf it is).
|
|
67
|
+
*/
|
|
68
|
+
const ASSETS: Readonly<Record<string, Asset>> = {
|
|
69
|
+
"linux-x64": {
|
|
70
|
+
triple: "x86_64-linux-musl",
|
|
71
|
+
sha256: "288332a313e9edd884a1575c2627844fd9f3388fcefc62982b6b4eae12828357",
|
|
72
|
+
},
|
|
73
|
+
"linux-arm64": {
|
|
74
|
+
triple: "aarch64-linux-musl",
|
|
75
|
+
sha256: "18000c51542fe63f0acc5cd3d89fb2217e35574f4c2911aec33e6774973a0809",
|
|
76
|
+
},
|
|
77
|
+
"linux-arm": {
|
|
78
|
+
triple: "arm-linux-gnueabihf",
|
|
79
|
+
sha256: "bdfba66572546a5331a31e36be99bbbcfa1cbe78cf2175088f2c0ff9d7f6757f",
|
|
80
|
+
},
|
|
81
|
+
"darwin-x64": {
|
|
82
|
+
triple: "x86_64-macos",
|
|
83
|
+
sha256: "4686806d59abea93866a917c3025a049ef3d7a3240de742ad66bd1a4d9890dc7",
|
|
84
|
+
},
|
|
85
|
+
"darwin-arm64": {
|
|
86
|
+
triple: "arm64-macos",
|
|
87
|
+
sha256: "2569f4139460fcb301484d37938de91b1c220efd15487ee22a9db554262062fe",
|
|
88
|
+
},
|
|
89
|
+
"win32-x64": {
|
|
90
|
+
triple: "x86_64-win32",
|
|
91
|
+
sha256: "26085abd9e16139a1394197318dbf4ccea51810e7c1baaab12b348e28553ef4d",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const DOWNLOAD_TIMEOUT_MS = 90_000;
|
|
96
|
+
const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
97
|
+
/** After a failed install, don't retry on every fetch — once an hour. */
|
|
98
|
+
const INSTALL_RETRY_MS = 60 * 60 * 1000;
|
|
99
|
+
|
|
100
|
+
/** The pinned asset for this host, or undefined when unsupported. */
|
|
101
|
+
export function assetFor(
|
|
102
|
+
platform: string = process.platform,
|
|
103
|
+
arch: string = process.arch,
|
|
104
|
+
): (Asset & { url: string; exe: string }) | undefined {
|
|
105
|
+
const asset = ASSETS[`${platform}-${arch}`];
|
|
106
|
+
if (!asset) return undefined;
|
|
107
|
+
const v = CURL_IMPERSONATE_VERSION;
|
|
108
|
+
return {
|
|
109
|
+
...asset,
|
|
110
|
+
url: `https://github.com/lexiforest/curl-impersonate/releases/download/${v}/curl-impersonate-${v}.${asset.triple}.tar.gz`,
|
|
111
|
+
exe: platform === "win32" ? "curl-impersonate.exe" : "curl-impersonate",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── tar extraction ──────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pull one regular file out of an uncompressed ustar archive. The release
|
|
119
|
+
* tarballs are flat (optionally "./"-prefixed), so no long-name or pax
|
|
120
|
+
* handling is needed; anything else is simply not found.
|
|
121
|
+
*/
|
|
122
|
+
export function extractTarMember(tar: Buffer, name: string): Buffer | null {
|
|
123
|
+
let offset = 0;
|
|
124
|
+
while (offset + 512 <= tar.length) {
|
|
125
|
+
const header = tar.subarray(offset, offset + 512);
|
|
126
|
+
if (header.every((b) => b === 0)) break; // end-of-archive block
|
|
127
|
+
const rawName = header
|
|
128
|
+
.subarray(0, 100)
|
|
129
|
+
.toString("utf8")
|
|
130
|
+
.replace(/\0.*$/s, "");
|
|
131
|
+
const prefix = header
|
|
132
|
+
.subarray(345, 500)
|
|
133
|
+
.toString("utf8")
|
|
134
|
+
.replace(/\0.*$/s, "");
|
|
135
|
+
const full = (prefix ? `${prefix}/${rawName}` : rawName).replace(
|
|
136
|
+
/^\.\//,
|
|
137
|
+
"",
|
|
138
|
+
);
|
|
139
|
+
const size = parseInt(
|
|
140
|
+
header.subarray(124, 136).toString("utf8").replace(/\0.*$/s, "").trim() ||
|
|
141
|
+
"0",
|
|
142
|
+
8,
|
|
143
|
+
);
|
|
144
|
+
const type = String.fromCharCode(header[156] || 48); // NUL = regular file
|
|
145
|
+
const start = offset + 512;
|
|
146
|
+
if (full === name && (type === "0" || type === "\0")) {
|
|
147
|
+
return tar.subarray(start, start + size);
|
|
148
|
+
}
|
|
149
|
+
offset = start + Math.ceil(size / 512) * 512;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── install ─────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
type InstallOptions = {
|
|
157
|
+
/** Install root (default ~/.talon/bin). */
|
|
158
|
+
root?: string;
|
|
159
|
+
/** Download seam for tests. */
|
|
160
|
+
download?: (url: string) => Promise<Buffer>;
|
|
161
|
+
platform?: string;
|
|
162
|
+
arch?: string;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
async function defaultDownload(url: string): Promise<Buffer> {
|
|
166
|
+
const resp = await fetch(url, {
|
|
167
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
168
|
+
redirect: "follow",
|
|
169
|
+
});
|
|
170
|
+
if (!resp.ok) throw fetchError(`HTTP ${resp.status} for ${url}`);
|
|
171
|
+
return await readBodyLimited(resp, MAX_ARCHIVE_BYTES);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sha256(buf: Buffer): string {
|
|
175
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Install the pinned curl-impersonate for this host and return the
|
|
180
|
+
* executable's path. Idempotent: an existing install is re-verified
|
|
181
|
+
* against the digest recorded when it was extracted.
|
|
182
|
+
*/
|
|
183
|
+
export async function installCurlImpersonate(
|
|
184
|
+
opts: InstallOptions = {},
|
|
185
|
+
): Promise<string> {
|
|
186
|
+
const asset = assetFor(opts.platform, opts.arch);
|
|
187
|
+
if (!asset) {
|
|
188
|
+
throw fetchError(
|
|
189
|
+
`no pinned curl-impersonate build for ${opts.platform ?? process.platform}-${opts.arch ?? process.arch}; set fetch.curlImpersonatePath`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const dir = join(
|
|
193
|
+
opts.root ?? dirs.bin,
|
|
194
|
+
`curl-impersonate-${CURL_IMPERSONATE_VERSION}`,
|
|
195
|
+
);
|
|
196
|
+
const exe = join(dir, asset.exe);
|
|
197
|
+
const marker = `${exe}.sha256`;
|
|
198
|
+
if (existsSync(exe) && existsSync(marker)) {
|
|
199
|
+
const [recorded, actual] = await Promise.all([
|
|
200
|
+
readFile(marker, "utf8").then((s) => s.trim()),
|
|
201
|
+
readFile(exe).then(sha256),
|
|
202
|
+
]);
|
|
203
|
+
if (recorded && recorded === actual) return exe;
|
|
204
|
+
log(
|
|
205
|
+
"fetch",
|
|
206
|
+
`curl-impersonate at ${exe} failed its digest check; reinstalling`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const archive = await (opts.download ?? defaultDownload)(asset.url);
|
|
211
|
+
const got = sha256(archive);
|
|
212
|
+
if (got !== asset.sha256) {
|
|
213
|
+
throw fetchError(
|
|
214
|
+
`curl-impersonate archive digest mismatch (expected ${asset.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…) — refusing it`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const binary = extractTarMember(gunzipSync(archive), asset.exe);
|
|
218
|
+
if (!binary || binary.length === 0) {
|
|
219
|
+
throw fetchError(`${asset.exe} not found in ${asset.url}`);
|
|
220
|
+
}
|
|
221
|
+
await mkdir(dir, { recursive: true });
|
|
222
|
+
const tmp = `${exe}.${process.pid}.tmp`;
|
|
223
|
+
await writeFile(tmp, binary);
|
|
224
|
+
await chmod(tmp, 0o755);
|
|
225
|
+
await rename(tmp, exe);
|
|
226
|
+
await writeFile(marker, `${sha256(binary)}\n`);
|
|
227
|
+
log(
|
|
228
|
+
"fetch",
|
|
229
|
+
`Installed curl-impersonate ${CURL_IMPERSONATE_VERSION} → ${exe}`,
|
|
230
|
+
);
|
|
231
|
+
return exe;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Process-wide resolver: explicit path > cached install > first-use
|
|
236
|
+
* download. Failures are remembered for an hour so a host without
|
|
237
|
+
* GitHub access doesn't pay a download timeout on every fetch.
|
|
238
|
+
*/
|
|
239
|
+
let installing: Promise<string> | null = null;
|
|
240
|
+
let failedAt = 0;
|
|
241
|
+
let failure = "";
|
|
242
|
+
|
|
243
|
+
export async function resolveCurlImpersonate(
|
|
244
|
+
explicitPath?: string,
|
|
245
|
+
): Promise<string> {
|
|
246
|
+
if (explicitPath) {
|
|
247
|
+
if (!existsSync(explicitPath)) {
|
|
248
|
+
throw fetchError(
|
|
249
|
+
`fetch.curlImpersonatePath ${explicitPath} does not exist`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return explicitPath;
|
|
253
|
+
}
|
|
254
|
+
if (failedAt && Date.now() - failedAt < INSTALL_RETRY_MS) {
|
|
255
|
+
throw fetchError(failure);
|
|
256
|
+
}
|
|
257
|
+
installing ??= installCurlImpersonate().catch((err: unknown) => {
|
|
258
|
+
installing = null;
|
|
259
|
+
failedAt = Date.now();
|
|
260
|
+
failure = `curl-impersonate unavailable: ${err instanceof Error ? err.message : String(err)}`;
|
|
261
|
+
log("fetch", failure);
|
|
262
|
+
throw fetchError(failure);
|
|
263
|
+
});
|
|
264
|
+
return await installing;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── the rung ────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/** Proxy for a rung: URL without credentials, credentials kept apart. */
|
|
270
|
+
export type ProxySpec = { url: string; user?: string; pass?: string };
|
|
271
|
+
|
|
272
|
+
/** Quote a value for a curl config file (`-K`): double quotes, escaped. */
|
|
273
|
+
function curlConfigValue(value: string): string {
|
|
274
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Parse the last header block curl wrote with `-D`. */
|
|
278
|
+
export function parseHeaderDump(dump: string): Headers {
|
|
279
|
+
const blocks = dump.split(/\r?\n\r?\n/).filter((b) => b.trim());
|
|
280
|
+
const last = blocks[blocks.length - 1] ?? "";
|
|
281
|
+
const headers = new Headers();
|
|
282
|
+
for (const line of last.split(/\r?\n/).slice(1)) {
|
|
283
|
+
const idx = line.indexOf(":");
|
|
284
|
+
if (idx <= 0) continue;
|
|
285
|
+
try {
|
|
286
|
+
headers.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
|
287
|
+
} catch {
|
|
288
|
+
/* a header Headers refuses is not one we need */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return headers;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Thrown when curl gave no HTTP answer (DNS, connect, TLS, timeout). */
|
|
295
|
+
class CurlTransportError extends Error {
|
|
296
|
+
constructor(
|
|
297
|
+
message: string,
|
|
298
|
+
readonly exitCode: number,
|
|
299
|
+
) {
|
|
300
|
+
super(message);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** curl exit code for --max-filesize. */
|
|
305
|
+
const CURL_FILESIZE_EXCEEDED = 63;
|
|
306
|
+
|
|
307
|
+
type RunCurl = (
|
|
308
|
+
exe: string,
|
|
309
|
+
args: string[],
|
|
310
|
+
stdin: string,
|
|
311
|
+
) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
312
|
+
|
|
313
|
+
const runCurl: RunCurl = (exe, args, stdin) =>
|
|
314
|
+
new Promise((resolve, reject) => {
|
|
315
|
+
const child = spawn(exe, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
316
|
+
let stdout = "";
|
|
317
|
+
let stderr = "";
|
|
318
|
+
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
|
|
319
|
+
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
|
320
|
+
child.on("error", reject);
|
|
321
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
|
|
322
|
+
child.stdin.end(stdin);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
export type CurlRungOptions = {
|
|
326
|
+
target: string;
|
|
327
|
+
proxy?: ProxySpec;
|
|
328
|
+
/** Label for the proxy in `via` (host only — never credentials). */
|
|
329
|
+
proxyLabel?: string;
|
|
330
|
+
/** Resolves the executable (download on first use by default). */
|
|
331
|
+
resolveExe?: () => Promise<string>;
|
|
332
|
+
/** Process seam for tests. */
|
|
333
|
+
run?: RunCurl;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** A single-hop curl-impersonate request (the ladder follows redirects). */
|
|
337
|
+
export function curlImpersonateRung(opts: CurlRungOptions): Rung {
|
|
338
|
+
const resolveExe = opts.resolveExe ?? (() => resolveCurlImpersonate());
|
|
339
|
+
const run = opts.run ?? runCurl;
|
|
340
|
+
const name = opts.proxyLabel
|
|
341
|
+
? `impersonate:${opts.target}@${opts.proxyLabel}`
|
|
342
|
+
: `impersonate:${opts.target}`;
|
|
343
|
+
return {
|
|
344
|
+
name,
|
|
345
|
+
relayed: !!opts.proxy,
|
|
346
|
+
followsRedirects: false,
|
|
347
|
+
publicOnly: true,
|
|
348
|
+
async available() {
|
|
349
|
+
try {
|
|
350
|
+
await resolveExe();
|
|
351
|
+
return true;
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return err instanceof Error ? err.message : String(err);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
async request(req: RungRequest): Promise<RawResponse> {
|
|
357
|
+
const exe = await resolveExe();
|
|
358
|
+
const work = await mkdtemp(join(tmpdir(), "talon-fetch-"));
|
|
359
|
+
const headerFile = join(work, "headers");
|
|
360
|
+
const bodyFile = join(work, "body");
|
|
361
|
+
try {
|
|
362
|
+
const args = [
|
|
363
|
+
"--impersonate",
|
|
364
|
+
opts.target,
|
|
365
|
+
"--compressed",
|
|
366
|
+
"--silent",
|
|
367
|
+
"--show-error",
|
|
368
|
+
"--proto",
|
|
369
|
+
"=http,https",
|
|
370
|
+
"--max-time",
|
|
371
|
+
String(Math.max(1, Math.ceil(req.timeoutMs / 1000))),
|
|
372
|
+
"--max-filesize",
|
|
373
|
+
String(req.maxBytes),
|
|
374
|
+
"--dump-header",
|
|
375
|
+
headerFile,
|
|
376
|
+
"--output",
|
|
377
|
+
bodyFile,
|
|
378
|
+
"--write-out",
|
|
379
|
+
"%{http_code}",
|
|
380
|
+
// Config on stdin: proxy credentials never appear in argv (ps).
|
|
381
|
+
"--config",
|
|
382
|
+
"-",
|
|
383
|
+
];
|
|
384
|
+
if (req.cookieJar)
|
|
385
|
+
args.push("--cookie", req.cookieJar, "--cookie-jar", req.cookieJar);
|
|
386
|
+
if (req.pinnedAddresses?.length && !opts.proxy) {
|
|
387
|
+
const port =
|
|
388
|
+
req.url.port || (req.url.protocol === "https:" ? "443" : "80");
|
|
389
|
+
const host = req.url.hostname.replace(/^\[|\]$/g, "");
|
|
390
|
+
const addrs = req.pinnedAddresses
|
|
391
|
+
.map((a) => (a.includes(":") ? `[${a}]` : a))
|
|
392
|
+
.join(",");
|
|
393
|
+
args.push("--resolve", `${host}:${port}:${addrs}`);
|
|
394
|
+
}
|
|
395
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
396
|
+
args.push("--header", `${k}: ${v}`);
|
|
397
|
+
}
|
|
398
|
+
args.push("--url", req.url.href);
|
|
399
|
+
|
|
400
|
+
const config: string[] = [];
|
|
401
|
+
if (opts.proxy) {
|
|
402
|
+
config.push(`proxy = ${curlConfigValue(opts.proxy.url)}`);
|
|
403
|
+
if (opts.proxy.user !== undefined) {
|
|
404
|
+
config.push(
|
|
405
|
+
`proxy-user = ${curlConfigValue(`${opts.proxy.user}:${opts.proxy.pass ?? ""}`)}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const { code, stdout, stderr } = await run(
|
|
410
|
+
exe,
|
|
411
|
+
args,
|
|
412
|
+
`${config.join("\n")}\n`,
|
|
413
|
+
);
|
|
414
|
+
const status = Number(stdout.trim().slice(-3)) || 0;
|
|
415
|
+
if (code === CURL_FILESIZE_EXCEEDED) {
|
|
416
|
+
throw new ResponseTooLargeError();
|
|
417
|
+
}
|
|
418
|
+
if (code !== 0 || status === 0) {
|
|
419
|
+
throw new CurlTransportError(
|
|
420
|
+
(stderr.trim().split("\n").pop() ?? "") || `curl exited ${code}`,
|
|
421
|
+
code,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const [dump, body] = await Promise.all([
|
|
425
|
+
readFile(headerFile, "latin1").catch(() => ""),
|
|
426
|
+
readFile(bodyFile).catch(() => Buffer.alloc(0)),
|
|
427
|
+
]);
|
|
428
|
+
return {
|
|
429
|
+
status,
|
|
430
|
+
headers: parseHeaderDump(dump),
|
|
431
|
+
body,
|
|
432
|
+
url: req.url.href,
|
|
433
|
+
};
|
|
434
|
+
} finally {
|
|
435
|
+
await rm(work, { recursive: true, force: true }).catch(() => {});
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Seam for a second impersonation engine. A curl_cffi sidecar
|
|
443
|
+
* (`python3 -c` reading a JSON request on stdin) fits here unchanged: it
|
|
444
|
+
* only has to return a Rung for a (target, proxy) pair. Not implemented:
|
|
445
|
+
* curl-impersonate needs no Python and is the engine curl_cffi wraps.
|
|
446
|
+
*/
|
|
447
|
+
export type ImpersonationEngine = (opts: CurlRungOptions) => Rung;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classified errors for the fetch ladder (see core/errors.ts). A rung that
|
|
3
|
+
* throws one of these is recorded as a "network" outcome and the ladder
|
|
4
|
+
* climbs on; setup failures (no binary, bad digest) are "unknown".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { TalonError } from "../errors.js";
|
|
8
|
+
|
|
9
|
+
export function fetchError(
|
|
10
|
+
message: string,
|
|
11
|
+
reason: "network" | "unknown" = "network",
|
|
12
|
+
): TalonError {
|
|
13
|
+
return new TalonError(message, { reason, retryable: false });
|
|
14
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the fetch ladder from config (`fetch`, `fetchUrl`, `playwright`).
|
|
3
|
+
* See ladder.ts for the climb and docs/fetch-ladder.md for the operator view.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import type { TalonConfig } from "../config/index.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_TARGETS,
|
|
10
|
+
curlImpersonateRung,
|
|
11
|
+
resolveCurlImpersonate,
|
|
12
|
+
type ImpersonationEngine,
|
|
13
|
+
type ProxySpec,
|
|
14
|
+
} from "./curl-impersonate.js";
|
|
15
|
+
import { DailyByteBudget, FetchLadder } from "./ladder.js";
|
|
16
|
+
import {
|
|
17
|
+
browserRung,
|
|
18
|
+
egressRung,
|
|
19
|
+
plainRung,
|
|
20
|
+
playwrightEndpoint,
|
|
21
|
+
} from "./rungs.js";
|
|
22
|
+
import type { Rung } from "./types.js";
|
|
23
|
+
|
|
24
|
+
type FetchSettings = NonNullable<TalonConfig["fetch"]>;
|
|
25
|
+
|
|
26
|
+
/** Config slice the builder reads; everything optional so partial test configs work. */
|
|
27
|
+
export type LadderConfig = {
|
|
28
|
+
fetch?: Partial<FetchSettings>;
|
|
29
|
+
fetchUrl?: { allowPrivateNetworks?: boolean };
|
|
30
|
+
playwright?: { endpoint?: string; endpointFile?: string };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* SOCKS credentials: TALON_FETCH_SOCKS_USER/TALON_FETCH_SOCKS_PASS, else
|
|
35
|
+
* the first line of `fetch.socksCredentialsFile` ("user:pass"). Never
|
|
36
|
+
* config.json — the schema refuses URLs with userinfo.
|
|
37
|
+
*/
|
|
38
|
+
export function socksCredentials(
|
|
39
|
+
file: string | undefined,
|
|
40
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
41
|
+
): { user: string; pass: string } | undefined {
|
|
42
|
+
if (env.TALON_FETCH_SOCKS_USER) {
|
|
43
|
+
return {
|
|
44
|
+
user: env.TALON_FETCH_SOCKS_USER,
|
|
45
|
+
pass: env.TALON_FETCH_SOCKS_PASS ?? "",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (!file) return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const line = readFileSync(file, "utf8").split(/\r?\n/)[0]?.trim() ?? "";
|
|
51
|
+
const idx = line.indexOf(":");
|
|
52
|
+
if (idx <= 0) return undefined;
|
|
53
|
+
return { user: line.slice(0, idx), pass: line.slice(idx + 1) };
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type BuildOptions = {
|
|
60
|
+
maxBytes: number;
|
|
61
|
+
engine?: ImpersonationEngine;
|
|
62
|
+
env?: NodeJS.ProcessEnv;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The ordered rung list for a config. Exported for tests and diagnostics. */
|
|
66
|
+
export function buildRungs(
|
|
67
|
+
config: LadderConfig | null | undefined,
|
|
68
|
+
opts: BuildOptions,
|
|
69
|
+
): Rung[] {
|
|
70
|
+
const f = config?.fetch ?? {};
|
|
71
|
+
const guard = config?.fetchUrl?.allowPrivateNetworks === false;
|
|
72
|
+
const engine = opts.engine ?? curlImpersonateRung;
|
|
73
|
+
const targets = f.impersonateTargets?.length
|
|
74
|
+
? f.impersonateTargets
|
|
75
|
+
: DEFAULT_TARGETS;
|
|
76
|
+
const resolveExe = () => resolveCurlImpersonate(f.curlImpersonatePath);
|
|
77
|
+
const rungs: Rung[] = [];
|
|
78
|
+
|
|
79
|
+
if (f.impersonate !== false) {
|
|
80
|
+
// (a) every profile direct — a site that rejects one often takes another.
|
|
81
|
+
for (const target of targets) rungs.push(engine({ target, resolveExe }));
|
|
82
|
+
// (b) the first profile through each exit: the IP was the problem.
|
|
83
|
+
const creds = socksCredentials(f.socksCredentialsFile, opts.env);
|
|
84
|
+
for (const exit of f.socksExits ?? []) {
|
|
85
|
+
let label: string;
|
|
86
|
+
try {
|
|
87
|
+
label = new URL(exit).hostname;
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const proxy: ProxySpec = { url: exit, ...creds };
|
|
92
|
+
rungs.push(
|
|
93
|
+
engine({ target: targets[0], proxy, proxyLabel: label, resolveExe }),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// (c) plain fetch: some sites dislike impersonation, and it is the only
|
|
98
|
+
// rung for local targets.
|
|
99
|
+
rungs.push(plainRung());
|
|
100
|
+
// (d) anti-detect browser for JS challenges.
|
|
101
|
+
if (f.camoufox) {
|
|
102
|
+
const endpoint =
|
|
103
|
+
f.browserEndpoint ?? playwrightEndpoint(config?.playwright);
|
|
104
|
+
if (endpoint) rungs.push(browserRung({ endpoint, guard }));
|
|
105
|
+
}
|
|
106
|
+
// (e) someone else's IP — last, and only when the operator named a device.
|
|
107
|
+
if (f.egressDevice) rungs.push(egressRung(f.egressDevice));
|
|
108
|
+
return rungs;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A ready ladder for the current config. Cheap: build one per call. */
|
|
112
|
+
export function buildFetchLadder(
|
|
113
|
+
config: LadderConfig | null | undefined,
|
|
114
|
+
opts: BuildOptions,
|
|
115
|
+
): FetchLadder {
|
|
116
|
+
const cap = config?.fetch?.dailyByteCap ?? DEFAULT_DAILY_BYTE_CAP;
|
|
117
|
+
return new FetchLadder({
|
|
118
|
+
rungs: buildRungs(config, opts),
|
|
119
|
+
allowPrivateNetworks: config?.fetchUrl?.allowPrivateNetworks !== false,
|
|
120
|
+
maxBytes: opts.maxBytes,
|
|
121
|
+
byteBudget: budgetFor(cap),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const DEFAULT_DAILY_BYTE_CAP = 100 * 1024 * 1024;
|
|
126
|
+
|
|
127
|
+
/** One process-wide budget per cap value (a config reload with a new cap starts fresh). */
|
|
128
|
+
let sharedBudget: DailyByteBudget | undefined;
|
|
129
|
+
function budgetFor(cap: number): DailyByteBudget {
|
|
130
|
+
if (!sharedBudget || sharedBudget.cap !== cap)
|
|
131
|
+
sharedBudget = new DailyByteBudget(cap);
|
|
132
|
+
return sharedBudget;
|
|
133
|
+
}
|