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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,907 @@
1
+ /**
2
+ * ── ⭐⭐ THE AGENT COULD BUILD AN API AND NEVER ONCE CALL IT ─────────────────
3
+ *
4
+ * MEASURED GAP, 2026-08-15. This CLI can already do two of the three things a
5
+ * backend task needs:
6
+ *
7
+ * · `start_process` (background.mjs) starts a dev server and tells you the
8
+ * port it announced, and even probes that the port is accepting HTTP.
9
+ * · `see_page` (media.mjs) renders a page in a browser and looks at it.
10
+ *
11
+ * What it could NOT do is make ONE request to an endpoint and read the answer.
12
+ * So an agent could write `POST /users`, start the server, watch the port come
13
+ * up — and have no way on earth to find out whether the route returns 201, 500
14
+ * or a stack trace. The only evidence available was unit tests it also wrote,
15
+ * which is a closed loop: the same misunderstanding writes the code and the
16
+ * test, and both agree. `check_process`'s probe is not a substitute either — it
17
+ * reports REACHABILITY on `/` and deliberately never returns a body.
18
+ *
19
+ * ── ⚠️⚠️ THE SECURITY ARGUMENT IS THE WHOLE DESIGN ──────────────────────────
20
+ *
21
+ * There are two precedents in this package and they point in opposite
22
+ * directions. Both are right, and this module has to inherit both.
23
+ *
24
+ * 1. `fetch-text.mjs` REFUSES loopback and every private range, on resolved
25
+ * addresses, re-checked on every redirect hop, with the socket pinned. That
26
+ * guard exists because a page the model was told to read can talk it into
27
+ * fetching `169.254.169.254` or an internal admin panel. Its own comment
28
+ * states the cost out loud: "this tool CANNOT talk to a server the agent
29
+ * just started on localhost… merging the two is how the guard gets a bypass
30
+ * flag that eventually defaults to on."
31
+ *
32
+ * 2. `background.mjs` DOES touch loopback, and says exactly why it is allowed
33
+ * to: "it only ever connects to a port this module started itself, on
34
+ * loopback, discovered from that process's own output. Not 'loopback is
35
+ * allowed now' — 'this specific port, because we launched the thing
36
+ * listening on it'."
37
+ *
38
+ * ⭐ SO THE CHOICE HERE WAS BETWEEN "ANY LOOPBACK PORT" AND "A PORT WE STARTED",
39
+ * AND THIS MODULE IMPLEMENTS THE SECOND. The argument, honestly:
40
+ *
41
+ * · "Any loopback port" is not a small widening. A developer laptop routinely
42
+ * has an unauthenticated Docker daemon on 2375, a Kubelet on 10250, an
43
+ * Elasticsearch on 9200, an Ollama on 11434, a Redis/DB admin UI, a
44
+ * `kubectl proxy` that fronts a whole cluster, and this CLI's own MCP
45
+ * servers. Every one of those trusts localhost as its entire auth model.
46
+ * · And unlike `fetch_url` — GET only, no headers, no body — THIS verb takes a
47
+ * method, headers and a JSON body, because that is what testing an API
48
+ * means. A read primitive pointed at 2375 lists your containers; a WRITE
49
+ * primitive pointed at 2375 starts one with the host filesystem mounted.
50
+ * The blast radius of the same widening is categorically larger here.
51
+ * · The port is chosen by a language model that reads files, and a repo can
52
+ * contain text that suggests a port. "Only a port a process from THIS run
53
+ * announced" is a fact the model cannot author: it requires having actually
54
+ * spawned the listener through the allowlisted `start_process` door.
55
+ *
56
+ * ⚠️ THE COST IS REAL AND IS NOT HIDDEN: you cannot call a server you started by
57
+ * hand in another terminal, or one running in Docker. The refusal says so and
58
+ * names the way out — start it with `start_process` and it becomes reachable.
59
+ * That is a worse tool for one workflow and a much smaller hole, and the
60
+ * asymmetry is deliberate: the thing that is easy to add later is reach.
61
+ *
62
+ * ── ⚠️ WHERE A CREDENTIAL TRAVELS ──────────────────────────────────────────
63
+ *
64
+ * Headers are the credential path, so the rule is stated rather than assumed:
65
+ *
66
+ * · The caller MAY set `authorization`, `cookie`, `x-api-key`, `content-type`,
67
+ * `accept` and any other well-formed header. That is allowed HERE and
68
+ * refused in `fetch_url` for one reason that actually holds: the destination
69
+ * is a port a process from this run announced, on loopback, and this tool
70
+ * NEVER follows a redirect — so there is no hop at which a header can leave
71
+ * the machine or reach a third party. Refusing `Authorization` would make it
72
+ * impossible to test the auth middleware you just wrote, which is one of the
73
+ * most common things a backend agent must check.
74
+ * · The caller MAY NOT set the framing headers — `host`, `content-length`,
75
+ * `transfer-encoding`, `connection`, `te`, `trailer`, `upgrade`,
76
+ * `keep-alive`, `expect`. Those are request-smuggling primitives (or, for
77
+ * `expect: 100-continue`, a guaranteed hang), and we compute them.
78
+ * · The CLI ITSELF attaches NOTHING. No env var is ever read into a header, no
79
+ * cookie jar exists, no auth is remembered between calls, and nothing is
80
+ * carried from one call to the next.
81
+ * · ⭐ The result reports header NAMES and never header VALUES. A secret the
82
+ * model pasted into `authorization` should not be re-serialised back into
83
+ * the transcript a second time by the tool that sent it.
84
+ *
85
+ * ── ⚠️ THREE FAILURES, THREE SENTENCES ─────────────────────────────────────
86
+ *
87
+ * `ECONNREFUSED`, a timeout and an HTTP 500 are three different facts and the
88
+ * model must act differently on each:
89
+ * · refused → nothing is listening. It is still booting, or it died. That is
90
+ * a `check_process` question, not a code question.
91
+ * · timeout → something IS listening and did not answer. The handler hangs —
92
+ * an await that never settles, a missing `res.end()`.
93
+ * · 500 → the request SUCCEEDED. The endpoint is broken. This returns
94
+ * `ok: true`, because the tool did its job; a refusal here would
95
+ * teach the model that its own bug is a tool failure.
96
+ */
97
+
98
+ import http from 'node:http';
99
+ import { createGunzip, createInflate, createBrotliDecompress } from 'node:zlib';
100
+
101
+ import { listBackground } from './background.mjs';
102
+
103
+ /** Whole-request budget. A loopback call that takes 10s is a hang, not slowness. */
104
+ export const DEFAULT_TIMEOUT_MS = 10_000;
105
+ /** A deliberate slow-endpoint test is legitimate; a 5-minute one is a stuck run. */
106
+ export const MAX_TIMEOUT_MS = 60_000;
107
+ /** Hard ceiling on what comes off the wire, decompressed. */
108
+ export const MAX_BODY_BYTES = 256 * 1024;
109
+ /** How much of it is rendered into the transcript. The rest is counted, not shown. */
110
+ export const MAX_BODY_CHARS = 8_000;
111
+ /** A request body bigger than this is a fixture, not a test case. */
112
+ export const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
113
+
114
+ /**
115
+ * ⚠️ A RUNAWAY-LOOP STOP, NOT A CRAWL STOP — and set high enough that real work
116
+ * never meets it. `fetch_url` has a tight budget because a fetch is a crawl
117
+ * waiting to happen and each one costs the user's bandwidth. This one is
118
+ * loopback, free, and can only reach a process we started, so the only failure
119
+ * it guards is a model retrying the same broken endpoint forever. A cap that
120
+ * refuses correct work is worse than no cap.
121
+ */
122
+ export const MAX_CALLS_PER_RUN = 100;
123
+
124
+ /** ⚠️ CONNECT tunnels and TRACE reflects. Neither is a thing you test an API with. */
125
+ export const ALLOWED_METHODS = Object.freeze(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
126
+
127
+ /** Framing and hop headers. We compute these; a caller setting them breaks the request. */
128
+ export const REFUSED_HEADERS = Object.freeze([
129
+ 'host', 'content-length', 'transfer-encoding', 'connection', 'keep-alive',
130
+ 'upgrade', 'te', 'trailer', 'expect',
131
+ ]);
132
+
133
+ /** The only hostnames that may appear in a `url`. Everything else is fetch_url's job. */
134
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
135
+
136
+ /** At most this many caller headers, each value at most this long. */
137
+ const MAX_HEADERS = 25;
138
+ const MAX_HEADER_VALUE_CHARS = 4_096;
139
+
140
+ /** RFC 7230 token. Anything outside it cannot be a header name. */
141
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
142
+
143
+ /** Content types worth rendering as text. Everything else is refused BY NAME. */
144
+ const TEXTUAL = /^(text\/|application\/(json|xml|xhtml\+xml|javascript|x-www-form-urlencoded)$|application\/[a-z0-9.+-]*\+json$|application\/[a-z0-9.+-]*\+xml$)/i;
145
+
146
+ /**
147
+ * @typedef {{ ok: false, error: string }} CallRefused
148
+ * @typedef {{
149
+ * ok: true, url: string, method: string, status: number, statusText: string,
150
+ * class: 'success'|'redirect'|'client-error'|'server-error'|'informational',
151
+ * headers: Record<string, string>, contentType: string,
152
+ * body: string|null, bytes: number, truncated: boolean,
153
+ * sentHeaders: string[], durationMs: number, note: string,
154
+ * bodyOmitted?: string
155
+ * }} CallOk
156
+ */
157
+
158
+ /* ────────────────────────────────────────────────────────────────────────────
159
+ * PER-PROCESS STATE
160
+ * ──────────────────────────────────────────────────────────────────────────── */
161
+
162
+ let callsMade = 0;
163
+
164
+ /** Reset between tests. Never called in normal operation — a run is short. */
165
+ export function resetHttpProbeState() {
166
+ callsMade = 0;
167
+ }
168
+
169
+ /* ────────────────────────────────────────────────────────────────────────────
170
+ * THE TARGET GUARD — the part that is security, not convenience
171
+ * ──────────────────────────────────────────────────────────────────────────── */
172
+
173
+ /**
174
+ * Describe the background registry in one line, so every refusal can name the
175
+ * way out with real ids instead of telling the model to go and look.
176
+ */
177
+ function describeProcesses(procs) {
178
+ if (procs.length === 0) return 'nothing is running in the background right now';
179
+ return procs
180
+ .map((p) => {
181
+ if (!p.running) return `${p.id} (exited${p.exitCode === null || p.exitCode === undefined ? '' : ` with code ${p.exitCode}`})`;
182
+ return p.port === null || p.port === undefined
183
+ ? `${p.id} (running, no port announced yet)`
184
+ : `${p.id} on port ${p.port}`;
185
+ })
186
+ .join(', ');
187
+ }
188
+
189
+ /**
190
+ * Turn the caller's arguments into a port + path this tool is allowed to open.
191
+ *
192
+ * ⚠️ THIS IS THE WHOLE SECURITY BOUNDARY AND IT IS EXPORTED SO IT CAN BE TESTED
193
+ * DIRECTLY. The registry is injectable (the tests need a port they control) but
194
+ * the RULE is not: the injected value is the list of processes, and the check
195
+ * that the port is in it still runs. A seam that let a test skip the check would
196
+ * be a guard that is switched off exactly where it is claimed to work.
197
+ *
198
+ * @returns {{ok: true, port: number, path: string, owner: string}|CallRefused}
199
+ */
200
+ export function resolveTarget({ url, port, path, listProcesses = listBackground } = {}) {
201
+ const hasUrl = typeof url === 'string' && url.trim() !== '';
202
+ const hasPort = port !== undefined && port !== null && port !== '';
203
+
204
+ /**
205
+ * ⚠️ BOTH IS A REFUSAL, NOT A PRECEDENCE RULE — the rule `start_process` learned.
206
+ * If one silently won, the model would read a `url` back in the result while a
207
+ * different port was actually called, and that reads as the tool lying.
208
+ */
209
+ if (hasUrl && hasPort) {
210
+ return { ok: false, error: 'give either "url" (e.g. "http://localhost:3000/users") or "port" + "path", not both.' };
211
+ }
212
+ if (!hasUrl && !hasPort) {
213
+ return {
214
+ ok: false,
215
+ error: 'call_endpoint needs a target: "url" (e.g. "http://localhost:3000/users"), or "port" and "path" '
216
+ + '(e.g. port 3000, path "/users"). check_process prints the url of the server you started.',
217
+ };
218
+ }
219
+
220
+ let wantPort;
221
+ let wantPath;
222
+
223
+ if (hasUrl) {
224
+ let u;
225
+ try {
226
+ u = new URL(url.trim());
227
+ } catch {
228
+ return { ok: false, error: `"${String(url).slice(0, 120)}" is not a full URL. Include the scheme and the port, e.g. "http://localhost:3000/users".` };
229
+ }
230
+ if (u.protocol === 'https:') {
231
+ return {
232
+ ok: false,
233
+ error: 'call_endpoint speaks plain http only. A local dev server with TLS would need its certificate trusted, '
234
+ + 'and a self-signed one is not worth that door — start the server on http and call that.',
235
+ };
236
+ }
237
+ if (u.protocol !== 'http:') {
238
+ return { ok: false, error: `call_endpoint speaks http only — "${u.protocol}" is refused.` };
239
+ }
240
+ if (u.username || u.password) {
241
+ return {
242
+ ok: false,
243
+ error: 'this URL embeds credentials (user:pass@host) and is refused — they would be silently turned into an '
244
+ + 'Authorization header. Put the header in "headers" yourself if the endpoint needs one.',
245
+ };
246
+ }
247
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
248
+ if (!LOOPBACK_HOSTS.has(host)) {
249
+ return {
250
+ ok: false,
251
+ // ⚠️ NAMES THE OTHER TOOL. A model told only "refused" tries the same
252
+ // call three more ways; told "fetch_url does public hosts" it moves on.
253
+ error: `call_endpoint only calls a local server that you started with start_process — "${host}" is not one. `
254
+ + 'Use fetch_url for a public http/https address.',
255
+ };
256
+ }
257
+ if (!u.port) {
258
+ return { ok: false, error: `"${url.trim()}" has no port. A dev server always has one — check_process prints it, e.g. "http://localhost:3000/".` };
259
+ }
260
+ wantPort = Number(u.port);
261
+ wantPath = `${u.pathname || '/'}${u.search || ''}`;
262
+ } else {
263
+ wantPort = typeof port === 'number' ? port : Number(String(port).trim());
264
+ if (!Number.isInteger(wantPort) || wantPort < 1 || wantPort > 65535) {
265
+ return { ok: false, error: `port must be a whole number between 1 and 65535, not ${JSON.stringify(port)}` };
266
+ }
267
+ if (path === undefined || path === null || path === '') {
268
+ wantPath = '/';
269
+ } else if (typeof path !== 'string') {
270
+ return { ok: false, error: `path must be a string like "/users" or "/users?limit=10", not ${JSON.stringify(path)}` };
271
+ } else {
272
+ wantPath = path;
273
+ }
274
+ }
275
+
276
+ if (!wantPath.startsWith('/')) {
277
+ return { ok: false, error: `path must start with "/" — write "/${wantPath}" rather than "${wantPath}".` };
278
+ }
279
+ /**
280
+ * ⚠️ A CR OR LF IN THE PATH IS REQUEST SPLITTING. It ends the request line and
281
+ * starts a second request the caller never wrote — the header-injection bug in
282
+ * its oldest form. A space is refused with it because the fix is the same one.
283
+ */
284
+ if (/[\x00-\x20\x7f]/.test(wantPath)) {
285
+ return {
286
+ ok: false,
287
+ error: 'path contains a space or a control character. Percent-encode it (a space is %20) — a newline in a path '
288
+ + 'would split the request in two, so it is refused rather than escaped.',
289
+ };
290
+ }
291
+
292
+ /* ── THE REGISTRY CHECK ─────────────────────────────────────────────────── */
293
+ let procs;
294
+ try {
295
+ procs = listProcesses() ?? [];
296
+ } catch {
297
+ // ⚠️ FAILS CLOSED. "I could not read the registry" and "the port is fine"
298
+ // are different answers, and only one of them is honest.
299
+ procs = [];
300
+ }
301
+ if (!Array.isArray(procs)) procs = [];
302
+
303
+ /**
304
+ * ── ⚠️⚠️ MATCHING ON THE NUMBER WAS THE HOLE. THE VERDICT IS THE CHECK ─────
305
+ *
306
+ * This used to be `p.port === wantPort`. `p.port` is parsed out of the child's
307
+ * own STDOUT, so a repository somebody cloned decided it — and the refusal
308
+ * message a few lines below, *"must not be able to poke arbitrary localhost
309
+ * services such as a Docker daemon"*, described exactly what that let through.
310
+ * Proven end to end 2026-08-15: a decoy bound one port while printing
311
+ * `"Docker daemon on port 2375"`, and the probe went to the real 2375.
312
+ *
313
+ * `background.mjs` now asks the OS who holds the port (`verifyPortOwner`) and
314
+ * publishes the answer as `portVerified`. ⭐ REQUIRING `=== true` IS THE POINT:
315
+ * an older record, a hand-built stub, or a future field rename all yield
316
+ * `undefined`, and `undefined` must refuse rather than pass.
317
+ */
318
+ const owner = procs.find((p) => p && p.running === true && p.port === wantPort && p.portVerified === true);
319
+ if (owner) return { ok: true, port: wantPort, path: wantPath, owner: owner.id };
320
+
321
+ /**
322
+ * ⭐ A CLAIMED-BUT-UNVERIFIED PORT GETS ITS OWN ANSWER. Lumping it in with
323
+ * "that port is not one of ours" would tell a model whose dev server is simply
324
+ * still booting to go and start a server it has already started — so it would
325
+ * start a second one, take a different port, and be further from working.
326
+ */
327
+ const unverified = procs.find((p) => p && p.running === true && p.port === wantPort);
328
+ if (unverified) {
329
+ return {
330
+ ok: false,
331
+ error: `${unverified.id} printed port ${wantPort}, but this machine has not confirmed that ${unverified.id} `
332
+ + 'actually holds it — that number came out of the process\'s own output, so it is a claim, not a fact. '
333
+ + `Call check_process {"id":"${unverified.id}"} — if the server is still starting, the port becomes usable `
334
+ + 'once it is really listening; if it stays unconfirmed, something else owns that port and calling it would '
335
+ + 'be reaching a service this run did not start.',
336
+ };
337
+ }
338
+
339
+ /**
340
+ * ⭐ A PORT WHOSE PROCESS HAS DIED IS ITS OWN ANSWER, not a generic refusal.
341
+ * The model is about to conclude "my request tool is broken" when the truth is
342
+ * "your server crashed", and those lead to opposite next moves. The port is
343
+ * also genuinely no longer ours — anything on the machine may have taken it.
344
+ */
345
+ const dead = procs.find((p) => p && p.running !== true && p.port === wantPort);
346
+ if (dead) {
347
+ return {
348
+ ok: false,
349
+ error: `${dead.id} announced port ${wantPort}, but it is NOT running any more`
350
+ + `${dead.exitCode === null || dead.exitCode === undefined ? '' : ` (it exited with code ${dead.exitCode})`}. `
351
+ + `Anything on this machine could hold that port now, so call_endpoint will not connect to it. `
352
+ + `Read check_process {"id":"${dead.id}"} for why it died, fix that, and start it again.`,
353
+ };
354
+ }
355
+
356
+ return {
357
+ ok: false,
358
+ error: `call_endpoint can only reach a port announced by a process THIS run started with start_process — `
359
+ + `port ${wantPort} is not one of them. Right now: ${describeProcesses(procs)}. `
360
+ + 'Start the server with start_process, call check_process to see the port it announced, then call that port. '
361
+ + '(A server you started by hand in another terminal, or one in Docker, is deliberately out of reach: this tool '
362
+ + 'must not be able to poke arbitrary localhost services such as a Docker daemon or a database admin panel.)',
363
+ };
364
+ }
365
+
366
+ /* ────────────────────────────────────────────────────────────────────────────
367
+ * HEADERS
368
+ * ──────────────────────────────────────────────────────────────────────────── */
369
+
370
+ /**
371
+ * Validate the caller's headers. Returns them lowercased, or a refusal that
372
+ * names the offending header — never a silent drop, which would teach the model
373
+ * that a header it needs was sent when it was not.
374
+ */
375
+ export function validateHeaders(headers) {
376
+ if (headers === undefined || headers === null) return { ok: true, headers: {} };
377
+ if (typeof headers !== 'object' || Array.isArray(headers)) {
378
+ return { ok: false, error: 'headers must be an object like {"content-type":"application/json"}' };
379
+ }
380
+ const entries = Object.entries(headers);
381
+ if (entries.length > MAX_HEADERS) {
382
+ return { ok: false, error: `that is ${entries.length} headers; at most ${MAX_HEADERS} may be set.` };
383
+ }
384
+ /** @type {Record<string,string>} */
385
+ const out = {};
386
+ for (const [rawName, rawValue] of entries) {
387
+ const name = String(rawName).toLowerCase();
388
+ if (!HEADER_NAME.test(rawName)) {
389
+ return { ok: false, error: `"${String(rawName).slice(0, 60)}" is not a valid header name — letters, digits and -_.~ only.` };
390
+ }
391
+ if (REFUSED_HEADERS.includes(name)) {
392
+ return {
393
+ ok: false,
394
+ error: `call_endpoint sets "${name}" itself and will not take one from you. `
395
+ + `Framing headers (${REFUSED_HEADERS.join(', ')}) decide where one request ends and the next begins; `
396
+ + 'a caller-supplied one either breaks the request or smuggles a second one. Everything else, including '
397
+ + 'authorization and cookie, you may set.',
398
+ };
399
+ }
400
+ if (typeof rawValue === 'number' || typeof rawValue === 'boolean') {
401
+ out[name] = String(rawValue);
402
+ continue;
403
+ }
404
+ if (typeof rawValue !== 'string') {
405
+ return { ok: false, error: `the value of "${name}" must be a string, not ${JSON.stringify(rawValue)}` };
406
+ }
407
+ if (/[\r\n]/.test(rawValue)) {
408
+ // ⚠️ Header injection, the classic. Refused rather than stripped: a value
409
+ // that silently changes is a value the model will trust wrongly.
410
+ return { ok: false, error: `the value of "${name}" contains a newline. That would inject extra headers into the request, so it is refused.` };
411
+ }
412
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(rawValue)) {
413
+ return { ok: false, error: `the value of "${name}" contains a control character and is refused.` };
414
+ }
415
+ if (rawValue.length > MAX_HEADER_VALUE_CHARS) {
416
+ return { ok: false, error: `the value of "${name}" is ${rawValue.length} characters; the limit is ${MAX_HEADER_VALUE_CHARS}.` };
417
+ }
418
+ out[name] = rawValue;
419
+ }
420
+ return { ok: true, headers: out };
421
+ }
422
+
423
+ /* ────────────────────────────────────────────────────────────────────────────
424
+ * TRANSPORT
425
+ * ──────────────────────────────────────────────────────────────────────────── */
426
+
427
+ /**
428
+ * One request, one answer, no redirect following.
429
+ *
430
+ * ⚠️⚠️ THE HOST IS AN IP LITERAL AND THERE IS NO DNS LOOKUP AT ALL. Even when
431
+ * the caller wrote `http://localhost:3000`, the socket goes to `127.0.0.1`.
432
+ * `fetch-text.mjs` had to pin resolved addresses to a socket to stop DNS
433
+ * rebinding; here the same property comes for free by never asking a resolver,
434
+ * so a `hosts` file that redefines `localhost` cannot move this request.
435
+ *
436
+ * ⚠️ AND IT DOES NOT FOLLOW REDIRECTS, BY DESIGN. A 302 is reported as a fact
437
+ * with its Location header. Following one would be the hop at which a caller's
438
+ * `Authorization` header could be replayed somewhere it was never meant to go,
439
+ * and it would be a way to reach a port the guard above never approved.
440
+ */
441
+ export function nodeHttpRequest({ host, port, path, method, headers, body, timeoutMs, maxBytes = MAX_BODY_BYTES }) {
442
+ return new Promise((resolve, reject) => {
443
+ const req = http.request({
444
+ host,
445
+ port,
446
+ path,
447
+ method,
448
+ headers,
449
+ family: host.includes(':') ? 6 : 4,
450
+ timeout: timeoutMs,
451
+ }, (res) => {
452
+ const encoding = String(res.headers['content-encoding'] || '').toLowerCase();
453
+ let stream = res;
454
+ // We never ask for compression, but a server that always compresses would
455
+ // otherwise hand the model a binary smear and get refused as "binary".
456
+ if (encoding === 'gzip' || encoding === 'x-gzip') stream = res.pipe(createGunzip());
457
+ else if (encoding === 'deflate') stream = res.pipe(createInflate());
458
+ else if (encoding === 'br') stream = res.pipe(createBrotliDecompress());
459
+
460
+ /** @type {Buffer[]} */
461
+ const chunks = [];
462
+ let total = 0;
463
+ let truncated = false;
464
+ let settled = false;
465
+
466
+ const finish = () => {
467
+ if (settled) return;
468
+ settled = true;
469
+ resolve({
470
+ status: res.statusCode ?? 0,
471
+ statusText: res.statusMessage ?? '',
472
+ headers: res.headers,
473
+ body: Buffer.concat(chunks),
474
+ bytes: total,
475
+ truncated,
476
+ });
477
+ };
478
+
479
+ stream.on('data', (chunk) => {
480
+ total += chunk.length;
481
+ if (total > maxBytes) {
482
+ // ⚠️ The socket is destroyed rather than drained: reading a 4GB
483
+ // response to completion and discarding it is a denial of service the
484
+ // agent performs on its own user's machine.
485
+ truncated = true;
486
+ chunks.push(chunk.subarray(0, Math.max(0, chunk.length - (total - maxBytes))));
487
+ req.destroy();
488
+ res.destroy();
489
+ finish();
490
+ return;
491
+ }
492
+ chunks.push(chunk);
493
+ });
494
+ stream.on('end', finish);
495
+ stream.on('error', (err) => {
496
+ // A decompression failure on a body we deliberately cut short is the
497
+ // expected consequence of cutting it short, not an error.
498
+ if (truncated) return finish();
499
+ if (!settled) { settled = true; reject(err); }
500
+ });
501
+ });
502
+
503
+ req.on('timeout', () => {
504
+ const err = new Error('timed out');
505
+ err.code = 'ETIMEDOUT';
506
+ req.destroy(err);
507
+ });
508
+ req.on('error', reject);
509
+ if (body !== null && body !== undefined && body.length > 0) req.write(body);
510
+ req.end();
511
+ });
512
+ }
513
+
514
+ /* ────────────────────────────────────────────────────────────────────────────
515
+ * RENDERING THE ANSWER
516
+ * ──────────────────────────────────────────────────────────────────────────── */
517
+
518
+ function classOf(status) {
519
+ if (status >= 500) return 'server-error';
520
+ if (status >= 400) return 'client-error';
521
+ if (status >= 300) return 'redirect';
522
+ if (status >= 200) return 'success';
523
+ return 'informational';
524
+ }
525
+
526
+ /**
527
+ * ⭐ THE FOUR SENTENCES. A status number alone makes a model guess whose fault it
528
+ * is, and it guesses "mine" for a 500 (spending rounds rewriting a correct
529
+ * request) and "the server's" for a 400 (spending rounds restarting a healthy
530
+ * one). Naming the owner of the bug is the whole value of this line.
531
+ */
532
+ function noteFor({ status, statusText, method, path, port, durationMs, headers }) {
533
+ const line = `${method} ${path} on port ${port} → HTTP ${status}${statusText ? ` ${statusText}` : ''} in ${durationMs}ms.`;
534
+ const kind = classOf(status);
535
+ if (kind === 'success') return `${line} The endpoint answered.`;
536
+ if (kind === 'redirect') {
537
+ const loc = headers?.location;
538
+ return `${line} call_endpoint does NOT follow redirects — ${loc ? `it points at "${loc}"` : 'no Location header was sent'}. `
539
+ + 'Call that path yourself if you want to see where it lands.';
540
+ }
541
+ if (kind === 'client-error') {
542
+ return `${line} The server understood the request and REJECTED it — that is normally the request's fault `
543
+ + '(wrong path, missing field, missing or wrong auth), not a crash. The body below usually says which.';
544
+ }
545
+ if (kind === 'server-error') {
546
+ return `${line} The request itself worked — your HANDLER failed. This is a bug in the server code, not in the call. `
547
+ + 'The stack trace is usually on the process stdout, so read check_process, not just this body.';
548
+ }
549
+ return line;
550
+ }
551
+
552
+ /** Pretty-print JSON so a minified payload is readable and diffable. */
553
+ function renderBody(buf, contentType) {
554
+ const mime = String(contentType || '').split(';')[0].trim().toLowerCase();
555
+ // A server that states no type is almost always serving text (a bare res.end(),
556
+ // a small hand-written API). Guessing text is safe because the WRONG guess is
557
+ // caught two lines down by the same NUL check `read_file` uses.
558
+ const effective = mime || 'text/plain';
559
+ if (!TEXTUAL.test(effective)) {
560
+ return { text: null, omitted: `the response is ${effective}, which call_endpoint does not render as text — its status and headers above are the fact you asked for. Save it with a script if you need the bytes.` };
561
+ }
562
+ const charset = /charset\s*=\s*"?([\w-]+)"?/i.exec(contentType || '')?.[1]?.toLowerCase();
563
+ const text = charset === 'iso-8859-1' || charset === 'latin1' || charset === 'windows-1252'
564
+ ? buf.toString('latin1')
565
+ : buf.toString('utf8');
566
+ if (text.includes('\u0000')) {
567
+ return { text: null, omitted: `the response claims to be ${effective} but the body is binary. Refusing to render it as text.` };
568
+ }
569
+ // Detected by PARSING, not only by believing the header — the same trick
570
+ // fetch-text.mjs needed, and it cannot false-positive: the test is that
571
+ // JSON.parse succeeds on the whole body.
572
+ const looksJson = /json/.test(effective) || /^\s*[[{]/.test(text);
573
+ if (looksJson) {
574
+ try { return { text: JSON.stringify(JSON.parse(text), null, 2) }; } catch { /* not JSON after all */ }
575
+ }
576
+ return { text };
577
+ }
578
+
579
+ /* ────────────────────────────────────────────────────────────────────────────
580
+ * THE TOOL
581
+ * ──────────────────────────────────────────────────────────────────────────── */
582
+
583
+ const KNOWN_ARGS = new Set([
584
+ 'url', 'port', 'path', 'method', 'headers', 'body', 'json', 'timeoutMs',
585
+ 'requestImpl', 'listProcesses', 'now', 'executor',
586
+ ]);
587
+ const SEAMS = ['requestImpl', 'listProcesses', 'now'];
588
+
589
+ /**
590
+ * Make ONE HTTP request to a server this run started, and return what came back.
591
+ *
592
+ * @param {object} params
593
+ * @returns {Promise<CallOk | CallRefused>}
594
+ */
595
+ export async function httpProbe(params = {}) {
596
+ /**
597
+ * ⚠️ UNKNOWN KEYS ARE REFUSED BY NAME, the rule `fetch_url` states: the
598
+ * dispatcher spreads the model's arguments straight in, so this is the only
599
+ * place that sees them, and silently dropping `data` teaches the model that
600
+ * its body was sent when it was not.
601
+ */
602
+ const unknown = Object.keys(params).filter((k) => !KNOWN_ARGS.has(k));
603
+ if (unknown.length) {
604
+ return {
605
+ ok: false,
606
+ error: `call_endpoint does not accept "${unknown[0]}". It takes url (or port + path), method, headers, body, json and timeoutMs.`
607
+ + (unknown[0] === 'data' ? ' Use "json" for a JSON body, or "body" for a raw string.' : ''),
608
+ };
609
+ }
610
+ for (const seam of SEAMS) {
611
+ if (params[seam] !== undefined && typeof params[seam] !== 'function') {
612
+ return { ok: false, error: `call_endpoint does not accept "${seam}". It takes url (or port + path), method, headers, body, json and timeoutMs.` };
613
+ }
614
+ }
615
+
616
+ const {
617
+ executor,
618
+ requestImpl = nodeHttpRequest,
619
+ listProcesses = listBackground,
620
+ now = Date.now,
621
+ } = params;
622
+
623
+ /**
624
+ * ⚠️ A DRY RUN CALLS NOTHING. `POST /users` creates a user; `DELETE /users/1`
625
+ * deletes one. A dry run promises not to change anything, and "it is only a
626
+ * local dev database" is exactly the reasoning that makes the promise false.
627
+ */
628
+ if (executor?.dryRun) {
629
+ return { ok: false, error: 'this is a --dry-run, so no request is made (POST /users changes something, and a dry run promises not to).' };
630
+ }
631
+
632
+ /* ── METHOD ────────────────────────────────────────────────────────────── */
633
+ const method = params.method === undefined || params.method === null || params.method === ''
634
+ ? 'GET'
635
+ : String(params.method).trim().toUpperCase();
636
+ if (!ALLOWED_METHODS.includes(method)) {
637
+ const why = method === 'CONNECT'
638
+ ? ' CONNECT opens a tunnel to somewhere else, which would walk straight around the port check.'
639
+ : method === 'TRACE'
640
+ ? ' TRACE only reflects your own request back at you.'
641
+ : '';
642
+ return { ok: false, error: `"${method}" is not a method call_endpoint sends. Allowed: ${ALLOWED_METHODS.join(', ')}.${why}` };
643
+ }
644
+
645
+ /* ── BODY ──────────────────────────────────────────────────────────────── */
646
+ if (params.body !== undefined && params.json !== undefined) {
647
+ // Ambiguous, and the model would never see which one won.
648
+ return { ok: false, error: 'give either "body" (a raw string) or "json" (an object serialised for you), not both.' };
649
+ }
650
+ let bodyText = null;
651
+ let impliedContentType = null;
652
+ if (params.json !== undefined) {
653
+ try {
654
+ bodyText = JSON.stringify(params.json);
655
+ } catch {
656
+ return { ok: false, error: 'json could not be serialised (it is probably circular). Pass a plain object or array.' };
657
+ }
658
+ if (bodyText === undefined) return { ok: false, error: 'json serialised to nothing. Pass a plain object or array.' };
659
+ impliedContentType = 'application/json';
660
+ } else if (params.body !== undefined && params.body !== null) {
661
+ if (typeof params.body !== 'string') {
662
+ return { ok: false, error: `body must be a string — use "json" if you want an object serialised for you, not ${JSON.stringify(params.body).slice(0, 80)}` };
663
+ }
664
+ bodyText = params.body;
665
+ }
666
+ const bodyBuf = bodyText === null ? null : Buffer.from(bodyText, 'utf8');
667
+ if (bodyBuf && bodyBuf.length > MAX_REQUEST_BODY_BYTES) {
668
+ return {
669
+ ok: false,
670
+ error: `that request body is ${bodyBuf.length} bytes; the limit is ${MAX_REQUEST_BODY_BYTES}. `
671
+ + 'Write the payload to a file and have a script post it if it really needs to be that big.',
672
+ };
673
+ }
674
+
675
+ /* ── HEADERS ───────────────────────────────────────────────────────────── */
676
+ const hv = validateHeaders(params.headers);
677
+ if (!hv.ok) return hv;
678
+
679
+ /* ── TIMEOUT ───────────────────────────────────────────────────────────── */
680
+ let timeoutMs = params.timeoutMs === undefined || params.timeoutMs === null
681
+ ? DEFAULT_TIMEOUT_MS
682
+ : Number(params.timeoutMs);
683
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
684
+ return { ok: false, error: `timeoutMs must be a positive number of milliseconds, not ${JSON.stringify(params.timeoutMs)}` };
685
+ }
686
+ // Clamped rather than refused: a model asking for 10 minutes wants "wait a
687
+ // while", and 60s answers that. Refusing costs a round to learn a number.
688
+ if (timeoutMs > MAX_TIMEOUT_MS) timeoutMs = MAX_TIMEOUT_MS;
689
+ timeoutMs = Math.round(timeoutMs);
690
+
691
+ /* ── THE GUARD ─────────────────────────────────────────────────────────── */
692
+ const target = resolveTarget({ url: params.url, port: params.port, path: params.path, listProcesses });
693
+ if (!target.ok) return target;
694
+
695
+ /* ── BUDGET ────────────────────────────────────────────────────────────── */
696
+ if (callsMade >= MAX_CALLS_PER_RUN) {
697
+ return {
698
+ ok: false,
699
+ error: `call_endpoint has already made ${MAX_CALLS_PER_RUN} requests this run, which is the limit — that is a loop, not a test. `
700
+ + 'Write the remaining checks into a script and run it once with run_command.',
701
+ };
702
+ }
703
+ callsMade += 1;
704
+
705
+ /* ── SEND ──────────────────────────────────────────────────────────────── */
706
+ const sendHeaders = {
707
+ // ⚠️ The Host header is ours: it names the port so a vhost-routing server
708
+ // behaves, and a caller cannot use it to reach a different app behind a
709
+ // local proxy.
710
+ host: `127.0.0.1:${target.port}`,
711
+ accept: '*/*',
712
+ 'user-agent': 'acuvo-code (call_endpoint)',
713
+ ...(impliedContentType ? { 'content-type': impliedContentType } : {}),
714
+ // Caller headers land last so an explicit content-type wins over the implied
715
+ // one — but never over the framing headers, which validateHeaders refuses.
716
+ ...hv.headers,
717
+ connection: 'close',
718
+ ...(bodyBuf ? { 'content-length': String(bodyBuf.length) } : {}),
719
+ };
720
+
721
+ const started = now();
722
+ let res;
723
+ let usedHost = '127.0.0.1';
724
+ try {
725
+ res = await requestImpl({
726
+ host: usedHost, port: target.port, path: target.path, method,
727
+ headers: sendHeaders, body: bodyBuf, timeoutMs, maxBytes: MAX_BODY_BYTES,
728
+ });
729
+ } catch (err) {
730
+ const code = err?.code || err?.cause?.code || err?.name || 'unknown';
731
+ /**
732
+ * ⭐ ONE RETRY, ON ::1 ONLY, AND ONLY FOR A REFUSAL. A server that binds the
733
+ * IPv6 loopback exclusively (`server.listen(port, '::1')`, and some
734
+ * frameworks do it when told "localhost") refuses 127.0.0.1 while being
735
+ * perfectly healthy. Reporting "nothing is listening" there would send the
736
+ * model to debug a working server. Still loopback, still the same approved
737
+ * port — this widens nothing.
738
+ */
739
+ if (code === 'ECONNREFUSED') {
740
+ try {
741
+ usedHost = '::1';
742
+ res = await requestImpl({
743
+ host: usedHost, port: target.port, path: target.path, method,
744
+ headers: { ...sendHeaders, host: `[::1]:${target.port}` }, body: bodyBuf, timeoutMs, maxBytes: MAX_BODY_BYTES,
745
+ });
746
+ } catch {
747
+ return { ok: false, error: refusedMessage(target, method) };
748
+ }
749
+ } else if (code === 'ETIMEDOUT' || code === 'ERR_SOCKET_TIMEOUT' || err?.name === 'AbortError') {
750
+ /**
751
+ * ⚠️ A DIFFERENT FACT FROM A REFUSAL, AND THE OPPOSITE NEXT MOVE. Something
752
+ * accepted the connection and never answered: the route hangs. Restarting
753
+ * the server will not help; finding the await that never settles will.
754
+ */
755
+ return {
756
+ ok: false,
757
+ error: `${method} ${target.path} connected to port ${target.port} (${target.owner} is listening) but the server did not ANSWER within ${timeoutMs}ms. `
758
+ + 'It is not down — the handler is hanging: an await that never settles, a missing res.end(), or an infinite loop. '
759
+ + 'Read check_process for what it printed while you waited, and raise timeoutMs only if the work really is slow.',
760
+ };
761
+ } else if (code === 'ECONNRESET' || code === 'EPIPE' || /socket hang up/i.test(String(err?.message))) {
762
+ return {
763
+ ok: false,
764
+ error: `${method} ${target.path} reached port ${target.port}, and the server then closed the connection WITHOUT answering (${code}). `
765
+ + `That is usually the process crashing mid-request — check_process {"id":"${target.owner}"} will have the stack trace, `
766
+ + 'which is the actual bug, not this call.',
767
+ };
768
+ } else {
769
+ return { ok: false, error: `${method} ${target.path} on port ${target.port} failed: ${code}${err?.message ? ` (${err.message})` : ''}` };
770
+ }
771
+ }
772
+
773
+ const durationMs = Math.max(0, Math.round(now() - started));
774
+
775
+ /* ── ANSWER ────────────────────────────────────────────────────────────── */
776
+ const rawHeaders = res?.headers ?? {};
777
+ /** @type {Record<string,string>} */
778
+ const headers = {};
779
+ for (const [k, v] of Object.entries(rawHeaders)) headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
780
+ const contentType = headers['content-type'] ?? '';
781
+ const buf = Buffer.isBuffer(res?.body) ? res.body : Buffer.from(String(res?.body ?? ''), 'utf8');
782
+ const status = Number(res?.status ?? 0);
783
+
784
+ const rendered = method === 'HEAD'
785
+ ? { text: '', omitted: undefined }
786
+ : renderBody(buf, contentType);
787
+
788
+ /**
789
+ * ⚠️⚠️ TWO DIFFERENT CUTS, AND THE FIRST DRAFT LOST ONE OF THEM. The wire cut
790
+ * (the server had more than 256KB) and the display cut (more than 8,000
791
+ * characters reached the transcript) are different facts, and anything over
792
+ * 256KB triggers BOTH. Appending the wire sentence first and then slicing to
793
+ * 8,000 characters deleted it every single time — so the one case the wire cap
794
+ * exists for could never say so. Both sentences are composed after the slice.
795
+ */
796
+ let body = rendered.text;
797
+ let truncated = Boolean(res?.truncated);
798
+ if (body !== null) {
799
+ const tail = [];
800
+ if (body.length > MAX_BODY_CHARS) {
801
+ tail.push(`[call_endpoint showed the first ${MAX_BODY_CHARS} of ${body.length} characters. Ask for a narrower endpoint, or save the response with a script if you need all of it]`);
802
+ body = body.slice(0, MAX_BODY_CHARS);
803
+ truncated = true;
804
+ }
805
+ if (res?.truncated) {
806
+ tail.push(`[call_endpoint stopped reading at ${MAX_BODY_BYTES / 1024}KB — the response was longer than that and the rest was never downloaded]`);
807
+ }
808
+ if (tail.length) body += `\n\n${tail.join('\n')}`;
809
+ }
810
+
811
+ return {
812
+ ok: true,
813
+ url: `http://${usedHost.includes(':') ? `[${usedHost}]` : usedHost}:${target.port}${target.path}`,
814
+ method,
815
+ status,
816
+ statusText: String(res?.statusText ?? ''),
817
+ class: classOf(status),
818
+ headers,
819
+ contentType,
820
+ body,
821
+ bytes: Number.isFinite(res?.bytes) ? res.bytes : buf.length,
822
+ truncated,
823
+ /**
824
+ * ⭐ NAMES ONLY, NEVER VALUES. This is the receipt that an Authorization
825
+ * header really was sent — the fact the model needs to interpret a 401 —
826
+ * without writing the secret it pasted into the transcript a second time.
827
+ */
828
+ sentHeaders: Object.keys(sendHeaders).sort(),
829
+ durationMs,
830
+ ...(rendered.omitted ? { bodyOmitted: rendered.omitted } : {}),
831
+ note: noteFor({ status, statusText: res?.statusText ?? '', method, path: target.path, port: target.port, durationMs, headers }),
832
+ };
833
+ }
834
+
835
+ /** The refusal for "nothing accepted the connection". Its own function so both
836
+ * the v4 and the ::1 attempt end in exactly the same sentence. */
837
+ function refusedMessage(target, method) {
838
+ return `${method} ${target.path} could not connect: NOTHING is listening on port ${target.port} right now. `
839
+ + `${target.owner} announced that port, so it has either not finished booting or it has crashed since. `
840
+ + `This is not a bad request — call check_process {"id":"${target.owner}"} to see whether it is still alive and what it printed, `
841
+ + 'then call again once it is up.';
842
+ }
843
+
844
+ /* ────────────────────────────────────────────────────────────────────────────
845
+ * SCHEMA
846
+ * ──────────────────────────────────────────────────────────────────────────── */
847
+
848
+ export const HTTP_PROBE_TOOL_NAMES = ['call_endpoint'];
849
+
850
+ /**
851
+ * ⚠️ THE DESCRIPTION STATES THE BOUNDARY OUT LOUD, for the reason `fetch_url`'s
852
+ * does: a model that does not know the rule spends one round discovering it and
853
+ * a second arguing with the refusal. What the schema says is the cheapest
854
+ * documentation in the system.
855
+ */
856
+ export function httpProbeToolSchemas() {
857
+ return [
858
+ {
859
+ type: 'function',
860
+ function: {
861
+ name: 'call_endpoint',
862
+ description: [
863
+ 'Make ONE HTTP request to a server YOU started with start_process, and read the real response —',
864
+ 'status, headers and body. This is how you prove that POST /users returns 201 instead of only running',
865
+ 'unit tests you also wrote. A 4xx or 5xx still comes back as a normal result, with a note saying whether',
866
+ 'the request or the handler is at fault.',
867
+ 'IT CAN ONLY REACH A PORT ANNOUNCED BY A PROCESS THIS RUN STARTED: run start_process, then check_process',
868
+ 'to see the port, then call that port. A server you started by hand in another terminal, anything in Docker,',
869
+ 'and every other localhost service is deliberately out of reach — use fetch_url for public URLs.',
870
+ 'You may set any headers except the framing ones (host, content-length, transfer-encoding, connection…);',
871
+ 'authorization and cookie ARE allowed, and nothing is attached for you. Redirects are reported, never followed.',
872
+ `The body is returned as text (JSON pretty-printed) up to ${MAX_BODY_CHARS} characters; a binary response`,
873
+ 'reports its status and headers with the body omitted.',
874
+ ].join(' '),
875
+ parameters: {
876
+ type: 'object',
877
+ properties: {
878
+ url: { type: 'string', description: 'The full local URL, e.g. "http://localhost:3000/users?limit=10". Use this OR port+path, not both.' },
879
+ port: { type: 'integer', description: 'The port check_process reported, e.g. 3000. Use with path.' },
880
+ path: { type: 'string', description: 'The path and query, starting with "/", e.g. "/users" or "/users?limit=10". Defaults to "/".' },
881
+ method: { type: 'string', enum: [...ALLOWED_METHODS], description: 'HTTP method. Defaults to GET.' },
882
+ headers: {
883
+ type: 'object',
884
+ description: 'Request headers, e.g. {"authorization":"Bearer test-token"}. content-type is set for you when you use "json".',
885
+ additionalProperties: { type: 'string' },
886
+ },
887
+ json: { description: 'An object or array to send as a JSON body. Sets content-type: application/json. Use this OR body.' },
888
+ body: { type: 'string', description: 'A raw request body, e.g. form-encoded text. Set content-type yourself. Use this OR json.' },
889
+ timeoutMs: { type: 'integer', description: `How long to wait for an answer, default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.` },
890
+ },
891
+ },
892
+ },
893
+ },
894
+ ];
895
+ }
896
+
897
+ /** Dispatch. Mirrors the shape every other tool module in this package uses. */
898
+ export async function runHttpProbeTool(name, args = {}, { executor } = {}) {
899
+ if (name !== 'call_endpoint') return { ok: false, error: `unknown http tool "${name}"` };
900
+ /**
901
+ * ⚠️ The model's arguments are passed through AS THEY ARE, not coerced and not
902
+ * filtered: `httpProbe` refuses an unknown key by name, and it can only do
903
+ * that if the key reaches it. The seams are not readable from JSON, so a model
904
+ * cannot supply one — and if it somehow did, the type check above refuses it.
905
+ */
906
+ return httpProbe({ ...args, executor });
907
+ }