diff --git a/crates/skills/tests/launcher_contract_tests.rs b/crates/skills/tests/launcher_contract_tests.rs index ccacf160c..1e9b86c14 100644 --- a/crates/skills/tests/launcher_contract_tests.rs +++ b/crates/skills/tests/launcher_contract_tests.rs @@ -57,6 +57,28 @@ fn cmd_launcher_asset_naming_matches_engine() { assert!(!cmd.contains("npm i -g")); } +#[test] +fn cmd_launcher_forwards_engine_exit_code() { + // Bare `exit /b` drops the process exit code when this file is cmd.exe's + // entry point (cmd /c, PowerShell, Node spawn). Forward %errorlevel% + // after each engine invocation instead. + let cmd = launcher_file("impeccable.cmd"); + for (i, line) in cmd.lines().enumerate() { + assert_ne!( + line.trim(), + "exit /b", + "impeccable.cmd line {}: bare exit /b drops the process code: {line}", + i + 1 + ); + } + let forward = "exit /b %errorlevel%"; + assert_eq!( + cmd.matches(forward).count(), + 2, + "PATH candidate and :run must both forward the engine exit code" + ); +} + #[test] fn cmd_launcher_has_no_multiline_parenthesized_blocks() { // cmd.exe expands %var% inside a parenthesized block at parse time, so a diff --git a/skill/scripts/impeccable.cmd b/skill/scripts/impeccable.cmd index 7ebe419bf..dc0fd33e5 100644 --- a/skill/scripts/impeccable.cmd +++ b/skill/scripts/impeccable.cmd @@ -59,7 +59,7 @@ if errorlevel 1 goto download call :probe impeccable if not "%probe_ok%"=="1" goto download impeccable %* -exit /b +exit /b %errorlevel% :download rem Last resort: fetch this version's binary from the release channel into @@ -166,7 +166,7 @@ exit /b 127 :run "%run%" %* -exit /b +exit /b %errorlevel% :probe rem Sets probe_ok=1 when %1 answers the engine handshake: prints diff --git a/tests/launcher-download.test.mjs b/tests/launcher-download.test.mjs index 862731075..0f0621f4f 100644 --- a/tests/launcher-download.test.mjs +++ b/tests/launcher-download.test.mjs @@ -174,6 +174,34 @@ test('launcher downloads and runs a verified executable', async t => { assert.equal(result.requests.length, 2); }); +test('cmd launcher forwards engine exit code through cmd /c', { skip: WINDOWS ? false : 'Windows-only cmd /c exit-code forwarding' }, async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-launcher-exit-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const home = path.join(root, 'home'); + fs.mkdirSync(home); + const launcher = path.join(root, 'impeccable.cmd'); + fs.copyFileSync(path.join(ROOT, 'skill/scripts/impeccable.cmd'), launcher); + const env = { + PATH: `${process.env.SystemRoot}\\System32;${process.env.SystemRoot}`, + HOME: home, USERPROFILE: home, TEMP: root, TMP: root, + IMPECCABLE_HOME: path.join(root, 'cache'), + IMPECCABLE_BIN: COMSPEC, + SystemRoot: process.env.SystemRoot, + ComSpec: COMSPEC, + PROCESSOR_ARCHITECTURE: 'AMD64', + }; + const run = (args) => new Promise((resolve, reject) => { + const child = spawn(COMSPEC, ['/d', '/s', '/c', `""${launcher}" ${args}"`], { env, cwd: root, windowsVerbatimArguments: true, timeout: 20000 }); + child.on('error', reject); + child.on('close', (status, signal) => resolve({ status, signal })); + }); + for (const [args, expected] of [['/c exit 2', 2], ['/c exit 1', 1], ['/c exit 0', 0]]) { + const result = await run(args); + assert.equal(result.signal, null, JSON.stringify(result)); + assert.equal(result.status, expected, args); + } +}); + for (const scenario of ['removed', 'emptied', 'empty-download', 'no-sidecar', 'empty-sidecar', 'mismatch', 'hash-failure', 'removed-during-hash', 'removed-before-move', 'removed-after-move', 'emptied-after-move', 'move-failure']) { test(`launcher refuses ${scenario} with an accurate diagnostic`, async t => { const result = await exercise(t, scenario);