返回 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;
12
13 use anyhow::{Context, Result, anyhow, bail};
14 use clap::{Args, Subcommand};
15 use codewhale_config::device_code::DevicePollOutcome;
16 use codewhale_config::{ConfigStore, ProviderKind};
17 use codewhale_secrets::Secrets;
18 use codewhale_secrets::account::{
19 ACCOUNT_API_BASE_ENV as CLOUD_API_BASE_ENV, AccountAuthBundle as AuthBundle,
20 AccountSessionSnapshot, AccountSessionStore, AccountUser as CloudUser,
21 DEFAULT_ACCOUNT_API_BASE as DEFAULT_API_BASE, StoredAccountAuth as StoredCloudAuth,
22 normalize_account_profile as normalized_profile, secure_account_session_secrets,
23 validate_account_auth_bundle as validate_auth_bundle,
24 };
25 use reqwest::Url;
26 use serde::{Deserialize, Serialize, de::DeserializeOwned};
27
28 pub(crate) mod machine;
29
30 const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
31 const MIN_API_KEY_BYTES: usize = 8;
32 const MAX_API_KEY_BYTES: u64 = 4096;
33 const MAX_API_KEY_STDIN_BYTES: u64 = MAX_API_KEY_BYTES + 1024;
34 const MAX_KEY_LABEL_CHARS: usize = 80;
35 pub(crate) const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 600;
36 pub(crate) const MAX_LOGIN_TIMEOUT_SECONDS: u64 = 3600;
37
38 #[derive(Debug, Args)]
39 pub(crate) struct CloudArgs {
40 /// Codewhale account API origin. HTTPS is required except for loopback HTTP.
41 #[arg(long, global = true, value_name = "URL")]
42 api_base: Option<String>,
43 #[command(subcommand)]
44 command: CloudCommand,
45 }
46
47 #[derive(Debug, Subcommand)]
48 enum CloudCommand {
49 /// Sign this CLI profile in through the browser device flow.
50 Login(CloudLoginArgs),
51 /// Show the signed-in account for this CLI profile.
52 Status,
53 /// Remove this profile's local account session and revoke it when reachable.
54 Logout,
55 /// Manage provider API keys stored in the signed-in Codewhale account.
56 ///
57 /// These are credentials Codewhale presents *to* a model provider. For the
58 /// machine tokens a customer presents *to* Codewhale, see `api-keys`.
59 Keys(CloudKeysArgs),
60 /// Manage Codewhale account API keys: machine tokens for CI.
61 #[command(name = "api-keys")]
62 ApiKeys(machine::ApiKeysArgs),
63 /// Show the account this CLI authenticates as, preferring a machine key.
64 Whoami,
65 /// Check the account's agent-model precondition for machine work.
66 Agent,
67 /// Inspect the account document; local settings import is not available yet.
68 Pull(CloudPullArgs),
69 /// Push local settings to the account document (never automatic, --dry-run required).
70 Push(CloudPushArgs),
71 }
72
73 #[derive(Debug, Args)]
74 struct CloudLoginArgs {
75 /// Print the verification URL without trying to open a browser.
76 #[arg(long, default_value_t = false)]
77 no_open: bool,
78 /// Maximum time to wait for browser authorization.
79 #[arg(
80 long = "timeout-seconds",
81 default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS,
82 value_parser = clap::value_parser!(u64).range(1..=MAX_LOGIN_TIMEOUT_SECONDS)
83 )]
84 timeout_seconds: u64,
85 }
86
87 #[derive(Debug, Args)]
88 struct CloudPullArgs {
89 /// Inspect the account document without writing local files.
90 #[arg(long, default_value_t = false)]
91 dry_run: bool,
92 }
93
94 #[derive(Debug, Args)]
95 struct CloudPushArgs {
96 /// Show what would be pushed without writing the remote document.
97 #[arg(long, default_value_t = false)]
98 dry_run: bool,
99 }
100
101 #[derive(Debug, Args)]
102 struct CloudKeysArgs {
103 #[command(subcommand)]
104 command: CloudKeysCommand,
105 }
106
107 #[derive(Debug, Subcommand)]
108 enum CloudKeysCommand {
109 /// List configured providers without revealing key values.
110 List,
111 /// Save a provider key to the signed-in Codewhale account.
112 Set(CloudKeySetArgs),
113 /// Remove a provider key from the signed-in Codewhale account.
114 Remove {
115 /// Provider id from the account's catalog (`account keys list`).
116 provider: String,
117 },
118 }
119
120 #[derive(Debug, Args)]
121 struct CloudKeySetArgs {
122 /// Provider id from the account's catalog (`account keys list`).
123 provider: String,
124 /// Read the key from stdin. Useful for pipes and secret-manager commands.
125 #[arg(long = "api-key-stdin", conflicts_with = "from_local")]
126 api_key_stdin: bool,
127 /// Upload the locally resolved key (config, secret store, then environment).
128 #[arg(long, conflicts_with = "api_key_stdin")]
129 from_local: bool,
130 /// Non-secret label shown beside the stored credential.
131 #[arg(long, default_value = "Codewhale CLI")]
132 label: String,
133 }
134
135 /// One row of the account control plane's public provider catalog.
136 ///
137 /// This is untrusted remote data, not a Codewhale-owned enum: the account
138 /// service adds providers without a CLI release, so the catalog is read as
139 /// data and every id is re-validated locally before it reaches a URL path.
140 /// Only the fields this surface actually uses are modeled; unknown fields are
141 /// ignored rather than being turned into behavior.
142 #[derive(Debug, Clone, Deserialize)]
143 #[serde(rename_all = "camelCase")]
144 struct CatalogProvider {
145 id: String,
146 #[serde(default)]
147 label: String,
148 /// The runtime provider id this catalog row maps onto, when one exists.
149 /// `--from-local` uses it to find the local credential; without it the
150 /// row's own id is tried.
151 #[serde(default)]
152 runtime_provider: Option<String>,
153 }
154
155 #[derive(Debug, Deserialize)]
156 struct ProviderCatalogResponse {
157 #[serde(default)]
158 providers: Vec<CatalogProvider>,
159 }
160
161 impl CatalogProvider {
162 /// Non-empty display label, falling back to the id.
163 fn display_label(&self) -> String {
164 let label = printable(&self.label);
165 if label.is_empty() {
166 printable(&self.id)
167 } else {
168 label
169 }
170 }
171
172 /// The local [`ProviderKind`] this catalog row maps onto, if any.
173 ///
174 /// The catalog states its own runtime mapping (`xiaomi` →
175 /// `xiaomi-mimo`); the row id is only a fallback for a provider whose
176 /// catalog id already equals the runtime id.
177 fn local_kind(&self) -> Option<ProviderKind> {
178 self.runtime_provider
179 .as_deref()
180 .map(str::trim)
181 .filter(|value| !value.is_empty())
182 .and_then(ProviderKind::parse_config_identity)
183 .or_else(|| ProviderKind::parse_config_identity(&self.id))
184 }
185 }
186
187 /// Accept a provider id conservatively before it is ever put in a URL path.
188 ///
189 /// `^[a-z0-9][a-z0-9-]{0,63}$`. The catalog is remote data, so this guards
190 /// both directions: a hostile catalog cannot smuggle a path segment, and a
191 /// mistyped argument fails locally instead of as a confusing 404.
192 fn validate_provider_id(value: &str) -> Result<String> {
193 let trimmed = value.trim();
194 let bytes = trimmed.as_bytes();
195 let well_formed = (1..=64).contains(&bytes.len())
196 && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
197 && bytes
198 .iter()
199 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-');
200 if !well_formed {
201 bail!(
202 "`{}` is not a valid provider id. Ids are 1-64 characters of lowercase letters, digits, and `-`. Run `codewhale account keys list` to see the account's providers",
203 printable(trimmed)
204 );
205 }
206 Ok(trimmed.to_string())
207 }
208
209 #[derive(Clone, Copy, PartialEq, Eq)]
210 enum HttpMethod {
211 Get,
212 Post,
213 Put,
214 Delete,
215 }
216
217 pub(crate) struct CloudRequest {
218 method: HttpMethod,
219 path: String,
220 bearer: Option<String>,
221 body: Option<Vec<u8>>,
222 }
223
224 pub(crate) struct CloudResponse {
225 status: u16,
226 body: Vec<u8>,
227 /// `Retry-After` in whole seconds, when the service supplied one. Kept on
228 /// the response rather than re-parsed by callers so the retry policy has a
229 /// single source for how long the server asked us to wait.
230 retry_after: Option<u64>,
231 }
232
233 pub(crate) trait CloudTransport {
234 fn execute(&self, request: CloudRequest) -> Result<CloudResponse>;
235 }
236
237 struct ReqwestTransport {
238 base: Url,
239 client: reqwest::blocking::Client,
240 }
241
242 impl ReqwestTransport {
243 fn new(base: Url) -> Result<Self> {
244 let client = codewhale_release::platform_blocking_http_client_builder()
245 .connect_timeout(Duration::from_secs(8))
246 .timeout(Duration::from_secs(30))
247 // Never replay bearer tokens or provider-key request bodies to a
248 // redirect target. The control-plane origin is an explicit trust
249 // boundary, so redirects are treated as ordinary non-2xx replies.
250 .redirect(reqwest::redirect::Policy::none())
251 .user_agent(concat!("codewhale/", env!("CARGO_PKG_VERSION")))
252 .build()
253 .context("failed to initialize the Codewhale account HTTP client")?;
254 Ok(Self { base, client })
255 }
256 }
257
258 impl CloudTransport for ReqwestTransport {
259 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
260 let url = self
261 .base
262 .join(request.path.trim_start_matches('/'))
263 .context("failed to construct the Codewhale account request URL")?;
264 let method = match request.method {
265 HttpMethod::Get => reqwest::Method::GET,
266 HttpMethod::Post => reqwest::Method::POST,
267 HttpMethod::Put => reqwest::Method::PUT,
268 HttpMethod::Delete => reqwest::Method::DELETE,
269 };
270 let mut builder = self
271 .client
272 .request(method, url)
273 .header(reqwest::header::ACCEPT, "application/json");
274 if let Some(token) = request.bearer {
275 builder = builder.bearer_auth(token);
276 }
277 if let Some(body) = request.body {
278 builder = builder
279 .header(reqwest::header::CONTENT_TYPE, "application/json")
280 .body(body);
281 }
282 let response = builder
283 .send()
284 .context("could not reach the Codewhale service")?;
285 let status = response.status().as_u16();
286 let retry_after = response
287 .headers()
288 .get(reqwest::header::RETRY_AFTER)
289 .and_then(|value| value.to_str().ok())
290 .and_then(|value| value.trim().parse::<u64>().ok());
291 let mut body = Vec::new();
292 response
293 .take(MAX_RESPONSE_BYTES + 1)
294 .read_to_end(&mut body)
295 .context("failed to read the Codewhale service response")?;
296 if body.len() as u64 > MAX_RESPONSE_BYTES {
297 bail!("The Codewhale service returned an unexpectedly large response");
298 }
299 Ok(CloudResponse {
300 status,
301 body,
302 retry_after,
303 })
304 }
305 }
306
307 #[derive(Deserialize)]
308 #[serde(rename_all = "camelCase")]
309 struct DeviceStart {
310 device_code: String,
311 user_code: String,
312 verification_uri: String,
313 verification_uri_complete: String,
314 expires_in: u64,
315 interval: u64,
316 }
317
318 #[derive(Deserialize)]
319 struct MeResponse {
320 user: CloudUser,
321 }
322
323 #[derive(Serialize)]
324 #[serde(rename_all = "camelCase")]
325 struct DeviceTokenRequest<'a> {
326 device_code: &'a str,
327 }
328
329 #[derive(Serialize)]
330 #[serde(rename_all = "camelCase")]
331 struct RefreshRequest<'a> {
332 refresh_token: &'a str,
333 }
334
335 #[derive(Serialize)]
336 struct ModelKeyRequest<'a> {
337 key: &'a str,
338 label: &'a str,
339 }
340
341 pub(crate) struct CloudClient<'a, T: CloudTransport> {
342 transport: &'a T,
343 account_store: AccountSessionStore,
344 }
345
346 impl<'a, T: CloudTransport> CloudClient<'a, T> {
347 fn new(transport: &'a T, secrets: &'a Secrets, profile: &str, api_base: &'a str) -> Self {
348 Self {
349 transport,
350 account_store: AccountSessionStore::new(secrets.clone(), Some(profile), api_base),
351 }
352 }
353
354 fn start_device(&self) -> Result<DeviceStart> {
355 let response = self.transport.execute(CloudRequest {
356 method: HttpMethod::Post,
357 path: "/api/cli/device/start".to_string(),
358 bearer: None,
359 body: Some(b"{}".to_vec()),
360 })?;
361 expect_json(response, &[200])
362 }
363
364 fn poll_device(
365 &self,
366 device: &DeviceStart,
367 timeout: Duration,
368 sleep: &mut dyn FnMut(Duration),
369 ) -> Result<AuthBundle> {
370 validate_device_code(&device.device_code)?;
371 let server_lifetime =
372 Duration::from_secs(device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS));
373 // The Codewhale account service answers HTTP 202 while the code is
374 // still pending, so the first response is already meaningful: poll
375 // immediately and sleep afterwards. It has no slow_down.
376 let bundle = codewhale_config::device_code::DeviceCodePoll::new(
377 timeout.min(server_lifetime),
378 "Codewhale account login timed out; run `codewhale login` to try again",
379 )
380 .interval_seconds(Some(device.interval))
381 .max_interval_seconds(10)
382 .run(sleep, || {
383 let response = self.transport.execute(CloudRequest {
384 method: HttpMethod::Post,
385 path: "/api/cli/device/token".to_string(),
386 bearer: None,
387 body: Some(json_body(&DeviceTokenRequest {
388 device_code: &device.device_code,
389 })?),
390 })?;
391 match response.status {
392 200 => {
393 let bundle: AuthBundle = parse_json_body(&response.body)?;
394 validate_auth_bundle(&bundle)?;
395 Ok(DevicePollOutcome::Complete(bundle))
396 }
397 202 => Ok(DevicePollOutcome::Pending),
398 _ => Err(response_error(&response)),
399 }
400 })?;
401 self.save_auth(bundle.clone())?;
402 Ok(bundle)
403 }
404
405 fn load_auth(&self) -> Result<Option<StoredCloudAuth>> {
406 self.account_store.load().context(
407 "the local Codewhale account session is unreadable; run `codewhale account logout` and sign in again",
408 )
409 }
410
411 fn save_auth(&self, bundle: AuthBundle) -> Result<()> {
412 self.account_store
413 .save(bundle)
414 .context("failed to save the Codewhale account session in the local secret store")
415 }
416
417 fn me(&self) -> Result<CloudUser> {
418 let (response, snapshot) =
419 self.execute_authenticated_snapshot(HttpMethod::Get, "/api/me", None)?;
420 let me: MeResponse = expect_json(response, &[200])?;
421 if me.user.id.trim().is_empty() {
422 bail!("The Codewhale service returned an account without an ID");
423 }
424 if let Some(mut stored) = snapshot.load()? {
425 if stored
426 .bundle
427 .user
428 .as_ref()
429 .is_some_and(|user| !user.id.is_empty() && user.id != me.user.id)
430 {
431 bail!("The signed-in account changed. Refresh and try again.");
432 }
433 stored.bundle.user = Some(me.user.clone());
434 if self
435 .account_store
436 .save_if_unchanged(&snapshot, stored.bundle)?
437 .is_none()
438 {
439 bail!("The signed-in account changed. Refresh and try again.");
440 }
441 }
442 Ok(me.user)
443 }
444
445 fn set_key(&self, provider: &str, key: &str, label: &str) -> Result<()> {
446 let path = format!("/api/model-keys/{provider}");
447 let response = self.execute_authenticated(
448 HttpMethod::Put,
449 &path,
450 Some(json_body(&ModelKeyRequest { key, label })?),
451 )?;
452 expect_empty(response, &[200, 201])
453 }
454
455 fn remove_key(&self, provider: &str) -> Result<()> {
456 let path = format!("/api/model-keys/{provider}");
457 let response = self.execute_authenticated(HttpMethod::Delete, &path, None)?;
458 expect_empty(response, &[200, 204])
459 }
460
461 /// The account control plane's public provider catalog.
462 ///
463 /// This replaced a hardcoded eight-provider enum: the set of providers a
464 /// customer can connect is owned by the control plane, not the CLI, so a
465 /// newly supported provider must not need a CLI release. The route is
466 /// public, so no session is required to *list* what could be connected —
467 /// only to read or write this account's keys.
468 ///
469 /// Rows with an id this CLI would refuse to put in a URL path are dropped
470 /// rather than trusted; duplicates collapse onto the first row.
471 fn provider_catalog(&self) -> Result<Vec<CatalogProvider>> {
472 let response = self.transport.execute(CloudRequest {
473 method: HttpMethod::Get,
474 path: "/api/model-providers".to_string(),
475 bearer: None,
476 body: None,
477 })?;
478 let listing: ProviderCatalogResponse = expect_json(response, &[200])?;
479 let mut seen = std::collections::BTreeSet::new();
480 let providers: Vec<CatalogProvider> = listing
481 .providers
482 .into_iter()
483 .filter(|row| validate_provider_id(&row.id).is_ok())
484 .filter(|row| seen.insert(row.id.trim().to_string()))
485 .map(|mut row| {
486 row.id = row.id.trim().to_string();
487 row
488 })
489 .collect();
490 if providers.is_empty() {
491 bail!(
492 "The Codewhale service returned no connectable providers. Check the account API origin, or try again"
493 );
494 }
495 Ok(providers)
496 }
497
498 fn logout(&self) -> Result<bool> {
499 let snapshot = self.account_store.snapshot()?;
500 self.account_store
501 .with_transaction(|transaction| -> Result<bool> {
502 if !transaction.matches(&snapshot) {
503 bail!("The signed-in account changed. Refresh and try again.");
504 }
505 let stored = match transaction.load() {
506 Ok(Some(stored)) => stored,
507 Ok(None) | Err(_) => {
508 transaction.clear();
509 return Ok(false);
510 }
511 };
512 let body = json_body(&RefreshRequest {
513 refresh_token: &stored.bundle.refresh_token,
514 })?;
515 let response = self.transport.execute(CloudRequest {
516 method: HttpMethod::Post,
517 path: "/api/auth/logout".into(),
518 bearer: None,
519 body: Some(body),
520 })?;
521 if (200..300).contains(&response.status) || matches!(response.status, 401 | 403) {
522 transaction.clear();
523 return Ok((200..300).contains(&response.status));
524 }
525 // Keep custody on transient failure so revocation can be retried.
526 Err(response_error(&response))
527 })
528 }
529
530 fn execute_authenticated(
531 &self,
532 method: HttpMethod,
533 path: &str,
534 body: Option<Vec<u8>>,
535 ) -> Result<CloudResponse> {
536 self.execute_authenticated_snapshot(method, path, body)
537 .map(|(response, _)| response)
538 }
539
540 fn execute_authenticated_snapshot(
541 &self,
542 method: HttpMethod,
543 path: &str,
544 body: Option<Vec<u8>>,
545 ) -> Result<(CloudResponse, AccountSessionSnapshot)> {
546 let snapshot = self.account_store.snapshot()?;
547 let Some(mut stored) = snapshot.load()? else {
548 bail!("Not signed in. Run `codewhale login` first");
549 };
550 let first = self.transport.execute(CloudRequest {
551 method,
552 path: path.into(),
553 bearer: Some(stored.bundle.access_token.clone()),
554 body: body.clone(),
555 })?;
556 if first.status != 401 {
557 return Ok((first, snapshot));
558 }
559 // Serialize the refresh HTTP request itself with native/CLI writers:
560 // two processes must not spend the same rotating refresh token.
561 let renewed = self
562 .account_store
563 .with_transaction(|transaction| -> Result<_> {
564 if !transaction.matches(&snapshot) {
565 bail!("The signed-in account changed. Refresh and try again.");
566 }
567 let refresh = self.transport.execute(CloudRequest {
568 method: HttpMethod::Post,
569 path: "/api/auth/refresh".into(),
570 bearer: None,
571 body: Some(json_body(&RefreshRequest {
572 refresh_token: &stored.bundle.refresh_token,
573 })?),
574 })?;
575 match refresh.status {
576 200 => {}
577 401 => {
578 transaction.clear();
579 return Ok(None);
580 }
581 _ => return Err(response_error(&refresh)),
582 }
583 let mut next: AuthBundle = parse_json_body(&refresh.body)?;
584 validate_auth_bundle(&next)?;
585 if next.user.is_none() {
586 next.user = stored.bundle.user.take();
587 }
588 if next.session.is_none() {
589 next.session = stored.bundle.session.take();
590 }
591 transaction.replace(next.clone())?;
592 Ok(Some((next, transaction.snapshot())))
593 })?;
594 let Some((next, next_snapshot)) = renewed else {
595 bail!("The Codewhale account session expired. Run `codewhale login` again");
596 };
597 // The rotated token is durable before a potentially failing retry.
598 let retried = self.transport.execute(CloudRequest {
599 method,
600 path: path.into(),
601 bearer: Some(next.access_token),
602 body,
603 })?;
604 if retried.status == 401 {
605 self.account_store.clear_if_unchanged(&next_snapshot)?;
606 bail!("The Codewhale account session expired. Run `codewhale login` again");
607 }
608 Ok((retried, next_snapshot))
609 }
610
611 /// Whether an interactive session exists for this profile and origin.
612 ///
613 /// A management command asks this before it asks anything of the network,
614 /// so "you have a machine key but no login" is answered locally instead of
615 /// as a 403 from a route the key was never allowed to touch.
616 fn has_session(&self) -> Result<bool> {
617 Ok(self.load_auth()?.is_some())
618 }
619
620 /// `execute_authenticated`, retrying only what the caller marks replayable.
621 ///
622 /// `machine::Retry::Never` is not a default worth having: the one POST in
623 /// this surface mints a secret shown exactly once, so a retry that quietly
624 /// succeeded server-side would leave an unrevocable key behind.
625 fn execute_authenticated_with_retry(
626 &self,
627 method: HttpMethod,
628 path: &str,
629 body: Option<Vec<u8>>,
630 retry: machine::Retry,
631 sleeper: &mut dyn FnMut(Duration),
632 ) -> Result<CloudResponse> {
633 let max_attempts = if retry == machine::Retry::Idempotent {
634 3
635 } else {
636 1
637 };
638 let mut attempt = 1;
639 loop {
640 let response = self.execute_authenticated(method, path, body.clone())?;
641 if (200..300).contains(&response.status) || attempt >= max_attempts {
642 return Ok(response);
643 }
644 let retry_after = response.retry_after;
645 if !machine::classify(&response).retryable {
646 return Ok(response);
647 }
648 sleeper(machine::backoff_delay(attempt, retry_after));
649 attempt += 1;
650 }
651 }
652 }
653
654 enum KeyReadMode {
655 Stdin,
656 HiddenPrompt(String),
657 }
658
659 pub(crate) fn run(args: CloudArgs, profile: Option<&str>, config: &ConfigStore) -> Result<()> {
660 let machine = machine::MachineKeyEnv::from_process_env();
661 let requested_base = machine::resolve_api_base(
662 args.api_base.as_deref(),
663 std::env::var(machine::MACHINE_API_BASE_ENV).ok().as_deref(),
664 std::env::var(CLOUD_API_BASE_ENV).ok().as_deref(),
665 DEFAULT_API_BASE,
666 );
667 if machine.is_present() {
668 // A machine token is a bearer credential with no replay protection.
669 // Refuse cleartext to a remote host before a transport exists, so
670 // there is no code path on which the key could be written to a socket.
671 machine::require_secure_base(&requested_base)?;
672 }
673 let api_base = validate_api_base(&requested_base)?;
674 let transport = ReqwestTransport::new(api_base.url.clone())?;
675 // Account refresh tokens require an OS credential manager. The ordinary
676 // provider backend remains independently configurable for `--from-local`.
677 let cloud_secrets = cloud_session_secrets()?;
678 let provider_secrets = Secrets::auto_detect();
679 let profile = normalized_profile(profile);
680 let mut stdout = io::stdout().lock();
681 let mut key_reader = |mode: KeyReadMode| match mode {
682 KeyReadMode::Stdin => read_key_from_stdin(),
683 KeyReadMode::HiddenPrompt(provider) => read_key_hidden(&provider),
684 };
685 let mut opener = |url: String| webbrowser::open(&url).is_ok();
686 let mut sleeper = |duration| thread::sleep(duration);
687 run_with(
688 args.command,
689 &profile,
690 &api_base.display,
691 config,
692 &cloud_secrets,
693 &provider_secrets,
694 &machine,
695 &transport,
696 &mut stdout,
697 &mut key_reader,
698 &mut opener,
699 &mut sleeper,
700 )
701 }
702
703 fn cloud_session_secrets() -> Result<Secrets> {
704 // Codex-style storage contract: the OS credential manager is preferred
705 // but never required; without one, sessions live in the private 0600
706 // Codewhale secrets file. Only an unresolvable store path fails here.
707 secure_account_session_secrets().map_err(|err| anyhow!(err.to_string()))
708 }
709
710 /// `codewhale login` is a convenience entry to the account device flow — the
711 /// same path as `codewhale account login`, without re-spelling the subcommand.
712 pub(crate) fn run_account_login(
713 no_open: bool,
714 timeout_seconds: u64,
715 profile: Option<&str>,
716 config: &ConfigStore,
717 ) -> Result<()> {
718 run(
719 CloudArgs {
720 api_base: None,
721 command: CloudCommand::Login(CloudLoginArgs {
722 no_open,
723 timeout_seconds,
724 }),
725 },
726 profile,
727 config,
728 )
729 }
730
731 pub(crate) fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> {
732 if api_key.is_some() {
733 bail!(
734 "`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`"
735 );
736 }
737 Ok(())
738 }
739
740 #[allow(clippy::too_many_arguments)]
741 fn run_with<T: CloudTransport, W: Write>(
742 command: CloudCommand,
743 profile: &str,
744 api_base: &str,
745 config: &ConfigStore,
746 cloud_secrets: &Secrets,
747 provider_secrets: &Secrets,
748 machine: &machine::MachineKeyEnv,
749 transport: &T,
750 out: &mut W,
751 key_reader: &mut dyn FnMut(KeyReadMode) -> Result<String>,
752 opener: &mut dyn FnMut(String) -> bool,
753 sleeper: &mut dyn FnMut(Duration),
754 ) -> Result<()> {
755 let client = CloudClient::new(transport, cloud_secrets, profile, api_base);
756 match command {
757 CloudCommand::Login(login) => {
758 let device = client.start_device()?;
759 validate_user_code(&device.user_code)?;
760 validate_verification_url(
761 &device.verification_uri,
762 api_base,
763 &device.user_code,
764 false,
765 )?;
766 let verification_uri_complete = validate_verification_url(
767 &device.verification_uri_complete,
768 api_base,
769 &device.user_code,
770 true,
771 )?;
772 writeln!(out, "Codewhale account sign-in")?;
773 writeln!(out, "Code: {}", device.user_code)?;
774 writeln!(out, "Open: {verification_uri_complete}")?;
775 writeln!(out, "Profile: {}", printable(profile))?;
776 if !login.no_open && !opener(verification_uri_complete) {
777 writeln!(
778 out,
779 "Browser could not be opened; use the URL and code above."
780 )?;
781 }
782 let _ =
783 client.poll_device(&device, Duration::from_secs(login.timeout_seconds), sleeper)?;
784 let user = client.me()?;
785 write_account(out, "Signed in to Codewhale.", profile, api_base, &user)?;
786 Ok(())
787 }
788 CloudCommand::Status => match client.load_auth()? {
789 Some(_) => {
790 let user = client.me()?;
791 write_account(out, "Signed in to Codewhale.", profile, api_base, &user)
792 }
793 None => {
794 writeln!(out, "Not signed in to Codewhale.")?;
795 writeln!(out, "Profile: {}", printable(profile))?;
796 writeln!(out, "API: {api_base}")?;
797 writeln!(out, "Run `codewhale login` to sign in.")?;
798 Ok(())
799 }
800 },
801 CloudCommand::Logout => {
802 let remote_revoked = client.logout()?;
803 writeln!(out, "Removed the local Codewhale account session.")?;
804 writeln!(out, "Profile: {}", printable(profile))?;
805 if !remote_revoked {
806 writeln!(
807 out,
808 "Remote revocation was not confirmed; the local tokens are gone."
809 )?;
810 }
811 Ok(())
812 }
813 CloudCommand::Keys(keys) => match keys.command {
814 CloudKeysCommand::List => {
815 let user = client.me()?;
816 let catalog = client.provider_catalog()?;
817 write_account(out, "Codewhale account keys.", profile, api_base, &user)?;
818 for row in &catalog {
819 let stored = user.model_keys.get(&row.id);
820 let status = match stored {
821 Some(state) if state.configured => match state
822 .state
823 .as_deref()
824 .map(printable)
825 .filter(|value| !value.is_empty())
826 {
827 Some(reported) => format!("set ({reported})"),
828 None => "set".to_string(),
829 },
830 _ => "not set".to_string(),
831 };
832 writeln!(out, "{}: {status} — {}", row.id, row.display_label())?;
833 }
834 Ok(())
835 }
836 CloudKeysCommand::Set(set) => {
837 let provider = validate_provider_id(&set.provider)?;
838 let user = client.me()?;
839 let catalog = client.provider_catalog()?;
840 let row = catalog_row(&catalog, &provider)?;
841 let key = if set.from_local {
842 let kind = row.local_kind().ok_or_else(|| {
843 anyhow!(
844 "`{provider}` has no local runtime provider, so there is no local key to copy. Use `--api-key-stdin` or the hidden prompt"
845 )
846 })?;
847 resolve_local_key(config, provider_secrets, kind)?.ok_or_else(|| {
848 anyhow!(
849 "No local {} API key was found in config, the secret store, or the environment",
850 kind.as_str()
851 )
852 })?
853 } else if set.api_key_stdin {
854 key_reader(KeyReadMode::Stdin)?
855 } else {
856 key_reader(KeyReadMode::HiddenPrompt(provider.clone()))?
857 };
858 let key = key.trim().to_string();
859 validate_api_key(&key)?;
860 let label = validate_label(&set.label)?;
861 client.set_key(&provider, &key, &label)?;
862 writeln!(
863 out,
864 "Saved {provider} for Codewhale account {} (profile {}).",
865 printable(&user.id),
866 printable(profile)
867 )?;
868 Ok(())
869 }
870 CloudKeysCommand::Remove { provider } => {
871 let provider = validate_provider_id(&provider)?;
872 let user = client.me()?;
873 let catalog = client.provider_catalog()?;
874 let _ = catalog_row(&catalog, &provider)?;
875 client.remove_key(&provider)?;
876 writeln!(
877 out,
878 "Removed {provider} from Codewhale account {} (profile {}).",
879 printable(&user.id),
880 printable(profile)
881 )?;
882 Ok(())
883 }
884 },
885 CloudCommand::ApiKeys(api_keys) => {
886 machine::run_api_keys(api_keys, &client, machine, provider_secrets, out, sleeper)
887 }
888 CloudCommand::Whoami => match machine.resolve()? {
889 // A present machine key wins and never falls back: silently
890 // downgrading a machine credential to a human one is how CI ends
891 // up running as the wrong identity.
892 Some(key) => {
893 let machine_client = machine::MachineClient::new(transport, key);
894 let who = machine_client.whoami(sleeper)?;
895 machine::write_whoami(out, &who, api_base, machine_client.key_head())
896 }
897 None => {
898 let user = client.me()?;
899 write_account(out, "Signed in to Codewhale.", profile, api_base, &user)
900 }
901 },
902 CloudCommand::Agent => {
903 // The agent route is machine-key-only by design, so CI and humans
904 // never blur in an audit trail. There is no session fallback.
905 let key = machine.require()?;
906 let machine_client = machine::MachineClient::new(transport, key);
907 let agent = machine_client.agent(sleeper)?.agent;
908 machine::write_agent(out, &agent)
909 }
910 CloudCommand::Pull(args) => {
911 if !args.dry_run {
912 bail!(
913 "Account settings import is not available yet; local config was not changed. Run `codewhale account pull --dry-run` to inspect the signed-in account."
914 );
915 }
916 let user = client.me()?;
917 // `/api/me` currently exposes account identity and key metadata,
918 // not a versioned settings document that can be applied locally.
919 // Stay read-only and explicit until that import contract exists.
920 writeln!(out, "Account settings (pull --dry-run):")?;
921 writeln!(out, "Account ID: {}", printable(&user.id))?;
922 writeln!(out, "Profile: {}", printable(profile))?;
923 writeln!(out, "API: {api_base}")?;
924 writeln!(
925 out,
926 "dry-run: remote settings import is not available; local config unchanged"
927 )?;
928 // Show the invariant: Bearer custody stays in the OS keyring, never in config.toml.
929 writeln!(
930 out,
931 "Secure custody: Bearer tokens remain in the OS keyring"
932 )?;
933 Ok(())
934 }
935 CloudCommand::Push(args) => {
936 let user = client.me()?;
937 if !args.dry_run {
938 bail!(
939 "Push is never automatic; re-run with --dry-run to preview, then confirm explicitly"
940 );
941 }
942 writeln!(out, "Account settings (push --dry-run):")?;
943 writeln!(out, "Account ID: {}", printable(&user.id))?;
944 writeln!(out, "Profile: {}", printable(profile))?;
945 writeln!(out, "API: {api_base}")?;
946 writeln!(
947 out,
948 "dry-run: would PATCH /api/me/preferences with If-Match revision check (412 on conflict)"
949 )?;
950 writeln!(
951 out,
952 "No credentials, paths, or env are copied; only explicit fields (field-level last-writer-wins)"
953 )?;
954 Ok(())
955 }
956 }
957 }
958
959 /// Resolve the account's configured agent route for a machine-key run.
960 ///
961 /// `codewhale review` hard-errors when a model resolves to several configured
962 /// routes. When CI authenticates with a machine key, the account has already
963 /// answered that question, so its configured provider is the disambiguator —
964 /// no new flag, and no guess. Returns `None` when no machine key is set, which
965 /// leaves the ordinary local resolution untouched.
966 pub(crate) fn machine_review_provider() -> Result<Option<ProviderKind>> {
967 let machine = machine::MachineKeyEnv::from_process_env();
968 let Some(key) = machine.resolve()? else {
969 return Ok(None);
970 };
971 let requested_base = machine::resolve_api_base(
972 None,
973 std::env::var(machine::MACHINE_API_BASE_ENV).ok().as_deref(),
974 std::env::var(CLOUD_API_BASE_ENV).ok().as_deref(),
975 DEFAULT_API_BASE,
976 );
977 machine::require_secure_base(&requested_base)?;
978 let api_base = validate_api_base(&requested_base)?;
979 let transport = ReqwestTransport::new(api_base.url)?;
980 let client = machine::MachineClient::new(&transport, key);
981 // The call that actually needs a model is the call that refuses without
982 // one, so this precondition runs before any review work starts.
983 let agent = client.agent(&mut |duration| thread::sleep(duration))?.agent;
984 machine::review_provider_from_agent(&agent).map(Some)
985 }
986
987 fn write_account<W: Write>(
988 out: &mut W,
989 heading: &str,
990 profile: &str,
991 api_base: &str,
992 user: &CloudUser,
993 ) -> Result<()> {
994 writeln!(out, "{heading}")?;
995 writeln!(out, "Account ID: {}", printable(&user.id))?;
996 if !user.display_name.trim().is_empty() {
997 writeln!(out, "Name: {}", printable(&user.display_name))?;
998 }
999 if !user.email.trim().is_empty() {
1000 writeln!(out, "Email: {}", printable(&user.email))?;
1001 }
1002 if !user.plan.trim().is_empty() {
1003 writeln!(out, "Plan: {}", printable(&user.plan))?;
1004 }
1005 writeln!(out, "Profile: {}", printable(profile))?;
1006 writeln!(out, "API: {api_base}")?;
1007 Ok(())
1008 }
1009
1010 struct ValidatedApiBase {
1011 url: Url,
1012 display: String,
1013 }
1014
1015 fn validate_api_base(value: &str) -> Result<ValidatedApiBase> {
1016 let mut url = Url::parse(value.trim()).context("invalid Codewhale account API base URL")?;
1017 if !url.username().is_empty() || url.password().is_some() {
1018 bail!("Codewhale account API base URL must not contain credentials");
1019 }
1020 if url.query().is_some() || url.fragment().is_some() {
1021 bail!("Codewhale account API base URL must not contain a query or fragment");
1022 }
1023 if !matches!(url.path(), "" | "/") {
1024 bail!("Codewhale account API base URL must be an origin without a path");
1025 }
1026 let host = url
1027 .host_str()
1028 .ok_or_else(|| anyhow!("Codewhale account API base URL must include a host"))?;
1029 let allowed = url.scheme() == "https" || (url.scheme() == "http" && is_loopback_host(host));
1030 if !allowed {
1031 bail!(
1032 "Codewhale account API base URL must use HTTPS (loopback HTTP is allowed for testing)"
1033 );
1034 }
1035 url.set_path("/");
1036 let display = url.as_str().trim_end_matches('/').to_string();
1037 Ok(ValidatedApiBase { url, display })
1038 }
1039
1040 fn validate_verification_url(
1041 value: &str,
1042 api_base: &str,
1043 user_code: &str,
1044 complete: bool,
1045 ) -> Result<String> {
1046 let url =
1047 Url::parse(value).context("The Codewhale service returned an invalid verification URL")?;
1048 if value != url.as_str() {
1049 bail!("The Codewhale service returned an unsafe verification URL");
1050 }
1051 let host = url.host_str().ok_or_else(|| {
1052 anyhow!("The Codewhale service returned a verification URL without a host")
1053 })?;
1054 if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
1055 bail!("The Codewhale service returned an unsafe verification URL");
1056 }
1057 if url.path() != "/cli/authorize" {
1058 bail!("The Codewhale service returned an unsafe verification URL");
1059 }
1060
1061 let api = Url::parse(api_base).context("invalid Codewhale account API base URL")?;
1062 let canonical_api = api.scheme() == "https"
1063 && api.host_str() == Some("api.codewhale.net")
1064 && api.port_or_known_default() == Some(443);
1065 let loopback_api = api.host_str().is_some_and(is_loopback_host);
1066 if canonical_api {
1067 if url.scheme() != "https"
1068 || !host.eq_ignore_ascii_case("app.codewhale.net")
1069 || url.port_or_known_default() != Some(443)
1070 {
1071 bail!("The Codewhale service returned an untrusted verification origin");
1072 }
1073 } else if loopback_api {
1074 if !matches!(url.scheme(), "http" | "https") || !is_loopback_host(host) {
1075 bail!("The Codewhale service returned an untrusted verification origin");
1076 }
1077 } else {
1078 bail!(
1079 "Browser login is only enabled for the canonical Codewhale account API or a loopback test API"
1080 );
1081 }
1082
1083 let query = url.query_pairs().collect::<Vec<_>>();
1084 if complete {
1085 if query.len() != 1 || query[0].0 != "user_code" || query[0].1 != user_code {
1086 bail!("The Codewhale service returned an unsafe verification URL");
1087 }
1088 } else if !query.is_empty() {
1089 bail!("The Codewhale service returned an unsafe verification URL");
1090 }
1091 Ok(url.to_string())
1092 }
1093
1094 fn is_loopback_host(host: &str) -> bool {
1095 let host = host
1096 .strip_prefix('[')
1097 .and_then(|value| value.strip_suffix(']'))
1098 .unwrap_or(host);
1099 host.eq_ignore_ascii_case("localhost")
1100 || host
1101 .parse::<IpAddr>()
1102 .is_ok_and(|address| address.is_loopback())
1103 }
1104
1105 fn validate_user_code(code: &str) -> Result<()> {
1106 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1107 let bytes = code.as_bytes();
1108 if bytes.len() != 14
1109 || bytes[4] != b'-'
1110 || bytes[9] != b'-'
1111 || bytes
1112 .iter()
1113 .enumerate()
1114 .any(|(index, byte)| !matches!(index, 4 | 9) && !ALPHABET.contains(byte))
1115 {
1116 bail!("The Codewhale service returned an invalid user code");
1117 }
1118 Ok(())
1119 }
1120
1121 fn validate_device_code(code: &str) -> Result<()> {
1122 if code.len() != 43
1123 || !code
1124 .bytes()
1125 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1126 {
1127 bail!("The Codewhale service returned an invalid device authorization response");
1128 }
1129 Ok(())
1130 }
1131
1132 fn validate_api_key(key: &str) -> Result<()> {
1133 let bytes = key.len();
1134 if bytes < MIN_API_KEY_BYTES || bytes as u64 > MAX_API_KEY_BYTES {
1135 bail!("API key must be {MIN_API_KEY_BYTES}-{MAX_API_KEY_BYTES} UTF-8 bytes");
1136 }
1137 if key.chars().any(is_ascii_control) {
1138 bail!("API key contains invalid control characters");
1139 }
1140 Ok(())
1141 }
1142
1143 fn validate_label(label: &str) -> Result<String> {
1144 let label = label.split_whitespace().collect::<Vec<_>>().join(" ");
1145 if label.is_empty()
1146 || label.chars().count() > MAX_KEY_LABEL_CHARS
1147 || label.chars().any(is_ascii_control)
1148 {
1149 bail!("key label must contain 1-{MAX_KEY_LABEL_CHARS} characters");
1150 }
1151 Ok(label)
1152 }
1153
1154 fn is_ascii_control(character: char) -> bool {
1155 character <= '\u{001f}' || character == '\u{007f}'
1156 }
1157
1158 /// Find one catalog row by id, or fail naming what the account does offer.
1159 ///
1160 /// A catalog miss is the common typo, so the message lists the ids rather than
1161 /// leaving the user to guess or read a 404.
1162 fn catalog_row<'a>(catalog: &'a [CatalogProvider], provider: &str) -> Result<&'a CatalogProvider> {
1163 catalog
1164 .iter()
1165 .find(|row| row.id == provider)
1166 .ok_or_else(|| {
1167 let known = catalog
1168 .iter()
1169 .map(|row| row.id.as_str())
1170 .collect::<Vec<_>>()
1171 .join(", ");
1172 anyhow!("`{provider}` is not a provider this Codewhale account can connect. Known providers: {known}")
1173 })
1174 }
1175
1176 fn resolve_local_key(
1177 config: &ConfigStore,
1178 secrets: &Secrets,
1179 kind: ProviderKind,
1180 ) -> Result<Option<String>> {
1181 let provider_config = config.config.providers.for_provider(kind);
1182 let from_config = provider_config.api_key.clone().or_else(|| {
1183 (kind == ProviderKind::Deepseek)
1184 .then(|| config.config.api_key.clone())
1185 .flatten()
1186 });
1187 if let Some(value) = from_config
1188 .and_then(resolve_config_key_reference)
1189 .filter(|value| !value.trim().is_empty())
1190 {
1191 return Ok(Some(value));
1192 }
1193 if let Some(value) = secrets
1194 .get(kind.as_str())
1195 .context("failed to read the local provider secret store")?
1196 .filter(|value| !value.trim().is_empty())
1197 {
1198 return Ok(Some(value));
1199 }
1200 Ok(kind.provider().env_vars().iter().find_map(|name| {
1201 std::env::var(name)
1202 .ok()
1203 .filter(|value| !value.trim().is_empty())
1204 }))
1205 }
1206
1207 fn resolve_config_key_reference(value: String) -> Option<String> {
1208 let trimmed = value.trim();
1209 let Some(variable) = trimmed.strip_prefix('$') else {
1210 return Some(value);
1211 };
1212 if variable.is_empty()
1213 || !variable
1214 .bytes()
1215 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1216 {
1217 return None;
1218 }
1219 std::env::var(variable)
1220 .ok()
1221 .filter(|value| !value.trim().is_empty())
1222 }
1223
1224 fn read_key_from_stdin() -> Result<String> {
1225 let mut bytes = Vec::new();
1226 io::stdin()
1227 .take(MAX_API_KEY_STDIN_BYTES + 1)
1228 .read_to_end(&mut bytes)
1229 .context("failed to read API key from stdin")?;
1230 parse_key_input(bytes)
1231 }
1232
1233 fn parse_key_input(bytes: Vec<u8>) -> Result<String> {
1234 if bytes.len() as u64 > MAX_API_KEY_STDIN_BYTES {
1235 bail!("API key input is unexpectedly large");
1236 }
1237 let value = String::from_utf8(bytes).context("API key from stdin is not valid UTF-8")?;
1238 let value = value.trim().to_string();
1239 validate_api_key(&value)?;
1240 Ok(value)
1241 }
1242
1243 fn read_key_hidden(provider: &str) -> Result<String> {
1244 if !io::stdin().is_terminal() {
1245 bail!("interactive key entry requires a terminal; use `--api-key-stdin` for piped input");
1246 }
1247 let term = console::Term::stderr();
1248 term.write_str(&format!("Enter {provider} API key: "))
1249 .context("failed to write API key prompt")?;
1250 let value = term
1251 .read_secure_line()
1252 .context("failed to read API key securely")?;
1253 term.write_line("").ok();
1254 let value = value.trim().to_string();
1255 validate_api_key(&value)?;
1256 Ok(value)
1257 }
1258
1259 fn json_body(value: &impl Serialize) -> Result<Vec<u8>> {
1260 serde_json::to_vec(value).context("failed to encode Codewhale account request")
1261 }
1262
1263 fn expect_json<T: DeserializeOwned>(response: CloudResponse, statuses: &[u16]) -> Result<T> {
1264 if !statuses.contains(&response.status) {
1265 return Err(response_error(&response));
1266 }
1267 parse_json_body(&response.body)
1268 }
1269
1270 fn expect_empty(response: CloudResponse, statuses: &[u16]) -> Result<()> {
1271 if statuses.contains(&response.status) {
1272 Ok(())
1273 } else {
1274 Err(response_error(&response))
1275 }
1276 }
1277
1278 fn parse_json_body<T: DeserializeOwned>(body: &[u8]) -> Result<T> {
1279 serde_json::from_slice(body).context("The Codewhale service returned an invalid JSON response")
1280 }
1281
1282 fn response_error(response: &CloudResponse) -> anyhow::Error {
1283 let code = serde_json::from_slice::<serde_json::Value>(&response.body)
1284 .ok()
1285 .and_then(|body| {
1286 body.get("code")
1287 .and_then(serde_json::Value::as_str)
1288 .or_else(|| {
1289 body.get("error")
1290 .and_then(|error| error.get("code"))
1291 .and_then(serde_json::Value::as_str)
1292 })
1293 .and_then(safe_error_code)
1294 });
1295 match code {
1296 Some(code) => anyhow!(
1297 "Codewhale account request failed (HTTP {}, code {code})",
1298 response.status
1299 ),
1300 None => anyhow!(
1301 "Codewhale account request failed (HTTP {})",
1302 response.status
1303 ),
1304 }
1305 }
1306
1307 fn safe_error_code(code: &str) -> Option<String> {
1308 if code.is_empty()
1309 || code.len() > 80
1310 || !code
1311 .bytes()
1312 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
1313 {
1314 return None;
1315 }
1316 Some(code.to_string())
1317 }
1318
1319 fn printable(value: &str) -> String {
1320 value
1321 .chars()
1322 .filter(|character| !character.is_control())
1323 .take(200)
1324 .collect::<String>()
1325 .trim()
1326 .to_string()
1327 }
1328
1329 #[cfg(test)]
1330 mod tests;
1331
1331 lines RUST