From 5813c1c2df4984be67e70d9947f0a0a411706192 Mon Sep 17 00:00:00 2001 From: HemSoft Date: Tue, 27 Oct 2026 12:37:12 -0400 Subject: [PATCH 1/3] fix: keep background Antigravity refreshes from opening Google sign-in (#282) A signed-out agy started browser sign-in on every background /usage read and then waited 60 s for a code. CodexBar now runs agy with a loopback SSH_CONNECTION/SSH_CLIENT, so agy prints its sign-in link instead of opening a browser, and stops the run as soon as agy asks for sign-in. The Gemini coding row reports "Not signed in" instead of a timeout. The command runner gains `stop_on_stderr`, used only by agy. The interactive Settings sign-in still clears the SSH variables and uses agy's local browser flow. Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-providers/src/command.rs | 36 ++++++++++ crates/codexbar-providers/src/gemini.rs | 48 ++++++++++--- .../codexbar-providers/src/gemini_coding.rs | 72 +++++++++++++++++-- crates/codexbar-providers/src/muse.rs | 1 + .../tests/command_runner.rs | 25 +++++++ .../tests/command_runner/profile.rs | 1 + 6 files changed, 165 insertions(+), 18 deletions(-) diff --git a/crates/codexbar-providers/src/command.rs b/crates/codexbar-providers/src/command.rs index 9b73942f..2f22fae7 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,9 @@ 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) + .filter(|marker| !marker.is_empty()), ); let status = process.stop().map_err(|_| CommandError::Failed)?; let (stdout, stderr) = output?; @@ -198,19 +204,35 @@ impl Capture

{ } } +/// Whether `marker` (ignoring ASCII case) ends somewhere past `searched`, the length already searched. +fn contains_from(bytes: &[u8], searched: usize, marker: &[u8]) -> bool { + let start = searched.saturating_sub(marker.len() - 1); + bytes[start..] + .windows(marker.len()) + .any(|window| window.eq_ignore_ascii_case(marker)) +} + fn collect( child: &mut std::process::Child, timeout: Duration, limit: usize, + stop_on_stderr: Option<&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 searched = 0; loop { if start.elapsed() >= timeout { return Err(CommandError::TimedOut); } let progress = stdout.drain()? | stderr.drain()?; + if let Some(marker) = stop_on_stderr { + if contains_from(&stderr.bytes, searched, marker.as_bytes()) { + return Ok((stdout.bytes, stderr.bytes)); + } + searched = stderr.bytes.len(); + } if stdout.closed && stderr.closed && pipe::finished(child).map_err(|_| CommandError::Failed)? { return Ok((stdout.bytes, stderr.bytes)); } @@ -231,4 +253,18 @@ mod tests { CommandError::Failed ); } + + #[test] + fn a_stop_marker_is_found_across_reads_and_case() { + let text = b"Authentication required. Please visit the URL"; + assert!(contains_from(text, 0, b"authentication required")); + // Split after "Authenti": the second read still finds the marker that straddles both. + assert!(contains_from(text, 8, b"authentication required")); + assert!(!contains_from(text, text.len(), b"authentication required")); + assert!(!contains_from( + b"Authentication succeeded", + 0, + b"authentication required" + )); + } } diff --git a/crates/codexbar-providers/src/gemini.rs b/crates/codexbar-providers/src/gemini.rs index 588e8daa..dc506b00 100644 --- a/crates/codexbar-providers/src/gemini.rs +++ b/crates/codexbar-providers/src/gemini.rs @@ -24,17 +24,22 @@ 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.", - }, - } + let reason = match error { + // Shown as signed out, never as a failed read, so the row points at the Settings sign-in (#282). + CodingError::SignedOut => { + return ProviderError::NotSignedIn { + hint: "Sign in to the Antigravity CLI from Settings > Accounts.", + }; + } + 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.", + }; + ProviderError::Refused { reason } } impl GeminiProvider { @@ -229,6 +234,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..a09fb5c6 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. @@ -85,6 +101,7 @@ impl std::fmt::Display for CodingError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_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." } @@ -219,6 +236,9 @@ pub fn fetch(runner: &dyn CommandRunner, now: DateTime) -> Result CodingError::TimedOut, CommandError::Failed => CodingError::NoUsage, })?; + if output.stderr.to_ascii_lowercase().contains(SIGN_IN_PROMPT) { + return Err(CodingError::SignedOut); + } if !output.success { return Err(CodingError::NoUsage); } @@ -420,10 +440,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 +509,33 @@ 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)); + } + /// 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( From 627e1f1d2411e4f4d3b4970527034784e1b88006 Mon Sep 17 00:00:00 2001 From: HemSoft Date: Tue, 27 Oct 2026 12:38:00 -0400 Subject: [PATCH 2/3] docs: record why background agy runs can't start sign-in (#282) Co-Authored-By: Claude Opus 5.5 --- docs/ANTIGRAVITY_LOGIN_TESTING.md | 3 ++- docs/COMMAND_RUNNER.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 From c59a20aad8bec6559543c93ba19d74d9bb2c0670 Mon Sep 17 00:00:00 2001 From: HemSoft Date: Tue, 27 Oct 2026 13:03:21 -0400 Subject: [PATCH 3/3] refactor: keep the #282 runner and agy error changes CRAP-neutral Move collect's completion check, now including the stop marker, into complete(); share CodingError's text through message(), which Display and the provider's Refused reason both use; and classify a failed /usage run in CodingError::of_failed_run. Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-providers/src/command.rs | 80 ++++++++++++------- crates/codexbar-providers/src/gemini.rs | 23 ++---- .../codexbar-providers/src/gemini_coding.rs | 47 +++++++++-- 3 files changed, 100 insertions(+), 50 deletions(-) diff --git a/crates/codexbar-providers/src/command.rs b/crates/codexbar-providers/src/command.rs index 2f22fae7..972fdcc1 100644 --- a/crates/codexbar-providers/src/command.rs +++ b/crates/codexbar-providers/src/command.rs @@ -96,9 +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) - .filter(|marker| !marker.is_empty()), + options.and_then(|o| o.stop_on_stderr).unwrap_or_default(), ); let status = process.stop().map_err(|_| CommandError::Failed)?; let (stdout, stderr) = output?; @@ -204,36 +202,56 @@ impl Capture

{ } } -/// Whether `marker` (ignoring ASCII case) ends somewhere past `searched`, the length already searched. -fn contains_from(bytes: &[u8], searched: usize, marker: &[u8]) -> bool { - let start = searched.saturating_sub(marker.len() - 1); - bytes[start..] - .windows(marker.len()) - .any(|window| window.eq_ignore_ascii_case(marker)) +/// 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: Option<&str>, + 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 searched = 0; + 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 let Some(marker) = stop_on_stderr { - if contains_from(&stderr.bytes, searched, marker.as_bytes()) { - return Ok((stdout.bytes, stderr.bytes)); - } - searched = stderr.bytes.len(); - } - 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 { @@ -257,14 +275,22 @@ mod tests { #[test] fn a_stop_marker_is_found_across_reads_and_case() { let text = b"Authentication required. Please visit the URL"; - assert!(contains_from(text, 0, b"authentication required")); - // Split after "Authenti": the second read still finds the marker that straddles both. - assert!(contains_from(text, 8, b"authentication required")); - assert!(!contains_from(text, text.len(), b"authentication required")); - assert!(!contains_from( - b"Authentication succeeded", - 0, - b"authentication required" - )); + 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 dc506b00..c5965540 100644 --- a/crates/codexbar-providers/src/gemini.rs +++ b/crates/codexbar-providers/src/gemini.rs @@ -24,22 +24,15 @@ pub struct GeminiProvider { } fn coding_error(error: &CodingError) -> ProviderError { - let reason = match error { + match error { // Shown as signed out, never as a failed read, so the row points at the Settings sign-in (#282). - CodingError::SignedOut => { - return ProviderError::NotSignedIn { - hint: "Sign in to the Antigravity CLI from Settings > Accounts.", - }; - } - 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.", - }; - ProviderError::Refused { reason } + CodingError::SignedOut => ProviderError::NotSignedIn { + hint: "Sign in to the Antigravity CLI from Settings > Accounts.", + }, + _ => ProviderError::Refused { + reason: error.message(), + }, + } } impl GeminiProvider { diff --git a/crates/codexbar-providers/src/gemini_coding.rs b/crates/codexbar-providers/src/gemini_coding.rs index a09fb5c6..6bd1558d 100644 --- a/crates/codexbar-providers/src/gemini_coding.rs +++ b/crates/codexbar-providers/src/gemini_coding.rs @@ -97,9 +97,10 @@ 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 => { @@ -108,7 +109,22 @@ impl std::fmt::Display for CodingError { 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()) } } @@ -236,11 +252,8 @@ pub fn fetch(runner: &dyn CommandRunner, now: DateTime) -> Result CodingError::TimedOut, CommandError::Failed => CodingError::NoUsage, })?; - if output.stderr.to_ascii_lowercase().contains(SIGN_IN_PROMPT) { - return Err(CodingError::SignedOut); - } if !output.success { - return Err(CodingError::NoUsage); + return Err(CodingError::of_failed_run(&output.stderr)); } parse(&output.stdout, now) } @@ -536,6 +549,24 @@ mod tests { 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);