1use std::collections::BTreeMap;
39use std::io::Write;
40use std::path::{Path, PathBuf};
41
42use rpi_ai::{Api, InputModality, Model, StreamingProtocolCompat};
43
44pub const CONFIG_DIR_NAME: &str = ".rpi";
47
48pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
51
52pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
55
56#[derive(Debug, thiserror::Error)]
63pub enum ConfigError {
64 #[error("could not resolve home directory (set {env} to override)")]
65 NoHomeDir { env: &'static str },
66 #[error("config dir override {env}={val:?} is not an absolute path")]
67 RelativeOverride { env: &'static str, val: String },
68 #[error("could not read {path}: {source}")]
69 Read {
70 path: PathBuf,
71 #[source]
72 source: std::io::Error,
73 },
74 #[error("could not write {path}: {source}")]
75 Write {
76 path: PathBuf,
77 #[source]
78 source: std::io::Error,
79 },
80 #[error("invalid JSON in {path}: {source}")]
81 Json {
82 path: PathBuf,
83 #[source]
84 source: serde_json::Error,
85 },
86}
87
88pub fn agent_dir() -> Result<PathBuf, ConfigError> {
98 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
99 let p = PathBuf::from(&val);
100 if !p.is_absolute() {
101 return Err(ConfigError::RelativeOverride {
102 env: CONFIG_DIR_ENV,
103 val: val.to_string_lossy().into_owned(),
104 });
105 }
106 return Ok(p);
107 }
108 let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
109 env: CONFIG_DIR_ENV,
110 })?;
111 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
112}
113
114fn config_root_dir() -> Result<PathBuf, ConfigError> {
118 let agent = agent_dir()?;
119 agent
120 .parent()
121 .map(Path::to_path_buf)
122 .ok_or(ConfigError::NoHomeDir {
123 env: CONFIG_DIR_ENV,
124 })
125}
126
127pub fn auth_path() -> Result<PathBuf, ConfigError> {
129 Ok(agent_dir()?.join("auth.json"))
130}
131
132pub fn models_path() -> Result<PathBuf, ConfigError> {
134 Ok(agent_dir()?.join("models.json"))
135}
136
137pub fn settings_path() -> Result<PathBuf, ConfigError> {
139 Ok(agent_dir()?.join("settings.json"))
140}
141
142pub fn trust_path() -> Result<PathBuf, ConfigError> {
144 Ok(agent_dir()?.join("trust.json"))
145}
146
147fn native_pi_agent_file(file_name: &str) -> Option<PathBuf> {
151 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
152 return None;
153 }
154 dirs::home_dir().map(|home| home.join(".pi/agent").join(file_name))
155}
156
157fn read_text_with_fallback(
158 primary: PathBuf,
159 fallback: Option<PathBuf>,
160) -> Result<Option<(PathBuf, String)>, ConfigError> {
161 match std::fs::read_to_string(&primary) {
162 Ok(text) => Ok(Some((primary, text))),
163 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
164 let Some(fallback) = fallback.filter(|path| path != &primary) else {
165 return Ok(None);
166 };
167 match std::fs::read_to_string(&fallback) {
168 Ok(text) => Ok(Some((fallback, text))),
169 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
170 Err(source) => Err(ConfigError::Read {
171 path: fallback,
172 source,
173 }),
174 }
175 }
176 Err(source) => Err(ConfigError::Read {
177 path: primary,
178 source,
179 }),
180 }
181}
182
183pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
191 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
193 return Ok(0);
194 }
195 let root = match config_root_dir() {
196 Ok(p) => p,
197 Err(_) => return Ok(0),
198 };
199 let agent = agent_dir()?;
200 migrate_legacy_layout_in(&root, &agent)
201}
202
203fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
210 if agent.exists() {
212 return Ok(0);
213 }
214 let flat_auth = root.join("auth.json");
216 let flat_models = root.join("models.json");
217 if !flat_auth.exists() && !flat_models.exists() {
218 return Ok(0);
219 }
220 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
221 path: agent.to_path_buf(),
222 source: e,
223 })?;
224 let mut moved = 0usize;
225 for leaf in ["auth.json", "models.json", ".setup_done", ".earendil_seen"] {
226 let from = root.join(leaf);
227 let to = agent.join(leaf);
228 if from.exists() && !to.exists() {
229 if let Err(_e) = std::fs::rename(&from, &to) {
232 if std::fs::copy(&from, &to).is_ok() {
233 let _ = std::fs::remove_file(&from);
234 }
235 }
236 moved += 1;
237 }
238 }
239 Ok(moved)
240}
241
242#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
251#[serde(rename_all = "snake_case", tag = "type")]
252pub enum Credential {
253 ApiKey {
256 key: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 env: Option<BTreeMap<String, String>>,
259 },
260 Oauth {
262 access: String,
263 refresh: String,
264 expires: i64,
266 },
267}
268
269pub type AuthStore = BTreeMap<String, Credential>;
272
273pub fn read_auth() -> Result<AuthStore, ConfigError> {
276 let path = auth_path()?;
277 match read_text_with_fallback(path, native_pi_agent_file("auth.json"))? {
278 Some((path, text)) => {
279 serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
280 }
281 None => Ok(AuthStore::new()),
282 }
283}
284
285pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
288 let path = auth_path()?;
289 let dir = agent_dir()?;
290 ensure_dir(&dir)?;
291 let json = serde_json::to_string_pretty(store).unwrap();
292 atomic_write(&path, json.as_bytes())?;
293 set_owner_only(&path);
294 Ok(())
295}
296
297pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
299 let mut store = read_auth()?;
300 store.insert(provider_id.to_string(), cred);
301 write_auth(&store)
302}
303
304pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
309 let mut store = read_auth()?;
310 if store.remove(provider_id).is_some() {
311 write_auth(&store)?;
312 Ok(true)
313 } else {
314 Ok(false)
315 }
316}
317
318#[derive(serde::Deserialize, Default, Debug, Clone)]
325#[serde(rename_all = "camelCase")]
326pub struct ModelsConfig {
327 #[serde(default)]
328 pub providers: indexmap::IndexMap<String, ProviderConfig>,
331}
332
333#[derive(serde::Deserialize, Debug, Clone)]
337#[serde(rename_all = "camelCase")]
338pub struct ProviderConfig {
339 #[serde(default)]
340 pub name: Option<String>,
341 #[serde(default)]
342 pub base_url: Option<String>,
343 #[serde(default)]
344 pub api_key: Option<String>,
345 #[serde(default)]
346 pub api: Option<String>,
347 #[serde(default)]
348 pub headers: Option<BTreeMap<String, String>>,
349 #[serde(default)]
352 pub auth_header: Option<bool>,
353 #[serde(default)]
354 pub models: Vec<ModelDefinition>,
355}
356
357#[derive(serde::Deserialize, Debug, Clone)]
359#[serde(rename_all = "camelCase")]
360pub struct ModelDefinition {
361 pub id: String,
362 #[serde(default)]
363 pub name: Option<String>,
364 #[serde(default)]
365 pub base_url: Option<String>,
366 #[serde(default)]
367 pub reasoning: Option<bool>,
368 #[serde(default)]
369 pub context_window: Option<u64>,
370 #[serde(default)]
371 pub max_tokens: Option<u64>,
372 #[serde(default)]
375 pub input: Option<Vec<String>>,
376 #[serde(default)]
377 pub headers: Option<BTreeMap<String, String>>,
378 #[serde(default)]
379 pub compat: Option<serde_json::Value>,
380}
381
382pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
384 let path = models_path()?;
385 match read_text_with_fallback(path, native_pi_agent_file("models.json"))? {
386 Some((path, text)) => {
387 parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
388 }
389 None => Ok(ModelsConfig::default()),
390 }
391}
392
393pub type TrustStore = BTreeMap<String, Option<bool>>;
403
404pub fn read_trust() -> Result<TrustStore, ConfigError> {
408 let path = trust_path()?;
409 match std::fs::read_to_string(&path) {
410 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
411 path: path.clone(),
412 source: e,
413 }),
414 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
415 Err(e) => Err(ConfigError::Read { path, source: e }),
416 }
417}
418
419pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
422 let key = std::fs::canonicalize(cwd)
423 .unwrap_or_else(|_| cwd.to_path_buf())
424 .to_string_lossy()
425 .into_owned();
426 let mut store = read_trust()?;
427 if let Some(decision) = trusted {
428 store.insert(key, Some(decision));
429 } else {
430 store.remove(&key);
431 }
432 let path = trust_path()?;
433 ensure_dir(&agent_dir()?)?;
434 let json = serde_json::to_string_pretty(&store).unwrap();
435 atomic_write(&path, json.as_bytes())?;
436 set_owner_only(&path);
437 Ok(())
438}
439
440pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
443 let key = std::fs::canonicalize(cwd)
444 .unwrap_or_else(|_| cwd.to_path_buf())
445 .to_string_lossy()
446 .into_owned();
447 Ok(read_trust()?.get(&key).copied().flatten())
448}
449
450fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
454 match serde_json::from_str(text) {
455 Ok(c) => Ok(c),
456 Err(first) => {
457 let stripped = strip_line_comments(text);
462 serde_json::from_str(&stripped).map_err(|_| first)
463 }
464 }
465}
466
467pub(crate) fn strip_line_comments(text: &str) -> String {
472 text.lines()
473 .map(|line| match find_line_comment(line) {
474 Some(idx) => line[..idx].to_string(),
475 None => line.to_string(),
476 })
477 .collect::<Vec<_>>()
478 .join("\n")
479}
480
481fn find_line_comment(line: &str) -> Option<usize> {
483 let mut in_str = false;
484 let mut esc = false;
485 for (i, ch) in line.char_indices() {
486 if esc {
487 esc = false;
488 continue;
489 }
490 match ch {
491 '\\' if in_str => esc = true,
492 '"' => in_str = !in_str,
493 '/' if !in_str => {
494 if line.as_bytes().get(i + 1) == Some(&b'/') {
495 return Some(i);
496 }
497 }
498 _ => {}
499 }
500 }
501 None
502}
503
504fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
511 static CACHE: std::sync::OnceLock<
512 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
513 > = std::sync::OnceLock::new();
514 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
515}
516
517pub fn resolve_config_value(
532 config: &str,
533 env_overlay: Option<&BTreeMap<String, String>>,
534) -> Option<String> {
535 if let Some(cmd) = config.strip_prefix('!') {
536 return resolve_command(cmd);
537 }
538 resolve_template(config, env_overlay)
539}
540
541pub fn resolve_config_value_uncached(
545 config: &str,
546 env_overlay: Option<&BTreeMap<String, String>>,
547) -> Option<String> {
548 if let Some(cmd) = config.strip_prefix('!') {
549 return resolve_command_uncached(cmd);
550 }
551 resolve_template(config, env_overlay)
552}
553
554pub fn resolve_headers(
558 headers: &BTreeMap<String, String>,
559 env_overlay: Option<&BTreeMap<String, String>>,
560) -> BTreeMap<String, String> {
561 let mut out = BTreeMap::new();
562 for (k, v) in headers {
563 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
564 out.insert(k.clone(), resolved);
565 }
566 }
567 out
568}
569
570fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
573 if let Some(overlay) = env_overlay {
574 if let Some(v) = overlay.get(name) {
575 return Some(v.clone());
576 }
577 }
578 std::env::var(name).ok()
579}
580
581enum TemplatePart {
583 Literal(String),
584 Env(String),
585}
586
587fn parse_template(config: &str) -> Vec<TemplatePart> {
592 let mut parts: Vec<TemplatePart> = Vec::new();
593 let bytes = config.as_bytes();
594 let mut i = 0usize;
595 while i < bytes.len() {
596 match config[i..].find('$') {
598 None => {
599 push_literal(&mut parts, &config[i..]);
600 break;
601 }
602 Some(offset) => {
603 let dollar = i + offset;
604 push_literal(&mut parts, &config[i..dollar]);
605 let after = dollar + 1;
606 let next = bytes.get(after).copied();
607 if next == Some(b'$') || next == Some(b'!') {
608 push_literal(&mut parts, &config[after..after + 1]);
609 i = after + 1;
610 continue;
611 }
612 if next == Some(b'{') {
613 if let Some(end_rel) = config[after + 1..].find('}') {
615 let end = after + 1 + end_rel;
616 let name = &config[after + 1..end];
617 if is_env_name(name) {
618 parts.push(TemplatePart::Env(name.to_string()));
619 } else {
620 push_literal(&mut parts, &config[dollar..=end]);
622 }
623 i = end + 1;
624 continue;
625 }
626 push_literal(&mut parts, "$");
628 i = after;
629 continue;
630 }
631 if let Some(name) = env_name_prefix(&config[after..]) {
633 parts.push(TemplatePart::Env(name.to_string()));
634 i = after + name.len();
635 } else {
636 push_literal(&mut parts, "$");
637 i = after;
638 }
639 }
640 }
641 }
642 parts
643}
644
645fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
646 if value.is_empty() {
647 return;
648 }
649 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
650 s.push_str(value);
651 } else {
652 parts.push(TemplatePart::Literal(value.to_string()));
653 }
654}
655
656fn is_env_name(s: &str) -> bool {
657 let mut chars = s.chars();
658 match chars.next() {
659 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
660 _ => return false,
661 }
662 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
663}
664
665fn env_name_prefix(s: &str) -> Option<&str> {
668 let mut chars = s.char_indices();
669 match chars.next() {
670 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
671 _ => return None,
672 }
673 let end = chars
674 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
675 .map(|(idx, _)| idx)
676 .unwrap_or(s.len());
677 Some(&s[..end])
678}
679
680fn resolve_template(
683 config: &str,
684 env_overlay: Option<&BTreeMap<String, String>>,
685) -> Option<String> {
686 let parts = parse_template(config);
687 let mut out = String::with_capacity(config.len());
688 for part in parts {
689 match part {
690 TemplatePart::Literal(s) => out.push_str(&s),
691 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
692 Some(v) => out.push_str(&v),
693 None => return None,
694 },
695 }
696 }
697 Some(out)
698}
699
700fn resolve_command(cmd: &str) -> Option<String> {
704 let key = format!("!{cmd}");
705 if let Some(v) = command_cache().lock().ok()?.get(&key) {
706 return v.clone();
707 }
708 let result = resolve_command_uncached(cmd);
709 if let Ok(mut cache) = command_cache().lock() {
710 cache.insert(key, result.clone());
711 }
712 result
713}
714
715#[cfg(unix)]
716fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
717 std::process::Command::new("sh")
718 .arg("-c")
719 .arg(cmd)
720 .stdin(std::process::Stdio::null())
721 .stdout(std::process::Stdio::piped())
722 .stderr(std::process::Stdio::null())
723 .output()
724 .ok()
725}
726
727#[cfg(windows)]
728fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
729 use std::os::windows::process::CommandExt;
730 std::process::Command::new("cmd")
731 .arg("/C")
732 .arg(cmd)
733 .stdin(std::process::Stdio::null())
734 .stdout(std::process::Stdio::piped())
735 .stderr(std::process::Stdio::null())
736 .creation_flags(0x0800_0000) .output()
738 .ok()
739}
740
741fn resolve_command_uncached(cmd: &str) -> Option<String> {
743 let output = spawn_shell_command(cmd)?;
744 if !output.status.success() {
745 return None;
746 }
747 let stdout = String::from_utf8_lossy(&output.stdout);
748 let trimmed = stdout.trim();
749 if trimmed.is_empty() {
750 None
751 } else {
752 Some(trimmed.to_string())
753 }
754}
755
756pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
761 match cfg.api.as_deref() {
762 None | Some("") | Some("anthropic-messages") => true,
763 _ => false,
764 }
765}
766
767pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
769 matches!(cfg.api.as_deref(), Some("openai-completions"))
770}
771
772pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
773 matches!(cfg.api.as_deref(), Some("openai-responses"))
774}
775
776pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
781 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
782 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
783 return Some(value);
784 }
785 }
786
787 let name = native_provider_api_key_env_var(provider_id)?;
788 std::env::var(name).ok().filter(|value| !value.is_empty())
789}
790
791fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
795 match provider_id {
796 "ant-ling" => Some("ANT_LING_API_KEY"),
797 "qwen-token-plan" | "qwen-token-plan-individual" => Some("QWEN_TOKEN_PLAN_API_KEY"),
798 "qwen-token-plan-cn" => Some("QWEN_TOKEN_PLAN_CN_API_KEY"),
799 "openai" => Some("OPENAI_API_KEY"),
800 "azure-openai-responses" => Some("AZURE_OPENAI_API_KEY"),
801 "nvidia" => Some("NVIDIA_API_KEY"),
802 "deepseek" => Some("DEEPSEEK_API_KEY"),
803 "google" => Some("GEMINI_API_KEY"),
804 "google-vertex" => Some("GOOGLE_CLOUD_API_KEY"),
805 "groq" => Some("GROQ_API_KEY"),
806 "cerebras" => Some("CEREBRAS_API_KEY"),
807 "xai" => Some("XAI_API_KEY"),
808 "radius" => Some("RADIUS_API_KEY"),
809 "openrouter" => Some("OPENROUTER_API_KEY"),
810 "vercel-ai-gateway" => Some("AI_GATEWAY_API_KEY"),
811 "zai" => Some("ZAI_API_KEY"),
812 "zai-coding-cn" => Some("ZAI_CODING_CN_API_KEY"),
813 "mistral" => Some("MISTRAL_API_KEY"),
814 "minimax" => Some("MINIMAX_API_KEY"),
815 "minimax-cn" => Some("MINIMAX_CN_API_KEY"),
816 "moonshotai" | "moonshotai-cn" => Some("MOONSHOT_API_KEY"),
817 "huggingface" => Some("HF_TOKEN"),
818 "fireworks" => Some("FIREWORKS_API_KEY"),
819 "together" => Some("TOGETHER_API_KEY"),
820 "baseten" => Some("BASETEN_API_KEY"),
821 "opencode" | "opencode-go" => Some("OPENCODE_API_KEY"),
822 "kimi-coding" => Some("KIMI_API_KEY"),
823 "cloudflare-workers-ai" | "cloudflare-ai-gateway" => Some("CLOUDFLARE_API_KEY"),
824 "xiaomi" => Some("XIAOMI_API_KEY"),
825 "xiaomi-token-plan-cn" => Some("XIAOMI_TOKEN_PLAN_CN_API_KEY"),
826 "xiaomi-token-plan-ams" => Some("XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
827 "xiaomi-token-plan-sgp" => Some("XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
828 _ => None,
829 }
830}
831
832pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
836 let api = if provider_is_anthropic_compatible(cfg) {
837 Api::AnthropicMessages
838 } else if provider_is_openai_completions(cfg) {
839 Api::OpenaiCompletions
840 } else if provider_is_openai_responses(cfg) {
841 Api::OpenaiResponses
842 } else {
843 return None;
844 };
845 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
846 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
847 _ => default_anthropic_base_url(),
848 });
849 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
850 for def in &cfg.models {
851 let base_url = def
852 .base_url
853 .clone()
854 .unwrap_or_else(|| provider_base.clone());
855 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
856 let mut m = Model::new(
857 def.id.clone(),
858 name,
859 api.clone(),
860 provider_id.to_string(),
861 base_url,
862 );
863 m.reasoning = def.reasoning.unwrap_or(false);
864 m.context_window = def.context_window.unwrap_or(0);
865 m.max_tokens = def.max_tokens.unwrap_or(0);
866 m.input = parse_input_modalities(def.input.as_deref());
867 let mut headers: BTreeMap<String, String> = BTreeMap::new();
881 if let Some(h) = def.headers.clone() {
882 for (k, v) in resolve_headers(&h, None) {
883 headers.insert(k, v);
884 }
885 }
886 if let Some(h) = cfg.headers.clone() {
887 for (k, v) in resolve_headers(&h, None) {
888 headers.insert(k, v);
889 }
890 }
891 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
892 if let Some(key) = openai_provider_api_key(provider_id, cfg) {
893 headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
894 headers.insert("authorization".to_string(), format!("Bearer {key}"));
895 }
896 if let Some(value) = def.compat.clone() {
897 if matches!(api, Api::OpenaiResponses) {
898 if let Ok(compat) = serde_json::from_value(value) {
899 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
900 }
901 } else if let Ok(compat) = serde_json::from_value(value) {
902 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
903 }
904 }
905 }
906 if !headers.is_empty() {
907 m.headers = Some(headers);
908 }
909 merged.push(m);
910 }
911 Some(merged)
912}
913
914fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
917 match input {
918 None => vec![InputModality::Text],
919 Some(list) if list.is_empty() => vec![InputModality::Text],
920 Some(list) => list
921 .iter()
922 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
923 "text" => Some(InputModality::Text),
924 "image" => Some(InputModality::Image),
925 _ => None,
926 })
927 .collect::<Vec<_>>()
928 .pipe(|v| {
929 if v.is_empty() {
930 vec![InputModality::Text]
931 } else {
932 v
933 }
934 }),
935 }
936}
937
938pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
944
945pub fn default_anthropic_base_url() -> String {
949 ANTHROPIC_DEFAULT_BASE_URL.to_string()
950}
951
952#[cfg(unix)]
957use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
958
959fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
962 if dir.exists() {
963 return Ok(());
964 }
965 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
966 path: dir.to_path_buf(),
967 source: e,
968 })?;
969 #[cfg(unix)]
970 {
971 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
972 }
973 Ok(())
974}
975
976pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
979 let dir = path.parent().ok_or_else(|| ConfigError::Write {
980 path: path.to_path_buf(),
981 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
982 })?;
983 let tmp = dir.join(format!(
984 ".{}.{}.tmp",
985 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
986 uuid::Uuid::new_v4().simple()
987 ));
988 let mut options = std::fs::OpenOptions::new();
989 options.write(true).create_new(true);
990 #[cfg(unix)]
991 options.mode(0o600);
992 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
993 path: tmp.clone(),
994 source,
995 })?;
996 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
997 let _ = std::fs::remove_file(&tmp);
1001 return Err(ConfigError::Write {
1002 path: tmp,
1003 source: error,
1004 });
1005 }
1006 drop(file);
1007 if let Err(error) = std::fs::rename(&tmp, path) {
1008 let _ = std::fs::remove_file(&tmp);
1011 return Err(ConfigError::Write {
1012 path: path.to_path_buf(),
1013 source: error,
1014 });
1015 }
1016 Ok(())
1017}
1018
1019fn set_owner_only(_path: &Path) {
1022 #[cfg(unix)]
1023 {
1024 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
1025 }
1026}
1027
1028trait Pipe: Sized {
1031 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
1032 f(self)
1033 }
1034}
1035impl<T> Pipe for T {}
1036
1037#[cfg(test)]
1042pub(crate) mod test_support {
1043 use std::sync::{Mutex, OnceLock};
1049 pub(crate) fn env_lock() -> &'static Mutex<()> {
1050 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1051 LOCK.get_or_init(|| Mutex::new(()))
1052 }
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use super::*;
1058 use crate::config::test_support::env_lock;
1059
1060 struct TempConfig {
1064 _guard: std::sync::MutexGuard<'static, ()>,
1065 _tmp: tempfile::TempDir,
1066 prev: Option<std::ffi::OsString>,
1067 }
1068 impl TempConfig {
1069 fn new() -> Self {
1070 let guard = env_lock().lock().unwrap();
1071 let prev = std::env::var_os(CONFIG_DIR_ENV);
1072 let tmp = tempfile::TempDir::new().unwrap();
1073 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1074 Self {
1075 _guard: guard,
1076 _tmp: tmp,
1077 prev,
1078 }
1079 }
1080 }
1081 impl Drop for TempConfig {
1082 fn drop(&mut self) {
1083 restore_env(CONFIG_DIR_ENV, self.prev.take());
1084 }
1085 }
1086
1087 #[test]
1088 fn read_auth_missing_file_is_empty() {
1089 let _cfg = TempConfig::new();
1090 let store = read_auth().unwrap();
1091 assert!(store.is_empty());
1092 }
1093
1094 #[test]
1095 fn upsert_then_read_roundtrip() {
1096 let _cfg = TempConfig::new();
1097 upsert_credential(
1098 "anthropic",
1099 Credential::ApiKey {
1100 key: Some("sk-test-123".into()),
1101 env: None,
1102 },
1103 )
1104 .unwrap();
1105 let store = read_auth().unwrap();
1106 match store.get("anthropic") {
1107 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
1108 other => panic!("unexpected cred: {other:?}"),
1109 }
1110 let path = auth_path().unwrap();
1112 assert!(path.exists(), "auth.json should exist after upsert");
1113 let raw = std::fs::read_to_string(&path).unwrap();
1114 assert!(raw.contains("\"anthropic\""));
1115 assert!(raw.contains("api_key"));
1116 }
1117
1118 #[test]
1119 fn delete_credential_removes_entry() {
1120 let _cfg = TempConfig::new();
1121 upsert_credential(
1122 "anthropic",
1123 Credential::ApiKey {
1124 key: Some("k".into()),
1125 env: None,
1126 },
1127 )
1128 .unwrap();
1129 assert!(delete_credential("anthropic").unwrap());
1130 assert!(!delete_credential("anthropic").unwrap());
1132 assert!(read_auth().unwrap().is_empty());
1133 }
1134
1135 #[test]
1136 fn load_models_config_missing_is_empty() {
1137 let _cfg = TempConfig::new();
1138 let c = load_models_config().unwrap();
1139 assert!(c.providers.is_empty());
1140 }
1141
1142 #[test]
1143 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1144 let tmp = tempfile::tempdir().unwrap();
1145 let primary = tmp.path().join("primary.json");
1146 let fallback = tmp.path().join("fallback.json");
1147 std::fs::write(&fallback, "fallback").unwrap();
1148
1149 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1150 .unwrap()
1151 .expect("fallback should be read");
1152 assert_eq!(text, "fallback");
1153
1154 std::fs::write(&primary, "primary").unwrap();
1155 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1156 .unwrap()
1157 .expect("primary should be read");
1158 assert_eq!(path, primary);
1159 assert_eq!(text, "primary");
1160 }
1161
1162 #[test]
1163 fn fallback_reader_does_not_mask_primary_read_errors() {
1164 let tmp = tempfile::tempdir().unwrap();
1165 let primary = tmp.path().join("primary.json");
1166 let fallback = tmp.path().join("fallback.json");
1167 std::fs::create_dir(&primary).unwrap();
1168 std::fs::write(&fallback, "fallback").unwrap();
1169
1170 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1171 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1172 }
1173
1174 #[test]
1175 fn load_models_config_parses_with_comments() {
1176 let _cfg = TempConfig::new();
1177 let json = r#"{
1178 // a one-api style gateway
1179 "providers": {
1180 "gateway": {
1181 "baseUrl": "https://gw.example.com",
1182 "authHeader": true,
1183 "apiKey": "gw-secret",
1184 "models": [
1185 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1186 ]
1187 }
1188 }
1189}"#;
1190 std::fs::write(models_path().unwrap(), json).unwrap();
1191 let c = load_models_config().unwrap();
1192 let gw = c
1193 .providers
1194 .get("gateway")
1195 .expect("gateway provider present");
1196 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1197 assert!(gw.auth_header.unwrap_or(false));
1198 assert_eq!(gw.models.len(), 1);
1199 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1200 }
1201
1202 #[test]
1203 fn provider_to_models_merges_headers_without_synth_bearer() {
1204 let cfg = ProviderConfig {
1210 name: None,
1211 base_url: Some("https://gw.example.com".into()),
1212 api_key: Some("gw-secret".into()),
1213 api: None,
1214 headers: Some({
1215 let mut h = BTreeMap::new();
1216 h.insert("x-portkey-key".into(), "portkey-secret".into());
1217 h
1218 }),
1219 auth_header: Some(true),
1220 models: vec![ModelDefinition {
1221 id: "claude-sonnet-5".into(),
1222 name: None,
1223 base_url: None,
1224 reasoning: None,
1225 context_window: None,
1226 max_tokens: None,
1227 input: None,
1228 headers: None,
1229 compat: None,
1230 }],
1231 };
1232 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1233 assert_eq!(models.len(), 1);
1234 let m = &models[0];
1235 assert_eq!(m.id, "claude-sonnet-5");
1236 assert_eq!(m.base_url, "https://gw.example.com");
1237 assert_eq!(m.provider, "gateway");
1238 let headers = m.headers.as_ref().expect("provider headers merged");
1239 assert_eq!(
1241 headers.get("x-portkey-key").map(|s| s.as_str()),
1242 Some("portkey-secret")
1243 );
1244 assert!(
1249 headers.get("authorization").is_none(),
1250 "provider_to_models must not synthesize the Bearer; resolve does"
1251 );
1252 }
1253
1254 #[test]
1255 fn provider_to_models_supports_openai_completions() {
1256 let config: ModelsConfig = serde_json::from_str(
1257 r#"{
1258 "providers": {
1259 "oai": {
1260 "api": "openai-completions",
1261 "baseUrl": "https://gateway.example.com/v1",
1262 "apiKey": "secret",
1263 "models": [{"id":"gpt-test","maxTokens":4096}]
1264 }
1265 }
1266 }"#,
1267 )
1268 .unwrap();
1269 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1270 assert_eq!(models.len(), 1);
1271 assert_eq!(models[0].api, Api::OpenaiCompletions);
1272 assert_eq!(models[0].provider, "oai");
1273 assert_eq!(models[0].max_tokens, 4096);
1274 assert_eq!(
1275 models[0]
1276 .headers
1277 .as_ref()
1278 .and_then(|headers| headers.get("authorization"))
1279 .map(String::as_str),
1280 Some("Bearer secret")
1281 );
1282 }
1283
1284 #[test]
1285 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1286 let _guard = env_lock().lock().unwrap();
1287 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1288 std::env::set_var(env_name, "env-secret");
1289 let cfg = ProviderConfig {
1290 name: None,
1291 base_url: Some("https://gateway.example.com/v1".into()),
1292 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1293 api: Some("openai-completions".into()),
1294 headers: None,
1295 auth_header: None,
1296 models: vec![ModelDefinition {
1297 id: "fake-model".into(),
1298 name: None,
1299 base_url: None,
1300 reasoning: None,
1301 context_window: None,
1302 max_tokens: None,
1303 input: None,
1304 headers: None,
1305 compat: None,
1306 }],
1307 };
1308 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1309 assert_eq!(
1310 models[0]
1311 .headers
1312 .as_ref()
1313 .and_then(|headers| headers.get("authorization"))
1314 .map(String::as_str),
1315 Some("Bearer env-secret")
1316 );
1317 std::env::remove_var(env_name);
1318 }
1319
1320 #[test]
1321 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1322 let _guard = env_lock().lock().unwrap();
1323 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1324 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1325 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1326 std::env::set_var("OPENAI_API_KEY", "official-key");
1327 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1328 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1329 let config = ProviderConfig {
1330 name: None,
1331 base_url: Some("https://gateway.example.com/v1".into()),
1332 api_key: None,
1333 api: Some("openai-responses".into()),
1334 headers: None,
1335 auth_header: None,
1336 models: Vec::new(),
1337 };
1338
1339 assert_eq!(
1340 openai_provider_api_key("openai", &config).as_deref(),
1341 Some("official-key")
1342 );
1343 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1344 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1345 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1346
1347 restore_env("OPENAI_API_KEY", prev_openai);
1348 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1349 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1350 }
1351
1352 #[test]
1353 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1354 for (provider, env_var) in [
1355 ("ant-ling", "ANT_LING_API_KEY"),
1356 ("qwen-token-plan", "QWEN_TOKEN_PLAN_API_KEY"),
1357 ("qwen-token-plan-cn", "QWEN_TOKEN_PLAN_CN_API_KEY"),
1358 ("qwen-token-plan-individual", "QWEN_TOKEN_PLAN_API_KEY"),
1359 ("openai", "OPENAI_API_KEY"),
1360 ("azure-openai-responses", "AZURE_OPENAI_API_KEY"),
1361 ("nvidia", "NVIDIA_API_KEY"),
1362 ("deepseek", "DEEPSEEK_API_KEY"),
1363 ("google", "GEMINI_API_KEY"),
1364 ("google-vertex", "GOOGLE_CLOUD_API_KEY"),
1365 ("groq", "GROQ_API_KEY"),
1366 ("cerebras", "CEREBRAS_API_KEY"),
1367 ("xai", "XAI_API_KEY"),
1368 ("radius", "RADIUS_API_KEY"),
1369 ("openrouter", "OPENROUTER_API_KEY"),
1370 ("vercel-ai-gateway", "AI_GATEWAY_API_KEY"),
1371 ("zai", "ZAI_API_KEY"),
1372 ("zai-coding-cn", "ZAI_CODING_CN_API_KEY"),
1373 ("mistral", "MISTRAL_API_KEY"),
1374 ("minimax", "MINIMAX_API_KEY"),
1375 ("minimax-cn", "MINIMAX_CN_API_KEY"),
1376 ("moonshotai", "MOONSHOT_API_KEY"),
1377 ("moonshotai-cn", "MOONSHOT_API_KEY"),
1378 ("huggingface", "HF_TOKEN"),
1379 ("fireworks", "FIREWORKS_API_KEY"),
1380 ("together", "TOGETHER_API_KEY"),
1381 ("baseten", "BASETEN_API_KEY"),
1382 ("opencode", "OPENCODE_API_KEY"),
1383 ("opencode-go", "OPENCODE_API_KEY"),
1384 ("kimi-coding", "KIMI_API_KEY"),
1385 ("cloudflare-workers-ai", "CLOUDFLARE_API_KEY"),
1386 ("cloudflare-ai-gateway", "CLOUDFLARE_API_KEY"),
1387 ("xiaomi", "XIAOMI_API_KEY"),
1388 ("xiaomi-token-plan-cn", "XIAOMI_TOKEN_PLAN_CN_API_KEY"),
1389 ("xiaomi-token-plan-ams", "XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
1390 ("xiaomi-token-plan-sgp", "XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
1391 ] {
1392 assert_eq!(
1393 native_provider_api_key_env_var(provider),
1394 Some(env_var),
1395 "unexpected env mapping for {provider}"
1396 );
1397 }
1398
1399 for alias in [
1400 "togetherai",
1401 "perplexity",
1402 "moonshot",
1403 "kimi",
1404 "qwen",
1405 "zhipu",
1406 "OpenAI",
1407 ] {
1408 assert_eq!(
1409 native_provider_api_key_env_var(alias),
1410 None,
1411 "non-native alias {alias} must not inherit credentials"
1412 );
1413 }
1414 }
1415
1416 #[test]
1417 fn malformed_auth_json_is_an_error_not_silent_empty() {
1418 let _cfg = TempConfig::new();
1419 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1420 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1421 }
1422
1423 #[test]
1424 fn agent_dir_nests_under_agent_by_default() {
1425 let _guard = env_lock().lock().unwrap();
1429 let prev = std::env::var_os(CONFIG_DIR_ENV);
1430 std::env::remove_var(CONFIG_DIR_ENV);
1431 let dir = agent_dir().unwrap();
1432 restore_env(CONFIG_DIR_ENV, prev);
1433 assert!(dir.ends_with("agent"));
1434 assert!(dir
1435 .parent()
1436 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1437 .unwrap_or(false));
1438 }
1439
1440 #[test]
1441 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1442 let tmp = tempfile::TempDir::new().unwrap();
1446 let root = tmp.path().to_path_buf();
1447 let agent = root.join("agent");
1448 std::fs::write(root.join("auth.json"), "{}").unwrap();
1449 std::fs::write(root.join("models.json"), "{}").unwrap();
1450 std::fs::write(root.join(".setup_done"), "1").unwrap();
1451 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1452 assert_eq!(moved, 3);
1453 assert!(agent.join("auth.json").exists());
1454 assert!(agent.join("models.json").exists());
1455 assert!(agent.join(".setup_done").exists());
1456 assert!(!root.join("auth.json").exists());
1457 }
1458
1459 #[test]
1460 fn migrate_legacy_layout_noop_when_agent_exists() {
1461 let tmp = tempfile::TempDir::new().unwrap();
1462 let root = tmp.path().to_path_buf();
1463 let agent = root.join("agent");
1464 std::fs::write(root.join("auth.json"), "{}").unwrap();
1465 std::fs::create_dir_all(&agent).unwrap();
1466 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1467 assert_eq!(moved, 0); }
1469
1470 #[test]
1471 fn migrate_legacy_layout_noop_when_no_flat_files() {
1472 let tmp = tempfile::TempDir::new().unwrap();
1473 let root = tmp.path().to_path_buf();
1474 let agent = root.join("agent");
1475 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1476 assert_eq!(moved, 0);
1477 }
1478
1479 #[test]
1480 fn migrate_legacy_layout_public_skips_env_override() {
1481 let _cfg = TempConfig::new();
1484 let moved = migrate_legacy_layout().unwrap();
1485 assert_eq!(moved, 0);
1486 }
1487
1488 #[test]
1489 fn read_trust_missing_file_is_empty() {
1490 let _cfg = TempConfig::new();
1491 assert!(read_trust().unwrap().is_empty());
1492 }
1493
1494 #[test]
1495 fn read_trust_parses_decisions() {
1496 let _cfg = TempConfig::new();
1497 let path = trust_path().unwrap();
1498 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1499 std::fs::write(
1500 &path,
1501 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1502 )
1503 .unwrap();
1504 let store = read_trust().unwrap();
1505 assert_eq!(store.len(), 3);
1506 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1507 assert_eq!(
1508 store.get("/home/me/untrusted").copied().flatten(),
1509 Some(false)
1510 );
1511 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1512 }
1513
1514 #[test]
1515 fn resolve_config_value_literal_passthrough() {
1516 assert_eq!(
1517 resolve_config_value("sk-literal-key", None),
1518 Some("sk-literal-key".into())
1519 );
1520 }
1521
1522 #[test]
1523 fn resolve_config_value_env_var() {
1524 let _guard = env_lock().lock().unwrap();
1525 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1526 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1527 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1528 assert_eq!(
1529 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1530 Some("secret-from-env".into())
1531 );
1532 assert_eq!(
1533 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1534 Some("prefix-secret-from-env-suffix".into())
1535 );
1536 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1538 assert_eq!(
1539 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1540 Some("a-secret-from-env-b-two-c".into())
1541 );
1542 let mut overlay = BTreeMap::new();
1544 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1545 assert_eq!(
1546 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1547 Some("overlay-value".into())
1548 );
1549 restore_env("RPI_TEST_CFG_KEY", prev);
1550 restore_env("RPI_TEST_CFG_KEY2", prev2);
1551 }
1552
1553 #[test]
1554 fn resolve_config_value_unset_env_is_none() {
1555 let _guard = env_lock().lock().unwrap();
1556 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1557 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1558 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1560 assert_eq!(
1561 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1562 None
1563 );
1564 restore_env("RPI_TEST_CFG_ABSENT", prev);
1565 }
1566
1567 #[test]
1568 fn resolve_config_value_dollar_dollar_escapes_literal() {
1569 assert_eq!(
1570 resolve_config_value("price-$$5", None),
1571 Some("price-$5".into())
1572 );
1573 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1574 }
1575
1576 #[test]
1577 fn resolve_config_value_command_runs_shell() {
1578 assert_eq!(
1580 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1581 Some("rpi-cfg-resolved".into())
1582 );
1583 assert_eq!(resolve_config_value_uncached("!false", None), None);
1585 }
1586
1587 #[test]
1588 fn resolve_headers_drops_unresolvable() {
1589 let _guard = env_lock().lock().unwrap();
1590 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1591 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1592 let mut h = BTreeMap::new();
1593 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1594 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1595 h.insert("x-literal".into(), "literal-value".into());
1596 let resolved = resolve_headers(&h, None);
1597 assert_eq!(resolved.len(), 2);
1598 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1599 assert_eq!(
1600 resolved.get("x-literal").map(|s| s.as_str()),
1601 Some("literal-value")
1602 );
1603 assert!(!resolved.contains_key("x-unset"));
1604 restore_env("RPI_TEST_HDR_SET", prev);
1605 }
1606
1607 #[test]
1608 fn agent_dir_respects_env_override() {
1609 let _guard = env_lock().lock().unwrap();
1610 let prev = std::env::var_os(CONFIG_DIR_ENV);
1611 let tmp = tempfile::TempDir::new().unwrap();
1612 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1613 let dir = agent_dir().unwrap();
1614 restore_env(CONFIG_DIR_ENV, prev);
1615 assert_eq!(dir, tmp.path());
1616 }
1617
1618 #[test]
1619 fn relative_override_is_rejected() {
1620 let _guard = env_lock().lock().unwrap();
1621 let prev = std::env::var_os(CONFIG_DIR_ENV);
1622 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1623 let err = agent_dir().unwrap_err();
1624 restore_env(CONFIG_DIR_ENV, prev);
1625 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1626 }
1627
1628 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1630 match prev {
1631 Some(v) => std::env::set_var(name, v),
1632 None => std::env::remove_var(name),
1633 }
1634 }
1635}