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..8e58eb4 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 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 @@ -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..60bd844 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 to a +/// non-empty path, 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();