| 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 |