返回 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 provider: ApiProvider,
72 ) -> CredentialResolution {
73 resolve_credential_source_with(config, provider, &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 provider: ApiProvider,
86 ctx: &dyn AuthContext,
87 ) -> CredentialResolution {
88 let mut probed: Vec<CredentialProbe> = Vec::new();
89
90 let auth_mode = config.auth_mode_for_provider(provider);
91 if auth_mode_disables_api_key(auth_mode.as_deref()) {
92 return CredentialResolution::found(CredentialSource::AuthModeNone);
93 }
94
95 if provider == config.api_provider()
96 && !provider_uses_oauth_credentials(config, provider)
97 && explicit_cli_api_key_override().is_some()
98 {
99 return CredentialResolution::found(CredentialSource::CliOverride);
100 }
101
102 if let Some(var) = bound_provider_api_key_env_name(config, provider) {
103 if provider_config_env_api_key(config, provider).is_some() {
104 return CredentialResolution::found(CredentialSource::ProviderConfigEnv { var });
105 }
106 probed.push(CredentialProbe::with_fix(
107 format!("env {var} (bound by api_key_env)"),
108 format!("export {var}=<key>"),
109 ));
110 }
111
112 let skip_secret_store = config.should_skip_secret_store_for_provider(provider);
113 if !skip_secret_store {
114 if let Some(var) = provider
115 .env_vars()
116 .iter()
117 .find(|var| ctx.env(var).is_some())
118 {
119 return CredentialResolution::found(CredentialSource::AmbientEnv {
120 var: (*var).to_string(),
121 });
122 }
123 if let Some(var) = provider.env_vars().first() {
124 probed.push(CredentialProbe::with_fix(
125 format!("env {}", provider.env_vars_label()),
126 format!("export {var}=<key>"),
127 ));
128 }
129 }
130
131 if provider == ApiProvider::Moonshot && provider_uses_oauth_credentials(config, provider) {
132 // Kimi CLI credentials are never imported; the route needs its own key.
133 probed.push(CredentialProbe::with_fix(
134 "Kimi CLI credentials (never imported)",
135 "codewhale auth set --provider moonshot",
136 ));
137 return CredentialResolution::missing(probed);
138 }
139 if provider == ApiProvider::OpenaiCodex && !config.provider_uses_custom_endpoint(provider) {
140 if crate::oauth::credentials_present(crate::oauth::OAuthProvider::Chatgpt, config) {
141 return CredentialResolution::found(CredentialSource::OAuth {
142 flow: "ChatGPT".to_string(),
143 });
144 }
145 probed.push(CredentialProbe::with_fix(
146 "Codewhale-owned ChatGPT sign-in",
147 "codewhale auth chatgpt",
148 ));
149 // Token env overrides are checked above. An external Codex login is
150 // considered only after exact read-only consent has been validated.
151 match resolve_external_grant(
152 config,
153 provider,
154 codewhale_config::ExternalCredentialSource::CodexCli,
155 "Codex CLI",
156 "codewhale auth external-consent --provider openai-codex --mode read-only",
157 crate::oauth::stored_credentials_present,
158 ) {
159 Ok(source) => return CredentialResolution::found(source),
160 Err(probe) => {
161 probed.push(probe);
162 return CredentialResolution::missing(probed);
163 }
164 }
165 }
166 if provider == ApiProvider::Xai
167 && !config.provider_uses_custom_endpoint(provider)
168 && crate::oauth::credentials_present(crate::oauth::OAuthProvider::Xai, config)
169 {
170 // xAI supports both API keys and OAuth. A Grok-compatible token file is
171 // sufficient, but its absence must fall through to the ordinary API-key
172 // checks below instead of masking a configured key.
173 return CredentialResolution::found(CredentialSource::OAuth {
174 flow: "xAI".to_string(),
175 });
176 }
177 if matches!(
178 provider,
179 ApiProvider::Deepseek | ApiProvider::DeepseekAnthropic
180 ) && !config.provider_uses_custom_endpoint(provider)
181 {
182 match resolve_external_grant(
183 config,
184 provider,
185 codewhale_config::ExternalCredentialSource::DshCli,
186 "DeepSeek Harness",
187 "codewhale auth external-consent --provider deepseek --mode read-only",
188 |grant| {
189 crate::dsh_credentials::deepseek_api_key_from_grant(grant)
190 .ok()
191 .flatten()
192 .is_some()
193 },
194 ) {
195 Ok(source) => return CredentialResolution::found(source),
196 Err(probe) => probed.push(probe),
197 }
198 }
199
200 if !auth_mode_requires_api_key(auth_mode.as_deref())
201 && (provider_route_is_keyless_self_hosted(provider, &config.base_url_for_route(provider))
202 || (provider == config.api_provider()
203 && base_url_uses_local_host(&config.active_route_base_url())))
204 {
205 return CredentialResolution::found(CredentialSource::KeylessRoute {
206 base_url: config.base_url_for_route(provider),
207 });
208 }
209
210 if config.config_credentials_are_bound_to_provider_endpoint(provider) {
211 if config
212 .provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone())
213 .is_some_and(|key| {
214 classify_config_api_key_value(&key) == ConfigApiKeyValueKind::Literal
215 })
216 {
217 return CredentialResolution::found(CredentialSource::ProviderConfigApiKey {
218 table: provider_config_table_name(provider)
219 .unwrap_or_else(|_| format!("providers.{}", provider.as_str())),
220 });
221 }
222 if let Ok(table) = provider_config_table_name(provider) {
223 probed.push(CredentialProbe::with_fix(
224 format!("[{table}] api_key"),
225 format!("add api_key to [{table}] in ~/.codewhale/config.toml"),
226 ));
227 }
228 }
229 // Probe the active provider, plus any provider whose persisted
230 // `[providers.<name>]` table carries the marker the secret-store save
231 // path itself writes (an api-key auth mode with no config literal). A
232 // configured-but-inactive provider must not render as unconfigured just
233 // because the operator switched providers after saving its key (#5033).
234 // Shared-slot families (one account, several provider variants — e.g.
235 // Model Studio Token/Coding Plan × OpenAI/Anthropic dialects) honor the
236 // marker written by ANY sibling variant, since the save path stores one
237 // key under the family's canonical slot. The probe stays bounded to
238 // explicitly configured providers, and the non-active case is strictly
239 // read-only so rendering the catalog never migrates a legacy store or
240 // opens a write-capable backend.
241 if !skip_secret_store {
242 let slot = provider_secret_store_slot(provider).to_string();
243 if provider == config.api_provider() {
244 if provider_secret_store_api_key(config, provider).is_some() {
245 return CredentialResolution::found(CredentialSource::SecretStore { slot });
246 }
247 probed.push(secret_store_probe(&slot, provider));
248 } else if secret_slot_save_marker_on_shared_slot(config, provider) {
249 if provider_secret_store_api_key_with_mode(config, provider, true).is_some() {
250 return CredentialResolution::found(CredentialSource::SecretStore { slot });
251 }
252 probed.push(secret_store_probe(&slot, provider));
253 } else {
254 // #5033's marker gate: without a `[providers.<name>]` api-key
255 // auth-mode marker the store is not read at all for an inactive
256 // provider. Say so, because the row is otherwise indistinguishable
257 // from a genuinely empty slot — and the request path *would* read
258 // it once this provider became active.
259 probed.push(CredentialProbe::with_fix(
260 format!(
261 "secret store \"{slot}\" (not read: inactive provider, no api-key marker)"
262 ),
263 format!(
264 "codewhale auth set --provider {} writes the marker that makes this slot readable while inactive",
265 provider.as_str()
266 ),
267 ));
268 }
269 }
270
271 if (matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
272 || (provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route()))
273 && config.config_credentials_are_bound_to_provider_endpoint(provider)
274 && config
275 .api_key
276 .as_ref()
277 .is_some_and(|key| classify_config_api_key_value(key) == ConfigApiKeyValueKind::Literal)
278 {
279 return CredentialResolution::found(CredentialSource::RootConfigApiKey);
280 }
281
282 // Last resort: the user-global config file. A key saved there must not
283 // disappear just because this process loaded a workspace config.
284 if user_global_config_api_key(provider).is_some() {
285 return CredentialResolution::found(CredentialSource::UserGlobalConfig);
286 }
287 probed.push(CredentialProbe::with_fix(
288 "~/.codewhale/config.toml",
289 format!("codewhale auth set --provider {}", provider.as_str()),
290 ));
291
292 if config.account_model_api_key(provider).is_some() {
293 return CredentialResolution::found(CredentialSource::AccountSession);
294 }
295
296 CredentialResolution::missing(probed)
297 }
298
299 fn secret_store_probe(slot: &str, provider: ApiProvider) -> CredentialProbe {
300 CredentialProbe::with_fix(
301 format!("secret store \"{slot}\""),
302 format!("codewhale auth set --provider {}", provider.as_str()),
303 )
304 }
305
306 /// Resolve one external CLI credential owner for `provider` (#5772).
307 ///
308 /// The order here is the whole invariant, and each step is gated on the one
309 /// before it:
310 ///
311 /// 1. **No consent record** — nothing is resolved, stat'ed, read, or named.
312 /// The probe offers only the explicit consent command, because deriving a
313 /// candidate path from `HOME` in order to report it would already disclose
314 /// where another CLI keeps credentials.
315 /// 2. **Consent record, provider not active** — the grant is refused by
316 /// [`Config::external_credential_read_grant`], so the record is reported as
317 /// dormant. Still no filesystem access.
318 /// 3. **Consent record, provider active** — the exact consented file is read
319 /// through the secure adapter and `validate` decides whether it holds a
320 /// usable credential. Structural consent alone never resolves as found.
321 fn resolve_external_grant(
322 config: &Config,
323 provider: ApiProvider,
324 source: codewhale_config::ExternalCredentialSource,
325 cli: &str,
326 consent_command: &str,
327 validate: impl FnOnce(&codewhale_config::ExternalCredentialReadGrant) -> bool,
328 ) -> Result<CredentialSource, CredentialProbe> {
329 let Some(consent) = config
330 .provider_config_for(provider)
331 .and_then(|entry| entry.external_credentials.as_ref())
332 else {
333 return Err(CredentialProbe::with_fix(
334 format!("{cli} credentials (no read-only consent recorded)"),
335 consent_command.to_string(),
336 ));
337 };
338 // The pinned path comes from the consent record the user confirmed, never
339 // from an ambient candidate, so no resolver runs here either.
340 let Ok(grant) = config.external_credential_read_grant(provider, source, &consent.path) else {
341 return Err(CredentialProbe::with_fix(
342 format!("{cli} credentials (consent dormant until this provider is selected)"),
343 format!("codewhale config set provider {}", provider.as_str()),
344 ));
345 };
346 if validate(&grant) {
347 return Ok(CredentialSource::ExternalGrant {
348 cli: cli.to_string(),
349 path: consent.path.display().to_string(),
350 });
351 }
352 // Consented, read, and unusable: missing, malformed, or expired. Read-only
353 // consent never refreshes another CLI's file, so the fix is to renew it
354 // there — not to re-consent here.
355 Err(CredentialProbe::with_fix(
356 format!("{cli} credentials (consented, but no usable credential in that file)"),
357 format!(
358 "log in again with {cli}, or run codewhale auth set --provider {}",
359 provider.as_str()
360 ),
361 ))
362 }
363
364 #[cfg(test)]
365 mod tests {
366 use super::*;
367 use crate::credentials::context::MapAuthContext;
368 use crate::test_support::{EnvVarGuard, lock_test_env};
369
370 fn deepseek_config() -> Config {
371 Config {
372 provider: Some("deepseek".to_string()),
373 ..Config::default()
374 }
375 }
376
377 /// The precedence rule has to be enforced somewhere a test can see it.
378 #[test]
379 fn a_named_env_binding_resolves_and_names_itself() {
380 let _lock = lock_test_env();
381 let _key = EnvVarGuard::set("CW_TEST_BOUND_KEY", "bound-value");
382 let config = Config {
383 provider: Some("openrouter".to_string()),
384 providers: Some(
385 toml::from_str("[openrouter]\napi_key_env = \"CW_TEST_BOUND_KEY\"\n")
386 .expect("provider table"),
387 ),
388 ..Config::default()
389 };
390 let resolution = resolve_credential_source(&config, ApiProvider::Openrouter);
391 assert_eq!(
392 resolution.source,
393 CredentialSource::ProviderConfigEnv {
394 var: "CW_TEST_BOUND_KEY".to_string()
395 },
396 "a route that names its variable must resolve from it and say so"
397 );
398 assert_eq!(resolution.source.label(), "api_key_env CW_TEST_BOUND_KEY");
399 }
400
401 /// An ambient export must name the exact variable that won, not just
402 /// "configured" — this is pi's `source: "ANTHROPIC_API_KEY"`.
403 #[test]
404 fn ambient_env_names_the_variable_that_won() {
405 let _lock = lock_test_env();
406 let ctx = MapAuthContext::new().with_env("OPENROUTER_API_KEY", "value");
407 let config = Config::default();
408 let resolution = resolve_credential_source_with(&config, ApiProvider::Openrouter, &ctx);
409 assert_eq!(
410 resolution.source,
411 CredentialSource::AmbientEnv {
412 var: "OPENROUTER_API_KEY".to_string()
413 }
414 );
415 assert_eq!(resolution.source.label(), "OPENROUTER_API_KEY");
416 }
417
418 /// The regression this whole lane exists for: a provider with no
419 /// credential anywhere used to report a bare boolean. It must now name
420 /// every place that was probed, in precedence order, and offer a fix.
421 #[test]
422 fn a_missing_credential_names_every_place_that_was_checked() {
423 let _lock = lock_test_env();
424 let ctx = MapAuthContext::new();
425 let config = Config::default();
426 let resolution = resolve_credential_source_with(&config, ApiProvider::Openrouter, &ctx);
427 assert!(!resolution.is_present());
428
429 let checked = resolution.checked_places();
430 assert!(
431 checked.contains("OPENROUTER_API_KEY"),
432 "the ambient variable must be named: {checked}"
433 );
434 assert!(
435 checked.contains("secret store \"openrouter\""),
436 "the durable slot must be named: {checked}"
437 );
438 assert!(
439 checked.contains("~/.codewhale/config.toml"),
440 "the user-global config must be named: {checked}"
441 );
442 assert_eq!(
443 resolution.first_fix(),
444 Some("export OPENROUTER_API_KEY=<key>"),
445 "the first probed place must carry the command that fixes it"
446 );
447 }
448
449 /// #5033's marker gate is a real asymmetry between what the picker
450 /// reports and what the request path would find: for a provider that is
451 /// not active and whose config table carries no api-key marker, the
452 /// durable slot is *not read at all*. That is defensible, but it must be
453 /// visible — a user staring at "missing key" has to be told the slot was
454 /// skipped rather than found empty.
455 #[test]
456 fn an_unread_secret_slot_says_it_was_not_read_and_why() {
457 let _lock = lock_test_env();
458 let ctx = MapAuthContext::new();
459 let config = deepseek_config();
460 let resolution = resolve_credential_source_with(&config, ApiProvider::Openrouter, &ctx);
461
462 let checked = resolution.checked_places();
463 assert!(
464 checked.contains("(not read: inactive provider, no api-key marker)"),
465 "an unread slot must not look like an empty one: {checked}"
466 );
467 }
468
469 /// `auth_mode = "none"` is a resolution, not an absence.
470 #[test]
471 fn no_auth_routes_resolve_to_the_auth_mode_itself() {
472 let _lock = lock_test_env();
473 let config = Config {
474 providers: Some(
475 toml::from_str("[openrouter]\nauth_mode = \"none\"\n").expect("provider table"),
476 ),
477 ..Config::default()
478 };
479 let resolution = resolve_credential_source(&config, ApiProvider::Openrouter);
480 assert_eq!(resolution.source, CredentialSource::AuthModeNone);
481 assert!(resolution.is_present());
482 assert!(resolution.checked_places().is_empty());
483 }
484
485 /// The resolver is the sole authority; `has_api_key_for` must agree with
486 /// it for every provider, or two surfaces can disagree again.
487 #[test]
488 fn has_api_key_for_agrees_with_the_resolver_for_every_provider() {
489 let _lock = lock_test_env();
490 let config = Config::default();
491 for provider in ApiProvider::all() {
492 let resolution = resolve_credential_source(&config, *provider);
493 assert_eq!(
494 has_api_key_for(&config, *provider),
495 resolution.is_present(),
496 "{provider:?} disagreed: {:?}",
497 resolution.source
498 );
499 }
500 }
501
502 /// #5772: with reuse off, an existing external CLI file must not be
503 /// stat'ed, read, or adopted — and the probe must not even claim whether
504 /// the candidate exists.
505 #[test]
506 fn unconsented_external_candidates_are_never_probed() {
507 let _lock = lock_test_env();
508 let temp = tempfile::tempdir().expect("external fixture");
509 let codex_path = temp
510 .path()
511 .canonicalize()
512 .expect("canonical temp root")
513 .join("auth.json");
514 std::fs::write(&codex_path, "{\"tokens\":{\"access_token\":\"x\"}}").expect("fixture");
515 let _auth = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
516 let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
517 let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
518 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
519 let config = Config {
520 provider: Some("openai-codex".to_string()),
521 ..Config::default()
522 };
523
524 crate::external_credentials::reset_side_effect_trap();
525 let resolution = resolve_credential_source(&config, ApiProvider::OpenaiCodex);
526 assert!(!resolution.is_present());
527 assert!(!has_api_key_for(&config, ApiProvider::OpenaiCodex));
528 let checked = resolution.checked_places();
529 assert!(
530 checked.contains("no read-only consent recorded"),
531 "the probe explains the missing consent without an existence claim: {checked}"
532 );
533 assert!(
534 !checked.contains("(absent)") && !checked.contains("present, not consented"),
535 "no stat means no existence claim: {checked}"
536 );
537 assert_eq!(
538 crate::external_credentials::complete_side_effect_trap_counts(),
539 (0, 0, 0, 0, 0),
540 "resolution must not touch external credential state"
541 );
542 }
543
544 /// #5772: a consent record is not a credential. With a persisted consent
545 /// record whose pinned file is absent, resolution performs exactly the
546 /// read the user authorized — one secure open of the exact consented path —
547 /// and resolves as *missing* rather than masquerading as a stored
548 /// credential. No write, refresh, or network side effect is permitted.
549 #[test]
550 fn consented_external_resolution_validates_the_exact_consented_file() {
551 let _lock = lock_test_env();
552 let temp = tempfile::tempdir().expect("external fixture");
553 let codex_path = temp
554 .path()
555 .canonicalize()
556 .expect("canonical temp root")
557 .join("absent-auth.json");
558 let _auth = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
559 let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
560 let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
561 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
562 let config = Config {
563 provider: Some("openai-codex".to_string()),
564 providers: Some(ProvidersConfig {
565 openai_codex: ProviderConfig {
566 auth_mode: Some("oauth".to_string()),
567 external_credentials: Some(
568 codewhale_config::ExternalCredentialConsentToml::read_only(
569 codewhale_config::ProviderKind::OpenaiCodex,
570 codewhale_config::ExternalCredentialSource::CodexCli,
571 codex_path.clone(),
572 ),
573 ),
574 ..ProviderConfig::default()
575 },
576 ..ProvidersConfig::default()
577 }),
578 ..Config::default()
579 };
580
581 crate::external_credentials::reset_side_effect_trap();
582 let resolution = resolve_credential_source(&config, ApiProvider::OpenaiCodex);
583 assert!(
584 !resolution.is_present(),
585 "a consent record whose file is gone must resolve as missing: {:?}",
586 resolution.source
587 );
588 assert!(
589 resolution
590 .checked_places()
591 .contains("consented, but no usable credential in that file"),
592 "the probe names the consented-read outcome: {}",
593 resolution.checked_places()
594 );
595 assert_eq!(
596 crate::external_credentials::complete_side_effect_trap_counts(),
597 (1, 0, 0, 0, 0),
598 "one secure open attempt of the exact consented path; NotFound stops before the read"
599 );
600 assert!(!has_api_key_for(&config, ApiProvider::OpenaiCodex));
601 assert_eq!(
602 crate::external_credentials::complete_side_effect_trap_counts(),
603 (2, 0, 0, 0, 0),
604 "has_api_key_for re-resolves through the same consented read; still no write/refresh/network"
605 );
606 }
607 }
608
608 lines RUST