返回 CodeWhale
secrets.rs
根目录 / crates / tui / src / runtime_api / secrets.rs
1 use axum::Json;
2 use axum::extract::{Path, State};
3 use codewhale_config::ConfigStore;
4 use serde::{Deserialize, Serialize};
5 use serde_json::{Value, json};
6
7 use crate::config::ApiProvider;
8
9 use super::{ApiError, ProviderCredentialState, RuntimeApiState};
10
11 /// Largest accepted credential payload. Provider keys are single-line
12 /// tokens; anything larger is a mistake, not a longer secret.
13 const MAX_KEY_BYTES: usize = 4 * 1024;
14
15 /// Request body cap for the key route — the key plus JSON framing.
16 pub(super) const PROVIDER_KEY_BODY_LIMIT_BYTES: usize = MAX_KEY_BYTES + 1024;
17
18 #[derive(Debug, Deserialize)]
19 #[serde(deny_unknown_fields)]
20 pub(super) struct SetProviderKeyRequest {
21 key: String,
22 }
23
24 /// Only the first-party account endpoint can receive an account device key.
25 /// This endpoint supplies a process-only reversible auth transform; it never
26 /// mutates a user's provider slot or durable config. Running turns retain their
27 /// materialized client: server-side device/session revocation is authoritative.
28 fn account_model_access_receipt(config: &crate::config::Config) -> Value {
29 let api_base = config.base_url_for_route(ApiProvider::Codewhale);
30 let supported = api_base.trim_end_matches('/') == crate::config::DEFAULT_CODEWHALE_BASE_URL
31 && super::runtime_account_api_base()
32 == codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE;
33 let access = config.account_model_access.read().clone();
34 let live = access
35 .as_ref()
36 .filter(|a| a.expires_at > chrono::Utc::now().timestamp());
37 json!({
38 "apiBase": if supported { api_base.as_str() } else { "" },
39 "supported": supported,
40 "configured": config.account_model_api_key(ApiProvider::Codewhale).is_some(),
41 "catalogRefreshNeeded": false,
42 "sessionId": live.map(|a| a.session_id.as_str()),
43 "expiresAt": live.map(|a| a.expires_at),
44 })
45 }
46
47 #[derive(Deserialize)]
48 #[serde(rename_all = "camelCase", deny_unknown_fields)]
49 pub(super) struct SetAccountModelAccessRequest {
50 api_base: String,
51 session_id: String,
52 expected_session_id: Option<String>,
53 key: String,
54 expires_at: i64,
55 }
56
57 #[derive(Deserialize)]
58 #[serde(rename_all = "camelCase", deny_unknown_fields)]
59 pub(super) struct ClearAccountModelAccessRequest {
60 session_id: String,
61 }
62
63 pub(super) async fn get_account_model_access(
64 State(state): State<RuntimeApiState>,
65 ) -> Result<Json<Value>, ApiError> {
66 tokio::task::spawn_blocking(move || {
67 Ok(Json(account_model_access_receipt(&state.config.read())))
68 })
69 .await
70 .map_err(|_| ApiError::internal("account access read failed"))?
71 }
72
73 pub(super) async fn set_account_model_access(
74 State(state): State<RuntimeApiState>,
75 Json(request): Json<SetAccountModelAccessRequest>,
76 ) -> Result<Json<Value>, ApiError> {
77 tokio::task::spawn_blocking(move || {
78 let config = state.config.write();
79 let refresh_needed =
80 install_account_model_access(&config, state.config_profile.as_deref(), request)?;
81 let mut receipt = account_model_access_receipt(&config);
82 receipt["catalogRefreshNeeded"] = json!(refresh_needed);
83 Ok(Json(receipt))
84 })
85 .await
86 .map_err(|_| ApiError::internal("account access update failed"))?
87 }
88
89 fn install_account_model_access(
90 config: &crate::config::Config,
91 profile: Option<&str>,
92 request: SetAccountModelAccessRequest,
93 ) -> Result<bool, ApiError> {
94 let expected_base = crate::config::DEFAULT_CODEWHALE_BASE_URL;
95 if request.api_base.trim_end_matches('/') != expected_base
96 || config
97 .base_url_for_route(ApiProvider::Codewhale)
98 .trim_end_matches('/')
99 != expected_base
100 || super::runtime_account_api_base() != codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE
101 {
102 return Err(ApiError::conflict(
103 "Account model access requires the first-party Codewhale endpoint.",
104 ));
105 }
106 if !request.key.starts_with("cwc_")
107 || request.key.len() < 32
108 || request.key.len() > MAX_KEY_BYTES
109 || request
110 .key
111 .chars()
112 .any(|c| c.is_control() || c.is_whitespace())
113 {
114 return Err(ApiError::bad_request("Invalid account device credential."));
115 }
116 let now = chrono::Utc::now();
117 let secrets = codewhale_secrets::account::secure_account_session_secrets()
118 .map_err(|_| ApiError::internal("Account session storage is unavailable."))?;
119 let account = codewhale_secrets::account::AccountSessionStore::new(
120 secrets,
121 profile,
122 codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
123 )
124 .runtime_info_at(now)
125 .map_err(|_| ApiError::conflict("Sign in again before connecting account models."))?;
126 let session_expiry = account
127 .expires_at
128 .as_deref()
129 .and_then(|date| chrono::DateTime::parse_from_rfc3339(date).ok())
130 .map(|date| date.timestamp());
131 if account.state != codewhale_secrets::account::AccountSessionState::Authenticated
132 || account.session_id.as_deref() != Some(request.session_id.as_str())
133 || request.expires_at <= now.timestamp()
134 || session_expiry.is_none_or(|expiry| request.expires_at > expiry)
135 {
136 return Err(ApiError::conflict(
137 "Account session changed or expired; sign in again.",
138 ));
139 }
140 // Probe the existing resolver without the account layer. Scope to Codewhale
141 // so even an inactive unmarked durable slot is checked. Never replace it.
142 let mut existing = config.clone();
143 existing.account_model_access = Default::default();
144 existing.provider = Some("codewhale".into());
145 let stored_key_present = match crate::config::credential_secret_store() {
146 Some(secrets) => secrets
147 .get("codewhale")
148 .map_err(|_| {
149 ApiError::conflict("The existing Codewhale credential could not be checked.")
150 })?
151 .is_some(),
152 None => false,
153 };
154 if stored_key_present
155 || existing
156 .provider_config_for(ApiProvider::Codewhale)
157 .is_some_and(|entry| entry.auth.is_some() || entry.api_key_env.is_some())
158 || !credential_writeability(&existing, ApiProvider::Codewhale).writable
159 || crate::config::has_api_key_for(&existing, ApiProvider::Codewhale)
160 {
161 return Err(ApiError::conflict(
162 "This Codewhale route already has its own credential.",
163 ));
164 }
165 let mut access = config.account_model_access.write();
166 let owner = access
167 .as_ref()
168 .filter(|a| a.expires_at > now.timestamp())
169 .map(|a| a.session_id.as_str());
170 if owner != request.expected_session_id.as_deref() {
171 return Err(ApiError::conflict(
172 "Account model access changed; refresh before retrying.",
173 ));
174 }
175 let changed = access.as_ref().is_none_or(|current| {
176 current.session_id != request.session_id
177 || current.profile.as_deref() != profile
178 || current.credential.expose_secret() != request.key
179 });
180 if changed {
181 invalidate_account_catalog(config);
182 }
183 *access = Some(crate::config::AccountModelAccess {
184 session_id: request.session_id,
185 credential: crate::credentials::Credential::ApiKey { key: request.key },
186 expires_at: request.expires_at,
187 profile: profile.map(str::to_string),
188 });
189 Ok(changed)
190 }
191
192 pub(super) async fn clear_account_model_access(
193 State(state): State<RuntimeApiState>,
194 Json(request): Json<ClearAccountModelAccessRequest>,
195 ) -> Result<Json<Value>, ApiError> {
196 tokio::task::spawn_blocking(move || {
197 let config = state.config.write();
198 remove_account_model_access(&config, &request.session_id)?;
199 Ok(Json(account_model_access_receipt(&config)))
200 })
201 .await
202 .map_err(|_| ApiError::internal("account access removal failed"))?
203 }
204
205 fn remove_account_model_access(
206 config: &crate::config::Config,
207 session_id: &str,
208 ) -> Result<(), ApiError> {
209 let mut access = config.account_model_access.write();
210 if access.as_ref().is_some_and(|a| a.session_id != session_id) {
211 return Err(ApiError::conflict(
212 "Account model access belongs to a different session.",
213 ));
214 }
215 if access.is_some() {
216 invalidate_account_catalog(config);
217 }
218 *access = None;
219 Ok(())
220 }
221
222 // Beginning a generation already invalidates this account-only memory roster
223 // and all older in-flight tickets. No network request or second cache is needed.
224 fn invalidate_account_catalog(config: &crate::config::Config) {
225 crate::provider_catalog_live::begin_refresh_for_identity(
226 ApiProvider::Codewhale,
227 "codewhale",
228 &config.base_url_for_route(ApiProvider::Codewhale),
229 );
230 }
231
232 pub(super) fn invalidate_stale_account_catalog(config: &crate::config::Config) {
233 let bound = config.account_model_access.read().is_some();
234 if bound
235 && config
236 .account_model_api_key(ApiProvider::Codewhale)
237 .is_none()
238 {
239 invalidate_account_catalog(config);
240 }
241 }
242
243 /// Write-only credential entry for native clients (APPS-48).
244 ///
245 /// `PUT /v1/providers/{id}/key` accepts `{ "key": "…" }`, persists it through
246 /// the same transactional path as `codewhale auth set` (secret backend plus
247 /// plaintext-free config metadata, rolled back together on failure), and
248 /// answers with the redacted receipt: which backend holds the secret and the
249 /// post-write `credential_state` readback. The key itself — and even its
250 /// length — never appears in the response, in errors, or in logs.
251 ///
252 /// There is deliberately no GET: a route that can return a secret can leak
253 /// one. Clients needing assurance re-read `credential_state` here or on
254 /// `GET /v1/providers`.
255 pub(super) async fn set_provider_key(
256 State(state): State<RuntimeApiState>,
257 Path(id): Path<String>,
258 Json(request): Json<SetProviderKeyRequest>,
259 ) -> Result<Json<Value>, ApiError> {
260 // Shared with the clear route: unknown id, legacy alias, no credential
261 // slot, and — the half #6179 was missing — a credential this route does
262 // not own, which must refuse before the write rather than appear to
263 // succeed against a source that still wins at request time.
264 let (provider, kind) = writable_provider(&state, &id)?;
265
266 let key = request.key;
267 let key = key.trim();
268 if key.is_empty() {
269 return Err(ApiError::bad_request("key must not be empty"));
270 }
271 if key.len() > MAX_KEY_BYTES || key.chars().any(char::is_control) {
272 return Err(ApiError::bad_request(
273 "key must be a single-line credential at most 4 KiB",
274 ));
275 }
276
277 let secrets = crate::config::credential_secret_store().ok_or_else(|| {
278 ApiError::internal("no credential store is available in this environment")
279 })?;
280
281 let store_path = state.config_path.clone();
282 let kind_owned = kind;
283 let key_owned = key.to_string();
284 let provider_owned = provider;
285 let (backend, saved_config_path) = tokio::task::spawn_blocking(move || {
286 let mut store = ConfigStore::load(store_path)
287 .map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?;
288 let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store)
289 .map_err(|error| ApiError::internal(format!("credential store: {error}")))?;
290 let target = credential_store.as_mut().unwrap_or(&mut store);
291 let slot = codewhale_config::credentials::provider_slot(kind_owned);
292 crate::credentials::store::with_provider_write_lock(slot, || {
293 codewhale_config::credentials::set_provider_api_key(
294 target, &secrets, kind_owned, &key_owned,
295 )
296 })
297 .map_err(|error| {
298 // The credential-write errors name paths and backends only — the
299 // key material is never embedded in the message.
300 ApiError::internal(format!("credential write failed: {error}"))
301 })?;
302 Ok::<_, ApiError>((
303 secrets.backend_name().to_string(),
304 target.path().to_path_buf(),
305 ))
306 })
307 .await
308 .map_err(|_| ApiError::internal("credential write task failed"))??;
309
310 // Mirror the persisted credential markers into the live config. The
311 // durable write may have landed on the user-global document while this
312 // server's ambient config is workspace-scoped, and `credential_state`
313 // only probes the secret store for an inactive provider when the
314 // `auth_mode` save marker is visible — without this mirror
315 // `GET /v1/providers` would keep reporting the provider as missing its
316 // credential until the next process start. Only marker fields are
317 // mirrored; the key itself never enters the runtime config.
318 {
319 let mut config = state.config.write();
320 config.auth_mode = Some("api_key".to_string());
321 {
322 let entry = config.provider_config_for_mut(provider_owned);
323 entry.auth_mode = Some("api_key".to_string());
324 entry.external_credentials = None;
325 entry.api_key = None;
326 if provider_owned == ApiProvider::Xai {
327 entry.oauth_credential_generation = None;
328 }
329 }
330 if provider_owned == ApiProvider::Deepseek {
331 config.api_key = None;
332 if config.default_text_model.is_none() {
333 config.default_text_model = config
334 .provider_config_for(ApiProvider::Deepseek)
335 .and_then(|entry| entry.model.clone())
336 .or_else(|| Some("deepseek-v4-pro".to_string()));
337 }
338 }
339 }
340
341 let credential_state: ProviderCredentialState =
342 crate::provider_readiness::credential_state_for_provider(
343 &state.config.read(),
344 provider_owned,
345 )
346 .into();
347
348 Ok(Json(json!({
349 "provider": provider_owned.as_str(),
350 "stored": true,
351 "backend": backend,
352 "credentialState": credential_state,
353 "configPath": saved_config_path,
354 })))
355 }
356
357 /// Where the credential for a route comes from, as a *class* and never as a
358 /// value, a path, or an environment variable name.
359 ///
360 /// This exists so a client can disable its own credential control with a
361 /// truthful reason before submitting, instead of letting a write fail late or —
362 /// worse — appear to succeed against a source Codewhale does not own.
363 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
364 #[serde(rename_all = "snake_case")]
365 pub(super) enum ProviderCredentialSource {
366 /// Codewhale's own durable secret backend. The only writable source.
367 SecretStore,
368 /// The expiring account transform; disconnect through account sign-out.
369 AccountSession,
370 /// A literal value sitting in a config document.
371 Config,
372 /// An external consent or auth-command source (OAuth, `auth_source`).
373 ExternalAuth,
374 /// The route takes no credential at all.
375 None,
376 }
377
378 /// Whether this route's credential can be written through the runtime API, and
379 /// the reason when it cannot. The reason is user-facing copy.
380 pub(super) struct CredentialWriteability {
381 pub(super) source: ProviderCredentialSource,
382 pub(super) writable: bool,
383 pub(super) reason: Option<&'static str>,
384 }
385
386 /// Classify a route's credential ownership without reading any credential.
387 ///
388 /// Deliberately structural: it consults declared auth mode, consent state and
389 /// the *kind* of any configured `api_key` value, and never resolves a secret,
390 /// an environment value, or an auth command.
391 pub(super) fn credential_writeability(
392 config: &crate::config::Config,
393 provider: ApiProvider,
394 ) -> CredentialWriteability {
395 let auth_mode = config.auth_mode_for_provider(provider);
396 if codewhale_config::auth_mode_disables_api_key(auth_mode.as_deref()) {
397 return CredentialWriteability {
398 source: ProviderCredentialSource::None,
399 writable: false,
400 reason: Some("This route is configured to send no credential."),
401 };
402 }
403 if provider.kind().is_none() {
404 return CredentialWriteability {
405 source: ProviderCredentialSource::None,
406 writable: false,
407 reason: Some("This route has no credential slot."),
408 };
409 }
410 // An active external consent owns the credential. Overwriting the key slot
411 // would not change what the route sends, so a write here must refuse
412 // rather than report a success the user cannot observe.
413 if config
414 .external_credential_consent_status(provider)
415 .is_some_and(|status| status.route_state == "active")
416 {
417 return CredentialWriteability {
418 source: ProviderCredentialSource::ExternalAuth,
419 writable: false,
420 reason: Some(
421 "This route signs in through an external consent. Sign out of it before setting a key.",
422 ),
423 };
424 }
425 // A literal key in a config document is a plaintext credential Codewhale
426 // did not put there. Writing the secret store would leave the literal in
427 // place and still winning, so refuse and name the file-owned source.
428 if let Some(entry) = config.provider_config_for(provider)
429 && let Some(existing) = entry.api_key.as_deref()
430 && codewhale_config::classify_config_api_key_value(existing)
431 == codewhale_config::ConfigApiKeyValueKind::Literal
432 {
433 return CredentialWriteability {
434 source: ProviderCredentialSource::Config,
435 writable: false,
436 reason: Some(
437 "This route's key is set literally in a config file. Remove it there before managing it here.",
438 ),
439 };
440 }
441 let account_bound = config.account_model_access.read().is_some();
442 if account_bound
443 && matches!(
444 crate::config::resolve_credential_source(config, provider).source,
445 crate::credentials::CredentialSource::AccountSession
446 )
447 {
448 return CredentialWriteability {
449 source: ProviderCredentialSource::AccountSession,
450 writable: false,
451 reason: Some("This route uses your Codewhale account. Sign out to disconnect it."),
452 };
453 }
454 CredentialWriteability {
455 source: ProviderCredentialSource::SecretStore,
456 writable: true,
457 reason: None,
458 }
459 }
460
461 /// Shared provider validation for both credential routes.
462 fn writable_provider(
463 state: &RuntimeApiState,
464 id: &str,
465 ) -> Result<(ApiProvider, codewhale_config::ProviderKind), ApiError> {
466 let provider = ApiProvider::parse(id)
467 .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?;
468 if provider == ApiProvider::DeepseekCN {
469 return Err(ApiError::bad_request(
470 "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
471 ));
472 }
473 let kind = provider
474 .kind()
475 .ok_or_else(|| ApiError::bad_request("provider has no credential slot"))?;
476 let writeability = credential_writeability(&state.config.read(), provider);
477 if !writeability.writable {
478 return Err(ApiError::conflict(
479 writeability
480 .reason
481 .unwrap_or("This route's credential is not managed by Codewhale."),
482 ));
483 }
484 Ok((provider, kind))
485 }
486
487 /// `DELETE /v1/providers/{id}/key` — remove a Codewhale-owned credential.
488 ///
489 /// Refuses for exactly the sources `PUT` refuses for, and for the same reason:
490 /// a route that reports "cleared" for a credential it cannot reach has lied
491 /// about a security action. The secret-store leg is reported separately,
492 /// because the config write lands first and the backend can still refuse.
493 pub(super) async fn clear_provider_key(
494 State(state): State<RuntimeApiState>,
495 Path(id): Path<String>,
496 ) -> Result<Json<Value>, ApiError> {
497 let (provider, kind) = writable_provider(&state, &id)?;
498
499 let secrets = crate::config::credential_secret_store().ok_or_else(|| {
500 ApiError::internal("no credential store is available in this environment")
501 })?;
502
503 let store_path = state.config_path.clone();
504 let outcome = tokio::task::spawn_blocking(move || {
505 let mut store = ConfigStore::load(store_path)
506 .map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?;
507 let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store)
508 .map_err(|error| ApiError::internal(format!("credential store: {error}")))?;
509 let target = credential_store.as_mut().unwrap_or(&mut store);
510 let slot = codewhale_config::credentials::provider_slot(kind);
511 crate::credentials::store::with_provider_write_lock(slot, || {
512 codewhale_config::credentials::clear_provider_api_key(target, &secrets, kind)
513 })
514 .map_err(|error| {
515 // Clear errors name slots and paths only; no key material can
516 // reach this message because none was read.
517 ApiError::internal(format!("credential clear failed: {error}"))
518 })
519 })
520 .await
521 .map_err(|_| ApiError::internal("credential clear task failed"))??;
522
523 // Mirror the cleared markers into the live config for the same reason the
524 // write path mirrors them: the durable clear may have landed on the
525 // user-global document while this server's ambient config is
526 // workspace-scoped, and `credential_state` would otherwise keep reporting
527 // the provider as configured until the next process start.
528 {
529 let mut config = state.config.write();
530 let entry = config.provider_config_for_mut(provider);
531 entry.api_key = None;
532 if provider == ApiProvider::Xai {
533 entry.auth_mode = None;
534 entry.external_credentials = None;
535 entry.oauth_credential_generation = None;
536 }
537 if provider == ApiProvider::Deepseek {
538 config.api_key = None;
539 }
540 }
541
542 let credential_state: ProviderCredentialState =
543 crate::provider_readiness::credential_state_for_provider(&state.config.read(), provider)
544 .into();
545
546 if let Some(error) = outcome.secret_store_error {
547 return Err(ApiError::internal(format!(
548 "the config entry was cleared, but the secret store refused to delete {}: {error}",
549 outcome.slot
550 )));
551 }
552
553 Ok(Json(json!({
554 "provider": provider.as_str(),
555 "cleared": true,
556 "credentialState": credential_state,
557 })))
558 }
559
560 #[cfg(test)]
561 mod tests {
562 use super::*;
563 use crate::config::Config;
564
565 fn saved_account_session(session_id: &str) -> codewhale_secrets::account::AccountSessionStore {
566 let store = codewhale_secrets::account::AccountSessionStore::new(
567 codewhale_secrets::account::secure_account_session_secrets().unwrap(),
568 None,
569 codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
570 );
571 store
572 .save(codewhale_secrets::account::AccountAuthBundle {
573 token_type: "Bearer".into(),
574 access_token: "fixture-access-token-for-account-models".into(),
575 refresh_token: "fixture-refresh-token-for-account-models".into(),
576 session: Some(codewhale_secrets::account::AccountSession {
577 id: session_id.into(),
578 status: "active".into(),
579 expires_at: (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
580 refresh_expires_at: (chrono::Utc::now() + chrono::Duration::days(1))
581 .to_rfc3339(),
582 ..Default::default()
583 }),
584 user: Some(codewhale_secrets::account::AccountUser {
585 id: "fixture-user".into(),
586 ..Default::default()
587 }),
588 })
589 .unwrap();
590 store
591 }
592
593 fn account_request(owner: Option<&str>) -> SetAccountModelAccessRequest {
594 SetAccountModelAccessRequest {
595 api_base: crate::config::DEFAULT_CODEWHALE_BASE_URL.into(),
596 session_id: "fixture-session".into(),
597 expected_session_id: owner.map(str::to_string),
598 key: "cwc_fixture_device_credential_not_real".into(),
599 expires_at: chrono::Utc::now().timestamp() + 1800,
600 }
601 }
602
603 #[test]
604 fn account_overlay_resolves_without_disk_residue_and_logout_revokes_clones() {
605 let _env = crate::test_support::lock_test_env();
606 let tmp = tempfile::tempdir().unwrap();
607 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
608 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
609 let _base = crate::test_support::EnvVarGuard::set(
610 "CODEWHALE_CLOUD_API_BASE",
611 codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
612 );
613 let _api_base = crate::test_support::EnvVarGuard::set(
614 "CODEWHALE_API_BASE",
615 crate::config::DEFAULT_CODEWHALE_BASE_URL,
616 );
617 let _api_key = crate::test_support::EnvVarGuard::set("CODEWHALE_API_KEY", "");
618 let store = saved_account_session("fixture-session");
619 let config = Config {
620 provider: Some("codewhale".into()),
621 ..Default::default()
622 };
623 let cloned_before_install = config.clone();
624 assert!(install_account_model_access(&config, None, account_request(None)).unwrap());
625 assert_eq!(
626 cloned_before_install
627 .active_route_api_key_read_only()
628 .unwrap(),
629 "cwc_fixture_device_credential_not_real"
630 );
631 assert!(crate::config::has_api_key_for(
632 &config,
633 ApiProvider::Codewhale
634 ));
635 assert!(!format!("{config:?}").contains("cwc_fixture"));
636 assert!(
637 !account_model_access_receipt(&config)
638 .to_string()
639 .contains("cwc_fixture")
640 );
641 assert!(config.provider_config_for(ApiProvider::Codewhale).is_none());
642 assert!(
643 crate::config::credential_secret_store()
644 .unwrap()
645 .get("codewhale")
646 .unwrap()
647 .is_none()
648 );
649 use codewhale_config::catalog::{
650 CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
651 };
652 let endpoint = crate::config::DEFAULT_CODEWHALE_BASE_URL;
653 let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
654 ApiProvider::Codewhale,
655 "codewhale",
656 endpoint,
657 );
658 let delta = || ProviderCatalogDelta {
659 provider: "codewhale".into(),
660 base_url_fingerprint: base_url_fingerprint(endpoint),
661 fetched_at: 1,
662 offerings: vec![],
663 };
664 assert_eq!(
665 crate::provider_catalog_live::record_success_if_current(&ticket, delta()),
666 Some(CatalogStatus::Fresh)
667 );
668 assert!(
669 !install_account_model_access(&config, None, account_request(Some("fixture-session")))
670 .unwrap()
671 );
672 assert!(
673 crate::provider_catalog_live::cached_entry_for_route(
674 ApiProvider::Codewhale,
675 "codewhale",
676 endpoint
677 )
678 .unwrap()
679 .is_some()
680 );
681 let mut extended = account_request(Some("fixture-session"));
682 extended.expires_at += 60;
683 assert!(!install_account_model_access(&config, None, extended).unwrap());
684 assert_eq!(
685 crate::provider_catalog_live::record_success_if_current(&ticket, delta()),
686 Some(CatalogStatus::Fresh)
687 );
688 store.clear().unwrap();
689 invalidate_stale_account_catalog(&config);
690 assert!(
691 crate::provider_catalog_live::cached_entry_for_route(
692 ApiProvider::Codewhale,
693 "codewhale",
694 endpoint
695 )
696 .unwrap()
697 .is_none()
698 );
699 assert_eq!(
700 crate::provider_catalog_live::record_success_if_current(&ticket, delta()),
701 None
702 );
703 assert!(
704 cloned_before_install
705 .active_route_api_key_read_only()
706 .is_err()
707 );
708 assert!(
709 install_account_model_access(&config, None, account_request(Some("fixture-session")))
710 .is_err()
711 );
712 }
713
714 #[test]
715 fn account_overlay_refuses_endpoint_credentials_and_stale_session_ownership() {
716 let _env = crate::test_support::lock_test_env();
717 let tmp = tempfile::tempdir().unwrap();
718 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
719 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
720 let _base = crate::test_support::EnvVarGuard::set(
721 "CODEWHALE_CLOUD_API_BASE",
722 codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
723 );
724 let _api_base = crate::test_support::EnvVarGuard::set(
725 "CODEWHALE_API_BASE",
726 crate::config::DEFAULT_CODEWHALE_BASE_URL,
727 );
728 let _api_key = crate::test_support::EnvVarGuard::set("CODEWHALE_API_KEY", "");
729 saved_account_session("fixture-session");
730 let mut config = Config {
731 provider: Some("codewhale".into()),
732 ..Default::default()
733 };
734 config
735 .provider_config_for_mut(ApiProvider::Codewhale)
736 .base_url = Some("https://api.codewhale.net/other/v1".into());
737 assert!(install_account_model_access(&config, None, account_request(None)).is_err());
738 config
739 .provider_config_for_mut(ApiProvider::Codewhale)
740 .base_url = None;
741 config
742 .provider_config_for_mut(ApiProvider::Codewhale)
743 .api_key = Some("existing-user-key".into());
744 assert!(install_account_model_access(&config, None, account_request(None)).is_err());
745 config
746 .provider_config_for_mut(ApiProvider::Codewhale)
747 .api_key = None;
748 let mut expired = account_request(None);
749 expired.expires_at = chrono::Utc::now().timestamp() - 1;
750 assert!(install_account_model_access(&config, None, expired).is_err());
751 install_account_model_access(&config, None, account_request(None)).unwrap();
752 assert!(install_account_model_access(&config, None, account_request(None)).is_err());
753 install_account_model_access(&config, None, account_request(Some("fixture-session")))
754 .unwrap();
755 assert!(remove_account_model_access(&config, "stale-session").is_err());
756 assert!(
757 config
758 .account_model_api_key(ApiProvider::Codewhale)
759 .is_some()
760 );
761 config
762 .provider_config_for_mut(ApiProvider::Codewhale)
763 .api_key = Some("new-user-key".into());
764 assert_eq!(
765 config.active_route_api_key_read_only().unwrap(),
766 "new-user-key"
767 );
768 config
769 .provider_config_for_mut(ApiProvider::Codewhale)
770 .api_key = None;
771 config
772 .provider_config_for_mut(ApiProvider::Codewhale)
773 .base_url = Some("https://api.codewhale.net/other/v1".into());
774 assert!(
775 config
776 .account_model_api_key(ApiProvider::Codewhale)
777 .is_none()
778 );
779 }
780
781 #[test]
782 fn account_overlay_clear_is_shared_and_preserves_manual_config() {
783 let _env = crate::test_support::lock_test_env();
784 let mut config = Config::default();
785 config
786 .provider_config_for_mut(ApiProvider::Codewhale)
787 .api_key = Some("manual-key".into());
788 *config.account_model_access.write() = Some(crate::config::AccountModelAccess {
789 session_id: "current".into(),
790 credential: crate::credentials::Credential::ApiKey {
791 key: "cwc_fixture".into(),
792 },
793 expires_at: chrono::Utc::now().timestamp() + 60,
794 profile: None,
795 });
796 let clone = config.clone();
797 assert!(remove_account_model_access(&config, "stale").is_err());
798 assert!(clone.account_model_access.read().is_some());
799 remove_account_model_access(&config, "current").unwrap();
800 assert!(clone.account_model_access.read().is_none());
801 assert_eq!(
802 config
803 .provider_config_for(ApiProvider::Codewhale)
804 .unwrap()
805 .api_key
806 .as_deref(),
807 Some("manual-key")
808 );
809 }
810
811 /// A route Codewhale owns is writable, and says its source is the store it
812 /// would actually write.
813 #[test]
814 fn a_codewhale_owned_route_is_writable_through_the_secret_store() {
815 let config = Config::default();
816 let writeability = credential_writeability(&config, ApiProvider::Openai);
817 assert_eq!(writeability.source, ProviderCredentialSource::SecretStore);
818 assert!(writeability.writable);
819 assert!(writeability.reason.is_none());
820 }
821
822 /// The case #6179 exists for: a literal key in a config file still wins at
823 /// request time, so a write here must refuse rather than report a success
824 /// the user cannot observe. The reason names the file-owned source.
825 #[test]
826 fn a_literal_config_key_refuses_the_write_and_says_why() {
827 let mut config = Config::default();
828 config.provider_config_for_mut(ApiProvider::Openai).api_key =
829 Some("sk-literal-in-a-config-file".to_string());
830
831 let writeability = credential_writeability(&config, ApiProvider::Openai);
832 assert_eq!(writeability.source, ProviderCredentialSource::Config);
833 assert!(!writeability.writable);
834 let reason = writeability.reason.expect("a refusal must name its reason");
835 assert!(reason.contains("config file"), "{reason}");
836 // The reason is copy, not a credential: it can never carry the value.
837 assert!(!reason.contains("sk-literal-in-a-config-file"));
838 }
839
840 /// The secret-store sentinel is routing metadata, not a credential, so it
841 /// must not be mistaken for a file-owned literal and refused.
842 #[test]
843 fn the_secret_store_sentinel_is_not_a_file_owned_key() {
844 let mut config = Config::default();
845 config.provider_config_for_mut(ApiProvider::Openai).api_key =
846 Some(codewhale_config::API_KEYRING_SENTINEL.to_string());
847
848 let writeability = credential_writeability(&config, ApiProvider::Openai);
849 assert_eq!(writeability.source, ProviderCredentialSource::SecretStore);
850 assert!(writeability.writable);
851 }
852
853 /// A route declared to send no credential has nothing to manage, and says
854 /// so instead of offering a control that would do nothing.
855 #[test]
856 fn a_no_auth_route_reports_no_credential_source() {
857 let mut config = Config::default();
858 config
859 .provider_config_for_mut(ApiProvider::Openai)
860 .auth_mode = Some("none".to_string());
861
862 let writeability = credential_writeability(&config, ApiProvider::Openai);
863 assert_eq!(writeability.source, ProviderCredentialSource::None);
864 assert!(!writeability.writable);
865 assert!(writeability.reason.is_some());
866 }
867 }
868
868 lines RUST