Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/codexbar-app/src/gemini_sign_in.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
2 changes: 1 addition & 1 deletion crates/codexbar-app/src/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub fn enabled(hub: &SettingsHub) -> Vec<Arc<dyn UsageProvider>> {
}
// 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));
}
Expand Down
2 changes: 1 addition & 1 deletion crates/codexbar-app/src/ui_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8444,7 +8444,7 @@ fn live_gemini_status_from_saved_provider_data() {
args: &[&str],
timeout: Duration,
) -> Result<codexbar_providers::CommandOutput, codexbar_providers::CommandError> {
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::<serde_json::Value>(&result.stdout)
{
Expand Down
4 changes: 4 additions & 0 deletions crates/codexbar-providers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 24 additions & 16 deletions crates/codexbar-providers/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
pub remove_env: Vec<&'static str>,
pub env: Vec<(&'static str, &'static str)>,
pub output_limit: usize,
Expand Down Expand Up @@ -74,21 +75,7 @@ impl SystemCommandRunner {
) -> Result<CommandOutput, CommandError> {
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)
Expand Down Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion crates/codexbar-providers/src/gemini_coding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<CommandOutput, CommandError> {
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`.
Expand Down Expand Up @@ -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<CommandOutput, CommandError>, std::sync::Mutex<Vec<Vec<String>>>);

impl CommandRunner for Runner {
Expand Down
5 changes: 5 additions & 0 deletions crates/codexbar-providers/src/gemini_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions crates/codexbar-providers/src/muse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl<R: CommandRunner> UsageProvider for MuseProvider<R> {
return cached.result.clone();
}
let options = CommandOptions {
cwd: self.home.clone(),
cwd: Some(self.home.clone()),
remove_env: vec![
"META_API_KEY",
"MUSE_LOGIN",
Expand Down Expand Up @@ -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);
Expand Down
85 changes: 84 additions & 1 deletion crates/codexbar-providers/tests/command_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 _;
Expand Down Expand Up @@ -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))]
Expand Down
2 changes: 1 addition & 1 deletion crates/codexbar-providers/tests/command_runner/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions docs/COMMAND_RUNNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading