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