use std::fmt;use std::path::Path;use std::path::PathBuf;use std::sync::Arc;use anyhow::Result;use codex_git_utils::get_git_repo_root;use codex_keyring_store::DefaultKeyringStore;use codex_keyring_store::KeyringStore;use schemars::JsonSchema;use serde::Deserialize;use serde::Serialize;use sha2::Digest;use sha2::Sha256;mod local;mod sanitizer;pub use local::LocalSecretsBackend;pub use local::LocalSecretsNamespace;pub use sanitizer::redact_secrets;const KEYRING_SERVICE: &str = "codex";#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]pub struct SecretName(String);impl SecretName { pub fn new(raw: &str) -> Result<Self> { let trimmed = raw.trim(); anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty"); anyhow::ensure!( trimmed .chars() .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'), "secret name must contain only A-Z, 0-9, or _" ); Ok(Self(trimmed.to_string())) } pub fn as_str(&self) -> &str { self.0.as_str() }}impl fmt::Display for SecretName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) }}#[derive(Debug, Clone, PartialEq, Eq, Hash)]pub enum SecretScope { Global, Environment(String),}impl SecretScope { pub fn environment(environment_id: impl Into<String>) -> Result<Self> { let env_id = environment_id.into(); let trimmed = env_id.trim(); anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty"); Ok(Self::Environment(trimmed.to_string())) } pub fn canonical_key(&self, name: &SecretName) -> String { // Stable, env-safe identifier used as the on-disk map key. match self { Self::Global => format!("global/{}", name.as_str()), Self::Environment(environment_id) => { format!("env/{environment_id}/{}", name.as_str()) } } }}#[derive(Debug, Clone, PartialEq, Eq)]pub struct SecretListEntry { pub scope: SecretScope, pub name: SecretName,}#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]#[serde(rename_all = "lowercase")]pub enum SecretsBackendKind { #[default] Local,}pub trait SecretsBackend: Send + Sync { fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>; fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>>; fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>; fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>;}#[derive(Clone)]pub struct SecretsManager { backend: Arc<dyn SecretsBackend>,}impl SecretsManager { pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { let backend: Arc<dyn SecretsBackend> = match backend_kind { SecretsBackendKind::Local => { let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore); Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)) } }; Self { backend } } pub fn new_with_keyring_store( codex_home: PathBuf, backend_kind: SecretsBackendKind, keyring_store: Arc<dyn KeyringStore>, ) -> Self { let backend: Arc<dyn SecretsBackend> = match backend_kind { SecretsBackendKind::Local => { Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)) } }; Self { backend } } pub fn new_with_keyring_store_and_namespace( codex_home: PathBuf, backend_kind: SecretsBackendKind, keyring_store: Arc<dyn KeyringStore>, namespace: LocalSecretsNamespace, ) -> Self { let backend: Arc<dyn SecretsBackend> = match backend_kind { SecretsBackendKind::Local => Arc::new(LocalSecretsBackend::new_with_namespace( codex_home, keyring_store, namespace, )), }; Self { backend } } pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { self.backend.set(scope, name, value) } pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> { self.backend.get(scope, name) } pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> { self.backend.delete(scope, name) } pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> { self.backend.list(scope_filter) }}pub fn environment_id_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = get_git_repo_root(cwd) && let Some(name) = repo_root.file_name() { let name = name.to_string_lossy().trim().to_string(); if !name.is_empty() { return name; } } let canonical = cwd .canonicalize() .unwrap_or_else(|_| cwd.to_path_buf()) .to_string_lossy() .into_owned(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let short = hex.get(..12).unwrap_or(hex.as_str()); format!("cwd-{short}")}/// Computes the OS keyring account name used to store the local secrets passphrase.pub fn compute_keyring_account(codex_home: &Path) -> String { let canonical = codex_home .canonicalize() .unwrap_or_else(|_| codex_home.to_path_buf()) .to_string_lossy() .into_owned(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let short = hex.get(..16).unwrap_or(hex.as_str()); format!("secrets|{short}")}pub(crate) fn keyring_service() -> &'static str { KEYRING_SERVICE}#[cfg(test)]mod tests { use super::*; use codex_keyring_store::tests::MockKeyringStore; use pretty_assertions::assert_eq; #[test] fn environment_id_fallback_has_cwd_prefix() { let dir = tempfile::tempdir().expect("tempdir"); let env_id = environment_id_from_cwd(dir.path()); let canonical = dir .path() .canonicalize() .expect("tempdir canonical path should exist") .to_string_lossy() .into_owned(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let short = hex.get(..12).expect("digest has at least 12 chars"); assert_eq!(env_id, format!("cwd-{short}")); } #[test] fn manager_round_trips_local_backend() -> Result<()> { let codex_home = tempfile::tempdir().expect("tempdir"); let keyring = Arc::new(MockKeyringStore::default()); let manager = SecretsManager::new_with_keyring_store( codex_home.path().to_path_buf(), SecretsBackendKind::Local, keyring, ); let scope = SecretScope::Global; let name = SecretName::new("GITHUB_TOKEN")?; manager.set(&scope, &name, "token-1")?; assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string())); let listed = manager.list(/*scope_filter*/ None)?; assert_eq!(listed.len(), 1); assert_eq!(listed[0].name, name); assert!(manager.delete(&scope, &name)?); assert_eq!(manager.get(&scope, &name)?, None); Ok(()) }}