diff --git a/crates/codexbar-app/src/gemini_sign_in.rs b/crates/codexbar-app/src/gemini_sign_in.rs index d76b6122..f299d046 100644 --- a/crates/codexbar-app/src/gemini_sign_in.rs +++ b/crates/codexbar-app/src/gemini_sign_in.rs @@ -67,7 +67,7 @@ pub trait SignInService: Send + Sync { struct Antigravity; impl SignInService for Antigravity { fn check(&self) -> Result<(), String> { - codexbar_providers::gemini_coding::fetch(&codexbar_providers::SystemCommandRunner, chrono::Utc::now()) + codexbar_providers::gemini_coding::fetch(&codexbar_providers::gemini_coding::CliRunner, chrono::Utc::now()) .map(|_| ()) .map_err(|error| error.to_string()) } diff --git a/crates/codexbar-app/src/providers.rs b/crates/codexbar-app/src/providers.rs index 3fc81d84..8e4f0cd3 100644 --- a/crates/codexbar-app/src/providers.rs +++ b/crates/codexbar-app/src/providers.rs @@ -101,7 +101,7 @@ pub fn enabled(hub: &SettingsHub) -> Vec> { } // Coding quotas (#107) come from the Antigravity CLI's own sign-in. if account.gemini_coding { - provider = provider.with_coding(Arc::new(SystemCommandRunner)); + provider = provider.with_coding(Arc::new(codexbar_providers::gemini_coding::CliRunner)); } providers.push(Arc::new(provider)); } diff --git a/crates/codexbar-app/src/ui_tests.rs b/crates/codexbar-app/src/ui_tests.rs index 6d0a51ba..f5828cbd 100644 --- a/crates/codexbar-app/src/ui_tests.rs +++ b/crates/codexbar-app/src/ui_tests.rs @@ -8444,7 +8444,7 @@ fn live_gemini_status_from_saved_provider_data() { args: &[&str], timeout: Duration, ) -> Result { - let result = codexbar_providers::SystemCommandRunner.run(program, args, timeout)?; + let result = codexbar_providers::gemini_coding::CliRunner.run(program, args, timeout)?; if args.first() == Some(&"-p") && let Ok(value) = serde_json::from_str::(&result.stdout) { diff --git a/crates/codexbar-providers/Cargo.toml b/crates/codexbar-providers/Cargo.toml index 8eb9ed3e..d0d474b6 100644 --- a/crates/codexbar-providers/Cargo.toml +++ b/crates/codexbar-providers/Cargo.toml @@ -23,6 +23,10 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] windows.workspace = true +# The command runner's console fixtures (chief#43) detach from and inspect their console. +[target.'cfg(windows)'.dev-dependencies] +windows = { workspace = true, features = ["Win32_System_Console"] } + [lints] workspace = true diff --git a/crates/codexbar-providers/src/command.rs b/crates/codexbar-providers/src/command.rs index c6c107a1..9b73942f 100644 --- a/crates/codexbar-providers/src/command.rs +++ b/crates/codexbar-providers/src/command.rs @@ -21,7 +21,8 @@ pub enum CommandError { /// Options honored only for this child process, without changing the parent's environment. #[derive(Clone, Debug)] pub struct CommandOptions { - pub cwd: std::path::PathBuf, + /// The child's working folder; `None` keeps this process's. + pub cwd: Option, pub remove_env: Vec<&'static str>, pub env: Vec<(&'static str, &'static str)>, pub output_limit: usize, @@ -74,21 +75,7 @@ impl SystemCommandRunner { ) -> Result { let mut command = crate::platform::command(program); if let Some(options) = options { - command.current_dir(&options.cwd); - // Remove every case variant too, matching the shipped Muse subscription-only contract. - for (name, _) in std::env::vars_os() { - if options - .remove_env - .iter() - .any(|removed| name.to_string_lossy().eq_ignore_ascii_case(removed)) - { - command.env_remove(name); - } - } - for name in &options.remove_env { - command.env_remove(name); - } - command.envs(options.env.iter().copied()); + configure(&mut command, options); } command .args(args) @@ -117,6 +104,27 @@ impl SystemCommandRunner { } } +/// Applies the options that hold only for this child: its working folder and its environment. +fn configure(command: &mut std::process::Command, options: &CommandOptions) { + if let Some(cwd) = &options.cwd { + command.current_dir(cwd); + } + // Remove every case variant too, matching the shipped Muse subscription-only contract. + for (name, _) in std::env::vars_os() { + if options + .remove_env + .iter() + .any(|removed| name.to_string_lossy().eq_ignore_ascii_case(removed)) + { + command.env_remove(name); + } + } + for name in &options.remove_env { + command.env_remove(name); + } + command.envs(options.env.iter().copied()); +} + fn spawn_error(error: std::io::Error) -> CommandError { if error.kind() == std::io::ErrorKind::NotFound { CommandError::NotFound diff --git a/crates/codexbar-providers/src/gemini_coding.rs b/crates/codexbar-providers/src/gemini_coding.rs index 2096a0e4..00976ae1 100644 --- a/crates/codexbar-providers/src/gemini_coding.rs +++ b/crates/codexbar-providers/src/gemini_coding.rs @@ -14,7 +14,7 @@ use codexbar_core::Metric; use serde_json::Value; use crate::pace::elapsed_pace; -use crate::{CommandError, CommandRunner}; +use crate::{CommandError, CommandOptions, CommandOutput, CommandRunner, SystemCommandRunner}; /// The CLI's usage command can take a moment to start; it never runs a model turn. const TIMEOUT: StdDuration = StdDuration::from_secs(30); @@ -28,6 +28,32 @@ const BUCKETS: [(&str, &str, &str, i64); 5] = [ ("3p-weekly", "weekly", "Other models weekly", 7 * 24), ]; +/// Antigravity's own switch for its background self-updater; only `true` turns it off (chief#43). The updater starts +/// without a console once its 15-minute check is due, and its `agy --version` child then gets a new, visible console +/// that no creation flag on the agy CodexBar starts can reach. Agy run in a terminal still updates itself. +pub const NO_AUTO_UPDATE: (&str, &str) = ("AGY_CLI_DISABLE_AUTO_UPDATE", "true"); + +/// Runs the Antigravity CLI without its self-updater: `SystemCommandRunner` with `NO_AUTO_UPDATE` set, and any +/// value inherited from this process replaced. Its working folder stays this process's. +pub struct CliRunner; + +impl CliRunner { + fn options() -> CommandOptions { + CommandOptions { + cwd: None, + remove_env: vec![NO_AUTO_UPDATE.0], + env: vec![NO_AUTO_UPDATE], + output_limit: 8 * 1024 * 1024, + } + } +} + +impl CommandRunner for CliRunner { + fn run(&self, program: &str, args: &[&str], timeout: StdDuration) -> Result { + SystemCommandRunner.run_with_options(program, args, timeout, &Self::options()) + } +} + /// The Antigravity CLI to run: on PATH, or where its installer puts it. pub fn cli() -> String { // Its installer's folder: `%LOCALAPPDATA%\agy\bin` on Windows; elsewhere it links `agy` into `~/.local/bin`. @@ -393,6 +419,23 @@ mod tests { ); } + #[test] + fn codexbar_runs_the_cli_with_its_self_updater_off() { + let options = CliRunner::options(); + assert_eq!(options.env, [("AGY_CLI_DISABLE_AUTO_UPDATE", "true")]); + assert_eq!(options.remove_env, ["AGY_CLI_DISABLE_AUTO_UPDATE"]); + assert_eq!(options.cwd, None); + assert_eq!(options.output_limit, 8 * 1024 * 1024); + assert_eq!( + CliRunner.run( + "codexbar-missing-agy-fixture", + &["--version"], + StdDuration::from_secs(3) + ), + Err(CommandError::NotFound) + ); + } + struct Runner(Result, std::sync::Mutex>>); impl CommandRunner for Runner { diff --git a/crates/codexbar-providers/src/gemini_login.rs b/crates/codexbar-providers/src/gemini_login.rs index 00f69d41..8109b580 100644 --- a/crates/codexbar-providers/src/gemini_login.rs +++ b/crates/codexbar-providers/src/gemini_login.rs @@ -14,6 +14,11 @@ pub struct LoginWindow { fn command(dir: &Path) -> Command { let mut command = crate::platform::command(crate::gemini_coding::cli()); command.current_dir(dir); + // The sign-in window's own agy must not start the self-updater's console either. + command.env( + crate::gemini_coding::NO_AUTO_UPDATE.0, + crate::gemini_coding::NO_AUTO_UPDATE.1, + ); // A desktop app launched by an SSH qualification harness must still use the vendor's local browser flow. for name in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] { command.env_remove(name); diff --git a/crates/codexbar-providers/src/muse.rs b/crates/codexbar-providers/src/muse.rs index b63fc742..0b1a353a 100644 --- a/crates/codexbar-providers/src/muse.rs +++ b/crates/codexbar-providers/src/muse.rs @@ -71,7 +71,7 @@ impl UsageProvider for MuseProvider { return cached.result.clone(); } let options = CommandOptions { - cwd: self.home.clone(), + cwd: Some(self.home.clone()), remove_env: vec![ "META_API_KEY", "MUSE_LOGIN", @@ -383,7 +383,7 @@ mod tests { assert_eq!(args, ["usage", "--json"]); assert_eq!(timeout, Timeout::from_secs(15)); assert_eq!(options.output_limit, OUTPUT_LIMIT); - assert_eq!(options.cwd, PathBuf::from("/synthetic-home")); + assert_eq!(options.cwd, Some(PathBuf::from("/synthetic-home"))); assert!(options.remove_env.contains(&"META_API_KEY")); assert_eq!(options.env, [("MUSE_LOGIN", "0"), ("MUSE_NO_AUTO_UPDATE", "1")]); self.calls.fetch_add(1, Ordering::Relaxed); diff --git a/crates/codexbar-providers/tests/command_runner.rs b/crates/codexbar-providers/tests/command_runner.rs index 468d0202..c9e92c33 100644 --- a/crates/codexbar-providers/tests/command_runner.rs +++ b/crates/codexbar-providers/tests/command_runner.rs @@ -72,9 +72,14 @@ fn fixture(args: &[String]) -> ! { assert_eq!(std::env::var("MUSE_NO_AUTO_UPDATE").unwrap(), "1"); assert_eq!(std::io::stdin().read(&mut [0u8; 1]).unwrap(), 0); } + // Without a working folder the child keeps the parent's, and still gets its variables (agy, chief#43). + "unscoped" => { + assert_eq!(std::env::current_dir().unwrap(), PathBuf::from(&args[1])); + assert_eq!(std::env::var("AGY_CLI_DISABLE_AUTO_UPDATE").unwrap(), "true"); + } "scoped-parent" => { let options = CommandOptions { - cwd: PathBuf::from(&args[1]), + cwd: Some(PathBuf::from(&args[1])), remove_env: vec![ "CODEXBAR_SYNTHETIC_PARENT_ONLY", "META_API_KEY", @@ -105,6 +110,12 @@ fn fixture(args: &[String]) -> ! { Err(CommandError::Failed) ); } + #[cfg(windows)] + "console-parent" => console_parent(), + #[cfg(windows)] + "console-child" => console_child(), + #[cfg(windows)] + "console-grandchild" => print!("{}", console_window()), "failure" => { std::io::stderr().write_all(b"synthetic failure").unwrap(); std::process::exit(7); @@ -146,6 +157,57 @@ fn fixture(args: &[String]) -> ! { std::process::exit(0); } +/// Starts the runner's child the way the release app does (chief#43): from a process with no console, like the +/// GUI-subsystem CodexBar, where a console child that isn't started without a window gets a new, visible console. +#[cfg(windows)] +fn console_parent() { + // SAFETY: detaches this fixture process from the test's console, if it has one; its standard streams stay the + // captured pipes. The assertion below checks the outcome either way. + let _ = unsafe { windows::Win32::System::Console::FreeConsole() }; + assert_eq!( + console_window(), + "none", + "the fixture parent has no console, like the release app" + ); + let program = std::env::current_exe().unwrap(); + let output = SystemCommandRunner + .run(program.to_str().unwrap(), &["console-child"], Duration::from_secs(10)) + .unwrap(); + assert!(output.success, "{}", output.stderr); + print!("{}", output.stdout); +} + +/// A background CLI and an ordinary descendant it starts with no creation flags (#271), both of which must have no +/// console window at all, visible or not. +#[cfg(windows)] +fn console_child() { + let grandchild = Command::new(std::env::current_exe().unwrap()) + .arg("console-grandchild") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(grandchild.status.success()); + print!( + "child={} grandchild={}", + console_window(), + String::from_utf8_lossy(&grandchild.stdout) + ); +} + +/// This process's console window: `none` without one, else whether Windows shows it. +#[cfg(windows)] +fn console_window() -> &'static str { + // SAFETY: plain queries of this process's console window handle and its visibility. + let window = unsafe { windows::Win32::System::Console::GetConsoleWindow() }; + if window.is_invalid() { + "none" + } else if unsafe { windows::Win32::UI::WindowsAndMessaging::IsWindowVisible(window) }.as_bool() { + "visible" + } else { + "hidden" + } +} + #[cfg(windows)] fn capture_inheritance(clear: bool) { use std::os::windows::io::AsRawHandle as _; @@ -321,6 +383,27 @@ fn main() { .output() .unwrap(); assert!(scoped.status.success(), "{}", String::from_utf8_lossy(&scoped.stderr)); + let here = std::env::current_dir().unwrap(); + let unscoped = CommandOptions { + cwd: None, + remove_env: vec!["AGY_CLI_DISABLE_AUTO_UPDATE"], + env: vec![("AGY_CLI_DISABLE_AUTO_UPDATE", "true")], + output_limit: MIB, + }; + let kept = SystemCommandRunner + .run_with_options(program, &["unscoped", here.to_str().unwrap()], timeout, &unscoped) + .unwrap(); + assert!(kept.success, "{}", kept.stderr); + #[cfg(windows)] + { + let console = Command::new(program).arg("console-parent").output().unwrap(); + assert!(console.status.success(), "{}", String::from_utf8_lossy(&console.stderr)); + assert_eq!( + String::from_utf8_lossy(&console.stdout), + "child=none grandchild=none", + "background CLIs and their descendants must get no console window (chief#43)" + ); + } #[cfg(all(coverage, any(target_os = "linux", target_os = "macos")))] profile::qualify(program, &scratch.0); #[cfg(not(windows))] diff --git a/crates/codexbar-providers/tests/command_runner/profile.rs b/crates/codexbar-providers/tests/command_runner/profile.rs index 6082dc0a..28e65a52 100644 --- a/crates/codexbar-providers/tests/command_runner/profile.rs +++ b/crates/codexbar-providers/tests/command_runner/profile.rs @@ -261,7 +261,7 @@ pub(super) fn qualify(program: &str, scratch: &Path) { let dir = scratch.join(format!("profile-{order}")); std::fs::create_dir(&dir).unwrap(); let options = CommandOptions { - cwd: dir.clone(), + cwd: Some(dir.clone()), remove_env: Vec::new(), env: Vec::new(), output_limit: CAP, diff --git a/docs/COMMAND_RUNNER.md b/docs/COMMAND_RUNNER.md index b97a9262..632223fd 100644 --- a/docs/COMMAND_RUNNER.md +++ b/docs/COMMAND_RUNNER.md @@ -4,6 +4,23 @@ program lookup. It does not invoke a shell, opens no console window, and closes stdin. Interactive sign-in flows use their separate login adapters. +## Console windows + +On Windows every background CLI starts with `CREATE_NO_WINDOW`, so it gets a +console with no window. Descendants that inherit that console have no window +either. A descendant started without a console, such as one detached from it, +gets a new, visible console that no flag on CodexBar's child can reach. The +Antigravity CLI's self-updater is one: it runs detached and starts +`agy --version`. `gemini_coding::CliRunner` therefore runs agy with Antigravity's +own `AGY_CLI_DISABLE_AUTO_UPDATE=true` +([troubleshooting](https://antigravity.google/docs/cli/troubleshooting/)). Only +`true` works ([antigravity-cli#1046](https://github.com/google-antigravity/antigravity-cli/issues/1046)). +`CommandOptions` without `cwd` keeps this process's working folder. + +The `command_runner` fixture starts its child from a process with no console, +as the release app does. The child and an ordinary grandchild must both report +no console window. + ## Output and deadline policy - Read stdout and stderr while the child runs. Each loop drains at most one