@zocomputer/agent-sdk 0.5.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
package/src/walk.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import ignore, { type Ignore } from "ignore";
4
+
5
+ // The *fallback* candidate source — `listGitFiles` (git's own ignore
6
+ // semantics) is preferred — so it honors `.gitignore` files itself (root,
7
+ // nested, and ancestors between `base` and `root` for scoped walks) rather
8
+ // than duplicating the repo's ignore list here. The hardcoded set is only a
9
+ // safety net for trees with no `.gitignore` at all: VCS stores plus the one
10
+ // universally-huge dependency dir. Exported so the sandbox search backend
11
+ // (../sandbox-io.ts) applies the same unconditional skips — the two backends
12
+ // must not drift on what a search can wander into.
13
+ export const ALWAYS_IGNORED = new Set([".git", ".jj", ".hg", ".svn", "node_modules"]);
14
+
15
+ // One .gitignore's matcher, scoped to the directory that holds it. `prefix` is
16
+ // that directory relative to the walk base (forward-slash, "" for the base
17
+ // itself); patterns only apply to paths under it. Matching is delegated to the
18
+ // `ignore` npm package (the battle-tested matcher ESLint/Prettier use), so we
19
+ // get the full gitignore spec — escapes and all — instead of maintaining one.
20
+ type ScopedIgnore = { prefix: string; matcher: Ignore };
21
+
22
+ // Read `<absDir>/.gitignore` into a scope. Null when absent or unreadable —
23
+ // callers just skip pushing a scope.
24
+ function loadGitignore(absDir: string, prefix: string): ScopedIgnore | null {
25
+ let text: string;
26
+ try {
27
+ text = readFileSync(join(absDir, ".gitignore"), "utf8");
28
+ } catch {
29
+ return null;
30
+ }
31
+ return { prefix, matcher: ignore().add(text) };
32
+ }
33
+
34
+ // Git-style decision for a base-relative, forward-slash path: evaluate every
35
+ // scope whose directory contains the path (callers pass them outer→inner) and
36
+ // let the innermost explicit verdict — ignore or `!` re-include — win. A
37
+ // trailing slash tells the matcher "directory", so dir-only patterns (`out/`)
38
+ // apply. Files under an ignored directory never get here — the walker prunes
39
+ // the directory, matching git's "can't re-include under an excluded dir" rule.
40
+ function isIgnored(scopes: readonly ScopedIgnore[], relPath: string, isDir: boolean): boolean {
41
+ let ignored = false;
42
+ for (const scope of scopes) {
43
+ let sub: string;
44
+ if (scope.prefix === "") sub = relPath;
45
+ else if (relPath.startsWith(`${scope.prefix}/`)) sub = relPath.slice(scope.prefix.length + 1);
46
+ else continue;
47
+ const verdict = scope.matcher.test(isDir ? `${sub}/` : sub);
48
+ if (verdict.ignored) ignored = true;
49
+ else if (verdict.unignored) ignored = false;
50
+ }
51
+ return ignored;
52
+ }
53
+
54
+ type Frame = {
55
+ dir: string; // absolute
56
+ rel: string; // base-relative, forward-slash ("" for base itself)
57
+ scopes: ScopedIgnore[]; // .gitignore scopes from ancestors (not `dir` itself)
58
+ };
59
+
60
+ // Depth-first walk yielding `base`-relative, forward-slash file paths. glob
61
+ // and grep run in-process (no ripgrep dependency), so this is deliberately
62
+ // modest — it exists to serve interactive searches, not to index the world.
63
+ // `base` defaults to `root` for whole-workspace walks; a scoped walk passes
64
+ // the subtree as `root` and the workspace as `base` so paths stay
65
+ // base-relative and ancestor .gitignores still apply.
66
+ export function* walkFiles(root: string, base: string = root): Generator<string> {
67
+ // Ancestor .gitignore chain: when walking a subtree, patterns from `base`
68
+ // down to `root`'s parent still apply to it (git semantics). `root`'s own
69
+ // .gitignore loads when its frame is processed, like every other dir.
70
+ const scopes: ScopedIgnore[] = [];
71
+ const relRoot = relative(base, root).split(sep).join("/");
72
+ if (relRoot !== "" && !relRoot.startsWith("..")) {
73
+ let absDir = base;
74
+ let prefix = "";
75
+ const own = loadGitignore(base, "");
76
+ if (own !== null) scopes.push(own);
77
+ const segments = relRoot.split("/");
78
+ for (const segment of segments.slice(0, -1)) {
79
+ absDir = join(absDir, segment);
80
+ prefix = prefix === "" ? segment : `${prefix}/${segment}`;
81
+ const scope = loadGitignore(absDir, prefix);
82
+ if (scope !== null) scopes.push(scope);
83
+ }
84
+ }
85
+
86
+ const stack: Frame[] = [{ dir: root, rel: relRoot === "." ? "" : relRoot, scopes }];
87
+ while (stack.length > 0) {
88
+ const frame = stack.pop();
89
+ if (frame === undefined) break;
90
+ let entries;
91
+ try {
92
+ entries = readdirSync(frame.dir, { withFileTypes: true });
93
+ } catch {
94
+ continue;
95
+ }
96
+ const own = loadGitignore(frame.dir, frame.rel);
97
+ const active = own === null ? frame.scopes : [...frame.scopes, own];
98
+ for (const entry of entries) {
99
+ const rel = frame.rel === "" ? entry.name : `${frame.rel}/${entry.name}`;
100
+ if (entry.isDirectory()) {
101
+ if (ALWAYS_IGNORED.has(entry.name)) continue;
102
+ if (isIgnored(active, rel, true)) continue;
103
+ stack.push({ dir: join(frame.dir, entry.name), rel, scopes: active });
104
+ } else if (entry.isFile()) {
105
+ // In a linked worktree `.git` is a file (a gitdir pointer), not a dir.
106
+ if (entry.name === ".git") continue;
107
+ if (isIgnored(active, rel, false)) continue;
108
+ yield rel;
109
+ }
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,104 @@
1
+ // Pure output watcher for background commands: feed it output chunks, get back
2
+ // the complete lines that match a regex — debounced and capped so a chatty
3
+ // pattern can't flood the session with notification turns. The effect side
4
+ // (posting a park notification per match batch) lives with the tools; this
5
+ // module is the testable decision core.
6
+
7
+ export interface OutputWatcherOptions {
8
+ /** Regex source matched against complete output lines. */
9
+ pattern: string;
10
+ /** Minimum ms between match batches; matches inside the window drop. */
11
+ debounceMs?: number | undefined;
12
+ /** Max match batches over the watcher's lifetime; later matches drop. */
13
+ maxNotifications?: number;
14
+ /** Clock, injectable for tests. */
15
+ now?: () => number;
16
+ }
17
+
18
+ export interface OutputWatcher {
19
+ /**
20
+ * Consume an output chunk (any framing — watcher handles line buffering).
21
+ * Returns the matching complete lines that clear debounce/cap, or null.
22
+ */
23
+ feed(chunk: string): string[] | null;
24
+ /** Flush the unterminated tail as a final line (call when the command ends). */
25
+ flush(): string[] | null;
26
+ }
27
+
28
+ export const DEFAULT_WATCH_DEBOUNCE_MS = 5_000;
29
+ export const DEFAULT_MAX_WATCH_NOTIFICATIONS = 5;
30
+
31
+ /**
32
+ * Build a watcher. An invalid regex throws here — at tool-input time, where
33
+ * the error surfaces as a normal tool failure the model can correct.
34
+ */
35
+ export function createOutputWatcher(options: OutputWatcherOptions): OutputWatcher {
36
+ const regex = new RegExp(options.pattern);
37
+ const debounceMs = options.debounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS;
38
+ const maxNotifications = options.maxNotifications ?? DEFAULT_MAX_WATCH_NOTIFICATIONS;
39
+ const now = options.now ?? Date.now;
40
+
41
+ let buffer = "";
42
+ let notifications = 0;
43
+ let lastNotifiedAt: number | null = null;
44
+
45
+ function emit(lines: string[]): string[] | null {
46
+ const matches = lines.filter((line) => regex.test(line));
47
+ if (matches.length === 0) return null;
48
+ if (notifications >= maxNotifications) return null;
49
+ const at = now();
50
+ if (lastNotifiedAt !== null && at - lastNotifiedAt < debounceMs) return null;
51
+ notifications += 1;
52
+ lastNotifiedAt = at;
53
+ return matches;
54
+ }
55
+
56
+ return {
57
+ feed(chunk) {
58
+ buffer += chunk;
59
+ const parts = buffer.split("\n");
60
+ // The last part is an unterminated tail; keep buffering it.
61
+ buffer = parts.pop() ?? "";
62
+ return emit(parts);
63
+ },
64
+ flush() {
65
+ if (buffer.length === 0) return null;
66
+ const tail = buffer;
67
+ buffer = "";
68
+ // Bypass debounce for the final output: a command ending is the signal.
69
+ const matches = [tail].filter((line) => regex.test(line));
70
+ if (matches.length === 0) return null;
71
+ if (notifications >= maxNotifications) return null;
72
+ notifications += 1;
73
+ lastNotifiedAt = now();
74
+ return matches;
75
+ },
76
+ };
77
+ }
78
+
79
+ /** The message text for a watcher match, complete and self-describing. */
80
+ export function formatWatchNotification(opts: {
81
+ taskId: string;
82
+ label: string;
83
+ reason: string;
84
+ lines: readonly string[];
85
+ }): string {
86
+ return (
87
+ `Background task ${opts.taskId} (${opts.label}) — ${opts.reason}. ` +
88
+ `Output matched your watch pattern:\n${opts.lines.join("\n")}`
89
+ );
90
+ }
91
+
92
+ /** The message text for a completion notice (run_async notify_on_complete). */
93
+ export function formatCompletionNotification(opts: {
94
+ taskId: string;
95
+ label: string;
96
+ status: "done" | "error";
97
+ error?: string;
98
+ }): string {
99
+ const outcome =
100
+ opts.status === "done"
101
+ ? "finished"
102
+ : `failed${opts.error ? `: ${opts.error}` : ""}`;
103
+ return `Background task ${opts.taskId} (${opts.label}) ${outcome}. Call await_task to collect its result.`;
104
+ }
@@ -0,0 +1,324 @@
1
+ import { Parser } from "htmlparser2";
2
+ import { parseHTML } from "linkedom";
3
+ import TurndownService from "turndown";
4
+ import {
5
+ buildContentCollapseNote,
6
+ buildMetadataHeader,
7
+ extractMainContent,
8
+ looksLikeRawHtmlOutput,
9
+ visibleTextLength,
10
+ } from "./web-page";
11
+
12
+ // The fetch + render core for the `webfetch` tool. Conventions follow the
13
+ // converged Claude Code / opencode / eve standard: browser UA with per-format
14
+ // Accept q-values, markdown as the default rendering for HTML, a one-shot
15
+ // honest-UA retry when Cloudflare's bot check rejects the browser UA (TLS
16
+ // fingerprint mismatch), and a hard response-size cap. HTML reduces to its
17
+ // main content with a metadata header (./web-page.ts) before conversion. Kept
18
+ // framework-free (no eve imports) with `fetchImpl` injectable so tests never
19
+ // touch the network; the tool wrapper owns bounding/spill and the attachment
20
+ // contract.
21
+
22
+ /** Hard cap on the fetched body; larger responses error rather than truncate. */
23
+ export const WEB_FETCH_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
24
+ export const WEB_FETCH_DEFAULT_TIMEOUT_SECONDS = 30;
25
+ /** PDFs are routinely tens of MB behind slow servers; give them a longer default. */
26
+ export const WEB_FETCH_PDF_DEFAULT_TIMEOUT_SECONDS = 60;
27
+ export const WEB_FETCH_MAX_TIMEOUT_SECONDS = 120;
28
+
29
+ export type WebFetchFormat = "markdown" | "text" | "html";
30
+
31
+ // A real browser UA gets past naive bot filters; sites that inspect the TLS
32
+ // fingerprint (Cloudflare) see through it, hence the honest-UA retry below.
33
+ export const BROWSER_USER_AGENT =
34
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
35
+ export const FALLBACK_USER_AGENT = "agent-sdk";
36
+
37
+ function acceptHeader(format: WebFetchFormat): string {
38
+ switch (format) {
39
+ case "markdown":
40
+ return "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1";
41
+ case "text":
42
+ return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1";
43
+ case "html":
44
+ return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1";
45
+ }
46
+ }
47
+
48
+ export function buildWebFetchHeaders(
49
+ format: WebFetchFormat,
50
+ userAgent: string = BROWSER_USER_AGENT,
51
+ ): Record<string, string> {
52
+ return {
53
+ Accept: acceptHeader(format),
54
+ "Accept-Language": "en-US,en;q=0.9",
55
+ "User-Agent": userAgent,
56
+ };
57
+ }
58
+
59
+ export function convertHtmlToMarkdown(html: string): string {
60
+ const turndown = new TurndownService({
61
+ headingStyle: "atx",
62
+ hr: "---",
63
+ bulletListMarker: "-",
64
+ codeBlockStyle: "fenced",
65
+ emDelimiter: "*",
66
+ });
67
+ turndown.remove(["script", "style", "meta", "link"]);
68
+ // Parse with linkedom explicitly instead of handing turndown the string:
69
+ // given a string, turndown sniffs a global DOMParser at import time and
70
+ // prefers it, and DOM-polyfilled hosts (e.g. a happy-dom test preload)
71
+ // produce a document it can't traverse — output silently collapses to "".
72
+ // The wrapper element mirrors turndown's own string path (elements arranged
73
+ // in one root instead of head/body).
74
+ const { document } = parseHTML(`<x-turndown id="turndown-root">${html}</x-turndown>`);
75
+ const root = document.getElementById("turndown-root");
76
+ if (root === null) throw new Error("unreachable: the turndown-root wrapper always parses");
77
+ // linkedom's nodes implement the DOM surface turndown needs but aren't
78
+ // lib.dom types (we compile without the DOM lib) — the one sanctioned cast.
79
+ return turndown.turndown(root as unknown as Parameters<TurndownService["turndown"]>[0]);
80
+ }
81
+
82
+ const SKIPPED_HTML_TAGS = ["script", "style", "noscript", "iframe", "object", "embed"];
83
+
84
+ export function extractTextFromHtml(html: string): string {
85
+ let text = "";
86
+ const skipStack: string[] = [];
87
+ const parser = new Parser({
88
+ onopentag(name: string) {
89
+ if (skipStack.length > 0 || SKIPPED_HTML_TAGS.includes(name)) {
90
+ skipStack.push(name);
91
+ }
92
+ },
93
+ ontext(input: string) {
94
+ if (skipStack.length === 0) text += input;
95
+ },
96
+ onclosetag(name: string) {
97
+ if (skipStack.length > 0 && skipStack[skipStack.length - 1] === name) {
98
+ skipStack.pop();
99
+ }
100
+ },
101
+ });
102
+ parser.write(html);
103
+ parser.end();
104
+ // Collapse the whitespace runs left behind by stripped markup.
105
+ return text
106
+ .split("\n")
107
+ .map((line) => line.replace(/[ \t]+/g, " ").trim())
108
+ .join("\n")
109
+ .replace(/\n{3,}/g, "\n\n")
110
+ .trim();
111
+ }
112
+
113
+ export function isHtmlContentType(contentType: string): boolean {
114
+ const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
115
+ return mime === "text/html" || mime === "application/xhtml+xml";
116
+ }
117
+
118
+ /**
119
+ * Sniff content to detect HTML even when the Content-Type header is wrong or
120
+ * missing. Checks for common HTML markers at the start of the content.
121
+ */
122
+ export function looksLikeHtml(content: string): boolean {
123
+ const trimmed = content.trimStart().slice(0, 512).toLowerCase();
124
+ return (
125
+ trimmed.startsWith("<!doctype html") ||
126
+ trimmed.startsWith("<html") ||
127
+ /^<(!--|head|body|div|p|h[1-6]|span|a|ul|ol|li|table|script|style)\b/.test(trimmed)
128
+ );
129
+ }
130
+
131
+ export interface RenderedWebText {
132
+ readonly text: string;
133
+ /** Honest-failure signal (content collapse, leftover raw HTML); absent when the render looks healthy. */
134
+ readonly note?: string;
135
+ }
136
+
137
+ /**
138
+ * Render fetched text per the requested format; non-HTML (and format "html")
139
+ * passes through untouched. For markdown/text, the page first reduces to its
140
+ * main content with a metadata header (falling back to the whole document
141
+ * when extraction can't find one), and a render that comes back suspiciously
142
+ * empty or tag-heavy carries a note saying so.
143
+ */
144
+ export function renderWebText(
145
+ content: string,
146
+ contentType: string,
147
+ format: WebFetchFormat,
148
+ url: string,
149
+ ): RenderedWebText {
150
+ // Route by sniffed content in addition to Content-Type, since servers lie.
151
+ const isHtml = isHtmlContentType(contentType) || looksLikeHtml(content);
152
+ if (!isHtml || format === "html") return { text: content };
153
+ const page = extractMainContent(content, url);
154
+ const bodyHtml = page === null ? content : page.contentHtml;
155
+ const converted =
156
+ format === "markdown" ? convertHtmlToMarkdown(bodyHtml) : extractTextFromHtml(bodyHtml);
157
+ const header = page === null ? null : buildMetadataHeader(page);
158
+ const text = header === null ? converted : `${header}\n\n${converted}`;
159
+ const notes: string[] = [];
160
+ const collapse = buildContentCollapseNote({
161
+ url,
162
+ renderedChars: converted.trim().length,
163
+ htmlChars: content.length,
164
+ documentTextChars: visibleTextLength(content),
165
+ });
166
+ if (collapse !== null) notes.push(collapse);
167
+ if (format === "markdown" && looksLikeRawHtmlOutput(converted)) {
168
+ notes.push(
169
+ 'The markdown conversion left substantial raw HTML in the output — treat it as partially converted, or re-fetch with format "html" for the true page.',
170
+ );
171
+ }
172
+ return notes.length === 0 ? { text } : { text, note: notes.join(" ") };
173
+ }
174
+
175
+ /**
176
+ * The slice of `fetch` this module needs. Narrower than `typeof fetch` (Bun
177
+ * adds a `preconnect` property to the global) so test stubs stay plain
178
+ * functions.
179
+ */
180
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
181
+
182
+ export interface FetchedWebResource {
183
+ readonly body: Buffer;
184
+ /** Raw `content-type` header value; empty string when absent. */
185
+ readonly contentType: string;
186
+ /** URL after redirects when the fetch implementation exposes it; else the request URL. */
187
+ readonly finalUrl: string;
188
+ }
189
+
190
+ /** Throws with a clear message for anything that isn't a plain http(s) URL. */
191
+ export function assertHttpUrl(url: string): void {
192
+ let parsed: URL;
193
+ try {
194
+ parsed = new URL(url);
195
+ } catch {
196
+ throw new Error(`Not a valid URL: ${url}`);
197
+ }
198
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
199
+ throw new Error("URL must start with http:// or https://");
200
+ }
201
+ }
202
+
203
+ /**
204
+ * GET a URL with the standard headers, the Cloudflare-challenge retry, and the
205
+ * response-size cap. Throws on non-2xx, oversized, or timed-out responses.
206
+ */
207
+ export async function fetchWebResource(opts: {
208
+ url: string;
209
+ format: WebFetchFormat;
210
+ timeoutMs: number;
211
+ /**
212
+ * Deadline (from request start) to extend to when the response headers
213
+ * reveal a PDF the request URL didn't — a redirect to `.pdf` or an
214
+ * extensionless URL served as `application/pdf`. Omit when the caller set
215
+ * an explicit timeout, which always wins.
216
+ */
217
+ pdfTimeoutMs?: number;
218
+ fetchImpl?: FetchLike;
219
+ }): Promise<FetchedWebResource> {
220
+ const { url, format, timeoutMs, pdfTimeoutMs } = opts;
221
+ const fetchImpl = opts.fetchImpl ?? fetch;
222
+ assertHttpUrl(url);
223
+ // One deadline shared by both attempts — the retry doesn't reset the clock.
224
+ // An AbortController (not AbortSignal.timeout) so the deadline can extend
225
+ // once the headers show the resource is a PDF.
226
+ const controller = new AbortController();
227
+ const startedAt = Date.now();
228
+ let deadlineMs = timeoutMs;
229
+ let timer = setTimeout(() => controller.abort(), deadlineMs);
230
+ const timedOut = () => new Error(`Request timed out after ${deadlineMs / 1000}s: ${url}`);
231
+ try {
232
+ const headers = buildWebFetchHeaders(format);
233
+ let response: Response;
234
+ try {
235
+ response = await fetchImpl(url, { headers, signal: controller.signal });
236
+ if (
237
+ response.status === 403 &&
238
+ response.headers.get("cf-mitigated") === "challenge"
239
+ ) {
240
+ response = await fetchImpl(url, {
241
+ headers: { ...headers, "User-Agent": FALLBACK_USER_AGENT },
242
+ signal: controller.signal,
243
+ });
244
+ }
245
+ } catch (error) {
246
+ if (controller.signal.aborted) throw timedOut();
247
+ throw error;
248
+ }
249
+ if (!response.ok) {
250
+ throw new Error(`Request failed with status ${response.status}: ${url}`);
251
+ }
252
+ const contentType = response.headers.get("content-type") ?? "";
253
+ // Some fetch stubs/implementations leave Response.url empty; fall back to
254
+ // the request URL so callers can always compare for cross-host redirects.
255
+ const finalUrl = response.url === "" ? url : response.url;
256
+ // The request URL can hide that the resource is a PDF; once the headers
257
+ // say so, the body download gets the PDF deadline instead of the page one.
258
+ if (
259
+ pdfTimeoutMs !== undefined &&
260
+ pdfTimeoutMs > deadlineMs &&
261
+ responseLooksLikePdf(contentType, finalUrl)
262
+ ) {
263
+ clearTimeout(timer);
264
+ deadlineMs = pdfTimeoutMs;
265
+ const remaining = Math.max(deadlineMs - (Date.now() - startedAt), 0);
266
+ timer = setTimeout(() => controller.abort(), remaining);
267
+ }
268
+ const contentLength = response.headers.get("content-length");
269
+ if (contentLength !== null && Number.parseInt(contentLength, 10) > WEB_FETCH_MAX_RESPONSE_BYTES) {
270
+ throw new Error(
271
+ `Response too large (content-length exceeds the ${WEB_FETCH_MAX_RESPONSE_BYTES}-byte limit): ${url}`,
272
+ );
273
+ }
274
+ let arrayBuffer: ArrayBuffer;
275
+ try {
276
+ arrayBuffer = await response.arrayBuffer();
277
+ } catch (error) {
278
+ if (controller.signal.aborted) throw timedOut();
279
+ throw error;
280
+ }
281
+ if (arrayBuffer.byteLength > WEB_FETCH_MAX_RESPONSE_BYTES) {
282
+ throw new Error(
283
+ `Response too large (exceeds the ${WEB_FETCH_MAX_RESPONSE_BYTES}-byte limit): ${url}`,
284
+ );
285
+ }
286
+ return {
287
+ body: Buffer.from(arrayBuffer),
288
+ contentType,
289
+ finalUrl,
290
+ };
291
+ } finally {
292
+ clearTimeout(timer);
293
+ }
294
+ }
295
+
296
+ function responseLooksLikePdf(contentType: string, finalUrl: string): boolean {
297
+ const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
298
+ return mime === "application/pdf" || urlLooksLikePdf(finalUrl);
299
+ }
300
+
301
+ /**
302
+ * Clamp the model-supplied timeout (seconds) into the allowed range, in ms.
303
+ * An explicit timeout always wins; without one, `.pdf` URLs default higher
304
+ * than pages (models rarely think to raise the timeout for a slow PDF).
305
+ */
306
+ export function resolveWebFetchTimeoutMs(
307
+ timeoutSeconds: number | undefined,
308
+ url?: string,
309
+ ): number {
310
+ const fallback = urlLooksLikePdf(url)
311
+ ? WEB_FETCH_PDF_DEFAULT_TIMEOUT_SECONDS
312
+ : WEB_FETCH_DEFAULT_TIMEOUT_SECONDS;
313
+ const seconds = timeoutSeconds ?? fallback;
314
+ return Math.min(Math.max(seconds, 1), WEB_FETCH_MAX_TIMEOUT_SECONDS) * 1000;
315
+ }
316
+
317
+ function urlLooksLikePdf(url: string | undefined): boolean {
318
+ if (url === undefined) return false;
319
+ try {
320
+ return new URL(url).pathname.toLowerCase().endsWith(".pdf");
321
+ } catch {
322
+ return false;
323
+ }
324
+ }