Skip to main content

codewhale_config/
external_credentials.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5
6use crate::ProviderKind;
7
8/// Schema version for informed consent to another CLI's credential file.
9pub const EXTERNAL_CREDENTIAL_CONSENT_VERSION: u32 = 1;
10
11/// The complete side-effect contract for read-only external credentials.
12pub const EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS: &str = "read this exact file; no refresh, identity-provider or discovery requests, external-file writes, or rewrites; normal requests to the explicitly selected provider may use the token";
13
14/// Quote an OS path for terminals, logs, JSON display fields, and errors.
15///
16/// The result is always one line. Terminal controls, line separators, bidi
17/// formatting controls, quotes, and backslashes are escaped. Unix paths keep
18/// non-UTF-8 bytes exact as `\xNN`; Windows preserves unpaired UTF-16 units as
19/// `\u{NNNN}`.
20#[must_use]
21pub fn quote_os_path(path: &Path) -> String {
22    quote_os_path_inner(path)
23}
24
25#[cfg(unix)]
26fn quote_os_path_inner(path: &Path) -> String {
27    use std::os::unix::ffi::OsStrExt as _;
28    let bytes = path.as_os_str().as_bytes();
29    if let Ok(text) = std::str::from_utf8(bytes) {
30        return quote_path_text(text);
31    }
32    let mut out = String::from("\"");
33    for byte in bytes {
34        match byte {
35            b'"' => out.push_str("\\\""),
36            b'\\' => out.push_str("\\\\"),
37            0x20..=0x7e => out.push(char::from(*byte)),
38            _ => out.push_str(&format!("\\x{byte:02x}")),
39        }
40    }
41    out.push('"');
42    out
43}
44
45#[cfg(windows)]
46fn quote_os_path_inner(path: &Path) -> String {
47    use std::os::windows::ffi::OsStrExt as _;
48    let mut out = String::from("\"");
49    for decoded in char::decode_utf16(path.as_os_str().encode_wide()) {
50        match decoded {
51            Ok(character) => push_escaped_path_character(&mut out, character),
52            Err(error) => out.push_str(&format!("\\u{{{:04x}}}", error.unpaired_surrogate())),
53        }
54    }
55    out.push('"');
56    out
57}
58
59#[cfg(not(any(unix, windows)))]
60fn quote_os_path_inner(path: &Path) -> String {
61    quote_path_text(&path.to_string_lossy())
62}
63
64#[cfg(not(windows))]
65fn quote_path_text(text: &str) -> String {
66    let mut out = String::with_capacity(text.len() + 2);
67    out.push('"');
68    for character in text.chars() {
69        push_escaped_path_character(&mut out, character);
70    }
71    out.push('"');
72    out
73}
74
75fn push_escaped_path_character(out: &mut String, character: char) {
76    match character {
77        '"' => out.push_str("\\\""),
78        '\\' => out.push_str("\\\\"),
79        '\n' => out.push_str("\\n"),
80        '\r' => out.push_str("\\r"),
81        '\t' => out.push_str("\\t"),
82        '\u{1b}' => out.push_str("\\x1b"),
83        character if character.is_control() || is_bidi_format_control(character) => {
84            out.extend(character.escape_unicode());
85        }
86        character => out.push(character),
87    }
88}
89
90fn is_bidi_format_control(character: char) -> bool {
91    matches!(
92        character,
93        '\u{061c}'
94            | '\u{200e}'
95            | '\u{200f}'
96            | '\u{2028}'
97            | '\u{2029}'
98            | '\u{202a}'..='\u{202e}'
99            | '\u{2066}'..='\u{2069}'
100    )
101}
102
103/// Resolve a user-selected path without touching the filesystem.
104///
105/// Consent is bound to the exact logical path, so this intentionally avoids
106/// canonicalization (which would stat the candidate before consent exists).
107pub fn resolve_external_credential_path(path: impl AsRef<Path>) -> Result<PathBuf> {
108    let path = path.as_ref();
109    let absolute = if path.is_absolute() {
110        path.to_path_buf()
111    } else {
112        std::env::current_dir()
113            .map_err(|err| anyhow::anyhow!("resolving external credential path: {err}"))?
114            .join(path)
115    };
116
117    // Normalize only lexical `.` / `..` components. Canonicalization would
118    // inspect a credential path before consent exists and would also silently
119    // bless a symlink target. The secure reader rejects symlink/reparse-point
120    // components when the granted capability is actually consumed.
121    let mut normalized = PathBuf::new();
122    for component in absolute.components() {
123        match component {
124            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
125            Component::RootDir => normalized.push(component.as_os_str()),
126            Component::CurDir => {}
127            Component::ParentDir => {
128                if !normalized.pop() {
129                    bail!(
130                        "external credential path escapes its absolute root: {}",
131                        quote_os_path(&absolute)
132                    );
133                }
134            }
135            Component::Normal(part) => normalized.push(part),
136        }
137    }
138    if !normalized.is_absolute() {
139        bail!(
140            "external credential path must resolve to an absolute path: {}",
141            quote_os_path(&normalized)
142        );
143    }
144    Ok(normalized)
145}
146
147/// The side-effect envelope Codewhale may use for an external credential.
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ExternalCredentialAccess {
151    /// Do not inspect or access the external credential store.
152    #[default]
153    Disabled,
154    /// Read the exact selected file without refreshing or rewriting it.
155    ReadOnly,
156    /// Permit a documented preservation adapter to refresh and rewrite it.
157    Managed,
158}
159
160impl ExternalCredentialAccess {
161    #[must_use]
162    pub const fn as_str(self) -> &'static str {
163        match self {
164            Self::Disabled => "disabled",
165            Self::ReadOnly => "read_only",
166            Self::Managed => "managed",
167        }
168    }
169}
170
171/// External credential owners supported by the consent schema.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ExternalCredentialSource {
175    CodexCli,
176    KimiCodeCli,
177    GrokCli,
178    /// Official DeepSeek Harness (`dsh`) `$DSH_HOME/.credentials.yaml`.
179    DshCli,
180    /// Official Antigravity CLI (`agy`) `state.vscdb` OAuth token.
181    AgyCli,
182}
183
184/// Default DeepSeek Harness credentials document, resolved without probing.
185///
186/// Matches dsh-credentials-local: `$DSH_HOME/.credentials.yaml`, or
187/// `~/.dsh/.credentials.yaml` when `DSH_HOME` is unset. Consent is pinned to
188/// this exact path; a later `DSH_HOME` change is reported, never followed.
189#[must_use]
190pub fn default_dsh_credentials_path() -> PathBuf {
191    let home = match std::env::var_os("DSH_HOME") {
192        Some(value) if !value.is_empty() => PathBuf::from(value),
193        _ => codewhale_paths::user_home()
194            .unwrap_or_else(|| PathBuf::from("."))
195            .join(".dsh"),
196    };
197    home.join(".credentials.yaml")
198}
199
200/// Default Antigravity credential store, resolved without probing: the
201/// official `agy` CLI persists its OAuth token in the Antigravity app's
202/// VSCode-style `state.vscdb` under the user profile. Consent is pinned to
203/// this exact path; an ambient move is reported, never followed.
204#[must_use]
205pub fn default_agy_credentials_path() -> PathBuf {
206    let base = match std::env::var_os("ANTIGRAVITY_STATE_DIR") {
207        Some(value) if !value.is_empty() => PathBuf::from(value),
208        _ => agy_profile_base(),
209    };
210    base.join("User").join("globalStorage").join("state.vscdb")
211}
212
213#[cfg(target_os = "macos")]
214fn agy_profile_base() -> PathBuf {
215    codewhale_paths::user_home()
216        .unwrap_or_else(|| PathBuf::from("."))
217        .join("Library/Application Support/Antigravity")
218}
219
220#[cfg(all(unix, not(target_os = "macos")))]
221fn agy_profile_base() -> PathBuf {
222    match std::env::var_os("XDG_CONFIG_HOME") {
223        Some(value) if !value.is_empty() => PathBuf::from(value),
224        _ => codewhale_paths::user_home()
225            .unwrap_or_else(|| PathBuf::from("."))
226            .join(".config"),
227    }
228    .join("Antigravity")
229}
230
231#[cfg(windows)]
232fn agy_profile_base() -> PathBuf {
233    match std::env::var_os("APPDATA") {
234        Some(value) if !value.is_empty() => PathBuf::from(value),
235        _ => codewhale_paths::user_home().unwrap_or_else(|| PathBuf::from(".")),
236    }
237    .join("Antigravity")
238}
239
240impl ExternalCredentialSource {
241    #[must_use]
242    pub const fn as_str(self) -> &'static str {
243        match self {
244            Self::CodexCli => "codex_cli",
245            Self::KimiCodeCli => "kimi_code_cli",
246            Self::GrokCli => "grok_cli",
247            Self::DshCli => "dsh_cli",
248            Self::AgyCli => "agy_cli",
249        }
250    }
251
252    /// Human-facing owner name used in informed-consent disclosures.
253    #[must_use]
254    pub const fn owner_label(self) -> &'static str {
255        match self {
256            Self::CodexCli => "Codex CLI",
257            Self::KimiCodeCli => "Kimi Code CLI",
258            Self::GrokCli => "Grok CLI",
259            Self::DshCli => "DeepSeek Harness",
260            Self::AgyCli => "Antigravity CLI",
261        }
262    }
263}
264
265/// Side-effect-free projection used by picker, config, and doctor surfaces.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ExternalCredentialConsentStatus {
268    pub access: ExternalCredentialAccess,
269    pub provider: String,
270    pub source: ExternalCredentialSource,
271    pub owner: &'static str,
272    pub path: PathBuf,
273    pub consent_version: u32,
274    pub configured: bool,
275    pub scope_valid: bool,
276    /// True when the ambient CLI path now differs from the persisted pinned
277    /// path. This is informational; it never redirects or deactivates consent.
278    pub ambient_path_changed: bool,
279    pub route_state: &'static str,
280    pub semantics: &'static str,
281    pub revoke_command: String,
282}
283
284impl ExternalCredentialConsentStatus {
285    /// Warn without displaying the untrusted ambient replacement. The
286    /// persisted path remains authoritative and is escaped for one line.
287    #[must_use]
288    pub fn ambient_path_warning(&self) -> Option<String> {
289        self.ambient_path_changed.then(|| {
290            format!(
291                "warning: ambient {} credential path changed; consent remains pinned to {} and was not redirected",
292                self.owner,
293                quote_os_path(&self.path)
294            )
295        })
296    }
297}
298
299/// Describe persisted external-credential policy without filesystem or network
300/// access. `expected_path` is resolved lexically by the caller.
301#[must_use]
302pub fn external_credential_consent_status(
303    consent: Option<&ExternalCredentialConsentToml>,
304    provider: ProviderKind,
305    source: ExternalCredentialSource,
306    expected_path: &Path,
307    active_provider: ProviderKind,
308) -> ExternalCredentialConsentStatus {
309    let configured = consent.is_some();
310    let access = consent.map_or(ExternalCredentialAccess::Disabled, |value| value.access);
311    // User-facing status identifies the route being inspected. Persisted
312    // provider/source fields are untrusted config input and are represented by
313    // `scope_valid` rather than echoed into a terminal surface.
314    let reported_provider = provider.as_str().to_string();
315    let reported_source = source;
316    let reported_path = consent
317        .map(|value| value.path.clone())
318        .unwrap_or_else(|| expected_path.to_path_buf());
319    let consent_version = consent.map_or(EXTERNAL_CREDENTIAL_CONSENT_VERSION, |value| {
320        value.consent_version
321    });
322    let scope_valid = consent.is_some_and(|value| {
323        value
324            .validate_read_scope(provider, source, &value.path)
325            .is_ok()
326    });
327    let ambient_path_changed = consent.is_some_and(|value| value.path != expected_path);
328    let active =
329        provider == active_provider && access == ExternalCredentialAccess::ReadOnly && scope_valid;
330    let route_state = if active { "active" } else { "dormant" };
331    let semantics = match access {
332        ExternalCredentialAccess::Disabled => {
333            "disabled; no external-credential probing, reading, refresh, discovery, identity-provider or network acquisition, writes, or rewrites; normal requests to the explicitly selected provider may use Codewhale-owned credentials"
334        }
335        ExternalCredentialAccess::ReadOnly => EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
336        ExternalCredentialAccess::Managed => {
337            "managed access unavailable; no schema-safe preservation adapter"
338        }
339    };
340
341    ExternalCredentialConsentStatus {
342        access,
343        provider: reported_provider,
344        source: reported_source,
345        owner: reported_source.owner_label(),
346        path: reported_path,
347        consent_version,
348        configured,
349        scope_valid,
350        ambient_path_changed,
351        route_state,
352        semantics,
353        revoke_command: format!(
354            "codewhale auth external-revoke --provider {}",
355            provider.as_str()
356        ),
357    }
358}
359
360/// Persisted, provider-scoped consent for one exact external credential file.
361///
362/// Provider and source are repeated intentionally. A copied provider table or
363/// a future source-path remap must fail closed instead of inheriting authority.
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(deny_unknown_fields)]
366pub struct ExternalCredentialConsentToml {
367    pub access: ExternalCredentialAccess,
368    pub provider: String,
369    pub source: ExternalCredentialSource,
370    pub path: PathBuf,
371    pub consent_version: u32,
372}
373
374impl ExternalCredentialConsentToml {
375    #[must_use]
376    pub fn read_only(
377        provider: ProviderKind,
378        source: ExternalCredentialSource,
379        path: PathBuf,
380    ) -> Self {
381        Self {
382            access: ExternalCredentialAccess::ReadOnly,
383            provider: provider.as_str().to_string(),
384            source,
385            path,
386            consent_version: EXTERNAL_CREDENTIAL_CONSENT_VERSION,
387        }
388    }
389
390    /// Validate that this record is a current read-only consent for one exact
391    /// provider/source/path tuple without minting an I/O capability.
392    ///
393    /// This is intentionally side-effect free so inventory and picker surfaces
394    /// can acknowledge dormant consent without inspecting the external file.
395    pub fn validate_read_scope(
396        &self,
397        provider: ProviderKind,
398        source: ExternalCredentialSource,
399        resolved_path: &Path,
400    ) -> Result<()> {
401        if self.access == ExternalCredentialAccess::Disabled {
402            bail!(
403                "external credential access is disabled for {}",
404                provider.as_str()
405            );
406        }
407        if self.access == ExternalCredentialAccess::Managed {
408            bail!(
409                "managed external credential access is unsupported for {}; no schema-safe preservation adapter is available",
410                provider.as_str()
411            );
412        }
413        if self.consent_version != EXTERNAL_CREDENTIAL_CONSENT_VERSION {
414            bail!(
415                "external credential consent for {} uses unsupported version {}; revoke and consent again",
416                provider.as_str(),
417                self.consent_version
418            );
419        }
420        if self.provider != provider.as_str() {
421            bail!(
422                "external credential consent is scoped to provider {:?}, not {}",
423                self.provider,
424                provider.as_str()
425            );
426        }
427        if self.source != source {
428            bail!(
429                "external credential consent source mismatch for {} (expected {})",
430                provider.as_str(),
431                source.as_str()
432            );
433        }
434        if !self.path.is_absolute() {
435            bail!(
436                "external credential consent path for {} must be absolute",
437                provider.as_str()
438            );
439        }
440        let normalized = resolve_external_credential_path(&self.path)?;
441        if normalized != self.path {
442            bail!(
443                "external credential consent path for {} must be lexically normalized: {}",
444                provider.as_str(),
445                quote_os_path(&self.path)
446            );
447        }
448        if self.path != resolved_path {
449            bail!(
450                "external credential path changed for {}; consent covers {}, current path is {}",
451                provider.as_str(),
452                quote_os_path(&self.path),
453                quote_os_path(resolved_path)
454            );
455        }
456        Ok(())
457    }
458
459    /// Validate and mint the read capability consumed by credential adapters.
460    /// No filesystem operation occurs while validating the policy.
461    pub fn read_grant(
462        &self,
463        provider: ProviderKind,
464        source: ExternalCredentialSource,
465        resolved_path: &Path,
466    ) -> Result<ExternalCredentialReadGrant> {
467        self.validate_read_scope(provider, source, resolved_path)?;
468        Ok(ExternalCredentialReadGrant {
469            provider,
470            source,
471            path: resolved_path.to_path_buf(),
472            consent_version: self.consent_version,
473        })
474    }
475}
476
477/// Opaque proof that one exact provider/source/path tuple may be read.
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct ExternalCredentialReadGrant {
480    provider: ProviderKind,
481    source: ExternalCredentialSource,
482    path: PathBuf,
483    consent_version: u32,
484}
485
486impl ExternalCredentialReadGrant {
487    #[must_use]
488    pub fn provider(&self) -> ProviderKind {
489        self.provider
490    }
491
492    #[must_use]
493    pub fn source(&self) -> ExternalCredentialSource {
494        self.source
495    }
496
497    #[must_use]
498    pub fn path(&self) -> &Path {
499        &self.path
500    }
501
502    #[must_use]
503    pub fn consent_version(&self) -> u32 {
504        self.consent_version
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn absolute_test_path(file: &str) -> PathBuf {
513        if cfg!(windows) {
514            PathBuf::from(format!(r"C:\Users\test\{file}"))
515        } else {
516            PathBuf::from(format!("/tmp/{file}"))
517        }
518    }
519
520    #[test]
521    fn default_dsh_credentials_path_uses_dsh_home_or_dot_dsh() {
522        let previous = std::env::var_os("DSH_HOME");
523        unsafe {
524            std::env::set_var("DSH_HOME", "/opt/dsh-home");
525        }
526        let with_home = default_dsh_credentials_path();
527        match previous {
528            Some(value) => unsafe { std::env::set_var("DSH_HOME", value) },
529            None => unsafe { std::env::remove_var("DSH_HOME") },
530        }
531        assert_eq!(with_home, PathBuf::from("/opt/dsh-home/.credentials.yaml"));
532        assert_eq!(ExternalCredentialSource::DshCli.as_str(), "dsh_cli");
533        assert_eq!(
534            ExternalCredentialSource::DshCli.owner_label(),
535            "DeepSeek Harness"
536        );
537    }
538
539    #[test]
540    fn disclosed_paths_are_absolute_and_lexically_normalized_without_io() {
541        let resolved =
542            resolve_external_credential_path("one/./two/../auth.json").expect("lexical resolution");
543        assert!(resolved.is_absolute());
544        assert!(
545            resolved.ends_with(Path::new("one/auth.json")),
546            "{}",
547            resolved.display()
548        );
549        assert!(!resolved.to_string_lossy().contains("/./"));
550        assert!(!resolved.to_string_lossy().contains("/../"));
551    }
552
553    #[test]
554    fn structural_status_reports_full_scope_without_io() {
555        let path = absolute_test_path("codex-auth.json");
556        let consent = ExternalCredentialConsentToml::read_only(
557            ProviderKind::OpenaiCodex,
558            ExternalCredentialSource::CodexCli,
559            path.clone(),
560        );
561        let active = external_credential_consent_status(
562            Some(&consent),
563            ProviderKind::OpenaiCodex,
564            ExternalCredentialSource::CodexCli,
565            &path,
566            ProviderKind::OpenaiCodex,
567        );
568        assert_eq!(active.access, ExternalCredentialAccess::ReadOnly);
569        assert_eq!(active.owner, "Codex CLI");
570        assert_eq!(active.path, path);
571        assert_eq!(active.route_state, "active");
572        assert!(active.scope_valid);
573        assert!(active.semantics.contains("no refresh"));
574        assert_eq!(
575            active.revoke_command,
576            "codewhale auth external-revoke --provider openai-codex"
577        );
578
579        let changed_path = absolute_test_path("moved-auth.json");
580        let pinned = external_credential_consent_status(
581            Some(&consent),
582            ProviderKind::OpenaiCodex,
583            ExternalCredentialSource::CodexCli,
584            &changed_path,
585            ProviderKind::OpenaiCodex,
586        );
587        assert!(pinned.scope_valid);
588        assert_eq!(pinned.route_state, "active");
589        assert!(pinned.ambient_path_changed);
590        assert_eq!(pinned.path, path, "report the pinned persisted grant path");
591        let warning = pinned
592            .ambient_path_warning()
593            .expect("ambient mismatch warning");
594        assert!(warning.contains("remains pinned"), "{warning}");
595        assert!(warning.contains(&quote_os_path(&path)), "{warning}");
596    }
597
598    #[test]
599    fn displayed_paths_escape_terminal_and_bidi_controls_on_one_line() {
600        let path = PathBuf::from(
601            "/safe/line\nmanaged\u{1b}[2J\u{2028}first\u{2029}second\u{202e}name.json",
602        );
603        let quoted = quote_os_path(&path);
604        assert!(quoted.starts_with('"') && quoted.ends_with('"'));
605        assert!(quoted.contains("\\n"), "{quoted}");
606        assert!(quoted.contains("\\x1b"), "{quoted}");
607        assert!(quoted.contains("\\u{2028}"), "{quoted}");
608        assert!(quoted.contains("\\u{2029}"), "{quoted}");
609        assert!(quoted.contains("\\u{202e}"), "{quoted}");
610        assert!(!quoted.contains('\n'));
611        assert!(!quoted.contains('\u{1b}'));
612        assert!(!quoted.contains('\u{2028}'));
613        assert!(!quoted.contains('\u{2029}'));
614        assert!(!quoted.contains('\u{202e}'));
615    }
616
617    #[test]
618    fn disabled_disclosure_does_not_imply_normal_provider_network_is_disabled() {
619        let path = absolute_test_path("codex-auth.json");
620        let status = external_credential_consent_status(
621            None,
622            ProviderKind::OpenaiCodex,
623            ExternalCredentialSource::CodexCli,
624            &path,
625            ProviderKind::OpenaiCodex,
626        );
627        assert!(status.semantics.contains("no external-credential"));
628        assert!(status.semantics.contains("normal requests"));
629        assert!(!status.semantics.contains("no network requests"));
630    }
631
632    #[test]
633    fn read_grant_requires_exact_provider_source_path_and_version() {
634        let path = absolute_test_path("codex-auth.json");
635        let consent = ExternalCredentialConsentToml::read_only(
636            ProviderKind::OpenaiCodex,
637            ExternalCredentialSource::CodexCli,
638            path.clone(),
639        );
640
641        let grant = consent
642            .read_grant(
643                ProviderKind::OpenaiCodex,
644                ExternalCredentialSource::CodexCli,
645                &path,
646            )
647            .expect("exact consent tuple");
648        assert_eq!(grant.path(), path);
649
650        assert!(
651            consent
652                .read_grant(ProviderKind::Xai, ExternalCredentialSource::CodexCli, &path)
653                .is_err()
654        );
655        assert!(
656            consent
657                .read_grant(
658                    ProviderKind::OpenaiCodex,
659                    ExternalCredentialSource::GrokCli,
660                    &path
661                )
662                .is_err()
663        );
664        assert!(
665            consent
666                .read_grant(
667                    ProviderKind::OpenaiCodex,
668                    ExternalCredentialSource::CodexCli,
669                    &path.with_file_name("other.json")
670                )
671                .is_err()
672        );
673    }
674
675    #[test]
676    fn persisted_consent_path_must_be_lexically_normalized() {
677        let raw_path = if cfg!(windows) {
678            PathBuf::from(r"C:\Users\test\credentials\..\auth.json")
679        } else {
680            PathBuf::from("/tmp/credentials/../auth.json")
681        };
682        let consent = ExternalCredentialConsentToml::read_only(
683            ProviderKind::Xai,
684            ExternalCredentialSource::GrokCli,
685            raw_path.clone(),
686        );
687        assert!(
688            consent
689                .read_grant(
690                    ProviderKind::Xai,
691                    ExternalCredentialSource::GrokCli,
692                    &raw_path
693                )
694                .is_err()
695        );
696    }
697
698    #[test]
699    fn managed_consent_is_explicitly_unsupported_without_an_adapter() {
700        let path = absolute_test_path("grok-auth.json");
701        let mut consent = ExternalCredentialConsentToml::read_only(
702            ProviderKind::Xai,
703            ExternalCredentialSource::GrokCli,
704            path.clone(),
705        );
706        consent.access = ExternalCredentialAccess::Managed;
707
708        let error = consent
709            .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
710            .expect_err("managed access must fail closed");
711        assert!(
712            error
713                .to_string()
714                .contains("schema-safe preservation adapter")
715        );
716    }
717
718    #[test]
719    fn consent_round_trips_every_scope_field() {
720        let path = absolute_test_path("codex-auth.json");
721        let consent = ExternalCredentialConsentToml::read_only(
722            ProviderKind::OpenaiCodex,
723            ExternalCredentialSource::CodexCli,
724            path,
725        );
726
727        let encoded = toml::to_string(&consent).expect("serialize consent");
728        let decoded: ExternalCredentialConsentToml =
729            toml::from_str(&encoded).expect("deserialize consent");
730        assert_eq!(decoded, consent);
731        assert!(encoded.contains("access = \"read_only\""));
732        assert!(encoded.contains("provider = \"openai-codex\""));
733        assert!(encoded.contains("source = \"codex_cli\""));
734        assert!(encoded.contains("consent_version = 1"));
735    }
736
737    #[test]
738    fn disabled_stale_and_relative_consent_fail_before_a_grant() {
739        let path = absolute_test_path("grok-auth.json");
740        let mut consent = ExternalCredentialConsentToml::read_only(
741            ProviderKind::Xai,
742            ExternalCredentialSource::GrokCli,
743            path.clone(),
744        );
745
746        consent.access = ExternalCredentialAccess::Disabled;
747        assert!(
748            consent
749                .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
750                .expect_err("disabled consent")
751                .to_string()
752                .contains("disabled")
753        );
754
755        consent.access = ExternalCredentialAccess::ReadOnly;
756        consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION + 1;
757        assert!(
758            consent
759                .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
760                .expect_err("stale consent")
761                .to_string()
762                .contains("unsupported version")
763        );
764
765        consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION;
766        consent.path = PathBuf::from("relative/auth.json");
767        assert!(
768            consent
769                .read_grant(
770                    ProviderKind::Xai,
771                    ExternalCredentialSource::GrokCli,
772                    Path::new("relative/auth.json"),
773                )
774                .expect_err("relative path")
775                .to_string()
776                .contains("must be absolute")
777        );
778    }
779}