返回 CodeWhale
machine.rs
根目录 / crates / cli / src / cloud / machine.rs
1 //! Codewhale-account machine tokens (`CODEWHALE_API_KEY`).
2 //!
3 //! This is the CI path. A machine token authenticates as the account with no
4 //! local session file and no browser, and it is deliberately weaker than the
5 //! interactive session: it may call exactly two read-only routes and can never
6 //! mint, list, or revoke a key. Those asymmetries are enforced by the control
7 //! plane; this module refuses to blur them locally so a CI failure reads as a
8 //! credential problem, a configuration problem, or a CLI bug — never as an
9 //! ambiguous 403.
10 //!
11 //! Two rules are load-bearing and are held by tests below:
12 //!
13 //! * The token value never leaves this module. It is written to no file, no
14 //! log, and no diagnostic. Everything that identifies a key in output is the
15 //! 32-character non-secret head (`cwc_key_` + the 24-hex id), which is
16 //! exactly what an operator needs to match a leaked key to a listing row.
17 //! * A machine credential never silently downgrades to the interactive
18 //! session. Falling back would run CI as the wrong identity.
19
20 use std::fmt;
21 use std::io::Write;
22 use std::time::Duration;
23
24 use anyhow::{Context, Result, anyhow, bail};
25 use clap::{Args, Subcommand};
26 use codewhale_config::ProviderKind;
27 use codewhale_config::route::parse_route_kind;
28 use serde::{Deserialize, Serialize};
29
30 use super::{
31 CloudClient, CloudRequest, CloudResponse, CloudTransport, HttpMethod, printable,
32 validate_api_base,
33 };
34
35 /// The only environment variable that carries a machine token.
36 pub(crate) const MACHINE_KEY_ENV: &str = "CODEWHALE_API_KEY";
37 /// Machine-token API origin override, checked before the device-flow origin.
38 pub(crate) const MACHINE_API_BASE_ENV: &str = "CODEWHALE_API_BASE";
39
40 /// Fixed literal no other Codewhale credential uses, so `grep -r cwc_key_`
41 /// finds every leaked key.
42 const TOKEN_PREFIX: &str = "cwc_key_";
43 /// `cwc_key_` + 24 hex + `_` + 43 base64url.
44 const TOKEN_LEN: usize = 76;
45 /// `cwc_key_` + the 24-hex key id. Non-secret by design: it is the display
46 /// prefix a listing shows, so printing it is how an operator maps a token in a
47 /// build log to the one row they must revoke.
48 const TOKEN_HEAD_LEN: usize = 32;
49 const KEY_ID_LEN: usize = 24;
50
51 /// The closed scope set. Widening it is a control-plane change, not a CLI one.
52 ///
53 /// `models:infer` is what lets a key reach the Codewhale API's model routes
54 /// (`/v1/models`, `/v1/chat/completions`, `/v1/messages`); the other two stay
55 /// the account and agent read/run scopes.
56 const SCOPES: [&str; 3] = ["account:read", "agent:run", "models:infer"];
57
58 /// Per-account ceiling quoted by `api_key_limit_reached`.
59 const MAX_LIVE_KEYS: usize = 25;
60
61 const MAX_KEY_NAME_CHARS: usize = 64;
62 const MAX_EXPIRY_DAYS: u32 = 365;
63
64 /// Exit codes. CI logs must be able to tell a bad credential from a missing
65 /// model without parsing English, so the classes are distinct integers.
66 pub(crate) const EXIT_INPUT: i32 = 2;
67 pub(crate) const EXIT_AUTH: i32 = 3;
68 pub(crate) const EXIT_AGENT_UNCONFIGURED: i32 = 4;
69 pub(crate) const EXIT_LIMIT: i32 = 5;
70 pub(crate) const EXIT_UNAVAILABLE: i32 = 6;
71 pub(crate) const EXIT_TRANSPORT: i32 = 7;
72
73 /// Attempts for an idempotent request, including the first.
74 const MAX_ATTEMPTS: u32 = 3;
75 const BASE_BACKOFF_MS: u64 = 500;
76 const MAX_BACKOFF_MS: u64 = 30_000;
77
78 // ---------------------------------------------------------------------------
79 // Token
80 // ---------------------------------------------------------------------------
81
82 /// A validated machine token.
83 ///
84 /// No `Display`, and `Debug` prints only the non-secret head, so the value
85 /// cannot reach a panic message or a `{:?}` dump by accident.
86 #[derive(Clone)]
87 pub(crate) struct MachineKey(String);
88
89 impl MachineKey {
90 /// Validate a raw environment value without sending it anywhere.
91 ///
92 /// A malformed value is almost always a truncated or shell-mangled paste.
93 /// Saying so locally is strictly more useful than a server 401, which
94 /// cannot distinguish "you pasted half a key" from "this key was deleted".
95 pub(crate) fn parse(raw: &str) -> Result<Self> {
96 let value = unwrap_quoted(raw);
97 if !token_is_well_formed(value) {
98 bail!(
99 "{MACHINE_KEY_ENV} is not a well-formed Codewhale API key, so it was not sent. \
100 Expected {TOKEN_LEN} characters shaped `cwc_key_<24 hex>_<43 chars>`; got {} characters. \
101 That is almost always a truncated or shell-mangled paste — re-copy the value, or create a \
102 new key with `codewhale account api-keys create`.",
103 value.chars().count()
104 );
105 }
106 Ok(Self(value.to_string()))
107 }
108
109 /// The non-secret 32-character head: `cwc_key_` plus the 24-hex key id.
110 #[must_use]
111 pub(crate) fn head(&self) -> &str {
112 &self.0[..TOKEN_HEAD_LEN]
113 }
114
115 /// Hand the full token to the transport. The only caller is this module.
116 fn expose(&self) -> String {
117 self.0.clone()
118 }
119 }
120
121 impl fmt::Debug for MachineKey {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 // Head only. A machine token must survive being logged.
124 write!(formatter, "MachineKey({}…)", self.head())
125 }
126 }
127
128 /// Trim whitespace and one wrapping pair of quotes. CI secret pasting adds
129 /// both, and neither is part of the credential.
130 fn unwrap_quoted(raw: &str) -> &str {
131 let trimmed = raw.trim();
132 for quote in ['"', '\''] {
133 if trimmed.len() >= 2 && trimmed.starts_with(quote) && trimmed.ends_with(quote) {
134 return trimmed[1..trimmed.len() - 1].trim();
135 }
136 }
137 trimmed
138 }
139
140 /// `^cwc_key_[0-9a-f]{24}_[A-Za-z0-9_-]{43}$`, hand-rolled to avoid a regex
141 /// dependency in a credential path.
142 fn token_is_well_formed(value: &str) -> bool {
143 let bytes = value.as_bytes();
144 if bytes.len() != TOKEN_LEN || !value.is_ascii() || !value.starts_with(TOKEN_PREFIX) {
145 return false;
146 }
147 let id_end = TOKEN_PREFIX.len() + KEY_ID_LEN;
148 if bytes[TOKEN_PREFIX.len()..id_end]
149 .iter()
150 .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(byte))
151 {
152 return false;
153 }
154 if bytes[id_end] != b'_' {
155 return false;
156 }
157 bytes[id_end + 1..]
158 .iter()
159 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
160 }
161
162 /// Deferred read of `CODEWHALE_API_KEY`.
163 ///
164 /// Presence and validity are separate questions: a management command must
165 /// refuse locally when a key is *present* (rule: a key cannot manage keys)
166 /// without first demanding that the key be well formed.
167 #[derive(Clone, Default)]
168 pub(crate) struct MachineKeyEnv {
169 raw: Option<String>,
170 }
171
172 impl MachineKeyEnv {
173 /// Read the process environment.
174 #[must_use]
175 pub(crate) fn from_process_env() -> Self {
176 Self::from_raw(std::env::var(MACHINE_KEY_ENV).ok().as_deref())
177 }
178
179 /// Build from an explicit value. Empty and whitespace-only mean "unset";
180 /// an exported-but-empty CI secret is an unset secret, not a bad one.
181 #[must_use]
182 pub(crate) fn from_raw(raw: Option<&str>) -> Self {
183 Self {
184 raw: raw
185 .map(str::to_string)
186 .filter(|value| !value.trim().is_empty()),
187 }
188 }
189
190 /// Whether a machine credential is present at all, valid or not.
191 #[must_use]
192 pub(crate) fn is_present(&self) -> bool {
193 self.raw.is_some()
194 }
195
196 /// Validate the key if one is set.
197 pub(crate) fn resolve(&self) -> Result<Option<MachineKey>> {
198 self.raw.as_deref().map(MachineKey::parse).transpose()
199 }
200
201 /// Validate the key, requiring one to be set.
202 pub(crate) fn require(&self) -> Result<MachineKey> {
203 self.resolve()?.ok_or_else(|| {
204 anyhow!(
205 "This command authenticates with a Codewhale account API key. \
206 Set {MACHINE_KEY_ENV}, or create one with `codewhale account api-keys create` after \
207 `codewhale login`."
208 )
209 })
210 }
211 }
212
213 // ---------------------------------------------------------------------------
214 // Base URL
215 // ---------------------------------------------------------------------------
216
217 /// Resolve the account API origin.
218 ///
219 /// Order: explicit `--api-base`, then `CODEWHALE_API_BASE`, then whatever the
220 /// device flow already uses, then the production default. The flag outranks
221 /// the variable for the same reason `--api-key` would outrank the environment:
222 /// the nearer, more deliberate signal wins.
223 pub(crate) fn resolve_api_base(
224 flag: Option<&str>,
225 machine_base: Option<&str>,
226 device_base: Option<&str>,
227 default_base: &str,
228 ) -> String {
229 [flag, machine_base, device_base]
230 .into_iter()
231 .flatten()
232 .map(str::trim)
233 .find(|value| !value.is_empty())
234 .map_or_else(
235 || default_base.to_string(),
236 |value| value.trim_end_matches('/').to_string(),
237 )
238 }
239
240 /// Reject a plaintext origin for a non-loopback host.
241 ///
242 /// A machine token is a bearer credential with no replay protection, so
243 /// sending it over cleartext to a remote host is a hard error rather than a
244 /// warning: a warning in CI is a line nobody reads.
245 pub(crate) fn require_secure_base(api_base: &str) -> Result<()> {
246 validate_api_base(api_base)
247 .map(|_| ())
248 .with_context(|| format!("refusing to send a {MACHINE_KEY_ENV} credential to {api_base}"))
249 }
250
251 // ---------------------------------------------------------------------------
252 // Error envelope
253 // ---------------------------------------------------------------------------
254
255 #[derive(Debug, Default, Deserialize)]
256 struct ErrorEnvelope {
257 #[serde(default)]
258 message: String,
259 #[serde(default)]
260 details: ErrorDetails,
261 }
262
263 #[derive(Debug, Default, Deserialize)]
264 struct ErrorDetails {
265 #[serde(default)]
266 code: String,
267 #[serde(default)]
268 fields: Vec<String>,
269 }
270
271 /// A classified control-plane failure.
272 ///
273 /// `code` is read from `details.code`, never inferred from the HTTP status:
274 /// three different 401s and two different 403s need three and two different
275 /// fixes, and only the code tells them apart.
276 #[derive(Debug, Clone)]
277 pub(crate) struct MachineError {
278 pub(crate) status: u16,
279 pub(crate) code: String,
280 pub(crate) message: String,
281 pub(crate) exit_code: i32,
282 pub(crate) retryable: bool,
283 }
284
285 impl fmt::Display for MachineError {
286 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
287 formatter.write_str(&self.message)
288 }
289 }
290
291 impl std::error::Error for MachineError {}
292
293 impl MachineError {
294 fn new(status: u16, code: &str, message: impl Into<String>, exit_code: i32) -> Self {
295 Self {
296 status,
297 code: code.to_string(),
298 message: message.into(),
299 exit_code,
300 retryable: false,
301 }
302 }
303
304 fn retryable(mut self) -> Self {
305 self.retryable = true;
306 self
307 }
308
309 /// Transport failure: no HTTP status was ever produced.
310 pub(crate) fn transport(message: impl Into<String>) -> Self {
311 Self::new(0, "transport_error", message, EXIT_TRANSPORT).retryable()
312 }
313 }
314
315 /// Map a non-success response onto an actionable local error.
316 pub(crate) fn classify(response: &CloudResponse) -> MachineError {
317 let envelope = serde_json::from_slice::<ErrorEnvelope>(&response.body).unwrap_or_default();
318 let code = sanitize_code(&envelope.details.code);
319 let server_message = sanitize_message(&envelope.message);
320 let status = response.status;
321
322 match code.as_str() {
323 "api_key_invalid" => MachineError::new(
324 status,
325 &code,
326 "That Codewhale API key is not valid. It may have been mistyped, truncated, or \
327 deleted. Create a new one with `codewhale account api-keys create`.",
328 EXIT_AUTH,
329 ),
330 "api_key_revoked" => MachineError::new(
331 status,
332 &code,
333 format!(
334 "This Codewhale API key was revoked. Create a new one and update {MACHINE_KEY_ENV}."
335 ),
336 EXIT_AUTH,
337 ),
338 "api_key_expired" => MachineError::new(
339 status,
340 &code,
341 format!(
342 "This Codewhale API key expired. Create a new one and update {MACHINE_KEY_ENV}."
343 ),
344 EXIT_AUTH,
345 ),
346 "api_key_required" => MachineError::new(
347 status,
348 &code,
349 "This Codewhale route accepts only an account API key, but the CLI sent an \
350 interactive session token. That is a Codewhale CLI bug, not a problem with your \
351 credentials — please report it.",
352 EXIT_AUTH,
353 ),
354 "auth_required" => MachineError::new(
355 status,
356 &code,
357 format!(
358 "No Codewhale credential reached the server. Set {MACHINE_KEY_ENV}, or run \
359 `codewhale login`."
360 ),
361 EXIT_AUTH,
362 ),
363 "api_key_route_denied" => MachineError::new(
364 status,
365 &code,
366 "A Codewhale API key cannot be used for this command. Managing API keys needs an \
367 interactive login. Run `codewhale login`.",
368 EXIT_AUTH,
369 ),
370 "api_key_scope_denied" => MachineError::new(
371 status,
372 &code,
373 join_message(
374 &server_message,
375 "This key does not have the scope this command needs. Create a new key with it \
376 using `codewhale account api-keys create --scope <scope>`.",
377 ),
378 EXIT_AUTH,
379 ),
380 "account_agent_model_unconfigured" => MachineError::new(
381 status,
382 &code,
383 "This Codewhale account has no agent model configured. Choose one in the Codewhale \
384 app, or run `codewhale account keys set <provider>`.",
385 EXIT_AGENT_UNCONFIGURED,
386 ),
387 "api_key_limit_reached" => MachineError::new(
388 status,
389 &code,
390 format!(
391 "This account already has {MAX_LIVE_KEYS} active API keys. Revoke one first with \
392 `codewhale account api-keys revoke <id>`."
393 ),
394 EXIT_LIMIT,
395 ),
396 "api_key_field_unknown" => {
397 let fields = envelope
398 .details
399 .fields
400 .iter()
401 .filter_map(|field| {
402 let field = sanitize_code(field);
403 (!field.is_empty()).then_some(field)
404 })
405 .collect::<Vec<_>>();
406 let detail = if fields.is_empty() {
407 String::new()
408 } else {
409 format!("Unknown field(s): {}.", fields.join(", "))
410 };
411 MachineError::new(
412 status,
413 &code,
414 join_message(&server_message, &detail),
415 EXIT_INPUT,
416 )
417 }
418 "api_key_name_invalid"
419 | "api_key_expiry_invalid"
420 | "api_key_scopes_invalid"
421 | "api_key_body_invalid" => MachineError::new(
422 status,
423 &code,
424 // Input errors are the server describing the request this CLI
425 // built, so its own wording is the most precise thing available.
426 join_message(&server_message, "Adjust the command and run it again."),
427 EXIT_INPUT,
428 ),
429 "api_key_not_found" => MachineError::new(
430 status,
431 &code,
432 // An unknown id, a malformed id, and another account's id are
433 // deliberately indistinguishable, so revoke cannot probe for
434 // foreign key ids. The message must not pretend otherwise.
435 "No such Codewhale API key. Run `codewhale account api-keys list` to see the ids \
436 this account owns.",
437 EXIT_INPUT,
438 ),
439 "api_key_unavailable" => MachineError::new(
440 status,
441 &code,
442 "This Codewhale deployment does not support API keys yet. Retrying will not help; \
443 the deployment needs a control-plane upgrade.",
444 EXIT_UNAVAILABLE,
445 ),
446 "control_plane_not_attached" => MachineError::new(
447 status,
448 &code,
449 "This Codewhale edge deployment has not been attached to the account control plane, \
450 so the API key routes are not reachable from it. This is a routing/deployment fix, not a \
451 missing feature and not a bad key.",
452 EXIT_UNAVAILABLE,
453 ),
454 _ => classify_by_status(status, &code, &server_message),
455 }
456 }
457
458 /// Fallback when the body carried no recognizable `details.code`.
459 fn classify_by_status(status: u16, code: &str, server_message: &str) -> MachineError {
460 let code = if code.is_empty() { "unknown" } else { code };
461 let context = if server_message.is_empty() {
462 format!("The Codewhale service returned HTTP {status}.")
463 } else {
464 format!("The Codewhale service returned HTTP {status}: {server_message}")
465 };
466 match status {
467 401 | 403 => MachineError::new(status, code, context, EXIT_AUTH),
468 429 => MachineError::new(
469 status,
470 code,
471 format!("{context} The request was rate limited."),
472 EXIT_TRANSPORT,
473 )
474 .retryable(),
475 400 | 404 | 409 | 422 => MachineError::new(status, code, context, EXIT_INPUT),
476 500..=504 => MachineError::new(status, code, context, EXIT_TRANSPORT).retryable(),
477 _ => MachineError::new(status, code, context, EXIT_TRANSPORT),
478 }
479 }
480
481 fn join_message(primary: &str, fallback: &str) -> String {
482 match (primary.is_empty(), fallback.is_empty()) {
483 (true, _) => fallback.to_string(),
484 (false, true) => primary.to_string(),
485 (false, false) => format!("{primary} {fallback}"),
486 }
487 }
488
489 fn sanitize_code(code: &str) -> String {
490 let code = code.trim();
491 if code.is_empty()
492 || code.len() > 80
493 || !code
494 .bytes()
495 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
496 {
497 return String::new();
498 }
499 code.to_string()
500 }
501
502 /// Server prose is untrusted output. Strip control characters and bound it so
503 /// a hostile or broken body cannot rewrite the terminal.
504 fn sanitize_message(message: &str) -> String {
505 printable(message)
506 }
507
508 // ---------------------------------------------------------------------------
509 // Retry
510 // ---------------------------------------------------------------------------
511
512 /// Whether a request may be replayed.
513 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
514 pub(crate) enum Retry {
515 /// Safe to replay: GET, and DELETE revoke, which the server defines as
516 /// idempotent (a repeat returns the identical `revokedAt`).
517 Idempotent,
518 /// Never replayed. `POST /api/account/api-keys` is the whole reason this
519 /// variant exists: a retry that actually succeeded server-side mints a
520 /// second key whose secret the caller never sees, and therefore can never
521 /// revoke by id from the output they have.
522 Never,
523 }
524
525 /// Backoff for `attempt` (1-based), honoring a server `Retry-After`.
526 ///
527 /// Jitter is derived from the attempt number rather than a clock or an RNG so
528 /// the schedule is deterministic and can be asserted in tests.
529 pub(crate) fn backoff_delay(attempt: u32, retry_after_seconds: Option<u64>) -> Duration {
530 if let Some(seconds) = retry_after_seconds {
531 return Duration::from_millis((seconds.saturating_mul(1000)).min(MAX_BACKOFF_MS));
532 }
533 let exponential = BASE_BACKOFF_MS.saturating_mul(1u64 << attempt.min(6).saturating_sub(1));
534 let jitter = u64::from(attempt).saturating_mul(137) % 250;
535 Duration::from_millis(exponential.saturating_add(jitter).min(MAX_BACKOFF_MS))
536 }
537
538 // ---------------------------------------------------------------------------
539 // Wire types
540 // ---------------------------------------------------------------------------
541
542 /// Non-secret account record returned beside a machine-key whoami.
543 #[derive(Debug, Default, Deserialize)]
544 #[serde(rename_all = "camelCase")]
545 pub(crate) struct MachineAccount {
546 #[serde(default)]
547 pub(crate) id: String,
548 #[serde(default)]
549 pub(crate) display_name: String,
550 #[serde(default)]
551 pub(crate) email: String,
552 #[serde(default)]
553 pub(crate) region: String,
554 #[serde(default)]
555 pub(crate) plan: String,
556 }
557
558 /// Machine-key metadata. Deliberately has no `secret` field: the plaintext
559 /// exists in one create response and nowhere else, and a type that cannot
560 /// represent it cannot accidentally carry it into a listing or a log.
561 #[derive(Debug, Default, Deserialize)]
562 #[serde(rename_all = "camelCase")]
563 pub(crate) struct ApiKeyMetadata {
564 #[serde(default)]
565 pub(crate) id: String,
566 #[serde(default)]
567 pub(crate) name: String,
568 #[serde(default)]
569 pub(crate) display_prefix: String,
570 #[serde(default)]
571 pub(crate) scopes: Vec<String>,
572 #[serde(default)]
573 pub(crate) created_at: String,
574 #[serde(default)]
575 pub(crate) expires_at: Option<String>,
576 #[serde(default)]
577 pub(crate) last_used_at: Option<String>,
578 #[serde(default)]
579 pub(crate) revoked_at: Option<String>,
580 }
581
582 /// Agent-model presence. The provider *identifier* only — a machine key can
583 /// cause the account's provider credential to be spent, never to be read back.
584 #[derive(Debug, Default, Deserialize)]
585 #[serde(rename_all = "camelCase")]
586 pub(crate) struct AgentState {
587 #[serde(default)]
588 pub(crate) configured: bool,
589 #[serde(default)]
590 pub(crate) model_provider: String,
591 #[serde(default)]
592 pub(crate) account_id: Option<String>,
593 }
594
595 #[derive(Debug, Deserialize)]
596 #[serde(rename_all = "camelCase")]
597 pub(crate) struct WhoamiResponse {
598 #[serde(default)]
599 pub(crate) account: MachineAccount,
600 #[serde(default)]
601 pub(crate) api_key: ApiKeyMetadata,
602 #[serde(default)]
603 pub(crate) agent: AgentState,
604 }
605
606 #[derive(Debug, Deserialize)]
607 pub(crate) struct AgentResponse {
608 #[serde(default)]
609 pub(crate) agent: AgentState,
610 }
611
612 #[derive(Debug, Deserialize)]
613 #[serde(rename_all = "camelCase")]
614 struct ApiKeyListResponse {
615 #[serde(default)]
616 api_keys: Vec<ApiKeyMetadata>,
617 }
618
619 #[derive(Debug, Deserialize)]
620 #[serde(rename_all = "camelCase")]
621 struct ApiKeyCreateResponse {
622 #[serde(default)]
623 api_key: ApiKeyMetadata,
624 #[serde(default)]
625 secret: String,
626 }
627
628 #[derive(Debug, Deserialize)]
629 #[serde(rename_all = "camelCase")]
630 struct ApiKeyRevokeResponse {
631 #[serde(default)]
632 api_key: ApiKeyMetadata,
633 }
634
635 #[derive(Debug, Serialize)]
636 #[serde(rename_all = "camelCase")]
637 struct ApiKeyCreateRequest<'a> {
638 name: &'a str,
639 /// Absent means "never expires". Sent as absent, not null, so the body
640 /// stays inside the server's closed field set.
641 #[serde(skip_serializing_if = "Option::is_none")]
642 expires_in_days: Option<u32>,
643 #[serde(skip_serializing_if = "Option::is_none")]
644 scopes: Option<Vec<String>>,
645 }
646
647 // ---------------------------------------------------------------------------
648 // Machine client
649 // ---------------------------------------------------------------------------
650
651 /// Read-only client authenticated by a machine token.
652 pub(crate) struct MachineClient<'a, T: CloudTransport> {
653 transport: &'a T,
654 key: MachineKey,
655 }
656
657 impl<'a, T: CloudTransport> MachineClient<'a, T> {
658 pub(crate) fn new(transport: &'a T, key: MachineKey) -> Self {
659 Self { transport, key }
660 }
661
662 /// The non-secret head of the key in use, for diagnostics.
663 #[must_use]
664 pub(crate) fn key_head(&self) -> &str {
665 self.key.head()
666 }
667
668 /// `GET /api/account/api-key/whoami` — the diagnosis surface.
669 pub(crate) fn whoami(&self, sleeper: &mut dyn FnMut(Duration)) -> Result<WhoamiResponse> {
670 self.get_json("/api/account/api-key/whoami", sleeper)
671 }
672
673 /// `GET /api/account/api-key/agent` — the precondition for machine work.
674 pub(crate) fn agent(&self, sleeper: &mut dyn FnMut(Duration)) -> Result<AgentResponse> {
675 self.get_json("/api/account/api-key/agent", sleeper)
676 }
677
678 fn get_json<R: serde::de::DeserializeOwned>(
679 &self,
680 path: &str,
681 sleeper: &mut dyn FnMut(Duration),
682 ) -> Result<R> {
683 let response = send_with_retry(self.transport, Retry::Idempotent, sleeper, || {
684 CloudRequest {
685 method: HttpMethod::Get,
686 path: path.to_string(),
687 // Exactly one credential per request. The transport carries a
688 // single Authorization header, so a machine key and a session
689 // bearer cannot both be presented.
690 bearer: Some(self.key.expose()),
691 body: None,
692 }
693 })?;
694 decode_json(response)
695 }
696 }
697
698 /// Issue a request, retrying only what is safe to replay.
699 fn send_with_retry<T: CloudTransport>(
700 transport: &T,
701 retry: Retry,
702 sleeper: &mut dyn FnMut(Duration),
703 build: impl Fn() -> CloudRequest,
704 ) -> Result<CloudResponse> {
705 let max_attempts = if retry == Retry::Idempotent {
706 MAX_ATTEMPTS
707 } else {
708 1
709 };
710 let mut attempt = 1;
711 loop {
712 let (error, retry_after) = match transport.execute(build()) {
713 Ok(response) if (200..300).contains(&response.status) => return Ok(response),
714 Ok(response) => {
715 let retry_after = response.retry_after;
716 (classify(&response), retry_after)
717 }
718 Err(err) => (
719 MachineError::transport(format!(
720 "Could not reach the Codewhale service: {}",
721 printable(&err.to_string())
722 )),
723 None,
724 ),
725 };
726 if !error.retryable || attempt >= max_attempts {
727 return Err(anyhow::Error::new(error));
728 }
729 sleeper(backoff_delay(attempt, retry_after));
730 attempt += 1;
731 }
732 }
733
734 fn decode_json<R: serde::de::DeserializeOwned>(response: CloudResponse) -> Result<R> {
735 serde_json::from_slice(&response.body)
736 .context("The Codewhale service returned an invalid JSON response")
737 }
738
739 // ---------------------------------------------------------------------------
740 // Management (interactive session only)
741 // ---------------------------------------------------------------------------
742
743 /// `codewhale account api-keys …` — machine tokens.
744 ///
745 /// Deliberately a different noun from `codewhale account keys`, which manages
746 /// the BYOK provider vault. Those are opposite directions of trust: a provider
747 /// key is what Codewhale presents *to* DeepSeek, while a machine token is what
748 /// a customer presents *to* Codewhale. Merging them would let one typo revoke
749 /// the wrong credential.
750 #[derive(Debug, Args)]
751 pub(crate) struct ApiKeysArgs {
752 #[command(subcommand)]
753 command: ApiKeysCommand,
754 }
755
756 #[derive(Debug, Subcommand)]
757 enum ApiKeysCommand {
758 /// Mint a machine token. The secret is printed once and never again.
759 Create(ApiKeyCreateArgs),
760 /// List this account's machine tokens. Metadata only; never the secret.
761 List,
762 /// Revoke a machine token by its 24-hex id.
763 Revoke {
764 /// The 24-hex key id, which is also the tail of the display prefix.
765 id: String,
766 },
767 }
768
769 #[derive(Debug, Args)]
770 pub(crate) struct ApiKeyCreateArgs {
771 /// Human label shown in listings and audit events.
772 #[arg(long)]
773 name: String,
774 /// Optional lifetime in days. Omit for a key that never expires.
775 #[arg(long = "expires-in-days", value_parser = clap::value_parser!(u32).range(1..=i64::from(MAX_EXPIRY_DAYS)))]
776 expires_in_days: Option<u32>,
777 /// Repeatable. Omit for all of `account:read`, `agent:run`, `models:infer`.
778 #[arg(long = "scope", value_name = "SCOPE")]
779 scopes: Vec<String>,
780 /// Also save the new secret as this machine's local `codewhale` provider
781 /// credential, so the CLI can immediately use Codewhale API models.
782 ///
783 /// The key never leaves this machine: it goes to the same secret store
784 /// `codewhale auth` writes, and nothing is uploaded anywhere.
785 #[arg(long = "use", default_value_t = false)]
786 use_locally: bool,
787 }
788
789 /// `/^[A-Za-z0-9][A-Za-z0-9 ._:@\/-]{0,63}$/`, checked locally so a bad name
790 /// costs a message instead of a round trip.
791 pub(crate) fn validate_key_name(name: &str) -> Result<&str> {
792 let invalid = || {
793 anyhow!(
794 "API key name must be 1-{MAX_KEY_NAME_CHARS} characters, start with a letter or \
795 digit, and contain only letters, digits, spaces, and `. _ : @ / -`."
796 )
797 };
798 let mut characters = name.chars();
799 let Some(first) = characters.next() else {
800 return Err(invalid());
801 };
802 if !first.is_ascii_alphanumeric() || name.chars().count() > MAX_KEY_NAME_CHARS {
803 return Err(invalid());
804 }
805 if characters.any(|character| {
806 !character.is_ascii_alphanumeric()
807 && !matches!(character, ' ' | '.' | '_' | ':' | '@' | '/' | '-')
808 }) {
809 return Err(invalid());
810 }
811 Ok(name)
812 }
813
814 /// Normalize `--scope` into the closed set.
815 ///
816 /// An omitted `--scope` means every scope, and is sent explicitly rather than
817 /// left to a server default: a key minted by this CLI should carry exactly the
818 /// scopes the CLI's own help promised, whatever the control plane's default is
819 /// this week.
820 pub(crate) fn validate_scopes(scopes: &[String]) -> Result<Option<Vec<String>>> {
821 if scopes.is_empty() {
822 return Ok(Some(
823 SCOPES.iter().map(|scope| (*scope).to_string()).collect(),
824 ));
825 }
826 let mut normalized = Vec::new();
827 for scope in scopes {
828 let scope = scope.trim();
829 if !SCOPES.contains(&scope) {
830 bail!(
831 "unknown scope `{}`; Codewhale API keys accept only {}",
832 printable(scope),
833 SCOPES.join(", ")
834 );
835 }
836 if !normalized.iter().any(|existing| existing == scope) {
837 normalized.push(scope.to_string());
838 }
839 }
840 Ok(Some(normalized))
841 }
842
843 /// The 24-hex key id, checked locally.
844 ///
845 /// This is a paste check, not an existence check: the server answers 404
846 /// identically for a malformed id, an unknown id, and another account's id, so
847 /// nothing here can or should try to distinguish them.
848 pub(crate) fn validate_key_id(id: &str) -> Result<&str> {
849 let id = id.trim();
850 if id.len() != KEY_ID_LEN
851 || !id
852 .bytes()
853 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
854 {
855 bail!(
856 "API key id must be {KEY_ID_LEN} lowercase hex characters — the part after `cwc_key_` \
857 in the display prefix. Run `codewhale account api-keys list` to see them."
858 );
859 }
860 Ok(id)
861 }
862
863 /// Refuse a management command that would be authenticated by a machine key.
864 ///
865 /// The server would answer 403 `api_key_route_denied`, but a local refusal is
866 /// better: it never puts the credential on the wire, and it names the fix.
867 /// This is the load-bearing rule of the whole design — a stolen key must not
868 /// be able to bootstrap a successor that outlives the revocation of the key
869 /// that was stolen — so the CLI states it rather than discovering it.
870 pub(crate) fn reject_machine_key_for_management(
871 machine: &MachineKeyEnv,
872 has_session: bool,
873 ) -> Result<()> {
874 if machine.is_present() && !has_session {
875 bail!(
876 "Managing API keys needs an interactive login. Run `codewhale login`.\n\
877 {MACHINE_KEY_ENV} is set, but a Codewhale API key deliberately cannot create, list, or \
878 revoke keys — that is what stops a leaked key from minting a replacement for itself."
879 );
880 }
881 Ok(())
882 }
883
884 /// Run `codewhale account api-keys …` against the interactive session.
885 pub(crate) fn run_api_keys<T: CloudTransport, W: Write>(
886 args: ApiKeysArgs,
887 client: &CloudClient<'_, T>,
888 machine: &MachineKeyEnv,
889 provider_secrets: &codewhale_secrets::Secrets,
890 out: &mut W,
891 sleeper: &mut dyn FnMut(Duration),
892 ) -> Result<()> {
893 reject_machine_key_for_management(machine, client.has_session()?)?;
894 match args.command {
895 ApiKeysCommand::Create(create) => {
896 let name = validate_key_name(create.name.trim())?;
897 let scopes = validate_scopes(&create.scopes)?;
898 let body = serde_json::to_vec(&ApiKeyCreateRequest {
899 name,
900 expires_in_days: create.expires_in_days,
901 scopes,
902 })
903 .context("failed to encode the Codewhale API key request")?;
904 // `Retry::Never` is the whole point of the enum here: a POST that
905 // actually succeeded server-side would mint a second key whose
906 // one-time secret the caller never saw, and therefore can never
907 // revoke by id from the output they hold.
908 let response = client.execute_authenticated_with_retry(
909 HttpMethod::Post,
910 "/api/account/api-keys",
911 Some(body),
912 Retry::Never,
913 sleeper,
914 )?;
915 if !(200..300).contains(&response.status) {
916 return Err(anyhow::Error::new(classify(&response)));
917 }
918 let created: ApiKeyCreateResponse = decode_json(response)?;
919 write_created_key(out, &created)?;
920 if create.use_locally {
921 save_key_as_local_codewhale_credential(provider_secrets, &created.secret, out)?;
922 }
923 Ok(())
924 }
925 ApiKeysCommand::List => {
926 let response = client.execute_authenticated_with_retry(
927 HttpMethod::Get,
928 "/api/account/api-keys",
929 None,
930 Retry::Idempotent,
931 sleeper,
932 )?;
933 if !(200..300).contains(&response.status) {
934 return Err(anyhow::Error::new(classify(&response)));
935 }
936 let listing: ApiKeyListResponse = decode_json(response)?;
937 write_key_listing(out, &listing.api_keys)
938 }
939 ApiKeysCommand::Revoke { id } => {
940 let id = validate_key_id(&id)?;
941 let path = format!("/api/account/api-keys/{id}");
942 let response = client.execute_authenticated_with_retry(
943 HttpMethod::Delete,
944 &path,
945 None,
946 Retry::Idempotent,
947 sleeper,
948 )?;
949 if !(200..300).contains(&response.status) {
950 return Err(anyhow::Error::new(classify(&response)));
951 }
952 let revoked: ApiKeyRevokeResponse = decode_json(response)?;
953 writeln!(
954 out,
955 "Revoked Codewhale API key {}.",
956 printable(&revoked.api_key.id)
957 )?;
958 if let Some(revoked_at) = revoked.api_key.revoked_at.as_deref() {
959 writeln!(out, "Revoked at: {}", printable(revoked_at))?;
960 }
961 // Re-revoking returns the identical revokedAt, so a retried CI
962 // cleanup step is a no-op rather than a failure. Say so, because
963 // an operator who sees the same timestamp twice should not worry.
964 writeln!(
965 out,
966 "Revocation takes effect on the key's next request. Repeating this command is safe."
967 )?;
968 Ok(())
969 }
970 }
971 }
972
973 /// Print a freshly minted key.
974 ///
975 /// The secret goes to stdout exactly once, whether or not stdout is a TTY: CI
976 /// captures stdout, and a secret written to stderr would land in a diagnostics
977 /// stream that is far more likely to be archived and shared.
978 fn write_created_key<W: Write>(out: &mut W, created: &ApiKeyCreateResponse) -> Result<()> {
979 let metadata = &created.api_key;
980 writeln!(
981 out,
982 "Created Codewhale API key {}.",
983 printable(&metadata.id)
984 )?;
985 writeln!(out, "Name: {}", printable(&metadata.name))?;
986 writeln!(out, "Scopes: {}", printable(&metadata.scopes.join(", ")))?;
987 writeln!(
988 out,
989 "Expires: {}",
990 metadata
991 .expires_at
992 .as_deref()
993 .map_or_else(|| "never".to_string(), printable)
994 )?;
995 writeln!(out)?;
996 writeln!(
997 out,
998 "-- THIS IS THE ONLY TIME YOU WILL SEE THIS SECRET ------------------"
999 )?;
1000 writeln!(out, "{}", created.secret)?;
1001 writeln!(
1002 out,
1003 "-------------------------------------------------------------------"
1004 )?;
1005 writeln!(
1006 out,
1007 "Codewhale stores only a hash of it and cannot show it again. Copy it now into \
1008 {MACHINE_KEY_ENV}. If you lose it, revoke this key and create another."
1009 )?;
1010 Ok(())
1011 }
1012
1013 /// Save a freshly minted key as this machine's local `codewhale` credential.
1014 ///
1015 /// `--use` is the one place a Codewhale API key is written to disk by this
1016 /// surface, and it writes only locally: the same secret store `codewhale auth`
1017 /// uses, under the `codewhale` provider's own slot. Nothing is uploaded, and
1018 /// no other provider's credential is touched.
1019 fn save_key_as_local_codewhale_credential<W: Write>(
1020 secrets: &codewhale_secrets::Secrets,
1021 secret: &str,
1022 out: &mut W,
1023 ) -> Result<()> {
1024 // Refuse to store a value this CLI would not accept as a key: a truncated
1025 // response is better caught here than as a 401 on the next model call.
1026 let key = MachineKey::parse(secret)?;
1027 let slot = ProviderKind::Codewhale.secret_store_slot();
1028 secrets.set(slot, secret).map_err(|error| {
1029 anyhow!(
1030 "The key was created, but saving it to the local secret store ({slot}) failed: {error}. Copy the secret above into {MACHINE_KEY_ENV} instead."
1031 )
1032 })?;
1033 writeln!(out)?;
1034 writeln!(
1035 out,
1036 "Saved {} as this machine's local `codewhale` provider credential.",
1037 key.head()
1038 )?;
1039 writeln!(
1040 out,
1041 "Select it with `codewhale config set provider codewhale`; models come from the account's own connected providers."
1042 )?;
1043 Ok(())
1044 }
1045
1046 fn write_key_listing<W: Write>(out: &mut W, keys: &[ApiKeyMetadata]) -> Result<()> {
1047 if keys.is_empty() {
1048 writeln!(out, "This Codewhale account has no API keys.")?;
1049 writeln!(
1050 out,
1051 "Create one with `codewhale account api-keys create --name <name>`."
1052 )?;
1053 return Ok(());
1054 }
1055 for key in keys {
1056 // Revoked and expired keys stay listed so an owner can audit history.
1057 let state = if key.revoked_at.is_some() {
1058 "revoked"
1059 } else {
1060 "active"
1061 };
1062 writeln!(
1063 out,
1064 "{} {} [{state}] scopes={}",
1065 printable(&key.id),
1066 printable(&key.name),
1067 printable(&key.scopes.join(","))
1068 )?;
1069 writeln!(out, " prefix: {}", printable(&key.display_prefix))?;
1070 writeln!(out, " created: {}", printable(&key.created_at))?;
1071 writeln!(
1072 out,
1073 " expires: {}",
1074 key.expires_at
1075 .as_deref()
1076 .map_or_else(|| "never".to_string(), printable)
1077 )?;
1078 writeln!(
1079 out,
1080 " last used: {}",
1081 key.last_used_at
1082 .as_deref()
1083 .map_or_else(|| "never".to_string(), printable)
1084 )?;
1085 if let Some(revoked_at) = key.revoked_at.as_deref() {
1086 writeln!(out, " revoked: {}", printable(revoked_at))?;
1087 }
1088 }
1089 Ok(())
1090 }
1091
1092 // ---------------------------------------------------------------------------
1093 // whoami / agent output
1094 // ---------------------------------------------------------------------------
1095
1096 /// Render a machine-key whoami.
1097 ///
1098 /// `agent.configured == false` arrives on a 200. Authentication succeeded, so
1099 /// this prints the account and then one distinct, actionable line — a
1100 /// diagnosis surface that failed on unrelated configuration would tell the
1101 /// operator nothing about the credential they came here to check.
1102 pub(crate) fn write_whoami<W: Write>(
1103 out: &mut W,
1104 who: &WhoamiResponse,
1105 api_base: &str,
1106 key_head: &str,
1107 ) -> Result<()> {
1108 writeln!(out, "Authenticated to Codewhale with an account API key.")?;
1109 writeln!(out, "Account ID: {}", printable(&who.account.id))?;
1110 if !who.account.display_name.trim().is_empty() {
1111 writeln!(out, "Name: {}", printable(&who.account.display_name))?;
1112 }
1113 if !who.account.email.trim().is_empty() {
1114 writeln!(out, "Email: {}", printable(&who.account.email))?;
1115 }
1116 if !who.account.region.trim().is_empty() {
1117 writeln!(out, "Region: {}", printable(&who.account.region))?;
1118 }
1119 if !who.account.plan.trim().is_empty() {
1120 writeln!(out, "Plan: {}", printable(&who.account.plan))?;
1121 }
1122 writeln!(out, "API: {api_base}")?;
1123 // The head is the whole key id, not a truncated fingerprint: it maps to
1124 // exactly one row in a listing, which is what makes revocation possible
1125 // from a build log.
1126 writeln!(out, "Key: {key_head} ({})", printable(&who.api_key.name))?;
1127 writeln!(
1128 out,
1129 "Key scopes: {}",
1130 printable(&who.api_key.scopes.join(", "))
1131 )?;
1132 if let Some(expires_at) = who.api_key.expires_at.as_deref() {
1133 writeln!(out, "Key expires: {}", printable(expires_at))?;
1134 }
1135 if who.agent.configured {
1136 writeln!(out, "Agent model: {}", printable(&who.agent.model_provider))?;
1137 } else {
1138 writeln!(
1139 out,
1140 "Agent model: not configured — this key authenticates, but agent work will refuse \
1141 until a model is chosen in the Codewhale app or with `codewhale account keys set <provider>`."
1142 )?;
1143 }
1144 Ok(())
1145 }
1146
1147 /// Render the agent precondition.
1148 pub(crate) fn write_agent<W: Write>(out: &mut W, agent: &AgentState) -> Result<()> {
1149 writeln!(
1150 out,
1151 "Codewhale agent model: {}",
1152 printable(&agent.model_provider)
1153 )?;
1154 if let Some(account_id) = agent.account_id.as_deref() {
1155 writeln!(out, "Account ID: {}", printable(account_id))?;
1156 }
1157 writeln!(out, "This account is ready to run machine work.")?;
1158 Ok(())
1159 }
1160
1161 // ---------------------------------------------------------------------------
1162 // Review wiring
1163 // ---------------------------------------------------------------------------
1164
1165 /// Map the account's configured provider identifier onto a local route.
1166 ///
1167 /// `codewhale review` hard-errors when a model resolves to several configured
1168 /// routes. When CI authenticates with a machine key the account has already
1169 /// answered that question, so the account's own provider is the disambiguator
1170 /// — no new flag, and no guess.
1171 pub(crate) fn review_provider_from_agent(agent: &AgentState) -> Result<ProviderKind> {
1172 if !agent.configured {
1173 return Err(anyhow::Error::new(MachineError::new(
1174 409,
1175 "account_agent_model_unconfigured",
1176 "This Codewhale account has no agent model configured. Choose one in the Codewhale \
1177 app, or run `codewhale account keys set <provider>`.",
1178 EXIT_AGENT_UNCONFIGURED,
1179 )));
1180 }
1181 let provider = agent.model_provider.trim();
1182 parse_route_kind(provider).ok_or_else(|| {
1183 anyhow!(
1184 "This Codewhale account is configured for agent provider `{}`, which this CLI build \
1185 does not know. Upgrade `codewhale`, or choose a supported provider in the Codewhale app.",
1186 printable(provider)
1187 )
1188 })
1189 }
1190
1191 #[cfg(test)]
1192 mod tests;
1193
1193 lines RUST