From e43cf6c6716c0095c25242f774e11c8bb5acf0ac Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Sat, 8 Aug 2026 00:31:01 -0700 Subject: [PATCH 1/2] `BULLPEN_HOME`: run bullpen against an isolated state directory (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bullpen process wrote to the one `~/.bullpen`, so there was no way to run a demo, a test, or a second copy of bullpen without it sharing sessions, credentials, and logs with the real one. The README's own recorded demo had to work around this, and any script wanting a scratch store had nothing to set. `BULLPEN_HOME` moves the whole directory, not just the database. The store, `auth.json`, and the background logs all land directly in it with no `.bullpen` segment appended. Splitting them — a relocatable database with logs still under `$HOME` — would leave logs keyed to session ids that live in a database somewhere else, which is the split-brain the single-directory concept exists to prevent. Empty counts as unset. `env::var_os` hands back `Some("")` for `BULLPEN_HOME=`, which taken literally would resolve the store to a bare relative `bullpen.db` in whatever directory the process happened to start in — a silent relocation of someone's sessions, the exact failure this change must not introduce. `store` and `auth` are siblings — neither depends on the other, both only on `bullpen-llm` — so there is no existing crate to share the resolver from, and a one-function crate is not worth its manifest. Each carries its own private `resolve_home` and its own fallback test, so a divergence between the two copies fails CI rather than quietly relocating one of the two files. `cli` calls the store's public `home_dir()` for the logs directory. Both `resolve_home`s are pure, taking the env values as arguments; only the `home_dir()` wrapper reads the process environment. That is not a style preference: edition 2024 makes `env::set_var` unsafe and `cargo test` runs the suite multi-threaded in one process, so a test that mutated `HOME` would race every other test in its binary. Additive. No migration, no rename, no `--store` flag, and nothing on disk moves. With the variable unset every path is byte-for-byte what it was. Verified: fmt, clippy -D warnings, and 98 workspace tests green (4 new — the required unset-fallback assertion and the override, in both crates). Also checked against the built binary, since the pure tests deliberately leave the env-reading wrapper uncovered: `BULLPEN_HOME= bullpen sessions --json` created `/bullpen.db` with no `.bullpen` segment, and the same command with the variable unset still read the pre-existing `~/.bullpen/bullpen.db` and returned its real sessions. Refs #8 Claude-Session: https://claude.ai/code/session_01PfAfAujueuZ3rDTiL9apx3 Co-authored-by: Claude Fable 5 --- ARCHITECTURE.md | 3 ++- README.md | 5 +++- crates/auth/src/lib.rs | 53 ++++++++++++++++++++++++++++++++++++----- crates/cli/src/bg.rs | 5 +--- crates/cli/src/main.rs | 2 +- crates/store/src/lib.rs | 48 +++++++++++++++++++++++++++++++++---- 6 files changed, 99 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a186a7b..3303db6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ crates above it in this table: | Crate | Owns | Must never know about | |---|---|---| | `bullpen-llm` | Provider-neutral conversation types, `Provider` trait, wire-format adapters (Anthropic messages, OpenAI chat-completions, Codex Responses/SSE), shared retry policy | Tools, transcripts, UI | -| `bullpen-auth` | Credential store (`~/.bullpen/auth.json`, 0600, atomic), PKCE, OpenRouter OAuth, Codex device-code flow + refresh, read-only borrow of `~/.codex/auth.json` | Tools, the loop, UI | +| `bullpen-auth` | Credential store (`~/.bullpen/auth.json` or `$BULLPEN_HOME/auth.json`, 0600, atomic), PKCE, OpenRouter OAuth, Codex device-code flow + refresh, read-only borrow of `~/.codex/auth.json` | Tools, the loop, UI | | `bullpen-tools` | `Tool` trait, `Registry`, built-ins (bash, read/write/edit, grep, glob), parallel-safety flags | Providers, the loop | | `bullpen-store` | SQLite persistence: sessions, transcripts, usage; schema migrations via `user_version` | Providers, tools, the loop | | `bullpen-agent` | The loop: transcript, provider calls, tool continuation, events, max-turns fuse, the `Journal` durability protocol (trait only) | Config files, sessions, vendors, UI, storage | @@ -101,6 +101,7 @@ never stops the loop. One database: `~/.bullpen/bullpen.db`, WAL mode, `busy_timeout` set, schema versioned by `pragma user_version`. Session ids resolve by unique prefix. +`BULLPEN_HOME` overrides the directory (see README, "Where state lives"). The durability rule, the reduction idea, and the recovery discipline below are adapted from pi's `harness-v2.md` design spec — see diff --git a/README.md b/README.md index 7e4330c..f1c9cb8 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,12 @@ Reading it while sessions run needs the immutable flag, since WAL databases can't be opened read-only without their shared-memory file: ```bash -sqlite3 "file:$HOME/.bullpen/bullpen.db?immutable=1" "select id, status from sessions" +sqlite3 "file:${BULLPEN_HOME:-$HOME/.bullpen}/bullpen.db?immutable=1" "select id, status from sessions" ``` +Set `BULLPEN_HOME` to move the whole directory — database, `auth.json`, and +background logs land directly in it, with no `.bullpen` segment appended. + ## Status **v0.** Honest about what that means: diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index d5280e1..c0fa38e 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -2,8 +2,9 @@ //! //! Two credential shapes cover every supported provider: plain API keys //! (OpenRouter, and later GLM/Kimi/Anthropic) and OAuth token sets with -//! refresh (ChatGPT/Codex). Everything lives in `~/.bullpen/auth.json`, -//! written atomically with mode 0600. +//! refresh (ChatGPT/Codex). Everything lives in `~/.bullpen/auth.json` (or +//! `$BULLPEN_HOME/auth.json` when that is set), written atomically with mode +//! 0600. //! //! This crate also implements `bullpen_llm::codex::TokenSource` twice: //! [`codex::StoredCodex`] (bullpen's own login, refreshes and persists) and @@ -16,6 +17,7 @@ pub mod openrouter; pub mod pkce; use std::collections::BTreeMap; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -49,6 +51,23 @@ pub enum Credential { }, } +/// The directory holding bullpen's own state: `$BULLPEN_HOME` when set, +/// otherwise `~/.bullpen`. +pub fn home_dir() -> PathBuf { + resolve_home(std::env::var_os("BULLPEN_HOME"), std::env::home_dir()) +} + +/// `bullpen_home` is `$BULLPEN_HOME` and `home` is `$HOME` (the caller reads +/// the environment). An empty `BULLPEN_HOME` counts as unset: taken +/// literally it would put credentials in whatever directory the process +/// happened to start in. +fn resolve_home(bullpen_home: Option, home: Option) -> PathBuf { + match bullpen_home { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => home.unwrap_or_else(|| PathBuf::from(".")).join(".bullpen"), + } +} + /// The on-disk credential store. #[derive(Debug)] pub struct AuthFile { @@ -58,10 +77,7 @@ pub struct AuthFile { impl AuthFile { pub fn default_path() -> PathBuf { - std::env::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".bullpen") - .join("auth.json") + home_dir().join("auth.json") } /// Load the store; a missing file is an empty store. @@ -129,6 +145,31 @@ pub(crate) fn now_unix() -> u64 { mod tests { use super::*; + #[test] + fn unset_bullpen_home_still_resolves_under_dot_bullpen() { + assert_eq!( + resolve_home(None, Some(PathBuf::from("/h"))).join("auth.json"), + PathBuf::from("/h/.bullpen/auth.json") + ); + // No $HOME either — the long-standing relative fallback. + assert_eq!( + resolve_home(None, None).join("auth.json"), + PathBuf::from("./.bullpen/auth.json") + ); + } + + #[test] + fn bullpen_home_overrides_the_default_directory() { + assert_eq!( + resolve_home(Some("/tmp/pen".into()), Some(PathBuf::from("/h"))).join("auth.json"), + PathBuf::from("/tmp/pen/auth.json") + ); + assert_eq!( + resolve_home(Some("".into()), Some(PathBuf::from("/h"))).join("auth.json"), + PathBuf::from("/h/.bullpen/auth.json") + ); + } + #[test] fn roundtrip_and_missing_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/cli/src/bg.rs b/crates/cli/src/bg.rs index 42d39ca..cd3d23e 100644 --- a/crates/cli/src/bg.rs +++ b/crates/cli/src/bg.rs @@ -14,10 +14,7 @@ pub fn log_path(session_id: &str) -> PathBuf { } fn logs_dir() -> PathBuf { - std::env::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".bullpen") - .join("logs") + bullpen_store::home_dir().join("logs") } /// Whether `pid` is a live process. Uses `kill(pid, 0)`: success or an diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d748dc1..262a20a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -70,7 +70,7 @@ enum Command { /// Session id or unique prefix. session: String, }, - /// Connect a provider account (stores credentials in ~/.bullpen). + /// Connect a provider account (stores credentials in $BULLPEN_HOME, default ~/.bullpen). Login { #[arg(value_enum)] provider: LoginProvider, diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 22d0f4e..c0c6738 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -20,6 +20,7 @@ pub mod status; pub use recovery::{Recovery, recover}; pub use status::AgentStatus; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use bullpen_llm::{Message, Role, Usage}; @@ -94,6 +95,23 @@ pub struct OpenRun { pub records: Vec, } +/// The directory holding bullpen's own state: `$BULLPEN_HOME` when set, +/// otherwise `~/.bullpen`. +pub fn home_dir() -> PathBuf { + resolve_home(std::env::var_os("BULLPEN_HOME"), std::env::home_dir()) +} + +/// `bullpen_home` is `$BULLPEN_HOME` and `home` is `$HOME` (the caller reads +/// the environment). An empty `BULLPEN_HOME` counts as unset: taken +/// literally it would put the whole store in whatever directory the process +/// happened to start in. +fn resolve_home(bullpen_home: Option, home: Option) -> PathBuf { + match bullpen_home { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => home.unwrap_or_else(|| PathBuf::from(".")).join(".bullpen"), + } +} + pub struct Store { conn: Connection, } @@ -114,10 +132,7 @@ impl Store { } pub fn default_path() -> PathBuf { - std::env::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".bullpen") - .join("bullpen.db") + home_dir().join("bullpen.db") } fn migrate(&mut self) -> Result<(), StoreError> { @@ -725,6 +740,31 @@ mod tests { serde_json::to_value(m).unwrap() } + #[test] + fn unset_bullpen_home_still_resolves_under_dot_bullpen() { + assert_eq!( + resolve_home(None, Some(PathBuf::from("/h"))).join("bullpen.db"), + PathBuf::from("/h/.bullpen/bullpen.db") + ); + // No $HOME either — the long-standing relative fallback. + assert_eq!( + resolve_home(None, None).join("bullpen.db"), + PathBuf::from("./.bullpen/bullpen.db") + ); + } + + #[test] + fn bullpen_home_overrides_the_default_directory() { + assert_eq!( + resolve_home(Some("/tmp/pen".into()), Some(PathBuf::from("/h"))).join("bullpen.db"), + PathBuf::from("/tmp/pen/bullpen.db") + ); + assert_eq!( + resolve_home(Some("".into()), Some(PathBuf::from("/h"))).join("bullpen.db"), + PathBuf::from("/h/.bullpen/bullpen.db") + ); + } + #[test] fn entry_chain_roundtrip() { let (_dir, mut store) = store(); From f7b9992c2cd4b0c9d2e6940027446ba30274bb46 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Sat, 8 Aug 2026 00:36:03 -0700 Subject: [PATCH 2/2] docs: note that only a non-empty BULLPEN_HOME overrides the default Refs #8 --- crates/auth/src/lib.rs | 4 ++-- crates/store/src/lib.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index c0fa38e..8e58eb4 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -3,8 +3,8 @@ //! Two credential shapes cover every supported provider: plain API keys //! (OpenRouter, and later GLM/Kimi/Anthropic) and OAuth token sets with //! refresh (ChatGPT/Codex). Everything lives in `~/.bullpen/auth.json` (or -//! `$BULLPEN_HOME/auth.json` when that is set), written atomically with mode -//! 0600. +//! `$BULLPEN_HOME/auth.json` when that is set to a non-empty path), written +//! atomically with mode 0600. //! //! This crate also implements `bullpen_llm::codex::TokenSource` twice: //! [`codex::StoredCodex`] (bullpen's own login, refreshes and persists) and diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index c0c6738..60bd844 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -95,8 +95,8 @@ pub struct OpenRun { pub records: Vec, } -/// The directory holding bullpen's own state: `$BULLPEN_HOME` when set, -/// otherwise `~/.bullpen`. +/// The directory holding bullpen's own state: `$BULLPEN_HOME` when set to a +/// non-empty path, otherwise `~/.bullpen`. pub fn home_dir() -> PathBuf { resolve_home(std::env::var_os("BULLPEN_HOME"), std::env::home_dir()) }