Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bbc6608
feat(proxy): schema + repo for per-user Anthropic keys
hashedone May 27, 2026
d89a9e1
feat(proxy): /api/v1/me/anthropic-key endpoints
hashedone May 27, 2026
7a185eb
feat(proxy): transparent Anthropic proxy handler
hashedone May 27, 2026
2fe30db
test(proxy): end-to-end integration tests against wiremock stub
hashedone May 27, 2026
d3987ae
test(proxy): real-Anthropic integration tests (#[ignore]-d)
hashedone May 27, 2026
76783a9
feat(proxy-web): /me/proxy/ page for configuring Anthropic key
hashedone May 27, 2026
d376a4a
feat(cli): tracevault proxy info
hashedone May 27, 2026
667846a
test(proxy): load .env in real-Anthropic tests
hashedone May 27, 2026
90310a8
test(cli): make init_test tests TTY-independent
hashedone May 27, 2026
7fb3830
feat(proxy-web): add LLM Proxy nav entry in sidebar footer
hashedone May 27, 2026
a23d5cd
feat(proxy-web): wrap /me/* routes in AppLayout
hashedone May 27, 2026
95a8ab8
fix(proxy-web): address PR review
hashedone May 27, 2026
40cb68a
refactor(proxy): split anthropic_proxy into 3 concern-scoped fns
hashedone May 27, 2026
e2bcc28
fix(me): cap Anthropic key length at 256 chars
hashedone May 27, 2026
e18cdbe
fix(proxy): reject `..` segments + simplify response-header copy
hashedone May 27, 2026
7a36be5
fix(proxy): raise body limit to 32MB and bound connect_timeout
hashedone May 27, 2026
3449aa1
chore: sync Cargo.lock with v0.16.0 base
hashedone May 27, 2026
6757910
fix(proxy): log path-traversal rejection as api_error, not authentica…
hashedone May 27, 2026
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
489 changes: 473 additions & 16 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/tracevault-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod flush;
pub mod init;
pub mod login;
pub mod logout;
pub mod proxy;
pub mod stats;
pub mod status;
pub mod stream;
Expand Down
64 changes: 64 additions & 0 deletions crates/tracevault-cli/src/commands/proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! `tracevault proxy info` — print the TraceVault LLM proxy configuration
//! a user needs to point their AI tool (Claude Code, GSD2, Cursor, etc.) at
//! the proxy.
//!
//! Read-only and purely local: never calls the network. Output is intended
//! to be copy-pasted directly into a shell or tool config.

use crate::credentials::Credentials;

const ANSI_BOLD: &str = "\x1b[1m";
const ANSI_DIM: &str = "\x1b[2m";
const ANSI_RESET: &str = "\x1b[0m";

/// Print the proxy configuration. Returns process exit code: 0 on success,
/// 1 when no credentials are available (user has not logged in).
pub fn run_proxy_info() -> i32 {
let creds = match Credentials::load() {
Some(c) => c,
None => {
eprintln!(
"Not logged in. Run `tracevault login --server-url <url>` first \
to obtain a TraceVault session token, then try again."
);
eprintln!(
"Credentials file expected at: {}",
Credentials::path().display()
);
return 1;
}
};

let server_url = creds.server_url.trim_end_matches('/');
let proxy_url = format!("{server_url}/proxy/anthropic");
let creds_path = Credentials::path();

println!("{ANSI_BOLD}TraceVault LLM Proxy{ANSI_RESET}");
println!();
println!(" Server: {server_url}");
println!(" Proxy base URL: {ANSI_BOLD}{proxy_url}{ANSI_RESET}");
println!(" Credentials file: {}", creds_path.display());
println!();
println!("{ANSI_BOLD}Setup{ANSI_RESET}");
println!();
println!(" 1. Configure your Anthropic API key once at:");
println!(" {server_url}/me/proxy");
println!();
println!(" 2. Set these environment variables for your AI tool:");
println!();
println!(" {ANSI_BOLD}export ANTHROPIC_BASE_URL=\"{proxy_url}\"{ANSI_RESET}");
println!(
" {ANSI_BOLD}export ANTHROPIC_API_KEY=\"<your TraceVault session token>\"{ANSI_RESET}"
);
println!();
println!(
" {ANSI_DIM}Your TraceVault session token lives in {} as the \"token\" field.{ANSI_RESET}",
creds_path.display()
);
println!();
println!(" 3. Run your AI tool as usual. Requests go through TraceVault and are");
println!(" forwarded to api.anthropic.com using the Anthropic key you stored");
println!(" in step 1.");

0
}
20 changes: 20 additions & 0 deletions crates/tracevault-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ enum Cli {
/// policies configured on the TraceVault server.
#[command(name = "agent-policies")]
AgentPolicies,
/// LLM proxy commands.
Proxy {
#[command(subcommand)]
cmd: ProxyCmd,
},
}

#[derive(clap::Subcommand)]
enum ProxyCmd {
/// Print the proxy URL and setup instructions for AI tools (Claude Code,
/// GSD2, Cursor, Codex CLI, etc.).
Info,
}

#[tokio::main]
Expand Down Expand Up @@ -212,5 +224,13 @@ async fn main() {
std::process::exit(1);
}
}
Cli::Proxy { cmd } => match cmd {
ProxyCmd::Info => {
let code = commands::proxy::run_proxy_info();
if code != 0 {
std::process::exit(code);
}
}
},
}
}
143 changes: 104 additions & 39 deletions crates/tracevault-cli/tests/init_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ fn tmp_git_repo() -> TempDir {
#[tokio::test]
async fn init_fails_without_git() {
let tmp = TempDir::new().unwrap();
let result =
tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false).await;
let result = tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
Expand All @@ -25,9 +30,14 @@ async fn init_creates_tracevault_config() {
let tmp = tmp_git_repo();
let config_path = tmp.path().join(".tracevault").join("config.toml");

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

assert!(config_path.exists());
let content = fs::read_to_string(&config_path).unwrap();
Expand All @@ -38,9 +48,14 @@ async fn init_creates_tracevault_config() {
async fn init_creates_directory_structure() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

assert!(tmp.path().join(".tracevault").exists());
assert!(tmp.path().join(".tracevault/sessions").exists());
Expand All @@ -56,9 +71,14 @@ async fn init_creates_directory_structure() {
async fn init_installs_claude_hooks() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let settings_path = tmp.path().join(".claude/settings.json");
assert!(settings_path.exists());
Expand All @@ -80,9 +100,14 @@ async fn init_merges_into_existing_settings() {
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("settings.json"), r#"{"model": "opus"}"#).unwrap();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
Expand All @@ -105,9 +130,14 @@ fn tracevault_hooks_has_pre_post_and_notification() {
async fn init_installs_git_pre_push_hook() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let hook_path = tmp.path().join(".git/hooks/pre-push");
assert!(hook_path.exists());
Expand All @@ -133,9 +163,14 @@ async fn init_preserves_existing_pre_push_hook() {
)
.unwrap();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let content = fs::read_to_string(hooks_dir.join("pre-push")).unwrap();
// Existing content preserved
Expand All @@ -150,12 +185,22 @@ async fn init_preserves_existing_pre_push_hook() {
async fn init_does_not_duplicate_hook_on_reinit() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let content = fs::read_to_string(tmp.path().join(".git/hooks/pre-push")).unwrap();
let marker_count = content.matches("# tracevault:enforce").count();
Expand All @@ -169,9 +214,14 @@ async fn init_does_not_duplicate_hook_on_reinit() {
async fn init_installs_post_commit_hook() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let hook_path = tmp.path().join(".git/hooks/post-commit");
assert!(hook_path.exists());
Expand All @@ -186,12 +236,22 @@ async fn init_installs_post_commit_hook() {
async fn init_does_not_duplicate_post_commit_hook_on_reinit() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
.unwrap();

let content = fs::read_to_string(tmp.path().join(".git/hooks/post-commit")).unwrap();
let marker_count = content.matches("# tracevault:post-commit").count();
Expand Down Expand Up @@ -280,7 +340,7 @@ async fn init_writes_server_url_to_config() {
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
Some("https://tv.example.com"),
None,
Some(ClaudeSettingsTarget::Shared),
false,
)
.await
Expand All @@ -295,9 +355,14 @@ async fn init_writes_server_url_to_config() {
async fn init_no_gitignore_skips_gitignore_update() {
let tmp = tmp_git_repo();

tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, true)
.await
.unwrap();
tracevault_cli::commands::init::init_in_directory(
tmp.path(),
None,
Some(ClaudeSettingsTarget::Shared),
true,
)
.await
.unwrap();

// .gitignore should not exist (tmp_git_repo creates a bare repo without one)
// or should not contain any tracevault entries if it already existed
Expand Down
2 changes: 1 addition & 1 deletion crates/tracevault-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
base64 = "0.22"
git2 = "0.20"
reqwest = { version = "0.13", features = ["json"] }
reqwest = { version = "0.13", features = ["json", "stream"] }
async-trait = "0.1"
aes-gcm = "0.10"
dotenvy = "0.15.7"
Expand Down
14 changes: 14 additions & 0 deletions crates/tracevault-server/migrations/024_user_anthropic_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Per-user Anthropic API keys, used by the transparent LLM proxy
-- (issue softwaremill/tracevault#207, parent #181).
--
-- One row per user. Key stored encrypted at rest (AES-256-GCM via
-- the existing encryption.rs path; same encryption_key env var as
-- org signing keys / SSO client secrets). The plaintext key never
-- leaves the proxy hot path and is never returned through any API.
CREATE TABLE user_anthropic_keys (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
key_encrypted TEXT NOT NULL,
key_nonce TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Loading
Loading