igneum/tools/ci/windows-spawn-check.mjs
igneum-labs 2bd168bf7d app: every elevated launch through one hidden-console builder; CI check for Windows spawns; PC 1 console watchers
The console-window class (the project lead, 5 October 2026: "Windows Command Processor" windows on PC 1 whenever a remote job runs).
Measured on PC 1 (ae432dc7, Windows 11 Pro 26200, default terminal "Let Windows decide" = Windows Terminal 1.24) with
tools/windows/console-watch.ps1 (job run-20261005-182528): no child a job script starts from the app's headless
console opens a window (powershell, cmd, query, curl, nvidia-smi, wsl --status, a distro, interop cmd and powershell,
powershell -WindowStyle Hidden: 0 windows each); Start-Process in a new console opens a Terminal window (the known-failed
case: 2 windows), the same with -WindowStyle Hidden opens none (the known-finished case). The elevated path
(Start-Process -Verb RunAs -WindowStyle Hidden through the AppInfo service) is the one road left; its watcher
(console-watch-elevated.ps1, job run-20261005-184610) was cancelled at the UAC prompt.

- platform.rs: elevated_ps_line + elevated_command build the one PowerShell line every elevated launch uses (the NVIDIA
  power cap, the sweep helper, the clock sync, an elevated remote job), -WindowStyle Hidden by construction; unit
  tests on the line, the quoting and the Command.
- jobrun.rs: the elevated job path uses it; the relaunch helper's Start-Process carries the reason it has no
  -WindowStyle Hidden (igneum-app.exe is a windows-subsystem program).
- tools/ci/windows-spawn-check.mjs (+ ci.yml): fails when a Command::new in app/igneum-app/src is not quieted,
  a creation_flags is not CREATE_NO_WINDOW alone, a Start-Process the Rust code writes lacks -WindowStyle Hidden or
  -NoNewWindow, or host.cpp spawns without CREATE_NO_WINDOW / SW_HIDE; self-test on known-good and known-bad samples.
- tools/windows/console-watch.ps1, console-watch-bg.ps1, console-watch-elevated.ps1: the watchers (run jobs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 08:21:46 +00:00

106 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
// The console-window class (5 October 2026, PC 1): a child the app starts on Windows without CREATE_NO_WINDOW, or an
// elevated child started without SW_HIDE, gets a console of its own, and on Windows 11 with Windows Terminal as the
// default terminal that console is a visible Terminal window on the user's desk. Rule: every process the app starts
// on Windows runs with a hidden console. This check fails CI when
// - a `Command::new(` in app/igneum-app/src is not quieted within 20 lines: crate::platform::quiet, run_timeout,
// run_capture, run_streamed, spawn_detached, elevated_command, or creation_flags(0x0800_0000) (CREATE_NO_WINDOW);
// programs that only exist off Windows (nohup, osascript, pkexec, hdiutil, ...) are allowed, and a
// `// console: <reason>` comment on the line or the line above allows a builder the caller quiets;
// the check looks 2 lines back as well, for `run_timeout(\n Command::new(...)`;
// - a `creation_flags(` carries anything but 0x0800_0000 (DETACHED_PROCESS made powershell exit at start-up,
// 0.3.0 to 0.3.4, docs/bugs.md);
// - a PowerShell `Start-Process` written by the Rust code lacks `-WindowStyle Hidden` or `-NoNewWindow` (a GUI
// program, which never gets a console, takes a `# console: <reason>` comment on the line or the line above);
// - app/windows/host.cpp calls CreateProcessW without CREATE_NO_WINDOW or sets up a ShellExecuteExW without
// nShow = SW_HIDE (ShellExecuteW "open" of a URL is the browser, allowed).
// node tools/ci/windows-spawn-check.mjs the tree
// node tools/ci/windows-spawn-check.mjs --self-test the rules on known-good and known-bad samples
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const QUIET = /\bquiet\(|\brun_timeout\(|\brun_capture\(|\brun_streamed\(|\bspawn_detached\(|\belevated_command\(|creation_flags\(0x0800_0000\)/;
const OFF_WINDOWS = /Command::new\((?:crate::platform::)?tool\("(?:nohup|osascript|pkexec|hdiutil|ditto|open|xattr|system_profiler|sysctl|sntp|scutil|caffeinate)"\)|Command::new\("(?:pkexec|xdg-open|\/bin\/bash|\/usr\/bin\/[a-z]+)"\)|Command::new\(staged\.join\("Contents\/MacOS/;
const WINDOW = 20;
export function checkRust(text, file) {
const lines = text.split('\n');
const out = [];
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (/Command::new\(/.test(l) && !/^\s*\/\//.test(l)) {
const allowed = OFF_WINDOWS.test(l) || /\/\/ console:/.test(l) || (i > 0 && /\/\/ console:/.test(lines[i - 1]));
if (!allowed) {
const span = lines.slice(Math.max(0, i - 2), i + WINDOW).join('\n'); // 2 lines back: run_timeout(\n Command::new(...)
if (!QUIET.test(span)) out.push(`${file}:${i + 1}: Command::new without a hidden console within ${WINDOW} lines (quiet, run_timeout, run_capture, run_streamed, spawn_detached, elevated_command or creation_flags(0x0800_0000)); add one, or a '// console: <reason>' comment`);
}
}
const cf = /creation_flags\(([^)]*)\)/.exec(l);
if (cf && cf[1].trim() !== '0x0800_0000') out.push(`${file}:${i + 1}: creation_flags(${cf[1]}) is not CREATE_NO_WINDOW alone (0x0800_0000)`);
if (/Start-Process\b/.test(l) && !/^\s*\/\//.test(l) && !/-WindowStyle Hidden|-NoNewWindow/.test(l) && !/(\/\/|#) console:/.test(l) && !(i > 0 && /(\/\/|#) console:/.test(lines[i - 1]))) out.push(`${file}:${i + 1}: Start-Process without -WindowStyle Hidden or -NoNewWindow (a GUI program takes a '# console: <reason>' comment)`);
}
return out;
}
export function checkHost(text, file) {
const lines = text.split('\n');
const out = [];
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (/CreateProcessW?\s*\(/.test(l) && !/CREATE_NO_WINDOW/.test(lines.slice(i, i + 3).join('\n'))) out.push(`${file}:${i + 1}: CreateProcess without CREATE_NO_WINDOW`);
if (/ShellExecuteExW?\s*\(/.test(l) && !/nShow\s*=\s*SW_HIDE/.test(lines.slice(Math.max(0, i - 12), i + 1).join('\n'))) out.push(`${file}:${i + 1}: ShellExecuteEx without nShow = SW_HIDE in the 12 lines before it`);
if (/ShellExecuteW?\s*\(/.test(l) && !/ShellExecuteExW?/.test(l) && !/L"open"/.test(l)) out.push(`${file}:${i + 1}: ShellExecute that is not the browser "open" of a URL`);
}
return out;
}
function walk(dir, ext, acc = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e);
if (statSync(p).isDirectory()) { if (e !== 'target') walk(p, ext, acc); } else if (p.endsWith(ext)) acc.push(p);
}
return acc;
}
function selfTest() {
const good = `fn a() {\n let mut c = Command::new(crate::platform::tool("powershell"));\n c.args(["-NoProfile"]);\n crate::platform::quiet(&mut c);\n c.spawn();\n}\n`;
const bad = `fn a() {\n let mut c = Command::new(crate::platform::tool("powershell"));\n c.args(["-NoProfile"]);\n c.spawn();\n}\n`;
const badFlag = `c.creation_flags(0x0000_0008);\n`;
const badPs = `let ps = format!("Start-Process -FilePath '{}' -Wait", exe);\n`;
const okPs = `let ps = format!("Start-Process -FilePath '{}' -Wait -WindowStyle Hidden", exe);\n`;
const offWin = `let out = Command::new(tool("osascript")).args(["-e", "x"]).output();\n`;
const allowed = `// console: the caller quiets it\nlet mut c = Command::new(wsl_exe);\n`;
const hostGood = `sei.nShow = SW_HIDE;\nif (!ShellExecuteExW(&sei)) {}\nCreateProcessW(exe, buf, nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, dir, &si, &pi);\nShellExecuteW(nullptr, L"open", url, nullptr, nullptr, SW_SHOWNORMAL);\n`;
const hostBad = `sei.nShow = SW_SHOW;\nif (!ShellExecuteExW(&sei)) {}\nCreateProcessW(exe, buf, nullptr, nullptr, TRUE, 0, nullptr, dir, &si, &pi);\n`;
const cases = [
['quieted Command', checkRust(good, 't.rs').length === 0],
['bare Command fails', checkRust(bad, 't.rs').length === 1],
['DETACHED_PROCESS fails', checkRust(badFlag, 't.rs').length === 1],
['Start-Process without Hidden fails', checkRust(badPs, 't.rs').length === 1],
['Start-Process with Hidden passes', checkRust(okPs, 't.rs').length === 0],
['off-Windows program passes', checkRust(offWin, 't.rs').length === 0],
['console: comment passes', checkRust(allowed, 't.rs').length === 0],
['host.cpp good passes', checkHost(hostGood, 'h.cpp').length === 0],
['host.cpp bad fails twice', checkHost(hostBad, 'h.cpp').length === 2],
];
let fail = 0;
for (const [name, ok] of cases) { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}`); if (!ok) fail++; }
return fail;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (process.argv.includes('--self-test')) {
const f = selfTest();
console.log(f ? `windows-spawn: self-test FAILED (${f})` : 'windows-spawn: self-test ok');
process.exit(f ? 1 : 0);
}
const problems = [];
for (const f of walk(join(ROOT, 'app', 'igneum-app', 'src'), '.rs')) problems.push(...checkRust(readFileSync(f, 'utf8'), relative(ROOT, f)));
const host = join(ROOT, 'app', 'windows', 'host.cpp');
try { problems.push(...checkHost(readFileSync(host, 'utf8'), relative(ROOT, host))); } catch {}
for (const p of problems) console.log(p);
console.log(problems.length ? `windows-spawn: ${problems.length} spawn(s) without a hidden console` : 'windows-spawn: every Windows spawn in app/igneum-app/src and app/windows/host.cpp runs with a hidden console');
process.exit(problems.length ? 1 : 0);
}