1use std::collections::BTreeMap;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39
40use rpi_ai::{Api, InputModality, Model, StreamingProtocolCompat};
41
42pub const CONFIG_DIR_NAME: &str = ".rpi";
44
45pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
48
49pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
52
53#[derive(Debug, thiserror::Error)]
60pub enum ConfigError {
61 #[error("could not resolve home directory (set {env} to override)")]
62 NoHomeDir { env: &'static str },
63 #[error("config dir override {env}={val:?} is not an absolute path")]
64 RelativeOverride { env: &'static str, val: String },
65 #[error("could not read {path}: {source}")]
66 Read {
67 path: PathBuf,
68 #[source]
69 source: std::io::Error,
70 },
71 #[error("could not write {path}: {source}")]
72 Write {
73 path: PathBuf,
74 #[source]
75 source: std::io::Error,
76 },
77 #[error("invalid JSON in {path}: {source}")]
78 Json {
79 path: PathBuf,
80 #[source]
81 source: serde_json::Error,
82 },
83}
84
85pub fn agent_dir() -> Result<PathBuf, ConfigError> {
94 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
95 let p = PathBuf::from(&val);
96 if !p.is_absolute() {
97 return Err(ConfigError::RelativeOverride {
98 env: CONFIG_DIR_ENV,
99 val: val.to_string_lossy().into_owned(),
100 });
101 }
102 return Ok(p);
103 }
104 let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
105 env: CONFIG_DIR_ENV,
106 })?;
107 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
108}
109
110fn config_root_dir() -> Result<PathBuf, ConfigError> {
114 let agent = agent_dir()?;
115 agent
116 .parent()
117 .map(Path::to_path_buf)
118 .ok_or(ConfigError::NoHomeDir {
119 env: CONFIG_DIR_ENV,
120 })
121}
122
123pub fn auth_path() -> Result<PathBuf, ConfigError> {
125 Ok(agent_dir()?.join("auth.json"))
126}
127
128pub fn models_path() -> Result<PathBuf, ConfigError> {
130 Ok(agent_dir()?.join("models.json"))
131}
132
133pub fn settings_path() -> Result<PathBuf, ConfigError> {
135 Ok(agent_dir()?.join("settings.json"))
136}
137
138pub fn trust_path() -> Result<PathBuf, ConfigError> {
140 Ok(agent_dir()?.join("trust.json"))
141}
142
143fn read_text_with_fallback(
144 primary: PathBuf,
145 fallback: Option<PathBuf>,
146) -> Result<Option<(PathBuf, String)>, ConfigError> {
147 match std::fs::read_to_string(&primary) {
148 Ok(text) => Ok(Some((primary, text))),
149 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
150 let Some(fallback) = fallback.filter(|path| path != &primary) else {
151 return Ok(None);
152 };
153 match std::fs::read_to_string(&fallback) {
154 Ok(text) => Ok(Some((fallback, text))),
155 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
156 Err(source) => Err(ConfigError::Read {
157 path: fallback,
158 source,
159 }),
160 }
161 }
162 Err(source) => Err(ConfigError::Read {
163 path: primary,
164 source,
165 }),
166 }
167}
168
169pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
177 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
179 return Ok(0);
180 }
181 let root = match config_root_dir() {
182 Ok(p) => p,
183 Err(_) => return Ok(0),
184 };
185 let agent = agent_dir()?;
186 migrate_legacy_layout_in(&root, &agent)
187}
188
189fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
196 if agent.exists() {
198 return Ok(0);
199 }
200 let flat_auth = root.join("auth.json");
202 let flat_models = root.join("models.json");
203 if !flat_auth.exists() && !flat_models.exists() {
204 return Ok(0);
205 }
206 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
207 path: agent.to_path_buf(),
208 source: e,
209 })?;
210 let mut moved = 0usize;
211 for leaf in ["auth.json", "models.json", ".setup_done"] {
212 let from = root.join(leaf);
213 let to = agent.join(leaf);
214 if from.exists() && !to.exists() {
215 if let Err(_e) = std::fs::rename(&from, &to) {
218 if std::fs::copy(&from, &to).is_ok() {
219 let _ = std::fs::remove_file(&from);
220 }
221 }
222 moved += 1;
223 }
224 }
225 Ok(moved)
226}
227
228#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
236#[serde(rename_all = "snake_case", tag = "type")]
237pub enum Credential {
238 ApiKey {
241 key: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 env: Option<BTreeMap<String, String>>,
244 },
245 Oauth {
247 access: String,
248 #[serde(default)]
249 refresh: String,
250 #[serde(default)]
252 expires: i64,
253 },
254}
255
256pub type AuthStore = BTreeMap<String, Credential>;
259
260pub fn read_auth() -> Result<AuthStore, ConfigError> {
263 let path = auth_path()?;
264 match read_text_with_fallback(path, None)? {
265 Some((path, text)) => {
266 serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
267 }
268 None => Ok(AuthStore::new()),
269 }
270}
271
272pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
275 let path = auth_path()?;
276 let dir = agent_dir()?;
277 ensure_dir(&dir)?;
278 let json = serde_json::to_string_pretty(store).unwrap();
279 atomic_write(&path, json.as_bytes())?;
280 set_owner_only(&path);
281 Ok(())
282}
283
284pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
286 let mut store = read_auth()?;
287 store.insert(provider_id.to_string(), cred);
288 write_auth(&store)
289}
290
291pub fn upsert_oauth_token(
294 provider_id: &str,
295 access: String,
296 refresh: String,
297 expires: i64,
298) -> Result<(), ConfigError> {
299 upsert_credential(
300 provider_id,
301 Credential::Oauth {
302 access,
303 refresh,
304 expires,
305 },
306 )
307}
308
309pub fn read_oauth_token(provider_id: &str) -> Result<Option<Credential>, ConfigError> {
311 Ok(match read_auth()?.get(provider_id) {
312 Some(Credential::Oauth {
313 access,
314 refresh,
315 expires,
316 }) => Some(Credential::Oauth {
317 access: access.clone(),
318 refresh: refresh.clone(),
319 expires: *expires,
320 }),
321 _ => None,
322 })
323}
324
325pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
330 let mut store = read_auth()?;
331 if store.remove(provider_id).is_some() {
332 write_auth(&store)?;
333 Ok(true)
334 } else {
335 Ok(false)
336 }
337}
338
339#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
346#[serde(rename_all = "camelCase")]
347pub struct ModelsConfig {
348 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
349 pub providers: indexmap::IndexMap<String, ProviderConfig>,
352}
353
354#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
358#[serde(rename_all = "camelCase")]
359pub struct ProviderConfig {
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub name: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub base_url: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub api_key: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub api: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub headers: Option<BTreeMap<String, String>>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub auth_header: Option<bool>,
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub models: Vec<ModelDefinition>,
376}
377
378#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
380#[serde(rename_all = "camelCase")]
381pub struct ModelDefinition {
382 pub id: String,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub name: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub base_url: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub reasoning: Option<bool>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub context_window: Option<u64>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub max_tokens: Option<u64>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub input: Option<Vec<String>>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub headers: Option<BTreeMap<String, String>>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub compat: Option<serde_json::Value>,
401}
402
403pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
405 let path = models_path()?;
406 match read_text_with_fallback(path, None)? {
407 Some((path, text)) => {
408 parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
409 }
410 None => Ok(ModelsConfig::default()),
411 }
412}
413
414pub fn save_models_config(cfg: &ModelsConfig) -> Result<(), ConfigError> {
420 let path = models_path()?;
421 let dir = agent_dir()?;
422 ensure_dir(&dir)?;
423 if let Ok(existing) = std::fs::read(&path) {
426 let stamp = std::time::SystemTime::now()
427 .duration_since(std::time::UNIX_EPOCH)
428 .map(|d| d.as_secs())
429 .unwrap_or(0);
430 let backup = dir.join(format!("models.json.bak-{stamp}"));
431 let _ = std::fs::write(&backup, existing);
432 }
433 let text = serde_json::to_string_pretty(cfg).map_err(|source| ConfigError::Json {
434 path: path.clone(),
435 source,
436 })?;
437 atomic_write(&path, text.as_bytes())
438}
439
440pub type TrustStore = BTreeMap<String, Option<bool>>;
450
451pub fn read_trust() -> Result<TrustStore, ConfigError> {
455 let path = trust_path()?;
456 match std::fs::read_to_string(&path) {
457 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
458 path: path.clone(),
459 source: e,
460 }),
461 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
462 Err(e) => Err(ConfigError::Read { path, source: e }),
463 }
464}
465
466pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
469 let key = std::fs::canonicalize(cwd)
470 .unwrap_or_else(|_| cwd.to_path_buf())
471 .to_string_lossy()
472 .into_owned();
473 let mut store = read_trust()?;
474 if let Some(decision) = trusted {
475 store.insert(key, Some(decision));
476 } else {
477 store.remove(&key);
478 }
479 let path = trust_path()?;
480 ensure_dir(&agent_dir()?)?;
481 let json = serde_json::to_string_pretty(&store).unwrap();
482 atomic_write(&path, json.as_bytes())?;
483 set_owner_only(&path);
484 Ok(())
485}
486
487pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
490 let key = std::fs::canonicalize(cwd)
491 .unwrap_or_else(|_| cwd.to_path_buf())
492 .to_string_lossy()
493 .into_owned();
494 Ok(read_trust()?.get(&key).copied().flatten())
495}
496
497fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
501 match serde_json::from_str(text) {
502 Ok(c) => Ok(c),
503 Err(first) => {
504 let stripped = strip_line_comments(text);
509 serde_json::from_str(&stripped).map_err(|_| first)
510 }
511 }
512}
513
514pub(crate) fn strip_line_comments(text: &str) -> String {
519 text.lines()
520 .map(|line| match find_line_comment(line) {
521 Some(idx) => line[..idx].to_string(),
522 None => line.to_string(),
523 })
524 .collect::<Vec<_>>()
525 .join("\n")
526}
527
528fn find_line_comment(line: &str) -> Option<usize> {
530 let mut in_str = false;
531 let mut esc = false;
532 for (i, ch) in line.char_indices() {
533 if esc {
534 esc = false;
535 continue;
536 }
537 match ch {
538 '\\' if in_str => esc = true,
539 '"' => in_str = !in_str,
540 '/' if !in_str => {
541 if line.as_bytes().get(i + 1) == Some(&b'/') {
542 return Some(i);
543 }
544 }
545 _ => {}
546 }
547 }
548 None
549}
550
551fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
558 static CACHE: std::sync::OnceLock<
559 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
560 > = std::sync::OnceLock::new();
561 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
562}
563
564pub fn resolve_config_value(
579 config: &str,
580 env_overlay: Option<&BTreeMap<String, String>>,
581) -> Option<String> {
582 if let Some(cmd) = config.strip_prefix('!') {
583 return resolve_command(cmd);
584 }
585 resolve_template(config, env_overlay)
586}
587
588pub fn resolve_config_value_uncached(
592 config: &str,
593 env_overlay: Option<&BTreeMap<String, String>>,
594) -> Option<String> {
595 if let Some(cmd) = config.strip_prefix('!') {
596 return resolve_command_uncached(cmd);
597 }
598 resolve_template(config, env_overlay)
599}
600
601pub fn resolve_headers(
605 headers: &BTreeMap<String, String>,
606 env_overlay: Option<&BTreeMap<String, String>>,
607) -> BTreeMap<String, String> {
608 let mut out = BTreeMap::new();
609 for (k, v) in headers {
610 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
611 out.insert(k.clone(), resolved);
612 }
613 }
614 out
615}
616
617fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
620 if let Some(overlay) = env_overlay {
621 if let Some(v) = overlay.get(name) {
622 return Some(v.clone());
623 }
624 }
625 std::env::var(name).ok()
626}
627
628enum TemplatePart {
630 Literal(String),
631 Env(String),
632}
633
634fn parse_template(config: &str) -> Vec<TemplatePart> {
639 let mut parts: Vec<TemplatePart> = Vec::new();
640 let bytes = config.as_bytes();
641 let mut i = 0usize;
642 while i < bytes.len() {
643 match config[i..].find('$') {
645 None => {
646 push_literal(&mut parts, &config[i..]);
647 break;
648 }
649 Some(offset) => {
650 let dollar = i + offset;
651 push_literal(&mut parts, &config[i..dollar]);
652 let after = dollar + 1;
653 let next = bytes.get(after).copied();
654 if next == Some(b'$') || next == Some(b'!') {
655 push_literal(&mut parts, &config[after..after + 1]);
656 i = after + 1;
657 continue;
658 }
659 if next == Some(b'{') {
660 if let Some(end_rel) = config[after + 1..].find('}') {
662 let end = after + 1 + end_rel;
663 let name = &config[after + 1..end];
664 if is_env_name(name) {
665 parts.push(TemplatePart::Env(name.to_string()));
666 } else {
667 push_literal(&mut parts, &config[dollar..=end]);
669 }
670 i = end + 1;
671 continue;
672 }
673 push_literal(&mut parts, "$");
675 i = after;
676 continue;
677 }
678 if let Some(name) = env_name_prefix(&config[after..]) {
680 parts.push(TemplatePart::Env(name.to_string()));
681 i = after + name.len();
682 } else {
683 push_literal(&mut parts, "$");
684 i = after;
685 }
686 }
687 }
688 }
689 parts
690}
691
692fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
693 if value.is_empty() {
694 return;
695 }
696 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
697 s.push_str(value);
698 } else {
699 parts.push(TemplatePart::Literal(value.to_string()));
700 }
701}
702
703fn is_env_name(s: &str) -> bool {
704 let mut chars = s.chars();
705 match chars.next() {
706 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
707 _ => return false,
708 }
709 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
710}
711
712fn env_name_prefix(s: &str) -> Option<&str> {
715 let mut chars = s.char_indices();
716 match chars.next() {
717 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
718 _ => return None,
719 }
720 let end = chars
721 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
722 .map(|(idx, _)| idx)
723 .unwrap_or(s.len());
724 Some(&s[..end])
725}
726
727fn resolve_template(
730 config: &str,
731 env_overlay: Option<&BTreeMap<String, String>>,
732) -> Option<String> {
733 let parts = parse_template(config);
734 let mut out = String::with_capacity(config.len());
735 for part in parts {
736 match part {
737 TemplatePart::Literal(s) => out.push_str(&s),
738 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
739 Some(v) => out.push_str(&v),
740 None => return None,
741 },
742 }
743 }
744 Some(out)
745}
746
747fn resolve_command(cmd: &str) -> Option<String> {
751 let key = format!("!{cmd}");
752 if let Some(v) = command_cache().lock().ok()?.get(&key) {
753 return v.clone();
754 }
755 let result = resolve_command_uncached(cmd);
756 if let Ok(mut cache) = command_cache().lock() {
757 cache.insert(key, result.clone());
758 }
759 result
760}
761
762#[cfg(unix)]
763fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
764 std::process::Command::new("sh")
765 .arg("-c")
766 .arg(cmd)
767 .stdin(std::process::Stdio::null())
768 .stdout(std::process::Stdio::piped())
769 .stderr(std::process::Stdio::null())
770 .output()
771 .ok()
772}
773
774#[cfg(windows)]
775fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
776 use std::os::windows::process::CommandExt;
777 std::process::Command::new("cmd")
778 .arg("/C")
779 .arg(cmd)
780 .stdin(std::process::Stdio::null())
781 .stdout(std::process::Stdio::piped())
782 .stderr(std::process::Stdio::null())
783 .creation_flags(0x0800_0000) .output()
785 .ok()
786}
787
788fn resolve_command_uncached(cmd: &str) -> Option<String> {
790 let output = spawn_shell_command(cmd)?;
791 if !output.status.success() {
792 return None;
793 }
794 let stdout = String::from_utf8_lossy(&output.stdout);
795 let trimmed = stdout.trim();
796 if trimmed.is_empty() {
797 None
798 } else {
799 Some(trimmed.to_string())
800 }
801}
802
803pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
808 match cfg.api.as_deref() {
809 None | Some("") | Some("anthropic-messages") => true,
810 _ => false,
811 }
812}
813
814pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
816 matches!(cfg.api.as_deref(), Some("openai-completions"))
817}
818
819pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
820 matches!(cfg.api.as_deref(), Some("openai-responses"))
821}
822
823pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
828 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
829 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
830 return Some(value);
831 }
832 }
833
834 let name = native_provider_api_key_env_var(provider_id)?;
835 std::env::var(name).ok().filter(|value| !value.is_empty())
836}
837
838fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
842 match provider_id {
843 "ant-ling" => Some("ANT_LING_API_KEY"),
844 "qwen-token-plan" | "qwen-token-plan-individual" => Some("QWEN_TOKEN_PLAN_API_KEY"),
845 "qwen-token-plan-cn" => Some("QWEN_TOKEN_PLAN_CN_API_KEY"),
846 "openai" => Some("OPENAI_API_KEY"),
847 "azure-openai-responses" => Some("AZURE_OPENAI_API_KEY"),
848 "nvidia" => Some("NVIDIA_API_KEY"),
849 "deepseek" => Some("DEEPSEEK_API_KEY"),
850 "google" => Some("GEMINI_API_KEY"),
851 "google-vertex" => Some("GOOGLE_CLOUD_API_KEY"),
852 "groq" => Some("GROQ_API_KEY"),
853 "cerebras" => Some("CEREBRAS_API_KEY"),
854 "xai" => Some("XAI_API_KEY"),
855 "radius" => Some("RADIUS_API_KEY"),
856 "openrouter" => Some("OPENROUTER_API_KEY"),
857 "vercel-ai-gateway" => Some("AI_GATEWAY_API_KEY"),
858 "zai" => Some("ZAI_API_KEY"),
859 "zai-coding-cn" => Some("ZAI_CODING_CN_API_KEY"),
860 "mistral" => Some("MISTRAL_API_KEY"),
861 "minimax" => Some("MINIMAX_API_KEY"),
862 "minimax-cn" => Some("MINIMAX_CN_API_KEY"),
863 "moonshotai" | "moonshotai-cn" => Some("MOONSHOT_API_KEY"),
864 "huggingface" => Some("HF_TOKEN"),
865 "fireworks" => Some("FIREWORKS_API_KEY"),
866 "together" => Some("TOGETHER_API_KEY"),
867 "baseten" => Some("BASETEN_API_KEY"),
868 "opencode" | "opencode-go" => Some("OPENCODE_API_KEY"),
869 "kimi-coding" => Some("KIMI_API_KEY"),
870 "cloudflare-workers-ai" | "cloudflare-ai-gateway" => Some("CLOUDFLARE_API_KEY"),
871 "xiaomi" => Some("XIAOMI_API_KEY"),
872 "xiaomi-token-plan-cn" => Some("XIAOMI_TOKEN_PLAN_CN_API_KEY"),
873 "xiaomi-token-plan-ams" => Some("XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
874 "xiaomi-token-plan-sgp" => Some("XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
875 _ => None,
876 }
877}
878
879pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
883 let api = if provider_is_anthropic_compatible(cfg) {
884 Api::AnthropicMessages
885 } else if provider_is_openai_completions(cfg) {
886 Api::OpenaiCompletions
887 } else if provider_is_openai_responses(cfg) {
888 Api::OpenaiResponses
889 } else {
890 return None;
891 };
892 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
893 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
894 _ => default_anthropic_base_url(),
895 });
896 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
897 for def in &cfg.models {
898 let base_url = def
899 .base_url
900 .clone()
901 .unwrap_or_else(|| provider_base.clone());
902 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
903 let mut m = Model::new(
904 def.id.clone(),
905 name,
906 api.clone(),
907 provider_id.to_string(),
908 base_url,
909 );
910 m.reasoning = def.reasoning.unwrap_or(false);
911 m.context_window = def.context_window.unwrap_or(0);
912 m.max_tokens = def.max_tokens.unwrap_or(0);
913 m.input = parse_input_modalities(def.input.as_deref());
914 let mut headers: BTreeMap<String, String> = BTreeMap::new();
928 if let Some(h) = def.headers.clone() {
929 for (k, v) in resolve_headers(&h, None) {
930 headers.insert(k, v);
931 }
932 }
933 if let Some(h) = cfg.headers.clone() {
934 for (k, v) in resolve_headers(&h, None) {
935 headers.insert(k, v);
936 }
937 }
938 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
939 if let Some(key) = openai_provider_api_key(provider_id, cfg) {
940 headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
941 headers.insert("authorization".to_string(), format!("Bearer {key}"));
942 }
943 if let Some(value) = def.compat.clone() {
944 if matches!(api, Api::OpenaiResponses) {
945 if let Ok(compat) = serde_json::from_value(value) {
946 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
947 }
948 } else if let Ok(compat) = serde_json::from_value(value) {
949 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
950 }
951 }
952 }
953 if !headers.is_empty() {
954 m.headers = Some(headers);
955 }
956 merged.push(m);
957 }
958 Some(merged)
959}
960
961fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
964 match input {
965 None => vec![InputModality::Text],
966 Some(list) if list.is_empty() => vec![InputModality::Text],
967 Some(list) => list
968 .iter()
969 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
970 "text" => Some(InputModality::Text),
971 "image" => Some(InputModality::Image),
972 _ => None,
973 })
974 .collect::<Vec<_>>()
975 .pipe(|v| {
976 if v.is_empty() {
977 vec![InputModality::Text]
978 } else {
979 v
980 }
981 }),
982 }
983}
984
985pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
991
992pub fn default_anthropic_base_url() -> String {
996 ANTHROPIC_DEFAULT_BASE_URL.to_string()
997}
998
999#[cfg(unix)]
1004use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1005
1006fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
1009 if dir.exists() {
1010 return Ok(());
1011 }
1012 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
1013 path: dir.to_path_buf(),
1014 source: e,
1015 })?;
1016 #[cfg(unix)]
1017 {
1018 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
1019 }
1020 Ok(())
1021}
1022
1023pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1026 let dir = path.parent().ok_or_else(|| ConfigError::Write {
1027 path: path.to_path_buf(),
1028 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
1029 })?;
1030 let tmp = dir.join(format!(
1031 ".{}.{}.tmp",
1032 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
1033 uuid::Uuid::new_v4().simple()
1034 ));
1035 let mut options = std::fs::OpenOptions::new();
1036 options.write(true).create_new(true);
1037 #[cfg(unix)]
1038 options.mode(0o600);
1039 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
1040 path: tmp.clone(),
1041 source,
1042 })?;
1043 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1044 let _ = std::fs::remove_file(&tmp);
1048 return Err(ConfigError::Write {
1049 path: tmp,
1050 source: error,
1051 });
1052 }
1053 drop(file);
1054 if let Err(error) = std::fs::rename(&tmp, path) {
1055 let _ = std::fs::remove_file(&tmp);
1058 return Err(ConfigError::Write {
1059 path: path.to_path_buf(),
1060 source: error,
1061 });
1062 }
1063 Ok(())
1064}
1065
1066fn set_owner_only(_path: &Path) {
1069 #[cfg(unix)]
1070 {
1071 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
1072 }
1073}
1074
1075trait Pipe: Sized {
1078 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
1079 f(self)
1080 }
1081}
1082impl<T> Pipe for T {}
1083
1084#[cfg(test)]
1089pub(crate) mod test_support {
1090 use std::sync::{Mutex, OnceLock};
1096 pub(crate) fn env_lock() -> &'static Mutex<()> {
1097 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1098 LOCK.get_or_init(|| Mutex::new(()))
1099 }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105 use crate::config::test_support::env_lock;
1106
1107 struct TempConfig {
1111 _guard: std::sync::MutexGuard<'static, ()>,
1112 _tmp: tempfile::TempDir,
1113 prev: Option<std::ffi::OsString>,
1114 }
1115 impl TempConfig {
1116 fn new() -> Self {
1117 let guard = env_lock().lock().unwrap();
1118 let prev = std::env::var_os(CONFIG_DIR_ENV);
1119 let tmp = tempfile::TempDir::new().unwrap();
1120 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1121 Self {
1122 _guard: guard,
1123 _tmp: tmp,
1124 prev,
1125 }
1126 }
1127 }
1128 impl Drop for TempConfig {
1129 fn drop(&mut self) {
1130 restore_env(CONFIG_DIR_ENV, self.prev.take());
1131 }
1132 }
1133
1134 #[test]
1135 fn read_auth_missing_file_is_empty() {
1136 let _cfg = TempConfig::new();
1137 let store = read_auth().unwrap();
1138 assert!(store.is_empty());
1139 }
1140
1141 #[test]
1142 fn upsert_then_read_roundtrip() {
1143 let _cfg = TempConfig::new();
1144 upsert_credential(
1145 "anthropic",
1146 Credential::ApiKey {
1147 key: Some("sk-test-123".into()),
1148 env: None,
1149 },
1150 )
1151 .unwrap();
1152 let store = read_auth().unwrap();
1153 match store.get("anthropic") {
1154 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
1155 other => panic!("unexpected cred: {other:?}"),
1156 }
1157 let path = auth_path().unwrap();
1159 assert!(path.exists(), "auth.json should exist after upsert");
1160 let raw = std::fs::read_to_string(&path).unwrap();
1161 assert!(raw.contains("\"anthropic\""));
1162 assert!(raw.contains("api_key"));
1163 }
1164
1165 #[test]
1166 fn oauth_token_is_scoped_and_round_trips() {
1167 let _cfg = TempConfig::new();
1168 upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
1169 assert!(matches!(
1170 read_oauth_token("acme").unwrap(),
1171 Some(Credential::Oauth { access, refresh, expires })
1172 if access == "access" && refresh == "refresh" && expires == 123
1173 ));
1174 assert!(read_oauth_token("other").unwrap().is_none());
1175 }
1176
1177 #[test]
1178 fn delete_credential_removes_entry() {
1179 let _cfg = TempConfig::new();
1180 upsert_credential(
1181 "anthropic",
1182 Credential::ApiKey {
1183 key: Some("k".into()),
1184 env: None,
1185 },
1186 )
1187 .unwrap();
1188 assert!(delete_credential("anthropic").unwrap());
1189 assert!(!delete_credential("anthropic").unwrap());
1191 assert!(read_auth().unwrap().is_empty());
1192 }
1193
1194 #[test]
1195 fn load_models_config_missing_is_empty() {
1196 let _cfg = TempConfig::new();
1197 let c = load_models_config().unwrap();
1198 assert!(c.providers.is_empty());
1199 }
1200
1201 #[test]
1202 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1203 let tmp = tempfile::tempdir().unwrap();
1204 let primary = tmp.path().join("primary.json");
1205 let fallback = tmp.path().join("fallback.json");
1206 std::fs::write(&fallback, "fallback").unwrap();
1207
1208 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1209 .unwrap()
1210 .expect("fallback should be read");
1211 assert_eq!(text, "fallback");
1212
1213 std::fs::write(&primary, "primary").unwrap();
1214 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1215 .unwrap()
1216 .expect("primary should be read");
1217 assert_eq!(path, primary);
1218 assert_eq!(text, "primary");
1219 }
1220
1221 #[test]
1222 fn fallback_reader_does_not_mask_primary_read_errors() {
1223 let tmp = tempfile::tempdir().unwrap();
1224 let primary = tmp.path().join("primary.json");
1225 let fallback = tmp.path().join("fallback.json");
1226 std::fs::create_dir(&primary).unwrap();
1227 std::fs::write(&fallback, "fallback").unwrap();
1228
1229 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1230 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1231 }
1232
1233 #[test]
1234 fn load_models_config_parses_with_comments() {
1235 let _cfg = TempConfig::new();
1236 let json = r#"{
1237 // a one-api style gateway
1238 "providers": {
1239 "gateway": {
1240 "baseUrl": "https://gw.example.com",
1241 "authHeader": true,
1242 "apiKey": "gw-secret",
1243 "models": [
1244 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1245 ]
1246 }
1247 }
1248}"#;
1249 std::fs::write(models_path().unwrap(), json).unwrap();
1250 let c = load_models_config().unwrap();
1251 let gw = c
1252 .providers
1253 .get("gateway")
1254 .expect("gateway provider present");
1255 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1256 assert!(gw.auth_header.unwrap_or(false));
1257 assert_eq!(gw.models.len(), 1);
1258 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1259 }
1260
1261 #[test]
1262 fn provider_to_models_merges_headers_without_synth_bearer() {
1263 let cfg = ProviderConfig {
1269 name: None,
1270 base_url: Some("https://gw.example.com".into()),
1271 api_key: Some("gw-secret".into()),
1272 api: None,
1273 headers: Some({
1274 let mut h = BTreeMap::new();
1275 h.insert("x-portkey-key".into(), "portkey-secret".into());
1276 h
1277 }),
1278 auth_header: Some(true),
1279 models: vec![ModelDefinition {
1280 id: "claude-sonnet-5".into(),
1281 name: None,
1282 base_url: None,
1283 reasoning: None,
1284 context_window: None,
1285 max_tokens: None,
1286 input: None,
1287 headers: None,
1288 compat: None,
1289 }],
1290 };
1291 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1292 assert_eq!(models.len(), 1);
1293 let m = &models[0];
1294 assert_eq!(m.id, "claude-sonnet-5");
1295 assert_eq!(m.base_url, "https://gw.example.com");
1296 assert_eq!(m.provider, "gateway");
1297 let headers = m.headers.as_ref().expect("provider headers merged");
1298 assert_eq!(
1300 headers.get("x-portkey-key").map(|s| s.as_str()),
1301 Some("portkey-secret")
1302 );
1303 assert!(
1308 headers.get("authorization").is_none(),
1309 "provider_to_models must not synthesize the Bearer; resolve does"
1310 );
1311 }
1312
1313 #[test]
1314 fn provider_to_models_supports_openai_completions() {
1315 let config: ModelsConfig = serde_json::from_str(
1316 r#"{
1317 "providers": {
1318 "oai": {
1319 "api": "openai-completions",
1320 "baseUrl": "https://gateway.example.com/v1",
1321 "apiKey": "secret",
1322 "models": [{"id":"gpt-test","maxTokens":4096}]
1323 }
1324 }
1325 }"#,
1326 )
1327 .unwrap();
1328 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1329 assert_eq!(models.len(), 1);
1330 assert_eq!(models[0].api, Api::OpenaiCompletions);
1331 assert_eq!(models[0].provider, "oai");
1332 assert_eq!(models[0].max_tokens, 4096);
1333 assert_eq!(
1334 models[0]
1335 .headers
1336 .as_ref()
1337 .and_then(|headers| headers.get("authorization"))
1338 .map(String::as_str),
1339 Some("Bearer secret")
1340 );
1341 }
1342
1343 #[test]
1344 fn models_json_compat_reaches_the_model_for_openai_completions() {
1345 let config: ModelsConfig = serde_json::from_str(
1352 r#"{
1353 "providers": {
1354 "qwen": {
1355 "api": "openai-completions",
1356 "baseUrl": "https://coding.example.com/v1",
1357 "apiKey": "secret",
1358 "models": [{
1359 "id": "qwen3.7-plus",
1360 "maxTokens": 131072,
1361 "compat": { "requiresThinkingAsText": true }
1362 }]
1363 }
1364 }
1365 }"#,
1366 )
1367 .unwrap();
1368 let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1369 match models[0].compat.as_ref().expect("compat must survive") {
1370 StreamingProtocolCompat::OpenaiCompletions(compat) => {
1371 assert_eq!(compat.requires_thinking_as_text, Some(true));
1372 }
1373 other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1374 }
1375
1376 let plain: ModelsConfig = serde_json::from_str(
1378 r#"{
1379 "providers": {
1380 "qwen": {
1381 "api": "openai-completions",
1382 "baseUrl": "https://coding.example.com/v1",
1383 "apiKey": "secret",
1384 "models": [{"id":"qwen3.7-plus"}]
1385 }
1386 }
1387 }"#,
1388 )
1389 .unwrap();
1390 let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1391 assert!(plain_models[0].compat.is_none());
1392 }
1393
1394 #[test]
1395 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1396 let _guard = env_lock().lock().unwrap();
1397 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1398 std::env::set_var(env_name, "env-secret");
1399 let cfg = ProviderConfig {
1400 name: None,
1401 base_url: Some("https://gateway.example.com/v1".into()),
1402 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1403 api: Some("openai-completions".into()),
1404 headers: None,
1405 auth_header: None,
1406 models: vec![ModelDefinition {
1407 id: "fake-model".into(),
1408 name: None,
1409 base_url: None,
1410 reasoning: None,
1411 context_window: None,
1412 max_tokens: None,
1413 input: None,
1414 headers: None,
1415 compat: None,
1416 }],
1417 };
1418 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1419 assert_eq!(
1420 models[0]
1421 .headers
1422 .as_ref()
1423 .and_then(|headers| headers.get("authorization"))
1424 .map(String::as_str),
1425 Some("Bearer env-secret")
1426 );
1427 std::env::remove_var(env_name);
1428 }
1429
1430 #[test]
1431 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1432 let _guard = env_lock().lock().unwrap();
1433 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1434 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1435 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1436 std::env::set_var("OPENAI_API_KEY", "official-key");
1437 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1438 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1439 let config = ProviderConfig {
1440 name: None,
1441 base_url: Some("https://gateway.example.com/v1".into()),
1442 api_key: None,
1443 api: Some("openai-responses".into()),
1444 headers: None,
1445 auth_header: None,
1446 models: Vec::new(),
1447 };
1448
1449 assert_eq!(
1450 openai_provider_api_key("openai", &config).as_deref(),
1451 Some("official-key")
1452 );
1453 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1454 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1455 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1456
1457 restore_env("OPENAI_API_KEY", prev_openai);
1458 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1459 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1460 }
1461
1462 #[test]
1463 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1464 for (provider, env_var) in [
1465 ("ant-ling", "ANT_LING_API_KEY"),
1466 ("qwen-token-plan", "QWEN_TOKEN_PLAN_API_KEY"),
1467 ("qwen-token-plan-cn", "QWEN_TOKEN_PLAN_CN_API_KEY"),
1468 ("qwen-token-plan-individual", "QWEN_TOKEN_PLAN_API_KEY"),
1469 ("openai", "OPENAI_API_KEY"),
1470 ("azure-openai-responses", "AZURE_OPENAI_API_KEY"),
1471 ("nvidia", "NVIDIA_API_KEY"),
1472 ("deepseek", "DEEPSEEK_API_KEY"),
1473 ("google", "GEMINI_API_KEY"),
1474 ("google-vertex", "GOOGLE_CLOUD_API_KEY"),
1475 ("groq", "GROQ_API_KEY"),
1476 ("cerebras", "CEREBRAS_API_KEY"),
1477 ("xai", "XAI_API_KEY"),
1478 ("radius", "RADIUS_API_KEY"),
1479 ("openrouter", "OPENROUTER_API_KEY"),
1480 ("vercel-ai-gateway", "AI_GATEWAY_API_KEY"),
1481 ("zai", "ZAI_API_KEY"),
1482 ("zai-coding-cn", "ZAI_CODING_CN_API_KEY"),
1483 ("mistral", "MISTRAL_API_KEY"),
1484 ("minimax", "MINIMAX_API_KEY"),
1485 ("minimax-cn", "MINIMAX_CN_API_KEY"),
1486 ("moonshotai", "MOONSHOT_API_KEY"),
1487 ("moonshotai-cn", "MOONSHOT_API_KEY"),
1488 ("huggingface", "HF_TOKEN"),
1489 ("fireworks", "FIREWORKS_API_KEY"),
1490 ("together", "TOGETHER_API_KEY"),
1491 ("baseten", "BASETEN_API_KEY"),
1492 ("opencode", "OPENCODE_API_KEY"),
1493 ("opencode-go", "OPENCODE_API_KEY"),
1494 ("kimi-coding", "KIMI_API_KEY"),
1495 ("cloudflare-workers-ai", "CLOUDFLARE_API_KEY"),
1496 ("cloudflare-ai-gateway", "CLOUDFLARE_API_KEY"),
1497 ("xiaomi", "XIAOMI_API_KEY"),
1498 ("xiaomi-token-plan-cn", "XIAOMI_TOKEN_PLAN_CN_API_KEY"),
1499 ("xiaomi-token-plan-ams", "XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
1500 ("xiaomi-token-plan-sgp", "XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
1501 ] {
1502 assert_eq!(
1503 native_provider_api_key_env_var(provider),
1504 Some(env_var),
1505 "unexpected env mapping for {provider}"
1506 );
1507 }
1508
1509 for alias in [
1510 "togetherai",
1511 "perplexity",
1512 "moonshot",
1513 "kimi",
1514 "qwen",
1515 "zhipu",
1516 "OpenAI",
1517 ] {
1518 assert_eq!(
1519 native_provider_api_key_env_var(alias),
1520 None,
1521 "non-native alias {alias} must not inherit credentials"
1522 );
1523 }
1524 }
1525
1526 #[test]
1527 fn malformed_auth_json_is_an_error_not_silent_empty() {
1528 let _cfg = TempConfig::new();
1529 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1530 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1531 }
1532
1533 #[test]
1534 fn agent_dir_nests_under_agent_by_default() {
1535 let _guard = env_lock().lock().unwrap();
1539 let prev = std::env::var_os(CONFIG_DIR_ENV);
1540 std::env::remove_var(CONFIG_DIR_ENV);
1541 let dir = agent_dir().unwrap();
1542 restore_env(CONFIG_DIR_ENV, prev);
1543 assert!(dir.ends_with("agent"));
1544 assert!(dir
1545 .parent()
1546 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1547 .unwrap_or(false));
1548 }
1549
1550 #[test]
1551 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1552 let tmp = tempfile::TempDir::new().unwrap();
1556 let root = tmp.path().to_path_buf();
1557 let agent = root.join("agent");
1558 std::fs::write(root.join("auth.json"), "{}").unwrap();
1559 std::fs::write(root.join("models.json"), "{}").unwrap();
1560 std::fs::write(root.join(".setup_done"), "1").unwrap();
1561 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1562 assert_eq!(moved, 3);
1563 assert!(agent.join("auth.json").exists());
1564 assert!(agent.join("models.json").exists());
1565 assert!(agent.join(".setup_done").exists());
1566 assert!(!root.join("auth.json").exists());
1567 }
1568
1569 #[test]
1570 fn migrate_legacy_layout_noop_when_agent_exists() {
1571 let tmp = tempfile::TempDir::new().unwrap();
1572 let root = tmp.path().to_path_buf();
1573 let agent = root.join("agent");
1574 std::fs::write(root.join("auth.json"), "{}").unwrap();
1575 std::fs::create_dir_all(&agent).unwrap();
1576 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1577 assert_eq!(moved, 0); }
1579
1580 #[test]
1581 fn migrate_legacy_layout_noop_when_no_flat_files() {
1582 let tmp = tempfile::TempDir::new().unwrap();
1583 let root = tmp.path().to_path_buf();
1584 let agent = root.join("agent");
1585 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1586 assert_eq!(moved, 0);
1587 }
1588
1589 #[test]
1590 fn migrate_legacy_layout_public_skips_env_override() {
1591 let _cfg = TempConfig::new();
1594 let moved = migrate_legacy_layout().unwrap();
1595 assert_eq!(moved, 0);
1596 }
1597
1598 #[test]
1599 fn read_trust_missing_file_is_empty() {
1600 let _cfg = TempConfig::new();
1601 assert!(read_trust().unwrap().is_empty());
1602 }
1603
1604 #[test]
1605 fn read_trust_parses_decisions() {
1606 let _cfg = TempConfig::new();
1607 let path = trust_path().unwrap();
1608 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1609 std::fs::write(
1610 &path,
1611 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1612 )
1613 .unwrap();
1614 let store = read_trust().unwrap();
1615 assert_eq!(store.len(), 3);
1616 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1617 assert_eq!(
1618 store.get("/home/me/untrusted").copied().flatten(),
1619 Some(false)
1620 );
1621 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1622 }
1623
1624 #[test]
1625 fn resolve_config_value_literal_passthrough() {
1626 assert_eq!(
1627 resolve_config_value("sk-literal-key", None),
1628 Some("sk-literal-key".into())
1629 );
1630 }
1631
1632 #[test]
1633 fn resolve_config_value_env_var() {
1634 let _guard = env_lock().lock().unwrap();
1635 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1636 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1637 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1638 assert_eq!(
1639 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1640 Some("secret-from-env".into())
1641 );
1642 assert_eq!(
1643 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1644 Some("prefix-secret-from-env-suffix".into())
1645 );
1646 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1648 assert_eq!(
1649 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1650 Some("a-secret-from-env-b-two-c".into())
1651 );
1652 let mut overlay = BTreeMap::new();
1654 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1655 assert_eq!(
1656 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1657 Some("overlay-value".into())
1658 );
1659 restore_env("RPI_TEST_CFG_KEY", prev);
1660 restore_env("RPI_TEST_CFG_KEY2", prev2);
1661 }
1662
1663 #[test]
1664 fn resolve_config_value_unset_env_is_none() {
1665 let _guard = env_lock().lock().unwrap();
1666 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1667 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1668 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1670 assert_eq!(
1671 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1672 None
1673 );
1674 restore_env("RPI_TEST_CFG_ABSENT", prev);
1675 }
1676
1677 #[test]
1678 fn resolve_config_value_dollar_dollar_escapes_literal() {
1679 assert_eq!(
1680 resolve_config_value("price-$$5", None),
1681 Some("price-$5".into())
1682 );
1683 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1684 }
1685
1686 #[test]
1687 fn resolve_config_value_command_runs_shell() {
1688 assert_eq!(
1690 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1691 Some("rpi-cfg-resolved".into())
1692 );
1693 assert_eq!(resolve_config_value_uncached("!false", None), None);
1695 }
1696
1697 #[test]
1698 fn resolve_headers_drops_unresolvable() {
1699 let _guard = env_lock().lock().unwrap();
1700 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1701 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1702 let mut h = BTreeMap::new();
1703 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1704 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1705 h.insert("x-literal".into(), "literal-value".into());
1706 let resolved = resolve_headers(&h, None);
1707 assert_eq!(resolved.len(), 2);
1708 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1709 assert_eq!(
1710 resolved.get("x-literal").map(|s| s.as_str()),
1711 Some("literal-value")
1712 );
1713 assert!(!resolved.contains_key("x-unset"));
1714 restore_env("RPI_TEST_HDR_SET", prev);
1715 }
1716
1717 #[test]
1718 fn agent_dir_respects_env_override() {
1719 let _guard = env_lock().lock().unwrap();
1720 let prev = std::env::var_os(CONFIG_DIR_ENV);
1721 let tmp = tempfile::TempDir::new().unwrap();
1722 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1723 let dir = agent_dir().unwrap();
1724 restore_env(CONFIG_DIR_ENV, prev);
1725 assert_eq!(dir, tmp.path());
1726 }
1727
1728 #[test]
1729 fn relative_override_is_rejected() {
1730 let _guard = env_lock().lock().unwrap();
1731 let prev = std::env::var_os(CONFIG_DIR_ENV);
1732 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1733 let err = agent_dir().unwrap_err();
1734 restore_env(CONFIG_DIR_ENV, prev);
1735 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1736 }
1737
1738 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1740 match prev {
1741 Some(v) => std::env::set_var(name, v),
1742 None => std::env::remove_var(name),
1743 }
1744 }
1745}