The footer showed invented percentages. It now shows what two harnesses actually report, or says it does not know. lumbridge-core gains an append-only per-profile UsageLedger and a projection that labels every derived value estimated, withholds a burn rate from a single sample, withholds a window fraction with no reported ceiling, withholds an exhaustion estimate that lands after the reset, and reports an expired window as rolled over rather than freezing its last percentage. A missing fact renders as missing, never as zero. (0012) lumbridge-harness is the impure side: processes, clocks, and untrusted wire text in, observations out. Three adapters: - Codex's account/rateLimits/read over the app-server's JSON-RPC stdio. The client cannot express a request outside a two-variant enum and answers every server-to-client request with -32601, so a harness asking Lumbridge for a credential is refused by construction. (0013) - Claude Code's session transcripts, as a byte-offset tail follower that reports nothing until the backlog is read to EOF — a partially-read backlog is indistinguishable from a burst of spend, and the first run against 20 MB reported forty-six billion tokens an hour. The parser models four counters, so the conversations in those files are not representable. (0014) - Claude Code's five-hour and seven-day subscription windows, via a bridge installed as its statusLine command. 0014 had claimed no such surface existed; it does, and the record is corrected in place rather than quietly edited. Lumbridge does not read the OAuth credential to call the account usage endpoint, which is what comparable tools do — AGENTS.md forbids it, and 0015 says so rather than leaving the gap unexplained. Also in here: a capability-check ordering fix in the workspace reducer, where the applied-request replay table was consulted before the capability check and so answered questions the caller had no right to ask; the GPUI spike wired to the live probes with per-harness gauges and provenance chips; and a launcher that matches its own window by PID, because GPUI sets WM_NAME but not _NET_WM_NAME and a title match never succeeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
325 lines
11 KiB
Rust
325 lines
11 KiB
Rust
//! A supervised, byte-capped stdio child.
|
|
//!
|
|
//! The child is launched with a cleared environment and a literal allowlist.
|
|
//! It speaks a machine protocol, so it gets pipes rather than a PTY: a TTY
|
|
//! would invite terminal control sequences in both directions for no benefit.
|
|
//! Its stderr is discarded rather than captured, because a harness's diagnostic
|
|
//! output is exactly the kind of text that ends up in a log carrying a path or
|
|
//! a token fragment.
|
|
//!
|
|
//! Reading a pipe blocks. The child therefore splits into three owners: a
|
|
//! reader that may block on its own thread, a writer, and a killer the
|
|
//! supervising thread keeps. Shutdown kills first and joins second, so a child
|
|
//! that has gone quiet — or hostile — can never hold the caller hostage.
|
|
//!
|
|
//! The child is put in its own process group and the group is what gets
|
|
//! killed. A pipe reports end of file only when *every* write end closes, so
|
|
//! killing the direct child alone is not enough: a launcher that execs the
|
|
//! real program as a grandchild with inherited stdio — which is how the npm
|
|
//! distribution of Codex works — leaves that grandchild holding our stdout,
|
|
//! and the reader would block forever on a pipe nothing will ever write to.
|
|
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use crate::HarnessError;
|
|
|
|
/// Environment a probe child may inherit.
|
|
///
|
|
/// Literal names only: never a pattern and never a user-supplied name.
|
|
///
|
|
/// This deliberately mirrors but does not reuse the terminal crate's private
|
|
/// allowlist. AGENTS.md keeps the harness and terminal boundaries separate,
|
|
/// and the two lists genuinely differ: a probe needs `CODEX_HOME` so it reads
|
|
/// the same account the user's panes use, and has no use for `SHELL`.
|
|
/// `CODEX_HOME` is a filesystem path, never a secret.
|
|
pub const PROBE_ENV_ALLOWLIST: &[&str] = &[
|
|
"CODEX_HOME",
|
|
"HOME",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
"LOGNAME",
|
|
"PATH",
|
|
"TMPDIR",
|
|
"USER",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_DATA_HOME",
|
|
"XDG_RUNTIME_DIR",
|
|
];
|
|
|
|
/// A single frame longer than this is treated as a fault rather than buffered.
|
|
/// A hostile or broken relay must not be able to grow this process's memory.
|
|
pub(crate) const MAX_LINE_BYTES: usize = 256 * 1024;
|
|
|
|
/// A freshly launched child, before its pipes are handed to their owners.
|
|
pub(crate) struct ProbeChild {
|
|
child: Arc<Mutex<Child>>,
|
|
group: u32,
|
|
stdin: ChildStdin,
|
|
stdout: ChildStdout,
|
|
}
|
|
|
|
impl ProbeChild {
|
|
/// Launches a probe program with a minimal environment.
|
|
///
|
|
/// This runs on the caller's thread, following the same pattern as the
|
|
/// runtime actor: start synchronously so a missing program is an immediate
|
|
/// error, then transfer the pipes to their threads.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`HarnessError::EmptyProgram`] for a blank program name and
|
|
/// [`HarnessError::SpawnFailed`] if the process cannot start. The
|
|
/// underlying [`std::io::Error`] is deliberately reduced to its
|
|
/// [`std::io::ErrorKind`] so a filesystem path cannot travel in an error.
|
|
pub(crate) fn spawn(program: &str, arguments: &[&str]) -> Result<Self, HarnessError> {
|
|
if program.trim().is_empty() {
|
|
return Err(HarnessError::EmptyProgram);
|
|
}
|
|
let mut command = Command::new(program);
|
|
command
|
|
.args(arguments)
|
|
.env_clear()
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null());
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
// The child leads its own group, so its group ID is its process ID
|
|
// and every descendant it does not deliberately detach joins it.
|
|
command.process_group(0);
|
|
}
|
|
for name in PROBE_ENV_ALLOWLIST {
|
|
if let Some(value) = std::env::var_os(name) {
|
|
command.env(name, value);
|
|
}
|
|
}
|
|
let mut child = command
|
|
.spawn()
|
|
.map_err(|error| HarnessError::SpawnFailed(error.kind()))?;
|
|
let broken = HarnessError::SpawnFailed(std::io::ErrorKind::BrokenPipe);
|
|
let group = child.id();
|
|
let stdin = child.stdin.take().ok_or(broken)?;
|
|
let stdout = child.stdout.take().ok_or(broken)?;
|
|
Ok(Self {
|
|
child: Arc::new(Mutex::new(child)),
|
|
group,
|
|
stdin,
|
|
stdout,
|
|
})
|
|
}
|
|
|
|
/// Splits the child into its three independent owners.
|
|
pub(crate) fn into_parts(self) -> (ProbeWriter, ProbeReader, ProbeKiller) {
|
|
(
|
|
ProbeWriter { stdin: self.stdin },
|
|
ProbeReader {
|
|
reader: BufReader::new(self.stdout),
|
|
},
|
|
ProbeKiller {
|
|
child: self.child,
|
|
group: self.group,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The writing half.
|
|
pub(crate) struct ProbeWriter {
|
|
stdin: ChildStdin,
|
|
}
|
|
|
|
impl ProbeWriter {
|
|
/// Writes one newline-terminated frame.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`HarnessError::Eof`] once the child has closed its input.
|
|
pub(crate) fn write_line(&mut self, line: &str) -> Result<(), HarnessError> {
|
|
self.stdin
|
|
.write_all(line.as_bytes())
|
|
.and_then(|()| self.stdin.write_all(b"\n"))
|
|
.and_then(|()| self.stdin.flush())
|
|
.map_err(|_| HarnessError::Eof)
|
|
}
|
|
}
|
|
|
|
/// The reading half. Its methods block, so it belongs on its own thread.
|
|
pub(crate) struct ProbeReader {
|
|
reader: BufReader<ChildStdout>,
|
|
}
|
|
|
|
impl ProbeReader {
|
|
/// Reads one frame, refusing an oversized line instead of buffering it.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`HarnessError::Eof`] at end of stream and
|
|
/// [`HarnessError::OversizedLine`] past [`MAX_LINE_BYTES`].
|
|
pub(crate) fn read_line(&mut self) -> Result<String, HarnessError> {
|
|
let mut buffer = Vec::new();
|
|
let read = self
|
|
.reader
|
|
.by_ref()
|
|
.take(MAX_LINE_BYTES as u64 + 1)
|
|
.read_until(b'\n', &mut buffer)
|
|
.map_err(|_| HarnessError::Eof)?;
|
|
if read == 0 {
|
|
return Err(HarnessError::Eof);
|
|
}
|
|
if read > MAX_LINE_BYTES {
|
|
return Err(HarnessError::OversizedLine(read));
|
|
}
|
|
String::from_utf8(buffer).map_err(|_| HarnessError::Malformed)
|
|
}
|
|
}
|
|
|
|
/// The half a supervisor keeps so it can always stop the child.
|
|
#[derive(Clone)]
|
|
pub(crate) struct ProbeKiller {
|
|
child: Arc<Mutex<Child>>,
|
|
group: u32,
|
|
}
|
|
|
|
impl ProbeKiller {
|
|
/// Ends the child and everything in its process group, then reaps it.
|
|
///
|
|
/// Killing the whole group is what closes every write end of our stdout
|
|
/// pipe, which is what unblocks the reader. Idempotent: killing an
|
|
/// already-dead process is not an error worth reporting, and a poisoned
|
|
/// lock means another thread died mid-kill, which the operating system has
|
|
/// already resolved.
|
|
pub(crate) fn kill(&self) {
|
|
self.kill_group();
|
|
if let Ok(mut child) = self.child.lock() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn kill_group(&self) {
|
|
use nix::sys::signal::{Signal, killpg};
|
|
use nix::unistd::Pid;
|
|
|
|
if let Ok(group) = i32::try_from(self.group) {
|
|
let _ = killpg(Pid::from_raw(group), Signal::SIGKILL);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn kill_group(&self) {}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{MAX_LINE_BYTES, PROBE_ENV_ALLOWLIST, ProbeChild};
|
|
use crate::HarnessError;
|
|
|
|
#[test]
|
|
fn an_empty_program_is_refused_before_spawning() {
|
|
assert!(matches!(
|
|
ProbeChild::spawn(" ", &[]).err(),
|
|
Some(HarnessError::EmptyProgram)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_program_reports_a_kind_and_never_a_path() {
|
|
let Err(error) = ProbeChild::spawn("lumbridge-no-such-probe-binary", &[]) else {
|
|
panic!("a missing program cannot spawn");
|
|
};
|
|
assert!(matches!(error, HarnessError::SpawnFailed(_)));
|
|
assert!(
|
|
!error.to_string().contains("lumbridge-no-such-probe-binary"),
|
|
"an error must not carry a filesystem path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_allowlist_is_literal_sorted_and_free_of_secret_bearing_names() {
|
|
let mut sorted = PROBE_ENV_ALLOWLIST.to_vec();
|
|
sorted.sort_unstable();
|
|
assert_eq!(sorted, PROBE_ENV_ALLOWLIST);
|
|
for name in PROBE_ENV_ALLOWLIST {
|
|
let upper = name.to_uppercase();
|
|
assert!(
|
|
!upper.contains("TOKEN")
|
|
&& !upper.contains("KEY")
|
|
&& !upper.contains("SECRET")
|
|
&& !upper.contains("PASSWORD"),
|
|
"{name} could carry a credential into a child"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_round_trips_one_frame() {
|
|
let Ok(child) = ProbeChild::spawn("cat", &[]) else {
|
|
return; // cat is not guaranteed on every supported platform.
|
|
};
|
|
let (mut writer, mut reader, killer) = child.into_parts();
|
|
writer
|
|
.write_line(r#"{"ok":true}"#)
|
|
.expect("cat accepts input");
|
|
assert_eq!(
|
|
reader.read_line().expect("cat echoes the line"),
|
|
"{\"ok\":true}\n"
|
|
);
|
|
killer.kill();
|
|
}
|
|
|
|
#[test]
|
|
fn an_oversized_frame_is_refused_rather_than_buffered() {
|
|
// Generated by the child so the test never deadlocks writing more than
|
|
// a pipe buffer into a process that is not reading.
|
|
let script = format!("head -c {} /dev/zero | tr '\\0' 'x'", MAX_LINE_BYTES + 16);
|
|
let Ok(child) = ProbeChild::spawn("sh", &["-c", &script]) else {
|
|
return;
|
|
};
|
|
let (_writer, mut reader, killer) = child.into_parts();
|
|
assert!(matches!(
|
|
reader.read_line(),
|
|
Err(HarnessError::OversizedLine(_))
|
|
));
|
|
killer.kill();
|
|
}
|
|
|
|
#[test]
|
|
fn killing_a_child_unblocks_a_reader_blocked_on_a_grandchild() {
|
|
// A launcher that backgrounds a helper and waits is the shape of the
|
|
// npm Codex distribution: the helper inherits our stdout, so killing
|
|
// only the direct child would leave the reader blocked forever.
|
|
let Ok(child) = ProbeChild::spawn("sh", &["-c", "sleep 30 & wait"]) else {
|
|
return;
|
|
};
|
|
let (_writer, mut reader, killer) = child.into_parts();
|
|
let reading = std::thread::spawn(move || reader.read_line());
|
|
killer.kill();
|
|
let outcome = reading.join().expect("the reader thread must not panic");
|
|
assert!(
|
|
matches!(outcome, Err(HarnessError::Eof)),
|
|
"killing the group must end the read rather than hang it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn killing_a_child_unblocks_its_reader() {
|
|
// `sleep` never writes, so the reader is blocked until the kill lands.
|
|
let Ok(child) = ProbeChild::spawn("sleep", &["30"]) else {
|
|
return;
|
|
};
|
|
let (_writer, mut reader, killer) = child.into_parts();
|
|
let reading = std::thread::spawn(move || reader.read_line());
|
|
killer.kill();
|
|
let outcome = reading.join().expect("the reader thread must not panic");
|
|
assert!(
|
|
matches!(outcome, Err(HarnessError::Eof)),
|
|
"a killed child must end the read rather than hang it"
|
|
);
|
|
}
|
|
}
|