From 273d36592a75297ca19cc0ca00f01385c2a28916 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Fri, 1 May 2026 14:23:24 -0500 Subject: [PATCH 1/3] Add doctor CI environment diagnostics --- docs/bootstrap/onboarding.md | 1 + rust/src/native_app.rs | 154 +++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index ecb3adc..ff37867 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -15,6 +15,7 @@ ## Runner Policy + - Run `apw --json doctor` first on a new local checkout or self-hosted runner to confirm the Rust, Xcode, secret-scan, signing, and runner-environment diagnostics before extended validation. - Shell-safe jobs may use `[self-hosted, synology, shell-only, public]`. - Docker, service-container, browser, and `container:` workloads stay on GitHub-hosted runners. - Keep PR checks cheap. Add heavy validation to `scripts/ci/run-extended-validation.sh` instead of the PR lane. diff --git a/rust/src/native_app.rs b/rust/src/native_app.rs index 6a48f17..dea1fbd 100644 --- a/rust/src/native_app.rs +++ b/rust/src/native_app.rs @@ -28,6 +28,20 @@ const SOCKET_TIMEOUT_MS: u64 = 3_000; const CONNECT_RETRIES: usize = 10; const CONNECT_RETRY_DELAY_MS: u64 = 200; +fn diagnostic( + status: &str, + id: &str, + message: impl Into, + hint: impl Into, +) -> Value { + json!({ + "id": id, + "status": status, + "message": message.into(), + "hint": hint.into(), + }) +} + fn home_dir() -> PathBuf { match env::var("HOME").or_else(|_| env::var("USERPROFILE")) { Ok(dir) => PathBuf::from(dir), @@ -216,6 +230,128 @@ fn load_status_file() -> Option { serde_json::from_str(&fs::read_to_string(native_app_status_path()).ok()?).ok() } +fn command_output(command: &str, args: &[&str]) -> std::io::Result { + Command::new(command).args(args).output() +} + +fn command_version_check(id: &str, command: &str, args: &[&str], hint: &str) -> Value { + match command_output(command, args) { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stdout + .lines() + .chain(stderr.lines()) + .find(|line| !line.trim().is_empty()) + .unwrap_or("available") + .trim() + .to_string(); + diagnostic("OK", id, first_line, "") + } + Ok(output) => diagnostic( + "FAIL", + id, + format!("{command} exited with status {}", output.status), + hint, + ), + Err(_) => diagnostic("FAIL", id, format!("{command} was not found"), hint), + } +} + +fn code_signing_identity_check() -> Value { + match command_output("security", &["find-identity", "-v", "-p", "codesigning"]) { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("Developer ID Application") { + diagnostic( + "OK", + "developer_id_identity", + "Developer ID Application identity found", + "", + ) + } else { + diagnostic( + "WARN", + "developer_id_identity", + "No Developer ID Application signing identity was found", + "Install the release signing certificate before notarized release builds.", + ) + } + } + Ok(output) => diagnostic( + "WARN", + "developer_id_identity", + format!( + "security find-identity exited with status {}", + output.status + ), + "Run `security find-identity -v -p codesigning` on the release runner.", + ), + Err(_) => diagnostic( + "WARN", + "developer_id_identity", + "security CLI was not found", + "Run release signing diagnostics on macOS with Xcode command line tools installed.", + ), + } +} + +fn ci_runner_environment_check() -> Value { + if env::var("CI").map(|value| value == "true").unwrap_or(false) { + let runner_os = env::var("RUNNER_OS").unwrap_or_else(|_| "unknown".to_string()); + let runner_arch = env::var("RUNNER_ARCH").unwrap_or_else(|_| "unknown".to_string()); + let runner_labels = env::var("RUNNER_LABELS") + .or_else(|_| env::var("APW_RUNNER_LABELS")) + .unwrap_or_default(); + if runner_labels.is_empty() { + return diagnostic( + "WARN", + "runner_labels", + format!("CI runner reports os={runner_os}, arch={runner_arch}, but labels are not exposed"), + "Confirm the workflow runs on the documented self-hosted labels in docs/bootstrap/onboarding.md.", + ); + } + return diagnostic( + "OK", + "runner_labels", + format!("CI runner labels: {runner_labels}; os={runner_os}; arch={runner_arch}"), + "", + ); + } + + diagnostic( + "WARN", + "runner_labels", + "Not running in GitHub Actions; runner labels cannot be verified locally", + "In CI, confirm shell-safe jobs use [self-hosted, synology, shell-only, public] and extended macOS validation uses [self-hosted, private, macOS, ARM64, xcode].", + ) +} + +fn ci_diagnostic_checks() -> Vec { + vec![ + command_version_check( + "xcodebuild", + "xcodebuild", + &["-version"], + "Install Xcode and select it with `sudo xcode-select -s /Applications/Xcode.app`.", + ), + command_version_check( + "cargo", + "cargo", + &["--version"], + "Install Rust from https://rustup.rs/ before running Rust or release validation.", + ), + command_version_check( + "detect_secrets", + "detect-secrets", + &["--version"], + "Install detect-secrets or run the repo's configured secret scan environment.", + ), + code_signing_identity_check(), + ci_runner_environment_check(), + ] +} + fn rotate_broker_log_if_needed(path: &Path) -> Result<()> { let metadata = match fs::metadata(path) { Ok(value) => value, @@ -552,6 +688,7 @@ pub fn native_app_doctor() -> Result { format!("Inspect broker logs at {}.", native_app_broker_log_path().display()) ]), ); + object.insert("ciDiagnostics".to_string(), json!(ci_diagnostic_checks())); } Ok(doctor) } @@ -1073,6 +1210,23 @@ mod tests { payload["frameworks"]["authenticationServicesLinked"], json!(true) ); + let diagnostics = payload["ciDiagnostics"].as_array().unwrap(); + for id in [ + "xcodebuild", + "cargo", + "detect_secrets", + "developer_id_identity", + "runner_labels", + ] { + assert!( + diagnostics.iter().any(|entry| entry["id"] == json!(id)), + "missing diagnostic {id}: {diagnostics:#?}" + ); + } + assert!(diagnostics.iter().all(|entry| entry["status"] + .as_str() + .map(|status| ["OK", "WARN", "FAIL"].contains(&status)) + .unwrap_or(false))); assert!(native_app_credentials_path().exists()); }); } From 1be73d5e3acb54137e252dacb15be1f680aba541 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Wed, 6 May 2026 22:16:13 -0500 Subject: [PATCH 2/3] Fix external fallback security regressions --- rust/tests/security_regressions.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index 2af518a..aeb77ff 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -225,8 +225,10 @@ fn login_rejects_relative_external_provider_path() { install_native_app_no_results(home); write_fallback_provider_config(home, "bw"); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, @@ -247,8 +249,10 @@ fn login_rejects_tilde_external_provider_path() { install_native_app_no_results(home); write_fallback_provider_config(home, "~/bin/bw"); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, @@ -274,8 +278,10 @@ fn login_rejects_world_writable_external_provider_path() { .expect("failed to chmod fallback provider"); write_fallback_provider_config(home, &provider_path.display().to_string()); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, @@ -303,8 +309,10 @@ fn login_rejects_external_provider_symlink_to_insecure_target() { symlink(&provider_path, &provider_link).expect("failed to create provider symlink"); write_fallback_provider_config(home, &provider_link.display().to_string()); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, From 1cf729c54352542ed2b3547d6166123689edf6c0 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Wed, 6 May 2026 22:17:35 -0500 Subject: [PATCH 3/3] Format external fallback regression tests --- rust/tests/security_regressions.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index aeb77ff..6a32f7f 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -227,7 +227,12 @@ fn login_rejects_relative_external_provider_path() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!( @@ -251,7 +256,12 @@ fn login_rejects_tilde_external_provider_path() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!( @@ -280,7 +290,12 @@ fn login_rejects_world_writable_external_provider_path() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!( @@ -311,7 +326,12 @@ fn login_rejects_external_provider_symlink_to_insecure_target() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!(