acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ FETCH A DOC AND READ IT — THE PRIMITIVE WHOSE ABSENCE BURNED FOUR RUNS ─
|
|
3
|
+
*
|
|
4
|
+
* Measured on this package's own probes, 2026-08-10:
|
|
5
|
+
*
|
|
6
|
+
* · Probe D was told to read https://nodejs.org/api/test.html. Round 1 refused
|
|
7
|
+
* (a `"` is not allowed in a command string). Rounds 2-6 hand-rolled a fetch
|
|
8
|
+
* plus HTML scraping inside `evaluate`: "I fetched the page successfully",
|
|
9
|
+
* then "The output was truncated", then "The output is being truncated in
|
|
10
|
+
* the middle". NOTES.md was never written — `ls -la wsD` shows only `.env` —
|
|
11
|
+
* and the run still printed ✔ VERIFIED.
|
|
12
|
+
* · Probe G repeated the shape, gave up on the URL, guessed empirically, and
|
|
13
|
+
* never wrote API.md.
|
|
14
|
+
* · Probe A died on `mock.timers.enable({ apis: ['setTimeout', …] })`, an enum
|
|
15
|
+
* member that does not exist. ERR_INVALID_ARG_VALUE ate the whole budget.
|
|
16
|
+
*
|
|
17
|
+
* ⭐ AND THE NUMBER THAT MAKES THE CASE. Across those runs: **0 invented
|
|
18
|
+
* function names in 12 uses of Node built-ins, 3 wrong facts in ~19 uses of a
|
|
19
|
+
* library API (~16%)** — an invented enum member, a wrong async shape (`glob`
|
|
20
|
+
* from fs/promises spread as an array → "files is not iterable"), and a
|
|
21
|
+
* wrong-version idiom (`z.string().email()` under an explicit "use the v4 APIs"
|
|
22
|
+
* instruction, asserted as "The code itself is correct" in the one probe where
|
|
23
|
+
* nothing could run to contradict it). Every single one was a doc lookup away.
|
|
24
|
+
*
|
|
25
|
+
* ── ⚠️ THIS ADDS ERGONOMICS AND RAILS, NOT REACH ────────────────────────────
|
|
26
|
+
* The network was ALREADY reachable from inside `evaluate` — the model proved
|
|
27
|
+
* that by fetching the page. What it did not have was: a converter that turns
|
|
28
|
+
* HTML into something readable, a cache so window two is free, and a boundary
|
|
29
|
+
* that stops "fetch a URL" from meaning "read the cloud metadata endpoint". So
|
|
30
|
+
* do not sell this as new capability. It is the difference between having a
|
|
31
|
+
* network and being able to look something up.
|
|
32
|
+
*
|
|
33
|
+
* ── ⚠️ THE PART THAT IS SECURITY, NOT CONVENIENCE ───────────────────────────
|
|
34
|
+
* This runs on other people's machines, and the URL is chosen by a language
|
|
35
|
+
* model that will happily follow a link out of a page it just read. A tool that
|
|
36
|
+
* takes a model-supplied URL and performs a GET is an SSRF primitive unless
|
|
37
|
+
* something stops it: `http://169.254.169.254/latest/meta-data/iam/…` hands out
|
|
38
|
+
* cloud credentials, and `http://127.0.0.1:*` is every unauthenticated admin
|
|
39
|
+
* panel a developer has ever run on a laptop.
|
|
40
|
+
*
|
|
41
|
+
* ⭐ So the guard is not a blocklist of strings, it is a check on the RESOLVED
|
|
42
|
+
* ADDRESSES, re-run on every redirect hop, and the socket is then pinned to the
|
|
43
|
+
* exact addresses that were checked — otherwise a hostname that resolves public
|
|
44
|
+
* once and private a millisecond later (DNS rebinding) walks straight through a
|
|
45
|
+
* check that already passed.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ THE CONSEQUENCE IS STATED IN THE TOOL DESCRIPTION, NOT HIDDEN: this tool
|
|
48
|
+
* CANNOT talk to a server the agent just started on localhost. That is a real
|
|
49
|
+
* loss and it is deliberate — "read my dev server" is a different primitive with
|
|
50
|
+
* a different threat model, and merging the two is how the guard gets a bypass
|
|
51
|
+
* flag that eventually defaults to on.
|
|
52
|
+
*
|
|
53
|
+
* ── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────────────────
|
|
54
|
+
* No POST. No model-supplied headers, cookies, auth or body. The schema exposes
|
|
55
|
+
* `url`, `offset`, `limit` and refuses everything else BY NAME — a tool that can
|
|
56
|
+
* be talked into sending an Authorization header is a credential exfiltration
|
|
57
|
+
* path with a docs-lookup costume on.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { createHash } from 'node:crypto';
|
|
61
|
+
import dns from 'node:dns/promises';
|
|
62
|
+
import http from 'node:http';
|
|
63
|
+
import https from 'node:https';
|
|
64
|
+
import { createGunzip, createInflate, createBrotliDecompress } from 'node:zlib';
|
|
65
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
66
|
+
import { dirname } from 'node:path';
|
|
67
|
+
|
|
68
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* ⚠️ THE SHAPES ARE DECLARED, NOT INFERRED — same reason as workspace.mjs and
|
|
72
|
+
* git.mjs. Inference widens `ok: false` to `ok: boolean` and the discriminated
|
|
73
|
+
* union dies at the call site, where the console's TypeScript client reads it.
|
|
74
|
+
*
|
|
75
|
+
* @typedef {{ ok: false, error: string }} FetchRefused
|
|
76
|
+
* @typedef {{
|
|
77
|
+
* ok: true, url: string, finalUrl: string, status: number, contentType: string,
|
|
78
|
+
* totalChars: number, cachePath: string | null, fromCache: boolean,
|
|
79
|
+
* text: string, nextOffset: number | null, truncated?: boolean
|
|
80
|
+
* }} FetchOk
|
|
81
|
+
* @typedef {{ address: string, family: number }} ResolvedAddress
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** A doc lookup is fast or it is not happening. Whole-request budget, all hops. */
|
|
85
|
+
export const FETCH_TIMEOUT_MS = 20_000;
|
|
86
|
+
/** Three hops covers http→https→www→canonical. A fourth is a loop or a trap. */
|
|
87
|
+
export const MAX_REDIRECTS = 3;
|
|
88
|
+
/** Hard ceiling on what comes off the wire, decompressed. */
|
|
89
|
+
export const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
90
|
+
/** One window. Big enough to hold an API section, small enough to not eat a turn. */
|
|
91
|
+
export const DEFAULT_WINDOW = 6_000;
|
|
92
|
+
export const MAX_WINDOW = 12_000;
|
|
93
|
+
/**
|
|
94
|
+
* ⚠️ PER-PROCESS BUDGETS, and they exist because of the loop this file is fixing.
|
|
95
|
+
* An agent that can fetch can crawl, and a crawl inside an agent turn is a
|
|
96
|
+
* budget fire nobody watches. Ten lookups settles any question a coding task
|
|
97
|
+
* has; three per host stops one site becoming the whole session.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_FETCHES_PER_PROCESS = 10;
|
|
100
|
+
export const MAX_FETCHES_PER_HOST = 3;
|
|
101
|
+
|
|
102
|
+
/** Same `.acuvo/` the audit log, policy and mcp config already live in. */
|
|
103
|
+
export const CACHE_DIR = '.acuvo/fetch';
|
|
104
|
+
|
|
105
|
+
/** Content types worth converting to text. Everything else is refused BY NAME. */
|
|
106
|
+
const TEXTUAL = /^(text\/|application\/(json|xml|xhtml\+xml)$|application\/[a-z0-9.+-]*\+json$|application\/[a-z0-9.+-]*\+xml$)/i;
|
|
107
|
+
|
|
108
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
* PER-PROCESS STATE
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ PER-PROCESS AND NOT PERSISTED, exactly like breaker.mjs and for the same
|
|
112
|
+
* reason: a CLI run is minutes long, so there is no cache-expiry policy worth
|
|
113
|
+
* modelling and the next invocation gets fresh docs for free. The disk file is
|
|
114
|
+
* for PAGING within a session, not an HTTP cache — a stale answer that survives
|
|
115
|
+
* a restart is how a model ends up quoting last month's API.
|
|
116
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
117
|
+
|
|
118
|
+
/** url → { cachePath, text|null, finalUrl, status, contentType, totalChars, truncated } */
|
|
119
|
+
const session = new Map();
|
|
120
|
+
/** host → number of requests made this process. */
|
|
121
|
+
const perHost = new Map();
|
|
122
|
+
let fetchesMade = 0;
|
|
123
|
+
|
|
124
|
+
/** Reset between tests. Never called in normal operation — a run is short. */
|
|
125
|
+
export function resetFetchState() {
|
|
126
|
+
session.clear();
|
|
127
|
+
perHost.clear();
|
|
128
|
+
fetchesMade = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
* THE ADDRESS GUARD
|
|
133
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
function parseIPv4(s) {
|
|
136
|
+
const parts = s.split('.');
|
|
137
|
+
if (parts.length !== 4) return null;
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const p of parts) {
|
|
140
|
+
// ⚠️ No leading zeros, no `0x`, no shorthand. `0177.0.0.1` is 127.0.0.1 to
|
|
141
|
+
// inet_aton and something else to a naive parser, and that gap has been a
|
|
142
|
+
// real SSRF bypass more than once. Anything unusual is refused, not parsed.
|
|
143
|
+
if (!/^\d{1,3}$/.test(p)) return null;
|
|
144
|
+
const n = Number(p);
|
|
145
|
+
if (n > 255) return null;
|
|
146
|
+
out.push(n);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Returns 16 bytes, or null. Handles `::` compression and a trailing IPv4. */
|
|
152
|
+
function parseIPv6(s) {
|
|
153
|
+
let text = s;
|
|
154
|
+
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
|
155
|
+
const zone = text.indexOf('%');
|
|
156
|
+
if (zone !== -1) text = text.slice(0, zone);
|
|
157
|
+
if (!text.includes(':')) return null;
|
|
158
|
+
|
|
159
|
+
let tail4 = null;
|
|
160
|
+
const lastColon = text.lastIndexOf(':');
|
|
161
|
+
const after = text.slice(lastColon + 1);
|
|
162
|
+
if (after.includes('.')) {
|
|
163
|
+
tail4 = parseIPv4(after);
|
|
164
|
+
if (!tail4) return null;
|
|
165
|
+
text = text.slice(0, lastColon + 1) + '0:0';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const halves = text.split('::');
|
|
169
|
+
if (halves.length > 2) return null;
|
|
170
|
+
const toGroups = (part) => (part === '' ? [] : part.split(':').map((g) => {
|
|
171
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return NaN;
|
|
172
|
+
return parseInt(g, 16);
|
|
173
|
+
}));
|
|
174
|
+
const head = toGroups(halves[0]);
|
|
175
|
+
const tail = halves.length === 2 ? toGroups(halves[1]) : [];
|
|
176
|
+
if ([...head, ...tail].some((n) => Number.isNaN(n))) return null;
|
|
177
|
+
|
|
178
|
+
let groups;
|
|
179
|
+
if (halves.length === 2) {
|
|
180
|
+
const fill = 8 - head.length - tail.length;
|
|
181
|
+
if (fill < 0) return null;
|
|
182
|
+
groups = [...head, ...new Array(fill).fill(0), ...tail];
|
|
183
|
+
} else {
|
|
184
|
+
groups = head;
|
|
185
|
+
}
|
|
186
|
+
if (groups.length !== 8) return null;
|
|
187
|
+
|
|
188
|
+
const bytes = [];
|
|
189
|
+
for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff);
|
|
190
|
+
if (tail4) { bytes[12] = tail4[0]; bytes[13] = tail4[1]; bytes[14] = tail4[2]; bytes[15] = tail4[3]; }
|
|
191
|
+
return bytes;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function blockedV4([a, b]) {
|
|
195
|
+
if (a === 0) return true; // 0.0.0.0/8 — "this network", and 0.0.0.0 IS localhost on Linux
|
|
196
|
+
if (a === 10) return true; // RFC1918
|
|
197
|
+
if (a === 127) return true; // loopback
|
|
198
|
+
if (a === 169 && b === 254) return true; // link-local — INCLUDING 169.254.169.254, the cloud metadata endpoint
|
|
199
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
200
|
+
if (a === 192 && b === 168) return true; // RFC1918
|
|
201
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT — a real private range on cloud hosts
|
|
202
|
+
if (a >= 224) return true; // multicast, reserved, and 255.255.255.255
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Is this resolved address one the tool must never open a socket to?
|
|
208
|
+
*
|
|
209
|
+
* ⚠️ FAILS CLOSED. An address string this cannot parse is refused, because the
|
|
210
|
+
* alternative — "I did not understand it, so it is probably fine" — is the exact
|
|
211
|
+
* reasoning that turns a parser gap into a bypass.
|
|
212
|
+
*
|
|
213
|
+
* @param {unknown} ip
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function isBlockedAddress(ip) {
|
|
217
|
+
if (typeof ip !== 'string' || !ip.trim()) return true;
|
|
218
|
+
const raw = ip.trim();
|
|
219
|
+
|
|
220
|
+
const v4 = parseIPv4(raw);
|
|
221
|
+
if (v4) return blockedV4(v4);
|
|
222
|
+
|
|
223
|
+
const v6 = parseIPv6(raw);
|
|
224
|
+
if (!v6) return true;
|
|
225
|
+
|
|
226
|
+
// IPv4-mapped (::ffff:127.0.0.1) and IPv4-compatible (::127.0.0.1) both reach
|
|
227
|
+
// v4 destinations, so they are checked as v4 rather than as "some IPv6 address
|
|
228
|
+
// that is not on the list".
|
|
229
|
+
const firstTenZero = v6.slice(0, 10).every((b) => b === 0);
|
|
230
|
+
if (firstTenZero && v6[10] === 0xff && v6[11] === 0xff) return blockedV4(v6.slice(12));
|
|
231
|
+
if (firstTenZero && v6[10] === 0 && v6[11] === 0) {
|
|
232
|
+
const low = v6.slice(12);
|
|
233
|
+
if (low.every((b) => b === 0)) return true; // ::
|
|
234
|
+
if (low[0] === 0 && low[1] === 0 && low[2] === 0 && low[3] === 1) return true; // ::1
|
|
235
|
+
return blockedV4(low); // ::a.b.c.d
|
|
236
|
+
}
|
|
237
|
+
if ((v6[0] & 0xfe) === 0xfc) return true; // fc00::/7 unique-local
|
|
238
|
+
if (v6[0] === 0xfe && (v6[1] & 0xc0) === 0x80) return true; // fe80::/10 link-local
|
|
239
|
+
if (v6[0] === 0xff) return true; // ff00::/8 multicast
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const BLOCKED_MESSAGE = 'this tool cannot reach private or local addresses';
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Is this hostname an address rather than a name?
|
|
247
|
+
*
|
|
248
|
+
* ⚠️ DELIBERATELY GENEROUS. Anything made only of digits and dots, or holding a
|
|
249
|
+
* colon, is treated as a literal and handed to `isBlockedAddress` — which fails
|
|
250
|
+
* closed. So `0177.0.0.1` and `2130706433` are refused as unparseable addresses
|
|
251
|
+
* rather than handed to a resolver that might make something of them. A real
|
|
252
|
+
* hostname cannot be all digits and dots, so nothing legitimate is lost.
|
|
253
|
+
*/
|
|
254
|
+
function looksLikeIpLiteral(host) {
|
|
255
|
+
return /^[0-9.]+$/.test(host) || host.includes(':');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Validate one hop: scheme, credentials, and every address the hostname resolves
|
|
260
|
+
* to. Returns the resolved addresses so the socket can be PINNED to them.
|
|
261
|
+
*
|
|
262
|
+
* ⚠️ Re-run on every redirect. A first hop that is public says nothing about the
|
|
263
|
+
* second — "public host 302s to 127.0.0.1" is the standard SSRF filter bypass,
|
|
264
|
+
* and it costs one function call to close.
|
|
265
|
+
*/
|
|
266
|
+
async function validateHop(urlString, lookupImpl) {
|
|
267
|
+
let u;
|
|
268
|
+
try {
|
|
269
|
+
u = new URL(urlString);
|
|
270
|
+
} catch {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: `"${String(urlString).slice(0, 120)}" is not a full URL. Include the scheme, e.g. https://nodejs.org/api/test.html`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
277
|
+
// Named, not lumped into "invalid URL": the model must learn the boundary,
|
|
278
|
+
// and for the common cases there is a better tool to point it at.
|
|
279
|
+
const alt = u.protocol === 'file:'
|
|
280
|
+
? ' Use read_file for something on this machine.'
|
|
281
|
+
: '';
|
|
282
|
+
return { ok: false, error: `fetch_url speaks http and https only — "${u.protocol}" is refused.${alt}` };
|
|
283
|
+
}
|
|
284
|
+
if (u.username || u.password) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: 'this URL embeds credentials (user:pass@host) and is refused. fetch_url never sends authentication; fetch a public URL instead.',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
291
|
+
if (!host) return { ok: false, error: 'that URL has no hostname' };
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* ⚠️⚠️ AN IP LITERAL IS CHECKED DIRECTLY AND NEVER SENT TO THE RESOLVER, and
|
|
295
|
+
* this was a real hole found by its own test. `http://127.0.0.1:8080/admin`
|
|
296
|
+
* has nothing to resolve — but routing it through `lookupImpl` anyway makes
|
|
297
|
+
* the verdict depend on what the resolver says about the string "127.0.0.1",
|
|
298
|
+
* and a resolver is a thing outside this process. The test that caught it
|
|
299
|
+
* stubbed the resolver, the stub answered "public", and the guard cheerfully
|
|
300
|
+
* approved loopback. In production the answer would have been right by luck;
|
|
301
|
+
* here it is right by construction.
|
|
302
|
+
*/
|
|
303
|
+
const literal = looksLikeIpLiteral(host);
|
|
304
|
+
if (literal) {
|
|
305
|
+
if (isBlockedAddress(host)) return { ok: false, error: `${BLOCKED_MESSAGE} — "${host}".` };
|
|
306
|
+
return { ok: true, url: u, host: host.toLowerCase(), addresses: [{ address: host, family: host.includes(':') ? 6 : 4 }] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @type {ResolvedAddress[]} */
|
|
310
|
+
let addresses;
|
|
311
|
+
try {
|
|
312
|
+
addresses = await lookupImpl(host);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const code = err?.code || err?.errno || 'lookup failed';
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
error: `could not resolve "${host}" (${code}). Check the host name — fetch_url takes an exact URL and does not search.`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
321
|
+
return { ok: false, error: `"${host}" resolved to no addresses` };
|
|
322
|
+
}
|
|
323
|
+
// ⚠️ ANY blocked address refuses the whole hop, not "the first one is fine".
|
|
324
|
+
// A host with one public and one loopback record would otherwise be a coin
|
|
325
|
+
// flip decided by resolver ordering.
|
|
326
|
+
const bad = addresses.find((a) => isBlockedAddress(a?.address));
|
|
327
|
+
if (bad) {
|
|
328
|
+
return { ok: false, error: `${BLOCKED_MESSAGE} — "${host}" resolves to ${bad.address}.` };
|
|
329
|
+
}
|
|
330
|
+
return { ok: true, url: u, host: host.toLowerCase(), addresses };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
* HTML → TEXT
|
|
335
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
336
|
+
|
|
337
|
+
const NAMED_ENTITIES = {
|
|
338
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
|
339
|
+
// The four that actually show up in docs prose and would otherwise read as
|
|
340
|
+
// literal `—` noise in the model's context.
|
|
341
|
+
mdash: '—', ndash: '–', hellip: '…', copy: '©',
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function decodeEntities(s) {
|
|
345
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
|
|
346
|
+
if (body[0] === '#') {
|
|
347
|
+
const code = body[1] === 'x' || body[1] === 'X'
|
|
348
|
+
? parseInt(body.slice(2), 16)
|
|
349
|
+
: parseInt(body.slice(1), 10);
|
|
350
|
+
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return whole;
|
|
351
|
+
try { return String.fromCodePoint(code); } catch { return whole; }
|
|
352
|
+
}
|
|
353
|
+
const hit = NAMED_ENTITIES[body.toLowerCase()];
|
|
354
|
+
return hit === undefined ? whole : hit;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Turn a documentation page into something a model can read.
|
|
360
|
+
*
|
|
361
|
+
* Pure and separately tested — which is the point. The transport can only be
|
|
362
|
+
* exercised with a socket or a stub; THIS is where the actual quality of a doc
|
|
363
|
+
* lookup lives, and it can be pinned down with a fixture.
|
|
364
|
+
*
|
|
365
|
+
* ⚠️ `<pre>` AND `<code>` ARE EXTRACTED FIRST AND PUT BACK LAST. Every other
|
|
366
|
+
* step here collapses whitespace, and collapsing whitespace inside a code sample
|
|
367
|
+
* turns the one part of the page the model came for into an unindented smear.
|
|
368
|
+
* That is not a formatting nicety: `mock.timers.enable({ apis: [...] })` is
|
|
369
|
+
* copied from a doc code block, and a mangled block is how you get a plausible
|
|
370
|
+
* invented call instead.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} html
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
export function htmlToText(html) {
|
|
376
|
+
if (typeof html !== 'string') return '';
|
|
377
|
+
let s = html;
|
|
378
|
+
|
|
379
|
+
// 1. Contents that are not prose. Dropped WITH their tags — `<script>` text
|
|
380
|
+
// is code the page runs, and handing it to the model is pure token burn.
|
|
381
|
+
s = s.replace(/<!--[\s\S]*?-->/g, '');
|
|
382
|
+
/**
|
|
383
|
+
* ⚠️⚠️ THE HEAD IS FOUND BY WHERE THE BODY STARTS, NOT BY MATCHING A TAG PAIR
|
|
384
|
+
* — and it took the real page twice to get this right.
|
|
385
|
+
*
|
|
386
|
+
* Attempt 1 was `/<head\b[^>]*>[\s\S]*?<\/head>/`, which is the obvious rule
|
|
387
|
+
* and does nothing at all on https://nodejs.org/api/test.html, the exact page
|
|
388
|
+
* this module was written for. Measured on the live document (943,859 chars,
|
|
389
|
+
* 2026-08-10): **it contains neither `<head>` nor `</head>`.** Both tags are
|
|
390
|
+
* optional in HTML and the minifier drops them. My grep for `<head` matched
|
|
391
|
+
* at 5750 and my grep for `</head` matched at 27418, which looked like proof
|
|
392
|
+
* the pair existed — they were `<header class=header>` and `</header>`. Being
|
|
393
|
+
* fooled by `<header>` is why attempt 2, cutting at `</head>`, also shipped
|
|
394
|
+
* nothing.
|
|
395
|
+
*
|
|
396
|
+
* ⭐ THE FIXTURE HAD BOTH TAGS, SO THE TEST WAS GREEN ABOUT A RULE THAT DID
|
|
397
|
+
* NOT WORK. A fixture written from what HTML is supposed to look like proves
|
|
398
|
+
* nothing about what servers send; only the render caught it, which is the
|
|
399
|
+
* same lesson `see_page` exists for one layer up.
|
|
400
|
+
*
|
|
401
|
+
* So the rule is now the browser's own: whatever precedes `<body>` is
|
|
402
|
+
* preamble. The `<pre>/<code>` guard keeps a page that merely QUOTES a body
|
|
403
|
+
* tag inside a code sample from being decapitated.
|
|
404
|
+
*/
|
|
405
|
+
const bodyOpen = /<body\b[^>]*>/i.exec(s);
|
|
406
|
+
const verbatimAt = s.search(/<(pre|code)\b/i);
|
|
407
|
+
if (bodyOpen && (verbatimAt === -1 || bodyOpen.index < verbatimAt)) {
|
|
408
|
+
s = s.slice(bodyOpen.index + bodyOpen[0].length);
|
|
409
|
+
} else {
|
|
410
|
+
const headEnd = /<\/head\s*>/i.exec(s);
|
|
411
|
+
if (headEnd && (verbatimAt === -1 || headEnd.index < verbatimAt)) s = s.slice(headEnd.index + headEnd[0].length);
|
|
412
|
+
else s = s.replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, '');
|
|
413
|
+
}
|
|
414
|
+
s = s.replace(/<(script|style|svg|noscript|template|iframe)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
|
|
415
|
+
// An unclosed <script> at the end of a truncated document would otherwise
|
|
416
|
+
// survive the pass above and leak its whole body.
|
|
417
|
+
s = s.replace(/<(script|style|svg|noscript)\b[^>]*>[\s\S]*$/gi, '');
|
|
418
|
+
|
|
419
|
+
// 2. Park the verbatim blocks behind sentinels. NUL is used because it cannot
|
|
420
|
+
// survive step 6 by accident and cannot appear in the source text — the
|
|
421
|
+
// transport refuses binary content types before we ever get here.
|
|
422
|
+
/** @type {string[]} */
|
|
423
|
+
const verbatim = [];
|
|
424
|
+
const park = (_all, inner) => {
|
|
425
|
+
const cleaned = decodeEntities(inner.replace(/<[^>]*>/g, '')).replace(/\r\n?/g, '\n');
|
|
426
|
+
verbatim.push(cleaned);
|
|
427
|
+
return `\u0000V${verbatim.length - 1}\u0000`;
|
|
428
|
+
};
|
|
429
|
+
s = s.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, park);
|
|
430
|
+
s = s.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, park);
|
|
431
|
+
|
|
432
|
+
// 3. The tags whose whole job is a line break.
|
|
433
|
+
s = s.replace(/<br\s*\/?>/gi, '\n');
|
|
434
|
+
s = s.replace(/<\/(p|div|section|article|blockquote|ul|ol|table)>/gi, '\n\n');
|
|
435
|
+
s = s.replace(/<\/(li|tr|dt|dd)>/gi, '\n');
|
|
436
|
+
// A cell boundary that becomes nothing merges two columns into one word.
|
|
437
|
+
s = s.replace(/<\/(td|th)>/gi, '\t');
|
|
438
|
+
s = s.replace(/<h[1-6]\b[^>]*>/gi, '\n\n');
|
|
439
|
+
s = s.replace(/<\/h[1-6]>/gi, '\n\n');
|
|
440
|
+
s = s.replace(/<li\b[^>]*>/gi, '\n- ');
|
|
441
|
+
|
|
442
|
+
// 4. Everything else that is markup is not content.
|
|
443
|
+
s = s.replace(/<[^>]*>/g, '');
|
|
444
|
+
|
|
445
|
+
// 5. Entities AFTER tag stripping, never before: decoding first would turn a
|
|
446
|
+
// literal `<script>` in the prose into a tag the next pass eats.
|
|
447
|
+
s = decodeEntities(s);
|
|
448
|
+
|
|
449
|
+
// 6. Whitespace. Horizontal runs collapse, trailing spaces go, 3+ blank lines
|
|
450
|
+
// become 2 — a doc page is mostly layout whitespace and the model pays for
|
|
451
|
+
// every space of it.
|
|
452
|
+
s = s.replace(/\r\n?/g, '\n');
|
|
453
|
+
s = s.replace(/[^\S\n]+/g, ' ');
|
|
454
|
+
s = s.split('\n').map((line) => line.trim()).join('\n');
|
|
455
|
+
s = s.replace(/\n{3,}/g, '\n\n');
|
|
456
|
+
|
|
457
|
+
// 7. Put the code back, indentation intact.
|
|
458
|
+
s = s.replace(/\u0000V(\d+)\u0000/g, (_m, i) => verbatim[Number(i)] ?? '');
|
|
459
|
+
// Any sentinel that survived a mangled document must not reach the model.
|
|
460
|
+
s = s.replace(/\u0000/g, '');
|
|
461
|
+
|
|
462
|
+
return s.trim();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
466
|
+
* TRANSPORT
|
|
467
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
468
|
+
|
|
469
|
+
let cachedVersion = null;
|
|
470
|
+
function userAgent() {
|
|
471
|
+
if (cachedVersion === null) {
|
|
472
|
+
try {
|
|
473
|
+
cachedVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
|
|
474
|
+
} catch {
|
|
475
|
+
// A CLI that cannot read its own manifest still has to be able to fetch.
|
|
476
|
+
cachedVersion = '0.0.0';
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return `acuvo-code/${cachedVersion}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* ⚠️⚠️ DELIBERATELY DUMB, AND THAT IS THE CONTRACT. This opens a socket to
|
|
484
|
+
* whatever it is given and validates NOTHING — the address guard lives in
|
|
485
|
+
* `fetchText`, above it, so there is exactly one place that decides what is
|
|
486
|
+
* reachable. Never call this with a model-supplied URL. It is exported so the
|
|
487
|
+
* tests can drive a real `node:http` server on 127.0.0.1 THROUGH the injection
|
|
488
|
+
* seam, which is how the SSRF guard stays under test instead of switched off
|
|
489
|
+
* for the test suite (a guard with a test-only bypass is a guard with a bypass).
|
|
490
|
+
*
|
|
491
|
+
* Resolves to `{ status, headers, body, truncated }`. Rejects only on a real
|
|
492
|
+
* transport failure, and the error carries `.code` so the caller can tell a
|
|
493
|
+
* socket reset apart from a timeout.
|
|
494
|
+
*/
|
|
495
|
+
export async function rawHttpRequest({ url, headers = {}, timeoutMs = FETCH_TIMEOUT_MS, addresses = null, maxBytes = MAX_BODY_BYTES }) {
|
|
496
|
+
const u = new URL(url);
|
|
497
|
+
const mod = u.protocol === 'https:' ? https : http;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* ⚠️ THE SOCKET IS PINNED TO THE ADDRESSES THAT WERE CHECKED. Without this,
|
|
501
|
+
* the guard resolves a hostname, approves it, and then Node resolves it AGAIN
|
|
502
|
+
* for the connection — a second lookup that can return a different answer.
|
|
503
|
+
* That is DNS rebinding, and it is the difference between a check and a
|
|
504
|
+
* guarantee. SNI and the Host header still come from the URL, so pinning does
|
|
505
|
+
* not break TLS or virtual hosting.
|
|
506
|
+
*/
|
|
507
|
+
const lookup = addresses
|
|
508
|
+
? (hostname, options, cb) => {
|
|
509
|
+
const callback = typeof options === 'function' ? options : cb;
|
|
510
|
+
const list = addresses.map((a) => ({ address: a.address, family: a.family || (a.address.includes(':') ? 6 : 4) }));
|
|
511
|
+
if (options && typeof options === 'object' && options.all) return callback(null, list);
|
|
512
|
+
return callback(null, list[0].address, list[0].family);
|
|
513
|
+
}
|
|
514
|
+
: undefined;
|
|
515
|
+
|
|
516
|
+
return new Promise((resolve, reject) => {
|
|
517
|
+
const req = mod.request(u, { method: 'GET', headers, lookup, timeout: timeoutMs }, (res) => {
|
|
518
|
+
const encoding = String(res.headers['content-encoding'] || '').toLowerCase();
|
|
519
|
+
let stream = res;
|
|
520
|
+
if (encoding === 'gzip' || encoding === 'x-gzip') stream = res.pipe(createGunzip());
|
|
521
|
+
else if (encoding === 'deflate') stream = res.pipe(createInflate());
|
|
522
|
+
else if (encoding === 'br') stream = res.pipe(createBrotliDecompress());
|
|
523
|
+
|
|
524
|
+
/** @type {Buffer[]} */
|
|
525
|
+
const chunks = [];
|
|
526
|
+
let total = 0;
|
|
527
|
+
let truncated = false;
|
|
528
|
+
let settled = false;
|
|
529
|
+
|
|
530
|
+
const finish = () => {
|
|
531
|
+
if (settled) return;
|
|
532
|
+
settled = true;
|
|
533
|
+
resolve({
|
|
534
|
+
status: res.statusCode ?? 0,
|
|
535
|
+
headers: res.headers,
|
|
536
|
+
body: Buffer.concat(chunks),
|
|
537
|
+
truncated,
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
stream.on('data', (chunk) => {
|
|
542
|
+
total += chunk.length;
|
|
543
|
+
if (total > maxBytes) {
|
|
544
|
+
// ⚠️ THE SOCKET IS DESTROYED, not just ignored. Reading a 4GB file to
|
|
545
|
+
// completion and then throwing it away is a denial of service the
|
|
546
|
+
// agent performs on its own user's connection.
|
|
547
|
+
truncated = true;
|
|
548
|
+
chunks.push(chunk.subarray(0, Math.max(0, chunk.length - (total - maxBytes))));
|
|
549
|
+
req.destroy();
|
|
550
|
+
res.destroy();
|
|
551
|
+
finish();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
chunks.push(chunk);
|
|
555
|
+
});
|
|
556
|
+
stream.on('end', finish);
|
|
557
|
+
stream.on('error', (err) => {
|
|
558
|
+
// A decompression failure on a body we deliberately cut short is not an
|
|
559
|
+
// error, it is the expected consequence of cutting it short.
|
|
560
|
+
if (truncated) return finish();
|
|
561
|
+
if (!settled) { settled = true; reject(err); }
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
req.on('timeout', () => {
|
|
566
|
+
const err = new Error('timed out');
|
|
567
|
+
err.code = 'ETIMEDOUT';
|
|
568
|
+
req.destroy(err);
|
|
569
|
+
});
|
|
570
|
+
req.on('error', reject);
|
|
571
|
+
req.end();
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Decode a body buffer, honouring the one legacy charset still worth honouring. */
|
|
576
|
+
function decodeBody(buf, contentType) {
|
|
577
|
+
const m = /charset\s*=\s*"?([\w-]+)"?/i.exec(contentType || '');
|
|
578
|
+
const charset = (m?.[1] || 'utf-8').toLowerCase();
|
|
579
|
+
if (charset === 'iso-8859-1' || charset === 'latin1' || charset === 'windows-1252') return buf.toString('latin1');
|
|
580
|
+
return buf.toString('utf8');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The default one-hop transport, in the shape `fetchText` injects. */
|
|
584
|
+
async function defaultFetchImpl({ url, headers, timeoutMs, addresses }) {
|
|
585
|
+
return rawHttpRequest({ url, headers, timeoutMs, addresses });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
589
|
+
* THE TOOL
|
|
590
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
591
|
+
|
|
592
|
+
const KNOWN_ARGS = new Set(['root', 'url', 'offset', 'limit', 'fetchImpl', 'lookupImpl']);
|
|
593
|
+
|
|
594
|
+
function cachePathFor(url) {
|
|
595
|
+
return `${CACHE_DIR}/${createHash('sha1').update(url).digest('hex')}.txt`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The window, plus the honest answer to "is there more". */
|
|
599
|
+
function windowOf(text, offset, limit) {
|
|
600
|
+
const slice = text.slice(offset, offset + limit);
|
|
601
|
+
const end = offset + slice.length;
|
|
602
|
+
return { text: slice, nextOffset: end < text.length ? end : null };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Fetch a URL and return a readable window of it.
|
|
607
|
+
*
|
|
608
|
+
* @param {{
|
|
609
|
+
* root?: string, url?: unknown, offset?: unknown, limit?: unknown,
|
|
610
|
+
* fetchImpl?: Function, lookupImpl?: Function
|
|
611
|
+
* }} params
|
|
612
|
+
* @returns {Promise<FetchOk | FetchRefused>}
|
|
613
|
+
*/
|
|
614
|
+
export async function fetchText(params = {}) {
|
|
615
|
+
/**
|
|
616
|
+
* ⚠️ UNKNOWN KEYS ARE REFUSED BY NAME, HERE RATHER THAN IN THE DISPATCHER.
|
|
617
|
+
* The registration snippet spreads the model's arguments straight in, so this
|
|
618
|
+
* is the only place that sees them. A model that tried `headers` or `method`
|
|
619
|
+
* must be TOLD that this tool has no such control — silently dropping the key
|
|
620
|
+
* teaches it that the header was sent, which is the worse of the two failures.
|
|
621
|
+
*/
|
|
622
|
+
const unknown = Object.keys(params).filter((k) => !KNOWN_ARGS.has(k));
|
|
623
|
+
if (unknown.length) {
|
|
624
|
+
return {
|
|
625
|
+
ok: false,
|
|
626
|
+
error: `fetch_url does not accept "${unknown[0]}". It sends a fixed GET — no headers, cookies, auth, method or body are configurable — and takes only url, offset and limit.`,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
// The two injection seams are for tests and for the console's executor. A
|
|
630
|
+
// model can only ever supply JSON, so a non-function here is a model reaching
|
|
631
|
+
// for a control it does not have.
|
|
632
|
+
for (const seam of ['fetchImpl', 'lookupImpl']) {
|
|
633
|
+
if (params[seam] !== undefined && typeof params[seam] !== 'function') {
|
|
634
|
+
return { ok: false, error: `fetch_url does not accept "${seam}". It takes only url, offset and limit.` };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const { root, fetchImpl = defaultFetchImpl } = params;
|
|
639
|
+
const lookupImpl = params.lookupImpl
|
|
640
|
+
|| ((host) => dns.lookup(host, { all: true, verbatim: true }));
|
|
641
|
+
|
|
642
|
+
if (typeof params.url !== 'string' || !params.url.trim()) {
|
|
643
|
+
return { ok: false, error: 'fetch_url needs a url, e.g. "https://nodejs.org/api/test.html"' };
|
|
644
|
+
}
|
|
645
|
+
const url = params.url.trim();
|
|
646
|
+
|
|
647
|
+
const offset = params.offset === undefined ? 0 : Number(params.offset);
|
|
648
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
649
|
+
return { ok: false, error: `offset must be a whole number of characters from the start of the document, not ${JSON.stringify(params.offset)}` };
|
|
650
|
+
}
|
|
651
|
+
let limit = params.limit === undefined ? DEFAULT_WINDOW : Number(params.limit);
|
|
652
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
653
|
+
return { ok: false, error: `limit must be a positive whole number of characters, not ${JSON.stringify(params.limit)}` };
|
|
654
|
+
}
|
|
655
|
+
// Clamped rather than refused: a model asking for 100k characters wants the
|
|
656
|
+
// document, and giving it 12k plus a nextOffset answers that. Refusing would
|
|
657
|
+
// cost a round to learn a number it could have been handed.
|
|
658
|
+
if (limit > MAX_WINDOW) limit = MAX_WINDOW;
|
|
659
|
+
|
|
660
|
+
/* ── ALREADY FETCHED THIS SESSION → NO REQUEST AT ALL ───────────────────── */
|
|
661
|
+
const previous = session.get(url);
|
|
662
|
+
if (previous) {
|
|
663
|
+
const full = readCached(previous);
|
|
664
|
+
if (full === null) {
|
|
665
|
+
return { ok: false, error: `the cached copy of ${url} is gone from ${previous.cachePath}. Fetch a different URL, or re-read it with read_file if you still have the path.` };
|
|
666
|
+
}
|
|
667
|
+
return finishWindow({ ...previous, text: full, url, offset, limit, fromCache: true });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/* ── BUDGETS ───────────────────────────────────────────────────────────── */
|
|
671
|
+
if (fetchesMade >= MAX_FETCHES_PER_PROCESS) {
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
error: `fetch_url has already made ${MAX_FETCHES_PER_PROCESS} requests this run, which is the limit. Re-read anything you already fetched (that is free and does not count), and work from what you have.`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* ── HOP LOOP ──────────────────────────────────────────────────────────── */
|
|
679
|
+
const deadline = Date.now() + FETCH_TIMEOUT_MS;
|
|
680
|
+
let current = url;
|
|
681
|
+
let hops = 0;
|
|
682
|
+
let response = null;
|
|
683
|
+
let finalUrl = url;
|
|
684
|
+
|
|
685
|
+
for (;;) {
|
|
686
|
+
const hop = await validateHop(current, lookupImpl);
|
|
687
|
+
if (!hop.ok) return hop;
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* ⚠️ THE BUDGET IS CHARGED ONCE PER LOOKUP, NOT ONCE PER HOP — and getting
|
|
691
|
+
* that wrong made its own test fail in an instructive way. Billing each
|
|
692
|
+
* redirect against the host meant `http → https → www → canonical`, which is
|
|
693
|
+
* ONE document and a completely normal chain, spent the entire per-host
|
|
694
|
+
* allowance before it arrived. The budgets exist to stop a CRAWL; a redirect
|
|
695
|
+
* chain is not a crawl and is already bounded by MAX_REDIRECTS.
|
|
696
|
+
*/
|
|
697
|
+
if (hops === 0) {
|
|
698
|
+
const hostCount = perHost.get(hop.host) ?? 0;
|
|
699
|
+
if (hostCount >= MAX_FETCHES_PER_HOST) {
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
error: `fetch_url has already requested ${MAX_FETCHES_PER_HOST} pages from ${hop.host} this run, which is the limit. Use what you fetched from it rather than crawling further.`,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
perHost.set(hop.host, hostCount + 1);
|
|
706
|
+
fetchesMade += 1;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const remaining = deadline - Date.now();
|
|
710
|
+
if (remaining <= 0) {
|
|
711
|
+
return { ok: false, error: `${url} did not finish within ${FETCH_TIMEOUT_MS / 1000}s. Try a more specific page — this tool is for one document, not a slow endpoint.` };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const request = () => fetchImpl({
|
|
715
|
+
url: current,
|
|
716
|
+
headers: {
|
|
717
|
+
// ⚠️ FIXED, AND THE MODEL CANNOT ADD TO THEM. No cookie, no auth, no
|
|
718
|
+
// referer — this request carries nothing about the machine it runs on.
|
|
719
|
+
'user-agent': userAgent(),
|
|
720
|
+
accept: 'text/html,text/plain,application/json',
|
|
721
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
722
|
+
connection: 'close',
|
|
723
|
+
},
|
|
724
|
+
timeoutMs: Math.min(remaining, FETCH_TIMEOUT_MS),
|
|
725
|
+
addresses: hop.addresses,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
response = await request();
|
|
730
|
+
} catch (err) {
|
|
731
|
+
const code = err?.code || err?.cause?.code || err?.name || 'unknown';
|
|
732
|
+
/**
|
|
733
|
+
* ⚠️ ONE RETRY, AND ONLY FOR A RESET. A connection reset genuinely is
|
|
734
|
+
* transient. A timeout is not — retrying it spends the timeout twice and
|
|
735
|
+
* is precisely the mistake breaker.mjs was written after.
|
|
736
|
+
*/
|
|
737
|
+
const retryable = code === 'ECONNRESET' || code === 'EPIPE';
|
|
738
|
+
if (retryable && deadline - Date.now() > 2_000) {
|
|
739
|
+
try {
|
|
740
|
+
response = await request();
|
|
741
|
+
} catch (err2) {
|
|
742
|
+
return { ok: false, error: `could not fetch ${current}: ${err2?.code || err2?.message || 'connection failed'} (retried once)` };
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
const hint = code === 'ETIMEDOUT' || code === 'ERR_SOCKET_TIMEOUT'
|
|
746
|
+
? ' — the server did not answer in time. Pick a lighter page rather than repeating this one.'
|
|
747
|
+
: '';
|
|
748
|
+
return { ok: false, error: `could not fetch ${current}: ${code}${hint}` };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const status = response?.status ?? 0;
|
|
753
|
+
const location = response?.headers?.location;
|
|
754
|
+
if (status >= 300 && status < 400 && location) {
|
|
755
|
+
hops += 1;
|
|
756
|
+
if (hops > MAX_REDIRECTS) {
|
|
757
|
+
return { ok: false, error: `${url} redirected more than ${MAX_REDIRECTS} times. Fetch the final URL directly.` };
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
current = new URL(location, current).toString();
|
|
761
|
+
} catch {
|
|
762
|
+
return { ok: false, error: `${current} redirected to something that is not a URL ("${String(location).slice(0, 80)}")` };
|
|
763
|
+
}
|
|
764
|
+
continue; // ⭐ and the guard runs again at the top — that is the whole point
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
finalUrl = current;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/* ── STATUS ────────────────────────────────────────────────────────────── */
|
|
772
|
+
const rawHeaders = response.headers || {};
|
|
773
|
+
const contentType = String(rawHeaders['content-type'] || '').trim();
|
|
774
|
+
const bodyBuf = Buffer.isBuffer(response.body) ? response.body : Buffer.from(String(response.body ?? ''), 'utf8');
|
|
775
|
+
|
|
776
|
+
if (response.status < 200 || response.status >= 300) {
|
|
777
|
+
// ⚠️ THE BODY IS INCLUDED. "HTTP 404" tells a model nothing it can act on;
|
|
778
|
+
// the body usually says "moved to /api/test.html" and names the fix.
|
|
779
|
+
const snippet = decodeBody(bodyBuf, contentType).replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
error: `${finalUrl} returned HTTP ${response.status}${snippet ? `: ${snippet}` : ''}`,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/* ── CONTENT TYPE ──────────────────────────────────────────────────────── */
|
|
787
|
+
const mime = contentType.split(';')[0].trim().toLowerCase();
|
|
788
|
+
// A server that states no type is almost always serving plain text (raw files,
|
|
789
|
+
// small APIs). Guessing text here is safe because the WRONG guess is caught
|
|
790
|
+
// below by the same binary check `read_file` uses.
|
|
791
|
+
const effective = mime || 'text/plain';
|
|
792
|
+
if (!TEXTUAL.test(effective)) {
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
// ⚠️ NAMES THE TYPE. "unsupported content" sends the model back to try the
|
|
796
|
+
// same URL a different way; "it is image/png" ends the line of enquiry.
|
|
797
|
+
error: `${finalUrl} is ${effective}, which fetch_url does not read — it converts text, HTML, JSON and XML only.`,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
let decoded = decodeBody(bodyBuf, contentType);
|
|
802
|
+
if (decoded.includes('\u0000')) {
|
|
803
|
+
return { ok: false, error: `${finalUrl} claims to be ${effective} but the body is binary. Refusing to read it as text.` };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/* ── CONVERT ───────────────────────────────────────────────────────────── */
|
|
807
|
+
let text;
|
|
808
|
+
/**
|
|
809
|
+
* ⭐ JSON IS DETECTED BY PARSING IT, NOT ONLY BY BELIEVING THE HEADER — found
|
|
810
|
+
* in the live demo: `https://registry.npmjs.org/zod/latest` came back declared
|
|
811
|
+
* `text/plain`, so a 40KB single-line payload went to the model minified, and
|
|
812
|
+
* every 6000-character window landed mid-token. Sniffing is normally a bad
|
|
813
|
+
* idea, but this variant cannot produce a false positive: the test IS
|
|
814
|
+
* `JSON.parse` succeeding on the whole body. If it parses, it is JSON.
|
|
815
|
+
*/
|
|
816
|
+
const looksJson = /json/.test(effective)
|
|
817
|
+
|| (effective === 'text/plain' && /^[\s]*[[{]/.test(decoded));
|
|
818
|
+
if (looksJson) {
|
|
819
|
+
// Pretty-printed rather than HTML-converted: a minified JSON payload on one
|
|
820
|
+
// line is unreadable and, worse, unpageable — every window lands mid-token.
|
|
821
|
+
try { text = JSON.stringify(JSON.parse(decoded), null, 2); } catch { text = decoded; }
|
|
822
|
+
} else if (effective === 'text/html' || effective === 'application/xhtml+xml') {
|
|
823
|
+
text = htmlToText(decoded);
|
|
824
|
+
} else {
|
|
825
|
+
// text/plain, text/markdown, xml, css, whatever else is textual — pass
|
|
826
|
+
// through. Converting markdown would destroy the structure it came with.
|
|
827
|
+
text = decoded;
|
|
828
|
+
}
|
|
829
|
+
if (response.truncated) {
|
|
830
|
+
text += `\n\n[fetch_url stopped at ${MAX_BODY_BYTES / (1024 * 1024)}MB — this document is longer than that and the rest was not downloaded]`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/* ── CACHE ─────────────────────────────────────────────────────────────── */
|
|
834
|
+
const record = {
|
|
835
|
+
cachePath: null, absolute: null, text: null, finalUrl, status: response.status,
|
|
836
|
+
contentType: effective, totalChars: text.length, truncated: Boolean(response.truncated),
|
|
837
|
+
};
|
|
838
|
+
const written = writeCache(root, url, text);
|
|
839
|
+
if (written.path) {
|
|
840
|
+
record.cachePath = written.path;
|
|
841
|
+
// The absolute path is kept so a repeat call reads the file back without
|
|
842
|
+
// re-deriving it from the URL hash — the derivation is the part that could
|
|
843
|
+
// silently drift if the naming ever changes.
|
|
844
|
+
record.absolute = written.absolute;
|
|
845
|
+
} else {
|
|
846
|
+
// ⚠️ A memory workspace (and a read-only disk) has nowhere to put it, so the
|
|
847
|
+
// full text is held in the session map instead. It is NOT reported as
|
|
848
|
+
// cached — claiming a cachePath that cannot be read is the reassuring lie
|
|
849
|
+
// this codebase keeps having to delete.
|
|
850
|
+
record.text = text;
|
|
851
|
+
}
|
|
852
|
+
session.set(url, record);
|
|
853
|
+
|
|
854
|
+
return finishWindow({ ...record, text, url, offset, limit, fromCache: false });
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Read the whole converted document back for a repeat call. */
|
|
858
|
+
function readCached(record) {
|
|
859
|
+
if (record.text !== null && record.text !== undefined) return record.text;
|
|
860
|
+
if (!record.absolute || !existsSync(record.absolute)) return null;
|
|
861
|
+
try { return readFileSync(record.absolute, 'utf8'); } catch { return null; }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Write the FULL converted text so later windows cost nothing.
|
|
866
|
+
*
|
|
867
|
+
* ⚠️ THROUGH `resolveInWorkspace`, like every other path in this package. The
|
|
868
|
+
* filename is a sha1 of the URL, so it cannot contain anything interesting, but
|
|
869
|
+
* routing it through the workspace rules anyway is what keeps "every path goes
|
|
870
|
+
* through one function" true rather than nearly true.
|
|
871
|
+
*/
|
|
872
|
+
function writeCache(root, url, text) {
|
|
873
|
+
if (!root || root === '(memory)') return { path: null };
|
|
874
|
+
const rel = cachePathFor(url);
|
|
875
|
+
const target = resolveInWorkspace(root, rel, 'write');
|
|
876
|
+
if (!target.ok) return { path: null };
|
|
877
|
+
try {
|
|
878
|
+
mkdirSync(dirname(target.absolute), { recursive: true });
|
|
879
|
+
writeFileSync(target.absolute, text, 'utf8');
|
|
880
|
+
} catch {
|
|
881
|
+
// A read-only or full disk must not fail the lookup — the window in hand is
|
|
882
|
+
// still useful, and the only thing lost is the free second page.
|
|
883
|
+
return { path: null };
|
|
884
|
+
}
|
|
885
|
+
return { path: target.relative, absolute: target.absolute };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Assemble the result. One place, so a cached hit and a fresh fetch cannot drift. */
|
|
889
|
+
function finishWindow(r) {
|
|
890
|
+
if (r.offset > 0 && r.offset >= r.text.length) {
|
|
891
|
+
return {
|
|
892
|
+
ok: false,
|
|
893
|
+
error: `offset ${r.offset} is past the end of ${r.url} (${r.text.length} characters). The last window starts at ${Math.max(0, r.text.length - DEFAULT_WINDOW)}.`,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
const w = windowOf(r.text, r.offset, r.limit);
|
|
897
|
+
return {
|
|
898
|
+
ok: true,
|
|
899
|
+
url: r.url,
|
|
900
|
+
finalUrl: r.finalUrl,
|
|
901
|
+
status: r.status,
|
|
902
|
+
contentType: r.contentType,
|
|
903
|
+
totalChars: r.text.length,
|
|
904
|
+
cachePath: r.cachePath ?? null,
|
|
905
|
+
fromCache: Boolean(r.fromCache),
|
|
906
|
+
text: w.text,
|
|
907
|
+
nextOffset: w.nextOffset,
|
|
908
|
+
...(r.truncated ? { truncated: true } : {}),
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
913
|
+
* SCHEMA
|
|
914
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* ⚠️ THE DESCRIPTION STATES THE LOCALHOST LIMIT OUT LOUD. A model that does not
|
|
918
|
+
* know it will spend a round discovering it, then a second round arguing with
|
|
919
|
+
* the refusal — the two rounds probe D spent on truncation. What the schema says
|
|
920
|
+
* is the cheapest documentation in the system.
|
|
921
|
+
*/
|
|
922
|
+
export function fetchToolSchemas() {
|
|
923
|
+
return [
|
|
924
|
+
{
|
|
925
|
+
type: 'function',
|
|
926
|
+
function: {
|
|
927
|
+
name: 'fetch_url',
|
|
928
|
+
description: [
|
|
929
|
+
'Fetch a public http/https page as readable TEXT — this is how you check an API before you use it,',
|
|
930
|
+
'instead of guessing an option name or an import shape.',
|
|
931
|
+
'HTML is converted to text with code blocks kept intact, JSON is pretty-printed, plain text passes through.',
|
|
932
|
+
`Returns a window of ${DEFAULT_WINDOW} characters by default (max ${MAX_WINDOW});`,
|
|
933
|
+
'the reply carries `nextOffset` — call again with that as `offset` for the next window, which is free',
|
|
934
|
+
'because the whole document is cached on the first call. `cachePath` names that file if you would rather read it.',
|
|
935
|
+
'GET only: no headers, cookies, authentication, method or body can be set, by design.',
|
|
936
|
+
'It CANNOT reach private or local addresses, so it cannot talk to a server you just started on localhost',
|
|
937
|
+
'— use run_command to test that instead.',
|
|
938
|
+
`At most ${MAX_FETCHES_PER_PROCESS} fetches per run and ${MAX_FETCHES_PER_HOST} per host; re-reading a URL you already fetched is free.`,
|
|
939
|
+
].join(' '),
|
|
940
|
+
parameters: {
|
|
941
|
+
type: 'object',
|
|
942
|
+
properties: {
|
|
943
|
+
url: { type: 'string', description: 'A full http/https URL, e.g. "https://nodejs.org/api/test.html".' },
|
|
944
|
+
offset: { type: 'integer', description: 'Character offset to start the window at. Use the `nextOffset` from the previous call.' },
|
|
945
|
+
limit: { type: 'integer', description: `Characters to return, default ${DEFAULT_WINDOW}, max ${MAX_WINDOW}.` },
|
|
946
|
+
},
|
|
947
|
+
required: ['url'],
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
];
|
|
952
|
+
}
|