返回 CodeWhale
credential_resolve.rs
根目录 / crates / tui / src / config / credential_resolve.rs
1 //! The one place provider credential precedence is decided.
2 //!
3 //! Ported from pi-mono `packages/ai/src/auth/resolve.ts` (MIT, Copyright (c)
4 //! 2025 Mario Zechner; full notice in `crate::credentials`). The idea taken is
5 //! pi's: a single resolver, one precedence rule stated in a doc comment beside
6 //! it, and a result that names the place it resolved from. The walk itself is
7 //! CodeWhale's — it is the former body of `has_api_key_for`, moved here
8 //! unchanged in order so no existing decision changes, with a
9 //! [`CredentialSource`] attached to each outcome.
10 //!
11 //! # Precedence rule
12 //!
13 //! **A stored credential owns the provider: ambient/env is consulted only when
14 //! nothing is stored. No silent env fallback after a failed refresh.**
15 //!
16 //! CodeWhale's order below is that rule instantiated over the stores it
17 //! actually has. Reading top to bottom:
18 //!
19 //! 1. `auth_mode = "none"` — the route sends no credential at all.
20 //! 2. An explicit `--api-key` on the active, non-OAuth provider.
21 //! 3. `[providers.<name>] api_key_env` — a credential the route *names*.
22 //! 4. An ambient provider environment variable (official endpoints only).
23 //! 5. Provider-owned login state: an explicitly consented supported external
24 //! CLI credential file (Codex or DeepSeek Harness) or CodeWhale's own xAI
25 //! OAuth storage.
26 //! 6. A keyless self-hosted / loopback route.
27 //! 7. `[providers.<name>] api_key` in the config file.
28 //! 8. CodeWhale's durable secret store.
29 //! 9. The root `api_key` compatibility slot.
30 //! 10. The user-global `~/.codewhale/config.toml`.
31 //!
32 //! Two departures from pi are deliberate and load-bearing here:
33 //!
34 //! * Ambient env outranks the secret store for a *named* binding (step 3) and
35 //! for official-endpoint provider variables (step 4). That is CodeWhale's
36 //! existing, documented behavior and users depend on it; changing it is not
37 //! in this lane's scope. It is stated here so it is at least *visible*.
38 //! * External CLI credential files are only ever consulted through
39 //! [`Config::external_credential_read_grant`], which enforces the read-only
40 //! consent model (exact path, explicit consent, never refreshed, never
41 //! rewritten). This resolver adds no new way to reach them, and #5772 tightens
42 //! the two ends of that model:
43 //! - **Nothing happens before consent.** With no persisted consent record
44 //! for a provider, this resolver resolves no candidate path, performs no
45 //! filesystem access, and names no location. Deriving a candidate from
46 //! `HOME` just to say "absent" is itself an unconsented disclosure of where
47 //! another CLI keeps credentials.
48 //! - **A consent record is not a credential.** Consent proves the user
49 //! authorized reading one exact file; it does not prove that file still
50 //! holds a usable token. Once consent exists, the consented file is read
51 //! through the secure adapter — the read the user actually authorized — so
52 //! a missing, malformed, or expired external credential resolves as missing
53 //! rather than masquerading as a stored one.
54 //!
55 //! # Redaction
56 //!
57 //! This module never returns, logs, or renders secret material. It returns
58 //! only a [`CredentialSource`] label. Every probe that needs a value calls an
59 //! existing helper and discards the value with `.is_some()`.
60
61 use super::*;
62 use crate::credentials::{
63 AuthContext, CredentialProbe, CredentialResolution, CredentialSource,
64 context::ProcessAuthContext,
65 };
66
67 /// Resolve which place holds a credential for `provider`, using the real
68 /// process environment.
69 pub(crate) fn resolve_credential_source(
70 config: &Config,
71 identity: &ProviderIdentity,
72 ) -> CredentialResolution {
73 resolve_credential_source_with(config, identity, &ProcessAuthContext)
74 }
75
76 /// Resolve with an injected [`AuthContext`].
77 ///
78 /// Only the ambient reads this function performs *itself* go through `ctx`.
79 /// The provider-specific helpers it delegates to (secret store, external
80 /// grants, xAI OAuth) still read the real environment and filesystem; making
81 /// those injectable means threading a context through config.rs and is not in
82 /// this lane.
83 pub(crate) fn resolve_credential_source_with(
84 config: &Config,
85 identity: &ProviderIdentity,
86 ctx: &dyn AuthContext,
87 ) -> CredentialResolution {
88 if let Err(error) = config.verify_provider_identity(identity) {
89 return CredentialResolution::missing(vec![CredentialProbe::with_fix(
90 error,
91 "repair the exact provider kind/id pair",
92 )]);
93 }
94 let provider = identity.provider;
95 let mut probed: Vec<CredentialProbe> = Vec::new();
96
97 let auth_mode = config.auth_mode_for_provider(identity);
98 if auth_mode_disables_api_key(auth_mode.as_deref()) {
99 return CredentialResolution::found(CredentialSource::AuthModeNone);
100 }
101
102 // The public ChatGPT API accepts only Codewhale's own issued registration.
103 // Environment tokens and consented Codex files belong to different clients.
104 if provider == ProviderKind::OpenaiCodex && !config.provider_uses_custom_endpoint(identity) {
105 if let Some(sign_in) =
106 crate::oauth::usable_sign_in(crate::oauth::OAuthProvider::Chatgpt, config)
107 {
108 return CredentialResolution::found(CredentialSource::OAuth {
109 flow: "ChatGPT".to_string(),
110 account: sign_in.account_label,
111 });
112 }
113 probed.push(CredentialProbe::with_fix(
114 "Codewhale-owned ChatGPT sign-in",
115 "codewhale auth chatgpt",
116 ));
117 return CredentialResolution::missing(probed);
118 }
119
120 if config
121 .active_provider_identity()
122 .is_ok_and(|active| active == *identity)
123 && !provider_uses_oauth_credentials(config, identity)
124 && explicit_cli_api_key_override().is_some()
125 {
126 return CredentialResolution::found(CredentialSource::CliOverride);
127 }
128
129 if let Some(var) = bound_provider_api_key_env_name(config, identity) {
130 if provider_config_env_api_key(config, identity).is_some() {
131 return CredentialResolution::found(CredentialSource::ProviderConfigEnv { var });
132 }
133 probed.push(CredentialProbe::with_fix(
134 format!("env {var} (bound by api_key_env)"),
135 format!("export {var}=<key>"),
136 ));
137 }
138
139 let skip_secret_store = config.should_skip_secret_store_for_provider(identity);
140 if !skip_secret_store {
141 if let Some(var) = provider
142 .provider()
143 .env_vars()
144 .iter()
145 .find(|var| ctx.env(var).is_some())
146 {
147 return CredentialResolution::found(CredentialSource::AmbientEnv {
148 var: (*var).to_string(),
149 });
150 }
151 if let Some(var) = provider.provider().env_vars().first() {
152 probed.push(CredentialProbe::with_fix(
153 format!("env {}", provider.provider().env_vars().join(" / ")),
154 format!("export {var}=<key>"),
155 ));
156 }
157 }
158
159 if provider == ProviderKind::Moonshot && provider_uses_oauth_credentials(config, identity) {
160 // Kimi CLI credentials are never imported; the route needs its own key.
161 probed.push(CredentialProbe::with_fix(
162 "Kimi CLI credentials (never imported)",
163 "codewhale auth set --provider moonshot",
164 ));
165 return CredentialResolution::missing(probed);
166 }
167 if provider == ProviderKind::Xai
168 && !config.provider_uses_custom_endpoint(identity)
169 && let Some(sign_in) =
170 crate::oauth::usable_sign_in(crate::oauth::OAuthProvider::Xai, config)
171 {
172 // xAI supports both API keys and OAuth. A Grok-compatible token file is
173 // sufficient, but its absence must fall through to the ordinary API-key
174 // checks below instead of masking a configured key.
175 return CredentialResolution::found(CredentialSource::OAuth {
176 flow: "xAI".to_string(),
177 account: sign_in.account_label,
178 });
179 }
180 if matches!(
181 provider,
182 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
183 ) && identity.key.as_str() != codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id
184 && !config.provider_uses_custom_endpoint(identity)
185 {
186 match resolve_external_grant(
187 config,
188 identity,
189 codewhale_config::ExternalCredentialSource::DshCli,
190 "DeepSeek Harness",
191 "codewhale auth external-consent --provider deepseek --mode read-only",
192 |grant| {
193 crate::dsh_credentials::deepseek_api_key_from_grant(grant)
194 .ok()
195 .flatten()
196 .is_some()
197 },
198 ) {
199 Ok(source) => return CredentialResolution::found(source),
200 Err(probe) => probed.push(probe),
201 }
202 }
203
204 if !auth_mode_requires_api_key(auth_mode.as_deref())
205 && (provider_route_is_keyless_self_hosted(provider, &config.base_url_for_route(identity))
206 || (config
207 .active_provider_identity()
208 .is_ok_and(|active| active == *identity)
209 && base_url_uses_local_host(&config.active_route_base_url())))
210 {
211 return CredentialResolution::found(CredentialSource::KeylessRoute {
212 base_url: config.base_url_for_route(identity),
213 });
214 }
215
216 if config.config_credentials_are_bound_to_provider_endpoint(identity) {
217 if config
218 .provider_route_string_with_deepseek_fallback(identity, |entry| entry.api_key.clone())
219 .is_some_and(|key| {
220 classify_config_api_key_value(&key) == ConfigApiKeyValueKind::Literal
221 })
222 {
223 return CredentialResolution::found(CredentialSource::ProviderConfigApiKey {
224 table: provider_config_table_name(identity)
225 .unwrap_or_else(|_| format!("providers.{}", identity.key.as_str())),
226 });
227 }
228 if let Ok(table) = provider_config_table_name(identity) {
229 probed.push(CredentialProbe::with_fix(
230 format!("[{table}] api_key"),
231 format!("add api_key to [{table}] in ~/.codewhale/config.toml"),
232 ));
233 }
234 }
235 // Probe the active provider, plus any provider whose persisted
236 // `[providers.<name>]` table carries the marker the secret-store save
237 // path itself writes (an api-key auth mode with no config literal). A
238 // configured-but-inactive provider must not render as unconfigured just
239 // because the operator switched providers after saving its key (#5033).
240 // Shared-slot families (one account, several provider variants — e.g.
241 // Model Studio Token/Coding Plan × OpenAI/Anthropic dialects) honor the
242 // marker written by ANY sibling variant, since the save path stores one
243 // key under the family's canonical slot. The probe stays bounded to
244 // explicitly configured providers, and the non-active case is strictly
245 // read-only so rendering the catalog never migrates a legacy store or
246 // opens a write-capable backend.
247 if !skip_secret_store {
248 let slot = provider_secret_store_slot(provider).to_string();
249 if config
250 .active_provider_identity()
251 .is_ok_and(|active| active == *identity)
252 {
253 if provider_secret_store_api_key(config, identity).is_some() {
254 return CredentialResolution::found(CredentialSource::SecretStore { slot });
255 }
256 probed.push(secret_store_probe(&slot, identity));
257 } else if secret_slot_save_marker_on_shared_slot(config, identity) {
258 if provider_secret_store_api_key_with_mode(config, identity, true).is_some() {
259 return CredentialResolution::found(CredentialSource::SecretStore { slot });
260 }
261 probed.push(secret_store_probe(&slot, identity));
262 } else {
263 // #5033's marker gate: without a `[providers.<name>]` api-key
264 // auth-mode marker the store is not read at all for an inactive
265 // provider. Say so, because the row is otherwise indistinguishable
266 // from a genuinely empty slot — and the request path *would* read
267 // it once this provider became active.
268 probed.push(CredentialProbe::with_fix(
269 format!(
270 "secret store \"{slot}\" (not read: inactive provider, no api-key marker)"
271 ),
272 format!(
273 "codewhale auth set --provider {} writes the marker that makes this slot readable while inactive",
274 identity.key.as_str()
275 ),
276 ));
277 }
278 }
279
280 // Last resort: the user-global config file. A key saved there must not
281 // disappear just because this process loaded a workspace config.
282 if user_global_config_api_key(identity).is_some() {
283 return CredentialResolution::found(CredentialSource::UserGlobalConfig);
284 }
285 probed.push(CredentialProbe::with_fix(
286 "~/.codewhale/config.toml",
287 format!("codewhale auth set --provider {}", identity.key.as_str()),
288 ));
289
290 if config.account_model_api_key(identity).is_some() {
291 return CredentialResolution::found(CredentialSource::AccountSession);
292 }
293
294 CredentialResolution::missing(probed)
295 }
296
297 fn secret_store_probe(slot: &str, identity: &ProviderIdentity) -> CredentialProbe {
298 CredentialProbe::with_fix(
299 format!("secret store \"{slot}\""),
300 format!("codewhale auth set --provider {}", identity.key.as_str()),
301 )
302 }
303
304 /// Resolve one external CLI credential owner for `provider` (#5772).
305 ///
306 /// The order here is the whole invariant, and each step is gated on the one
307 /// before it:
308 ///
309 /// 1. **No consent record** — nothing is resolved, stat'ed, read, or named.
310 /// The probe offers only the explicit consent command, because deriving a
311 /// candidate path from `HOME` in order to report it would already disclose
312 /// where another CLI keeps credentials.
313 /// 2. **Consent record, provider not active** — the grant is refused by
314 /// [`Config::external_credential_read_grant`], so the record is reported as
315 /// dormant. Still no filesystem access.
316 /// 3. **Consent record, provider active** — the exact consented file is read
317 /// through the secure adapter and `validate` decides whether it holds a
318 /// usable credential. Structural consent alone never resolves as found.
319 fn resolve_external_grant(
320 config: &Config,
321 identity: &ProviderIdentity,
322 source: codewhale_config::ExternalCredentialSource,
323 cli: &str,
324 consent_command: &str,
325 validate: impl FnOnce(&codewhale_config::ExternalCredentialReadGrant) -> bool,
326 ) -> Result<CredentialSource, CredentialProbe> {
327 let Some(consent) = config
328 .provider_config_for(identity)
329 .and_then(|entry| entry.external_credentials.as_ref())
330 else {
331 return Err(CredentialProbe::with_fix(
332 format!("{cli} credentials (no read-only consent recorded)"),
333 consent_command.to_string(),
334 ));
335 };
336 // The pinned path comes from the consent record the user confirmed, never
337 // from an ambient candidate, so no resolver runs here either.
338 let Ok(grant) = config.external_credential_read_grant(identity, source, &consent.path) else {
339 return Err(CredentialProbe::with_fix(
340 format!("{cli} credentials (consent dormant until this provider is selected)"),
341 format!("codewhale config set provider {}", identity.key.as_str()),
342 ));
343 };
344 if validate(&grant) {
345 return Ok(CredentialSource::ExternalGrant {
346 cli: cli.to_string(),
347 path: consent.path.display().to_string(),
348 });
349 }
350 // Consented, read, and unusable: missing, malformed, or expired. Read-only
351 // consent never refreshes another CLI's file, so the fix is to renew it
352 // there — not to re-consent here.
353 Err(CredentialProbe::with_fix(
354 format!("{cli} credentials (consented, but no usable credential in that file)"),
355 format!(
356 "log in again with {cli}, or run codewhale auth set --provider {}",
357 identity.key.as_str()
358 ),
359 ))
360 }
361
362 #[cfg(test)]
363 mod tests {
364 use super::*;
365 use crate::credentials::context::MapAuthContext;
366 use crate::test_support::{EnvVarGuard, lock_test_env};
367
368 fn deepseek_config() -> Config {
369 Config {
370 provider: Some("deepseek".to_string()),
371 ..Config::default()
372 }
373 }
374
375 /// The precedence rule has to be enforced somewhere a test can see it.
376 #[test]
377 fn a_named_env_binding_resolves_and_names_itself() {
378 let _lock = lock_test_env();
379 let _key = EnvVarGuard::set("CW_TEST_BOUND_KEY", "bound-value");
380 let config = Config {
381 provider: Some("openrouter".to_string()),
382 providers: Some(
383 toml::from_str("[openrouter]\napi_key_env = \"CW_TEST_BOUND_KEY\"\n")
384 .expect("provider table"),
385 ),
386 ..Config::default()
387 };
388 let resolution = resolve_credential_source(
389 &config,
390 &(config).test_identity_for_kind(ProviderKind::Openrouter),
391 );
392 assert_eq!(
393 resolution.source,
394 CredentialSource::ProviderConfigEnv {
395 var: "CW_TEST_BOUND_KEY".to_string()
396 },
397 "a route that names its variable must resolve from it and say so"
398 );
399 assert_eq!(resolution.source.label(), "api_key_env CW_TEST_BOUND_KEY");
400 }
401
402 /// An ambient export must name the exact variable that won, not just
403 /// "configured" — this is pi's `source: "ANTHROPIC_API_KEY"`.
404 #[test]
405 fn ambient_env_names_the_variable_that_won() {
406 let _lock = lock_test_env();
407 let ctx = MapAuthContext::new().with_env("OPENROUTER_API_KEY", "value");
408 let config = Config::default();
409 let resolution = resolve_credential_source_with(
410 &config,
411 &(config).test_identity_for_kind(ProviderKind::Openrouter),
412 &ctx,
413 );
414 assert_eq!(
415 resolution.source,
416 CredentialSource::AmbientEnv {
417 var: "OPENROUTER_API_KEY".to_string()
418 }
419 );
420 assert_eq!(resolution.source.label(), "OPENROUTER_API_KEY");
421 }
422
423 /// The regression this whole lane exists for: a provider with no
424 /// credential anywhere used to report a bare boolean. It must now name
425 /// every place that was probed, in precedence order, and offer a fix.
426 #[test]
427 fn a_missing_credential_names_every_place_that_was_checked() {
428 let _lock = lock_test_env();
429 let ctx = MapAuthContext::new();
430 let config = Config::default();
431 let resolution = resolve_credential_source_with(
432 &config,
433 &(config).test_identity_for_kind(ProviderKind::Openrouter),
434 &ctx,
435 );
436 assert!(!resolution.is_present());
437
438 let checked = resolution.checked_places();
439 assert!(
440 checked.contains("OPENROUTER_API_KEY"),
441 "the ambient variable must be named: {checked}"
442 );
443 assert!(
444 checked.contains("secret store \"openrouter\""),
445 "the durable slot must be named: {checked}"
446 );
447 assert!(
448 checked.contains("~/.codewhale/config.toml"),
449 "the user-global config must be named: {checked}"
450 );
451 assert_eq!(
452 resolution.first_fix(),
453 Some("export OPENROUTER_API_KEY=<key>"),
454 "the first probed place must carry the command that fixes it"
455 );
456 }
457
458 /// #5033's marker gate is a real asymmetry between what the picker
459 /// reports and what the request path would find: for a provider that is
460 /// not active and whose config table carries no api-key marker, the
461 /// durable slot is *not read at all*. That is defensible, but it must be
462 /// visible — a user staring at "missing key" has to be told the slot was
463 /// skipped rather than found empty.
464 #[test]
465 fn an_unread_secret_slot_says_it_was_not_read_and_why() {
466 let _lock = lock_test_env();
467 let ctx = MapAuthContext::new();
468 let config = deepseek_config();
469 let resolution = resolve_credential_source_with(
470 &config,
471 &(config).test_identity_for_kind(ProviderKind::Openrouter),
472 &ctx,
473 );
474
475 let checked = resolution.checked_places();
476 assert!(
477 checked.contains("(not read: inactive provider, no api-key marker)"),
478 "an unread slot must not look like an empty one: {checked}"
479 );
480 }
481
482 /// `auth_mode = "none"` is a resolution, not an absence.
483 #[test]
484 fn no_auth_routes_resolve_to_the_auth_mode_itself() {
485 let _lock = lock_test_env();
486 let config = Config {
487 providers: Some(
488 toml::from_str("[openrouter]\nauth_mode = \"none\"\n").expect("provider table"),
489 ),
490 ..Config::default()
491 };
492 let resolution = resolve_credential_source(
493 &config,
494 &(config).test_identity_for_kind(ProviderKind::Openrouter),
495 );
496 assert_eq!(resolution.source, CredentialSource::AuthModeNone);
497 assert!(resolution.is_present());
498 assert!(resolution.checked_places().is_empty());
499 }
500
501 /// The resolver is the sole authority; `has_api_key_for` must agree with
502 /// it for every provider, or two surfaces can disagree again.
503 #[test]
504 fn has_api_key_for_agrees_with_the_resolver_for_every_provider() {
505 let _lock = lock_test_env();
506 let mut config = Config::default();
507 config
508 .providers
509 .get_or_insert_with(Default::default)
510 .custom
511 .insert(
512 "custom".into(),
513 crate::config::ProviderConfig {
514 kind: Some("openai-compatible".into()),
515 base_url: Some("http://localhost:1234/v1".into()),
516 model: Some("fixture-model".into()),
517 ..Default::default()
518 },
519 );
520 for provider in ProviderKind::all() {
521 let identity = config.test_identity_for_kind(*provider);
522 let resolution = resolve_credential_source(&config, &identity);
523 assert_eq!(
524 has_api_key_for(&config, &identity),
525 resolution.is_present(),
526 "{provider:?} disagreed: {:?}",
527 resolution.source
528 );
529 }
530 }
531
532 /// #5772: with reuse off, an existing external CLI file must not be
533 /// stat'ed, read, or adopted — and the probe must not even claim whether
534 /// the candidate exists.
535 #[test]
536 fn unconsented_external_candidates_are_never_probed() {
537 let _lock = lock_test_env();
538 let temp = tempfile::tempdir().expect("external fixture");
539 let codex_path = temp
540 .path()
541 .canonicalize()
542 .expect("canonical temp root")
543 .join("auth.json");
544 std::fs::write(&codex_path, "{\"tokens\":{\"access_token\":\"x\"}}").expect("fixture");
545 let _auth = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
546 let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
547 let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
548 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
549 let config = Config {
550 provider: Some("openai-codex".to_string()),
551 ..Config::default()
552 };
553
554 crate::external_credentials::reset_side_effect_trap();
555 let resolution = resolve_credential_source(
556 &config,
557 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
558 );
559 assert!(!resolution.is_present());
560 assert!(!has_api_key_for(
561 &config,
562 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex)
563 ));
564 let checked = resolution.checked_places();
565 assert!(
566 checked.contains("Codewhale-owned ChatGPT sign-in"),
567 "the probe names the owned connection without accessing external state: {checked}"
568 );
569 assert!(
570 !checked.contains("(absent)") && !checked.contains("present, not consented"),
571 "no stat means no existence claim: {checked}"
572 );
573 assert_eq!(
574 crate::external_credentials::complete_side_effect_trap_counts(),
575 (0, 0, 0, 0, 0),
576 "resolution must not touch external credential state"
577 );
578 }
579
580 /// #5772: a consent record is not a credential. With a persisted consent
581 /// record whose pinned file is absent, resolution performs exactly the
582 /// read the user authorized — one secure open of the exact consented path —
583 /// and resolves as *missing* rather than masquerading as a stored
584 /// credential. No write, refresh, or network side effect is permitted.
585 #[test]
586 fn chatgpt_resolution_does_not_import_even_consented_external_files() {
587 let _lock = lock_test_env();
588 let temp = tempfile::tempdir().expect("external fixture");
589 let codex_path = temp
590 .path()
591 .canonicalize()
592 .expect("canonical temp root")
593 .join("absent-auth.json");
594 let _auth = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
595 let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
596 let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
597 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
598 let config = Config {
599 provider: Some("openai-codex".to_string()),
600 providers: Some(ProvidersConfig {
601 openai_codex: ProviderConfig {
602 auth_mode: Some("oauth".to_string()),
603 external_credentials: Some(
604 codewhale_config::ExternalCredentialConsentToml::read_only(
605 codewhale_config::ProviderKind::OpenaiCodex,
606 codewhale_config::ExternalCredentialSource::CodexCli,
607 codex_path.clone(),
608 ),
609 ),
610 ..ProviderConfig::default()
611 },
612 ..ProvidersConfig::default()
613 }),
614 ..Config::default()
615 };
616
617 crate::external_credentials::reset_side_effect_trap();
618 let resolution = resolve_credential_source(
619 &config,
620 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
621 );
622 assert!(
623 !resolution.is_present(),
624 "a consent record whose file is gone must resolve as missing: {:?}",
625 resolution.source
626 );
627 assert!(
628 resolution
629 .checked_places()
630 .contains("Codewhale-owned ChatGPT sign-in"),
631 "the probe requires Codewhale sign-in despite external consent: {}",
632 resolution.checked_places()
633 );
634 assert_eq!(
635 crate::external_credentials::complete_side_effect_trap_counts(),
636 (0, 0, 0, 0, 0),
637 "external consent cannot authorize the public ChatGPT API"
638 );
639 assert!(!has_api_key_for(
640 &config,
641 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex)
642 ));
643 assert_eq!(
644 crate::external_credentials::complete_side_effect_trap_counts(),
645 (0, 0, 0, 0, 0),
646 "has_api_key_for never reads another client credential"
647 );
648 }
649 }
650
650 lines RUST