Skip to main content

navi_core/
credentials.rs

1use crate::ProviderId;
2use crate::config::{ProviderConfig, canonical_provider_id, model_can_run_publicly};
3use anyhow::{Context, Result};
4use directories::BaseDirs;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13struct CredentialsFile {
14    #[serde(default)]
15    ignored_providers: Vec<String>,
16    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
17    project_accounts: HashMap<String, HashMap<String, String>>,
18    #[serde(flatten)]
19    providers: HashMap<String, ProviderCredentials>,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23struct ProviderCredentials {
24    #[serde(default, skip_serializing_if = "String::is_empty")]
25    api_key: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    commandcode: Option<CommandCodeCredentialMetadata>,
28    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29    accounts: BTreeMap<String, CredentialAccount>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    default_account: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36struct CredentialAccount {
37    api_key: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    label: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    commandcode: Option<CommandCodeCredentialMetadata>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    authenticated_at: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    oauth_api_kind: Option<String>,
46    /// OAuth refresh token (e.g. xAI Grok CLI session).
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    oauth_refresh_token: Option<String>,
49    /// Unix epoch seconds when the access token expires.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    oauth_expires_at: Option<i64>,
52}
53
54/// OAuth credential kinds that can be used as model API Bearer tokens.
55pub const XAI_GROK_CLI_OAUTH_KIND: &str = "xai-grok-cli";
56
57/// Returns true when an OAuth credential kind is usable for model API calls.
58pub fn is_model_usable_oauth_kind(kind: &str) -> bool {
59    kind == XAI_GROK_CLI_OAUTH_KIND
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CommandCodeCredentialMetadata {
65    pub user_id: String,
66    pub user_name: String,
67    pub key_name: String,
68    pub authenticated_at: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct CredentialAccountInfo {
74    pub account_id: String,
75    pub label: String,
76    pub is_default: bool,
77    pub is_project_selected: bool,
78    pub commandcode: Option<CommandCodeCredentialMetadata>,
79}
80
81const DEFAULT_ACCOUNT_ID: &str = "default";
82
83impl ProviderCredentials {
84    fn default_account_id(&self) -> Option<String> {
85        self.default_account
86            .clone()
87            .or_else(|| self.accounts.keys().next().cloned())
88            .or_else(|| (!self.api_key.is_empty()).then(|| DEFAULT_ACCOUNT_ID.to_string()))
89    }
90
91    fn default_api_key(&self) -> Option<String> {
92        self.default_account_id()
93            .and_then(|account_id| self.account_api_key(&account_id))
94            .or_else(|| (!self.api_key.is_empty()).then(|| self.api_key.clone()))
95    }
96
97    fn default_oauth_api_kind(&self) -> Option<String> {
98        self.default_account_id()
99            .and_then(|account_id| self.accounts.get(&account_id))
100            .and_then(|account| account.oauth_api_kind.clone())
101    }
102
103    fn account_api_key(&self, account_id: &str) -> Option<String> {
104        if account_id == DEFAULT_ACCOUNT_ID && !self.api_key.is_empty() {
105            return Some(self.api_key.clone());
106        }
107        self.accounts
108            .get(account_id)
109            .map(|account| account.api_key.clone())
110    }
111
112    fn default_model_api_key(&self) -> Option<String> {
113        self.default_account_id()
114            .and_then(|account_id| self.account_model_api_key(&account_id))
115            .or_else(|| {
116                if !self.api_key.is_empty()
117                    && self
118                        .default_oauth_api_kind()
119                        .as_deref()
120                        .is_none_or(|kind| is_model_usable_oauth_kind(kind))
121                {
122                    Some(self.api_key.clone())
123                } else {
124                    None
125                }
126            })
127    }
128
129    fn account_model_api_key(&self, account_id: &str) -> Option<String> {
130        if account_id == DEFAULT_ACCOUNT_ID
131            && !self.api_key.is_empty()
132            && self
133                .default_oauth_api_kind()
134                .as_deref()
135                .is_none_or(|kind| is_model_usable_oauth_kind(kind))
136        {
137            return Some(self.api_key.clone());
138        }
139        self.accounts
140            .get(account_id)
141            .filter(|account| {
142                account
143                    .oauth_api_kind
144                    .as_deref()
145                    .is_none_or(is_model_usable_oauth_kind)
146            })
147            .map(|account| account.api_key.clone())
148    }
149
150    fn has_oauth_credential(&self) -> bool {
151        self.default_oauth_api_kind().is_some()
152            || self
153                .accounts
154                .values()
155                .any(|account| account.oauth_api_kind.is_some())
156    }
157
158    /// True when the only stored credential is OAuth that cannot call model APIs.
159    fn has_non_model_oauth_only(&self) -> bool {
160        self.has_oauth_credential() && self.default_model_api_key().is_none()
161    }
162
163    fn default_oauth_refresh_token(&self) -> Option<String> {
164        self.default_account_id()
165            .and_then(|account_id| self.accounts.get(&account_id))
166            .and_then(|account| account.oauth_refresh_token.clone())
167    }
168
169    fn default_oauth_expires_at(&self) -> Option<i64> {
170        self.default_account_id()
171            .and_then(|account_id| self.accounts.get(&account_id))
172            .and_then(|account| account.oauth_expires_at)
173    }
174
175    fn default_commandcode_metadata(&self) -> Option<CommandCodeCredentialMetadata> {
176        self.default_account_id()
177            .and_then(|account_id| {
178                self.accounts
179                    .get(&account_id)
180                    .and_then(|account| account.commandcode.clone())
181            })
182            .or_else(|| self.commandcode.clone())
183    }
184}
185
186/// Where a provider's API key was resolved from.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum CredentialSource {
190    /// An environment variable.
191    Env,
192    /// NAVI's encrypted credential store on disk.
193    Stored,
194    /// An external auth source (e.g. OpenCode auth.json).
195    External,
196    /// The model is free and requires no key.
197    PublicModel,
198}
199
200impl CredentialSource {
201    /// Returns a lowercase string label for this source.
202    pub fn as_str(&self) -> &'static str {
203        match self {
204            Self::Env => "env",
205            Self::Stored => "stored",
206            Self::External => "external",
207            Self::PublicModel => "public-model",
208        }
209    }
210}
211
212/// The resolved credential status for a provider, indicating whether a key is
213/// available and where it came from.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct CredentialStatus {
217    /// Whether a usable credential was found.
218    pub configured: bool,
219    /// The source of the credential, if found.
220    pub source: Option<CredentialSource>,
221    /// Short label for display (e.g. `"env"`, `"stored"`, `"missing"`).
222    pub label: String,
223    /// Optional detail string (e.g. the env var name or auth path).
224    pub detail: Option<String>,
225}
226
227/// Manages API key storage in a TOML credentials file at `<data_dir>/credentials.toml`.
228#[derive(Debug, Clone)]
229pub struct CredentialStore {
230    path: PathBuf,
231}
232
233impl CredentialStore {
234    pub fn new(data_dir: PathBuf) -> Self {
235        Self {
236            path: data_dir.join("credentials.toml"),
237        }
238    }
239
240    /// Returns the path to the credentials file.
241    pub fn path(&self) -> &Path {
242        &self.path
243    }
244
245    /// Reads the stored API key for the given provider, or `None` if not found.
246    pub fn get_api_key(&self, provider_id: &str) -> Option<String> {
247        let provider_id = credential_provider_key(provider_id);
248        let content = fs::read_to_string(&self.path).ok()?;
249        let file: CredentialsFile = toml::from_str(&content).ok()?;
250        file.providers
251            .get(&provider_id)
252            .and_then(ProviderCredentials::default_api_key)
253    }
254
255    /// Reads a stored credential usable for model API calls.
256    ///
257    /// OAuth credentials obtained through OpenAI browser login are excluded:
258    /// they are account/connector tokens, not OpenAI Platform API keys.
259    pub fn get_model_api_key(&self, provider_id: &str) -> Option<String> {
260        let provider_id = credential_provider_key(provider_id);
261        let content = fs::read_to_string(&self.path).ok()?;
262        let file: CredentialsFile = toml::from_str(&content).ok()?;
263        file.providers
264            .get(&provider_id)
265            .and_then(ProviderCredentials::default_model_api_key)
266    }
267
268    /// Returns oauth_api_kind metadata for a stored OAuth token, or `None`.
269    pub fn get_oauth_api_kind(&self, provider_id: &str) -> Option<String> {
270        let provider_id = credential_provider_key(provider_id);
271        let content = fs::read_to_string(&self.path).ok()?;
272        let file: CredentialsFile = toml::from_str(&content).ok()?;
273        file.providers
274            .get(&provider_id)
275            .and_then(ProviderCredentials::default_oauth_api_kind)
276    }
277
278    pub fn get_api_key_for_account(&self, provider_id: &str, account_id: &str) -> Option<String> {
279        let provider_id = credential_provider_key(provider_id);
280        let content = fs::read_to_string(&self.path).ok()?;
281        let file: CredentialsFile = toml::from_str(&content).ok()?;
282        let credentials = file.providers.get(&provider_id)?;
283        credentials.account_api_key(account_id)
284    }
285
286    pub fn get_model_api_key_for_account(
287        &self,
288        provider_id: &str,
289        account_id: &str,
290    ) -> Option<String> {
291        let provider_id = credential_provider_key(provider_id);
292        let content = fs::read_to_string(&self.path).ok()?;
293        let file: CredentialsFile = toml::from_str(&content).ok()?;
294        let credentials = file.providers.get(&provider_id)?;
295        credentials.account_model_api_key(account_id)
296    }
297
298    pub fn has_oauth_credential(&self, provider_id: &str) -> bool {
299        let provider_id = credential_provider_key(provider_id);
300        let content = match fs::read_to_string(&self.path) {
301            Ok(content) => content,
302            Err(_) => return false,
303        };
304        let file: CredentialsFile = match toml::from_str(&content) {
305            Ok(file) => file,
306            Err(_) => return false,
307        };
308        file.providers
309            .get(&provider_id)
310            .is_some_and(ProviderCredentials::has_oauth_credential)
311    }
312
313    pub fn get_project_account(&self, project_dir: &Path, provider_id: &str) -> Option<String> {
314        let provider_id = credential_provider_key(provider_id);
315        let content = fs::read_to_string(&self.path).ok()?;
316        let file: CredentialsFile = toml::from_str(&content).ok()?;
317        file.project_accounts
318            .get(&project_account_key(project_dir))
319            .and_then(|providers| providers.get(&provider_id))
320            .cloned()
321    }
322
323    pub fn set_project_account(
324        &self,
325        project_dir: &Path,
326        provider_id: &str,
327        account_id: &str,
328    ) -> Result<()> {
329        let provider_id = credential_provider_key(provider_id);
330        ensure_private_parent_dir(&self.path)?;
331        let mut file = self.load_file()?;
332        let has_account = file
333            .providers
334            .get(&provider_id)
335            .and_then(|credentials| credentials.account_api_key(account_id))
336            .is_some();
337        if !has_account {
338            anyhow::bail!("unknown account '{account_id}' for provider '{provider_id}'");
339        }
340        if let Some(credentials) = file.providers.get_mut(&provider_id) {
341            credentials.default_account = Some(account_id.to_string());
342        }
343        file.project_accounts
344            .entry(project_account_key(project_dir))
345            .or_default()
346            .insert(provider_id, account_id.to_string());
347        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
348        self.write_content(&content)
349    }
350
351    pub fn list_credential_accounts(
352        &self,
353        provider_id: &str,
354        project_dir: Option<&Path>,
355    ) -> Result<Vec<CredentialAccountInfo>> {
356        let provider_id = credential_provider_key(provider_id);
357        let file = self.load_file()?;
358        let Some(credentials) = file.providers.get(&provider_id) else {
359            return Ok(Vec::new());
360        };
361        let default_account = credentials.default_account_id();
362        let project_account = project_dir.and_then(|path| {
363            file.project_accounts
364                .get(&project_account_key(path))
365                .and_then(|providers| providers.get(&provider_id))
366                .cloned()
367        });
368        let selected_account = project_account.as_deref().or(default_account.as_deref());
369        let mut accounts = credentials.accounts.clone();
370        if accounts.is_empty() && !credentials.api_key.is_empty() {
371            accounts.insert(
372                DEFAULT_ACCOUNT_ID.to_string(),
373                CredentialAccount {
374                    api_key: credentials.api_key.clone(),
375                    label: Some("Default".to_string()),
376                    commandcode: credentials.commandcode.clone(),
377                    authenticated_at: credentials
378                        .commandcode
379                        .as_ref()
380                        .map(|metadata| metadata.authenticated_at.clone()),
381                    oauth_api_kind: None,
382                    oauth_refresh_token: None,
383                    oauth_expires_at: None,
384                },
385            );
386        }
387        Ok(accounts
388            .into_iter()
389            .map(|(account_id, account)| CredentialAccountInfo {
390                label: account.label.unwrap_or_else(|| account_id.clone()),
391                is_default: default_account.as_deref() == Some(account_id.as_str()),
392                is_project_selected: selected_account == Some(account_id.as_str()),
393                commandcode: account.commandcode,
394                account_id,
395            })
396            .collect())
397    }
398
399    /// Returns `true` if the user explicitly ignored this provider (e.g. by deleting an env-provided key).
400    pub fn is_ignored(&self, provider_id: &str) -> bool {
401        let provider_id = credential_provider_key(provider_id);
402        let content = match fs::read_to_string(&self.path) {
403            Ok(c) => c,
404            Err(_) => return false,
405        };
406        let file: CredentialsFile = match toml::from_str(&content) {
407            Ok(f) => f,
408            Err(_) => return false,
409        };
410        file.ignored_providers.contains(&provider_id)
411    }
412
413    /// Returns the list of provider ids that have stored API keys.
414    pub fn list_api_key_providers(&self) -> Result<Vec<String>> {
415        let mut providers = self.load_file()?.providers.into_keys().collect::<Vec<_>>();
416        providers.sort();
417        Ok(providers)
418    }
419
420    /// Reads an API key from OpenCode's auth.json file, if it exists.
421    pub fn get_opencode_api_key(&self) -> Option<String> {
422        if let Ok(content) = std::env::var("OPENCODE_AUTH_CONTENT")
423            && let Some(key) = opencode_key_from_auth_content(&content)
424        {
425            return Some(key);
426        }
427
428        opencode_auth_paths()
429            .into_iter()
430            .find_map(|path| opencode_key_from_auth_file(&path))
431    }
432
433    /// Stores an API key for the given provider, creating the credentials file
434    /// if needed.
435    ///
436    /// **Note:** this replaces the provider's credential map with a single
437    /// default account. Prefer [`Self::add_api_key_account`] for multi-account.
438    pub fn set_api_key(&self, provider_id: &str, api_key: &str) -> Result<()> {
439        let provider_id = credential_provider_key(provider_id);
440        ensure_private_parent_dir(&self.path)?;
441
442        let mut file = self.load_file()?;
443        file.ignored_providers.retain(|p| p != &provider_id);
444
445        file.providers.insert(
446            provider_id,
447            ProviderCredentials {
448                api_key: api_key.to_string(),
449                commandcode: None,
450                accounts: BTreeMap::from([(
451                    DEFAULT_ACCOUNT_ID.to_string(),
452                    CredentialAccount {
453                        api_key: api_key.to_string(),
454                        label: Some("Default".to_string()),
455                        commandcode: None,
456                        authenticated_at: None,
457                        oauth_api_kind: None,
458                        oauth_refresh_token: None,
459                        oauth_expires_at: None,
460                    },
461                )]),
462                default_account: Some(DEFAULT_ACCOUNT_ID.to_string()),
463            },
464        );
465
466        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
467        self.write_content(&content)?;
468
469        Ok(())
470    }
471
472    /// Add (or update) one API-key account without wiping sibling accounts.
473    ///
474    /// Returns the `account_id`. When `account_id` is `None`, a new id is generated.
475    pub fn add_api_key_account(
476        &self,
477        provider_id: &str,
478        api_key: &str,
479        label: Option<&str>,
480        account_id: Option<&str>,
481    ) -> Result<String> {
482        let provider_id = credential_provider_key(provider_id);
483        let api_key = api_key.trim();
484        if api_key.is_empty() {
485            anyhow::bail!("api key cannot be empty");
486        }
487        ensure_private_parent_dir(&self.path)?;
488
489        let mut file = self.load_file()?;
490        file.ignored_providers.retain(|p| p != &provider_id);
491
492        let credentials = file.providers.entry(provider_id.clone()).or_default();
493        let id = account_id
494            .map(str::trim)
495            .filter(|s| !s.is_empty())
496            .map(str::to_string)
497            .unwrap_or_else(|| {
498                // Stable-ish id from key suffix so re-adding the same key updates.
499                let suffix: String = api_key
500                    .chars()
501                    .rev()
502                    .take(6)
503                    .collect::<String>()
504                    .chars()
505                    .rev()
506                    .collect();
507                format!("acct-{}", suffix)
508            });
509        let display = label
510            .map(str::trim)
511            .filter(|s| !s.is_empty())
512            .map(str::to_string)
513            .unwrap_or_else(|| {
514                if id == DEFAULT_ACCOUNT_ID {
515                    "Default".to_string()
516                } else {
517                    format!("Account {id}")
518                }
519            });
520
521        credentials.accounts.insert(
522            id.clone(),
523            CredentialAccount {
524                api_key: api_key.to_string(),
525                label: Some(display),
526                commandcode: None,
527                authenticated_at: Some(current_unix_timestamp_string()),
528                oauth_api_kind: None,
529                oauth_refresh_token: None,
530                oauth_expires_at: None,
531            },
532        );
533        if credentials.default_account.is_none() {
534            credentials.default_account = Some(id.clone());
535        }
536        // Keep legacy top-level key in sync with the default account for older readers.
537        if credentials.default_account.as_deref() == Some(id.as_str())
538            || credentials.api_key.is_empty()
539        {
540            credentials.api_key = api_key.to_string();
541            if credentials.default_account.is_none() {
542                credentials.default_account = Some(id.clone());
543            }
544        }
545
546        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
547        self.write_content(&content)?;
548        Ok(id)
549    }
550
551    /// Set the default account for a provider (global selection).
552    pub fn set_default_account(&self, provider_id: &str, account_id: &str) -> Result<()> {
553        let provider_id = credential_provider_key(provider_id);
554        ensure_private_parent_dir(&self.path)?;
555        let mut file = self.load_file()?;
556        let Some(credentials) = file.providers.get_mut(&provider_id) else {
557            anyhow::bail!("unknown provider '{provider_id}'");
558        };
559        if credentials.account_api_key(account_id).is_none() {
560            anyhow::bail!("unknown account '{account_id}' for provider '{provider_id}'");
561        }
562        credentials.default_account = Some(account_id.to_string());
563        if let Some(key) = credentials.account_api_key(account_id) {
564            credentials.api_key = key;
565        }
566        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
567        self.write_content(&content)
568    }
569    /// Stores an API key with oauth_api_kind metadata (e.g. "chat-completions"
570    /// for OAuth tokens that only work with Chat Completions API).
571    pub fn set_oauth_credential(
572        &self,
573        provider_id: &str,
574        api_key: &str,
575        oauth_api_kind: &str,
576    ) -> Result<()> {
577        self.set_oauth_credential_full(provider_id, api_key, oauth_api_kind, None, None)
578    }
579
580    /// Stores an OAuth credential with optional refresh token and expiry.
581    pub fn set_oauth_credential_full(
582        &self,
583        provider_id: &str,
584        api_key: &str,
585        oauth_api_kind: &str,
586        refresh_token: Option<&str>,
587        expires_at: Option<i64>,
588    ) -> Result<()> {
589        let provider_id = credential_provider_key(provider_id);
590        ensure_private_parent_dir(&self.path)?;
591
592        let mut file = self.load_file()?;
593        file.ignored_providers.retain(|p| p != &provider_id);
594
595        let label = if is_model_usable_oauth_kind(oauth_api_kind) {
596            "Grok OAuth".to_string()
597        } else {
598            "Default".to_string()
599        };
600
601        file.providers.insert(
602            provider_id,
603            ProviderCredentials {
604                api_key: api_key.to_string(),
605                commandcode: None,
606                accounts: BTreeMap::from([(
607                    DEFAULT_ACCOUNT_ID.to_string(),
608                    CredentialAccount {
609                        api_key: api_key.to_string(),
610                        label: Some(label),
611                        commandcode: None,
612                        authenticated_at: Some(current_unix_timestamp_string()),
613                        oauth_api_kind: Some(oauth_api_kind.to_string()),
614                        oauth_refresh_token: refresh_token
615                            .filter(|value| !value.is_empty())
616                            .map(str::to_string),
617                        oauth_expires_at: expires_at,
618                    },
619                )]),
620                default_account: Some(DEFAULT_ACCOUNT_ID.to_string()),
621            },
622        );
623
624        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
625        self.write_content(&content)?;
626
627        Ok(())
628    }
629
630    /// Returns the stored OAuth refresh token, if any.
631    pub fn get_oauth_refresh_token(&self, provider_id: &str) -> Option<String> {
632        let provider_id = credential_provider_key(provider_id);
633        let content = fs::read_to_string(&self.path).ok()?;
634        let file: CredentialsFile = toml::from_str(&content).ok()?;
635        file.providers
636            .get(&provider_id)
637            .and_then(ProviderCredentials::default_oauth_refresh_token)
638    }
639
640    /// Returns the stored OAuth access-token expiry (unix seconds), if any.
641    pub fn get_oauth_expires_at(&self, provider_id: &str) -> Option<i64> {
642        let provider_id = credential_provider_key(provider_id);
643        let content = fs::read_to_string(&self.path).ok()?;
644        let file: CredentialsFile = toml::from_str(&content).ok()?;
645        file.providers
646            .get(&provider_id)
647            .and_then(ProviderCredentials::default_oauth_expires_at)
648    }
649
650    /// Stores a Command Code OAuth-created API key and callback metadata.
651    pub fn set_commandcode_credential(
652        &self,
653        provider_id: &str,
654        api_key: &str,
655        metadata: CommandCodeCredentialMetadata,
656    ) -> Result<String> {
657        let provider_id = credential_provider_key(provider_id);
658        ensure_private_parent_dir(&self.path)?;
659
660        let mut file = self.load_file()?;
661        file.ignored_providers.retain(|p| p != &provider_id);
662        let account_id = commandcode_account_id(&metadata);
663        let credentials = file.providers.entry(provider_id).or_default();
664        credentials.accounts.insert(
665            account_id.clone(),
666            CredentialAccount {
667                api_key: api_key.to_string(),
668                label: Some(commandcode_account_label(&metadata)),
669                authenticated_at: Some(metadata.authenticated_at.clone()),
670                commandcode: Some(metadata),
671                oauth_api_kind: None,
672                oauth_refresh_token: None,
673                oauth_expires_at: None,
674            },
675        );
676        if credentials.default_account.is_none() && credentials.api_key.is_empty() {
677            credentials.default_account = Some(account_id.clone());
678        }
679
680        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
681        self.write_content(&content)?;
682        Ok(account_id)
683    }
684
685    pub fn get_commandcode_metadata(
686        &self,
687        provider_id: &str,
688    ) -> Option<CommandCodeCredentialMetadata> {
689        let provider_id = credential_provider_key(provider_id);
690        let content = fs::read_to_string(&self.path).ok()?;
691        let file: CredentialsFile = toml::from_str(&content).ok()?;
692        file.providers
693            .get(&provider_id)
694            .and_then(ProviderCredentials::default_commandcode_metadata)
695    }
696
697    pub fn delete_credential_account(&self, provider_id: &str, account_id: &str) -> Result<bool> {
698        let provider_id = credential_provider_key(provider_id);
699        let mut file = self.load_file().unwrap_or_default();
700        let Some(credentials) = file.providers.get_mut(&provider_id) else {
701            return Ok(false);
702        };
703        let removed = credentials.accounts.remove(account_id).is_some();
704        if !removed {
705            return Ok(false);
706        }
707        if credentials.default_account.as_deref() == Some(account_id) {
708            credentials.default_account = credentials.accounts.keys().next().cloned();
709        }
710        for providers in file.project_accounts.values_mut() {
711            if providers
712                .get(&provider_id)
713                .is_some_and(|selected| selected == account_id)
714            {
715                providers.remove(&provider_id);
716            }
717        }
718        ensure_private_parent_dir(&self.path)?;
719        let content = toml::to_string_pretty(&file).context("failed to serialize credentials")?;
720        self.write_content(&content)?;
721        Ok(true)
722    }
723
724    /// Deletes a stored API key and adds the provider to the ignored list.
725    /// Returns `true` if a stored key was removed.
726    pub fn delete_api_key(&self, provider_id: &str) -> Result<bool> {
727        let provider_id = credential_provider_key(provider_id);
728        let mut file = self.load_file().unwrap_or_default();
729        let removed = file.providers.remove(&provider_id).is_some();
730        for providers in file.project_accounts.values_mut() {
731            providers.remove(&provider_id);
732        }
733        let mut ignored = false;
734        if !file.ignored_providers.contains(&provider_id) {
735            file.ignored_providers.push(provider_id);
736            ignored = true;
737        }
738
739        if removed || ignored {
740            ensure_private_parent_dir(&self.path)?;
741            let content =
742                toml::to_string_pretty(&file).context("failed to serialize credentials")?;
743            self.write_content(&content)?;
744        }
745        Ok(removed)
746    }
747
748    /// Resolve API key for a provider: env var first (explicit override), then stored credential.
749    /// Resolves an API key by checking the environment variable first, then
750    /// the stored credential. Returns `None` if neither is set.
751    pub fn resolve_api_key(&self, provider_id: &str, env_var: &str) -> Option<String> {
752        if let Ok(key) = std::env::var(env_var)
753            && !key.is_empty()
754        {
755            return Some(key);
756        }
757        self.get_api_key(provider_id)
758    }
759
760    fn load_file(&self) -> Result<CredentialsFile> {
761        if !self.path.exists() {
762            return Ok(CredentialsFile::default());
763        }
764
765        let content = fs::read_to_string(&self.path).context("failed to read credentials file")?;
766        Ok(toml::from_str(&content).unwrap_or_default())
767    }
768
769    fn write_content(&self, content: &str) -> Result<()> {
770        fs::write(&self.path, content)
771            .with_context(|| format!("failed to write {}", self.path.display()))?;
772
773        // Restrict permissions so only the owner can read the credentials file.
774        #[cfg(unix)]
775        {
776            use std::os::unix::fs::PermissionsExt;
777            fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
778        }
779
780        Ok(())
781    }
782}
783
784/// Resolves the API key for a provider by id, using the configured env var from
785/// the provider's `ProviderConfig`. Returns `None` if no key is found.
786pub fn resolve_provider_api_key(
787    credential_store: &CredentialStore,
788    provider_config: &ProviderConfig,
789    requested_provider_id: &str,
790) -> Option<String> {
791    if credential_store.is_ignored(&provider_config.id) {
792        return None;
793    }
794
795    provider_env_api_key_for_config(provider_config)
796        .or_else(|| opencode_auth_json_api_key(credential_store, &provider_config.id))
797        .or_else(|| credential_store.get_model_api_key(&provider_config.id))
798        .or_else(|| {
799            if requested_provider_id != provider_config.id {
800                credential_store.get_model_api_key(requested_provider_id)
801            } else {
802                None
803            }
804        })
805        // Fallback: reuse xAI CLI session (~/.grok/auth.json) when NAVI
806        // has no stored/env credential yet.
807        .or_else(|| grok_auth_json_access_token(credential_store, &provider_config.id))
808        .or_else(|| {
809            if requested_provider_id != provider_config.id {
810                grok_auth_json_access_token(credential_store, requested_provider_id)
811            } else {
812                None
813            }
814        })
815}
816
817pub fn resolve_provider_api_key_for_project(
818    credential_store: &CredentialStore,
819    provider_config: &ProviderConfig,
820    requested_provider_id: &str,
821    project_dir: &Path,
822) -> Option<String> {
823    if credential_store.is_ignored(&provider_config.id) {
824        return None;
825    }
826
827    credential_store
828        .get_project_account(project_dir, &provider_config.id)
829        .and_then(|account_id| {
830            credential_store.get_model_api_key_for_account(&provider_config.id, &account_id)
831        })
832        .or_else(|| {
833            if requested_provider_id != provider_config.id {
834                credential_store
835                    .get_project_account(project_dir, requested_provider_id)
836                    .and_then(|account_id| {
837                        credential_store
838                            .get_model_api_key_for_account(requested_provider_id, &account_id)
839                    })
840            } else {
841                None
842            }
843        })
844        .or_else(|| {
845            resolve_provider_api_key(credential_store, provider_config, requested_provider_id)
846        })
847}
848
849/// Resolves the full [`CredentialStatus`] for a provider, including source
850/// label, env var name, and whether the model is public.
851pub fn resolve_provider_credential_status(
852    credential_store: &CredentialStore,
853    provider_config: &ProviderConfig,
854    requested_provider_id: &str,
855    model: Option<&str>,
856) -> CredentialStatus {
857    if credential_store.is_ignored(&provider_config.id) {
858        return CredentialStatus {
859            configured: false,
860            source: None,
861            label: "ignored".to_string(),
862            detail: Some("disabled by user".to_string()),
863        };
864    }
865
866    if let Some(env_var) = provider_env_var_for_config(provider_config) {
867        return CredentialStatus {
868            configured: true,
869            source: Some(CredentialSource::Env),
870            label: "env".to_string(),
871            detail: Some(env_var),
872        };
873    }
874
875    if ProviderId::from_config_id(&provider_config.id).is_opencode_family()
876        && credential_store.get_opencode_api_key().is_some()
877    {
878        return CredentialStatus {
879            configured: true,
880            source: Some(CredentialSource::External),
881            label: "opencode".to_string(),
882            detail: Some("OpenCode auth.json".to_string()),
883        };
884    }
885
886    if credential_store
887        .get_model_api_key(&provider_config.id)
888        .is_some()
889        || (requested_provider_id != provider_config.id
890            && credential_store
891                .get_model_api_key(requested_provider_id)
892                .is_some())
893    {
894        let oauth_kind = credential_store
895            .get_oauth_api_kind(&provider_config.id)
896            .or_else(|| {
897                (requested_provider_id != provider_config.id)
898                    .then(|| credential_store.get_oauth_api_kind(requested_provider_id))
899                    .flatten()
900            });
901        let (label, detail) = if oauth_kind.as_deref() == Some(XAI_GROK_CLI_OAUTH_KIND) {
902            ("oauth".to_string(), Some("xAI Grok OAuth".to_string()))
903        } else {
904            ("stored".to_string(), Some("stored credential".to_string()))
905        };
906        return CredentialStatus {
907            configured: true,
908            source: Some(CredentialSource::Stored),
909            label,
910            detail,
911        };
912    }
913
914    if is_xai_provider_id(&provider_config.id)
915        && grok_auth_json_access_token(credential_store, &provider_config.id).is_some()
916    {
917        return CredentialStatus {
918            configured: true,
919            source: Some(CredentialSource::External),
920            label: "grok".to_string(),
921            detail: Some("Grok CLI auth.json".to_string()),
922        };
923    }
924
925    if is_non_model_oauth_only(credential_store, &provider_config.id)
926        || (requested_provider_id != provider_config.id
927            && is_non_model_oauth_only(credential_store, requested_provider_id))
928    {
929        return CredentialStatus {
930            configured: false,
931            source: None,
932            label: "oauth-only".to_string(),
933            detail: Some(
934                "stored OAuth credential is not usable for model API calls; configure an API key"
935                    .to_string(),
936            ),
937        };
938    }
939
940    if let Some(model) = model
941        && (model_can_run_publicly(requested_provider_id, model)
942            || model_can_run_publicly(&provider_config.id, model))
943    {
944        return CredentialStatus {
945            configured: true,
946            source: Some(CredentialSource::PublicModel),
947            label: "public".to_string(),
948            detail: Some("free model access without key".to_string()),
949        };
950    }
951
952    CredentialStatus {
953        configured: false,
954        source: None,
955        label: "missing".to_string(),
956        detail: None,
957    }
958}
959
960fn provider_env_api_key_for_config(provider_config: &ProviderConfig) -> Option<String> {
961    provider_env_var_for_config(provider_config).and_then(|env_var| provider_env_api_key(&env_var))
962}
963
964fn provider_env_var_for_config(provider_config: &ProviderConfig) -> Option<String> {
965    provider_env_vars_for_config(provider_config)
966        .into_iter()
967        .find(|env_var| provider_env_api_key(env_var).is_some())
968}
969
970fn provider_env_vars_for_config(provider_config: &ProviderConfig) -> Vec<String> {
971    if provider_config.id == ProviderId::COMMANDCODE {
972        let mut env_vars = vec![
973            "COMMAND_CODE_API_KEY".to_string(),
974            "CMD_API_KEY".to_string(),
975        ];
976        if !env_vars
977            .iter()
978            .any(|env_var| env_var == &provider_config.api_key_env)
979        {
980            env_vars.push(provider_config.api_key_env.clone());
981        }
982        return env_vars;
983    }
984
985    if !ProviderId::from_config_id(&provider_config.id).is_opencode_family() {
986        return vec![provider_config.api_key_env.clone()];
987    }
988
989    let mut env_vars = vec![
990        "OPENCODE_API_KEY".to_string(),
991        "OPENCODE_ZEN_API_KEY".to_string(),
992    ];
993    if !env_vars
994        .iter()
995        .any(|env_var| env_var == &provider_config.api_key_env)
996    {
997        env_vars.push(provider_config.api_key_env.clone());
998    }
999    env_vars
1000}
1001
1002fn opencode_auth_json_api_key(
1003    credential_store: &CredentialStore,
1004    provider_id: &str,
1005) -> Option<String> {
1006    if ProviderId::from_config_id(provider_id).is_opencode_family() {
1007        credential_store.get_opencode_api_key()
1008    } else {
1009        None
1010    }
1011}
1012
1013fn is_non_model_oauth_only(credential_store: &CredentialStore, provider_id: &str) -> bool {
1014    let provider_id = credential_provider_key(provider_id);
1015    let content = match fs::read_to_string(credential_store.path()) {
1016        Ok(content) => content,
1017        Err(_) => return false,
1018    };
1019    let file: CredentialsFile = match toml::from_str(&content) {
1020        Ok(file) => file,
1021        Err(_) => return false,
1022    };
1023    file.providers
1024        .get(&provider_id)
1025        .is_some_and(ProviderCredentials::has_non_model_oauth_only)
1026}
1027
1028fn is_xai_provider_id(provider_id: &str) -> bool {
1029    credential_provider_key(provider_id) == ProviderId::XAI
1030}
1031
1032/// Reads a still-valid access token from Grok CLI's `~/.grok/auth.json`.
1033fn grok_auth_json_access_token(
1034    credential_store: &CredentialStore,
1035    provider_id: &str,
1036) -> Option<String> {
1037    if !is_xai_provider_id(provider_id) || credential_store.is_ignored(provider_id) {
1038        return None;
1039    }
1040
1041    if let Ok(content) = std::env::var("GROK_AUTH_CONTENT")
1042        && let Some(key) = grok_key_from_auth_content(&content)
1043    {
1044        return Some(key);
1045    }
1046
1047    grok_auth_paths()
1048        .into_iter()
1049        .find_map(|path| grok_key_from_auth_file(&path))
1050}
1051
1052fn grok_auth_paths() -> Vec<PathBuf> {
1053    let mut paths = Vec::new();
1054    if let Ok(home) = std::env::var("HOME")
1055        && !home.is_empty()
1056    {
1057        paths.push(PathBuf::from(home).join(".grok").join("auth.json"));
1058    }
1059    if let Some(base_dirs) = BaseDirs::new() {
1060        paths.push(base_dirs.home_dir().join(".grok").join("auth.json"));
1061    }
1062    paths.sort();
1063    paths.dedup();
1064    paths
1065}
1066
1067fn grok_key_from_auth_file(path: &Path) -> Option<String> {
1068    let content = fs::read_to_string(path).ok()?;
1069    grok_key_from_auth_content(&content)
1070}
1071
1072fn grok_key_from_auth_content(content: &str) -> Option<String> {
1073    let data: Value = serde_json::from_str(content).ok()?;
1074    let now = current_unix_timestamp_secs();
1075    let mut best: Option<(i64, String)> = None;
1076
1077    for (key, entry) in data.as_object()? {
1078        if !key.contains("auth.x.ai") {
1079            continue;
1080        }
1081        let Some(access) = entry
1082            .get("key")
1083            .or_else(|| entry.get("access_token"))
1084            .and_then(Value::as_str)
1085            .map(str::trim)
1086            .filter(|value| !value.is_empty())
1087        else {
1088            continue;
1089        };
1090
1091        let expires_at = entry
1092            .get("expires_at")
1093            .and_then(Value::as_str)
1094            .and_then(parse_rfc3339_to_unix)
1095            .unwrap_or(i64::MAX);
1096
1097        if expires_at + 300 < now {
1098            continue;
1099        }
1100
1101        match &best {
1102            Some((best_exp, _)) if *best_exp >= expires_at => {}
1103            _ => best = Some((expires_at, access.to_string())),
1104        }
1105    }
1106
1107    best.map(|(_, token)| token)
1108}
1109
1110fn parse_rfc3339_to_unix(value: &str) -> Option<i64> {
1111    let trimmed = value.trim().trim_end_matches('Z');
1112    let (date, time) = trimmed.split_once('T')?;
1113    let mut date_parts = date.split('-');
1114    let year: i64 = date_parts.next()?.parse().ok()?;
1115    let month: i64 = date_parts.next()?.parse().ok()?;
1116    let day: i64 = date_parts.next()?.parse().ok()?;
1117    let time = time.split(['.', '+']).next()?;
1118    let mut time_parts = time.split(':');
1119    let hour: i64 = time_parts.next()?.parse().ok()?;
1120    let minute: i64 = time_parts.next()?.parse().ok()?;
1121    let second: i64 = time_parts.next()?.parse().ok()?;
1122
1123    let y = if month <= 2 { year - 1 } else { year };
1124    let era = y.div_euclid(400);
1125    let yoe = y.rem_euclid(400);
1126    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
1127    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1128    let days = era * 146097 + doe - 719468;
1129    Some(days * 86_400 + hour * 3600 + minute * 60 + second)
1130}
1131
1132fn current_unix_timestamp_secs() -> i64 {
1133    std::time::SystemTime::now()
1134        .duration_since(std::time::UNIX_EPOCH)
1135        .unwrap_or_default()
1136        .as_secs() as i64
1137}
1138
1139fn current_unix_timestamp_string() -> String {
1140    current_unix_timestamp_secs().to_string()
1141}
1142
1143fn project_account_key(project_dir: &Path) -> String {
1144    project_dir
1145        .canonicalize()
1146        .unwrap_or_else(|_| project_dir.to_path_buf())
1147        .to_string_lossy()
1148        .to_string()
1149}
1150
1151fn credential_provider_key(provider_id: &str) -> String {
1152    canonical_provider_id(provider_id).to_string()
1153}
1154
1155fn commandcode_account_id(metadata: &CommandCodeCredentialMetadata) -> String {
1156    let base = if metadata.user_name.trim().is_empty() {
1157        format!("{}-{}", metadata.user_id, metadata.key_name)
1158    } else {
1159        format!("{}-{}", metadata.user_name, metadata.key_name)
1160    };
1161    slugify_account_id(&base)
1162}
1163
1164fn commandcode_account_label(metadata: &CommandCodeCredentialMetadata) -> String {
1165    if metadata.key_name.trim().is_empty() {
1166        metadata.user_name.clone()
1167    } else if metadata.user_name.trim().is_empty() {
1168        metadata.key_name.clone()
1169    } else {
1170        format!("{} ({})", metadata.user_name, metadata.key_name)
1171    }
1172}
1173
1174fn slugify_account_id(value: &str) -> String {
1175    let mut slug = String::new();
1176    let mut last_dash = false;
1177    for ch in value.chars().flat_map(char::to_lowercase) {
1178        if ch.is_ascii_alphanumeric() {
1179            slug.push(ch);
1180            last_dash = false;
1181        } else if !last_dash {
1182            slug.push('-');
1183            last_dash = true;
1184        }
1185    }
1186    let slug = slug.trim_matches('-').to_string();
1187    if slug.is_empty() {
1188        DEFAULT_ACCOUNT_ID.to_string()
1189    } else {
1190        slug
1191    }
1192}
1193
1194fn provider_env_api_key(env_var: &str) -> Option<String> {
1195    let key = std::env::var(env_var).ok()?;
1196    if key.is_empty() { None } else { Some(key) }
1197}
1198
1199fn opencode_auth_paths() -> Vec<PathBuf> {
1200    let mut paths = Vec::new();
1201
1202    if let Ok(data_home) = std::env::var("XDG_DATA_HOME")
1203        && !data_home.is_empty()
1204    {
1205        paths.push(PathBuf::from(data_home).join("opencode").join("auth.json"));
1206    }
1207
1208    if let Some(base_dirs) = BaseDirs::new() {
1209        paths.push(base_dirs.data_dir().join("opencode").join("auth.json"));
1210    }
1211
1212    paths.sort();
1213    paths.dedup();
1214    paths
1215}
1216
1217fn opencode_key_from_auth_file(path: &Path) -> Option<String> {
1218    let content = fs::read_to_string(path).ok()?;
1219    opencode_key_from_auth_content(&content)
1220}
1221
1222fn opencode_key_from_auth_content(content: &str) -> Option<String> {
1223    let data: Value = serde_json::from_str(content).ok()?;
1224    ["opencode", "opencode/"]
1225        .into_iter()
1226        .find_map(|provider_id| api_key_from_opencode_auth_entry(data.get(provider_id)?))
1227}
1228
1229fn api_key_from_opencode_auth_entry(entry: &Value) -> Option<String> {
1230    if entry.get("type")?.as_str()? != "api" {
1231        return None;
1232    }
1233
1234    let key = entry.get("key")?.as_str()?.trim();
1235    if key.is_empty() {
1236        None
1237    } else {
1238        Some(key.to_string())
1239    }
1240}
1241
1242fn ensure_private_parent_dir(path: &Path) -> Result<()> {
1243    if let Some(parent) = path.parent() {
1244        fs::create_dir_all(parent).context("failed to create credentials directory")?;
1245        #[cfg(unix)]
1246        {
1247            use std::os::unix::fs::PermissionsExt;
1248            fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
1249        }
1250    }
1251
1252    Ok(())
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::config::{ProviderConfig, ProviderKind};
1259
1260    #[test]
1261    fn roundtrip_store_and_load_api_key() {
1262        let tempdir = tempfile::tempdir().expect("tempdir");
1263        let store = CredentialStore::new(tempdir.path().to_path_buf());
1264
1265        assert!(store.get_api_key("openai").is_none());
1266
1267        store.set_api_key("openai", "sk-test-123").expect("save");
1268        assert_eq!(store.get_api_key("openai").as_deref(), Some("sk-test-123"));
1269    }
1270
1271    #[test]
1272    fn overwrite_existing_key() {
1273        let tempdir = tempfile::tempdir().expect("tempdir");
1274        let store = CredentialStore::new(tempdir.path().to_path_buf());
1275
1276        store.set_api_key("openai", "old-key").expect("save");
1277        store.set_api_key("openai", "new-key").expect("save");
1278        assert_eq!(store.get_api_key("openai").as_deref(), Some("new-key"));
1279    }
1280
1281    #[test]
1282    fn multiple_providers_stored_independently() {
1283        let tempdir = tempfile::tempdir().expect("tempdir");
1284        let store = CredentialStore::new(tempdir.path().to_path_buf());
1285
1286        store.set_api_key("openai", "sk-openai").expect("save");
1287        store.set_api_key("charm-hyper", "sk-charm").expect("save");
1288
1289        assert_eq!(store.get_api_key("openai").as_deref(), Some("sk-openai"));
1290        assert_eq!(
1291            store.get_api_key("charm-hyper").as_deref(),
1292            Some("sk-charm")
1293        );
1294    }
1295
1296    #[test]
1297    fn lists_and_deletes_stored_provider_ids_without_keys() {
1298        let tempdir = tempfile::tempdir().expect("tempdir");
1299        let store = CredentialStore::new(tempdir.path().to_path_buf());
1300
1301        store.set_api_key("z-provider", "sk-z").expect("save");
1302        store.set_api_key("a-provider", "sk-a").expect("save");
1303
1304        assert_eq!(
1305            store.list_api_key_providers().expect("list"),
1306            vec!["a-provider".to_string(), "z-provider".to_string()]
1307        );
1308        assert!(store.delete_api_key("a-provider").expect("delete"));
1309        assert!(!store.delete_api_key("missing-provider").expect("delete"));
1310        assert_eq!(
1311            store.list_api_key_providers().expect("list"),
1312            vec!["z-provider".to_string()]
1313        );
1314        assert_eq!(store.get_api_key("z-provider").as_deref(), Some("sk-z"));
1315        assert!(store.get_api_key("a-provider").is_none());
1316    }
1317
1318    #[test]
1319    fn resolve_prefers_env_var_over_stored() {
1320        let tempdir = tempfile::tempdir().expect("tempdir");
1321        let store = CredentialStore::new(tempdir.path().to_path_buf());
1322
1323        store
1324            .set_api_key("test-provider", "stored-key")
1325            .expect("save");
1326
1327        // Set the env var to simulate explicit override
1328        let key = "NAVI_TEST_RESOLVE_KEY_12345";
1329        unsafe { std::env::set_var(key, "env-key") };
1330        let result = store.resolve_api_key("test-provider", key);
1331        assert_eq!(result.as_deref(), Some("env-key"));
1332        unsafe { std::env::remove_var(key) };
1333    }
1334
1335    #[test]
1336    fn resolve_falls_back_to_stored_key() {
1337        let tempdir = tempfile::tempdir().expect("tempdir");
1338        let store = CredentialStore::new(tempdir.path().to_path_buf());
1339
1340        store
1341            .set_api_key("test-provider", "stored-key")
1342            .expect("save");
1343        let result = store.resolve_api_key("test-provider", "NAVI_NONEXISTENT_ENV_VAR_98765");
1344        assert_eq!(result.as_deref(), Some("stored-key"));
1345    }
1346
1347    #[test]
1348    fn provider_resolver_falls_back_to_store_key() {
1349        let tempdir = tempfile::tempdir().expect("tempdir");
1350        let store = CredentialStore::new(tempdir.path().to_path_buf());
1351        let provider = ProviderConfig {
1352            id: "openai".to_string(),
1353            label: "OpenAI".to_string(),
1354            description: String::new(),
1355            kind: ProviderKind::OpenAiResponses,
1356            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98766".to_string(),
1357            base_url: Some("https://api.openai.com/v1".to_string()),
1358            ..Default::default()
1359        };
1360
1361        store.set_api_key("openai", "stored-openai").expect("save");
1362
1363        let result = resolve_provider_api_key(&store, &provider, "openai");
1364        assert_eq!(result.as_deref(), Some("stored-openai"));
1365    }
1366
1367    #[test]
1368    fn provider_resolver_checks_requested_alias_key() {
1369        let tempdir = tempfile::tempdir().expect("tempdir");
1370        let store = CredentialStore::new(tempdir.path().to_path_buf());
1371        let provider = ProviderConfig {
1372            id: "custom-provider".to_string(),
1373            label: "Custom Provider".to_string(),
1374            description: String::new(),
1375            kind: ProviderKind::OpenAiResponses,
1376            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98767".to_string(),
1377            base_url: Some("https://example.test/v1".to_string()),
1378            ..Default::default()
1379        };
1380
1381        store
1382            .set_api_key("custom-provider-alias", "stored-alias")
1383            .expect("save");
1384
1385        let result = resolve_provider_api_key(&store, &provider, "custom-provider-alias");
1386        assert_eq!(result.as_deref(), Some("stored-alias"));
1387    }
1388
1389    #[test]
1390    fn provider_status_reports_stored_and_missing_credentials() {
1391        let tempdir = tempfile::tempdir().expect("tempdir");
1392        let store = CredentialStore::new(tempdir.path().to_path_buf());
1393        let provider = ProviderConfig {
1394            id: "openai".to_string(),
1395            label: "OpenAI".to_string(),
1396            description: String::new(),
1397            kind: ProviderKind::OpenAiResponses,
1398            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98768".to_string(),
1399            base_url: Some("https://api.openai.com/v1".to_string()),
1400            ..Default::default()
1401        };
1402
1403        let missing = resolve_provider_credential_status(&store, &provider, "openai", None);
1404        assert!(!missing.configured);
1405        assert_eq!(missing.label, "missing");
1406
1407        store.set_api_key("openai", "stored-openai").expect("save");
1408        let stored = resolve_provider_credential_status(&store, &provider, "openai", None);
1409        assert!(stored.configured);
1410        assert_eq!(stored.source, Some(CredentialSource::Stored));
1411        assert_eq!(stored.label, "stored");
1412    }
1413
1414    #[test]
1415    fn openai_oauth_credential_does_not_resolve_as_model_api_key() {
1416        let tempdir = tempfile::tempdir().expect("tempdir");
1417        let store = CredentialStore::new(tempdir.path().to_path_buf());
1418        let provider = ProviderConfig {
1419            id: "openai".to_string(),
1420            label: "OpenAI".to_string(),
1421            description: String::new(),
1422            kind: ProviderKind::OpenAiResponses,
1423            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98771".to_string(),
1424            base_url: Some("https://api.openai.com/v1".to_string()),
1425            ..Default::default()
1426        };
1427
1428        store
1429            .set_oauth_credential("openai", "oauth-access-token", "chat-completions")
1430            .expect("save oauth");
1431
1432        assert_eq!(
1433            store.get_api_key("openai").as_deref(),
1434            Some("oauth-access-token")
1435        );
1436        assert!(store.get_model_api_key("openai").is_none());
1437        assert!(resolve_provider_api_key(&store, &provider, "openai").is_none());
1438
1439        let status = resolve_provider_credential_status(&store, &provider, "openai", None);
1440        assert!(!status.configured);
1441        assert_eq!(status.label, "oauth-only");
1442    }
1443
1444    #[test]
1445    fn xai_grok_oauth_credential_resolves_as_model_api_key() {
1446        let tempdir = tempfile::tempdir().expect("tempdir");
1447        let store = CredentialStore::new(tempdir.path().to_path_buf());
1448        let provider = ProviderConfig {
1449            id: "xai".to_string(),
1450            label: "xAI".to_string(),
1451            description: String::new(),
1452            kind: ProviderKind::OpenAiResponses,
1453            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_XAI_OAUTH".to_string(),
1454            base_url: Some("https://api.x.ai/v1".to_string()),
1455            ..Default::default()
1456        };
1457
1458        store
1459            .set_oauth_credential_full(
1460                "xai",
1461                "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.payload.sig",
1462                XAI_GROK_CLI_OAUTH_KIND,
1463                Some("refresh-token-xyz"),
1464                Some(9_999_999_999),
1465            )
1466            .expect("save oauth");
1467
1468        assert_eq!(
1469            store.get_model_api_key("xai").as_deref(),
1470            Some("eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.payload.sig")
1471        );
1472        assert_eq!(
1473            store.get_oauth_api_kind("xai").as_deref(),
1474            Some(XAI_GROK_CLI_OAUTH_KIND)
1475        );
1476        assert_eq!(
1477            store.get_oauth_refresh_token("xai").as_deref(),
1478            Some("refresh-token-xyz")
1479        );
1480        assert!(resolve_provider_api_key(&store, &provider, "xai").is_some());
1481
1482        let status = resolve_provider_credential_status(&store, &provider, "xai", None);
1483        assert!(status.configured);
1484        assert_eq!(status.label, "oauth");
1485    }
1486
1487    #[test]
1488    fn grok_auth_json_content_is_used_as_external_xai_credential() {
1489        let tempdir = tempfile::tempdir().expect("tempdir");
1490        let store = CredentialStore::new(tempdir.path().to_path_buf());
1491        let provider = ProviderConfig {
1492            id: "xai".to_string(),
1493            label: "xAI".to_string(),
1494            description: String::new(),
1495            kind: ProviderKind::OpenAiResponses,
1496            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_XAI_GROK".to_string(),
1497            base_url: Some("https://api.x.ai/v1".to_string()),
1498            ..Default::default()
1499        };
1500
1501        let far_future = "2099-01-01T00:00:00Z";
1502        let content = format!(
1503            r#"{{
1504                "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{
1505                    "key": "eyJ.test.token",
1506                    "auth_mode": "oidc",
1507                    "expires_at": "{far_future}",
1508                    "oidc_issuer": "https://auth.x.ai"
1509                }}
1510            }}"#
1511        );
1512        // SAFETY: test-only env mutation.
1513        unsafe { std::env::set_var("GROK_AUTH_CONTENT", &content) };
1514        let key = resolve_provider_api_key(&store, &provider, "xai");
1515        let status = resolve_provider_credential_status(&store, &provider, "xai", None);
1516        unsafe { std::env::remove_var("GROK_AUTH_CONTENT") };
1517
1518        assert_eq!(key.as_deref(), Some("eyJ.test.token"));
1519        assert!(status.configured);
1520        assert_eq!(status.label, "grok");
1521    }
1522
1523    #[test]
1524    fn provider_status_reports_public_model_access() {
1525        let tempdir = tempfile::tempdir().expect("tempdir");
1526        let store = CredentialStore::new(tempdir.path().to_path_buf());
1527        let provider = ProviderConfig {
1528            id: "public-test".to_string(),
1529            label: "OpenCode".to_string(),
1530            description: String::new(),
1531            kind: ProviderKind::OpenAiChatCompletions,
1532            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98769".to_string(),
1533            base_url: None,
1534            ..Default::default()
1535        };
1536
1537        let status = resolve_provider_credential_status(
1538            &store,
1539            &provider,
1540            "opencode",
1541            Some("deepseek-v4-flash-free"),
1542        );
1543        assert!(status.configured);
1544        assert!(matches!(
1545            status.source,
1546            Some(CredentialSource::External) | Some(CredentialSource::PublicModel)
1547        ));
1548    }
1549
1550    #[test]
1551    fn reads_opencode_api_key_from_auth_content() {
1552        let content = r#"{
1553            "opencode": {
1554                "type": "api",
1555                "key": "zen-key"
1556            },
1557            "openai": {
1558                "type": "api",
1559                "key": "openai-key"
1560            }
1561        }"#;
1562
1563        assert_eq!(
1564            opencode_key_from_auth_content(content).as_deref(),
1565            Some("zen-key")
1566        );
1567    }
1568
1569    #[test]
1570    fn ignores_non_api_opencode_auth_content() {
1571        let content = r#"{
1572            "opencode": {
1573                "type": "oauth",
1574                "access": "access-token",
1575                "refresh": "refresh-token",
1576                "expires": 999999
1577            }
1578        }"#;
1579
1580        assert!(opencode_key_from_auth_content(content).is_none());
1581    }
1582
1583    #[test]
1584    fn stores_commandcode_oauth_metadata_in_navi_credentials() {
1585        let tempdir = tempfile::tempdir().expect("tempdir");
1586        let store = CredentialStore::new(tempdir.path().to_path_buf());
1587        let metadata = CommandCodeCredentialMetadata {
1588            user_id: "user-1".to_string(),
1589            user_name: "test-user".to_string(),
1590            key_name: "NAVI".to_string(),
1591            authenticated_at: "123".to_string(),
1592        };
1593
1594        store
1595            .set_commandcode_credential(ProviderId::COMMANDCODE, "cmd-key", metadata.clone())
1596            .expect("save commandcode credential");
1597
1598        assert_eq!(
1599            store.get_api_key(ProviderId::COMMANDCODE).as_deref(),
1600            Some("cmd-key")
1601        );
1602        assert_eq!(
1603            store.get_commandcode_metadata(ProviderId::COMMANDCODE),
1604            Some(metadata)
1605        );
1606    }
1607
1608    #[test]
1609    fn commandcode_provider_checks_cli_env_aliases() {
1610        let provider = ProviderConfig {
1611            id: ProviderId::COMMANDCODE.to_string(),
1612            label: "Command Code".to_string(),
1613            description: String::new(),
1614            kind: ProviderKind::OpenAiChatCompletions,
1615            api_key_env: "CMD_API_KEY".to_string(),
1616            base_url: Some("https://api.commandcode.ai".to_string()),
1617            ..Default::default()
1618        };
1619
1620        assert_eq!(
1621            provider_env_vars_for_config(&provider),
1622            vec![
1623                "COMMAND_CODE_API_KEY".to_string(),
1624                "CMD_API_KEY".to_string()
1625            ]
1626        );
1627    }
1628
1629    #[test]
1630    fn provider_resolver_uses_stored_commandcode_oauth_key() {
1631        let tempdir = tempfile::tempdir().expect("tempdir");
1632        let store = CredentialStore::new(tempdir.path().to_path_buf());
1633        let provider = ProviderConfig {
1634            id: ProviderId::COMMANDCODE.to_string(),
1635            label: "Command Code".to_string(),
1636            description: String::new(),
1637            kind: ProviderKind::OpenAiChatCompletions,
1638            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98770".to_string(),
1639            base_url: Some("https://api.commandcode.ai".to_string()),
1640            ..Default::default()
1641        };
1642
1643        store
1644            .set_commandcode_credential(
1645                ProviderId::COMMANDCODE,
1646                "cmd-stored-key",
1647                CommandCodeCredentialMetadata {
1648                    user_id: "user-1".to_string(),
1649                    user_name: "test-user".to_string(),
1650                    key_name: "NAVI".to_string(),
1651                    authenticated_at: "123".to_string(),
1652                },
1653            )
1654            .expect("save commandcode credential");
1655        let result = resolve_provider_api_key(&store, &provider, ProviderId::COMMANDCODE);
1656        let expected = std::env::var("COMMAND_CODE_API_KEY")
1657            .ok()
1658            .filter(|key| !key.is_empty())
1659            .or_else(|| {
1660                std::env::var("CMD_API_KEY")
1661                    .ok()
1662                    .filter(|key| !key.is_empty())
1663            })
1664            .unwrap_or_else(|| "cmd-stored-key".to_string());
1665
1666        assert_eq!(result.as_deref(), Some(expected.as_str()));
1667    }
1668
1669    #[test]
1670    fn selected_provider_account_persists_as_project_and_default_account() {
1671        let tempdir = tempfile::tempdir().expect("tempdir");
1672        let data_dir = tempdir.path().join("data");
1673        let project_dir = tempdir.path().join("project");
1674        fs::create_dir_all(&project_dir).expect("project dir");
1675        let store = CredentialStore::new(data_dir.clone());
1676        let provider = ProviderConfig {
1677            id: ProviderId::COMMANDCODE.to_string(),
1678            label: "Command Code".to_string(),
1679            description: String::new(),
1680            kind: ProviderKind::OpenAiChatCompletions,
1681            api_key_env: "NAVI_NONEXISTENT_ENV_VAR_98772".to_string(),
1682            base_url: Some("https://api.commandcode.ai".to_string()),
1683            ..Default::default()
1684        };
1685
1686        let first = store
1687            .set_commandcode_credential(
1688                ProviderId::COMMANDCODE,
1689                "cmd-first-key",
1690                CommandCodeCredentialMetadata {
1691                    user_id: "user-1".to_string(),
1692                    user_name: "first-user".to_string(),
1693                    key_name: "NAVI".to_string(),
1694                    authenticated_at: "123".to_string(),
1695                },
1696            )
1697            .expect("save first commandcode credential");
1698        let second = store
1699            .set_commandcode_credential(
1700                ProviderId::COMMANDCODE,
1701                "cmd-second-key",
1702                CommandCodeCredentialMetadata {
1703                    user_id: "user-2".to_string(),
1704                    user_name: "second-user".to_string(),
1705                    key_name: "NAVI".to_string(),
1706                    authenticated_at: "456".to_string(),
1707                },
1708            )
1709            .expect("save second commandcode credential");
1710
1711        assert_ne!(first, second);
1712        store
1713            .set_project_account(&project_dir, ProviderId::COMMANDCODE, &second)
1714            .expect("select project account");
1715
1716        let reopened = CredentialStore::new(data_dir);
1717        assert_eq!(
1718            reopened.get_project_account(&project_dir, ProviderId::COMMANDCODE),
1719            Some(second.clone())
1720        );
1721        assert_eq!(
1722            reopened.get_api_key(ProviderId::COMMANDCODE).as_deref(),
1723            Some("cmd-second-key")
1724        );
1725        assert_eq!(
1726            resolve_provider_api_key_for_project(
1727                &reopened,
1728                &provider,
1729                ProviderId::COMMANDCODE,
1730                &project_dir
1731            )
1732            .as_deref(),
1733            Some("cmd-second-key")
1734        );
1735
1736        let accounts = reopened
1737            .list_credential_accounts(ProviderId::COMMANDCODE, Some(&project_dir))
1738            .expect("list accounts");
1739        assert!(
1740            accounts
1741                .iter()
1742                .any(|account| account.account_id == second && account.is_project_selected)
1743        );
1744        let accounts_without_project = reopened
1745            .list_credential_accounts(ProviderId::COMMANDCODE, None)
1746            .expect("list accounts without project");
1747        assert!(
1748            accounts_without_project
1749                .iter()
1750                .any(|account| account.account_id == second && account.is_project_selected)
1751        );
1752    }
1753
1754    #[cfg(unix)]
1755    #[test]
1756    fn credentials_file_and_directory_are_private() {
1757        use std::os::unix::fs::PermissionsExt;
1758
1759        let tempdir = tempfile::tempdir().expect("tempdir");
1760        let data_dir = tempdir.path().join("navi-data");
1761        let store = CredentialStore::new(data_dir.clone());
1762
1763        store.set_api_key("openai", "sk-test").expect("save");
1764
1765        let dir_mode = fs::metadata(&data_dir)
1766            .expect("dir metadata")
1767            .permissions()
1768            .mode()
1769            & 0o777;
1770        let file_mode = fs::metadata(data_dir.join("credentials.toml"))
1771            .expect("file metadata")
1772            .permissions()
1773            .mode()
1774            & 0o777;
1775
1776        assert_eq!(dir_mode, 0o700);
1777        assert_eq!(file_mode, 0o600);
1778    }
1779
1780    // ── Regression tests ──────────────────────────────────────────────────────
1781
1782    #[test]
1783    fn regression_corrupt_credentials_file_returns_none() {
1784        let tempdir = tempfile::tempdir().expect("tempdir");
1785        let data_dir = tempdir.path().join("navi-data");
1786        let store = CredentialStore::new(data_dir.clone());
1787
1788        // Write corrupt TOML
1789        fs::create_dir_all(&data_dir).expect("create");
1790        fs::write(data_dir.join("credentials.toml"), "{not valid toml!!!").expect("write");
1791
1792        let key = store.get_api_key("openai");
1793        assert!(key.is_none(), "corrupt credentials must return None");
1794    }
1795
1796    #[test]
1797    fn regression_delete_api_key_missing_file_returns_false() {
1798        let tempdir = tempfile::tempdir().expect("tempdir");
1799        let data_dir = tempdir.path().join("navi-data");
1800        let store = CredentialStore::new(data_dir);
1801
1802        let result = store.delete_api_key("openai").expect("delete");
1803        assert!(!result, "deleting from missing file should return false");
1804    }
1805
1806    #[test]
1807    fn regression_empty_env_var_falls_through() {
1808        let tempdir = tempfile::tempdir().expect("tempdir");
1809        let data_dir = tempdir.path().join("navi-data");
1810        let store = CredentialStore::new(data_dir);
1811
1812        // Store a key
1813        store.set_api_key("openai", "sk-stored").expect("save");
1814
1815        // Set env var to empty
1816        unsafe { std::env::set_var("OPENAI_API_KEY", "") };
1817        let key = store.resolve_api_key("openai", "OPENAI_API_KEY");
1818        unsafe { std::env::remove_var("OPENAI_API_KEY") };
1819
1820        // Empty env var should fall through to stored key
1821        assert_eq!(key.as_deref(), Some("sk-stored"));
1822    }
1823
1824    #[test]
1825    fn regression_set_empty_api_key_stores_empty() {
1826        let tempdir = tempfile::tempdir().expect("tempdir");
1827        let data_dir = tempdir.path().join("navi-data");
1828        let store = CredentialStore::new(data_dir);
1829
1830        store.set_api_key("openai", "").expect("save");
1831        // The store doesn't validate non-empty, so empty is stored
1832        // This is a known behavior - callers should validate
1833        let key = store.get_api_key("openai");
1834        assert_eq!(key.as_deref(), Some(""));
1835    }
1836
1837    #[test]
1838    fn regression_opencode_key_from_invalid_json_returns_none() {
1839        let result = opencode_key_from_auth_content("{not json");
1840        assert!(result.is_none());
1841    }
1842}