Files
lumbridge-code/crates/lumbridge-harness/src/child.rs
T
Metal AgentandClaude Opus 5 ef52aa7ce2 Replace the footer's placeholder usage with a real observation ledger
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>
2026-08-31 21:47:11 -07:00

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"
);
}
}