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
64 changes: 63 additions & 1 deletion crates/codexbar-providers/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -198,20 +202,56 @@ impl<P: pipe::Pipe> Capture<P> {
}
}

/// 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<P: pipe::Pipe>(
child: &mut std::process::Child,
stdout_closed: bool,
stderr: &Capture<P>,
stop: &mut StopMarker,
) -> Result<bool, CommandError> {
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<u8>, Vec<u8>), 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 {
Expand All @@ -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");
}
}
37 changes: 28 additions & 9 deletions crates/codexbar-providers/src/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,13 @@ pub struct GeminiProvider<H: HttpClient> {
}

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(),
},
}
}
Expand Down Expand Up @@ -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<Utc> = "2026-10-23T16:38:00Z".parse().unwrap();
Expand Down
113 changes: 101 additions & 12 deletions crates/codexbar-providers/src/gemini_coding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
}
Expand Down Expand Up @@ -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.
Expand All @@ -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())
}
}

Expand Down Expand Up @@ -220,7 +253,7 @@ pub fn fetch(runner: &dyn CommandRunner, now: DateTime<Utc>) -> Result<CodingUsa
CommandError::Failed => CodingError::NoUsage,
})?;
if !output.success {
return Err(CodingError::NoUsage);
return Err(CodingError::of_failed_run(&output.stderr));
}
parse(&output.stdout, now)
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions crates/codexbar-providers/src/muse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ impl<R: CommandRunner> UsageProvider for MuseProvider<R> {
],
env: vec![("MUSE_LOGIN", "0"), ("MUSE_NO_AUTO_UPDATE", "1")],
output_limit: OUTPUT_LIMIT,
stop_on_stderr: None,
};
let result = self
.runner
Expand Down
25 changes: 25 additions & 0 deletions crates/codexbar-providers/tests/command_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/codexbar-providers/tests/command_runner/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading