From b808dad0e52c83462bb08c0266cd2ce36dc228cf Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:43:03 +0200 Subject: [PATCH] Apply stable CLI failure exit codes --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 106 ++++++++++++++++++++++++++-- scripts/acceptance-private.sh | 1 + scripts/acceptance-public.sh | 1 + scripts/cli-error-exit-smoke.js | 93 ++++++++++++++++++++++++ scripts/cli-first-contract-smoke.js | 21 ++++++ scripts/docs-smoke.js | 4 ++ scripts/verify-public-split.sh | 1 + 8 files changed, 224 insertions(+), 7 deletions(-) create mode 100755 scripts/cli-error-exit-smoke.js diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 0725257..655be2c 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "3d37a45472228c553477f8462927dfa6a6622882", - "release_name": "dryrun-3d37a4547222", + "source_commit": "143d109ea0261671012be8ef6b48e86284601eec", + "release_name": "dryrun-143d109ea026", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 5350d04..8c56953 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1676,12 +1676,16 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result { } fn emit_report(report: &T, json_output: bool) -> Result<()> { - let value = serde_json::to_value(report)?; + let mut value = serde_json::to_value(report)?; + let exit_code = apply_command_report_exit_code(&mut value); if json_output { println!("{}", serde_json::to_string_pretty(&value)?); } else { println!("{}", human_report(&value)); } + if let Some(exit_code) = exit_code { + std::process::exit(exit_code); + } Ok(()) } @@ -2046,6 +2050,33 @@ fn compact_json(value: &Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| "".to_owned()) } +fn apply_command_report_exit_code(value: &mut Value) -> Option { + for pointer in [ + "/machine_error", + "/run_start/machine_error", + "/restart_request/machine_error", + "/cancel_request/machine_error", + "/task_restart/machine_error", + ] { + let Some(machine_error) = value.pointer_mut(pointer) else { + continue; + }; + let Some(exit_code) = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()) + .filter(|code| *code != 0) + else { + continue; + }; + if let Some(object) = machine_error.as_object_mut() { + object.insert("process_exit_code_applied".to_owned(), json!(true)); + } + return Some(exit_code); + } + None +} + fn cli_error_summary(message: &str) -> Value { let category = classify_cli_error_message(message); cli_error_summary_for_category(category, message) @@ -2230,7 +2261,25 @@ fn cli_error_next_actions(category: &str) -> Vec<&'static str> { } } -fn main() -> Result<()> { +fn main() { + if let Err(error) = run_cli() { + let message = error.to_string(); + let machine_error = cli_error_summary(&message); + let category = machine_error + .get("category") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let exit_code = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()) + .unwrap_or(1); + eprintln!("Error ({category}, exit {exit_code}): {error:#}"); + std::process::exit(exit_code); + } +} + +fn run_cli() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Doctor(args) => { @@ -2912,15 +2961,25 @@ fn artifact_name_from_path(path: &str) -> String { path.rsplit('/').next().unwrap_or(path).to_owned() } +fn response_error_message(response: &Value, fallback: &str) -> String { + response + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| fallback.to_owned()) +} + fn process_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value { if response.get("type").and_then(Value::as_str) != Some("process_started") { + let message = response_error_message(response, "coordinator rejected process restart"); return json!({ "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), "operation": "restart_virtual_process", "accepted": false, "requires_confirmation": requires_confirmation, "explicit_user_action": true, - "error": response.get("message").cloned().unwrap_or(Value::Null), + "error": message, + "machine_error": cli_error_summary(&message), }); } @@ -2939,6 +2998,7 @@ fn process_restart_request_summary(response: &Value, requires_confirmation: bool fn process_cancel_request_summary(response: &Value, requires_confirmation: bool) -> Value { if response.get("type").and_then(Value::as_str) != Some("process_cancellation_requested") { + let message = response_error_message(response, "coordinator rejected process cancel"); return json!({ "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), "operation": "cancel_virtual_process", @@ -2946,7 +3006,8 @@ fn process_cancel_request_summary(response: &Value, requires_confirmation: bool) "requires_confirmation": requires_confirmation, "explicit_user_action": true, "whole_process_cancel_available": true, - "error": response.get("message").cloned().unwrap_or(Value::Null), + "error": message, + "machine_error": cli_error_summary(&message), }); } @@ -2975,6 +3036,7 @@ fn process_cancel_request_summary(response: &Value, requires_confirmation: bool) fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value { if response.get("type").and_then(Value::as_str) != Some("task_restart") { + let message = response_error_message(response, "coordinator rejected task restart"); return json!({ "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), "operation": "restart_selected_task", @@ -2982,7 +3044,8 @@ fn task_restart_request_summary(response: &Value, requires_confirmation: bool) - "requires_confirmation": requires_confirmation, "explicit_user_action": true, "clean_boundary_required": true, - "error": response.get("message").cloned().unwrap_or(Value::Null), + "error": message, + "machine_error": cli_error_summary(&message), }); } @@ -4415,6 +4478,39 @@ mod tests { } } + #[test] + fn command_report_exit_code_marks_command_failures_only() { + let run_start = run_start_summary(&json!({ + "type": "error", + "message": "quota unavailable: resource limit exceeded for api_calls", + })); + let mut report = json!({ + "command": "run", + "status": run_start["status"].clone(), + "run_start": run_start, + }); + + assert_eq!(apply_command_report_exit_code(&mut report), Some(22)); + assert_eq!( + report["run_start"]["machine_error"]["process_exit_code_applied"], + true + ); + + let mut task_list = json!({ + "command": "task list", + "tasks": [{ + "task": "compile", + "state": "failed", + "machine_error": cli_error_summary_for_category("program", "task exited with status 1"), + }], + }); + assert_eq!(apply_command_report_exit_code(&mut task_list), None); + assert_eq!( + task_list["tasks"][0]["machine_error"]["process_exit_code_applied"], + false + ); + } + #[test] fn top_level_version_is_available() { let command = Cli::command(); diff --git a/scripts/acceptance-private.sh b/scripts/acceptance-private.sh index 2f7316c..8f5bdc0 100755 --- a/scripts/acceptance-private.sh +++ b/scripts/acceptance-private.sh @@ -14,6 +14,7 @@ node scripts/release-blocker-smoke.js node scripts/resource-metering-contract-smoke.js node scripts/hostile-input-contract-smoke.js node scripts/tenant-isolation-contract-smoke.js +node scripts/cli-error-exit-smoke.js scripts/release-source-scan.sh cargo test --manifest-path private/hosted-policy/Cargo.toml node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js diff --git a/scripts/acceptance-public.sh b/scripts/acceptance-public.sh index eef4478..aa3ca79 100755 --- a/scripts/acceptance-public.sh +++ b/scripts/acceptance-public.sh @@ -39,6 +39,7 @@ cargo build --workspace --bins node scripts/docs-smoke.js node scripts/cli-output-mode-smoke.js node scripts/cli-login-smoke.js +node scripts/cli-error-exit-smoke.js node scripts/cli-browser-login-flow-smoke.js node scripts/cli-install-smoke.js node scripts/user-session-token-boundary-smoke.js diff --git a/scripts/cli-error-exit-smoke.js b/scripts/cli-error-exit-smoke.js new file mode 100755 index 0000000..c149eb2 --- /dev/null +++ b/scripts/cli-error-exit-smoke.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const net = require("net"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); + +function runDisasmer(args) { + return new Promise((resolve) => { + const child = cp.spawn( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { + cwd: repo, + stdio: ["ignore", "pipe", "pipe"], + } + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (code, signal) => { + resolve({ code, signal, stdout, stderr }); + }); + }); +} + +async function main() { + let request = ""; + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + request += chunk; + if (!request.includes("\n")) return; + socket.write( + JSON.stringify({ + type: "error", + message: "quota unavailable: resource limit exceeded for api_calls", + }) + "\n" + ); + socket.end(); + server.close(); + }); + }); + + const address = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address())); + }); + const coordinator = `http://${address.address}:${address.port}`; + + const result = await runDisasmer([ + "run", + "build", + "--project", + project, + "--coordinator", + coordinator, + "--json", + ]); + + assert.strictEqual(result.signal, null, result.stderr); + assert.strictEqual(result.code, 22, result.stderr); + assert.match(request, /"type":"start_process"/); + const report = JSON.parse(result.stdout); + assert.strictEqual(report.status, "coordinator_rejected"); + assert.strictEqual(report.run_start.machine_error.category, "quota"); + assert.strictEqual(report.run_start.machine_error.stable_exit_code, 22); + assert.strictEqual( + report.run_start.machine_error.process_exit_code_applied, + true + ); + assert( + report.run_start.machine_error.next_actions.includes("disasmer quota status") + ); +} + +main() + .then(() => { + console.log("CLI error exit smoke passed"); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index b23ab7a..d1486a7 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -34,6 +34,7 @@ const criteria = read("cli_acceptance_criteria.md"); const cli = read("crates/disasmer-cli/src/main.rs"); const coordinator = read("crates/disasmer-coordinator/src/service.rs"); const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); +const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); expect( @@ -116,6 +117,7 @@ for (const [name, pattern] of [ ["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/], ["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/], ["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/], + ["CLI command exit-code coverage", /fn command_report_exit_code_marks_command_failures_only\(\)/], ["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/], ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], @@ -240,6 +242,16 @@ expect( "CLI attaches machine errors to task failures", /task_failure_machine_error[\s\S]*cli_error_summary_with_default/ ); +expect( + cli, + "CLI applies process exit code after printing command failure report", + /fn emit_report[\s\S]*apply_command_report_exit_code[\s\S]*std::process::exit\(exit_code\)/ +); +expect( + cli, + "CLI wraps fallible main with classified exit", + /fn main\(\)[\s\S]*cli_error_summary\(&message\)[\s\S]*std::process::exit\(exit_code\)/ +); expect( cli, "CLI parses dangerous capability overrides", @@ -269,4 +281,13 @@ for (const [name, pattern] of [ expect(outputModeSmoke, name, pattern); } +for (const [name, pattern] of [ + ["fake coordinator quota rejection", /quota unavailable: resource limit exceeded for api_calls/], + ["run uses JSON mode", /"run"[\s\S]*"--json"/], + ["actual quota exit code", /assert\.strictEqual\(result\.code, 22/], + ["exit-code application in JSON", /process_exit_code_applied[\s\S]*true/], +]) { + expect(errorExitSmoke, name, pattern); +} + console.log("CLI-first contract smoke passed"); diff --git a/scripts/docs-smoke.js b/scripts/docs-smoke.js index 03a9f88..f63529f 100644 --- a/scripts/docs-smoke.js +++ b/scripts/docs-smoke.js @@ -166,6 +166,10 @@ for (const script of [publicAcceptance, publicSplit]) { script.includes("node scripts/cli-login-smoke.js"), "public acceptance gates must run cli-login-smoke.js" ); + assert( + script.includes("node scripts/cli-error-exit-smoke.js"), + "public acceptance gates must run cli-error-exit-smoke.js" + ); assert( script.includes("node scripts/acceptance-report-smoke.js"), "public acceptance gates must run acceptance-report-smoke.js" diff --git a/scripts/verify-public-split.sh b/scripts/verify-public-split.sh index 80c9888..96f5281 100755 --- a/scripts/verify-public-split.sh +++ b/scripts/verify-public-split.sh @@ -48,6 +48,7 @@ fi (cd "$tmp_dir" && node scripts/docs-smoke.js) (cd "$tmp_dir" && node scripts/cli-output-mode-smoke.js) (cd "$tmp_dir" && node scripts/cli-login-smoke.js) +(cd "$tmp_dir" && node scripts/cli-error-exit-smoke.js) (cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js) (cd "$tmp_dir" && node scripts/cli-install-smoke.js) (cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js)