Skip to main content

seher/codexbar/
client.rs

1//! Spawns the external `codexbar` binary and parses its JSON usage payload.
2//!
3//! Mirrors `seher-ts/packages/sdk/src/codexbar/client.ts`. We follow the
4//! codebase convention of running blocking `std::process` work on a Tokio
5//! blocking thread (see `kiro::client`) rather than pulling in the tokio
6//! `process` feature. A hard timeout is enforced by polling `try_wait` and
7//! killing the child once the deadline passes.
8
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Stdio};
11use std::time::{Duration, Instant};
12
13#[cfg(unix)]
14use std::os::unix::process::CommandExt;
15
16use super::errors::CodexBarError;
17use super::types::CodexBarUsageResponse;
18
19const DEFAULT_BIN: &str = "/usr/local/bin/codexbar";
20const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
21const POLL_INTERVAL: Duration = Duration::from_millis(50);
22/// codexbar exits with this code when its own internal usage fetch times out.
23const TIMEOUT_EXIT_CODE: i32 = 4;
24
25/// Options for [`run_codexbar_usage`]. Defaults mirror seher-ts.
26#[derive(Debug, Clone, Default)]
27pub struct RunCodexBarUsageOptions {
28    /// Explicit binary path; overrides `SEHER_CODEXBAR_BIN`, PATH lookup and the default.
29    pub bin_path: Option<String>,
30    /// `--account <label>` selector.
31    pub account_label: Option<String>,
32    /// `--account-index <n>` selector.
33    pub account_index: Option<i64>,
34    /// Hard timeout; defaults to 15s.
35    pub timeout: Option<Duration>,
36}
37
38fn which_codexbar() -> Option<PathBuf> {
39    let path = std::env::var_os("PATH")?;
40    for dir in std::env::split_paths(&path) {
41        if dir.as_os_str().is_empty() {
42            continue;
43        }
44        let candidate = dir.join("codexbar");
45        if candidate.is_file() {
46            return Some(candidate);
47        }
48    }
49    None
50}
51
52fn resolve_bin_path(explicit: Option<&str>) -> PathBuf {
53    if let Some(p) = explicit
54        && !p.is_empty()
55    {
56        return PathBuf::from(p);
57    }
58    if let Ok(env_bin) = std::env::var("SEHER_CODEXBAR_BIN")
59        && !env_bin.is_empty()
60    {
61        return PathBuf::from(env_bin);
62    }
63    which_codexbar().unwrap_or_else(|| PathBuf::from(DEFAULT_BIN))
64}
65
66/// Run `codexbar usage --format json --provider <provider>` and return the entry
67/// matching `provider`.
68///
69/// # Errors
70///
71/// Returns [`CodexBarError`] when the binary is missing, the process fails or
72/// times out, the output is not valid JSON, or no entry matches `provider`.
73pub async fn run_codexbar_usage(
74    provider: &str,
75    opts: &RunCodexBarUsageOptions,
76) -> Result<CodexBarUsageResponse, CodexBarError> {
77    let bin = resolve_bin_path(opts.bin_path.as_deref());
78    let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
79    let provider = provider.to_string();
80
81    let mut args: Vec<String> = vec![
82        "usage".into(),
83        "--format".into(),
84        "json".into(),
85        "--provider".into(),
86        provider.clone(),
87    ];
88    if let Some(label) = &opts.account_label {
89        args.push("--account".into());
90        args.push(label.clone());
91    }
92    if let Some(idx) = opts.account_index {
93        args.push("--account-index".into());
94        args.push(idx.to_string());
95    }
96
97    let provider_for_blocking = provider.clone();
98    let raw = tokio::task::spawn_blocking(move || {
99        run_blocking(&bin, &args, timeout, &provider_for_blocking)
100    })
101    .await
102    .map_err(|e| CodexBarError::Spawn(e.to_string()))??;
103
104    parse_response(&raw.stdout, &raw.stderr, raw.code, &provider)
105}
106
107struct RawOutput {
108    stdout: String,
109    stderr: String,
110    code: Option<i32>,
111}
112
113/// Drain a child pipe to a `String` on its own thread. Returning the reader as a
114/// join handle lets the caller consume stdout/stderr concurrently with the
115/// process-completion poll loop.
116fn spawn_reader<R: std::io::Read + Send + 'static>(
117    pipe: Option<R>,
118) -> std::thread::JoinHandle<String> {
119    std::thread::spawn(move || {
120        let mut buf = String::new();
121        if let Some(mut p) = pipe {
122            let _ = p.read_to_string(&mut buf);
123        }
124        buf
125    })
126}
127
128/// Build the `codexbar` invocation with the platform-appropriate isolation.
129///
130/// On Unix the child is detached into its own session (`setsid`): `CodexBarCLI`
131/// probes agent CLIs through an internal pty and, when it shares the caller's
132/// session, it moves the controlling terminal's foreground process group to
133/// its own group and exits without restoring it. Ctrl-C then signals a dead
134/// group and the host process becomes uninterruptible. A fresh session has no
135/// controlling terminal, so codexbar cannot touch ours. It also implies a new
136/// process group, so terminal-generated signals (e.g. Ctrl-C SIGINT) don't
137/// reach codexbar, and our timeout kill can take down that whole group. A mere
138/// `process_group(0)` is not enough — the child would stay in our session and
139/// could still claim the terminal.
140fn build_command(bin: &Path, args: &[String]) -> Command {
141    let mut cmd = Command::new(bin);
142    cmd.args(args)
143        .stdin(Stdio::null())
144        .stdout(Stdio::piped())
145        .stderr(Stdio::piped());
146    #[cfg(unix)]
147    {
148        // SAFETY: the closure calls setsid(2) and, on failure, builds an
149        // io::Error from errno; both are async-signal-safe and allocation-free,
150        // so it is safe to run between fork and exec.
151        unsafe {
152            cmd.pre_exec(|| {
153                // setsid fails only when the caller already leads a process
154                // group; a freshly forked child never does, but surface the
155                // error instead of silently keeping the parent's session.
156                if libc::setsid() == -1 {
157                    return Err(std::io::Error::last_os_error());
158                }
159                Ok(())
160            });
161        }
162    }
163    cmd
164}
165
166/// SIGKILL the child's whole process group, falling back to the child alone.
167///
168/// After [`build_command`]'s setsid the child leads its own process group
169/// (pgid == its pid), so a negative-pid kill(2) also takes down any probe
170/// helpers codexbar spawned into that group. The caller still holds the
171/// unreaped child handle, so the pid cannot have been recycled.
172fn kill_process_group(child: &mut Child) {
173    #[cfg(unix)]
174    if let Ok(pid) = i32::try_from(child.id()) {
175        // SAFETY: kill(2) takes the pgid by value and touches no memory.
176        if unsafe { libc::kill(-pid, libc::SIGKILL) } == 0 {
177            return;
178        }
179    }
180    let _ = child.kill();
181}
182
183fn run_blocking(
184    bin: &Path,
185    args: &[String],
186    timeout: Duration,
187    provider: &str,
188) -> Result<RawOutput, CodexBarError> {
189    let mut cmd = build_command(bin, args);
190
191    let mut child = match cmd.spawn() {
192        Ok(c) => c,
193        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
194            return Err(CodexBarError::NotFound {
195                bin: bin.display().to_string(),
196            });
197        }
198        Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
199    };
200
201    // Drain stdout/stderr on dedicated threads so a child that fills the OS pipe
202    // buffer (~64KB) can't deadlock: it would block on write while our `try_wait`
203    // loop waits for an exit that never comes. Mirrors seher-ts, which reads the
204    // streams concurrently with process completion.
205    let stdout_reader = spawn_reader(child.stdout.take());
206    let stderr_reader = spawn_reader(child.stderr.take());
207
208    let timeout_ms = timeout.as_millis();
209    let deadline = Instant::now() + timeout;
210    let status = loop {
211        match child.try_wait() {
212            Ok(Some(status)) => break status,
213            Ok(None) => {
214                if Instant::now() >= deadline {
215                    kill_process_group(&mut child);
216                    let _ = child.wait();
217                    // Readers unblock at EOF once the killed child's pipes close.
218                    let _ = stdout_reader.join();
219                    let _ = stderr_reader.join();
220                    return Err(CodexBarError::Timeout {
221                        provider: provider.to_string(),
222                        ms: timeout_ms,
223                    });
224                }
225                std::thread::sleep(POLL_INTERVAL);
226            }
227            Err(e) => return Err(CodexBarError::Spawn(e.to_string())),
228        }
229    };
230
231    let stdout = stdout_reader.join().unwrap_or_default();
232    let stderr = stderr_reader.join().unwrap_or_default();
233
234    // codexbar exits 4 when its own usage fetch times out internally.
235    if status.code() == Some(TIMEOUT_EXIT_CODE) {
236        return Err(CodexBarError::Timeout {
237            provider: provider.to_string(),
238            ms: timeout_ms,
239        });
240    }
241
242    Ok(RawOutput {
243        stdout,
244        stderr,
245        code: status.code(),
246    })
247}
248
249fn parse_response(
250    stdout: &str,
251    stderr: &str,
252    code: Option<i32>,
253    provider: &str,
254) -> Result<CodexBarUsageResponse, CodexBarError> {
255    if code != Some(0) {
256        return Err(CodexBarError::Exited {
257            code,
258            provider: provider.to_string(),
259            stderr: stderr.trim().to_string(),
260        });
261    }
262
263    let value: serde_json::Value =
264        serde_json::from_str(stdout).map_err(|e| CodexBarError::Parse(e.to_string()))?;
265    // codexbar emits a JSON array (one entry per provider) even when --provider
266    // selects a single one -- unwrap to the matching entry.
267    let entries = value
268        .as_array()
269        .ok_or_else(|| CodexBarError::NonArray(provider.to_string()))?;
270    for item in entries {
271        let Ok(entry) = serde_json::from_value::<CodexBarUsageResponse>(item.clone()) else {
272            continue;
273        };
274        if entry.provider == provider {
275            return Ok(entry);
276        }
277    }
278    Err(CodexBarError::NoEntry(provider.to_string()))
279}
280
281#[cfg(test)]
282#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn resolve_bin_path_prefers_explicit() {
288        let p = resolve_bin_path(Some("/custom/codexbar"));
289        assert_eq!(p, PathBuf::from("/custom/codexbar"));
290    }
291
292    #[test]
293    fn parse_response_unwraps_matching_provider() {
294        let stdout = r#"[{"provider":"claude","usage":{"primary":{"usedPercent":40}}}]"#;
295        let entry = parse_response(stdout, "", Some(0), "claude").expect("entry");
296        assert_eq!(entry.provider, "claude");
297        let primary = entry.usage.primary.expect("primary");
298        assert!((primary.used_percent - 40.0).abs() < f64::EPSILON);
299    }
300
301    #[test]
302    fn parse_response_no_entry_for_unknown_provider() {
303        let stdout = r#"[{"provider":"claude","usage":{}}]"#;
304        let err = parse_response(stdout, "", Some(0), "zai").expect_err("no entry");
305        assert!(matches!(err, CodexBarError::NoEntry(_)));
306    }
307
308    #[test]
309    fn parse_response_nonzero_exit_is_error() {
310        let err = parse_response("", "boom", Some(2), "claude").expect_err("exit err");
311        assert!(matches!(err, CodexBarError::Exited { .. }));
312    }
313
314    #[test]
315    fn parse_response_non_array_payload() {
316        let err = parse_response("{}", "", Some(0), "claude").expect_err("non-array");
317        assert!(matches!(err, CodexBarError::NonArray(_)));
318    }
319
320    /// Guards the terminal-safety property of [`build_command`]: the child must
321    /// lead a brand-new session, otherwise codexbar can steal the controlling
322    /// terminal's foreground process group and make the host process
323    /// uninterruptible via Ctrl-C.
324    #[cfg(unix)]
325    #[test]
326    fn build_command_detaches_child_into_own_session() {
327        let mut child = build_command(Path::new("/bin/sleep"), &["5".into()])
328            .spawn()
329            .expect("spawn sleep");
330        // `spawn` reports exec errors through the parent, so by the time it
331        // returns Ok the pre_exec hook (setsid) has already run.
332        let child_pid = i32::try_from(child.id()).expect("pid fits in i32");
333        // SAFETY: getsid takes a pid by value and touches no memory.
334        let session_of_child = unsafe { libc::getsid(child_pid) };
335        // SAFETY: getsid(0) queries the calling process; no memory involved.
336        let session_of_parent = unsafe { libc::getsid(0) };
337        let _ = child.kill();
338        let _ = child.wait();
339        assert_eq!(
340            session_of_child, child_pid,
341            "child should lead a fresh session"
342        );
343        assert_ne!(
344            session_of_child, session_of_parent,
345            "child must not share our session"
346        );
347    }
348}