model-orchestrator 0.1.17 → 0.1.19
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 +34 -1
- package/README.md +1 -1
- package/bin/cli-run.mjs +123 -4
- package/bin/cli.js +10 -1
- package/docs/audit-brief.md +13 -0
- package/package.json +1 -1
- package/src/install.js +34 -7
- package/templates/advanced/vm/jobs/weekly-audit.sh +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.19] - 2026-09-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The `route-gate.mjs` non-regular-file guard is now tested on every OS, not only where `mkfifo` exists.** The FIFO test is the only one that can prove the HANG the guard exists to prevent (a naive `readFileSync` on a writer-less FIFO blocks forever), and Windows has no `mkfifo` to build one, so that test was skipped there and nothing exercised `!st.isFile()` on Windows at all. A directory at the same rules path reaches the same guard before any `open` or `read` call, on every OS, so the guard itself is covered everywhere and the win32 skip is no longer its only coverage.
|
|
12
|
+
- **A guard that refuses an undocumented test skip.** `test/prose.test.js` pins each skip to its file, its exact marker and a phrase the README has to carry, then counts every `{ skip:` in the suite and fails if the totals disagree. Proved in both directions: adding a skip anywhere fails it, and removing a skip's explanation from the README fails it.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **The README's Windows skip list named three of the four skips.** The `mkfifo` skip in `test/hooks.test.js` had never been documented, in the README or the changelog, while the sentence above it read "a few narrow skips remain" and enumerated the rest. A skipped test reads as a test that passed, so an undocumented skip is a coverage claim nobody made deliberately. All four are named now, the conditional `statSync().mode` assertion is labelled as the one-assertion case it is rather than a skipped test, and the sentence says outright that the list is enforced by a test rather than maintained by hand.
|
|
17
|
+
|
|
18
|
+
## [0.1.18] - 2026-09-11
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **The level 3 box's weekly audit could record a hung `--version` probe as a version string instead of "UNVERIFIED: timed out".** `weekly-audit.sh`'s `killtree` killed a hung process's children before the process itself, so in that gap the probe's own shell could print and exit 0, and `bounded()` reported success. Seen once on macOS CI ("codex never" in the report); 0 of 20 local runs reproduced it, so it is rare but real on the box. `killtree` now freezes each process (SIGSTOP) before walking its children, and the watchdog leaves a marker when it fires so `bounded()` returns 124 whatever order the kills land in. A new test forces the bad ordering and checks the timeout still reads as one.
|
|
23
|
+
- **On Windows, `bin/cli-run.mjs` could not run any lane at all: `spawn()` threw `EINVAL` for every `.cmd` binary, which is how npm installs every agent CLI there.** Since Node's fix for CVE-2024-27980 (18.20.2, 20.12.2, and every 22.x), spawning a `.bat`/`.cmd` target without `shell: true` throws instead of silently running it through an unsafely-escaped `cmd.exe`. This was a real, shipped defect, not a test gap: a Windows user following this README could not have run a single lane before this release. `bin/cli-run.mjs` now resolves the `.cmd` shim to the Node script npm's own `cmd-shim` tool wrote underneath it and spawns Node directly on that script (`resolveCmdShim`, `windowsSpawnPlan`), so a prompt (untrusted text this tool does not control) never passes through a shell at all in the common case. A lane whose `.cmd`/`.bat` cannot be resolved that way (an old or hand-edited shim) is refused with exit 13 and a message saying how to fix it, never run through `cmd.exe`: a batch file re-reads its arguments through `%*` after `cmd.exe` has parsed them once, and no escaping fully contains a prompt through both passes. The escaped `cmd.exe` path (the algorithm documented at [qntm.org/cmd](https://qntm.org/cmd) and used by `cross-spawn`, with `windowsVerbatimArguments`) survives only as an explicit opt-in for the installer's own `npm install -g <pinned spec>`, whose arguments never include user text. Verified against the real, byte-for-byte output of `cmd-shim@9.0.2` (the package npm itself uses), not a guessed shape; the escaping is pinned to exact expected strings for `&`, `|`, `^`, `%`, `"`, a trailing backslash and a literal newline in `test/judges.test.js`.
|
|
24
|
+
- **A reconfigure's terminal report (`runtime upgraded:`, `runtime CONFLICT, kept:`, `documents kept:`, and the rest) printed a Windows install's file paths with backslashes**, while every other path this tool prints in generated text uses forward slashes; `src/install.js`'s manifest key was already posix-normalized, but the label built alongside it for the human-readable report was not. `writeFiles()` now builds both from the same posix-normalized value.
|
|
25
|
+
- **A `--dir` outside the project root, or the `vm/` level-3 templates' `INSTALL_DIR`/`INSTALL_DIR_SH`/`INSTALL_DIR_SYSTEMD`, ran through this host's own `path.resolve()`, which reads a leading `/` as drive-relative on win32**, wrong for both: the `vm/` templates describe a REMOTE Linux box (`weekly-audit.sh` is bash, `weekly-audit.service` is a systemd unit, neither of which can run anywhere but Linux), and the outside-project case is documentation prose, not a local filesystem path. An absolute `--dir` given as a bare POSIX path now renders unchanged on every host for both; a real local Windows path (one naming a drive) is untouched, since that case never took this branch.
|
|
26
|
+
- **`killTree`'s win32 branch spawned a bare `taskkill`, which depends on PATH containing `System32`; when it does not, the spawn's `ENOENT` reaches this tool as an unheard `error` event on the returned process and crashes the whole run over what should be a best-effort cleanup step.** Found on `windows-latest` CI the first time a lane actually ran end to end there, once the `EINVAL` fix above stopped hiding it. `taskkillPath()` now resolves the executable under `%SystemRoot%` (falling back through `%windir%` to a fixed path), independent of PATH, and `killTree` attaches an error listener so a spawn failure can never crash the wrapper.
|
|
27
|
+
- **`bin/cli.js`'s own opt-in `npm install -g <ai>` prompt had the identical `EINVAL`-shaped defect as the lane spawn above, in a different file**, since it called `spawnSync('npm', ...)` directly with no shell. It now resolves `npm` with `which()` and runs it through the same `windowsSpawnPlan()` `bin/cli-run.mjs` exports, instead of a second, duplicated fix.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **The three Windows test-skip groups tracked in [#30](https://github.com/aunysillyme/model-orchestrator/issues/30) are gone, replaced by two narrower, individually-justified skips found by actually running the unskipped suite on `windows-latest` CI, plus the one pre-existing skip this pass never touched (`statSync().mode`'s executable bit; NTFS has nothing equivalent).** `test/cli.test.js`'s fake lane binaries now install as an npm-style `.cmd` shim (verified against the real `cmd-shim@9.0.2` output) pointing at a small Node script, the same shape a real vendor CLI's install takes through `windowsSpawnPlan()` above, instead of a bespoke `sh.exe` bridge that never exercised cli-run.mjs's own spawn path at all; every test in that group but one, and the `#12` upgrade-path pair (fixed by the report-label change above), now runs unconditionally. `test/install.test.js`'s 7 formerly-skipped tests split on what their path assertion actually describes: the ones naming a `vm/` remote-box path stay literal POSIX (now true on every host, per the `INSTALL_DIR*` fix above); the ones naming a LOCAL path (where this run wrote files on this host) now build their expectation with `resolve()`/`join()` instead of a hardcoded POSIX literal, so they assert the same real, platform-native value the product renders rather than a string that only happened to match on POSIX. Two of those seven also had their own, separate bug once actually run on Windows: their fake `curl`/`jq`/`node`/`codex` binaries were placed on a hand-built PATH containing the literal strings `/usr/bin` and `/bin`, which name nothing on that OS; they now prepend the stub directory to the REAL `process.env.PATH` (this job already runs under Git Bash, so that PATH already carries what `bash` itself needs) instead of replacing it with a POSIX-only guess.
|
|
32
|
+
- **New skip: a lane dying mid-run from a real POSIX signal genuinely cannot be reproduced on win32.** cli-run.mjs's `r.signal || r.status === null` branch exists for a real lane crashing or being sent a signal, but a real Windows lane is a plain `node <script>` process (via the resolved cmd-shim), so it cannot die "by signal" any more than the product it is testing can; Windows has no OS-level POSIX signals. The only way this file's fixture can even simulate a signal death is a nested `sh -c "...; kill -TERM $$"` (writeShellStub's win32 branch has to bridge through `sh` for the shell body to run at all), which puts an extra node process between cli-run.mjs and the dying shell; measured on `windows-latest` CI, MSYS bash's own self-kill status leaks through as a plain nonzero exit code (3840), which this tool already handles correctly, just under a different, honest verdict (`exit_nonzero`, not `killed`).
|
|
33
|
+
- **`#13` (SIGTERM/SIGINT to the wrapper) is Windows-aware now, not skipped, for both signals: Windows has no OS-level signals at all**, proven on `windows-latest` CI (the wrapper died as `{code: null, signal: sig}` for SIGTERM AND SIGINT alike; a hypothesis that SIGINT gets a real, catchable console-control event there was tried first and measured false in this exact scenario, not assumed). The test now expects an unhandled termination for both signals on win32; the graceful exit-143/130-and-kill-the-lane-first behavior stays a POSIX guarantee, asserted as before on every other OS.
|
|
34
|
+
- **New skip: `#10`'s watchdog-kill test, and the new `#10b` `bounded()` timeout check, for the same reason.** `weekly-audit.sh`'s `bounded()`/`killtree()` rely on `pgrep -P` and killing a backgrounded subshell's process tree, real bash job control this script only ever runs under on the box it targets (a systemd-scheduled job on Ubuntu, never something a Windows user runs locally). Actually executing that watchdog against a genuinely hanging stub under `windows-latest` CI's Git Bash, rather than just rendering and syntax-checking the script (which the rest of this test group does, and which passes), hung past a 20s outer timeout: MSYS's job-control emulation does not reliably propagate a `kill -KILL` to the underlying Windows process tree of a backgrounded `( subshell ) &`, a known class of MSYS/Cygwin limitation, not a defect in the generated script.
|
|
35
|
+
- One test-report-label assertion in `test/install.test.js` and one in `test/cli.test.js` still hardcoded `path.join()`'s native separator for what is now posix-normalized generated text (the report-label fix above); both now match the posix form.
|
|
36
|
+
- `docs/audit-brief.md` gained a section on the Windows spawn path: what runs, why no shell in the common case, and why a lane is refused rather than run through `cmd.exe`, and how the installer's one opt-in `cmd.exe` call escapes its arguments.
|
|
37
|
+
|
|
7
38
|
## [0.1.17] - 2026-09-11
|
|
8
39
|
|
|
9
40
|
### Changed
|
|
@@ -262,7 +293,9 @@ First release.
|
|
|
262
293
|
- Tests: a case per fix, judges proven to go red, mutation checks; `npm test` prints the current count.
|
|
263
294
|
- Adversarial audit: two Codex rounds plus a two-engine review (Codex, Antigravity); findings and fixes in `docs/audit-brief.md`. After the review: subagents go to the project root (`--project`), snippet paths computed from `--dir`, lane sections rendered from the selection, a primary agent required, level 3 asks for API keys separately from CLIs, images and CLI installs pinned, an activation summary at the end of every install.
|
|
264
295
|
|
|
265
|
-
[Unreleased]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.
|
|
296
|
+
[Unreleased]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.19...HEAD
|
|
297
|
+
[0.1.19]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.18...v0.1.19
|
|
298
|
+
[0.1.18]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.17...v0.1.18
|
|
266
299
|
[0.1.17]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.16...v0.1.17
|
|
267
300
|
[0.1.16]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.15...v0.1.16
|
|
268
301
|
[0.1.15]: https://github.com/aunysillyme/model-orchestrator/compare/v0.1.14...v0.1.15
|
package/README.md
CHANGED
|
@@ -260,7 +260,7 @@ Yes. `--yes` with `--level`, `--ais` and `--project` runs headless, `--dry-run`
|
|
|
260
260
|
|
|
261
261
|
## Requirements
|
|
262
262
|
|
|
263
|
-
Node 18 or newer. No dependencies. Works on macOS and Linux; the level 3 box templates assume Ubuntu. Windows: CI runs the suite on `windows-latest` (Node 18, 20, 22). Install, detection, the hooks and `cli-run`'s `taskkill` tree kill are tested there
|
|
263
|
+
Node 18 or newer. No dependencies. Works on macOS and Linux; the level 3 box templates assume Ubuntu. Windows: CI runs the suite on `windows-latest` (Node 18, 20, 22), including lane execution end to end through `cli-run` against a fake CLI installed the same way npm installs a real one (a `.cmd` shim). `cli-run` never runs a lane through `cmd.exe` when it can avoid it: it resolves the shim to the Node script underneath and spawns Node directly, so a prompt reaching a real lane never passes through a Windows shell. A `.cmd` or `.bat` lane that cannot be resolved that way (an old or hand-edited shim) is refused with exit 13 and a message saying how to fix it, rather than run through `cmd.exe`: a batch file re-reads its arguments after `cmd.exe` has parsed them once, and no escaping fully contains a prompt through both passes. Install, detection, the hooks and `cli-run`'s `taskkill` tree kill are tested on Windows too, including SIGTERM/SIGINT to the wrapper (Windows has no OS-level signals: both terminate it unconditionally, verified there rather than treated the same as POSIX). Four narrow skips remain on Windows, each for a POSIX behavior the OS or the CI shell genuinely does not have, and each named here because a test that is quietly skipped reads as a test that passed: `statSync().mode`'s executable bit (NTFS has none, so that one assertion is conditional inside a test that otherwise runs everywhere); a lane dying mid-run from a real POSIX signal (a real Windows lane cannot die "by signal"); running `weekly-audit.sh`'s watchdog functions for real under Git Bash's job control, both the end-to-end run and the `bounded()` timeout check (the script itself only ever runs on the Ubuntu box it targets); and a `mkfifo` FIFO at the rules path, the one case that proves `route-gate.mjs` cannot HANG on a non-regular file, since Windows has no `mkfifo` to build one (the guard behind it is covered on every OS by a directory at the same path). The list is not prose on trust: `test/prose.test.js` counts every `skip:` in the suite and fails if one of them is not documented here.
|
|
264
264
|
|
|
265
265
|
**Privacy.** The installer sends no telemetry and makes no network call of its own once it is running. Two things around that are worth being exact about:
|
|
266
266
|
|
package/bin/cli-run.mjs
CHANGED
|
@@ -322,12 +322,129 @@ export function unfence(text) {
|
|
|
322
322
|
return m ? m[1].trim() : t;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
// --- Windows: spawning a lane without a shell ------------------------------
|
|
326
|
+
// Node's fix for CVE-2024-27980 makes spawn() throw EINVAL for a .bat/.cmd
|
|
327
|
+
// target unless shell:true is set: launching a batch file always goes
|
|
328
|
+
// through cmd.exe, and cmd.exe reads metacharacters (& | ^ < > ( ) % " and
|
|
329
|
+
// space) directly off the command line before the target program's own argv
|
|
330
|
+
// is parsed, even inside quotes. A lane's argv[1] here is a user PROMPT, text
|
|
331
|
+
// this wrapper does not control the contents of, so that is a real injection
|
|
332
|
+
// surface, not a theoretical one.
|
|
333
|
+
//
|
|
334
|
+
// npm installs every CLI on Windows as a "cmd-shim": a short .cmd launcher
|
|
335
|
+
// that hands off to node with a script path (see npm's own `cmd-shim`
|
|
336
|
+
// package). Reading that path out and spawning node directly sidesteps
|
|
337
|
+
// cmd.exe, and the injection surface it carries, entirely: this is the
|
|
338
|
+
// preferred path, used whenever the shim matches the shape cmd-shim writes.
|
|
339
|
+
//
|
|
340
|
+
// A LANE whose .cmd/.bat does not match (hand-written, or an older cmd-shim
|
|
341
|
+
// layout) is refused, not run through cmd.exe: a batch file re-reads its
|
|
342
|
+
// arguments through %* after cmd.exe has already parsed them once, which is
|
|
343
|
+
// the case CVE-2024-27980 is about, and no escaping fully contains user text
|
|
344
|
+
// through both passes. Removing that path beats guarding it.
|
|
345
|
+
//
|
|
346
|
+
// The cmd.exe path survives only for a caller that opts in with
|
|
347
|
+
// { allowCmdFallback: true } and passes arguments it fully controls (the
|
|
348
|
+
// installer's own `npm install -g <pinned spec>`, whose npm.cmd is not a
|
|
349
|
+
// cmd-shim). It uses the caret-escaping algorithm documented at
|
|
350
|
+
// https://qntm.org/cmd and used by `cross-spawn`: quote each argument for
|
|
351
|
+
// CommandLineToArgvW, THEN caret-escape cmd.exe's own metacharacters, THEN
|
|
352
|
+
// pass the whole line with windowsVerbatimArguments so Node does not
|
|
353
|
+
// re-quote it a second, conflicting way.
|
|
354
|
+
const NPM_CMD_SHIM = /"%_prog%"\s+"([^"]+)"\s*%\*/;
|
|
355
|
+
export function resolveCmdShim(cmdPath) {
|
|
356
|
+
let text;
|
|
357
|
+
try {
|
|
358
|
+
text = readFileSync(cmdPath, 'utf8');
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const m = NPM_CMD_SHIM.exec(text);
|
|
363
|
+
if (!m) return null;
|
|
364
|
+
const dp0 = /^%~?dp0%?[\\/]?/i;
|
|
365
|
+
if (!dp0.test(m[1])) return null; // only the %dp0%-relative shape cmd-shim writes
|
|
366
|
+
const rel = m[1].replace(dp0, '').replace(/\\/g, '/');
|
|
367
|
+
let script;
|
|
368
|
+
try {
|
|
369
|
+
script = resolve(dirname(cmdPath), rel);
|
|
370
|
+
if (!statSync(script).isFile()) return null;
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
// Only ever hand off to node for a real JS entry point; anything else (a
|
|
375
|
+
// shim generated for a non-node binary, or a hand-edited file) falls
|
|
376
|
+
// through to the cmd.exe fallback instead of being executed as a script.
|
|
377
|
+
return /\.(m?js|cjs)$/i.test(script) ? script : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function escapeCmdArg(arg) {
|
|
381
|
+
let s = String(arg);
|
|
382
|
+
// A run of backslashes immediately before a quote (or at the very end of
|
|
383
|
+
// the argument) must be doubled, or CommandLineToArgvW on the receiving
|
|
384
|
+
// end eats one; this is the standard Windows argv-quoting rule, not a
|
|
385
|
+
// cmd.exe-specific one.
|
|
386
|
+
s = s.replace(/(\\*)"/g, '$1$1\\"');
|
|
387
|
+
s = s.replace(/(\\*)$/, '$1$1');
|
|
388
|
+
s = `"${s}"`;
|
|
389
|
+
// cmd.exe reads these characters off the raw command line and acts on
|
|
390
|
+
// them (pipe, redirect, chain, subshell, percent-expand, the caret escape
|
|
391
|
+
// itself) whether or not they sit inside a quoted argument.
|
|
392
|
+
return s.replace(/[()%!^"<>&|;, ]/g, '^$&');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildCmdExeCommand(cmdPath, args) {
|
|
396
|
+
return [escapeCmdArg(cmdPath), ...args.map(escapeCmdArg)].join(' ');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Decides what spawn() actually receives. POSIX and a plain .exe/extensionless
|
|
400
|
+
// binary on win32 are unchanged: no shell, argv passed straight through.
|
|
401
|
+
export function windowsSpawnPlan(argv, platform = process.platform, { allowCmdFallback = false } = {}) {
|
|
402
|
+
const [bin, ...args] = argv;
|
|
403
|
+
if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(bin)) {
|
|
404
|
+
return { command: bin, args, options: {} };
|
|
405
|
+
}
|
|
406
|
+
const script = resolveCmdShim(bin);
|
|
407
|
+
if (script) return { command: process.execPath, args: [script, ...args], options: {} };
|
|
408
|
+
if (!allowCmdFallback) {
|
|
409
|
+
return {
|
|
410
|
+
refuse: `${bin} is a batch file that is not a standard npm shim, and cli-run never passes a prompt through cmd.exe. Reinstall the CLI with npm (npm install -g <package>) so npm writes a standard shim, or put the CLI's .exe first on PATH.`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const comspec = process.env.ComSpec || process.env.COMSPEC || 'C:\\Windows\\System32\\cmd.exe';
|
|
414
|
+
return { command: comspec, args: ['/d', '/s', '/c', buildCmdExeCommand(bin, args)], options: { windowsVerbatimArguments: true } };
|
|
415
|
+
}
|
|
416
|
+
|
|
325
417
|
// Kill a lane and everything it spawned. POSIX: the detached process group.
|
|
326
|
-
// Windows has no process groups a signal can reach, so taskkill walks the
|
|
327
|
-
//
|
|
418
|
+
// Windows has no process groups a signal can reach, so taskkill walks the
|
|
419
|
+
// tree (#18): whether the direct child is node (the resolved-shim path) or
|
|
420
|
+
// cmd.exe (the fallback), taskkill /T reaches every descendant either way.
|
|
421
|
+
// taskkill is resolved by an absolute path under SystemRoot rather than a
|
|
422
|
+
// bare command name: this call must not depend on PATH containing
|
|
423
|
+
// System32, which real callers cannot guarantee (this project's own test
|
|
424
|
+
// harness deliberately narrows PATH to isolate a fake lane, and hit
|
|
425
|
+
// exactly this on windows-latest CI: `spawn taskkill ENOENT`) and a
|
|
426
|
+
// sandboxed or otherwise stripped-down environment might not either.
|
|
427
|
+
// %SystemRoot% is the documented, always-set location; %windir% is the
|
|
428
|
+
// older equivalent kept as a fallback; C:\Windows is the last resort.
|
|
429
|
+
export function taskkillPath(env = process.env) {
|
|
430
|
+
// Always a Windows path, built with a literal backslash rather than
|
|
431
|
+
// node:path's join(): join() picks its separator from the HOST running
|
|
432
|
+
// this code, not from the OS the path describes, so on a POSIX host (this
|
|
433
|
+
// test suite runs on all three) it would join with "/" and silently
|
|
434
|
+
// produce a path Windows itself would not recognize as one.
|
|
435
|
+
const root = String(env.SystemRoot || env.windir || 'C:\\Windows').replace(/[\\/]+$/, '');
|
|
436
|
+
return `${root}\\System32\\taskkill.exe`;
|
|
437
|
+
}
|
|
438
|
+
|
|
328
439
|
export function killTree(pid, platform = process.platform, deps = { kill: (p, sig) => process.kill(p, sig), spawn }) {
|
|
329
440
|
if (platform === 'win32') {
|
|
330
|
-
deps.spawn(
|
|
441
|
+
const child = deps.spawn(taskkillPath(), ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
442
|
+
// Fire-and-forget: nothing here awaits taskkill's own exit. But a spawn
|
|
443
|
+
// failure (ENOENT if this host's layout is unusual, EPERM, ...) still
|
|
444
|
+
// emits an async 'error' event on the returned ChildProcess, and Node
|
|
445
|
+
// treats an EventEmitter's unheard 'error' as fatal, crashing the whole
|
|
446
|
+
// wrapper mid-run over what should be a best-effort cleanup step.
|
|
447
|
+
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
331
448
|
return 'taskkill';
|
|
332
449
|
}
|
|
333
450
|
deps.kill(-pid, 'SIGKILL');
|
|
@@ -367,7 +484,9 @@ export function runBounded(argv, timeoutSec, maxBuffer = 16 * 1024 * 1024) {
|
|
|
367
484
|
process.on('SIGINT', onSignal);
|
|
368
485
|
process.on('SIGTERM', onSignal);
|
|
369
486
|
try {
|
|
370
|
-
|
|
487
|
+
const plan = windowsSpawnPlan(argv);
|
|
488
|
+
if (plan.refuse) throw new Error(plan.refuse); // reported as lane unavailable, exit 13
|
|
489
|
+
child = spawn(plan.command, plan.args, { stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', ...plan.options });
|
|
371
490
|
} catch (e) {
|
|
372
491
|
process.off('SIGINT', onSignal);
|
|
373
492
|
process.off('SIGTERM', onSignal);
|
package/bin/cli.js
CHANGED
|
@@ -11,6 +11,12 @@ import { resolve, join } from 'node:path';
|
|
|
11
11
|
import { which } from '../src/detect.js';
|
|
12
12
|
import { AIS, LEVELS, TOOLS, PROVIDERS, aisForLevel, agentCandidates, byId, npmSpec } from '../src/catalog.js';
|
|
13
13
|
import { planFiles, writeFiles, resolveSelection, resolveTools, resolveApis, dirProblems, readManifest, activationSteps, MACHINE_OWNED, RUNTIME, toPosixRel, GENERATOR_VERSION } from '../src/install.js';
|
|
14
|
+
// npm resolves to npm.cmd on Windows; spawning that bare name with no shell
|
|
15
|
+
// hits the same EINVAL bin/cli-run.mjs's lanes did (Node's fix for
|
|
16
|
+
// CVE-2024-27980). windowsSpawnPlan is the same fix reused here rather than
|
|
17
|
+
// duplicated: resolve npm's own cmd-shim and run node on it directly, no
|
|
18
|
+
// shell, or fall back to the escaped cmd.exe path it also provides.
|
|
19
|
+
import { windowsSpawnPlan } from './cli-run.mjs';
|
|
14
20
|
|
|
15
21
|
// One strict parse. Unknown flags, missing values and duplicates are usage
|
|
16
22
|
// errors (exit 2) before anything is planned, so a typo like --dryy can never
|
|
@@ -354,7 +360,10 @@ async function main() {
|
|
|
354
360
|
const spec = npmSpec(a); // the same pinned spec the table and the box script use
|
|
355
361
|
const run = flag('no-install') || yes ? 'n' : await ask(` ${a.name}: run \`npm install -g ${spec}\` now? [y/N]: `, 'n');
|
|
356
362
|
if (/^y/i.test(run)) {
|
|
357
|
-
|
|
363
|
+
// Opt-in cmd.exe fallback: npm.cmd is not a cmd-shim, and every argument here
|
|
364
|
+
// is the catalog's pinned spec, never user text (lanes refuse this path).
|
|
365
|
+
const plan = windowsSpawnPlan([which('npm') || 'npm', 'install', '-g', spec], process.platform, { allowCmdFallback: true });
|
|
366
|
+
const r = spawnSync(plan.command, plan.args, { stdio: 'inherit', ...plan.options });
|
|
358
367
|
console.log(r.status === 0 ? ` installed ${spec}` : ` npm exited ${r.status}; install it by hand`);
|
|
359
368
|
} else {
|
|
360
369
|
console.log(` ${a.name}: npm install -g ${spec} (pinned to the version this installer was released with)`);
|
package/docs/audit-brief.md
CHANGED
|
@@ -113,3 +113,16 @@ Three findings reproduced against the 0.1.15 branch before it shipped, none of t
|
|
|
113
113
|
- **Fail-open, on purpose.** Every code path that can fail (a malformed state file, a full disk, a rotation race, invalid JSON on stdin, an unrecognized event) is caught and produces no record rather than a thrown error or a non-zero exit; the process always exits 0. A miss here is a missing line in a telemetry log, never a blocked turn, so there is nothing to gate.
|
|
114
114
|
- **Bounded.** Stdin is drained asynchronously against a combined 1s time cap and 8 MB size cap; a payload that exceeds either is treated as truncated and parsed as nothing, never partially. `--summary` reads the log directly (never spawns anything, never executes a line in it).
|
|
115
115
|
- **Not yet attacked.** Untested here: two processes racing the same rotation at once (a rename plus an append landing on the same file); a state directory with thousands of leaked files from a long-lived session with a crashed hook (pruning runs, but only on `SubagentStart`, so an install that never starts a subagent again would never prune); behavior if `agent_id` collides across two concurrent subagents (sha256 makes this astronomically unlikely, not impossible).
|
|
116
|
+
|
|
117
|
+
## New in 0.1.18: the Windows spawn path
|
|
118
|
+
|
|
119
|
+
`bin/cli-run.mjs` runs a lane's binary through `windowsSpawnPlan()` before every `spawn()` call. On POSIX, and for a plain `.exe` or extensionless binary on Windows, this is a no-op: the same argv reaches `spawn()` with no shell, exactly as before. What changed is the two shapes Windows can hand it that used to reach `spawn()` unchanged and throw `EINVAL` (Node's fix for CVE-2024-27980: a `.bat`/`.cmd` target without `shell: true` is refused rather than run through an unsafely-escaped `cmd.exe`).
|
|
120
|
+
|
|
121
|
+
- **What runs, in order.** `resolveCmdShim(cmdPath)` reads the `.cmd` file and looks for the exact line npm's `cmd-shim` package writes: `"%_prog%" ... "<path>" %*`, where `<path>` is `%dp0%`-relative (verified against the real, byte-for-byte output of `cmd-shim@9.0.2`, the package npm itself uses to write a shim from a package.json `bin` entry with a `#!/usr/bin/env node` shebang; `test/judges.test.js` pins that exact fixture). If it matches, the `%dp0%`-relative path is resolved against the `.cmd` file's own directory and checked with `statSync` (must exist, must be a file, must end in `.js`/`.mjs`/`.cjs`); on success, `windowsSpawnPlan()` returns `{ command: process.execPath, args: [scriptPath, ...args] }`, and `spawn()` runs `node <script> <args>` directly. A lane's prompt (argv[1] and on) is text this tool does not control the contents of; this path never puts it anywhere a shell parses it.
|
|
122
|
+
- **Why no shell, ever, for a lane.** Every real lane (grok, codex, agy, hermes, qwen) is an npm-installed Node CLI, so on a real Windows install the resolved-shim branch is the one every run takes. If `resolveCmdShim` returns nothing for a lane (an old cmd-shim layout, a hand-written `.cmd`, or a `.bat`), `windowsSpawnPlan()` returns `{ refuse }` and `cli-run` reports the lane unavailable (exit 13) with a message saying how to fix it. It does not fall back to `cmd.exe`: a batch file re-reads its arguments through `%*` after `cmd.exe` has parsed them once, which is the case CVE-2024-27980 is about, and no escaping fully contains user text through both passes. Removing that path was chosen over guarding it.
|
|
123
|
+
- **The one opt-in `cmd.exe` path, and how its arguments are escaped.** Only a caller passing `{ allowCmdFallback: true }` with arguments it fully controls gets the `cmd.exe` path: today that is the installer's own `npm install -g <pinned spec>` (`npm.cmd` is not a cmd-shim, and every argument comes from the catalog, none from a user). For that caller, `windowsSpawnPlan()` builds one command-line string with `escapeCmdArg`/`buildCmdExeCommand` and returns `{ command: <ComSpec>, args: ['/d', '/s', '/c', <built string>], options: { windowsVerbatimArguments: true } }`. The algorithm is the one documented at [qntm.org/cmd](https://qntm.org/cmd) (the reference writeup of `cmd.exe`'s quoting behavior) and used by the widely-deployed `cross-spawn` package: each argument is quoted the way `CommandLineToArgvW` expects (backslash-doubling before an embedded quote or at the end of the string, then wrapped in `"`), and THEN every `cmd.exe` metacharacter in that quoted text (`( ) % ! ^ " < > & | ; ,` and space) is caret-escaped, because `cmd.exe`'s own line scanner reads those characters off the raw command line before the quoting is honored, quote or no quote. `windowsVerbatimArguments: true` tells Node not to re-quote the string a second, conflicting way. `test/judges.test.js` pins exact expected output for `&`, `|`, `^`, `%`, a literal `"`, a trailing backslash and a literal newline (the last one deliberately unescaped: it is not a `cmd.exe` metacharacter).
|
|
124
|
+
- **`killTree` needs no change for which process it targets, either path.** `taskkill /pid <pid> /T /F` walks the whole descendant tree regardless of whether the direct child is `node` (the resolved-shim path) or `cmd.exe` (the fallback); there is no intermediate shell layer to lose track of in the common case, since there is no shell there at all. It DID need a change for how `taskkill` itself is found: `windows-latest` CI caught a bare `spawn('taskkill', ...)` failing `ENOENT` the first time a lane actually ran end to end there (this project's own test harness deliberately narrows PATH to isolate a fake lane, and that narrowed PATH does not include `System32`; a sandboxed or otherwise stripped-down real environment might not either), and the resulting unheard `error` event on the returned process crashed the whole run over what should be a best-effort cleanup step. `taskkillPath()` resolves the executable under `%SystemRoot%` (falling back through `%windir%` to a fixed path) instead of relying on PATH, built with a literal backslash rather than `node:path`'s `join()`, which picks its separator from the HOST running the code, not the OS the path describes; `killTree` now attaches an `error` listener so any future spawn failure stays a missed cleanup, never a crash.
|
|
125
|
+
- **`bin/cli.js`'s own `npm install -g` prompt reuses this, rather than duplicating it.** The installer's opt-in "run `npm install -g <ai>` now?" prompt had the identical `EINVAL`-shaped defect (`spawnSync('npm', ...)` with no shell), found the same way: it failed the moment its own test actually ran on `windows-latest`. It now resolves `npm` with `which()` and calls `windowsSpawnPlan()`, the same function above, instead of a second copy of the fix.
|
|
126
|
+
- **Not yet attacked for real.** `windowsSpawnPlan`, `resolveCmdShim` and the escaping functions are unit-tested (pure string logic, runs on every CI host) and the resolved-shim path is exercised end to end on `windows-latest` through the fake-lane fixtures in `test/cli.test.js` (installed as a real npm-style `.cmd` shim). A lane can no longer reach `cmd.exe` at all (a unit test pins the refusal). The opt-in `cmd.exe` branch, used only by the installer's own `npm install -g`, is not exercised end to end through a live Windows process in this suite; its escaping is proven by exact-string unit tests only, and its arguments never include user text.
|
|
127
|
+
- **A platform limit found the same way, unrelated to the spawn path itself: Windows has no OS-level signals at all.** `cli-run.mjs`'s graceful shutdown (`process.on('SIGTERM', ...)`, kill the lane's process group, then exit 143/130) is a POSIX guarantee only: `ChildProcess.kill(sig)` on Windows calls `TerminateProcess()` unconditionally for SIGTERM AND SIGINT alike, giving the target process no chance to run any handler at all, proven on `windows-latest` CI (the wrapper died as `{code: null, signal: sig}` for both; a hypothesis that SIGINT gets a real, catchable console-control event on Windows was tried first and measured false in this exact scenario, not assumed). `test/cli.test.js`'s `#13` now expects an unhandled termination for either signal on win32, and the original graceful-exit assertion elsewhere.
|
|
128
|
+
- **Two narrow, individually-verified Windows skips remain, neither in the spawn path itself.** (1) A lane dying mid-run from a real POSIX signal cannot be reproduced on win32: a real Windows lane is a plain `node <script>` process, so it cannot die "by signal" any more than the product being tested can, and the only way a test fixture can even simulate one (a nested `sh -c "...; kill -TERM $$"`) puts an extra node process between cli-run.mjs and the dying shell, so cli-run.mjs observes only that node's translated exit code (measured: MSYS bash's self-kill status leaks through as a plain nonzero exit code, 3840, which this tool already handles honestly via `exit_nonzero`). (2) `weekly-audit.sh`'s watchdog (`bounded()`/`killtree()`, `pgrep -P` plus killing a backgrounded subshell's tree) relies on real bash job control this script only ever runs under on the Ubuntu box it targets; actually executing it against a genuinely hanging stub under Git Bash's job-control emulation hung past a 20s outer timeout on `windows-latest` CI, a known class of MSYS/Cygwin limitation (a `kill -KILL` not reliably reaching the underlying Windows process tree of a backgrounded subshell), not a defect in the generated script, which still renders and syntax-checks correctly.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-orchestrator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Model orchestrator for AI coding agents and LLMs: Claude Code, Codex, Gemini, Grok, Qwen, Ollama. Routing rules tell your agent which model, subagent or CLI to use for each task, so small work goes to cheap tiers and fewer tokens go to frontier models. One installer, plus a CLI runner that logs every route.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/install.js
CHANGED
|
@@ -405,9 +405,30 @@ function vars(opts) {
|
|
|
405
405
|
const codecalc = tools.some((t) => t.id === 'codecalc');
|
|
406
406
|
const dirAbs = resolve(opts.dir || 'ai-orchestrator');
|
|
407
407
|
const projectAbs = resolve(opts.project || process.cwd());
|
|
408
|
+
// dirPosix backs two things that must read the same on every host:
|
|
409
|
+
// 1. INSTALL_DIR / INSTALL_DIR_SH / INSTALL_DIR_SYSTEMD (below), rendered
|
|
410
|
+
// into vm/jobs/weekly-audit.sh (bash) and vm/jobs/weekly-audit.service
|
|
411
|
+
// (a systemd unit) for the REMOTE Linux box, neither of which can run
|
|
412
|
+
// anywhere but Linux;
|
|
413
|
+
// 2. rulesPath below when --dir falls outside --project, which is
|
|
414
|
+
// prose in generated markdown ("a dir outside the project renders an
|
|
415
|
+
// absolute path"), not a filesystem call.
|
|
416
|
+
// An absolute --dir given as a bare POSIX path ("/opt/x") is never
|
|
417
|
+
// re-resolved through this host's own path semantics for either: a real
|
|
418
|
+
// Windows path always names a drive ("C:\...", caught by the `startsWith`
|
|
419
|
+
// check below falling through to dirAbs), so a bare "/opt/x" only ever
|
|
420
|
+
// means "a Linux path, or documentation text, verbatim" - resolving it
|
|
421
|
+
// with plain path.resolve() reads that leading "/" as drive-relative on
|
|
422
|
+
// win32 and silently turns it into a local path that does not exist,
|
|
423
|
+
// on the box or in the doc. A relative --dir resolves against this
|
|
424
|
+
// host's cwd exactly as before, which is already correct in the common
|
|
425
|
+
// case: level 3 is normally installed by running this CLI ON the box,
|
|
426
|
+
// where "this host" and "the box" are the same filesystem.
|
|
427
|
+
const rawDir = opts.dir || 'ai-orchestrator';
|
|
428
|
+
const dirPosix = rawDir.startsWith('/') ? posix.normalize(rawDir) : dirAbs;
|
|
408
429
|
let rulesPath = relative(projectAbs, dirAbs).split(sep).join(posix.sep);
|
|
409
430
|
if (rulesPath === '') rulesPath = '.';
|
|
410
|
-
else if (rulesPath.startsWith('..')) rulesPath =
|
|
431
|
+
else if (rulesPath.startsWith('..')) rulesPath = dirPosix; // outside the project: absolute is the only honest path
|
|
411
432
|
const pinOf = (id) => (toolById[id] && toolById[id].pin) || 'latest';
|
|
412
433
|
const snippet = snippetFor(primary);
|
|
413
434
|
const steps = activationSteps({ level, selected, primary, tools, dir: opts.dir, project: opts.project });
|
|
@@ -416,7 +437,7 @@ function vars(opts) {
|
|
|
416
437
|
// The path route-gate.mjs and subagent-context.mjs resolve at runtime,
|
|
417
438
|
// relative to CLAUDE_PROJECT_DIR. Mirrors the RULES_PATH fallback below:
|
|
418
439
|
// outside the project, the honest path is absolute, never a hardcoded one.
|
|
419
|
-
const relJoin = (name) => (rulesPath ===
|
|
440
|
+
const relJoin = (name) => (rulesPath === dirPosix ? posix.join(dirPosix, name) : rulesPath === '.' ? name : rulesPath + '/' + name);
|
|
420
441
|
const rulesFileRel = relJoin(routingFile);
|
|
421
442
|
const taskBundleRel = relJoin('TASK_BUNDLE.md');
|
|
422
443
|
// Only claude-code and agy put files under the project root. A chat primary
|
|
@@ -449,9 +470,9 @@ function vars(opts) {
|
|
|
449
470
|
CODECALC_PIN: pinOf('codecalc'),
|
|
450
471
|
OBSIDIAN_TC_PIN: pinOf('obsidian-tc'),
|
|
451
472
|
APIS_LIST: apis.length ? apis.map((prov) => '- ' + prov.name + ' (`' + prov.envName + '`)').join('\n') : '- none: no metered provider key was selected, so the gateway serves only a local lane if you picked one',
|
|
452
|
-
INSTALL_DIR:
|
|
453
|
-
INSTALL_DIR_SH: shellQuote(
|
|
454
|
-
INSTALL_DIR_SYSTEMD: systemdEscape(
|
|
473
|
+
INSTALL_DIR: dirPosix,
|
|
474
|
+
INSTALL_DIR_SH: shellQuote(dirPosix),
|
|
475
|
+
INSTALL_DIR_SYSTEMD: systemdEscape(dirPosix),
|
|
455
476
|
// vm/README.md step 3 named `grok login` and `agy` whatever you picked (#26).
|
|
456
477
|
VM_SIGNIN: (() => {
|
|
457
478
|
const lines = selected.filter((a) => a.bin && a.kind === 'agent-cli').map((a) => ` - ${a.name}: ${a.auth}`);
|
|
@@ -784,8 +805,14 @@ export function writeFiles(files, opts) {
|
|
|
784
805
|
for (const f of groups[k]) {
|
|
785
806
|
const abs = resolve(root, f.rel);
|
|
786
807
|
const exists = existsSync(abs);
|
|
787
|
-
|
|
788
|
-
|
|
808
|
+
// label is what reaches the terminal report (bin/cli.js's "runtime
|
|
809
|
+
// upgraded:", "runtime CONFLICT, kept:", etc lines): posix-normalized
|
|
810
|
+
// like key, below, so the report reads the same on every host. Before
|
|
811
|
+
// this it carried f.rel verbatim, which is native-separated (join()),
|
|
812
|
+
// so on win32 the report named "bin\cli-run.mjs" while everything
|
|
813
|
+
// else in this tool (docs, other path prose) uses forward slashes.
|
|
814
|
+
const label = (k === 'project' ? '[project] ' : '') + f.rel.split(sep).join('/');
|
|
815
|
+
const key = label;
|
|
789
816
|
const cls = k === 'dir' ? fileClass(f.rel) : 'document';
|
|
790
817
|
if (exists && !force) {
|
|
791
818
|
if (cls === 'document') {
|
|
@@ -31,17 +31,27 @@ find reports -maxdepth 1 -name '.audit-*' -type f -mtime +0 -delete 2>/dev/null
|
|
|
31
31
|
# pipe open). Process groups do not help here: bash disables job control inside
|
|
32
32
|
# pipeline subshells, so `kill -- -pid` would kill nothing. A recursive tree
|
|
33
33
|
# kill via pgrep works on macOS and Linux alike; `timeout(1)` is not on macOS.
|
|
34
|
+
#
|
|
35
|
+
# Two things make a timeout always read as a timeout. killtree freezes each
|
|
36
|
+
# process (SIGSTOP) before walking its children, so a parent cannot run on,
|
|
37
|
+
# print, and exit 0 in the gap after its child dies. And the watchdog leaves a
|
|
38
|
+
# marker when it fires, so bounded returns 124 whatever order the kills land
|
|
39
|
+
# in. Without both, a hung `--version` probe could be recorded as a version
|
|
40
|
+
# string instead of "UNVERIFIED: timed out" (seen once on macOS CI).
|
|
34
41
|
killtree() {
|
|
35
42
|
local p="$1" c
|
|
43
|
+
kill -STOP "$p" 2>/dev/null
|
|
36
44
|
for c in $(pgrep -P "$p" 2>/dev/null); do killtree "$c"; done
|
|
37
45
|
kill -KILL "$p" 2>/dev/null
|
|
38
46
|
}
|
|
39
47
|
bounded() {
|
|
40
48
|
local secs="$1"; shift
|
|
49
|
+
local fired; fired="$(mktemp "${TMPDIR:-/tmp}/wa-fired.XXXXXX" 2>/dev/null)" && rm -f "$fired"
|
|
41
50
|
( "$@" ) & local pid=$!
|
|
42
|
-
( sleep "$secs"; killtree "$pid" ) >/dev/null 2>&1 & local wd=$!
|
|
51
|
+
( sleep "$secs"; [ -n "$fired" ] && : > "$fired"; killtree "$pid" ) >/dev/null 2>&1 & local wd=$!
|
|
43
52
|
wait "$pid" 2>/dev/null; local rc=$?
|
|
44
53
|
killtree "$wd" >/dev/null 2>&1; wait "$wd" 2>/dev/null
|
|
54
|
+
if [ -n "$fired" ] && [ -e "$fired" ]; then rm -f "$fired"; return 124; fi
|
|
45
55
|
return $rc
|
|
46
56
|
}
|
|
47
57
|
|