diff --git a/crates/codexbar-providers/src/command.rs b/crates/codexbar-providers/src/command.rs index 9b73942f..972fdcc1 100644 --- a/crates/codexbar-providers/src/command.rs +++ b/crates/codexbar-providers/src/command.rs @@ -26,6 +26,9 @@ pub struct CommandOptions { pub remove_env: Vec<&'static str>, pub env: Vec<(&'static str, &'static str)>, pub output_limit: usize, + /// Stderr text that means the command now waits for a person (agy's sign-in prompt). Once it appears the command + /// is stopped and returns what it wrote so far, instead of waiting out its deadline. ASCII case is ignored. + pub stop_on_stderr: Option<&'static str>, } /// Runs local CLIs (`gh`, …). A seam so provider logic is tested without touching the user's real tools. @@ -93,6 +96,7 @@ impl SystemCommandRunner { &mut process.inner.child, timeout, options.map_or(OUTPUT_LIMIT, |o| o.output_limit.min(OUTPUT_LIMIT)), + options.and_then(|o| o.stop_on_stderr).unwrap_or_default(), ); let status = process.stop().map_err(|_| CommandError::Failed)?; let (stdout, stderr) = output?; @@ -198,20 +202,56 @@ impl Capture

{ } } +/// Watches stderr for `CommandOptions::stop_on_stderr`, ignoring ASCII case. Each look searches only what arrived +/// since the previous one, plus enough before it to catch a marker split across reads. An empty marker never matches. +struct StopMarker<'a> { + marker: &'a [u8], + searched: usize, +} + +impl StopMarker<'_> { + fn seen(&mut self, stderr: &[u8]) -> bool { + let start = self.searched.saturating_sub(self.marker.len().saturating_sub(1)); + self.searched = stderr.len(); + !self.marker.is_empty() + && stderr[start..] + .windows(self.marker.len()) + .any(|window| window.eq_ignore_ascii_case(self.marker)) + } +} + +/// Whether collection is over: the command asked for a person (`stop`), or both streams closed and it exited. +fn complete( + child: &mut std::process::Child, + stdout_closed: bool, + stderr: &Capture

, + stop: &mut StopMarker, +) -> Result { + if stop.seen(&stderr.bytes) { + return Ok(true); + } + Ok(stdout_closed && stderr.closed && pipe::finished(child).map_err(|_| CommandError::Failed)?) +} + fn collect( child: &mut std::process::Child, timeout: Duration, limit: usize, + stop_on_stderr: &str, ) -> Result<(Vec, Vec), CommandError> { let start = Instant::now(); let mut stdout = Capture::new(child.stdout.take().ok_or(CommandError::Failed)?, limit)?; let mut stderr = Capture::new(child.stderr.take().ok_or(CommandError::Failed)?, limit)?; + let mut stop = StopMarker { + marker: stop_on_stderr.as_bytes(), + searched: 0, + }; loop { if start.elapsed() >= timeout { return Err(CommandError::TimedOut); } let progress = stdout.drain()? | stderr.drain()?; - if stdout.closed && stderr.closed && pipe::finished(child).map_err(|_| CommandError::Failed)? { + if complete(child, stdout.closed, &stderr, &mut stop)? { return Ok((stdout.bytes, stderr.bytes)); } if !progress { @@ -231,4 +271,26 @@ mod tests { CommandError::Failed ); } + + #[test] + fn a_stop_marker_is_found_across_reads_and_case() { + let text = b"Authentication required. Please visit the URL"; + let marker = |searched| StopMarker { + marker: b"authentication required", + searched, + }; + assert!(marker(0).seen(text)); + // Read in two parts, split after "Authenti": the second look finds the marker that straddles both. + let mut split = marker(0); + assert!(!split.seen(&text[..8])); + assert!(split.seen(text)); + // Already searched to the end: nothing new to find. + assert!(!marker(text.len()).seen(text)); + assert!(!marker(0).seen(b"Authentication succeeded")); + let mut none = StopMarker { + marker: b"", + searched: 0, + }; + assert!(!none.seen(text), "an empty marker never stops a command"); + } } diff --git a/crates/codexbar-providers/src/gemini.rs b/crates/codexbar-providers/src/gemini.rs index 588e8daa..c5965540 100644 --- a/crates/codexbar-providers/src/gemini.rs +++ b/crates/codexbar-providers/src/gemini.rs @@ -24,15 +24,13 @@ pub struct GeminiProvider { } fn coding_error(error: &CodingError) -> ProviderError { - ProviderError::Refused { - reason: match error { - CodingError::NotInstalled => "Coding quotas need the Antigravity CLI (agy), signed in.", - CodingError::TooOld => { - "Coding quotas need Antigravity CLI 1.1.11 or newer, whose /usage runs no model turn. Update agy." - } - CodingError::NoUsage => "The Antigravity CLI didn't report coding usage. Sign in by running agy once.", - CodingError::TimedOut => "The Antigravity CLI took too long to report coding usage.", - CodingError::Unknown => "The Antigravity CLI reported coding usage in a shape CodexBar doesn't know.", + match error { + // Shown as signed out, never as a failed read, so the row points at the Settings sign-in (#282). + CodingError::SignedOut => ProviderError::NotSignedIn { + hint: "Sign in to the Antigravity CLI from Settings > Accounts.", + }, + _ => ProviderError::Refused { + reason: error.message(), }, } } @@ -229,6 +227,27 @@ mod tests { "2026-10-12T00:00:00Z".parse().unwrap() } + #[test] + fn a_signed_out_cli_shows_the_account_as_not_signed_in() { + let runner = Arc::new(Agy(Ok(crate::CommandOutput { + success: false, + stdout: String::new(), + stderr: "Authentication required. Please visit the URL to log in:\n".to_owned(), + }))); + let outcome = GeminiProvider::new(Offline, None, "gm-1", "Personal") + .with_coding(runner) + .fetch_outcomes(now()) + .unwrap(); + let AccountOutcome::Failed { error, .. } = &outcome[0] else { + panic!("a signed-out CLI has no usage to show"); + }; + assert!(error.needs_sign_in()); + assert_eq!( + error.to_string(), + "Not signed in. Sign in to the Antigravity CLI from Settings > Accounts." + ); + } + #[test] fn partial_or_late_cli_results_never_use_argon_for_subscription_status() { let at: DateTime = "2026-10-23T16:38:00Z".parse().unwrap(); diff --git a/crates/codexbar-providers/src/gemini_coding.rs b/crates/codexbar-providers/src/gemini_coding.rs index 00976ae1..6bd1558d 100644 --- a/crates/codexbar-providers/src/gemini_coding.rs +++ b/crates/codexbar-providers/src/gemini_coding.rs @@ -33,17 +33,31 @@ const BUCKETS: [(&str, &str, &str, i64); 5] = [ /// 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. +/// A signed-out agy starts Google sign-in in the default browser unless it believes it runs over SSH; then it prints +/// the sign-in link and waits for a pasted code instead (#282). A background read must never open a browser, so agy +/// is told it runs over a loopback SSH session. The interactive sign-in (`gemini_login`) clears these again. +pub const NO_BROWSER: [(&str, &str); 2] = [ + ("SSH_CONNECTION", "127.0.0.1 50000 127.0.0.1 22"), + ("SSH_CLIENT", "127.0.0.1 50000 22"), +]; + +/// What a signed-out agy prints to stderr before it waits for a sign-in nobody here can give (`Authentication +/// required. Please visit the URL to log in:`, or `Error: authentication required.` with no terminal). +const SIGN_IN_PROMPT: &str = "authentication required"; + +/// Runs the Antigravity CLI without its self-updater or browser sign-in: `SystemCommandRunner` with `NO_AUTO_UPDATE` +/// and `NO_BROWSER` set, any value inherited from this process replaced, and the run stopped as soon as agy asks for +/// sign-in. 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], + remove_env: vec![NO_AUTO_UPDATE.0, NO_BROWSER[0].0, NO_BROWSER[1].0, "SSH_TTY"], + env: vec![NO_AUTO_UPDATE, NO_BROWSER[0], NO_BROWSER[1]], output_limit: 8 * 1024 * 1024, + stop_on_stderr: Some(SIGN_IN_PROMPT), } } } @@ -73,6 +87,8 @@ pub fn cli() -> String { #[derive(Clone, Debug, PartialEq, Eq)] pub enum CodingError { NotInstalled, + /// The CLI has no sign-in and asked for one, which only the Settings sign-in can give. + SignedOut, /// The CLI is older than the first version whose `/usage` runs no model turn, or its version can't be read. TooOld, /// The CLI ran but didn't report usage: usually not signed in. @@ -81,17 +97,34 @@ pub enum CodingError { Unknown, } -impl std::fmt::Display for CodingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { +impl CodingError { + /// What the account shows for this failure. + pub fn message(&self) -> &'static str { + match self { Self::NotInstalled => "Coding quotas need the Antigravity CLI (agy), signed in.", + Self::SignedOut => "The Antigravity CLI is signed out. Sign in again from Settings > Accounts.", Self::TooOld => { "Coding quotas need Antigravity CLI 1.1.11 or newer, whose /usage runs no model turn. Update agy." } Self::NoUsage => "The Antigravity CLI didn't report coding usage. Sign in by running agy once.", Self::TimedOut => "The Antigravity CLI took too long to report coding usage.", Self::Unknown => "The Antigravity CLI reported coding usage in a shape CodexBar doesn't know.", - }) + } + } + + /// Why a `/usage` run that didn't succeed failed: agy asking for sign-in, or anything else. + fn of_failed_run(stderr: &str) -> Self { + if stderr.to_ascii_lowercase().contains(SIGN_IN_PROMPT) { + Self::SignedOut + } else { + Self::NoUsage + } + } +} + +impl std::fmt::Display for CodingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) } } @@ -220,7 +253,7 @@ pub fn fetch(runner: &dyn CommandRunner, now: DateTime) -> Result CodingError::NoUsage, })?; if !output.success { - return Err(CodingError::NoUsage); + return Err(CodingError::of_failed_run(&output.stderr)); } parse(&output.stdout, now) } @@ -420,10 +453,21 @@ mod tests { } #[test] - fn codexbar_runs_the_cli_with_its_self_updater_off() { + fn codexbar_runs_the_cli_with_its_self_updater_and_browser_sign_in_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.env, + [ + ("AGY_CLI_DISABLE_AUTO_UPDATE", "true"), + ("SSH_CONNECTION", "127.0.0.1 50000 127.0.0.1 22"), + ("SSH_CLIENT", "127.0.0.1 50000 22"), + ] + ); + assert_eq!( + options.remove_env, + ["AGY_CLI_DISABLE_AUTO_UPDATE", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] + ); + assert_eq!(options.stop_on_stderr, Some("authentication required")); assert_eq!(options.cwd, None); assert_eq!(options.output_limit, 8 * 1024 * 1024); assert_eq!( @@ -478,6 +522,51 @@ mod tests { assert_eq!(fetch(&missing, now()), Err(CodingError::NotInstalled)); } + #[test] + fn a_cli_that_asks_for_sign_in_is_reported_signed_out() { + for stderr in [ + "Authentication required. Please visit the URL to log in:\n https://accounts.google.com/o/oauth2/auth\n", + "Error: authentication required. Run 'agy' to log in, then retry.\n", + ] { + let runner = Runner( + Ok(CommandOutput { + success: false, + stdout: String::new(), + stderr: stderr.to_owned(), + }), + Default::default(), + ); + assert_eq!(fetch(&runner, now()), Err(CodingError::SignedOut), "{stderr}"); + } + let other = Runner( + Ok(CommandOutput { + success: false, + stdout: String::new(), + stderr: "error: something else".to_owned(), + }), + Default::default(), + ); + assert_eq!(fetch(&other, now()), Err(CodingError::NoUsage)); + } + + #[test] + fn every_coding_error_has_its_own_message() { + let all = [ + CodingError::NotInstalled, + CodingError::SignedOut, + CodingError::TooOld, + CodingError::NoUsage, + CodingError::TimedOut, + CodingError::Unknown, + ]; + let messages: std::collections::HashSet<_> = all.iter().map(CodingError::message).collect(); + assert_eq!(messages.len(), all.len()); + for error in all { + assert_eq!(error.to_string(), error.message()); + assert!(error.message().contains("Antigravity CLI"), "{error}"); + } + } + /// Answers `--version` with `version`, and records whether `/usage` was ever asked. struct Versioned(&'static str, std::sync::atomic::AtomicBool); diff --git a/crates/codexbar-providers/src/muse.rs b/crates/codexbar-providers/src/muse.rs index 0b1a353a..171cb06c 100644 --- a/crates/codexbar-providers/src/muse.rs +++ b/crates/codexbar-providers/src/muse.rs @@ -81,6 +81,7 @@ impl UsageProvider for MuseProvider { ], env: vec![("MUSE_LOGIN", "0"), ("MUSE_NO_AUTO_UPDATE", "1")], output_limit: OUTPUT_LIMIT, + stop_on_stderr: None, }; let result = self .runner diff --git a/crates/codexbar-providers/tests/command_runner.rs b/crates/codexbar-providers/tests/command_runner.rs index c9e92c33..0ce7c41c 100644 --- a/crates/codexbar-providers/tests/command_runner.rs +++ b/crates/codexbar-providers/tests/command_runner.rs @@ -88,6 +88,7 @@ fn fixture(args: &[String]) -> ! { ], env: vec![("MUSE_LOGIN", "0"), ("MUSE_NO_AUTO_UPDATE", "1")], output_limit: 4 * MIB, + stop_on_stderr: None, }; let executable = std::env::current_exe().unwrap(); let program = executable.to_str().unwrap(); @@ -121,6 +122,14 @@ fn fixture(args: &[String]) -> ! { std::process::exit(7); } "stdin" => assert_eq!(std::io::stdin().read(&mut [0u8; 1]).unwrap(), 0), + // A CLI that asks for sign-in and then waits for it, as a signed-out agy does (#282). + "prompt" => { + std::io::stdout().write_all(b"partial").unwrap(); + std::io::stderr() + .write_all(b"Authentication required. Please visit the URL to log in:\n") + .unwrap(); + idle() + } "hang" | "overflow" => { let _held = hold(Path::new(&args[1])); if args[0] == "overflow" { @@ -389,11 +398,27 @@ fn main() { remove_env: vec!["AGY_CLI_DISABLE_AUTO_UPDATE"], env: vec![("AGY_CLI_DISABLE_AUTO_UPDATE", "true")], output_limit: MIB, + stop_on_stderr: None, }; let kept = SystemCommandRunner .run_with_options(program, &["unscoped", here.to_str().unwrap()], timeout, &unscoped) .unwrap(); assert!(kept.success, "{}", kept.stderr); + let prompting = CommandOptions { + stop_on_stderr: Some("authentication required"), + ..unscoped.clone() + }; + let start = Instant::now(); + let stopped = SystemCommandRunner + .run_with_options(program, &["prompt"], timeout, &prompting) + .expect("a sign-in prompt stops the command instead of timing it out"); + assert!(start.elapsed() < timeout, "stopped at the prompt, not the deadline"); + assert!(!stopped.success); + assert!( + stopped.stderr.starts_with("Authentication required."), + "{}", + stopped.stderr + ); #[cfg(windows)] { let console = Command::new(program).arg("console-parent").output().unwrap(); diff --git a/crates/codexbar-providers/tests/command_runner/profile.rs b/crates/codexbar-providers/tests/command_runner/profile.rs index 28e65a52..2ca4ab2e 100644 --- a/crates/codexbar-providers/tests/command_runner/profile.rs +++ b/crates/codexbar-providers/tests/command_runner/profile.rs @@ -265,6 +265,7 @@ pub(super) fn qualify(program: &str, scratch: &Path) { remove_env: Vec::new(), env: Vec::new(), output_limit: CAP, + stop_on_stderr: None, }; let start = Instant::now(); let result = SystemCommandRunner.run_with_options( diff --git a/docs/ANTIGRAVITY_LOGIN_TESTING.md b/docs/ANTIGRAVITY_LOGIN_TESTING.md index e5ded9aa..db6f23c9 100644 --- a/docs/ANTIGRAVITY_LOGIN_TESTING.md +++ b/docs/ANTIGRAVITY_LOGIN_TESTING.md @@ -20,7 +20,8 @@ that local default-browser consent works. enable coding quotas without opening an interactive sign-in window. Confirm that the account's label and ID persist after restart and usage refreshes. 4. For new consent, use a separate Windows test user without Antigravity - credentials. After the initial check reports that sign-in is needed, choose + credentials. The initial check must open no browser (#282); after it + reports that sign-in is needed, choose **Open Antigravity sign-in**. Expect the ordinary `agy` terminal and the user's default browser. There must be no disposable browser profile, embedded browser, debugging port, cookie extraction or browser-policy bypass. diff --git a/docs/COMMAND_RUNNER.md b/docs/COMMAND_RUNNER.md index 632223fd..c802df96 100644 --- a/docs/COMMAND_RUNNER.md +++ b/docs/COMMAND_RUNNER.md @@ -21,6 +21,22 @@ 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. +## No interactive sign-in + +A background CLI must never start a sign-in the user didn't ask for. A +signed-out agy opens Google sign-in in the default browser (`rundll32 +url.dll,FileProtocolHandler` on Windows, `open` on macOS) and then waits 60 s +for a code, unless it believes it runs over SSH; then it prints the link and +waits instead ([Authenticate](https://antigravity.google/docs/install/)). A +closed stdin doesn't stop it on Windows, where agy reads the code from the +hidden console's `CONIN$`. `CliRunner` therefore sets a loopback +`SSH_CONNECTION` and `SSH_CLIENT`, removes any inherited `SSH_TTY`, and sets +`CommandOptions.stop_on_stderr` to agy's `authentication required`. The runner +stops the command once that text appears on stderr, ignoring ASCII case, and +returns what it wrote, so the account reports "Not signed in" at once instead +of timing out. The Settings sign-in window clears the SSH variables again and +uses agy's local browser flow. + ## Output and deadline policy - Read stdout and stderr while the child runs. Each loop drains at most one