返回 CodeWhale
cloud.rs
根目录 / crates / cli / src / cloud.rs
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
996 lines RUST