feat(brain-realtime): API-key loader + env-var/file-path posture (spec §5.3)

OPENAI_API_KEY default + OPENAI_API_KEY_FILE override (file wins —
k8s-secret pattern). Trims trailing whitespace (some k8s mounts add
newlines). KMS/Vault integration is step-6; the file-path override
makes secret-manager injection trivial later.

TDD: 5 tests (env-set, file-overrides-env, trim-newline, missing-file,
neither-set); verified deliberately-introduced-bug failure (upper()
breaks the env-var assertion).
This commit is contained in:
opencode controller
2026-06-30 20:35:21 -04:00
parent d1f4a9fcf4
commit 405cb7ef83
3 changed files with 237 additions and 2 deletions

34
Cargo.lock generated
View File

@@ -781,6 +781,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1144,6 +1150,19 @@ dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -1192,6 +1211,8 @@ dependencies = [
"rutster-tap",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
"tracing",
@@ -1515,6 +1536,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.69"

View File

@@ -19,6 +19,10 @@ tracing-subscriber = { workspace = true }
url = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"
[features]
default = []

View File

@@ -1,2 +1,199 @@
//! API-key loader (spec §5.3) — implements the env-var + file-path posture.
//! Filled in by Task 3.
//! # API-key loader (spec §5.3)
//!
//! Two-source config: `OPENAI_API_KEY` env var default + optional
//! `OPENAI_API_KEY_FILE` path override. KMS/Vault integration is deferred
//! to step 6 (spec §1.2); the file-path override makes secret-manager
//! injection (k8s secrets, Vault agent) trivial when that layer exists.
//!
//! # Why file-path override (not env-only)
//!
//! A k8s pod mounts secrets at file paths (e.g. `/var/secrets/openai_key`).
//! Env-var secrets are also fine, but file-path is the standard pattern for
//! k8s + Vault agent + sidecar secret rotators. The dev loop uses the env
//! var path (single-source, simplest).
use std::env;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiKeyError {
#[error("no API key: set OPENAI_API_KEY or OPENAI_API_KEY_FILE")]
NotFound,
#[error("OPENAI_API_KEY_FILE path is not valid UTF-8: {0:?}")]
PathNotUtf8(PathBuf),
#[error("failed to read OPENAI_API_KEY_FILE at {path}: {source}")]
FileRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
/// Load the OpenAI API key per the env-var + file-path posture (spec §5.3).
///
/// Precedence: `OPENAI_API_KEY_FILE` wins over `OPENAI_API_KEY` (a file
/// path is a more specific override; this matches k8s-secret patterns where
/// an operator mounts a file to override the default env var).
///
/// Trims trailing whitespace from either source (some k8s secret mounts
/// add trailing newlines).
pub fn load_api_key() -> Result<String, ApiKeyError> {
if let Ok(path_str) = env::var("OPENAI_API_KEY_FILE") {
let path = PathBuf::from(&path_str);
let raw = fs::read_to_string(&path).map_err(|source| ApiKeyError::FileRead {
path: path.clone(),
source,
})?;
return Ok(raw.trim().to_string());
}
if let Ok(raw) = env::var("OPENAI_API_KEY") {
return Ok(raw.trim().to_string());
}
Err(ApiKeyError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
// NOTE: edition 2024 makes env::set_var / env::remove_var `unsafe` (they
// can race with concurrent reads of the process environ). The brief was
// written against edition 2021 syntax; we wrap each mutation in
// `unsafe { ... }` rather than reverting to a pre-2024 edition. The
// unsafety is local to the test process and accepted by the stdlib's
// own test helpers; no threads here read these env vars concurrently.
// The process environ is process-global state. Even with the save/restore
// pattern below, running these tests in parallel would let one test's
// `set_var` race against another's `remove_var` for the same variable,
// producing flaky failures. This mutex serializes every test in this
// module so the env-var mutations are atomic across tests.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Acquire the env mutex for the test body. Panics if the lock is held
/// (only happens if a test panics while held — fine, fail fast).
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn returns_error_when_neither_env_nor_file_set() {
let _guard = lock_env();
// Save then clear both env vars to make the test order-independent.
let saved_key = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
let saved_file =
env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
unsafe {
env::remove_var("OPENAI_API_KEY");
env::remove_var("OPENAI_API_KEY_FILE");
}
let r = load_api_key();
assert!(matches!(r, Err(ApiKeyError::NotFound)));
// Restore for any other test in the same process.
if let Some((k, v)) = saved_key {
unsafe {
env::set_var(k, v);
}
}
if let Some((k, v)) = saved_file {
unsafe {
env::set_var(k, v);
}
}
}
#[test]
fn reads_from_env_var() {
let _guard = lock_env();
let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
let saved_file =
env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
unsafe {
env::set_var("OPENAI_API_KEY", "sk-test-12345");
env::remove_var("OPENAI_API_KEY_FILE");
}
let r = load_api_key().unwrap();
assert_eq!(r, "sk-test-12345");
if let Some((k, v)) = saved {
unsafe {
env::set_var(k, v);
}
} else {
unsafe {
env::remove_var("OPENAI_API_KEY");
}
}
if let Some((k, v)) = saved_file {
unsafe {
env::set_var(k, v);
}
}
}
#[test]
fn trim_trailing_newline_from_env() {
let _guard = lock_env();
let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
unsafe {
env::set_var("OPENAI_API_KEY", "sk-test-with-newline\n");
env::remove_var("OPENAI_API_KEY_FILE");
}
let r = load_api_key().unwrap();
assert_eq!(r, "sk-test-with-newline");
if let Some((k, v)) = saved {
unsafe {
env::set_var(k, v);
}
} else {
unsafe {
env::remove_var("OPENAI_API_KEY");
}
}
}
#[test]
fn file_path_overrides_env() {
let _guard = lock_env();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key.txt");
std::fs::write(&path, "sk-from-file-98765\n").unwrap();
unsafe {
env::set_var("OPENAI_API_KEY", "sk-from-env-should-be-overridden");
env::set_var("OPENAI_API_KEY_FILE", path.to_str().unwrap());
}
let r = load_api_key().unwrap();
assert_eq!(r, "sk-from-file-98765");
unsafe {
env::remove_var("OPENAI_API_KEY");
env::remove_var("OPENAI_API_KEY_FILE");
}
}
#[test]
fn file_path_missing_returns_file_read_error() {
let _guard = lock_env();
unsafe {
env::set_var("OPENAI_API_KEY_FILE", "/nonexistent/path/to/key.txt");
env::remove_var("OPENAI_API_KEY");
}
let r = load_api_key();
assert!(matches!(r, Err(ApiKeyError::FileRead { .. })));
unsafe {
env::remove_var("OPENAI_API_KEY_FILE");
}
}
}