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,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
|
+
}
|