| 1 | //! Codewhale account and BYOK credential commands. |
| 2 | //! |
| 3 | //! This module is deliberately separate from the provider-facing `login` and |
| 4 | //! `auth` commands in `lib.rs`: those configure the local runtime, while this |
| 5 | //! surface signs a CLI profile into the managed Codewhale account and stores |
| 6 | //! provider keys in that account's remote vault. |
| 7 | |
| 8 | use std::io::{self, IsTerminal, Read, Write}; |
| 9 | use std::net::IpAddr; |
| 10 | use std::thread; |
| 11 | use std::time::{Duration, Instant}; |
| 12 | |
| 13 | use anyhow::{Context, Result, anyhow, bail}; |
| 14 | use clap::{Args, Subcommand, ValueEnum}; |
| 15 | use codewhale_config::{ConfigStore, ProviderKind}; |
| 16 | use codewhale_secrets::Secrets; |
| 17 | use codewhale_secrets::account::{ |
| 18 | ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV as CLOUD_ALLOW_FILE_SESSION_STORE_ENV, |
| 19 | ACCOUNT_API_BASE_ENV as CLOUD_API_BASE_ENV, AccountAuthBundle as AuthBundle, |
| 20 | AccountSessionStore, AccountUser as CloudUser, DEFAULT_ACCOUNT_API_BASE as DEFAULT_API_BASE, |
| 21 | StoredAccountAuth as StoredCloudAuth, normalize_account_profile as normalized_profile, |
| 22 | secure_account_session_secrets, validate_account_auth_bundle as validate_auth_bundle, |
| 23 | }; |
| 24 | use reqwest::Url; |
| 25 | use serde::{Deserialize, Serialize, de::DeserializeOwned}; |
| 26 | |
| 27 | const MAX_RESPONSE_BYTES: u64 = 256 * 1024; |
| 28 | const MIN_API_KEY_BYTES: usize = 8; |
| 29 | const MAX_API_KEY_BYTES: u64 = 4096; |
| 30 | const MAX_API_KEY_STDIN_BYTES: u64 = MAX_API_KEY_BYTES + 1024; |
| 31 | const MAX_KEY_LABEL_CHARS: usize = 80; |
| 32 | const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 600; |
| 33 | const MAX_LOGIN_TIMEOUT_SECONDS: u64 = 3600; |
| 34 | |
| 35 | #[derive(Debug, Args)] |
| 36 | pub(crate) struct CloudArgs { |
| 37 | /// Codewhale account API origin. HTTPS is required except for loopback HTTP. |
| 38 | #[arg(long, global = true, value_name = "URL")] |
| 39 | api_base: Option<String>, |
| 40 | #[command(subcommand)] |
| 41 | command: CloudCommand, |
| 42 | } |
| 43 | |
| 44 | #[derive(Debug, Subcommand)] |
| 45 | enum CloudCommand { |
| 46 | /// Sign this CLI profile in through the browser device flow. |
| 47 | Login(CloudLoginArgs), |
| 48 | /// Show the signed-in account for this CLI profile. |
| 49 | Status, |
| 50 | /// Remove this profile's local account session and revoke it when reachable. |
| 51 | Logout, |
| 52 | /// Manage provider API keys stored in the signed-in Codewhale account. |
| 53 | Keys(CloudKeysArgs), |
| 54 | } |
| 55 | |
| 56 | #[derive(Debug, Args)] |
| 57 | struct CloudLoginArgs { |
| 58 | /// Print the verification URL without trying to open a browser. |
| 59 | #[arg(long, default_value_t = false)] |
| 60 | no_open: bool, |
| 61 | /// Maximum time to wait for browser authorization. |
| 62 | #[arg( |
| 63 | long = "timeout-seconds", |
| 64 | default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, |
| 65 | value_parser = clap::value_parser!(u64).range(1..=MAX_LOGIN_TIMEOUT_SECONDS) |
| 66 | )] |
| 67 | timeout_seconds: u64, |
| 68 | } |
| 69 | |
| 70 | #[derive(Debug, Args)] |
| 71 | struct CloudKeysArgs { |
| 72 | #[command(subcommand)] |
| 73 | command: CloudKeysCommand, |
| 74 | } |
| 75 | |
| 76 | #[derive(Debug, Subcommand)] |
| 77 | enum CloudKeysCommand { |
| 78 | /// List configured providers without revealing key values. |
| 79 | List, |
| 80 | /// Save a provider key to the signed-in Codewhale account. |
| 81 | Set(CloudKeySetArgs), |
| 82 | /// Remove a provider key from the signed-in Codewhale account. |
| 83 | Remove { provider: CloudProvider }, |
| 84 | } |
| 85 | |
| 86 | #[derive(Debug, Args)] |
| 87 | struct CloudKeySetArgs { |
| 88 | provider: CloudProvider, |
| 89 | /// Read the key from stdin. Useful for pipes and secret-manager commands. |
| 90 | #[arg(long = "api-key-stdin", conflicts_with = "from_local")] |
| 91 | api_key_stdin: bool, |
| 92 | /// Upload the locally resolved key (config, secret store, then environment). |
| 93 | #[arg(long, conflicts_with = "api_key_stdin")] |
| 94 | from_local: bool, |
| 95 | /// Non-secret label shown beside the stored credential. |
| 96 | #[arg(long, default_value = "Codewhale CLI")] |
| 97 | label: String, |
| 98 | } |
| 99 | |
| 100 | #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] |
| 101 | enum CloudProvider { |
| 102 | Deepseek, |
| 103 | Anthropic, |
| 104 | Openai, |
| 105 | Openrouter, |
| 106 | Zai, |
| 107 | Moonshot, |
| 108 | Xai, |
| 109 | #[value(name = "xiaomi", alias = "xiaomi-mimo")] |
| 110 | Xiaomi, |
| 111 | } |
| 112 | |
| 113 | impl CloudProvider { |
| 114 | const ALL: [Self; 8] = [ |
| 115 | Self::Deepseek, |
| 116 | Self::Anthropic, |
| 117 | Self::Openai, |
| 118 | Self::Openrouter, |
| 119 | Self::Zai, |
| 120 | Self::Moonshot, |
| 121 | Self::Xai, |
| 122 | Self::Xiaomi, |
| 123 | ]; |
| 124 | |
| 125 | fn slug(self) -> &'static str { |
| 126 | match self { |
| 127 | Self::Deepseek => "deepseek", |
| 128 | Self::Anthropic => "anthropic", |
| 129 | Self::Openai => "openai", |
| 130 | Self::Openrouter => "openrouter", |
| 131 | Self::Zai => "zai", |
| 132 | Self::Moonshot => "moonshot", |
| 133 | Self::Xai => "xai", |
| 134 | Self::Xiaomi => "xiaomi", |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | fn local_kind(self) -> ProviderKind { |
| 139 | match self { |
| 140 | Self::Deepseek => ProviderKind::Deepseek, |
| 141 | Self::Anthropic => ProviderKind::Anthropic, |
| 142 | Self::Openai => ProviderKind::Openai, |
| 143 | Self::Openrouter => ProviderKind::Openrouter, |
| 144 | Self::Zai => ProviderKind::Zai, |
| 145 | Self::Moonshot => ProviderKind::Moonshot, |
| 146 | Self::Xai => ProviderKind::Xai, |
| 147 | Self::Xiaomi => ProviderKind::XiaomiMimo, |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 153 | enum HttpMethod { |
| 154 | Get, |
| 155 | Post, |
| 156 | Put, |
| 157 | Delete, |
| 158 | } |
| 159 | |
| 160 | struct CloudRequest { |
| 161 | method: HttpMethod, |
| 162 | path: String, |
| 163 | bearer: Option<String>, |
| 164 | body: Option<Vec<u8>>, |
| 165 | } |
| 166 | |
| 167 | struct CloudResponse { |
| 168 | status: u16, |
| 169 | body: Vec<u8>, |
| 170 | } |
| 171 | |
| 172 | trait CloudTransport { |
| 173 | fn execute(&self, request: CloudRequest) -> Result<CloudResponse>; |
| 174 | } |
| 175 | |
| 176 | struct ReqwestTransport { |
| 177 | base: Url, |
| 178 | client: reqwest::blocking::Client, |
| 179 | } |
| 180 | |
| 181 | impl ReqwestTransport { |
| 182 | fn new(base: Url) -> Result<Self> { |
| 183 | let client = reqwest::blocking::Client::builder() |
| 184 | .connect_timeout(Duration::from_secs(8)) |
| 185 | .timeout(Duration::from_secs(30)) |
| 186 | // Never replay bearer tokens or provider-key request bodies to a |
| 187 | // redirect target. The control-plane origin is an explicit trust |
| 188 | // boundary, so redirects are treated as ordinary non-2xx replies. |
| 189 | .redirect(reqwest::redirect::Policy::none()) |
| 190 | .user_agent(concat!("codewhale/", env!("CARGO_PKG_VERSION"))) |
| 191 | .build() |
| 192 | .context("failed to initialize the Codewhale account HTTP client")?; |
| 193 | Ok(Self { base, client }) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | impl CloudTransport for ReqwestTransport { |
| 198 | fn execute(&self, request: CloudRequest) -> Result<CloudResponse> { |
| 199 | let url = self |
| 200 | .base |
| 201 | .join(request.path.trim_start_matches('/')) |
| 202 | .context("failed to construct the Codewhale account request URL")?; |
| 203 | let method = match request.method { |
| 204 | HttpMethod::Get => reqwest::Method::GET, |
| 205 | HttpMethod::Post => reqwest::Method::POST, |
| 206 | HttpMethod::Put => reqwest::Method::PUT, |
| 207 | HttpMethod::Delete => reqwest::Method::DELETE, |
| 208 | }; |
| 209 | let mut builder = self |
| 210 | .client |
| 211 | .request(method, url) |
| 212 | .header(reqwest::header::ACCEPT, "application/json"); |
| 213 | if let Some(token) = request.bearer { |
| 214 | builder = builder.bearer_auth(token); |
| 215 | } |
| 216 | if let Some(body) = request.body { |
| 217 | builder = builder |
| 218 | .header(reqwest::header::CONTENT_TYPE, "application/json") |
| 219 | .body(body); |
| 220 | } |
| 221 | let response = builder |
| 222 | .send() |
| 223 | .context("could not reach the Codewhale service")?; |
| 224 | let status = response.status().as_u16(); |
| 225 | let mut body = Vec::new(); |
| 226 | response |
| 227 | .take(MAX_RESPONSE_BYTES + 1) |
| 228 | .read_to_end(&mut body) |
| 229 | .context("failed to read the Codewhale service response")?; |
| 230 | if body.len() as u64 > MAX_RESPONSE_BYTES { |
| 231 | bail!("The Codewhale service returned an unexpectedly large response"); |
| 232 | } |
| 233 | Ok(CloudResponse { status, body }) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | #[derive(Deserialize)] |
| 238 | #[serde(rename_all = "camelCase")] |
| 239 | struct DeviceStart { |
| 240 | device_code: String, |
| 241 | user_code: String, |
| 242 | verification_uri: String, |
| 243 | verification_uri_complete: String, |
| 244 | expires_in: u64, |
| 245 | interval: u64, |
| 246 | } |
| 247 | |
| 248 | #[derive(Deserialize)] |
| 249 | struct MeResponse { |
| 250 | user: CloudUser, |
| 251 | } |
| 252 | |
| 253 | #[derive(Serialize)] |
| 254 | #[serde(rename_all = "camelCase")] |
| 255 | struct DeviceTokenRequest<'a> { |
| 256 | device_code: &'a str, |
| 257 | } |
| 258 | |
| 259 | #[derive(Serialize)] |
| 260 | #[serde(rename_all = "camelCase")] |
| 261 | struct RefreshRequest<'a> { |
| 262 | refresh_token: &'a str, |
| 263 | } |
| 264 | |
| 265 | #[derive(Serialize)] |
| 266 | struct ModelKeyRequest<'a> { |
| 267 | key: &'a str, |
| 268 | label: &'a str, |
| 269 | } |
| 270 | |
| 271 | struct CloudClient<'a, T: CloudTransport> { |
| 272 | transport: &'a T, |
| 273 | account_store: AccountSessionStore, |
| 274 | } |
| 275 | |
| 276 | impl<'a, T: CloudTransport> CloudClient<'a, T> { |
| 277 | fn new(transport: &'a T, secrets: &'a Secrets, profile: &str, api_base: &'a str) -> Self { |
| 278 | Self { |
| 279 | transport, |
| 280 | account_store: AccountSessionStore::new(secrets.clone(), Some(profile), api_base), |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | fn start_device(&self) -> Result<DeviceStart> { |
| 285 | let response = self.transport.execute(CloudRequest { |
| 286 | method: HttpMethod::Post, |
| 287 | path: "/api/cli/device/start".to_string(), |
| 288 | bearer: None, |
| 289 | body: Some(b"{}".to_vec()), |
| 290 | })?; |
| 291 | expect_json(response, &[200]) |
| 292 | } |
| 293 | |
| 294 | fn poll_device( |
| 295 | &self, |
| 296 | device: &DeviceStart, |
| 297 | timeout: Duration, |
| 298 | sleep: &mut dyn FnMut(Duration), |
| 299 | ) -> Result<AuthBundle> { |
| 300 | validate_device_code(&device.device_code)?; |
| 301 | let server_lifetime = |
| 302 | Duration::from_secs(device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS)); |
| 303 | let timeout = timeout.min(server_lifetime); |
| 304 | let interval = Duration::from_secs(device.interval.clamp(1, 10)); |
| 305 | let started = Instant::now(); |
| 306 | |
| 307 | loop { |
| 308 | if started.elapsed() >= timeout { |
| 309 | bail!( |
| 310 | "Codewhale account login timed out; run `codewhale account login` to try again" |
| 311 | ); |
| 312 | } |
| 313 | let response = self.transport.execute(CloudRequest { |
| 314 | method: HttpMethod::Post, |
| 315 | path: "/api/cli/device/token".to_string(), |
| 316 | bearer: None, |
| 317 | body: Some(json_body(&DeviceTokenRequest { |
| 318 | device_code: &device.device_code, |
| 319 | })?), |
| 320 | })?; |
| 321 | match response.status { |
| 322 | 200 => { |
| 323 | let bundle: AuthBundle = parse_json_body(&response.body)?; |
| 324 | validate_auth_bundle(&bundle)?; |
| 325 | self.save_auth(bundle.clone())?; |
| 326 | return Ok(bundle); |
| 327 | } |
| 328 | 202 => { |
| 329 | let remaining = timeout.saturating_sub(started.elapsed()); |
| 330 | if remaining.is_zero() { |
| 331 | bail!( |
| 332 | "Codewhale account login timed out; run `codewhale account login` to try again" |
| 333 | ); |
| 334 | } |
| 335 | sleep(interval.min(remaining)); |
| 336 | } |
| 337 | _ => return Err(response_error(&response)), |
| 338 | } |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | fn load_auth(&self) -> Result<Option<StoredCloudAuth>> { |
| 343 | self.account_store.load().context( |
| 344 | "the local Codewhale account session is unreadable; run `codewhale account logout` and sign in again", |
| 345 | ) |
| 346 | } |
| 347 | |
| 348 | fn save_auth(&self, bundle: AuthBundle) -> Result<()> { |
| 349 | self.account_store |
| 350 | .save(bundle) |
| 351 | .context("failed to save the Codewhale account session in the local secret store") |
| 352 | } |
| 353 | |
| 354 | fn clear_auth(&self) -> Result<()> { |
| 355 | self.account_store |
| 356 | .clear() |
| 357 | .context("failed to remove the local Codewhale account session") |
| 358 | } |
| 359 | |
| 360 | fn me(&self) -> Result<CloudUser> { |
| 361 | let response = self.execute_authenticated(HttpMethod::Get, "/api/me", None)?; |
| 362 | let me: MeResponse = expect_json(response, &[200])?; |
| 363 | if me.user.id.trim().is_empty() { |
| 364 | bail!("The Codewhale service returned an account without an ID"); |
| 365 | } |
| 366 | if let Some(mut stored) = self.load_auth()? { |
| 367 | stored.bundle.user = Some(me.user.clone()); |
| 368 | self.save_auth(stored.bundle)?; |
| 369 | } |
| 370 | Ok(me.user) |
| 371 | } |
| 372 | |
| 373 | fn set_key(&self, provider: CloudProvider, key: &str, label: &str) -> Result<()> { |
| 374 | let path = format!("/api/model-keys/{}", provider.slug()); |
| 375 | let response = self.execute_authenticated( |
| 376 | HttpMethod::Put, |
| 377 | &path, |
| 378 | Some(json_body(&ModelKeyRequest { key, label })?), |
| 379 | )?; |
| 380 | expect_empty(response, &[200, 201]) |
| 381 | } |
| 382 | |
| 383 | fn remove_key(&self, provider: CloudProvider) -> Result<()> { |
| 384 | let path = format!("/api/model-keys/{}", provider.slug()); |
| 385 | let response = self.execute_authenticated(HttpMethod::Delete, &path, None)?; |
| 386 | expect_empty(response, &[200, 204]) |
| 387 | } |
| 388 | |
| 389 | fn logout(&self) -> Result<bool> { |
| 390 | let stored = match self.load_auth() { |
| 391 | Ok(Some(stored)) => stored, |
| 392 | Ok(None) => { |
| 393 | // `load` deliberately treats obsolete-schema and wrong-origin |
| 394 | // records as signed out. Logout must still scrub their slot. |
| 395 | self.clear_auth()?; |
| 396 | return Ok(false); |
| 397 | } |
| 398 | Err(_) => { |
| 399 | // Logout is also the recovery path for a corrupt or obsolete |
| 400 | // local record, so it must remain able to remove that record. |
| 401 | self.clear_auth()?; |
| 402 | return Ok(false); |
| 403 | } |
| 404 | }; |
| 405 | let body = json_body(&RefreshRequest { |
| 406 | refresh_token: &stored.bundle.refresh_token, |
| 407 | })?; |
| 408 | let remote_revoked = self |
| 409 | .transport |
| 410 | .execute(CloudRequest { |
| 411 | method: HttpMethod::Post, |
| 412 | path: "/api/auth/logout".to_string(), |
| 413 | bearer: None, |
| 414 | body: Some(body), |
| 415 | }) |
| 416 | .is_ok_and(|response| (200..300).contains(&response.status)); |
| 417 | self.clear_auth()?; |
| 418 | Ok(remote_revoked) |
| 419 | } |
| 420 | |
| 421 | fn execute_authenticated( |
| 422 | &self, |
| 423 | method: HttpMethod, |
| 424 | path: &str, |
| 425 | body: Option<Vec<u8>>, |
| 426 | ) -> Result<CloudResponse> { |
| 427 | let Some(mut stored) = self.load_auth()? else { |
| 428 | bail!("Not signed in. Run `codewhale account login` first"); |
| 429 | }; |
| 430 | let first = self.transport.execute(CloudRequest { |
| 431 | method, |
| 432 | path: path.to_string(), |
| 433 | bearer: Some(stored.bundle.access_token.clone()), |
| 434 | body: body.clone(), |
| 435 | })?; |
| 436 | if first.status != 401 { |
| 437 | return Ok(first); |
| 438 | } |
| 439 | |
| 440 | let refresh = self.transport.execute(CloudRequest { |
| 441 | method: HttpMethod::Post, |
| 442 | path: "/api/auth/refresh".to_string(), |
| 443 | bearer: None, |
| 444 | body: Some(json_body(&RefreshRequest { |
| 445 | refresh_token: &stored.bundle.refresh_token, |
| 446 | })?), |
| 447 | })?; |
| 448 | match refresh.status { |
| 449 | 200 => {} |
| 450 | 401 => { |
| 451 | self.clear_auth()?; |
| 452 | bail!("The Codewhale account session expired. Run `codewhale account login` again"); |
| 453 | } |
| 454 | _ => return Err(response_error(&refresh)), |
| 455 | } |
| 456 | let mut next: AuthBundle = parse_json_body(&refresh.body)?; |
| 457 | validate_auth_bundle(&next)?; |
| 458 | if next.user.is_none() { |
| 459 | next.user = stored.bundle.user.take(); |
| 460 | } |
| 461 | self.save_auth(next.clone())?; |
| 462 | |
| 463 | let retried = self.transport.execute(CloudRequest { |
| 464 | method, |
| 465 | path: path.to_string(), |
| 466 | bearer: Some(next.access_token), |
| 467 | body, |
| 468 | })?; |
| 469 | if retried.status == 401 { |
| 470 | self.clear_auth()?; |
| 471 | bail!("The Codewhale account session expired. Run `codewhale account login` again"); |
| 472 | } |
| 473 | Ok(retried) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | enum KeyReadMode { |
| 478 | Stdin, |
| 479 | HiddenPrompt(String), |
| 480 | } |
| 481 | |
| 482 | pub(crate) fn run(args: CloudArgs, profile: Option<&str>, config: &ConfigStore) -> Result<()> { |
| 483 | let requested_base = args |
| 484 | .api_base |
| 485 | .or_else(|| std::env::var(CLOUD_API_BASE_ENV).ok()) |
| 486 | .unwrap_or_else(|| DEFAULT_API_BASE.to_string()); |
| 487 | let api_base = validate_api_base(&requested_base)?; |
| 488 | let transport = ReqwestTransport::new(api_base.url.clone())?; |
| 489 | // Account refresh tokens require an OS credential manager. The ordinary |
| 490 | // provider backend remains independently configurable for `--from-local`. |
| 491 | let cloud_secrets = cloud_session_secrets()?; |
| 492 | let provider_secrets = Secrets::auto_detect(); |
| 493 | let profile = normalized_profile(profile); |
| 494 | let mut stdout = io::stdout().lock(); |
| 495 | let mut key_reader = |mode: KeyReadMode| match mode { |
| 496 | KeyReadMode::Stdin => read_key_from_stdin(), |
| 497 | KeyReadMode::HiddenPrompt(provider) => read_key_hidden(&provider), |
| 498 | }; |
| 499 | let mut opener = |url: String| webbrowser::open(&url).is_ok(); |
| 500 | let mut sleeper = |duration| thread::sleep(duration); |
| 501 | run_with( |
| 502 | args.command, |
| 503 | &profile, |
| 504 | &api_base.display, |
| 505 | config, |
| 506 | &cloud_secrets, |
| 507 | &provider_secrets, |
| 508 | &transport, |
| 509 | &mut stdout, |
| 510 | &mut key_reader, |
| 511 | &mut opener, |
| 512 | &mut sleeper, |
| 513 | ) |
| 514 | } |
| 515 | |
| 516 | fn cloud_session_secrets() -> Result<Secrets> { |
| 517 | match secure_account_session_secrets() { |
| 518 | Ok(secrets) => { |
| 519 | if secrets.backend_name().starts_with("file-based") { |
| 520 | eprintln!( |
| 521 | "warning: OS credential manager unavailable; {CLOUD_ALLOW_FILE_SESSION_STORE_ENV}=1 explicitly enables the local 0600 Codewhale secrets file for cloud session tokens" |
| 522 | ); |
| 523 | } |
| 524 | Ok(secrets) |
| 525 | } |
| 526 | Err(_) => bail!( |
| 527 | "Codewhale account login requires an OS credential manager for session tokens. Configure Keychain, Credential Manager, or Secret Service and try again. Headless users may explicitly opt into the local 0600 secrets file with {CLOUD_ALLOW_FILE_SESSION_STORE_ENV}=1" |
| 528 | ), |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | pub(crate) fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> { |
| 533 | if api_key.is_some() { |
| 534 | bail!( |
| 535 | "`codewhale account` does not accept the global `--api-key` flag because command-line values can leak through shell history. Use `account keys set <provider>` for a hidden prompt, `--api-key-stdin`, or `--from-local`" |
| 536 | ); |
| 537 | } |
| 538 | Ok(()) |
| 539 | } |
| 540 | |
| 541 | #[allow(clippy::too_many_arguments)] |
| 542 | fn run_with<T: CloudTransport, W: Write>( |
| 543 | command: CloudCommand, |
| 544 | profile: &str, |
| 545 | api_base: &str, |
| 546 | config: &ConfigStore, |
| 547 | cloud_secrets: &Secrets, |
| 548 | provider_secrets: &Secrets, |
| 549 | transport: &T, |
| 550 | out: &mut W, |
| 551 | key_reader: &mut dyn FnMut(KeyReadMode) -> Result<String>, |
| 552 | opener: &mut dyn FnMut(String) -> bool, |
| 553 | sleeper: &mut dyn FnMut(Duration), |
| 554 | ) -> Result<()> { |
| 555 | let client = CloudClient::new(transport, cloud_secrets, profile, api_base); |
| 556 | match command { |
| 557 | CloudCommand::Login(login) => { |
| 558 | let device = client.start_device()?; |
| 559 | validate_user_code(&device.user_code)?; |
| 560 | let verification_uri = validate_verification_url( |
| 561 | &device.verification_uri, |
| 562 | api_base, |
| 563 | &device.user_code, |
| 564 | false, |
| 565 | )?; |
| 566 | let verification_uri_complete = validate_verification_url( |
| 567 | &device.verification_uri_complete, |
| 568 | api_base, |
| 569 | &device.user_code, |
| 570 | true, |
| 571 | )?; |
| 572 | writeln!(out, "Codewhale account sign-in")?; |
| 573 | writeln!(out, "Code: {}", device.user_code)?; |
| 574 | writeln!(out, "Open: {verification_uri}")?; |
| 575 | writeln!(out, "Profile: {}", printable(profile))?; |
| 576 | if !login.no_open && !opener(verification_uri_complete) { |
| 577 | writeln!( |
| 578 | out, |
| 579 | "Browser could not be opened; use the URL and code above." |
| 580 | )?; |
| 581 | } |
| 582 | let _ = |
| 583 | client.poll_device(&device, Duration::from_secs(login.timeout_seconds), sleeper)?; |
| 584 | let user = client.me()?; |
| 585 | write_account(out, "Signed in to Codewhale.", profile, api_base, &user) |
| 586 | } |
| 587 | CloudCommand::Status => match client.load_auth()? { |
| 588 | Some(_) => { |
| 589 | let user = client.me()?; |
| 590 | write_account(out, "Signed in to Codewhale.", profile, api_base, &user) |
| 591 | } |
| 592 | None => { |
| 593 | writeln!(out, "Not signed in to Codewhale.")?; |
| 594 | writeln!(out, "Profile: {}", printable(profile))?; |
| 595 | writeln!(out, "API: {api_base}")?; |
| 596 | writeln!(out, "Run `codewhale account login` to sign in.")?; |
| 597 | Ok(()) |
| 598 | } |
| 599 | }, |
| 600 | CloudCommand::Logout => { |
| 601 | let remote_revoked = client.logout()?; |
| 602 | writeln!(out, "Removed the local Codewhale account session.")?; |
| 603 | writeln!(out, "Profile: {}", printable(profile))?; |
| 604 | if !remote_revoked { |
| 605 | writeln!( |
| 606 | out, |
| 607 | "Remote revocation was not confirmed; the local tokens are gone." |
| 608 | )?; |
| 609 | } |
| 610 | Ok(()) |
| 611 | } |
| 612 | CloudCommand::Keys(keys) => match keys.command { |
| 613 | CloudKeysCommand::List => { |
| 614 | let user = client.me()?; |
| 615 | write_account(out, "Codewhale account keys.", profile, api_base, &user)?; |
| 616 | for provider in CloudProvider::ALL { |
| 617 | let state = user.model_keys.get(provider.slug()); |
| 618 | if state.is_some_and(|state| state.configured) { |
| 619 | writeln!(out, "{}: set", provider.slug())?; |
| 620 | } else { |
| 621 | writeln!(out, "{}: not set", provider.slug())?; |
| 622 | } |
| 623 | } |
| 624 | Ok(()) |
| 625 | } |
| 626 | CloudKeysCommand::Set(set) => { |
| 627 | let user = client.me()?; |
| 628 | let key = if set.from_local { |
| 629 | resolve_local_key(config, provider_secrets, set.provider)?.ok_or_else(|| { |
| 630 | anyhow!( |
| 631 | "No local {} API key was found in config, the secret store, or the environment", |
| 632 | set.provider.slug() |
| 633 | ) |
| 634 | })? |
| 635 | } else if set.api_key_stdin { |
| 636 | key_reader(KeyReadMode::Stdin)? |
| 637 | } else { |
| 638 | key_reader(KeyReadMode::HiddenPrompt(set.provider.slug().to_string()))? |
| 639 | }; |
| 640 | let key = key.trim().to_string(); |
| 641 | validate_api_key(&key)?; |
| 642 | let label = validate_label(&set.label)?; |
| 643 | client.set_key(set.provider, &key, &label)?; |
| 644 | writeln!( |
| 645 | out, |
| 646 | "Saved {} for Codewhale account {} (profile {}).", |
| 647 | set.provider.slug(), |
| 648 | printable(&user.id), |
| 649 | printable(profile) |
| 650 | )?; |
| 651 | Ok(()) |
| 652 | } |
| 653 | CloudKeysCommand::Remove { provider } => { |
| 654 | let user = client.me()?; |
| 655 | client.remove_key(provider)?; |
| 656 | writeln!( |
| 657 | out, |
| 658 | "Removed {} from Codewhale account {} (profile {}).", |
| 659 | provider.slug(), |
| 660 | printable(&user.id), |
| 661 | printable(profile) |
| 662 | )?; |
| 663 | Ok(()) |
| 664 | } |
| 665 | }, |
| 666 | } |
| 667 | } |
| 668 | |
| 669 | fn write_account<W: Write>( |
| 670 | out: &mut W, |
| 671 | heading: &str, |
| 672 | profile: &str, |
| 673 | api_base: &str, |
| 674 | user: &CloudUser, |
| 675 | ) -> Result<()> { |
| 676 | writeln!(out, "{heading}")?; |
| 677 | writeln!(out, "Account ID: {}", printable(&user.id))?; |
| 678 | if !user.display_name.trim().is_empty() { |
| 679 | writeln!(out, "Name: {}", printable(&user.display_name))?; |
| 680 | } |
| 681 | if !user.email.trim().is_empty() { |
| 682 | writeln!(out, "Email: {}", printable(&user.email))?; |
| 683 | } |
| 684 | if !user.plan.trim().is_empty() { |
| 685 | writeln!(out, "Plan: {}", printable(&user.plan))?; |
| 686 | } |
| 687 | writeln!(out, "Profile: {}", printable(profile))?; |
| 688 | writeln!(out, "API: {api_base}")?; |
| 689 | Ok(()) |
| 690 | } |
| 691 | |
| 692 | struct ValidatedApiBase { |
| 693 | url: Url, |
| 694 | display: String, |
| 695 | } |
| 696 | |
| 697 | fn validate_api_base(value: &str) -> Result<ValidatedApiBase> { |
| 698 | let mut url = Url::parse(value.trim()).context("invalid Codewhale account API base URL")?; |
| 699 | if !url.username().is_empty() || url.password().is_some() { |
| 700 | bail!("Codewhale account API base URL must not contain credentials"); |
| 701 | } |
| 702 | if url.query().is_some() || url.fragment().is_some() { |
| 703 | bail!("Codewhale account API base URL must not contain a query or fragment"); |
| 704 | } |
| 705 | if !matches!(url.path(), "" | "/") { |
| 706 | bail!("Codewhale account API base URL must be an origin without a path"); |
| 707 | } |
| 708 | let host = url |
| 709 | .host_str() |
| 710 | .ok_or_else(|| anyhow!("Codewhale account API base URL must include a host"))?; |
| 711 | let allowed = url.scheme() == "https" || (url.scheme() == "http" && is_loopback_host(host)); |
| 712 | if !allowed { |
| 713 | bail!( |
| 714 | "Codewhale account API base URL must use HTTPS (loopback HTTP is allowed for testing)" |
| 715 | ); |
| 716 | } |
| 717 | url.set_path("/"); |
| 718 | let display = url.as_str().trim_end_matches('/').to_string(); |
| 719 | Ok(ValidatedApiBase { url, display }) |
| 720 | } |
| 721 | |
| 722 | fn validate_verification_url( |
| 723 | value: &str, |
| 724 | api_base: &str, |
| 725 | user_code: &str, |
| 726 | complete: bool, |
| 727 | ) -> Result<String> { |
| 728 | let url = |
| 729 | Url::parse(value).context("The Codewhale service returned an invalid verification URL")?; |
| 730 | if value != url.as_str() { |
| 731 | bail!("The Codewhale service returned an unsafe verification URL"); |
| 732 | } |
| 733 | let host = url.host_str().ok_or_else(|| { |
| 734 | anyhow!("The Codewhale service returned a verification URL without a host") |
| 735 | })?; |
| 736 | if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { |
| 737 | bail!("The Codewhale service returned an unsafe verification URL"); |
| 738 | } |
| 739 | if url.path() != "/cli/authorize" { |
| 740 | bail!("The Codewhale service returned an unsafe verification URL"); |
| 741 | } |
| 742 | |
| 743 | let api = Url::parse(api_base).context("invalid Codewhale account API base URL")?; |
| 744 | let canonical_api = api.scheme() == "https" |
| 745 | && api.host_str() == Some("api.codewhale.net") |
| 746 | && api.port_or_known_default() == Some(443); |
| 747 | let loopback_api = api.host_str().is_some_and(is_loopback_host); |
| 748 | if canonical_api { |
| 749 | if url.scheme() != "https" |
| 750 | || !host.eq_ignore_ascii_case("app.codewhale.net") |
| 751 | || url.port_or_known_default() != Some(443) |
| 752 | { |
| 753 | bail!("The Codewhale service returned an untrusted verification origin"); |
| 754 | } |
| 755 | } else if loopback_api { |
| 756 | if !matches!(url.scheme(), "http" | "https") || !is_loopback_host(host) { |
| 757 | bail!("The Codewhale service returned an untrusted verification origin"); |
| 758 | } |
| 759 | } else { |
| 760 | bail!( |
| 761 | "Browser login is only enabled for the canonical Codewhale account API or a loopback test API" |
| 762 | ); |
| 763 | } |
| 764 | |
| 765 | let query = url.query_pairs().collect::<Vec<_>>(); |
| 766 | if complete { |
| 767 | if query.len() != 1 || query[0].0 != "user_code" || query[0].1 != user_code { |
| 768 | bail!("The Codewhale service returned an unsafe verification URL"); |
| 769 | } |
| 770 | } else if !query.is_empty() { |
| 771 | bail!("The Codewhale service returned an unsafe verification URL"); |
| 772 | } |
| 773 | Ok(url.to_string()) |
| 774 | } |
| 775 | |
| 776 | fn is_loopback_host(host: &str) -> bool { |
| 777 | let host = host |
| 778 | .strip_prefix('[') |
| 779 | .and_then(|value| value.strip_suffix(']')) |
| 780 | .unwrap_or(host); |
| 781 | host.eq_ignore_ascii_case("localhost") |
| 782 | || host |
| 783 | .parse::<IpAddr>() |
| 784 | .is_ok_and(|address| address.is_loopback()) |
| 785 | } |
| 786 | |
| 787 | fn validate_user_code(code: &str) -> Result<()> { |
| 788 | const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; |
| 789 | let bytes = code.as_bytes(); |
| 790 | if bytes.len() != 14 |
| 791 | || bytes[4] != b'-' |
| 792 | || bytes[9] != b'-' |
| 793 | || bytes |
| 794 | .iter() |
| 795 | .enumerate() |
| 796 | .any(|(index, byte)| !matches!(index, 4 | 9) && !ALPHABET.contains(byte)) |
| 797 | { |
| 798 | bail!("The Codewhale service returned an invalid user code"); |
| 799 | } |
| 800 | Ok(()) |
| 801 | } |
| 802 | |
| 803 | fn validate_device_code(code: &str) -> Result<()> { |
| 804 | if code.len() != 43 |
| 805 | || !code |
| 806 | .bytes() |
| 807 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) |
| 808 | { |
| 809 | bail!("The Codewhale service returned an invalid device authorization response"); |
| 810 | } |
| 811 | Ok(()) |
| 812 | } |
| 813 | |
| 814 | fn validate_api_key(key: &str) -> Result<()> { |
| 815 | let bytes = key.len(); |
| 816 | if bytes < MIN_API_KEY_BYTES || bytes as u64 > MAX_API_KEY_BYTES { |
| 817 | bail!("API key must be {MIN_API_KEY_BYTES}-{MAX_API_KEY_BYTES} UTF-8 bytes"); |
| 818 | } |
| 819 | if key.chars().any(is_ascii_control) { |
| 820 | bail!("API key contains invalid control characters"); |
| 821 | } |
| 822 | Ok(()) |
| 823 | } |
| 824 | |
| 825 | fn validate_label(label: &str) -> Result<String> { |
| 826 | let label = label.split_whitespace().collect::<Vec<_>>().join(" "); |
| 827 | if label.is_empty() |
| 828 | || label.chars().count() > MAX_KEY_LABEL_CHARS |
| 829 | || label.chars().any(is_ascii_control) |
| 830 | { |
| 831 | bail!("key label must contain 1-{MAX_KEY_LABEL_CHARS} characters"); |
| 832 | } |
| 833 | Ok(label) |
| 834 | } |
| 835 | |
| 836 | fn is_ascii_control(character: char) -> bool { |
| 837 | character <= '\u{001f}' || character == '\u{007f}' |
| 838 | } |
| 839 | |
| 840 | fn resolve_local_key( |
| 841 | config: &ConfigStore, |
| 842 | secrets: &Secrets, |
| 843 | provider: CloudProvider, |
| 844 | ) -> Result<Option<String>> { |
| 845 | let kind = provider.local_kind(); |
| 846 | let provider_config = config.config.providers.for_provider(kind); |
| 847 | let from_config = provider_config.api_key.clone().or_else(|| { |
| 848 | (kind == ProviderKind::Deepseek) |
| 849 | .then(|| config.config.api_key.clone()) |
| 850 | .flatten() |
| 851 | }); |
| 852 | if let Some(value) = from_config |
| 853 | .and_then(resolve_config_key_reference) |
| 854 | .filter(|value| !value.trim().is_empty()) |
| 855 | { |
| 856 | return Ok(Some(value)); |
| 857 | } |
| 858 | if let Some(value) = secrets |
| 859 | .get(kind.as_str()) |
| 860 | .context("failed to read the local provider secret store")? |
| 861 | .filter(|value| !value.trim().is_empty()) |
| 862 | { |
| 863 | return Ok(Some(value)); |
| 864 | } |
| 865 | Ok(kind.provider().env_vars().iter().find_map(|name| { |
| 866 | std::env::var(name) |
| 867 | .ok() |
| 868 | .filter(|value| !value.trim().is_empty()) |
| 869 | })) |
| 870 | } |
| 871 | |
| 872 | fn resolve_config_key_reference(value: String) -> Option<String> { |
| 873 | let trimmed = value.trim(); |
| 874 | let Some(variable) = trimmed.strip_prefix('$') else { |
| 875 | return Some(value); |
| 876 | }; |
| 877 | if variable.is_empty() |
| 878 | || !variable |
| 879 | .bytes() |
| 880 | .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') |
| 881 | { |
| 882 | return None; |
| 883 | } |
| 884 | std::env::var(variable) |
| 885 | .ok() |
| 886 | .filter(|value| !value.trim().is_empty()) |
| 887 | } |
| 888 | |
| 889 | fn read_key_from_stdin() -> Result<String> { |
| 890 | let mut bytes = Vec::new(); |
| 891 | io::stdin() |
| 892 | .take(MAX_API_KEY_STDIN_BYTES + 1) |
| 893 | .read_to_end(&mut bytes) |
| 894 | .context("failed to read API key from stdin")?; |
| 895 | parse_key_input(bytes) |
| 896 | } |
| 897 | |
| 898 | fn parse_key_input(bytes: Vec<u8>) -> Result<String> { |
| 899 | if bytes.len() as u64 > MAX_API_KEY_STDIN_BYTES { |
| 900 | bail!("API key input is unexpectedly large"); |
| 901 | } |
| 902 | let value = String::from_utf8(bytes).context("API key from stdin is not valid UTF-8")?; |
| 903 | let value = value.trim().to_string(); |
| 904 | validate_api_key(&value)?; |
| 905 | Ok(value) |
| 906 | } |
| 907 | |
| 908 | fn read_key_hidden(provider: &str) -> Result<String> { |
| 909 | if !io::stdin().is_terminal() { |
| 910 | bail!("interactive key entry requires a terminal; use `--api-key-stdin` for piped input"); |
| 911 | } |
| 912 | let term = console::Term::stderr(); |
| 913 | term.write_str(&format!("Enter {provider} API key: ")) |
| 914 | .context("failed to write API key prompt")?; |
| 915 | let value = term |
| 916 | .read_secure_line() |
| 917 | .context("failed to read API key securely")?; |
| 918 | term.write_line("").ok(); |
| 919 | let value = value.trim().to_string(); |
| 920 | validate_api_key(&value)?; |
| 921 | Ok(value) |
| 922 | } |
| 923 | |
| 924 | fn json_body(value: &impl Serialize) -> Result<Vec<u8>> { |
| 925 | serde_json::to_vec(value).context("failed to encode Codewhale account request") |
| 926 | } |
| 927 | |
| 928 | fn expect_json<T: DeserializeOwned>(response: CloudResponse, statuses: &[u16]) -> Result<T> { |
| 929 | if !statuses.contains(&response.status) { |
| 930 | return Err(response_error(&response)); |
| 931 | } |
| 932 | parse_json_body(&response.body) |
| 933 | } |
| 934 | |
| 935 | fn expect_empty(response: CloudResponse, statuses: &[u16]) -> Result<()> { |
| 936 | if statuses.contains(&response.status) { |
| 937 | Ok(()) |
| 938 | } else { |
| 939 | Err(response_error(&response)) |
| 940 | } |
| 941 | } |
| 942 | |
| 943 | fn parse_json_body<T: DeserializeOwned>(body: &[u8]) -> Result<T> { |
| 944 | serde_json::from_slice(body).context("The Codewhale service returned an invalid JSON response") |
| 945 | } |
| 946 | |
| 947 | fn response_error(response: &CloudResponse) -> anyhow::Error { |
| 948 | let code = serde_json::from_slice::<serde_json::Value>(&response.body) |
| 949 | .ok() |
| 950 | .and_then(|body| { |
| 951 | body.get("code") |
| 952 | .and_then(serde_json::Value::as_str) |
| 953 | .or_else(|| { |
| 954 | body.get("error") |
| 955 | .and_then(|error| error.get("code")) |
| 956 | .and_then(serde_json::Value::as_str) |
| 957 | }) |
| 958 | .and_then(safe_error_code) |
| 959 | }); |
| 960 | match code { |
| 961 | Some(code) => anyhow!( |
| 962 | "Codewhale account request failed (HTTP {}, code {code})", |
| 963 | response.status |
| 964 | ), |
| 965 | None => anyhow!( |
| 966 | "Codewhale account request failed (HTTP {})", |
| 967 | response.status |
| 968 | ), |
| 969 | } |
| 970 | } |
| 971 | |
| 972 | fn safe_error_code(code: &str) -> Option<String> { |
| 973 | if code.is_empty() |
| 974 | || code.len() > 80 |
| 975 | || !code |
| 976 | .bytes() |
| 977 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) |
| 978 | { |
| 979 | return None; |
| 980 | } |
| 981 | Some(code.to_string()) |
| 982 | } |
| 983 | |
| 984 | fn printable(value: &str) -> String { |
| 985 | value |
| 986 | .chars() |
| 987 | .filter(|character| !character.is_control()) |
| 988 | .take(200) |
| 989 | .collect::<String>() |
| 990 | .trim() |
| 991 | .to_string() |
| 992 | } |
| 993 | |
| 994 | #[cfg(test)] |
| 995 | mod tests; |
| 996 |