Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `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 |
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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:
Expand Down
53 changes: 47 additions & 6 deletions crates/auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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};
Expand Down Expand Up @@ -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<OsString>, home: Option<PathBuf>) -> 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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 1 addition & 4 deletions crates/cli/src/bg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 44 additions & 4 deletions crates/store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -94,6 +95,23 @@ pub struct OpenRun {
pub records: Vec<Record>,
}

/// 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<OsString>, home: Option<PathBuf>) -> 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,
}
Expand All @@ -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> {
Expand Down Expand Up @@ -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();
Expand Down