Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
73 KiB
JSON
1 line
73 KiB
JSON
{"repo": "c4pt0r/pie", "files": {"crates/ai/src/env_api_keys.rs": {"0a120dfd380fb7f009e09f2b7981b76c07b3fd95": {"t": 1785745830.0696955, "n": 1883, "h": "ed178c75fb6f3376", "c": "//! Provider → env-var mapping. 1:1 stub of `packages/ai/src/env-api-keys.ts`.\n//!\n//! The repo-root `test.sh` and `pi-test.sh` duplicate this list when stripping keys; keep them\n//! in sync.\n\nconst ENTRIES: &[(&str, &[&str])] = &[\n (\"anthropic\", &[\"ANTHROPIC_API_KEY\"]),\n (\"openai\", &[\"OPENAI_API_KEY\"]),\n (\"openai-codex\", &[\"OPENAI_API_KEY\"]),\n (\"azure-openai-responses\", &[\"AZURE_OPENAI_API_KEY\"]),\n (\"google\", &[\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"]),\n (\"google-vertex\", &[\"GOOGLE_APPLICATION_CREDENTIALS\"]),\n (\"amazon-bedrock\", &[\"AWS_ACCESS_KEY_ID\"]),\n (\"mistral\", &[\"MISTRAL_API_KEY\"]),\n (\"xai\", &[\"XAI_API_KEY\"]),\n (\"groq\", &[\"GROQ_API_KEY\"]),\n (\"cerebras\", &[\"CEREBRAS_API_KEY\"]),\n (\"openrouter\", &[\"OPENROUTER_API_KEY\"]),\n (\"vercel-ai-gateway\", &[\"AI_GATEWAY_API_KEY\"]),\n (\"zai\", &[\"ZAI_API_KEY\"]),\n (\"deepseek\", &[\"DEEPSEEK_API_KEY\"]),\n (\"ds4\", &[\"DS4_API_KEY\"]),\n (\"fireworks\", &[\"FIREWORKS_API_KEY\"]),\n (\"together\", &[\"TOGETHER_API_KEY\"]),\n (\"github-copilot\", &[\"GITHUB_COPILOT_TOKEN\"]),\n (\"huggingface\", &[\"HUGGINGFACE_API_KEY\", \"HF_TOKEN\"]),\n (\"cloudflare-workers-ai\", &[\"CLOUDFLARE_API_TOKEN\"]),\n];\n\npub fn get_env_api_key(provider: &str) -> Option<String> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n for v in *vars {\n if let Ok(val) = std::env::var(v) {\n if !val.is_empty() {\n return Some(val);\n }\n }\n }\n }\n }\n None\n}\n\npub fn env_var_names(provider: &str) -> Vec<&'static str> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n return vars.to_vec();\n }\n }\n vec![]\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn ds4_uses_dedicated_local_env_var() {\n assert_eq!(super::env_var_names(\"ds4\"), vec![\"DS4_API_KEY\"]);\n }\n}\n"}, "10736610d7a0225d17b5ae6d379e0f2f84b7a106": {"t": 1785756900.0345428, "n": 1883, "h": "ed178c75fb6f3376", "c": "//! Provider → env-var mapping. 1:1 stub of `packages/ai/src/env-api-keys.ts`.\n//!\n//! The repo-root `test.sh` and `pi-test.sh` duplicate this list when stripping keys; keep them\n//! in sync.\n\nconst ENTRIES: &[(&str, &[&str])] = &[\n (\"anthropic\", &[\"ANTHROPIC_API_KEY\"]),\n (\"openai\", &[\"OPENAI_API_KEY\"]),\n (\"openai-codex\", &[\"OPENAI_API_KEY\"]),\n (\"azure-openai-responses\", &[\"AZURE_OPENAI_API_KEY\"]),\n (\"google\", &[\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"]),\n (\"google-vertex\", &[\"GOOGLE_APPLICATION_CREDENTIALS\"]),\n (\"amazon-bedrock\", &[\"AWS_ACCESS_KEY_ID\"]),\n (\"mistral\", &[\"MISTRAL_API_KEY\"]),\n (\"xai\", &[\"XAI_API_KEY\"]),\n (\"groq\", &[\"GROQ_API_KEY\"]),\n (\"cerebras\", &[\"CEREBRAS_API_KEY\"]),\n (\"openrouter\", &[\"OPENROUTER_API_KEY\"]),\n (\"vercel-ai-gateway\", &[\"AI_GATEWAY_API_KEY\"]),\n (\"zai\", &[\"ZAI_API_KEY\"]),\n (\"deepseek\", &[\"DEEPSEEK_API_KEY\"]),\n (\"ds4\", &[\"DS4_API_KEY\"]),\n (\"fireworks\", &[\"FIREWORKS_API_KEY\"]),\n (\"together\", &[\"TOGETHER_API_KEY\"]),\n (\"github-copilot\", &[\"GITHUB_COPILOT_TOKEN\"]),\n (\"huggingface\", &[\"HUGGINGFACE_API_KEY\", \"HF_TOKEN\"]),\n (\"cloudflare-workers-ai\", &[\"CLOUDFLARE_API_TOKEN\"]),\n];\n\npub fn get_env_api_key(provider: &str) -> Option<String> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n for v in *vars {\n if let Ok(val) = std::env::var(v) {\n if !val.is_empty() {\n return Some(val);\n }\n }\n }\n }\n }\n None\n}\n\npub fn env_var_names(provider: &str) -> Vec<&'static str> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n return vars.to_vec();\n }\n }\n vec![]\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn ds4_uses_dedicated_local_env_var() {\n assert_eq!(super::env_var_names(\"ds4\"), vec![\"DS4_API_KEY\"]);\n }\n}\n"}, "ced9d5becbc08c8d7b6a819ba0bcfdca068a8bd9": {"t": 1785756901.8356657, "n": 1692, "h": "b4b8185b17d0d889", "c": "//! Provider → env-var mapping. 1:1 stub of `packages/ai/src/env-api-keys.ts`.\n//!\n//! The repo-root `test.sh` and `pi-test.sh` duplicate this list when stripping keys; keep them\n//! in sync.\n\nconst ENTRIES: &[(&str, &[&str])] = &[\n (\"anthropic\", &[\"ANTHROPIC_API_KEY\"]),\n (\"openai\", &[\"OPENAI_API_KEY\"]),\n (\"openai-codex\", &[\"OPENAI_API_KEY\"]),\n (\"azure-openai-responses\", &[\"AZURE_OPENAI_API_KEY\"]),\n (\"google\", &[\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"]),\n (\"google-vertex\", &[\"GOOGLE_APPLICATION_CREDENTIALS\"]),\n (\"amazon-bedrock\", &[\"AWS_ACCESS_KEY_ID\"]),\n (\"mistral\", &[\"MISTRAL_API_KEY\"]),\n (\"xai\", &[\"XAI_API_KEY\"]),\n (\"groq\", &[\"GROQ_API_KEY\"]),\n (\"cerebras\", &[\"CEREBRAS_API_KEY\"]),\n (\"openrouter\", &[\"OPENROUTER_API_KEY\"]),\n (\"vercel-ai-gateway\", &[\"AI_GATEWAY_API_KEY\"]),\n (\"zai\", &[\"ZAI_API_KEY\"]),\n (\"deepseek\", &[\"DEEPSEEK_API_KEY\"]),\n (\"fireworks\", &[\"FIREWORKS_API_KEY\"]),\n (\"together\", &[\"TOGETHER_API_KEY\"]),\n (\"github-copilot\", &[\"GITHUB_COPILOT_TOKEN\"]),\n (\"huggingface\", &[\"HUGGINGFACE_API_KEY\", \"HF_TOKEN\"]),\n (\"cloudflare-workers-ai\", &[\"CLOUDFLARE_API_TOKEN\"]),\n];\n\npub fn get_env_api_key(provider: &str) -> Option<String> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n for v in *vars {\n if let Ok(val) = std::env::var(v) {\n if !val.is_empty() {\n return Some(val);\n }\n }\n }\n }\n }\n None\n}\n\npub fn env_var_names(provider: &str) -> Vec<&'static str> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n return vars.to_vec();\n }\n }\n vec![]\n}\n"}, "b72579680bdfe616edbc05808fb1395a1be3660f": {"t": 1786068325.8518534, "n": 1883, "h": "ed178c75fb6f3376", "c": "//! Provider → env-var mapping. 1:1 stub of `packages/ai/src/env-api-keys.ts`.\n//!\n//! The repo-root `test.sh` and `pi-test.sh` duplicate this list when stripping keys; keep them\n//! in sync.\n\nconst ENTRIES: &[(&str, &[&str])] = &[\n (\"anthropic\", &[\"ANTHROPIC_API_KEY\"]),\n (\"openai\", &[\"OPENAI_API_KEY\"]),\n (\"openai-codex\", &[\"OPENAI_API_KEY\"]),\n (\"azure-openai-responses\", &[\"AZURE_OPENAI_API_KEY\"]),\n (\"google\", &[\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"]),\n (\"google-vertex\", &[\"GOOGLE_APPLICATION_CREDENTIALS\"]),\n (\"amazon-bedrock\", &[\"AWS_ACCESS_KEY_ID\"]),\n (\"mistral\", &[\"MISTRAL_API_KEY\"]),\n (\"xai\", &[\"XAI_API_KEY\"]),\n (\"groq\", &[\"GROQ_API_KEY\"]),\n (\"cerebras\", &[\"CEREBRAS_API_KEY\"]),\n (\"openrouter\", &[\"OPENROUTER_API_KEY\"]),\n (\"vercel-ai-gateway\", &[\"AI_GATEWAY_API_KEY\"]),\n (\"zai\", &[\"ZAI_API_KEY\"]),\n (\"deepseek\", &[\"DEEPSEEK_API_KEY\"]),\n (\"ds4\", &[\"DS4_API_KEY\"]),\n (\"fireworks\", &[\"FIREWORKS_API_KEY\"]),\n (\"together\", &[\"TOGETHER_API_KEY\"]),\n (\"github-copilot\", &[\"GITHUB_COPILOT_TOKEN\"]),\n (\"huggingface\", &[\"HUGGINGFACE_API_KEY\", \"HF_TOKEN\"]),\n (\"cloudflare-workers-ai\", &[\"CLOUDFLARE_API_TOKEN\"]),\n];\n\npub fn get_env_api_key(provider: &str) -> Option<String> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n for v in *vars {\n if let Ok(val) = std::env::var(v) {\n if !val.is_empty() {\n return Some(val);\n }\n }\n }\n }\n }\n None\n}\n\npub fn env_var_names(provider: &str) -> Vec<&'static str> {\n for (p, vars) in ENTRIES {\n if *p == provider {\n return vars.to_vec();\n }\n }\n vec![]\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn ds4_uses_dedicated_local_env_var() {\n assert_eq!(super::env_var_names(\"ds4\"), vec![\"DS4_API_KEY\"]);\n }\n}\n"}}, "crates/coding-agent/src/auth.rs": {"0a120dfd380fb7f009e09f2b7981b76c07b3fd95": {"t": 1785745831.235446, "n": 9525, "h": "a90370571ee8c659", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. /login + /logout populate it;\n//! resolve_for_provider plumbs into model auto-detection and the CLI stream wrapper.\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\n#[cfg(test)]\npub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now. Wired in by the OAuth refresher (lands in a follow-up).\n #[allow(dead_code)]\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`.\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n for env_var in pie_ai::env_api_keys::env_var_names(provider) {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::TempDir;\n\n struct EnvGuard {\n key: &'static str,\n original: Option<std::ffi::OsString>,\n }\n\n impl EnvGuard {\n fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::set_var(key, value) };\n Self { key, original }\n }\n\n fn remove(key: &'static str) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::remove_var(key) };\n Self { key, original }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n match self.original.take() {\n Some(value) => unsafe { std::env::set_var(self.key, value) },\n None => unsafe { std::env::remove_var(self.key) },\n }\n }\n }\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[test]\n fn resolve_for_provider_uses_shared_provider_env_map() {\n let _guard = ENV_LOCK.lock().unwrap();\n let _deepseek = EnvGuard::set(\"DEEPSEEK_API_KEY\", \"sk-deepseek-env\");\n let _openai = EnvGuard::set(\"OPENAI_API_KEY\", \"sk-openai-should-not-count\");\n\n let store = AuthStore::default();\n assert_eq!(\n store.resolve_for_provider(\"deepseek\").as_deref(),\n Some(\"sk-deepseek-env\")\n );\n\n drop(_deepseek);\n let _deepseek_removed = EnvGuard::remove(\"DEEPSEEK_API_KEY\");\n assert_eq!(store.resolve_for_provider(\"deepseek\"), None);\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}, "0667d9ca15beb7a129ece8c9f25061d561337f5e": {"t": 1785756898.716072, "n": 9525, "h": "a90370571ee8c659", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. /login + /logout populate it;\n//! resolve_for_provider plumbs into model auto-detection and the CLI stream wrapper.\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\n#[cfg(test)]\npub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now. Wired in by the OAuth refresher (lands in a follow-up).\n #[allow(dead_code)]\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`.\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n for env_var in pie_ai::env_api_keys::env_var_names(provider) {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::TempDir;\n\n struct EnvGuard {\n key: &'static str,\n original: Option<std::ffi::OsString>,\n }\n\n impl EnvGuard {\n fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::set_var(key, value) };\n Self { key, original }\n }\n\n fn remove(key: &'static str) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::remove_var(key) };\n Self { key, original }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n match self.original.take() {\n Some(value) => unsafe { std::env::set_var(self.key, value) },\n None => unsafe { std::env::remove_var(self.key) },\n }\n }\n }\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[test]\n fn resolve_for_provider_uses_shared_provider_env_map() {\n let _guard = ENV_LOCK.lock().unwrap();\n let _deepseek = EnvGuard::set(\"DEEPSEEK_API_KEY\", \"sk-deepseek-env\");\n let _openai = EnvGuard::set(\"OPENAI_API_KEY\", \"sk-openai-should-not-count\");\n\n let store = AuthStore::default();\n assert_eq!(\n store.resolve_for_provider(\"deepseek\").as_deref(),\n Some(\"sk-deepseek-env\")\n );\n\n drop(_deepseek);\n let _deepseek_removed = EnvGuard::remove(\"DEEPSEEK_API_KEY\");\n assert_eq!(store.resolve_for_provider(\"deepseek\"), None);\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}, "e4d4b076295e8ae5557b4cf967c2412cb8b4635e": {"t": 1785756900.0984926, "n": 9509, "h": "7da29af11f64ff20", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. /login + /logout populate it;\n//! resolve_for_provider plumbs into model auto-detection and the CLI stream wrapper.\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now. Wired in by the OAuth refresher (lands in a follow-up).\n #[allow(dead_code)]\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`.\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n for env_var in pie_ai::env_api_keys::env_var_names(provider) {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::sync::Mutex;\n use tempfile::TempDir;\n\n static ENV_LOCK: Mutex<()> = Mutex::new(());\n\n struct EnvGuard {\n key: &'static str,\n original: Option<std::ffi::OsString>,\n }\n\n impl EnvGuard {\n fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::set_var(key, value) };\n Self { key, original }\n }\n\n fn remove(key: &'static str) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::remove_var(key) };\n Self { key, original }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n match self.original.take() {\n Some(value) => unsafe { std::env::set_var(self.key, value) },\n None => unsafe { std::env::remove_var(self.key) },\n }\n }\n }\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[test]\n fn resolve_for_provider_uses_shared_provider_env_map() {\n let _guard = ENV_LOCK.lock().unwrap();\n let _deepseek = EnvGuard::set(\"DEEPSEEK_API_KEY\", \"sk-deepseek-env\");\n let _openai = EnvGuard::set(\"OPENAI_API_KEY\", \"sk-openai-should-not-count\");\n\n let store = AuthStore::default();\n assert_eq!(\n store.resolve_for_provider(\"deepseek\").as_deref(),\n Some(\"sk-deepseek-env\")\n );\n\n drop(_deepseek);\n let _deepseek_removed = EnvGuard::remove(\"DEEPSEEK_API_KEY\");\n assert_eq!(store.resolve_for_provider(\"deepseek\"), None);\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}, "10736610d7a0225d17b5ae6d379e0f2f84b7a106": {"t": 1785756903.4954445, "n": 8315, "h": "7a0237d01b0ceffd", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. /login + /logout populate it;\n//! resolve_for_provider plumbs into model auto-detection and the CLI stream wrapper.\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now. Wired in by the OAuth refresher (lands in a follow-up).\n #[allow(dead_code)]\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`.\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n let env_var = match provider {\n \"anthropic\" => \"ANTHROPIC_API_KEY\",\n \"openai\" => \"OPENAI_API_KEY\",\n \"openrouter\" => \"OPENROUTER_API_KEY\",\n \"groq\" => \"GROQ_API_KEY\",\n \"mistral\" => \"MISTRAL_API_KEY\",\n \"google\" => \"GEMINI_API_KEY\",\n \"ds4\" => \"DS4_API_KEY\",\n _ => \"\",\n };\n if !env_var.is_empty() {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::TempDir;\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}, "294b2b59b8651ec60770ab31acc631f01ba254b6": {"t": 1785756905.7433062, "n": 8550, "h": "231f9e88f85b1534", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. /login + /logout populate it;\n//! resolve_for_provider plumbs into the model resolver as an env-var fallback.\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now. Wired in by the OAuth refresher (lands in a follow-up).\n #[allow(dead_code)]\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`. Plumbed in by\n /// the provider clients via env-resolver; the binary's `model::auto_detect_model` already\n /// checks both env and `store.get`, so this helper exists for future code paths that\n /// don't have access to the model auto-detector.\n #[allow(dead_code)]\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n let env_var = match provider {\n \"anthropic\" => \"ANTHROPIC_API_KEY\",\n \"openai\" => \"OPENAI_API_KEY\",\n \"openrouter\" => \"OPENROUTER_API_KEY\",\n \"groq\" => \"GROQ_API_KEY\",\n \"mistral\" => \"MISTRAL_API_KEY\",\n \"google\" => \"GEMINI_API_KEY\",\n _ => \"\",\n };\n if !env_var.is_empty() {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::TempDir;\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}, "fadb55a5be50927cc8b282b56ab7a0f3e2c8c797": {"t": 1785756908.9382017, "n": 8380, "h": "05400097d11e8dd9", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. v1 lands the store + atomic-write\n//! invariant; OAuth flows that actually populate it follow.\n\n// The auth module is wired into the model resolver in a follow-up commit; for now the public\n// surface compiles and is fully tested, but isn't called from the binary entry path.\n#![allow(dead_code)]\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now.\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`.\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n let env_var = match provider {\n \"anthropic\" => \"ANTHROPIC_API_KEY\",\n \"openai\" => \"OPENAI_API_KEY\",\n \"openrouter\" => \"OPENROUTER_API_KEY\",\n \"groq\" => \"GROQ_API_KEY\",\n \"mistral\" => \"MISTRAL_API_KEY\",\n \"google\" => \"GEMINI_API_KEY\",\n _ => \"\",\n };\n if !env_var.is_empty() {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::TempDir;\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}, "b72579680bdfe616edbc05808fb1395a1be3660f": {"t": 1786068326.1139772, "n": 9525, "h": "a90370571ee8c659", "c": "//! Persistent credential store for `pie`. Foundation for c4pt0r/pie#13: stores per-provider\n//! credentials at `~/.pie/auth.json` with mode 0600. /login + /logout populate it;\n//! resolve_for_provider plumbs into model auto-detection and the CLI stream wrapper.\n//!\n//! Resolution precedence (in `resolve_for_provider`):\n//! 1. The provider's environment variable, if set and non-empty.\n//! 2. A matching entry in `auth.json`.\n//! 3. None.\n\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\n\nuse crate::config::base_dir;\n\n#[cfg(test)]\npub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n\npub fn auth_path() -> PathBuf {\n base_dir().join(\"auth.json\")\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum ProviderCredential {\n ApiKey {\n value: String,\n },\n Oauth {\n access_token: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n refresh_token: Option<String>,\n /// Unix epoch seconds.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n expires_at: Option<i64>,\n #[serde(default)]\n scopes: Vec<String>,\n },\n}\n\nimpl ProviderCredential {\n /// True if this is an OAuth credential whose expires_at is in the past or within `slack`\n /// seconds from now. Wired in by the OAuth refresher (lands in a follow-up).\n #[allow(dead_code)]\n pub fn needs_refresh(&self, slack_seconds: i64) -> bool {\n match self {\n Self::ApiKey { .. } => false,\n Self::Oauth { expires_at, .. } => match expires_at {\n Some(exp) => {\n let now = chrono::Utc::now().timestamp();\n now + slack_seconds >= *exp\n }\n None => false,\n },\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct AuthStore {\n /// Schema version — incremented on breaking changes; the loader tolerates older versions.\n #[serde(default = \"default_version\")]\n pub version: u32,\n /// Provider id → credential.\n #[serde(default)]\n pub providers: HashMap<String, ProviderCredential>,\n}\n\nfn default_version() -> u32 {\n 1\n}\n\nimpl AuthStore {\n pub fn load() -> Result<Self> {\n Self::load_from(&auth_path())\n }\n\n pub fn load_from(path: &std::path::Path) -> Result<Self> {\n if !path.exists() {\n return Ok(Self::default());\n }\n let text =\n std::fs::read_to_string(path).with_context(|| format!(\"read {}\", path.display()))?;\n if text.trim().is_empty() {\n return Ok(Self::default());\n }\n let store: AuthStore =\n serde_json::from_str(&text).with_context(|| format!(\"parse {}\", path.display()))?;\n Ok(store)\n }\n\n pub fn save(&self) -> Result<()> {\n self.save_to(&auth_path())\n }\n\n /// Atomic write: rename-temp + chmod 600. Best-effort on platforms without unix perms.\n pub fn save_to(&self, path: &std::path::Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)\n .with_context(|| format!(\"create {}\", parent.display()))?;\n }\n let tmp = path.with_extension(\"json.tmp\");\n let text = serde_json::to_string_pretty(self)?;\n std::fs::write(&tmp, text).with_context(|| format!(\"write {}\", tmp.display()))?;\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt as _;\n let perms = std::fs::Permissions::from_mode(0o600);\n std::fs::set_permissions(&tmp, perms).ok();\n }\n std::fs::rename(&tmp, path).with_context(|| format!(\"rename {}\", path.display()))?;\n Ok(())\n }\n\n pub fn set(&mut self, provider: impl Into<String>, cred: ProviderCredential) {\n self.providers.insert(provider.into(), cred);\n }\n\n pub fn remove(&mut self, provider: &str) -> Option<ProviderCredential> {\n self.providers.remove(provider)\n }\n\n pub fn get(&self, provider: &str) -> Option<&ProviderCredential> {\n self.providers.get(provider)\n }\n\n /// Resolve a credential for `provider`. Env var wins; auth.json is the fallback. Returns\n /// the bare API-key string for `api_key` and the access token for `oauth`.\n pub fn resolve_for_provider(&self, provider: &str) -> Option<String> {\n for env_var in pie_ai::env_api_keys::env_var_names(provider) {\n if let Ok(v) = std::env::var(env_var) {\n if !v.trim().is_empty() {\n return Some(v);\n }\n }\n }\n match self.providers.get(provider)? {\n ProviderCredential::ApiKey { value } => Some(value.clone()),\n ProviderCredential::Oauth { access_token, .. } => Some(access_token.clone()),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::TempDir;\n\n struct EnvGuard {\n key: &'static str,\n original: Option<std::ffi::OsString>,\n }\n\n impl EnvGuard {\n fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::set_var(key, value) };\n Self { key, original }\n }\n\n fn remove(key: &'static str) -> Self {\n let original = std::env::var_os(key);\n unsafe { std::env::remove_var(key) };\n Self { key, original }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n match self.original.take() {\n Some(value) => unsafe { std::env::set_var(self.key, value) },\n None => unsafe { std::env::remove_var(self.key) },\n }\n }\n }\n\n #[test]\n fn round_trip_api_key() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::ApiKey {\n value: \"sk-test\".into(),\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n assert_eq!(reloaded.providers.len(), 1);\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::ApiKey { value } => assert_eq!(value, \"sk-test\"),\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn round_trip_oauth() {\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\n \"anthropic\",\n ProviderCredential::Oauth {\n access_token: \"tok\".into(),\n refresh_token: Some(\"rtok\".into()),\n expires_at: Some(1_900_000_000),\n scopes: vec![\"chat\".into()],\n },\n );\n store.save_to(&path).unwrap();\n let reloaded = AuthStore::load_from(&path).unwrap();\n match reloaded.get(\"anthropic\").unwrap() {\n ProviderCredential::Oauth {\n access_token,\n refresh_token,\n expires_at,\n scopes,\n } => {\n assert_eq!(access_token, \"tok\");\n assert_eq!(refresh_token.as_deref(), Some(\"rtok\"));\n assert_eq!(expires_at, &Some(1_900_000_000));\n assert_eq!(scopes, &vec![\"chat\".to_string()]);\n }\n _ => panic!(\"wrong kind\"),\n }\n }\n\n #[test]\n fn needs_refresh_evaluates_expiry_slack() {\n let now = chrono::Utc::now().timestamp();\n let expires_soon = ProviderCredential::Oauth {\n access_token: \"x\".into(),\n refresh_token: None,\n expires_at: Some(now + 30),\n scopes: vec![],\n };\n assert!(expires_soon.needs_refresh(60));\n assert!(!expires_soon.needs_refresh(10));\n let api_key = ProviderCredential::ApiKey { value: \"x\".into() };\n assert!(!api_key.needs_refresh(0));\n }\n\n #[test]\n fn missing_file_loads_empty_store() {\n let dir = TempDir::new().unwrap();\n let store = AuthStore::load_from(&dir.path().join(\"nope.json\")).unwrap();\n assert!(store.providers.is_empty());\n }\n\n #[test]\n fn resolve_for_provider_uses_shared_provider_env_map() {\n let _guard = ENV_LOCK.lock().unwrap();\n let _deepseek = EnvGuard::set(\"DEEPSEEK_API_KEY\", \"sk-deepseek-env\");\n let _openai = EnvGuard::set(\"OPENAI_API_KEY\", \"sk-openai-should-not-count\");\n\n let store = AuthStore::default();\n assert_eq!(\n store.resolve_for_provider(\"deepseek\").as_deref(),\n Some(\"sk-deepseek-env\")\n );\n\n drop(_deepseek);\n let _deepseek_removed = EnvGuard::remove(\"DEEPSEEK_API_KEY\");\n assert_eq!(store.resolve_for_provider(\"deepseek\"), None);\n }\n\n #[cfg(unix)]\n #[test]\n fn save_sets_mode_0600() {\n use std::os::unix::fs::PermissionsExt as _;\n let dir = TempDir::new().unwrap();\n let path = dir.path().join(\"auth.json\");\n let mut store = AuthStore::default();\n store.set(\"p\", ProviderCredential::ApiKey { value: \"k\".into() });\n store.save_to(&path).unwrap();\n let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;\n assert_eq!(mode, 0o600, \"got {:o}\", mode);\n }\n}\n"}}}} |