From f904a455f942e6005550a0c05eb30d057d744ecc Mon Sep 17 00:00:00 2001 From: codehippie1 Date: Thu, 18 Jun 2026 16:25:12 -0400 Subject: [PATCH 1/3] fix(proxy): stop drains instead of vacating the port; default guard watchdog A soft `burnwall stop` now leaves the proxy up as a pass-through relay and pauses protection, so an already-running AI tool keeps working instead of failing on a dead port. The relay does no scanning/budget/cost capture and retires itself once traffic goes idle, freeing the port. `burnwall stop --hard` keeps the immediate-terminate behavior (used by uninstall/upgrade), and a fresh `start` takes over a draining proxy so `stop` -> `start` re-arms protection seamlessly. `burnwall start --daemon` now spawns the guard watchdog by default (`--no-guard` to opt out): it pauses shell routing within seconds when the proxy dies silently (best-effort relaunch on). The guard now watches the proxy's ACTUAL port -- a `--port` differing from config previously made it misread a healthy proxy as dead. The dead-proxy guidance in `status`/`stop` now points at `burnwall start` (revive) and `burnwall recover` (go direct) and shows a draining state. Bumps 0.11.0 -> 0.11.1. --- CHANGELOG.md | 31 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- editor/vscode/package.json | 2 +- packaging/mcp/server.json | 2 +- src/bypass.rs | 42 ++++++++ src/cli/daemon.rs | 116 ++++++++++++++++++++- src/cli/doctor.rs | 1 + src/cli/guard.rs | 25 +++-- src/cli/sidecar.rs | 3 + src/cli/start.rs | 80 +++++++++++++- src/cli/status.rs | 36 +++++-- src/cli/stop.rs | 161 ++++++++++++++++++++--------- src/cli/uninstall.rs | 6 +- src/cli/upgrade.rs | 7 +- src/proxy/handler.rs | 16 +++ src/proxy/mod.rs | 6 ++ tests/integration/daemon_test.rs | 100 ++++++++++++++++-- tests/integration/pause_test.rs | 47 +++++++++ tests/integration/pipeline_test.rs | 24 +++++ tests/integration/torture_test.rs | 1 + 21 files changed, 622 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce76338..e80dea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to Burnwall. +## [0.11.1] — 2026-06-18 + +A resilience release for the proxy lifecycle: stopping Burnwall can no longer +leave an already-running AI tool stranded on a dead port. + +### Changed + +- **`burnwall stop` no longer cuts off a tool that's still running.** By default + it now hands the proxy off to a pass-through relay and leaves the port serving, + so a tool mid-session keeps working instead of failing with a bare connection + error. The relay does no scanning, budget, or cost capture (protection is off) + and retires itself once traffic goes idle, freeing the port. Use + `burnwall stop --hard` to terminate immediately and free the port now. +- **Clearer recovery guidance.** When a shell is routed at a proxy that isn't + answering, `burnwall status` now points to `burnwall start` (revive — running + tools recover at once) and `burnwall recover` (go direct, then restart tools), + and shows a distinct "stopped (draining)" state instead of a misleading green. + +### Added + +- **Guard watchdog on by default.** `burnwall start --daemon` now also runs the + guard, which notices a silently-dead proxy within seconds and pauses shell + routing so new shells go direct (with a best-effort relaunch). Opt out with + `--no-guard`. + +### Fixed + +- The guard watched the configured port even when the proxy was started on a + different `--port`, so a non-default port could make it treat a healthy proxy + as dead. It now watches the proxy's actual port. + ## [0.11.0] — 2026-06-18 A dashboard-polish release: clearer, more glanceable surfaces, plus two new diff --git a/Cargo.lock b/Cargo.lock index 2221f94..50aed2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "burnwall" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index bc7a385..d0bed21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "burnwall" -version = "0.11.0" +version = "0.11.1" edition = "2024" rust-version = "1.87" description = "Local proxy for AI coding tools (Claude Code, Codex CLI, Aider): cache-aware cost tracking, path/command security checks, daily budget enforcement. Zero telemetry." diff --git a/editor/vscode/package.json b/editor/vscode/package.json index c137b9c..5a407bf 100644 --- a/editor/vscode/package.json +++ b/editor/vscode/package.json @@ -2,7 +2,7 @@ "name": "burnwall", "displayName": "Burnwall", "description": "Cost + security for your AI coding agents, at a glance — reads your local Burnwall CLI.", - "version": "0.11.0", + "version": "0.11.1", "publisher": "intbot", "license": "FSL-1.1-MIT", "repository": { "type": "git", "url": "https://github.com/intbot/burnwall" }, diff --git a/packaging/mcp/server.json b/packaging/mcp/server.json index 8b96088..da9af88 100644 --- a/packaging/mcp/server.json +++ b/packaging/mcp/server.json @@ -6,7 +6,7 @@ "url": "https://github.com/intbot/burnwall", "source": "github" }, - "version": "0.11.0", + "version": "0.11.1", "packages": [ { "registryType": "oci", diff --git a/src/bypass.rs b/src/bypass.rs index 67a43d2..1648f5b 100644 --- a/src/bypass.rs +++ b/src/bypass.rs @@ -46,6 +46,12 @@ pub const DEFAULT_PAUSE_SECS: u64 = 5 * 60; pub const MAX_PAUSE_SECS: u64 = 24 * 3600; /// How long an unused allow-once stays armed before it expires. pub const ALLOW_ONCE_TTL_SECS: u64 = 10 * 60; +/// Backstop expiry for a `Drain` (the relay a soft `burnwall stop` leaves +/// behind to keep already-running tools alive). The real teardown is the +/// proxy's idle-retire monitor; this is only a safety net so a drainer that +/// somehow never goes idle can't relay unchecked forever. A fresh `start` +/// also clears any stale drain on boot, so protection is never silently off. +pub const DRAIN_BACKSTOP_SECS: u64 = 12 * 3600; /// On-disk shape. Tiny and stable: a mode tag plus an absolute expiry. #[derive(Debug, Serialize, Deserialize)] @@ -59,6 +65,11 @@ struct StateFile { enum Mode { Pause, AllowOnce, + /// Soft-`stop` drain: relay everything unchecked, like `Pause`, but with no + /// auto-resume — the proxy is on its way out and only stays up to keep + /// already-running tools off a dead port. The proxy's idle-retire monitor + /// shuts it down once traffic stops; `DRAIN_BACKSTOP_SECS` is the safety net. + Drain, } /// The live bypass state, as the proxy and status surfaces see it. @@ -71,6 +82,10 @@ pub enum Bypass { /// The next request relays unchecked (consume-on-use), then protection /// restores. Expires unused after the TTL. AllowOnce { expires_in_secs: i64 }, + /// A soft `burnwall stop` left the proxy up as a pure relay so + /// already-running tools don't hit a dead port. Relays unchecked; the + /// proxy retires itself once traffic goes idle. No auto-resume. + Draining, } /// Default state-file path (`/pause.json`), `None` if no data dir @@ -105,9 +120,17 @@ pub fn read_at(path: &Path, now: i64) -> Bypass { Mode::AllowOnce => Bypass::AllowOnce { expires_in_secs: remaining, }, + Mode::Drain => Bypass::Draining, } } +/// True if a drain (soft-stop relay) is currently in effect at the default +/// path. Used by `start` (to retire a stale drainer and take over the port) +/// and by the proxy's idle-retire monitor. +pub fn is_draining(now: i64) -> bool { + matches!(read(now), Bypass::Draining) +} + /// Read the bypass state at the default path. pub fn read(now: i64) -> Bypass { match default_path() { @@ -135,6 +158,13 @@ pub fn arm_allow_once(now: i64) -> std::io::Result { write_state(Mode::AllowOnce, now + ALLOW_ONCE_TTL_SECS as i64) } +/// Enter drain (soft `burnwall stop`): the running proxy relays unchecked and +/// retires itself when idle. Backstopped at [`DRAIN_BACKSTOP_SECS`] so it can +/// never silently relay forever. Returns the expiry timestamp written. +pub fn drain(now: i64) -> std::io::Result { + write_state(Mode::Drain, now + DRAIN_BACKSTOP_SECS as i64) +} + /// Clear any pause / armed allow-once. `Ok(true)` if a file was removed. pub fn clear() -> std::io::Result { let Some(path) = default_path() else { @@ -244,6 +274,18 @@ mod tests { assert_eq!(read_at(&p, 1000), Bypass::None); } + #[test] + fn drain_reads_as_draining_until_backstop() { + let p = temp_path("drain-active.json"); + write_at(&p, Mode::Drain, 5000); + assert_eq!(read_at(&p, 1000), Bypass::Draining); + // Past the backstop it self-clears (protection restores) just like the + // other modes — a drainer can never relay unchecked forever. + write_at(&p, Mode::Drain, 1000); + assert_eq!(read_at(&p, 1000), Bypass::None); + assert!(!p.exists()); + } + #[test] fn expired_allow_once_is_none() { let p = temp_path("allow-once-expired.json"); diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index 1943e55..13189e5 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -105,6 +105,107 @@ pub fn running_pid() -> anyhow::Result> { } } +/// Decide whether a fresh `start` may proceed. Returns `Some(pid)` if a +/// fully-protecting proxy is already running — the caller must refuse to start a +/// second one. Returns `None` if the path is clear: either nothing was running, +/// or a DRAIN-only relay (left by a soft `burnwall stop` to keep already-running +/// tools alive) was retired here to free the port. Shared by the foreground +/// `start` and the `--daemon` launcher so `stop` → `start` re-arms protection +/// instead of failing "already running". +pub fn protecting_proxy_blocking_start() -> anyhow::Result> { + let Some(pid) = running_pid()? else { + return Ok(None); + }; + if !crate::bypass::is_draining(chrono::Utc::now().timestamp()) { + return Ok(Some(pid)); // a real, protecting proxy — caller should bail + } + tracing::info!("retiring the draining proxy (PID {pid}) to start a protected one"); + let _ = request_graceful_shutdown(pid); + let deadline = Instant::now() + Duration::from_secs(12); + while process_is_alive(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + if process_is_alive(pid) { + let _ = terminate_process(pid); + } + remove_pid_file().ok(); + clear_shutdown_file(); + Ok(None) +} + +// ───────────────────────── guard watchdog lifecycle ───────────────────────── +// +// `start --daemon` spawns a `burnwall guard` watchdog alongside the proxy +// (unless `--no-guard`). It outlives a proxy crash and auto-pauses routing so a +// silently-dead proxy (the classic Windows AV-quarantine case) can't keep +// stranding new shells. Tracked by its own PID file so `stop` can retire it and +// a second `start` doesn't stack duplicates. + +/// Absolute path to the guard watchdog's PID file +/// (`/burnwall.guard.pid`). +pub fn guard_pid_file_path() -> anyhow::Result { + Ok(data_dir() + .context("locating the Burnwall data directory")? + .join("burnwall.guard.pid")) +} + +/// PID of a live guard watchdog, if one is running. A file pointing at a dead +/// (or reused, non-burnwall) PID is stale — removed, and `None` returned. +pub fn running_guard_pid() -> anyhow::Result> { + let path = guard_pid_file_path()?; + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + }; + match contents.trim().parse::() { + Ok(pid) if pid > 0 && process_is_alive(pid) => Ok(Some(pid)), + _ => { + let _ = fs::remove_file(&path); + Ok(None) + } + } +} + +/// Spawn the guard watchdog as a detached process (if one isn't already +/// running) and record its PID. Best-effort restart of a crashed proxy is on +/// (`--restart`): the guard's primary action, pausing routing, always happens +/// first, so a quarantined binary fails the relaunch safely rather than +/// stranding shells. Returns the guard PID. +pub fn spawn_guard(port: u16) -> anyhow::Result { + if let Some(pid) = running_guard_pid()? { + return Ok(pid); // already watching + } + let exe = std::env::current_exe().context("locating the burnwall executable")?; + let pid = spawn_detached( + &exe, + &[ + "guard".to_string(), + "--port".to_string(), + port.to_string(), + "--restart".to_string(), + ], + ) + .context("spawning the guard watchdog")?; + let path = guard_pid_file_path()?; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(&path, pid.to_string()); + Ok(pid) +} + +/// Retire the guard watchdog (called by `stop`): terminate it if running and +/// clear its PID file. Best-effort — a stop must never fail on guard cleanup. +pub fn stop_guard() { + if let Ok(Some(pid)) = running_guard_pid() { + let _ = terminate_process(pid); + } + if let Ok(path) = guard_pid_file_path() { + let _ = fs::remove_file(path); + } +} + /// How the previous daemon run ended, inferred at the next start. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriorExit { @@ -170,7 +271,9 @@ pub fn note_clean_exit() { /// Re-exec `burnwall start` (without `--daemon`) as a detached background /// process, then wait for it to write its PID file before returning. pub async fn spawn_background(args: &StartArgs) -> anyhow::Result<()> { - if let Some(pid) = running_pid()? { + // A fully-protecting proxy blocks a second start; a soft-stop drain relay is + // retired here so `stop` → `start --daemon` re-arms protection seamlessly. + if let Some(pid) = protecting_proxy_blocking_start()? { anyhow::bail!( "Burnwall is already running (PID {pid}). Use `burnwall stop` to stop it first." ); @@ -204,6 +307,17 @@ pub async fn spawn_background(args: &StartArgs) -> anyhow::Result<()> { resolved_port(args) )); } + // Guard watchdog (default): outlives a proxy crash and auto-pauses + // routing so a silently-dead proxy can't keep stranding new shells. + // Opt out with `--no-guard`. + if !args.no_guard { + match spawn_guard(resolved_port(args)) { + Ok(gpid) => println!( + " Watchdog: guard running (PID {gpid}) — auto-recovers routing if the proxy dies." + ), + Err(e) => tracing::warn!("could not start the guard watchdog: {e}"), + } + } // Name the log file so a later crash is diagnosable (L-H2) — // before this, a dead daemon left nothing to look at. if let Some(log) = resolved_child_log_path() { diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 97eb01b..efe8f69 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -183,6 +183,7 @@ async fn run_fix(i: &DoctorInput) -> anyhow::Result<()> { rewrite_anthropic_cache: false, no_routing: false, pause_routing_on_exit: false, + no_guard: false, }; super::daemon::spawn_background(&start_args).await?; diff --git a/src/cli/guard.rs b/src/cli/guard.rs index 426b2a2..524468c 100644 --- a/src/cli/guard.rs +++ b/src/cli/guard.rs @@ -30,6 +30,11 @@ use super::routing; #[derive(Args, Debug)] pub struct GuardArgs { + /// Port to watch. Overrides `proxy.port` from config — the daemon launcher + /// passes the proxy's ACTUAL resolved port so a `--port` that differs from + /// config doesn't make the guard watch the wrong (dead-looking) port. + #[arg(long)] + pub port: Option, /// Seconds between checks. #[arg(long, default_value_t = 5)] pub interval: u64, @@ -88,11 +93,13 @@ fn any_routing_active() -> bool { } pub async fn run_cmd(args: GuardArgs) -> Result<()> { - let port = config::default_path() - .ok() - .and_then(|p| config::load_or_default(&p).ok()) - .map(|c| c.proxy.port) - .unwrap_or(4100); + let port = args.port.unwrap_or_else(|| { + config::default_path() + .ok() + .and_then(|p| config::load_or_default(&p).ok()) + .map(|c| c.proxy.port) + .unwrap_or(4100) + }); let threshold = args.threshold.max(1); let interval = Duration::from_secs(args.interval.max(1)); @@ -127,7 +134,7 @@ pub async fn run_cmd(args: GuardArgs) -> Result<()> { } dead_streak = 0; // acted; don't repeat every tick if args.restart { - try_restart(); + try_restart(port); } } GuardAction::Watching => { @@ -145,12 +152,14 @@ pub async fn run_cmd(args: GuardArgs) -> Result<()> { /// Best-effort relaunch of the daemon (`--restart`). Failures are logged, not /// fatal — the guard's primary job (pausing routing) already happened. -fn try_restart() { +fn try_restart(port: u16) { let Ok(exe) = std::env::current_exe() else { return; }; + // Restart on the SAME port the guard was watching, so a `--port` that + // differed from config is honored on relaunch too. match std::process::Command::new(exe) - .args(["start", "--daemon"]) + .args(["start", "--daemon", "--port", &port.to_string()]) .status() { Ok(s) if s.success() => tracing::info!("guard relaunched the proxy"), diff --git a/src/cli/sidecar.rs b/src/cli/sidecar.rs index 223a703..53b6b0e 100644 --- a/src/cli/sidecar.rs +++ b/src/cli/sidecar.rs @@ -63,6 +63,9 @@ pub async fn run_cmd(args: SidecarArgs) -> anyhow::Result<()> { rewrite_anthropic_cache: false, no_routing: true, pause_routing_on_exit: false, + // A sidecar serves a remote sandbox/CI agent with no local shell + // routing, so the routing-recovery watchdog has nothing to guard. + no_guard: true, }) .await } diff --git a/src/cli/start.rs b/src/cli/start.rs index cc669c7..8522432 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -57,6 +57,12 @@ pub struct StartArgs { /// background child doesn't strand Active env files at a dead port. #[arg(long, hide = true)] pub pause_routing_on_exit: bool, + /// Don't spawn the guard watchdog alongside the daemon. By default + /// `--daemon` also starts `burnwall guard`, which auto-pauses routing if + /// the proxy dies silently (e.g. an antivirus quarantine) so new shells go + /// direct instead of stranding at a dead port. + #[arg(long)] + pub no_guard: bool, } pub async fn run_cmd(args: StartArgs) -> anyhow::Result<()> { @@ -108,12 +114,18 @@ pub async fn run_cmd(args: StartArgs) -> anyhow::Result<()> { // Refuse to start a second proxy on top of a running one — `bind` below // is the real backstop, but this gives a clearer message in the common - // case (and cleans up a stale PID file from a previous crashed run). - if let Some(pid) = daemon::running_pid()? { + // case (and cleans up a stale PID file from a previous crashed run). A + // proxy that is only DRAINING (a soft `burnwall stop` left it up as a + // pass-through) is retired here so `stop` → `start` re-arms protection. + if let Some(pid) = daemon::protecting_proxy_blocking_start()? { anyhow::bail!( "Burnwall is already running (PID {pid}). Use `burnwall stop` to stop it first." ); } + // A fresh start means protection is ON: clear any stale bypass (the drain + // left by a proxy we just retired, or an orphaned pause) so the new daemon + // never boots straight into relay-only mode with protection silently off. + let _ = crate::bypass::clear(); let storage = Arc::new(Storage::open_default().context("opening default storage")?); @@ -260,6 +272,9 @@ pub async fn run_cmd(args: StartArgs) -> anyhow::Result<()> { // Live escape hatch: `burnwall pause` / `allow-once` write this file; // the handler checks it per request. Resolved once, here. pause_path: crate::bypass::default_path(), + last_activity: Arc::new(std::sync::atomic::AtomicI64::new( + chrono::Utc::now().timestamp(), + )), }; let host: IpAddr = host_str @@ -284,7 +299,13 @@ pub async fn run_cmd(args: StartArgs) -> anyhow::Result<()> { resume_and_report(&format!("http://localhost:{port}")); } - let result = serve_with_shutdown(listener, Arc::new(state), daemon::shutdown_signal()).await; + let state = Arc::new(state); + // Idle-retire monitor: when a soft `burnwall stop` flips us into drain + // (relay-only) mode, wind the process down once traffic goes idle so the + // port frees on its own — without ever cutting an in-use tool. A no-op + // until/unless drain is entered. + spawn_idle_retire_monitor(state.clone()); + let result = serve_with_shutdown(listener, state, daemon::shutdown_signal()).await; daemon::remove_pid_file().ok(); // We reached the end of `serve` on our own terms (signal / shutdown file), // so this run is exiting cleanly — clear the unclean-exit escalation. @@ -296,6 +317,47 @@ pub async fn run_cmd(args: StartArgs) -> anyhow::Result<()> { Ok(()) } +/// Seconds a drain relay (from a soft `burnwall stop`) may sit idle before it +/// retires itself and frees the port. Long enough to bridge a tool between +/// requests, short enough that a stopped proxy doesn't linger. +const DRAIN_IDLE_RETIRE_SECS: i64 = 60; + +/// Watchdog for the drain relay a soft `burnwall stop` leaves behind: while +/// drain is active and no real request has arrived for [`DRAIN_IDLE_RETIRE_SECS`], +/// ask the proxy to shut down (via the same shutdown file `stop` uses) so the +/// port frees itself. A no-op while protection is on — it only ever fires once +/// drain has been entered, and only after traffic has actually gone quiet. +fn spawn_idle_retire_monitor(state: Arc) { + const POLL: std::time::Duration = std::time::Duration::from_secs(5); + tokio::spawn(async move { + loop { + tokio::time::sleep(POLL).await; + let now = chrono::Utc::now().timestamp(); + let draining = crate::bypass::is_draining(now); + let last = state + .last_activity + .load(std::sync::atomic::Ordering::Relaxed); + if drain_should_retire(draining, now, last, DRAIN_IDLE_RETIRE_SECS) { + tracing::info!( + "drain idle for {}s — retiring the proxy so the port frees", + now - last + ); + if let Ok(path) = daemon::shutdown_file_path() { + let _ = std::fs::write(&path, "idle-retire after soft stop"); + } + return; + } + } + }); +} + +/// Pure decision for the idle-retire monitor: a drain relay retires only while +/// drain is actually in effect AND no real request has arrived for `idle_secs`. +/// Split out so the timing logic is unit-testable without a clock or a socket. +fn drain_should_retire(is_draining: bool, now: i64, last_activity: i64, idle_secs: i64) -> bool { + is_draining && now - last_activity >= idle_secs +} + /// Lines explaining an unclean prior exit, with platform-specific antivirus /// guidance. Escalates wording once it has happened repeatedly — a single /// occurrence is often a reboot; a streak is almost always AV quarantining @@ -680,6 +742,18 @@ mod tests { assert!(many.contains("Add-MpPreference"), "{many}"); } + #[test] + fn drain_retires_only_when_draining_and_idle() { + let idle = 60; + // Not draining → never retire, however long idle. + assert!(!super::drain_should_retire(false, 1_000, 0, idle)); + // Draining but still active (recent request) → keep relaying. + assert!(!super::drain_should_retire(true, 1_000, 990, idle)); + // Draining and idle past the window → retire (frees the port). + assert!(super::drain_should_retire(true, 1_000, 940, idle)); + assert!(super::drain_should_retire(true, 1_000, 900, idle)); + } + #[test] fn upstream_resolution_precedence() { // CLI flag (≠ default) wins; else non-empty config; else built-in. diff --git a/src/cli/status.rs b/src/cli/status.rs index 42ae586..9ea3cf0 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -177,6 +177,17 @@ pub fn run_cmd(args: StatusArgs) -> anyhow::Result<()> { sty.green("🟢 Protection active —") )?; } + (Some(pid), crate::bypass::Bypass::Draining) => { + writeln!( + out, + " {} proxy (pid {pid}) is relaying unchecked after `burnwall stop` — it retires once traffic goes idle.", + sty.yellow("⏹ Protection STOPPED (draining) —") + )?; + writeln!( + out, + " Already-running tools keep working. Turn protection back on: burnwall start" + )?; + } (Some(pid), crate::bypass::Bypass::None) => writeln!( out, " {} proxy running (pid {pid}); every request is scanned.", @@ -351,12 +362,17 @@ fn write_routing(w: &mut impl Write, sty: &Styler) -> std::io::Result<()> { )?; writeln!( w, - " Every AI tool launched from this shell will fail to connect." + " AI tools already running here will fail to connect (ConnectionRefused)." )?; - return writeln!( + writeln!( w, - " Fix: {} (or `burnwall stop` to pause routing and go direct)", + " Fix: {} (revive the proxy — running tools recover instantly)", sty.bold("burnwall start") + )?; + return writeln!( + w, + " {} (go direct instead, then restart already-open AI tools)", + sty.bold("burnwall recover") ); } writeln!( @@ -798,11 +814,14 @@ fn write_json( // Runtime pause (`burnwall pause`): the editor extension must be able to // warn that a green-looking proxy is currently checking nothing. - let (protection_paused, pause_resumes_in_secs) = - match crate::bypass::read(chrono::Utc::now().timestamp()) { - crate::bypass::Bypass::Paused { resumes_in_secs } => (true, Some(resumes_in_secs)), - _ => (false, None), - }; + let bypass_now = crate::bypass::read(chrono::Utc::now().timestamp()); + let (protection_paused, pause_resumes_in_secs) = match bypass_now { + crate::bypass::Bypass::Paused { resumes_in_secs } => (true, Some(resumes_in_secs)), + _ => (false, None), + }; + // Soft `burnwall stop` left the proxy up as a pass-through (relay-only), + // retiring when idle — surfaces should show it as stopped, not green. + let protection_draining = matches!(bypass_now, crate::bypass::Bypass::Draining); // De-duplicated cross-tool total (X4): excludes log rows of tools whose // provider flowed through the proxy today, so proxied Claude Code isn't @@ -820,6 +839,7 @@ fn write_json( "proxy_running": proxy_running, "protection_paused": protection_paused, "pause_resumes_in_secs": pause_resumes_in_secs, + "protection_draining": protection_draining, "total_cost_usd": today_cost, "total_requests": total_requests, "blocked_requests": blocked, diff --git a/src/cli/stop.rs b/src/cli/stop.rs index 2dcf0b1..90ed0da 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -26,6 +26,12 @@ pub struct StopArgs { /// stopped proxy until `burnwall start` runs again). #[arg(long)] pub keep_routing: bool, + /// Terminate the proxy immediately and free the port, instead of leaving + /// it up as a pass-through relay for already-running tools. Cuts in-flight + /// requests and will make any tool still routed here fail to connect until + /// it's restarted. The default (soft) stop avoids that. + #[arg(long)] + pub hard: bool, } pub fn run_cmd(args: StopArgs) -> anyhow::Result<()> { @@ -33,53 +39,25 @@ pub fn run_cmd(args: StopArgs) -> anyhow::Result<()> { // "nothing was running" apart from "a stale PID file was left behind". let had_pid_file = daemon::pid_file_path()?.exists(); + // Retire the guard watchdog: `stop` means we're done. A soft stop's drain + // is self-retiring and a hard stop pauses routing itself, so a lingering + // guard would only loop pointlessly (or fight a deliberate stop). + daemon::stop_guard(); + match daemon::running_pid()? { + // Soft stop (default): don't vacate the port. Flip the running proxy + // into drain (relay-only) mode and leave it serving so an + // already-running AI tool — which froze the proxy URL at launch and + // can't be repointed — keeps working instead of hitting a dead port. + // The proxy retires itself once traffic goes idle (then routing's + // liveness gate sends new shells direct). This is the fix for the + // "stop wedged my running tool with ConnectionRefused" failure. + Some(pid) if !args.hard => return soft_stop(pid), + // `--hard`: terminate now and free the port (cuts in-flight requests). Some(pid) => { - // Graceful first: ask the daemon to stop accepting, drain - // in-flight requests (the proxy gives them up to ~10s), and exit - // on its own. A hard kill cuts every active agent turn - // mid-stream — the user's AI tool sees a bare "socket closed - // unexpectedly" instead of a finished response. Escalate to the - // hard kill only when the daemon doesn't wind down in time (or - // the graceful request itself failed). - let graceful_requested = daemon::request_graceful_shutdown(pid).is_ok(); - if !graceful_requested { - daemon::terminate_process(pid)?; - } - - // An idle daemon exits within one poll tick; one that is - // draining can take up to the drain window. Tell the user why - // we're waiting once it's clearly not the quick case. - let started = Instant::now(); - let deadline = started + Duration::from_secs(13); - let mut announced_drain = false; - while daemon::process_is_alive(pid) && Instant::now() < deadline { - if graceful_requested - && !announced_drain - && started.elapsed() > Duration::from_secs(2) - { - println!(" draining in-flight requests (up to 10s)…"); - announced_drain = true; - } - std::thread::sleep(Duration::from_millis(100)); - } - - if daemon::process_is_alive(pid) { - // Drain window blown (or graceful never landed) — hard kill. - let _ = daemon::terminate_process(pid); - let kill_deadline = Instant::now() + Duration::from_secs(3); - while daemon::process_is_alive(pid) && Instant::now() < kill_deadline { - std::thread::sleep(Duration::from_millis(50)); - } - } - - daemon::remove_pid_file().ok(); - daemon::clear_shutdown_file(); - - if daemon::process_is_alive(pid) { - println!("Sent stop signal to Burnwall (PID {pid}); it has not exited yet."); - } else { - println!("Stopped Burnwall (PID {pid})."); + hard_stop(pid); + if !args.keep_routing { + pause_and_report(); } } None => { @@ -88,15 +66,86 @@ pub fn run_cmd(args: StopArgs) -> anyhow::Result<()> { } else { println!("Burnwall is not running."); } + if !args.keep_routing { + pause_and_report(); + } } } + Ok(()) +} - if !args.keep_routing { - pause_and_report(); - } +/// Soft stop: flip the running proxy into drain (relay-only) mode and leave it +/// up. Already-running tools keep working (unprotected); the proxy retires +/// itself once traffic goes idle, freeing the port — at which point routing's +/// liveness gate sends new shells direct. Never cuts an in-flight request, and +/// `stop` → `start` re-arms protection (a fresh `start` retires the drainer). +fn soft_stop(pid: u32) -> anyhow::Result<()> { + use anyhow::Context; + crate::bypass::drain(chrono::Utc::now().timestamp()) + .context("could not enter drain mode — run `burnwall stop --hard` to terminate instead")?; + let sty = Styler::stdout(); + println!( + "{} the proxy (PID {pid}) now relays as a pass-through — no security scan, no budget check, no cost capture.", + sty.yellow("⏹ Protection stopped —") + ); + println!( + " Already-running AI tools keep working; the proxy retires itself once traffic goes idle." + ); + println!( + " Free the port now (cuts in-flight requests): {}", + sty.bold("burnwall stop --hard") + ); + println!(" Turn protection back on: {}", sty.bold("burnwall start")); Ok(()) } +/// Hard stop: ask the daemon to shut down gracefully (drain in-flight requests, +/// up to ~10s), escalate to a kill if it doesn't wind down, and free the port. +/// A hard kill cuts every active agent turn mid-stream — the user's AI tool +/// sees a bare "socket closed unexpectedly" instead of a finished response — +/// so the graceful request goes first. +fn hard_stop(pid: u32) { + let graceful_requested = daemon::request_graceful_shutdown(pid).is_ok(); + if !graceful_requested { + let _ = daemon::terminate_process(pid); + } + + // An idle daemon exits within one poll tick; one that is draining can take + // up to the drain window. Tell the user why we're waiting once it's clearly + // not the quick case. + let started = Instant::now(); + let deadline = started + Duration::from_secs(13); + let mut announced_drain = false; + while daemon::process_is_alive(pid) && Instant::now() < deadline { + if graceful_requested && !announced_drain && started.elapsed() > Duration::from_secs(2) { + println!(" draining in-flight requests (up to 10s)…"); + announced_drain = true; + } + std::thread::sleep(Duration::from_millis(100)); + } + + if daemon::process_is_alive(pid) { + // Drain window blown (or graceful never landed) — hard kill. + let _ = daemon::terminate_process(pid); + let kill_deadline = Instant::now() + Duration::from_secs(3); + while daemon::process_is_alive(pid) && Instant::now() < kill_deadline { + std::thread::sleep(Duration::from_millis(50)); + } + } + + daemon::remove_pid_file().ok(); + daemon::clear_shutdown_file(); + // The proxy is gone for good — clear any drain/pause so a future `start` + // doesn't boot into relay-only mode. + let _ = crate::bypass::clear(); + + if daemon::process_is_alive(pid) { + println!("Sent stop signal to Burnwall (PID {pid}); it has not exited yet."); + } else { + println!("Stopped Burnwall (PID {pid})."); + } +} + /// Pause shell routing (active env files → paused stub) and tell the user /// what changed and how to clean already-open shells. Failures warn rather /// than error — the proxy is already down; routing cleanup must not turn @@ -140,10 +189,20 @@ pub(crate) fn pause_and_report() { println!(); println!( " {}", - sty.yellow("⚠ Terminals already open still have ANTHROPIC_BASE_URL set —") + sty.yellow("⚠ AI tools already running still point at the stopped proxy and will fail to connect.") + ); + println!( + " Bring it back — {} — and they recover instantly,", + sty.bold("burnwall start") + ); + println!( + " or go direct with {} and restart those tools.", + sty.bold("burnwall recover") ); - println!(" AI tools there will fail to connect until you restart them or run:"); if let Some(shell) = Shell::detect() { - println!(" {}", sty.bold(routing::manual_unset_hint(shell))); + println!( + " (Drop the vars from THIS shell: {})", + sty.bold(routing::manual_unset_hint(shell)) + ); } } diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index 7e4392f..2b69c03 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -52,7 +52,11 @@ pub fn run_cmd(args: UninstallArgs) -> Result<()> { // step 4 does the full routing teardown (env files AND rc hooks) — a // pause here would only double-write the env files. writeln!(out, "1. Stopping the proxy…")?; - if let Err(e) = super::stop::run_cmd(super::stop::StopArgs { keep_routing: true }) { + if let Err(e) = super::stop::run_cmd(super::stop::StopArgs { + keep_routing: true, + // Terminate for good and free the port — we're removing the binary. + hard: true, + }) { writeln!(out, " • {e}")?; } diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index c960d11..1d62442 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -50,7 +50,12 @@ pub fn run_cmd(args: UpgradeArgs) -> Result<()> { let was_running = matches!(super::daemon::running_pid(), Ok(Some(_))); if was_running { println!(" Stopping the running proxy so the binary can be replaced…"); - let _ = super::stop::run_cmd(super::stop::StopArgs { keep_routing: true }); + let _ = super::stop::run_cmd(super::stop::StopArgs { + keep_routing: true, + // Free the port and release the binary's file lock so the upgrade + // can replace it — a soft drain would keep the old proxy holding both. + hard: true, + }); } // The canonical install path, captured before any rename so the restart diff --git a/src/proxy/handler.rs b/src/proxy/handler.rs index c8e5d71..b33250d 100644 --- a/src/proxy/handler.rs +++ b/src/proxy/handler.rs @@ -30,6 +30,14 @@ pub async fn handle( return Ok(healthz_response()); } + // Record real request activity (after healthz, so a liveness probe can't + // keep a draining proxy alive). The idle-retire monitor uses this to wind + // a soft-stopped drain relay down once traffic actually stops. + state.last_activity.store( + chrono::Utc::now().timestamp(), + std::sync::atomic::Ordering::Relaxed, + ); + // ─── bypass kill-switch (L2) ─── // BURNWALL_BYPASS=1 turns the proxy into a pure relay: no security scan, // no budget check, no loop detection, no storage write. The user's last- @@ -62,6 +70,14 @@ pub async fn handle( return Ok(passthrough(req, &state).await); } } + crate::bypass::Bypass::Draining => { + // A soft `burnwall stop` left this proxy up purely so + // already-running tools don't hit a dead port. Relay + // unchecked; the idle-retire monitor shuts us down once + // traffic stops. + tracing::debug!("🛑 draining after stop — relaying this request unchecked"); + return Ok(passthrough(req, &state).await); + } crate::bypass::Bypass::None => {} } } diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index f228ca7..58dcbd6 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -217,6 +217,11 @@ pub struct AppState { /// (the test constructor's default, so a developer's real pause file /// can't leak into test runs). pub pause_path: Option, + /// Unix-seconds timestamp of the most recent request, bumped per request. + /// The idle-retire monitor (a soft `burnwall stop` leaves the proxy up as + /// a drain relay) reads this to retire the process once traffic stops, so + /// the port frees on its own without ever cutting an in-use tool. + pub last_activity: Arc, } impl AppState { @@ -242,6 +247,7 @@ impl AppState { #[cfg(feature = "observe")] otel: None, pause_path: None, + last_activity: Arc::new(std::sync::atomic::AtomicI64::new(0)), } } diff --git a/tests/integration/daemon_test.rs b/tests/integration/daemon_test.rs index 88b2668..f3e9aeb 100644 --- a/tests/integration/daemon_test.rs +++ b/tests/integration/daemon_test.rs @@ -169,42 +169,117 @@ fn stop_removes_a_stale_pid_file() { // ──────────────────────── full start --daemon / stop ─────────────────────── #[test] -fn start_daemon_then_stop_lifecycle() { +fn start_daemon_spawns_guard_then_hard_stop_terminates_both() { let dir = tempfile::tempdir().unwrap(); let pid_file = dir.path().join("burnwall.pid"); + let guard_pid_file = dir.path().join("burnwall.guard.pid"); // Port 0 lets the OS pick a free port — the test never connects, it only - // exercises the daemon lifecycle. + // exercises the daemon lifecycle. `--no-routing` keeps routing inactive so + // the watchdog stays Idle (it only acts when routing is live), which means + // it can't restart-spawn during the test. Guard is on by default. burnwall(dir.path()) - .args(["start", "--daemon", "--port", "0"]) + .args(["start", "--daemon", "--no-routing", "--port", "0"]) .assert() .success() - .stdout(predicate::str::contains("running in the background")); + .stdout(predicate::str::contains("running in the background")) + .stdout(predicate::str::contains("Watchdog: guard running")); let _cleanup = DaemonCleanup(pid_file.clone()); + let _guard_cleanup = DaemonCleanup(guard_pid_file.clone()); assert!( pid_file.exists(), "the daemon writes its PID file once it is serving" ); + assert!( + guard_pid_file.exists(), + "the guard watchdog is spawned by default" + ); let pid: u32 = fs::read_to_string(&pid_file) .unwrap() .trim() .parse() .expect("PID file holds a number"); + let guard_pid: u32 = fs::read_to_string(&guard_pid_file) + .unwrap() + .trim() + .parse() + .expect("guard PID file holds a number"); assert!( daemon::process_is_alive(pid), "the daemon process is running" ); + assert!( + daemon::process_is_alive(guard_pid), + "the guard process is running" + ); + // `stop --hard` fully terminates and frees the port (and retires the guard). burnwall(dir.path()) - .arg("stop") + .args(["stop", "--hard"]) .assert() .success() .stdout(predicate::str::contains("Stopped Burnwall")); - assert!(!pid_file.exists(), "stop clears the PID file"); - assert!(wait_until_gone(pid), "the daemon process exits after stop"); + assert!(!pid_file.exists(), "hard stop clears the PID file"); + assert!( + wait_until_gone(pid), + "the daemon process exits after a hard stop" + ); + assert!( + wait_until_gone(guard_pid), + "hard stop terminates the guard process too (no leak)" + ); + assert!( + !guard_pid_file.exists(), + "hard stop retires the guard watchdog too" + ); +} + +#[test] +fn soft_stop_leaves_the_proxy_draining_so_running_tools_dont_wedge() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("burnwall.pid"); + let drain_file = dir.path().join("pause.json"); + + // `--no-guard` keeps this focused on the proxy itself. + burnwall(dir.path()) + .args(["start", "--daemon", "--no-guard", "--port", "0"]) + .assert() + .success() + .stdout(predicate::str::contains("running in the background")) + .stdout(predicate::str::contains("Watchdog: guard running").not()); + + let _cleanup = DaemonCleanup(pid_file.clone()); + let pid: u32 = fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap(); + + // Default (soft) stop: the proxy stays UP as a pass-through relay so an + // already-running tool doesn't hit a dead port. This is the wedge fix. + burnwall(dir.path()) + .arg("stop") + .assert() + .success() + .stdout(predicate::str::contains("Protection stopped")) + .stdout(predicate::str::contains("pass-through")); + + assert!( + daemon::process_is_alive(pid), + "a soft stop must leave the proxy running so the port keeps answering" + ); + assert!(pid_file.exists(), "soft stop does not remove the PID file"); + let drain = fs::read_to_string(&drain_file).expect("soft stop writes the drain state file"); + assert!( + drain.contains("drain"), + "the state file records drain mode: {drain}" + ); + + // Now free the port for good. + burnwall(dir.path()) + .args(["stop", "--hard"]) + .assert() + .success(); + assert!(wait_until_gone(pid), "hard stop terminates the drainer"); } #[test] @@ -249,7 +324,7 @@ fn shutdown_file_alone_stops_the_daemon_gracefully() { let pid_file = dir.path().join("burnwall.pid"); burnwall(dir.path()) - .args(["start", "--daemon", "--port", "0"]) + .args(["start", "--daemon", "--no-guard", "--port", "0"]) .assert() .success(); let _cleanup = DaemonCleanup(pid_file.clone()); @@ -279,7 +354,7 @@ fn start_daemon_refuses_when_already_running() { let pid_file = dir.path().join("burnwall.pid"); burnwall(dir.path()) - .args(["start", "--daemon", "--port", "0"]) + .args(["start", "--daemon", "--no-guard", "--port", "0"]) .assert() .success(); @@ -287,7 +362,7 @@ fn start_daemon_refuses_when_already_running() { // A second daemon must not start on top of the first. burnwall(dir.path()) - .args(["start", "--daemon", "--port", "0"]) + .args(["start", "--daemon", "--no-guard", "--port", "0"]) .assert() .failure() .stderr(predicate::str::contains("already running")); @@ -300,5 +375,8 @@ fn start_daemon_refuses_when_already_running() { .failure() .stderr(predicate::str::contains("already running")); - burnwall(dir.path()).arg("stop").assert().success(); + burnwall(dir.path()) + .args(["stop", "--hard"]) + .assert() + .success(); } diff --git a/tests/integration/pause_test.rs b/tests/integration/pause_test.rs index afac752..e464a81 100644 --- a/tests/integration/pause_test.rs +++ b/tests/integration/pause_test.rs @@ -123,6 +123,53 @@ async fn pause_file_relays_unchecked_and_resume_restores() { assert_eq!(resp.status(), 403, "resume must restore protection"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drain_file_relays_unchecked_so_a_soft_stop_never_wedges_a_tool() { + // A soft `burnwall stop` leaves the proxy up in drain (relay-only) mode so + // an already-running tool keeps working instead of hitting a dead port. + // The handler must honor the `drain` state exactly like a pause: relay + // unchecked. Clearing it (a fresh `start` / idle-retire) restores guarding. + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) // exactly the drained request lands upstream + .mount(&mock) + .await; + + let dir = tempfile::tempdir().unwrap(); + let pause_path = dir.path().join("pause.json"); + let mut state = AppState::new(mock.uri(), "http://127.0.0.1:1".to_string()); + state.pause_path = Some(pause_path.clone()); + let proxy = spawn_proxy(state).await; + let url = format!("http://{}/anthropic/v1/messages", proxy); + + // Drain active (the exact JSON a soft `burnwall stop` writes) → relayed. + let now = chrono::Utc::now().timestamp(); + std::fs::write( + &pause_path, + format!(r#"{{"mode":"drain","expires_at":{}}}"#, now + 3600), + ) + .unwrap(); + let resp = client() + .post(&url) + .json(&violating_body()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "a draining proxy must relay unchecked"); + + // Drain cleared (a fresh `start` clears it) → protection restored. + std::fs::remove_file(&pause_path).unwrap(); + let resp = client() + .post(&url) + .json(&violating_body()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403, "clearing drain must restore protection"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn allow_once_relays_exactly_one_request() { let mock = MockServer::start().await; diff --git a/tests/integration/pipeline_test.rs b/tests/integration/pipeline_test.rs index 1896366..50e281c 100644 --- a/tests/integration/pipeline_test.rs +++ b/tests/integration/pipeline_test.rs @@ -70,6 +70,7 @@ async fn safe_anthropic_request_records_cost() { let budget = Arc::new(BudgetTracker::with_defaults()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -139,6 +140,7 @@ async fn safe_openai_request_records_cost_with_cache() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: "http://127.0.0.1:1".to_string(), upstream_openai: mock.uri(), http_client: reqwest::Client::new(), @@ -190,6 +192,7 @@ async fn security_violation_returns_403_and_records_event() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -273,6 +276,7 @@ async fn budget_exceeded_returns_429_without_forwarding() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -342,6 +346,7 @@ async fn subscription_traffic_not_blocked_by_dollar_cap() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -415,6 +420,7 @@ data: {\"type\":\"message_stop\"}\n\n"; let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -491,6 +497,7 @@ async fn budget_warning_does_not_block() { let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -550,6 +557,7 @@ async fn loop_detection_blocks_after_threshold_identical_requests() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -646,6 +654,7 @@ async fn accept_encoding_is_not_forwarded_upstream() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -706,6 +715,7 @@ async fn security_log_redact_details_strips_rule_from_storage() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: "http://127.0.0.1:1".to_string(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -779,6 +789,7 @@ async fn distinct_requests_dont_trip_loop_detector() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -839,6 +850,7 @@ async fn cache_injection_rewrites_outbound_anthropic_body_when_enabled() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -921,6 +933,7 @@ async fn cache_injection_off_forwards_body_unchanged() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -979,6 +992,7 @@ async fn utf8_bom_prefixed_body_still_triggers_security_scan() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), http_client: reqwest::Client::new(), @@ -1053,6 +1067,7 @@ async fn gemini_request_records_cost_and_latency() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: "http://127.0.0.1:1".to_string(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: mock.uri(), @@ -1132,6 +1147,7 @@ async fn failover_reroutes_to_healthy_endpoint_on_5xx() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: primary.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1176,6 +1192,7 @@ async fn failover_disabled_forwards_5xx_verbatim() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: primary.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1225,6 +1242,7 @@ async fn otel_span_written_for_forwarded_request() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1284,6 +1302,7 @@ async fn paranoid_mode_blocks_unscannable_body_default_forwards_it() { let base = |storage: Arc, paranoid: bool| AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1368,6 +1387,7 @@ async fn trim_tool_output_shrinks_oversized_tool_result_before_forwarding() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1445,6 +1465,7 @@ async fn response_exfil_warning_records_event_and_never_modifies_reply() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1516,6 +1537,7 @@ async fn response_exfil_warning_dedupes_per_host() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1619,6 +1641,7 @@ async fn compact_request_with_keys_in_history_forwards_not_403() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), @@ -1687,6 +1710,7 @@ async fn negative_control_in_flight_credential_exfil_still_blocks() { let storage = Arc::new(Storage::open_in_memory().unwrap()); let state = AppState { pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), upstream_anthropic: mock.uri(), upstream_openai: "http://127.0.0.1:1".to_string(), upstream_google: "http://127.0.0.1:1".to_string(), diff --git a/tests/integration/torture_test.rs b/tests/integration/torture_test.rs index 6f5f337..b12c418 100644 --- a/tests/integration/torture_test.rs +++ b/tests/integration/torture_test.rs @@ -56,6 +56,7 @@ fn state_for(upstream: String, storage: Arc, client: reqwest::Client) - #[cfg(feature = "observe")] otel: None, pause_path: None, + last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), } } From b8738b019936e4aa7d4d5df03318608454f80600 Mon Sep 17 00:00:00 2001 From: codehippie1 Date: Thu, 18 Jun 2026 18:40:41 -0400 Subject: [PATCH 2/3] fix(daemon): match full image path in the unix identity check The Linux branch compared only the bare file name, so a binary launched from a burnwall checkout (the daemon_test-* integration runner) read as not-burnwall and process_is_alive returned false on Linux only. Match the full /proc//exe path, consistent with the Windows (full image path) and macOS (ps -o comm=) checks. Fixes the two Linux-only daemon test failures. --- src/cli/daemon.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index 13189e5..2b874be 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -656,13 +656,16 @@ pub fn process_is_alive(pid: u32) -> bool { #[cfg(unix)] fn process_is_burnwall(pid: u32) -> bool { // Linux: /proc//exe symlink. macOS: no /proc — fall back to `ps`. + // Match against the FULL image path, not just the file name: the real + // binary's path always contains "burnwall" (its dir and/or file name), and + // this keeps the three platforms consistent — Windows checks the full image + // path and macOS's `ps -o comm=` returns the full path too. A bare file-name + // check diverged on Linux and read a binary launched from a burnwall checkout + // (e.g. the `daemon_test-*` integration runner) as "not burnwall". #[cfg(target_os = "linux")] { match std::fs::read_link(format!("/proc/{pid}/exe")) { - Ok(p) => p - .file_name() - .map(|n| n.to_string_lossy().contains("burnwall")) - .unwrap_or(true), + Ok(p) => p.to_string_lossy().contains("burnwall"), Err(_) => true, } } From 9e178a11273d804cb9d22342bc18e17f038fd598 Mon Sep 17 00:00:00 2001 From: codehippie1 Date: Thu, 18 Jun 2026 18:40:42 -0400 Subject: [PATCH 3/3] style: cargo fmt to resolve pre-existing rustfmt drift Formatting-only; clears the tree-wide rustfmt --check failures that predate this branch (accuracy/doctor/explain/export/history/nudge/security/status/term/...). --- src/cli/accuracy.rs | 8 ++- src/cli/doctor.rs | 110 ++++++++++++++++++++++--------- src/cli/explain.rs | 16 ++++- src/cli/export.rs | 10 ++- src/cli/history.rs | 42 +++++++++--- src/cli/mod.rs | 2 +- src/cli/nudge.rs | 6 +- src/cli/security.rs | 23 +++++-- src/cli/status.rs | 39 ++++++++--- src/cli/statusline.rs | 23 ++++--- src/cli/stop.rs | 9 ++- src/cli/tags.rs | 24 ++++--- src/cli/waste.rs | 8 ++- src/plan.rs | 10 ++- src/proxy/handler.rs | 14 +++- src/ribbon.rs | 20 ++++-- src/security/catalog.rs | 5 +- src/security/destructive.rs | 6 +- src/term.rs | 13 +++- tests/integration/cli_test.rs | 4 +- tests/integration/daemon_test.rs | 6 +- tests/unit/storage_test.rs | 10 ++- 22 files changed, 301 insertions(+), 107 deletions(-) diff --git a/src/cli/accuracy.rs b/src/cli/accuracy.rs index 3ea2ff4..401499f 100644 --- a/src/cli/accuracy.rs +++ b/src/cli/accuracy.rs @@ -152,8 +152,12 @@ fn write_table(w: &mut impl Write, r: &AccuracyReport) -> std::io::Result<()> { let cards = [ Card::new("On-wire", &format!("${:.2}", r.total_real), "cache-aware") .with_value_color(Color::Green), - Card::new("Naive tally", &format!("${:.2}", r.total_naive), "sticker rate") - .with_value_color(Color::Yellow), + Card::new( + "Naive tally", + &format!("${:.2}", r.total_naive), + "sticker rate", + ) + .with_value_color(Color::Yellow), Card::new( "Overstated", &format!("{:.0}%", pct), diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index efe8f69..92ad423 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -108,9 +108,15 @@ pub async fn run_cmd(args: DoctorArgs) -> anyhow::Result<()> { std::fs::write(&path, &report).with_context(|| format!("writing {}", path.display()))?; let issues = format!("{}/issues/new", env!("CARGO_PKG_REPOSITORY")); - writeln!(out, "🩺 Wrote a redacted diagnostic bundle (metadata only, nothing sent):")?; + writeln!( + out, + "🩺 Wrote a redacted diagnostic bundle (metadata only, nothing sent):" + )?; writeln!(out, " {}", path.display())?; - writeln!(out, " ✓ no secrets or prompt content in this file (self-scanned)")?; + writeln!( + out, + " ✓ no secrets or prompt content in this file (self-scanned)" + )?; writeln!(out)?; writeln!(out, " Review it, then attach it to a bug report:")?; writeln!(out, " {issues}")?; @@ -285,21 +291,20 @@ fn gather(storage: &Storage, days: i64) -> anyhow::Result { let env_contents = crate::cli::init::Shell::detect() .and_then(crate::cli::routing::env_file_path) .and_then(|p| std::fs::read_to_string(p).ok()); - let env_file_state = env_contents.as_deref().map(|c| { - match crate::cli::routing::classify_env_contents(c) { - crate::cli::routing::EnvFileState::Active => "active", - crate::cli::routing::EnvFileState::Paused => "paused", - crate::cli::routing::EnvFileState::Disabled => "disabled", - } - }); + let env_file_state = + env_contents + .as_deref() + .map(|c| match crate::cli::routing::classify_env_contents(c) { + crate::cli::routing::EnvFileState::Active => "active", + crate::cli::routing::EnvFileState::Paused => "paused", + crate::cli::routing::EnvFileState::Disabled => "disabled", + }); let probe_port = env_contents .as_deref() .and_then(crate::cli::routing::active_env_port) .unwrap_or(4100); - let proxy_listening = crate::cli::routing::proxy_port_alive( - probe_port, - std::time::Duration::from_millis(80), - ); + let proxy_listening = + crate::cli::routing::proxy_port_alive(probe_port, std::time::Duration::from_millis(80)); let cfg_path = crate::config::default_path()?; let cfg = crate::config::load_or_default(&cfg_path).context("loading config")?; @@ -470,7 +475,9 @@ pub fn assess_protection(i: &DoctorInput) -> Protection { Some("paused") => Protection { ok: false, headline: "routing was paused when the proxy stopped — traffic goes direct".into(), - fix: Some("run `burnwall start` to bring the proxy up and re-enable routing".into()), + fix: Some( + "run `burnwall start` to bring the proxy up and re-enable routing".into(), + ), chosen: true, }, Some("disabled") => Protection { @@ -552,9 +559,7 @@ fn print_health(out: &mut impl Write, i: &DoctorInput) -> anyhow::Result<()> { ("proxied", _) => { Card::new("Routing", "routed", "this shell").with_value_color(Color::Green) } - ("direct", _) => { - Card::new("Routing", "direct", "unprotected").with_value_color(Color::Red) - } + ("direct", _) => Card::new("Routing", "direct", "unprotected").with_value_color(Color::Red), ("bypassed", _) => { Card::new("Routing", "bypass", "no scan").with_value_color(Color::Yellow) } @@ -610,7 +615,11 @@ fn print_health(out: &mut impl Write, i: &DoctorInput) -> anyhow::Result<()> { i.alert_events, bs(i.alert_events) )?; - writeln!(out, " {:<14}{} ({}/{})", "Version", i.version, i.os, i.arch)?; + writeln!( + out, + " {:<14}{} ({}/{})", + "Version", i.version, i.os, i.arch + )?; writeln!(out)?; writeln!( out, @@ -759,9 +768,17 @@ fn redact_config(toml_text: &str) -> String { fn key_is_secretish(key: &str) -> bool { let k = key.to_ascii_lowercase(); // "canar" catches both `canary` and `canaries`. - ["key", "token", "secret", "password", "passwd", "canar", "credential"] - .iter() - .any(|needle| k.contains(needle)) + [ + "key", + "token", + "secret", + "password", + "passwd", + "canar", + "credential", + ] + .iter() + .any(|needle| k.contains(needle)) } /// Redact one `key = value` line by key-name, then mask any secret-shaped token. @@ -777,7 +794,11 @@ fn redact_kv_line(line: &str) -> String { /// Blank the literal values on an array-element line (` "AKIA…",`). fn blank_value(line: &str) -> String { let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); - let suffix = if line.trim_end().ends_with(',') { "," } else { "" }; + let suffix = if line.trim_end().ends_with(',') { + "," + } else { + "" + }; format!("{indent}\"[redacted]\"{suffix}") } @@ -834,7 +855,10 @@ fn harden(report: String) -> String { fn host_of(uri: &str) -> Option { let after = uri.split_once("://").map(|(_, r)| r).unwrap_or(uri); let host_port = after.split(['/', '?', '#']).next().unwrap_or(after); - let host = host_port.rsplit_once('@').map(|(_, h)| h).unwrap_or(host_port); + let host = host_port + .rsplit_once('@') + .map(|(_, h)| h) + .unwrap_or(host_port); let host = host.split(':').next().unwrap_or(host); if host.is_empty() { None @@ -933,7 +957,10 @@ mod tests { // self-scan must neutralize it. Assembled by concat so this source file // stays clean under the pre-push secret guard, and chosen so it matches // the detector (not the filtered AWS doc example). - let leaked = format!("note: {} appeared\n", "AKIA".to_string() + "QQQQRRRRSSSSTTTT"); + let leaked = format!( + "note: {} appeared\n", + "AKIA".to_string() + "QQQQRRRRSSSSTTTT" + ); let hardened = harden(leaked); assert!(!hardened.contains("QQQQRRRRSSSS")); // masked middle is gone // And the canonical self-scan agrees it is clean. @@ -942,8 +969,14 @@ mod tests { #[test] fn host_of_extracts_bare_host() { - assert_eq!(host_of("https://api.example.com:443/mcp?x=1").as_deref(), Some("api.example.com")); - assert_eq!(host_of("http://user@10.0.0.1/rpc").as_deref(), Some("10.0.0.1")); + assert_eq!( + host_of("https://api.example.com:443/mcp?x=1").as_deref(), + Some("api.example.com") + ); + assert_eq!( + host_of("http://user@10.0.0.1/rpc").as_deref(), + Some("10.0.0.1") + ); assert_eq!(host_of("").as_deref(), None); } @@ -981,7 +1014,10 @@ mod tests { // Routing configured (active env) but the proxy is down: unintended, // not chosen → `--fix` is allowed to act. This is the user's case. let p = assess_protection(&direct_input(Some("active"), false)); - assert!(!p.ok && !p.chosen, "must be unintended, not a choice: {p:?}"); + assert!( + !p.ok && !p.chosen, + "must be unintended, not a choice: {p:?}" + ); let fix = p.fix.unwrap(); assert!(fix.contains("burnwall start"), "fix: {fix}"); } @@ -994,13 +1030,19 @@ mod tests { assert!(!p.ok && !p.chosen, "{p:?}"); let fix = p.fix.unwrap(); assert!(fix.contains("new shell"), "fix: {fix}"); - assert!(!fix.contains("burnwall start"), "must not tell them to start: {fix}"); + assert!( + !fix.contains("burnwall start"), + "must not tell them to start: {fix}" + ); } #[test] fn disabled_routing_is_a_respected_choice() { let p = assess_protection(&direct_input(Some("disabled"), false)); - assert!(!p.ok && p.chosen, "a deliberate disable must be `chosen`: {p:?}"); + assert!( + !p.ok && p.chosen, + "a deliberate disable must be `chosen`: {p:?}" + ); assert!(p.fix.unwrap().contains("enable-routing")); } @@ -1013,9 +1055,15 @@ mod tests { #[test] fn pause_and_bypass_are_chosen_not_alarms() { - let paused = DoctorInput { paused: true, ..sample_input() }; + let paused = DoctorInput { + paused: true, + ..sample_input() + }; assert!(assess_protection(&paused).chosen); - let bypass = DoctorInput { routing: "bypassed", ..sample_input() }; + let bypass = DoctorInput { + routing: "bypassed", + ..sample_input() + }; assert!(assess_protection(&bypass).chosen); } diff --git a/src/cli/explain.rs b/src/cli/explain.rs index a4e37ff..3583614 100644 --- a/src/cli/explain.rs +++ b/src/cli/explain.rs @@ -94,7 +94,11 @@ pub fn run_cmd(args: ExplainArgs) -> anyhow::Result<()> { // The recorded detail, in full — terminal-only, the user's own machine // (same disclosure level as `burnwall security`). What was matched: writeln!(out, " {}", sty.bold("What matched"))?; - writeln!(out, " {}", strip_type_prefix(&event.event_type, &event.details))?; + writeln!( + out, + " {}", + strip_type_prefix(&event.event_type, &event.details) + )?; writeln!(out)?; writeln!(out, " {}", sty.bold("Why this is blocked"))?; @@ -155,9 +159,15 @@ mod tests { "~/.ssh/id_rsa" ); // No prefix: returned unchanged. - assert_eq!(strip_type_prefix("path_blocked", "~/.aws/credentials"), "~/.aws/credentials"); + assert_eq!( + strip_type_prefix("path_blocked", "~/.aws/credentials"), + "~/.aws/credentials" + ); // A label-only detail (secret_detected stores the pattern name). - assert_eq!(strip_type_prefix("secret_detected", "AWS access key ID"), "AWS access key ID"); + assert_eq!( + strip_type_prefix("secret_detected", "AWS access key ID"), + "AWS access key ID" + ); } #[test] diff --git a/src/cli/export.rs b/src/cli/export.rs index 172e64e..e6ba00c 100644 --- a/src/cli/export.rs +++ b/src/cli/export.rs @@ -202,10 +202,16 @@ mod tests { #[test] fn rows_are_sorted_date_desc_then_provider_model() { let per_day = vec![ - ("2026-06-10".to_string(), vec![mb("openai", "gpt-5.5", 0.04, 1)]), + ( + "2026-06-10".to_string(), + vec![mb("openai", "gpt-5.5", 0.04, 1)], + ), ( "2026-06-11".to_string(), - vec![mb("anthropic", "claude-opus-4-7", 0.10, 2), mb("anthropic", "claude-haiku-4-5", 0.01, 5)], + vec![ + mb("anthropic", "claude-opus-4-7", 0.10, 2), + mb("anthropic", "claude-haiku-4-5", 0.01, 5), + ], ), ]; let rows = build_rows(per_day); diff --git a/src/cli/history.rs b/src/cli/history.rs index fff447c..889e587 100644 --- a/src/cli/history.rs +++ b/src/cli/history.rs @@ -105,8 +105,10 @@ impl PriorWindow { /// sparkline has one cell per calendar day. fn dense_spend_series(totals: &[DailyTotal], days: i64) -> Vec { let today = Local::now().date_naive(); - let by_date: std::collections::HashMap<&str, f64> = - totals.iter().map(|t| (t.date.as_str(), t.total_cost)).collect(); + let by_date: std::collections::HashMap<&str, f64> = totals + .iter() + .map(|t| (t.date.as_str(), t.total_cost)) + .collect(); (0..days) .rev() .map(|i| { @@ -193,18 +195,38 @@ pub fn run_cmd(args: HistoryArgs) -> anyhow::Result<()> { let prior = PriorWindow::compute(&storage, days); let cards = [ - Card::new("Spent", &format!("${:.2}", total_cost), &format!("over {days}d")) - .with_delta(delta_chip_pct(total_cost, prior.cost, Trend::HigherWorse)), + Card::new( + "Spent", + &format!("${:.2}", total_cost), + &format!("over {days}d"), + ) + .with_delta(delta_chip_pct(total_cost, prior.cost, Trend::HigherWorse)), // Request volume is neutral (more isn't inherently better or worse), so // it carries no good/bad chip — its delta row stays blank, aligned. Card::new("Requests", &total_requests.to_string(), "total"), - Card::new("Cache", &format!("{avg_hit_pct:.0}%"), &fill_bar(avg_hit_pct, 8)) - .with_value_color(Color::Green) - .with_sub_color(Color::Green) - .with_delta(delta_chip_pct(avg_hit_pct, prior.cache_hit_pct, Trend::HigherBetter)), + Card::new( + "Cache", + &format!("{avg_hit_pct:.0}%"), + &fill_bar(avg_hit_pct, 8), + ) + .with_value_color(Color::Green) + .with_sub_color(Color::Green) + .with_delta(delta_chip_pct( + avg_hit_pct, + prior.cache_hit_pct, + Trend::HigherBetter, + )), Card::new("Blocked", &total_blocked.to_string(), "events") - .with_value_color(if total_blocked > 0 { Color::Red } else { Color::Green }) - .with_delta(delta_chip_count(total_blocked, prior.blocked, Trend::HigherWorse)), + .with_value_color(if total_blocked > 0 { + Color::Red + } else { + Color::Green + }) + .with_delta(delta_chip_count( + total_blocked, + prior.blocked, + Trend::HigherWorse, + )), ]; writeln!(out, "{}", render_cards(&cards, 11, 2, &sty))?; writeln!(out)?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5ad6c19..3bc41fc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -50,8 +50,8 @@ pub mod skills; pub mod start; pub mod status; pub mod statusline; -pub mod tags; pub mod stop; +pub mod tags; pub mod uninstall; pub mod upgrade; #[cfg(feature = "waste")] diff --git a/src/cli/nudge.rs b/src/cli/nudge.rs index 9270939..4517a5b 100644 --- a/src/cli/nudge.rs +++ b/src/cli/nudge.rs @@ -206,7 +206,11 @@ mod tests { s.security_blocked_window = 2; s.security_alerts_window = 153; let n = select(&s, None).expect("a finding"); - assert!(n.message.contains("blocked 2 request(s)"), "got: {}", n.message); + assert!( + n.message.contains("blocked 2 request(s)"), + "got: {}", + n.message + ); assert!( !n.message.contains("155"), "alerts must not inflate the blocked count: {}", diff --git a/src/cli/security.rs b/src/cli/security.rs index a304384..545ab08 100644 --- a/src/cli/security.rs +++ b/src/cli/security.rs @@ -103,12 +103,23 @@ pub fn run_cmd(args: SecurityArgs) -> anyhow::Result<()> { } }); let cards = [ - Card::new("Blocked", &blocked.to_string(), "stopped") - .with_value_color(if blocked > 0 { Color::Red } else { Color::Green }), - Card::new("Alerts", &alerts.to_string(), "advisory") - .with_value_color(if alerts > 0 { Color::Yellow } else { Color::Green }), - Card::new("Canaries", &canaries_armed.to_string(), "armed") - .with_value_color(if canaries_armed > 0 { Color::Green } else { Color::Blue }), + Card::new("Blocked", &blocked.to_string(), "stopped").with_value_color(if blocked > 0 { + Color::Red + } else { + Color::Green + }), + Card::new("Alerts", &alerts.to_string(), "advisory").with_value_color(if alerts > 0 { + Color::Yellow + } else { + Color::Green + }), + Card::new("Canaries", &canaries_armed.to_string(), "armed").with_value_color( + if canaries_armed > 0 { + Color::Green + } else { + Color::Blue + }, + ), ]; writeln!(out, "{}", render_cards(&cards, 11, 2, &sty))?; writeln!(out)?; diff --git a/src/cli/status.rs b/src/cli/status.rs index 9ea3cf0..0701d57 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -227,12 +227,20 @@ fn maybe_emit_nudge( today: &str, ) -> std::io::Result<()> { // Already nudged today → stay quiet. - if storage.meta_get("nudge_last_date").ok().flatten().as_deref() == Some(today) { + if storage + .meta_get("nudge_last_date") + .ok() + .flatten() + .as_deref() + == Some(today) + { return Ok(()); } const WINDOW_DAYS: i64 = 7; - let win = storage.breakdown_since_days(WINDOW_DAYS).unwrap_or_default(); + let win = storage + .breakdown_since_days(WINDOW_DAYS) + .unwrap_or_default(); let prompt_tokens: u64 = win .iter() .map(|b| b.input_tokens + b.cache_creation_tokens + b.cache_read_tokens) @@ -491,10 +499,18 @@ fn write_table( Card::new("Budget", "no cap", &format!("${:.2}", today_cost)) }); cards.push( - Card::new("Cache", &format!("{:.0}%", cache_hit), &fill_bar(cache_hit, 8)) - .with_value_color(Color::Green) - .with_sub_color(Color::Green) - .with_delta(delta_chip_pct(cache_hit, prev.cache_hit_pct, Trend::HigherBetter)), + Card::new( + "Cache", + &format!("{:.0}%", cache_hit), + &fill_bar(cache_hit, 8), + ) + .with_value_color(Color::Green) + .with_sub_color(Color::Green) + .with_delta(delta_chip_pct( + cache_hit, + prev.cache_hit_pct, + Trend::HigherBetter, + )), ); cards.push({ let sub = if security_alerts > 0 { @@ -525,7 +541,10 @@ fn write_table( // the week. Quiet when the whole week was idle. if spend_spark.iter().any(|&v| v > 0.0) { let lo = spend_spark.iter().cloned().fold(f64::INFINITY, f64::min); - let hi = spend_spark.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let hi = spend_spark + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); writeln!( w, " {} {} ${:.2}–${:.2}", @@ -981,7 +1000,11 @@ fn compute_prev_day(storage: &Storage, date: &str) -> PrevDay { /// A dense `len`-day spend series ending today (oldest → newest, one entry per /// local day, zero-filled for idle days). Powers the status sparkline and the /// panel's SVG chart. Best-effort: an error yields an all-zero series. -fn spend_series(storage: &Storage, now_local: chrono::DateTime, len: i64) -> Vec { +fn spend_series( + storage: &Storage, + now_local: chrono::DateTime, + len: i64, +) -> Vec { let by_date: std::collections::HashMap = storage .daily_totals(len) .unwrap_or_default() diff --git a/src/cli/statusline.rs b/src/cli/statusline.rs index e9c30f4..bd6e2ed 100644 --- a/src/cli/statusline.rs +++ b/src/cli/statusline.rs @@ -225,8 +225,7 @@ fn routing_state(model_id: &str) -> ribbon::Routing { /// `enable-routing` / `disable-routing` write. Reading it costs one small file /// read, and only on the (already unhappy) direct path. fn direct_state() -> ribbon::Routing { - let active = crate::cli::init::Shell::detect() - .and_then(crate::cli::routing::env_file_state) + let active = crate::cli::init::Shell::detect().and_then(crate::cli::routing::env_file_state) == Some(crate::cli::routing::EnvFileState::Active); if active { ribbon::Routing::DirectDegraded @@ -343,10 +342,7 @@ fn db_enrichment() -> (f64, u64) { return (0.0, 0); }; let cost = storage.total_cost_for_date(&today).unwrap_or(0.0); - let blocks = storage - .blocked_count_for_date(&today) - .unwrap_or(0) - .max(0) as u64; + let blocks = storage.blocked_count_for_date(&today).unwrap_or(0).max(0) as u64; (cost, blocks) } @@ -463,9 +459,15 @@ mod tests { }); let s = assemble_ribbon(&cc, None, 0.0, 0, plan, ribbon::Routing::Proxied).render(false); assert!(s.contains("5h ["), "got: {s}"); - assert!(s.contains("15% (44m)"), "binding window shows live reset: {s}"); + assert!( + s.contains("15% (44m)"), + "binding window shows live reset: {s}" + ); assert!(s.contains("7d 58%"), "got: {s}"); - assert!(!s.contains("sess"), "subscription mode hides notional dollars: {s}"); + assert!( + !s.contains("sess"), + "subscription mode hides notional dollars: {s}" + ); } #[test] @@ -497,7 +499,10 @@ mod tests { #[test] fn cc_model_id_prefers_id_then_display_name() { - assert_eq!(cc_model_id(&cc_from(r#"{"model":{"id":"claude-opus-4-8"}}"#)), "claude-opus-4-8"); + assert_eq!( + cc_model_id(&cc_from(r#"{"model":{"id":"claude-opus-4-8"}}"#)), + "claude-opus-4-8" + ); assert_eq!( cc_model_id(&cc_from(r#"{"model":{"id":"","display_name":"Opus"}}"#)), "Opus" diff --git a/src/cli/stop.rs b/src/cli/stop.rs index 90ed0da..689a685 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -95,7 +95,10 @@ fn soft_stop(pid: u32) -> anyhow::Result<()> { " Free the port now (cuts in-flight requests): {}", sty.bold("burnwall stop --hard") ); - println!(" Turn protection back on: {}", sty.bold("burnwall start")); + println!( + " Turn protection back on: {}", + sty.bold("burnwall start") + ); Ok(()) } @@ -189,7 +192,9 @@ pub(crate) fn pause_and_report() { println!(); println!( " {}", - sty.yellow("⚠ AI tools already running still point at the stopped proxy and will fail to connect.") + sty.yellow( + "⚠ AI tools already running still point at the stopped proxy and will fail to connect." + ) ); println!( " Bring it back — {} — and they recover instantly,", diff --git a/src/cli/tags.rs b/src/cli/tags.rs index d639734..0054587 100644 --- a/src/cli/tags.rs +++ b/src/cli/tags.rs @@ -64,7 +64,11 @@ fn aggregate(days: i64, rows: &[(String, f64)]) -> TagReport { }; for (k, v) in map { if let Some(val) = v.as_str() { - let entry = acc.entry(k).or_default().entry(val.to_string()).or_insert((0.0, 0)); + let entry = acc + .entry(k) + .or_default() + .entry(val.to_string()) + .or_insert((0.0, 0)); entry.0 += *cost; entry.1 += 1; } @@ -195,14 +199,16 @@ fn write_json(w: &mut impl Write, r: &TagReport, key_filter: Option<&str>) -> st .map(|(k, values)| { ( k.clone(), - json!(values - .iter() - .map(|v| json!({ - "value": v.value, - "cost_usd": v.cost, - "requests": v.requests, - })) - .collect::>()), + json!( + values + .iter() + .map(|v| json!({ + "value": v.value, + "cost_usd": v.cost, + "requests": v.requests, + })) + .collect::>() + ), ) }) .collect(); diff --git a/src/cli/waste.rs b/src/cli/waste.rs index 459d593..344b186 100644 --- a/src/cli/waste.rs +++ b/src/cli/waste.rs @@ -89,8 +89,12 @@ fn write_table( // Headline tiles: total avoidable, its per-day rate, and how many patterns. let per_day = total / days.max(1) as f64; let cards = [ - Card::new("Avoidable", &format!("${:.2}", total), &format!("over {days}d")) - .with_value_color(Color::Yellow), + Card::new( + "Avoidable", + &format!("${:.2}", total), + &format!("over {days}d"), + ) + .with_value_color(Color::Yellow), Card::new("Per day", &format!("${:.2}", per_day), "avg"), Card::new("Findings", &findings.len().to_string(), "patterns"), ]; diff --git a/src/plan.rs b/src/plan.rs index e031e50..577c3a7 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -432,7 +432,10 @@ mod tests { .expect("known subscriber stays in plan mode"); assert!(rl.stale); assert_eq!(rl.primary_label, "5h"); - assert_eq!(rl.primary_pct, 0.0, "a rolled 5h window must read 0%, not ~100%"); + assert_eq!( + rl.primary_pct, 0.0, + "a rolled 5h window must read 0%, not ~100%" + ); // 7d is still inside its period → last-known value preserved. assert_eq!(rl.secondary, Some(("7d".to_string(), 10.0))); } @@ -465,7 +468,10 @@ mod tests { // a 5h reading of 0%, not the resurrected last-known 11%. let snap = parse_limits("anthropic", &unified(), 1780951905).unwrap(); let after_5h_reset = 1780960800 + 60; - assert!(!snap.is_stale(after_5h_reset, 12 * 3600), "snapshot itself is fresh"); + assert!( + !snap.is_stale(after_5h_reset, 12 * 3600), + "snapshot itself is fresh" + ); let rl = snap .to_ribbon_limits_stale_aware(after_5h_reset, false) .or_else(|| snap.to_ribbon_limits_stale_aware(after_5h_reset, true)) diff --git a/src/proxy/handler.rs b/src/proxy/handler.rs index b33250d..7952a27 100644 --- a/src/proxy/handler.rs +++ b/src/proxy/handler.rs @@ -1018,7 +1018,9 @@ const MAX_TAG_LEN: usize = 64; /// fail-open: a malformed pair is skipped, an absent/empty/all-invalid header /// yields `None` — it never errors and never blocks a request. pub fn tags_from_headers(headers: &hyper::HeaderMap) -> Option { - let raw = headers.get("x-burnwall-tags").and_then(|v| v.to_str().ok())?; + let raw = headers + .get("x-burnwall-tags") + .and_then(|v| v.to_str().ok())?; let mut map = serde_json::Map::new(); for pair in raw.split(',') { if map.len() >= MAX_TAGS { @@ -1159,7 +1161,10 @@ mod tag_header_tests { #[test] fn tags_parse_normalises_into_json_object() { - let h = headers(&[("x-burnwall-tags", "feature=auth, Client=Acme , agent-run=run42")]); + let h = headers(&[( + "x-burnwall-tags", + "feature=auth, Client=Acme , agent-run=run42", + )]); let json = tags_from_headers(&h).expect("tags"); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["feature"], "auth"); @@ -1179,7 +1184,10 @@ mod tag_header_tests { #[test] fn tags_are_bounded() { // More than MAX_TAGS keys -> capped; over-long values truncated. - let many = (0..20).map(|i| format!("k{i}=v{i}")).collect::>().join(","); + let many = (0..20) + .map(|i| format!("k{i}=v{i}")) + .collect::>() + .join(","); let json = tags_from_headers(&headers(&[("x-burnwall-tags", &many)])).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v.as_object().unwrap().len(), MAX_TAGS); diff --git a/src/ribbon.rs b/src/ribbon.rs index 15e357b..3a39db8 100644 --- a/src/ribbon.rs +++ b/src/ribbon.rs @@ -829,10 +829,19 @@ mod tests { }); let s = r.render(false); assert!(s.contains("⚠ DIRECT (unprotected)"), "got: {s}"); - assert!(s.contains("burnwall doctor"), "degraded must hint a fix: {s}"); + assert!( + s.contains("burnwall doctor"), + "degraded must hint a fix: {s}" + ); assert!(s.contains("↑13k ↓615"), "token counts stay: {s}"); - assert!(!s.contains("5h"), "no stale plan window when unprotected: {s}"); - assert!(!s.contains("blocked"), "no block count when unprotected: {s}"); + assert!( + !s.contains("5h"), + "no stale plan window when unprotected: {s}" + ); + assert!( + !s.contains("blocked"), + "no block count when unprotected: {s}" + ); } #[test] @@ -842,7 +851,10 @@ mod tests { r.routing = Routing::Direct; let s = r.render(false); assert!(s.contains("⚠ DIRECT (unprotected)"), "got: {s}"); - assert!(!s.contains("doctor"), "chosen direct must not suggest a fix: {s}"); + assert!( + !s.contains("doctor"), + "chosen direct must not suggest a fix: {s}" + ); } #[test] diff --git a/src/security/catalog.rs b/src/security/catalog.rs index 4acc464..54fea87 100644 --- a/src/security/catalog.rs +++ b/src/security/catalog.rs @@ -263,7 +263,10 @@ mod tests { assert!(!is_advisory(et), "{et} blocks; must not classify advisory"); } for et in advisory { - assert!(is_advisory(et), "{et} is alert-only; must classify advisory"); + assert!( + is_advisory(et), + "{et} is alert-only; must classify advisory" + ); } // Unknown / pack-authored types are enforcement by default. assert!(!is_advisory("pack_authored_future_rule")); diff --git a/src/security/destructive.rs b/src/security/destructive.rs index ad47b92..a39ba32 100644 --- a/src/security/destructive.rs +++ b/src/security/destructive.rs @@ -170,16 +170,14 @@ mod tests { // (1) A scoped `rm -rf` after an unrelated `$()` (a PID capture). The // subshell belongs to `netstat`, not the rm; the rm target is an // explicit project artifact dir. - let scoped_rm_after_subshell = - "PID=$(netstat -ano | grep ':3210' | awk '{print $NF}')\n\ + let scoped_rm_after_subshell = "PID=$(netstat -ano | grep ':3210' | awk '{print $NF}')\n\ rm -rf \"C:/Code/ng2-pdfjs-viewer/.playwright-mcp\""; assert_eq!(first_match(scoped_rm_after_subshell), None); // (2) Searching source FOR the literal: `rm` lives in a grep pattern, and // a bare `/` lives in an unrelated echo on another line. Neither // segment is a delete. This exact shape blocked the session fixing it. - let grep_for_the_pattern = - "echo \"=== destructive_blocked / rules ===\"\n\ + let grep_for_the_pattern = "echo \"=== destructive_blocked / rules ===\"\n\ grep -rn \"rm -rf|disk wipe\" src/security/ | head"; assert_eq!(first_match(grep_for_the_pattern), None); } diff --git a/src/term.rs b/src/term.rs index ffe788b..a1396b0 100644 --- a/src/term.rs +++ b/src/term.rs @@ -351,7 +351,11 @@ pub fn sparkline(values: &[f64]) -> String { let min = values.iter().cloned().fold(f64::INFINITY, f64::min); let range = max - min; if range <= f64::EPSILON { - let c = if max > 0.0 { LEVELS[LEVELS.len() / 2] } else { LEVELS[0] }; + let c = if max > 0.0 { + LEVELS[LEVELS.len() / 2] + } else { + LEVELS[0] + }; return std::iter::repeat_n(c, values.len()).collect(); } values @@ -441,8 +445,11 @@ mod tests { fn delta_row_appears_when_any_card_has_one_and_stays_aligned() { let sty = Styler::with_enabled(false); let cards = [ - Card::new("Spend", "$4.20", "37 req") - .with_delta(delta_chip_pct(4.2, 3.0, Trend::HigherWorse)), + Card::new("Spend", "$4.20", "37 req").with_delta(delta_chip_pct( + 4.2, + 3.0, + Trend::HigherWorse, + )), // A card with no delta still reserves the row (blank), keeping height. Card::new("Cache", "88%", &fill_bar(88.0, 8)), ]; diff --git a/tests/integration/cli_test.rs b/tests/integration/cli_test.rs index 571b3f1..692634b 100644 --- a/tests/integration/cli_test.rs +++ b/tests/integration/cli_test.rs @@ -58,7 +58,9 @@ fn status_table_shows_seeded_data() { .stdout(predicate::str::contains("Today (")) .stdout(predicate::str::contains("anthropic/claude-sonnet-4-6")) .stdout(predicate::str::contains("$0.01")) - .stdout(predicate::str::contains("Security: 1 request blocked · 1 alert")); + .stdout(predicate::str::contains( + "Security: 1 request blocked · 1 alert", + )); } #[test] diff --git a/tests/integration/daemon_test.rs b/tests/integration/daemon_test.rs index f3e9aeb..7385da3 100644 --- a/tests/integration/daemon_test.rs +++ b/tests/integration/daemon_test.rs @@ -252,7 +252,11 @@ fn soft_stop_leaves_the_proxy_draining_so_running_tools_dont_wedge() { .stdout(predicate::str::contains("Watchdog: guard running").not()); let _cleanup = DaemonCleanup(pid_file.clone()); - let pid: u32 = fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap(); + let pid: u32 = fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); // Default (soft) stop: the proxy stays UP as a pass-through relay so an // already-running tool doesn't hit a dead port. This is the wedge fix. diff --git a/tests/unit/storage_test.rs b/tests/unit/storage_test.rs index 7681eba..bf4c0b6 100644 --- a/tests/unit/storage_test.rs +++ b/tests/unit/storage_test.rs @@ -69,8 +69,14 @@ fn open_in_memory_creates_all_tables() { fn tags_roundtrip_and_tag_rows_query() { let storage = Storage::open_in_memory().expect("open"); // A tagged forwarded row + an untagged one + a tagged-but-blocked one. - let tagged = RequestRecord::successful("anthropic", "claude-sonnet-4-6", &sample_usage(), 0.50, None) - .with_tags(Some(r#"{"client":"acme","feature":"auth"}"#.to_string())); + let tagged = RequestRecord::successful( + "anthropic", + "claude-sonnet-4-6", + &sample_usage(), + 0.50, + None, + ) + .with_tags(Some(r#"{"client":"acme","feature":"auth"}"#.to_string())); let id = storage.insert_request(&tagged).unwrap(); storage .insert_request(&RequestRecord::successful(