返回 CodeWhale
tests.rs
根目录 / crates / cli / src / cloud / tests.rs
1 use std::collections::VecDeque;
2 use std::sync::{Arc, Mutex};
3
4 use clap::Parser;
5 use codewhale_secrets::account::{
6 ACCOUNT_SESSION_SCHEMA_VERSION, AccountSession as AuthSession,
7 account_auth_slot as cloud_auth_slot,
8 };
9 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10 use serde_json::json;
11
12 use super::*;
13 use crate::{Cli, Commands};
14
15 struct FakeTransport {
16 responses: Mutex<VecDeque<CloudResponse>>,
17 requests: Mutex<Vec<CloudRequest>>,
18 }
19
20 impl FakeTransport {
21 fn new(responses: Vec<CloudResponse>) -> Self {
22 Self {
23 responses: Mutex::new(responses.into()),
24 requests: Mutex::new(Vec::new()),
25 }
26 }
27
28 fn requests(&self) -> std::sync::MutexGuard<'_, Vec<CloudRequest>> {
29 self.requests.lock().unwrap()
30 }
31 }
32
33 impl CloudTransport for FakeTransport {
34 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
35 self.requests.lock().unwrap().push(request);
36 self.responses
37 .lock()
38 .unwrap()
39 .pop_front()
40 .ok_or_else(|| anyhow!("fake transport exhausted"))
41 }
42 }
43
44 fn response(status: u16, body: serde_json::Value) -> CloudResponse {
45 CloudResponse {
46 status,
47 body: serde_json::to_vec(&body).unwrap(),
48 retry_after: None,
49 }
50 }
51
52 fn response_retry_after(status: u16, body: serde_json::Value, seconds: u64) -> CloudResponse {
53 CloudResponse {
54 status,
55 body: serde_json::to_vec(&body).unwrap(),
56 retry_after: Some(seconds),
57 }
58 }
59
60 fn sse_response(events: &[serde_json::Value]) -> CloudResponse {
61 let body = events
62 .iter()
63 .map(|event| {
64 format!(
65 "event: {}\ndata: {}\nid: {}\n\n",
66 event["type"].as_str().unwrap(),
67 event,
68 event["seq"].as_u64().unwrap()
69 )
70 })
71 .collect::<String>()
72 .into_bytes();
73 CloudResponse {
74 status: 200,
75 body,
76 retry_after: None,
77 }
78 }
79
80 fn account(id: &str) -> serde_json::Value {
81 json!({
82 "user": {
83 "id": id,
84 "displayName": "Hunter",
85 "email": "hunter@example.test",
86 "plan": "free",
87 "modelKeys": {}
88 }
89 })
90 }
91
92 /// A stand-in for `GET /api/model-providers`.
93 ///
94 /// Deliberately includes ids the retired hardcoded enum never knew
95 /// (`modelstudio-coding-plan`) so a test failure means the CLI went back to a
96 /// compiled provider list.
97 fn catalog() -> serde_json::Value {
98 json!({
99 "providers": [
100 {
101 "id": "openai",
102 "label": "OpenAI",
103 "runtimeProvider": "openai",
104 "availability": "account_key",
105 "connectionAvailable": true
106 },
107 {
108 "id": "anthropic",
109 "label": "Anthropic",
110 "runtimeProvider": "anthropic",
111 "availability": "account_key",
112 "connectionAvailable": true
113 },
114 {
115 "id": "xiaomi",
116 "label": "Xiaomi MiMo",
117 "runtimeProvider": "xiaomi-mimo",
118 "availability": "account_key",
119 "connectionAvailable": true
120 },
121 {
122 "id": "modelstudio-coding-plan",
123 "label": "Alibaba Model Studio Coding Plan",
124 "runtimeProvider": "modelstudio-coding-plan",
125 "availability": "account_key",
126 "connectionAvailable": true
127 }
128 ]
129 })
130 }
131
132 fn auth(access: &str, refresh: &str, account_id: &str) -> AuthBundle {
133 AuthBundle {
134 token_type: "Bearer".to_string(),
135 access_token: access.to_string(),
136 refresh_token: refresh.to_string(),
137 session: Some(AuthSession {
138 id: "session-1".to_string(),
139 provider: "github".to_string(),
140 expires_at: String::new(),
141 refresh_expires_at: String::new(),
142 ..AuthSession::default()
143 }),
144 user: Some(CloudUser {
145 id: account_id.to_string(),
146 display_name: "Hunter".to_string(),
147 email: "hunter@example.test".to_string(),
148 ..CloudUser::default()
149 }),
150 }
151 }
152
153 fn auth_json(access: &str, refresh: &str, account_id: &str) -> serde_json::Value {
154 serde_json::to_value(auth(access, refresh, account_id)).unwrap()
155 }
156
157 fn test_secrets() -> (Secrets, Arc<InMemoryKeyringStore>) {
158 let store = Arc::new(InMemoryKeyringStore::new());
159 (Secrets::new(store.clone()), store)
160 }
161
162 fn test_config() -> (tempfile::TempDir, ConfigStore) {
163 let temp = tempfile::tempdir().unwrap();
164 let path = temp.path().join("config.toml");
165 let config = ConfigStore::load(Some(path)).unwrap();
166 (temp, config)
167 }
168
169 fn command(argv: &[&str]) -> CloudCommand {
170 let cli = Cli::try_parse_from(argv).unwrap();
171 let Some(Commands::Account(args)) = cli.command else {
172 panic!("expected account command");
173 };
174 args.command
175 }
176
177 fn computer(id: &str, status: &str) -> serde_json::Value {
178 json!({
179 "id": id,
180 "ownerId": "acct-123",
181 "name": "Account pilot",
182 "region": "us-west",
183 "status": status,
184 "startQueueReason": if status == "queued" { "active_limit" } else { "" }
185 })
186 }
187
188 fn agent(id: &str, name: &str) -> serde_json::Value {
189 json!({ "id": id, "name": name, "status": "active", "projectId": "project-codewhale", "revision": 1 })
190 }
191
192 fn agent_thread(id: &str, agent_id: &str, title: &str) -> serde_json::Value {
193 json!({
194 "id": id,
195 "agentId": agent_id,
196 "projectId": "project-codewhale",
197 "title": title,
198 "model": "deepseek-flash",
199 "modelProvider": "deepseek",
200 "modelProviderId": "",
201 "kind": "conversation",
202 "archivedAt": ""
203 })
204 }
205
206 fn route_catalog() -> serde_json::Value {
207 json!({
208 "providers": [{
209 "id": "deepseek",
210 "label": "DeepSeek",
211 "runtimeProvider": "deepseek",
212 "availability": "account_key",
213 "connectionAvailable": true,
214 "models": ["deepseek-v4-pro", "deepseek-flash"]
215 }]
216 })
217 }
218
219 #[test]
220 fn parses_cloud_command_matrix_and_rejects_inline_keys() {
221 assert!(matches!(
222 command(&["codewhale", "account", "status"]),
223 CloudCommand::Status
224 ));
225 assert!(matches!(
226 command(&["codewhale", "account", "projects", "list"]),
227 CloudCommand::Projects(CloudProjectsArgs {
228 command: CloudProjectsCommand::List { json: false }
229 })
230 ));
231 assert!(matches!(
232 command(&["codewhale", "account", "github", "bindings"]),
233 CloudCommand::Github(CloudGithubArgs {
234 command: CloudGithubCommand::Bindings { json: false }
235 })
236 ));
237 assert!(matches!(
238 command(&[
239 "codewhale",
240 "account",
241 "computers",
242 "create",
243 "Trial",
244 "--boat-trial",
245 "--eu-compute-opt-in"
246 ]),
247 CloudCommand::Computers(CloudComputersArgs {
248 command: CloudComputersCommand::Create {
249 boat_trial: true,
250 eu_compute_opt_in: true,
251 ..
252 }
253 })
254 ));
255 for lone_flag in ["--boat-trial", "--eu-compute-opt-in"] {
256 assert!(
257 Cli::try_parse_from([
258 "codewhale",
259 "account",
260 "computers",
261 "create",
262 "Trial",
263 lone_flag
264 ])
265 .is_err()
266 );
267 }
268 assert!(
269 Cli::try_parse_from(["codewhale", "account", "computers", "usage", "computer-1"]).is_err()
270 );
271 assert!(matches!(
272 command(&[
273 "codewhale",
274 "account",
275 "projects",
276 "create",
277 "My Project",
278 "--repo-binding-id",
279 "github:acct-123:987:owner/repo",
280 "--operation-key",
281 "project-create-1",
282 ]),
283 CloudCommand::Projects(CloudProjectsArgs {
284 command: CloudProjectsCommand::Create { .. }
285 })
286 ));
287 assert!(matches!(
288 command(&[
289 "codewhale",
290 "account",
291 "agents",
292 "bind-project",
293 "Whale",
294 "project-codewhale"
295 ]),
296 CloudCommand::Agents(CloudAgentsArgs {
297 command: CloudAgentsCommand::BindProject { .. }
298 })
299 ));
300 assert!(matches!(
301 command(&["codewhale", "cloud", "login", "--no-open"]),
302 CloudCommand::Login(CloudLoginArgs { no_open: true, .. })
303 ));
304 assert!(matches!(
305 command(&[
306 "codewhale",
307 "cloud",
308 "keys",
309 "set",
310 "xiaomi-mimo",
311 "--from-local"
312 ]),
313 CloudCommand::Keys(CloudKeysArgs {
314 command: CloudKeysCommand::Set(CloudKeySetArgs {
315 from_local: true,
316 ..
317 })
318 })
319 ));
320 // Provider ids are open strings validated against the account's catalog,
321 // not a compiled enum: clap must not reject an id this CLI never heard of.
322 assert!(matches!(
323 command(&[
324 "codewhale",
325 "cloud",
326 "keys",
327 "set",
328 "modelstudio-coding-plan"
329 ]),
330 CloudCommand::Keys(CloudKeysArgs {
331 command: CloudKeysCommand::Set(CloudKeySetArgs { .. })
332 })
333 ));
334 assert!(
335 Cli::try_parse_from([
336 "codewhale",
337 "cloud",
338 "keys",
339 "set",
340 "openai",
341 "sk-unsafe-inline"
342 ])
343 .is_err()
344 );
345 assert!(
346 Cli::try_parse_from([
347 "codewhale",
348 "cloud",
349 "keys",
350 "set",
351 "openai",
352 "--from-local",
353 "--api-key-stdin"
354 ])
355 .is_err()
356 );
357 assert!(reject_inline_api_key(None).is_ok());
358 let error = reject_inline_api_key(Some("sk-never-render")).unwrap_err();
359 assert!(error.to_string().contains("--api-key-stdin"));
360 assert!(!error.to_string().contains("sk-never-render"));
361 }
362
363 #[test]
364 fn api_base_requires_https_or_literal_loopback_http() {
365 assert_eq!(
366 validate_api_base("https://api.codewhale.net/")
367 .unwrap()
368 .display,
369 "https://api.codewhale.net"
370 );
371 assert!(validate_api_base("http://127.0.0.1:8787").is_ok());
372 assert!(validate_api_base("http://[::1]:8787").is_ok());
373 assert!(validate_api_base("http://api.codewhale.net").is_err());
374 assert!(validate_api_base("https://user:secret@example.test").is_err());
375 assert!(validate_api_base("https://example.test/prefix").is_err());
376 }
377
378 #[test]
379 fn verification_urls_are_pinned_to_the_app_or_loopback() {
380 const CODE: &str = "ABCD-EFGH-JKLM";
381 const API: &str = "https://api.codewhale.net";
382 assert!(
383 validate_verification_url("https://app.codewhale.net/cli/authorize", API, CODE, false,)
384 .is_ok()
385 );
386 assert!(
387 validate_verification_url(
388 "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
389 API,
390 CODE,
391 true,
392 )
393 .is_ok()
394 );
395 for unsafe_url in [
396 "https://attacker.example/cli/authorize",
397 "https://user@app.codewhale.net/cli/authorize",
398 "https://app.codewhale.net/cli/authorize#continue",
399 "https://app.codewhale.net/cli/authorize/extra",
400 "https://app.codewhale.net/cli/other/../authorize",
401 "https://app.codewhale.net/cli/%61uthorize",
402 "https://app.codewhale.net/cli/authorize?next=https%3A%2F%2Fattacker.example",
403 "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM&next=evil",
404 ] {
405 assert!(
406 validate_verification_url(unsafe_url, API, CODE, unsafe_url.contains("user_code"))
407 .is_err(),
408 "accepted unsafe URL: {unsafe_url}"
409 );
410 }
411 assert!(
412 validate_verification_url(
413 "http://localhost:3000/cli/authorize?user_code=ABCD-EFGH-JKLM",
414 "http://127.0.0.1:8787",
415 CODE,
416 true,
417 )
418 .is_ok()
419 );
420 assert!(
421 validate_verification_url(
422 "https://staging-app.example/cli/authorize",
423 "https://staging-api.example",
424 CODE,
425 false,
426 )
427 .is_err()
428 );
429 }
430
431 #[test]
432 fn user_codes_and_key_inputs_match_the_server_contract() {
433 assert!(validate_user_code("ABCD-EFGH-JKLM").is_ok());
434 for invalid in [
435 "CW-1234",
436 "ABCI-EFGH-JKLM",
437 "ABCO-EFGH-JKLM",
438 "ABC1-EFGH-JKLM",
439 "abcd-EFGH-JKLM",
440 "ABCD_EFGH_JKLM",
441 ] {
442 assert!(validate_user_code(invalid).is_err(), "accepted {invalid}");
443 }
444
445 assert!(validate_device_code("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").is_ok());
446 for invalid in [
447 "too-short",
448 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
449 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA!",
450 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
451 ] {
452 assert!(validate_device_code(invalid).is_err(), "accepted {invalid}");
453 }
454
455 assert!(validate_api_key("1234567").is_err());
456 assert!(validate_api_key("12345678").is_ok());
457 assert!(validate_api_key(&"x".repeat(4096)).is_ok());
458 assert!(validate_api_key(&"x".repeat(4097)).is_err());
459 assert!(validate_api_key(&"é".repeat(4)).is_ok());
460 assert!(validate_api_key("1234567\n8").is_err());
461 assert_eq!(
462 parse_key_input(format!("{}\n", "x".repeat(4096)).into_bytes()).unwrap(),
463 "x".repeat(4096)
464 );
465 assert!(parse_key_input(vec![b'x'; MAX_API_KEY_STDIN_BYTES as usize + 1]).is_err());
466 assert_eq!(
467 validate_label(" Codewhale\tCLI ").unwrap(),
468 "Codewhale CLI"
469 );
470 assert!(validate_label(&"x".repeat(80)).is_ok());
471 assert!(validate_label(&"x".repeat(81)).is_err());
472 }
473
474 #[test]
475 fn device_flow_handles_pending_then_authorized_without_printing_tokens() {
476 for (no_open, browser_opens) in [(false, true), (false, false), (true, false)] {
477 let (temp, config) = test_config();
478 let _keep_temp = temp;
479 let (secrets, _) = test_secrets();
480 let transport = FakeTransport::new(vec![
481 response(
482 200,
483 json!({
484 "deviceCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
485 "userCode": "ABCD-EFGH-JKLM",
486 "verificationUri": "https://app.codewhale.net/cli/authorize",
487 "verificationUriComplete": "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
488 "expiresIn": 600,
489 "interval": 1
490 }),
491 ),
492 response(202, json!({ "status": "authorization_pending" })),
493 response(
494 200,
495 auth_json("access-never-print", "refresh-never-print", "acct-123"),
496 ),
497 response(200, account("acct-123")),
498 ]);
499 let mut output = Vec::new();
500 let mut key_reader = |_| bail!("key reader should not be called");
501 let mut opened = Vec::new();
502 let mut opener = |url: String| {
503 opened.push(url);
504 browser_opens
505 };
506 let mut sleeper = |_| {};
507 run_with(
508 command(if no_open {
509 &["codewhale", "cloud", "login", "--no-open"]
510 } else {
511 &["codewhale", "cloud", "login"]
512 }),
513 "work",
514 "https://api.codewhale.net",
515 &config,
516 &secrets,
517 &secrets,
518 &machine::MachineKeyEnv::default(),
519 &transport,
520 &mut output,
521 &mut key_reader,
522 &mut opener,
523 &mut sleeper,
524 )
525 .unwrap();
526
527 let output = String::from_utf8(output).unwrap();
528 assert_eq!(
529 output.lines().find(|line| line.starts_with("Open: ")),
530 Some("Open: https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM")
531 );
532 assert!(output.contains("ABCD-EFGH-JKLM"));
533 assert!(output.contains("Account ID: acct-123"));
534 assert!(output.contains("Profile: work"));
535 // No-brand invariant: login signs in the account; the internal
536 // cloud-agent credential is never taught here.
537 assert!(!output.to_lowercase().contains("daytona"), "{output}");
538 assert!(!output.contains("set-slot"), "{output}");
539 assert!(!output.contains("access-never-print"));
540 assert!(!output.contains("refresh-never-print"));
541 assert!(!output.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
542 if no_open {
543 assert!(opened.is_empty());
544 } else {
545 assert_eq!(
546 opened,
547 ["https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM"]
548 );
549 }
550 assert_eq!(
551 output.contains("Browser could not be opened; use the URL and code above."),
552 !no_open && !browser_opens
553 );
554 let requests = transport.requests();
555 assert_eq!(requests[0].path, "/api/cli/device/start");
556 assert_eq!(requests[1].path, "/api/cli/device/token");
557 assert_eq!(requests[2].path, "/api/cli/device/token");
558 assert_eq!(requests[3].path, "/api/me");
559 }
560 }
561
562 #[test]
563 fn cloud_sessions_are_isolated_by_profile_and_api_origin() {
564 let (secrets, _) = test_secrets();
565 let transport = FakeTransport::new(vec![]);
566 let default = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
567 let work = CloudClient::new(&transport, &secrets, "work", "https://api.codewhale.net");
568 let local = CloudClient::new(&transport, &secrets, "default", "http://127.0.0.1:8787");
569 default
570 .save_auth(auth("a-default", "r-default", "acct-default"))
571 .unwrap();
572 work.save_auth(auth("a-work", "r-work", "acct-work"))
573 .unwrap();
574 local
575 .save_auth(auth("a-local", "r-local", "acct-local"))
576 .unwrap();
577
578 assert_eq!(
579 default
580 .load_auth()
581 .unwrap()
582 .unwrap()
583 .bundle
584 .user
585 .unwrap()
586 .id,
587 "acct-default"
588 );
589 assert_eq!(
590 work.load_auth().unwrap().unwrap().bundle.user.unwrap().id,
591 "acct-work"
592 );
593 assert_eq!(
594 local.load_auth().unwrap().unwrap().bundle.user.unwrap().id,
595 "acct-local"
596 );
597 }
598
599 #[test]
600 fn status_refreshes_once_on_unauthorized_and_never_displays_tokens() {
601 let (temp, config) = test_config();
602 let _keep_temp = temp;
603 let (secrets, _) = test_secrets();
604 let transport = FakeTransport::new(vec![
605 response(401, json!({ "code": "access_token_expired" })),
606 response(
607 200,
608 auth_json("access-new-secret", "refresh-new-secret", "acct-refresh"),
609 ),
610 response(200, account("acct-refresh")),
611 ]);
612 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
613 .save_auth(auth(
614 "access-old-secret",
615 "refresh-old-secret",
616 "acct-refresh",
617 ))
618 .unwrap();
619 let mut output = Vec::new();
620 let mut key_reader = |_| bail!("unused");
621 let mut opener = |_| true;
622 let mut sleeper = |_| {};
623 run_with(
624 CloudCommand::Status,
625 "default",
626 "https://api.codewhale.net",
627 &config,
628 &secrets,
629 &secrets,
630 &machine::MachineKeyEnv::default(),
631 &transport,
632 &mut output,
633 &mut key_reader,
634 &mut opener,
635 &mut sleeper,
636 )
637 .unwrap();
638 let output = String::from_utf8(output).unwrap();
639 assert!(output.contains("acct-refresh"));
640 for secret in [
641 "access-old-secret",
642 "refresh-old-secret",
643 "access-new-secret",
644 "refresh-new-secret",
645 ] {
646 assert!(!output.contains(secret));
647 }
648 let requests = transport.requests();
649 assert_eq!(requests[0].path, "/api/me");
650 assert_eq!(requests[1].path, "/api/auth/refresh");
651 assert_eq!(requests[2].path, "/api/me");
652 }
653
654 #[test]
655 fn account_pull_refuses_to_claim_unimplemented_local_import() {
656 let (temp, config) = test_config();
657 let config_path = config.path().to_path_buf();
658 let (secrets, _) = test_secrets();
659 let transport = FakeTransport::new(vec![]);
660 let mut output = Vec::new();
661 let mut key_reader = |_| bail!("unused");
662 let mut opener = |_| true;
663 let mut sleeper = |_| {};
664
665 let error = run_with(
666 command(&["codewhale", "account", "pull"]),
667 "default",
668 "https://api.codewhale.net",
669 &config,
670 &secrets,
671 &secrets,
672 &machine::MachineKeyEnv::default(),
673 &transport,
674 &mut output,
675 &mut key_reader,
676 &mut opener,
677 &mut sleeper,
678 )
679 .expect_err("non-dry-run pull must fail until settings import exists");
680
681 assert!(error.to_string().contains("import is not available"));
682 assert!(error.to_string().contains("local config was not changed"));
683 assert!(
684 output.is_empty(),
685 "a rejected pull must not print success text"
686 );
687 assert!(
688 transport.requests().is_empty(),
689 "a rejected pull needs no API call"
690 );
691 assert!(
692 !config_path.exists(),
693 "a rejected pull must not create config.toml"
694 );
695 drop(temp);
696 }
697
698 #[test]
699 fn account_pull_dry_run_is_truthful_and_read_only() {
700 let (temp, config) = test_config();
701 let config_path = config.path().to_path_buf();
702 let (secrets, _) = test_secrets();
703 let transport = FakeTransport::new(vec![response(200, account("acct-pull"))]);
704 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
705 .save_auth(auth("access-secret", "refresh-secret", "acct-pull"))
706 .unwrap();
707 let mut output = Vec::new();
708 let mut key_reader = |_| bail!("unused");
709 let mut opener = |_| true;
710 let mut sleeper = |_| {};
711
712 run_with(
713 command(&["codewhale", "account", "pull", "--dry-run"]),
714 "default",
715 "https://api.codewhale.net",
716 &config,
717 &secrets,
718 &secrets,
719 &machine::MachineKeyEnv::default(),
720 &transport,
721 &mut output,
722 &mut key_reader,
723 &mut opener,
724 &mut sleeper,
725 )
726 .unwrap();
727
728 let output = String::from_utf8(output).unwrap();
729 assert!(output.contains("Account settings (pull --dry-run):"));
730 assert!(output.contains("Account ID: acct-pull"));
731 assert!(output.contains("remote settings import is not available"));
732 assert!(output.contains("local config unchanged"));
733 assert!(!output.contains("Pulled account document"));
734 assert!(!output.contains("would hydrate"));
735 assert!(!output.contains("access-secret"));
736 assert!(!output.contains("refresh-secret"));
737 assert!(!config_path.exists(), "dry-run must not create config.toml");
738 let requests = transport.requests();
739 assert_eq!(requests.len(), 1);
740 assert!(requests[0].method == HttpMethod::Get);
741 assert_eq!(requests[0].path, "/api/me");
742 drop(temp);
743 }
744
745 #[test]
746 fn non_terminal_refresh_responses_preserve_the_local_session() {
747 for status in [403, 429, 500, 503] {
748 let (secrets, _) = test_secrets();
749 let transport = FakeTransport::new(vec![
750 response(401, json!({ "code": "access_token_expired" })),
751 response(status, json!({ "code": "temporarily_unavailable" })),
752 ]);
753 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
754 client
755 .save_auth(auth(
756 "access-old-secret",
757 "refresh-still-valid",
758 "acct-refresh",
759 ))
760 .unwrap();
761
762 let error = client
763 .me()
764 .err()
765 .expect("refresh response should fail the request")
766 .to_string();
767 assert!(error.contains(&format!("HTTP {status}")));
768 assert_eq!(
769 client
770 .load_auth()
771 .unwrap()
772 .expect("retryable refresh failure must preserve the session")
773 .bundle
774 .refresh_token,
775 "refresh-still-valid"
776 );
777 let requests = transport.requests();
778 assert_eq!(requests.len(), 2);
779 assert_eq!(requests[0].path, "/api/me");
780 assert_eq!(requests[1].path, "/api/auth/refresh");
781 }
782 }
783
784 /// Revoke is defined as idempotent by the control plane (a repeat returns the
785 /// identical `revokedAt`), so a rate-limited revoke may be replayed — and the
786 /// server's own `Retry-After` decides how long CI waits, not a guess.
787 #[test]
788 fn a_rate_limited_revoke_waits_the_server_named_interval_and_replays() {
789 let (secrets, _) = test_secrets();
790 let transport = FakeTransport::new(vec![
791 response_retry_after(
792 429,
793 json!({
794 "error": "rate_limited",
795 "message": "slow down",
796 "details": { "code": "rate_limited" }
797 }),
798 4,
799 ),
800 response(
801 200,
802 json!({ "ok": true, "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "revokedAt": "2026-02-02T00:00:00Z" } }),
803 ),
804 ]);
805 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
806 client
807 .save_auth(auth("access-secret", "refresh-secret", "acct-1"))
808 .unwrap();
809 let mut slept = Vec::new();
810 let mut sleeper = |duration: Duration| slept.push(duration);
811 let response = client
812 .execute_authenticated_with_retry(
813 HttpMethod::Delete,
814 "/api/account/api-keys/3f2a9c1e4b7d8a0f5c6e2b91",
815 None,
816 machine::Retry::Idempotent,
817 &mut sleeper,
818 )
819 .unwrap();
820 assert_eq!(response.status, 200);
821 assert_eq!(slept, vec![Duration::from_secs(4)]);
822 assert_eq!(transport.requests().len(), 2);
823 }
824
825 /// The one POST in this surface mints a secret shown exactly once. A replay
826 /// that actually succeeded server-side would leave a key the caller can never
827 /// revoke by id, so `Retry::Never` must mean never — even on a 429.
828 #[test]
829 fn a_rate_limited_create_is_never_replayed() {
830 let (secrets, _) = test_secrets();
831 let transport = FakeTransport::new(vec![response_retry_after(
832 429,
833 json!({
834 "error": "rate_limited",
835 "message": "slow down",
836 "details": { "code": "rate_limited" }
837 }),
838 4,
839 )]);
840 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
841 client
842 .save_auth(auth("access-secret", "refresh-secret", "acct-1"))
843 .unwrap();
844 let mut sleeper = |_: Duration| panic!("create must never sleep-and-retry");
845 let response = client
846 .execute_authenticated_with_retry(
847 HttpMethod::Post,
848 "/api/account/api-keys",
849 Some(b"{}".to_vec()),
850 machine::Retry::Never,
851 &mut sleeper,
852 )
853 .unwrap();
854 assert_eq!(response.status, 429);
855 assert_eq!(transport.requests().len(), 1);
856 }
857
858 #[test]
859 fn refresh_transport_failure_preserves_the_local_session() {
860 let (secrets, _) = test_secrets();
861 let transport = FakeTransport::new(vec![response(
862 401,
863 json!({ "code": "access_token_expired" }),
864 )]);
865 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
866 client
867 .save_auth(auth(
868 "access-old-secret",
869 "refresh-still-valid",
870 "acct-refresh",
871 ))
872 .unwrap();
873
874 let error = client
875 .me()
876 .err()
877 .expect("refresh transport should fail")
878 .to_string();
879 assert!(error.contains("fake transport exhausted"));
880 assert_eq!(
881 client
882 .load_auth()
883 .unwrap()
884 .expect("transport failure must preserve the session")
885 .bundle
886 .refresh_token,
887 "refresh-still-valid"
888 );
889 let requests = transport.requests();
890 assert_eq!(requests.len(), 2);
891 assert_eq!(requests[1].path, "/api/auth/refresh");
892 }
893
894 #[test]
895 fn terminal_refresh_auth_failures_clear_the_local_session() {
896 let (secrets, _) = test_secrets();
897 let transport = FakeTransport::new(vec![
898 response(401, json!({ "code": "access_token_expired" })),
899 response(401, json!({ "code": "invalid_refresh_token" })),
900 ]);
901 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
902 client
903 .save_auth(auth(
904 "access-old-secret",
905 "refresh-terminal-secret",
906 "acct-refresh",
907 ))
908 .unwrap();
909
910 let error = client
911 .me()
912 .err()
913 .expect("terminal refresh response should fail the request")
914 .to_string();
915 assert!(error.contains("session expired"));
916 assert!(
917 client.load_auth().unwrap().is_none(),
918 "HTTP 401 must clear the terminal session"
919 );
920 }
921
922 #[test]
923 fn set_list_and_remove_use_account_routes_without_secret_output() {
924 let (temp, config) = test_config();
925 let _keep_temp = temp;
926 let (secrets, _) = test_secrets();
927 let list_account = json!({
928 "user": {
929 "id": "acct-keys",
930 "displayName": "Hunter",
931 "email": "hunter@example.test",
932 "modelKeys": {
933 "openai": { "configured": true, "label": "Laptop", "updatedAt": "now" }
934 }
935 }
936 });
937 let transport = FakeTransport::new(vec![
938 // set: /api/me, catalog, PUT
939 response(200, account("acct-keys")),
940 response(200, catalog()),
941 response(200, json!({ "ok": true })),
942 // list: /api/me, catalog
943 response(200, list_account),
944 response(200, catalog()),
945 // remove: /api/me, catalog, DELETE
946 response(200, account("acct-keys")),
947 response(200, catalog()),
948 response(204, json!(null)),
949 ]);
950 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
951 .save_auth(auth("access-secret", "refresh-secret", "acct-keys"))
952 .unwrap();
953 let mut output = Vec::new();
954 let mut key_reader = |_| Ok("sk-provider-never-print".to_string());
955 let mut opener = |_| true;
956 let mut sleeper = |_| {};
957 for cmd in [
958 command(&[
959 "codewhale",
960 "cloud",
961 "keys",
962 "set",
963 "openai",
964 "--api-key-stdin",
965 "--label",
966 "Laptop",
967 ]),
968 command(&["codewhale", "cloud", "keys", "list"]),
969 command(&["codewhale", "cloud", "keys", "remove", "openai"]),
970 ] {
971 run_with(
972 cmd,
973 "default",
974 "https://api.codewhale.net",
975 &config,
976 &secrets,
977 &secrets,
978 &machine::MachineKeyEnv::default(),
979 &transport,
980 &mut output,
981 &mut key_reader,
982 &mut opener,
983 &mut sleeper,
984 )
985 .unwrap();
986 }
987 let output = String::from_utf8(output).unwrap();
988 assert!(output.contains("openai: set"));
989 // Every catalog provider is listed, including ids the retired enum lacked.
990 assert!(output.contains("modelstudio-coding-plan: not set"));
991 assert!(output.contains("Alibaba Model Studio Coding Plan"));
992 assert!(!output.contains("Laptop"));
993 assert!(output.contains("Codewhale account acct-keys"));
994 assert!(!output.contains("sk-provider-never-print"));
995 assert!(!output.contains("access-secret"));
996 assert!(!output.contains("refresh-secret"));
997
998 let requests = transport.requests();
999 let put = requests
1000 .iter()
1001 .find(|request| request.method == HttpMethod::Put)
1002 .unwrap();
1003 assert_eq!(put.path, "/api/model-keys/openai");
1004 assert_eq!(
1005 serde_json::from_slice::<serde_json::Value>(put.body.as_ref().unwrap()).unwrap(),
1006 json!({ "key": "sk-provider-never-print", "label": "Laptop" })
1007 );
1008 assert!(requests.iter().any(|request| {
1009 request.method == HttpMethod::Delete && request.path == "/api/model-keys/openai"
1010 }));
1011 }
1012
1013 #[test]
1014 fn from_local_uses_config_without_printing_or_requiring_an_inline_key() {
1015 let temp = tempfile::tempdir().unwrap();
1016 let path = temp.path().join("config.toml");
1017 let mut config = ConfigStore::load(Some(path)).unwrap();
1018 config.config.providers.anthropic.api_key = Some("sk-local-upload-secret".to_string());
1019 let (secrets, _) = test_secrets();
1020 let transport = FakeTransport::new(vec![
1021 response(200, account("acct-local")),
1022 response(200, catalog()),
1023 response(200, json!({ "ok": true })),
1024 ]);
1025 CloudClient::new(&transport, &secrets, "work", "https://api.codewhale.net")
1026 .save_auth(auth("access", "refresh", "acct-local"))
1027 .unwrap();
1028 let mut output = Vec::new();
1029 let mut key_reader = |_| bail!("from-local must not prompt");
1030 let mut opener = |_| true;
1031 let mut sleeper = |_| {};
1032 run_with(
1033 command(&[
1034 "codewhale",
1035 "cloud",
1036 "keys",
1037 "set",
1038 "anthropic",
1039 "--from-local",
1040 ]),
1041 "work",
1042 "https://api.codewhale.net",
1043 &config,
1044 &secrets,
1045 &secrets,
1046 &machine::MachineKeyEnv::default(),
1047 &transport,
1048 &mut output,
1049 &mut key_reader,
1050 &mut opener,
1051 &mut sleeper,
1052 )
1053 .unwrap();
1054 let output = String::from_utf8(output).unwrap();
1055 assert!(output.contains("acct-local"));
1056 assert!(!output.contains("sk-local-upload-secret"));
1057 let requests = transport.requests();
1058 let put = requests
1059 .iter()
1060 .find(|request| request.method == HttpMethod::Put)
1061 .unwrap();
1062 assert!(String::from_utf8_lossy(put.body.as_ref().unwrap()).contains("sk-local-upload-secret"));
1063 }
1064
1065 #[test]
1066 fn catalog_ids_map_to_local_providers_through_the_catalog_not_a_compiled_table() {
1067 // `xiaomi` is the control plane's route id; `xiaomi-mimo` is the runtime's.
1068 // The catalog states that mapping, so `--from-local` must read it from the
1069 // response rather than from a compiled slug table.
1070 let temp = tempfile::tempdir().unwrap();
1071 let path = temp.path().join("config.toml");
1072 let mut config = ConfigStore::load(Some(path)).unwrap();
1073 config.config.providers.xiaomi_mimo.api_key = Some("sk-mimo-local".to_string());
1074 let (secrets, _) = test_secrets();
1075 let transport = FakeTransport::new(vec![
1076 response(200, account("acct-map")),
1077 response(200, catalog()),
1078 response(200, json!({ "ok": true })),
1079 ]);
1080 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1081 .save_auth(auth("access", "refresh", "acct-map"))
1082 .unwrap();
1083 let mut output = Vec::new();
1084 let mut key_reader = |_| bail!("from-local must not prompt");
1085 let mut opener = |_| true;
1086 let mut sleeper = |_| {};
1087 run_with(
1088 command(&[
1089 "codewhale",
1090 "cloud",
1091 "keys",
1092 "set",
1093 "xiaomi",
1094 "--from-local",
1095 ]),
1096 "default",
1097 "https://api.codewhale.net",
1098 &config,
1099 &secrets,
1100 &secrets,
1101 &machine::MachineKeyEnv::default(),
1102 &transport,
1103 &mut output,
1104 &mut key_reader,
1105 &mut opener,
1106 &mut sleeper,
1107 )
1108 .unwrap();
1109 let output = String::from_utf8(output).unwrap();
1110 assert!(!output.contains("sk-mimo-local"), "{output}");
1111 let requests = transport.requests();
1112 let put = requests
1113 .iter()
1114 .find(|request| request.method == HttpMethod::Put)
1115 .expect("a PUT to the catalog route id");
1116 assert_eq!(put.path, "/api/model-keys/xiaomi");
1117 assert!(String::from_utf8_lossy(put.body.as_ref().unwrap()).contains("sk-mimo-local"));
1118 }
1119
1120 #[test]
1121 fn an_id_outside_the_account_catalog_is_refused_and_names_what_is_available() {
1122 let (temp, config) = test_config();
1123 let _keep_temp = temp;
1124 let (secrets, _) = test_secrets();
1125 let transport = FakeTransport::new(vec![
1126 response(200, account("acct-unknown")),
1127 response(200, catalog()),
1128 ]);
1129 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1130 .save_auth(auth("access", "refresh", "acct-unknown"))
1131 .unwrap();
1132 let mut output = Vec::new();
1133 let mut key_reader = |_| bail!("an unknown provider must not prompt for a key");
1134 let mut opener = |_| true;
1135 let mut sleeper = |_| {};
1136 let error = run_with(
1137 command(&["codewhale", "cloud", "keys", "remove", "not-a-provider"]),
1138 "default",
1139 "https://api.codewhale.net",
1140 &config,
1141 &secrets,
1142 &secrets,
1143 &machine::MachineKeyEnv::default(),
1144 &transport,
1145 &mut output,
1146 &mut key_reader,
1147 &mut opener,
1148 &mut sleeper,
1149 )
1150 .expect_err("an id the account cannot connect must fail");
1151 let text = error.to_string();
1152 assert!(text.contains("modelstudio-coding-plan"), "{text}");
1153 assert!(
1154 !transport
1155 .requests()
1156 .iter()
1157 .any(|request| request.method == HttpMethod::Delete),
1158 "an unknown id must never reach a mutating route"
1159 );
1160 }
1161
1162 #[test]
1163 fn provider_ids_are_validated_before_they_can_reach_a_url_path() {
1164 validate_provider_id("modelstudio-coding-plan").unwrap();
1165 validate_provider_id(" deepseek ").unwrap();
1166 for bad in [
1167 "",
1168 "-leading",
1169 "Upper",
1170 "has_underscore",
1171 "../escape",
1172 "with/slash",
1173 &"a".repeat(65),
1174 ] {
1175 assert!(
1176 validate_provider_id(bad).is_err(),
1177 "{bad:?} must be refused"
1178 );
1179 }
1180 }
1181
1182 #[test]
1183 fn from_local_uses_config_before_the_provider_secret_store() {
1184 let (temp, mut config) = test_config();
1185 let _keep_temp = temp;
1186 let (secrets, store) = test_secrets();
1187 store.set("openai", "sk-secret-store").unwrap();
1188
1189 assert_eq!(
1190 resolve_local_key(&config, &secrets, ProviderKind::Openai)
1191 .unwrap()
1192 .as_deref(),
1193 Some("sk-secret-store")
1194 );
1195 config.config.providers.openai.api_key = Some("sk-config-first".to_string());
1196 assert_eq!(
1197 resolve_local_key(&config, &secrets, ProviderKind::Openai)
1198 .unwrap()
1199 .as_deref(),
1200 Some("sk-config-first")
1201 );
1202 }
1203
1204 #[test]
1205 fn logout_recovers_from_a_corrupt_local_session_record() {
1206 let (temp, config) = test_config();
1207 let _keep_temp = temp;
1208 let (secrets, store) = test_secrets();
1209 let slot = cloud_auth_slot("default", "https://api.codewhale.net");
1210 store.set(&slot, "not-json-and-not-a-token").unwrap();
1211 let transport = FakeTransport::new(vec![]);
1212 let mut output = Vec::new();
1213 let mut key_reader = |_| bail!("unused");
1214 let mut opener = |_| true;
1215 let mut sleeper = |_| {};
1216 run_with(
1217 CloudCommand::Logout,
1218 "default",
1219 "https://api.codewhale.net",
1220 &config,
1221 &secrets,
1222 &secrets,
1223 &machine::MachineKeyEnv::default(),
1224 &transport,
1225 &mut output,
1226 &mut key_reader,
1227 &mut opener,
1228 &mut sleeper,
1229 )
1230 .unwrap();
1231 assert!(store.get(&slot).unwrap().is_none());
1232 assert!(
1233 !String::from_utf8(output)
1234 .unwrap()
1235 .contains("not-json-and-not-a-token")
1236 );
1237 }
1238
1239 #[test]
1240 fn logout_clears_obsolete_or_wrong_origin_session_records() {
1241 let canonical_api_base = "https://api.codewhale.net";
1242 for (case, schema_version, stored_api_base) in [
1243 (
1244 "obsolete schema",
1245 ACCOUNT_SESSION_SCHEMA_VERSION.saturating_add(1),
1246 canonical_api_base,
1247 ),
1248 (
1249 "wrong origin",
1250 ACCOUNT_SESSION_SCHEMA_VERSION,
1251 "https://other.codewhale.net",
1252 ),
1253 ] {
1254 let (secrets, store) = test_secrets();
1255 let slot = cloud_auth_slot("default", canonical_api_base);
1256 let raw = serde_json::to_string(&StoredCloudAuth {
1257 schema_version,
1258 api_base: stored_api_base.to_string(),
1259 bundle: auth("access-obsolete", "refresh-obsolete", "acct-obsolete"),
1260 })
1261 .unwrap();
1262 store.set(&slot, &raw).unwrap();
1263 let transport = FakeTransport::new(vec![]);
1264 let client = CloudClient::new(&transport, &secrets, "default", canonical_api_base);
1265
1266 assert!(
1267 client.load_auth().unwrap().is_none(),
1268 "{case} must continue to load as signed out"
1269 );
1270 assert!(!client.logout().unwrap());
1271 assert!(
1272 store.get(&slot).unwrap().is_none(),
1273 "logout must scrub the {case} record"
1274 );
1275 assert!(transport.requests().is_empty());
1276 }
1277 }
1278
1279 #[test]
1280 fn server_errors_never_echo_response_messages() {
1281 let error = response_error(&response(
1282 400,
1283 json!({
1284 "error": {
1285 "code": "invalid_api_key",
1286 "message": "The submitted key was sk-never-echo-this"
1287 }
1288 }),
1289 ))
1290 .to_string();
1291 assert!(error.contains("invalid_api_key"));
1292 assert!(!error.contains("sk-never-echo-this"));
1293 }
1294
1295 #[test]
1296 fn cloud_auth_slot_does_not_embed_profile_or_origin() {
1297 let slot = cloud_auth_slot("private-profile", "https://api.codewhale.net");
1298 assert!(!slot.contains("private-profile"));
1299 assert!(!slot.contains("api.codewhale.net"));
1300 assert_ne!(
1301 slot,
1302 cloud_auth_slot("other-profile", "https://api.codewhale.net")
1303 );
1304 }
1305
1306 #[test]
1307 fn fake_store_is_profile_safe() {
1308 let (_, store) = test_secrets();
1309 store.set("unrelated", "keep-me").unwrap();
1310 store.delete("missing").unwrap();
1311 assert_eq!(store.get("unrelated").unwrap().as_deref(), Some("keep-me"));
1312 }
1313
1314 #[test]
1315 fn account_login_timeout_fails_the_command() {
1316 // §2.3 / #5033 class: a timed-out device login printed the timeout yet the
1317 // process exited 0. Pin the contract at the run_with seam — the command
1318 // must return Err so run_cli maps it to ExitCode::FAILURE. Verified live
1319 // against a stub server: `error: Codewhale account login timed out` now
1320 // exits 1.
1321 let (temp, config) = test_config();
1322 let _keep_temp = temp;
1323 let (secrets, _) = test_secrets();
1324 // Device start succeeds once; every token poll stays pending forever.
1325 struct PendingLogin;
1326 impl CloudTransport for PendingLogin {
1327 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
1328 if request.path == "/api/cli/device/start" {
1329 return Ok(response(
1330 200,
1331 json!({
1332 "deviceCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1333 "userCode": "ABCD-EFGH-JKLM",
1334 "verificationUri": "https://app.codewhale.net/cli/authorize",
1335 "verificationUriComplete": "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
1336 "expiresIn": 600,
1337 "interval": 1
1338 }),
1339 ));
1340 }
1341 Ok(response(202, json!({ "status": "authorization_pending" })))
1342 }
1343 }
1344 let pending = PendingLogin;
1345 let mut output = Vec::new();
1346 let mut key_reader = |_| bail!("key reader should not be called");
1347 let mut opener = |_| true;
1348 // A real (short) sleep keeps the pending loop from busy-spinning while
1349 // still reaching the 1s client timeout quickly.
1350 let mut sleeper = |duration: std::time::Duration| {
1351 std::thread::sleep(duration.min(std::time::Duration::from_millis(50)))
1352 };
1353 let result = run_with(
1354 command(&[
1355 "codewhale",
1356 "cloud",
1357 "login",
1358 "--no-open",
1359 "--timeout-seconds",
1360 "1",
1361 ]),
1362 "default",
1363 "https://api.codewhale.net",
1364 &config,
1365 &secrets,
1366 &secrets,
1367 &machine::MachineKeyEnv::default(),
1368 &pending,
1369 &mut output,
1370 &mut key_reader,
1371 &mut opener,
1372 &mut sleeper,
1373 );
1374 let err = match result {
1375 Ok(()) => panic!("a timed-out login must return Err so the exit code is non-zero"),
1376 Err(err) => err,
1377 };
1378 assert!(
1379 err.to_string().contains("login timed out"),
1380 "timeout error text: {err}"
1381 );
1382 }
1383
1384 // ---------------------------------------------------------------------------
1385 // Machine-token command surface, end to end through `run_with`.
1386 // ---------------------------------------------------------------------------
1387
1388 const MACHINE_TOKEN: &str =
1389 "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-xQRST";
1390
1391 fn machine_env() -> machine::MachineKeyEnv {
1392 machine::MachineKeyEnv::from_raw(Some(MACHINE_TOKEN))
1393 }
1394
1395 /// Drive one `codewhale account …` invocation with a scripted transport.
1396 fn run_account(
1397 argv: &[&str],
1398 machine: &machine::MachineKeyEnv,
1399 secrets: &Secrets,
1400 transport: &FakeTransport,
1401 ) -> (Result<()>, String) {
1402 let (temp, config) = test_config();
1403 let _keep_temp = temp;
1404 let mut output = Vec::new();
1405 let mut key_reader = |_| bail!("key reader should not be called");
1406 let mut opener = |_| true;
1407 let mut sleeper = |_| {};
1408 let result = run_with(
1409 command(argv),
1410 "default",
1411 "https://api.codewhale.net",
1412 &config,
1413 secrets,
1414 secrets,
1415 machine,
1416 transport,
1417 &mut output,
1418 &mut key_reader,
1419 &mut opener,
1420 &mut sleeper,
1421 );
1422 (result, String::from_utf8(output).unwrap())
1423 }
1424
1425 #[test]
1426 fn whoami_with_a_machine_key_uses_the_key_route_and_never_the_session_route() {
1427 let (secrets, _) = test_secrets();
1428 let transport = FakeTransport::new(vec![response(
1429 200,
1430 json!({
1431 "account": { "id": "user_1", "displayName": "Hunter", "email": "h@example.test",
1432 "region": "us-west", "plan": "free" },
1433 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "github-actions",
1434 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
1435 "scopes": ["account:read", "agent:run"],
1436 "createdAt": "2026-01-01T00:00:00Z" },
1437 "agent": { "configured": true, "modelProvider": "deepseek" }
1438 }),
1439 )]);
1440 let (result, output) = run_account(
1441 &["codewhale", "account", "whoami"],
1442 &machine_env(),
1443 &secrets,
1444 &transport,
1445 );
1446 result.unwrap();
1447 let requests = transport.requests();
1448 assert_eq!(requests.len(), 1);
1449 assert_eq!(requests[0].path, "/api/account/api-key/whoami");
1450 // Exactly one credential on the wire, and it is the machine key.
1451 assert_eq!(requests[0].bearer.as_deref(), Some(MACHINE_TOKEN));
1452 assert!(output.contains("user_1"), "{output}");
1453 assert!(
1454 output.contains("cwc_key_3f2a9c1e4b7d8a0f5c6e2b91"),
1455 "{output}"
1456 );
1457 assert!(
1458 !output.contains(&MACHINE_TOKEN[32..]),
1459 "secret half leaked: {output}"
1460 );
1461 }
1462
1463 /// The load-bearing failure mode: a machine credential that fails must not
1464 /// quietly become a human one, or CI runs as the wrong identity.
1465 #[test]
1466 fn a_rejected_machine_key_never_falls_back_to_the_stored_session() {
1467 let (secrets, _) = test_secrets();
1468 let transport = FakeTransport::new(vec![response(
1469 401,
1470 json!({
1471 "error": "unauthorized",
1472 "message": "invalid key",
1473 "details": { "code": "api_key_invalid" }
1474 }),
1475 )]);
1476 // A perfectly good interactive session exists alongside the bad key.
1477 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1478 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1479 .unwrap();
1480 let (result, output) = run_account(
1481 &["codewhale", "account", "whoami"],
1482 &machine_env(),
1483 &secrets,
1484 &transport,
1485 );
1486 let err = result.expect_err("an invalid machine key must fail the command");
1487 assert_eq!(
1488 transport.requests().len(),
1489 1,
1490 "there must be no second attempt"
1491 );
1492 assert!(output.is_empty(), "nothing should be printed: {output}");
1493 let machine_error = err
1494 .downcast_ref::<machine::MachineError>()
1495 .expect("the failure must carry a class");
1496 assert_eq!(machine_error.exit_code, machine::EXIT_AUTH);
1497 assert!(err.to_string().contains("is not valid"), "{err}");
1498 // The human session is untouched: a bad key is not a reason to log anyone out.
1499 assert!(
1500 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1501 .load_auth()
1502 .unwrap()
1503 .is_some()
1504 );
1505 }
1506
1507 #[test]
1508 fn managing_keys_with_only_a_machine_key_is_refused_before_anything_is_sent() {
1509 let (secrets, _) = test_secrets();
1510 for argv in [
1511 vec!["codewhale", "account", "api-keys", "list"],
1512 vec!["codewhale", "account", "api-keys", "create", "--name", "ci"],
1513 vec![
1514 "codewhale",
1515 "account",
1516 "api-keys",
1517 "revoke",
1518 "3f2a9c1e4b7d8a0f5c6e2b91",
1519 ],
1520 ] {
1521 let transport = FakeTransport::new(Vec::new());
1522 let (result, output) = run_account(&argv, &machine_env(), &secrets, &transport);
1523 let err = result.expect_err("a key cannot manage keys");
1524 assert!(
1525 transport.requests().is_empty(),
1526 "{argv:?} put the key on the wire"
1527 );
1528 assert!(output.is_empty(), "{argv:?}: {output}");
1529 assert!(
1530 err.to_string()
1531 .contains("Managing API keys needs an interactive login."),
1532 "{argv:?}: {err}"
1533 );
1534 }
1535 }
1536
1537 #[test]
1538 fn create_use_saves_the_key_only_in_the_local_codewhale_slot() {
1539 let (secrets, keyring) = test_secrets();
1540 let transport = FakeTransport::new(vec![response(
1541 201,
1542 json!({
1543 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "laptop",
1544 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
1545 "scopes": ["account:read", "agent:run", "models:infer"],
1546 "createdAt": "2026-01-01T00:00:00Z", "expiresAt": null },
1547 "secret": MACHINE_TOKEN
1548 }),
1549 )]);
1550 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1551 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1552 .unwrap();
1553 let (result, output) = run_account(
1554 &[
1555 "codewhale",
1556 "account",
1557 "api-keys",
1558 "create",
1559 "--name",
1560 "laptop",
1561 "--scope",
1562 "models:infer",
1563 "--use",
1564 ],
1565 &machine::MachineKeyEnv::default(),
1566 &secrets,
1567 &transport,
1568 );
1569 result.unwrap();
1570 // The secret is still printed exactly once, and the local save is stated.
1571 assert_eq!(output.matches(MACHINE_TOKEN).count(), 1, "{output}");
1572 assert!(
1573 output.contains("local `codewhale` provider credential"),
1574 "{output}"
1575 );
1576 assert_eq!(
1577 keyring.get("codewhale").unwrap().as_deref(),
1578 Some(MACHINE_TOKEN),
1579 "--use must write the codewhale provider slot"
1580 );
1581 // Only one request: --use is a local write, never an upload.
1582 let requests = transport.requests();
1583 assert_eq!(requests.len(), 1);
1584 let body: serde_json::Value =
1585 serde_json::from_slice(requests[0].body.as_ref().unwrap()).unwrap();
1586 assert_eq!(body["scopes"], json!(["models:infer"]), "{body}");
1587 }
1588
1589 #[test]
1590 fn creating_a_key_prints_the_secret_exactly_once_and_saves_it_nowhere() {
1591 let (secrets, keyring) = test_secrets();
1592 let transport = FakeTransport::new(vec![response(
1593 201,
1594 json!({
1595 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "github-actions",
1596 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
1597 "scopes": ["account:read", "agent:run"],
1598 "createdAt": "2026-01-01T00:00:00Z", "expiresAt": null },
1599 "secret": MACHINE_TOKEN
1600 }),
1601 )]);
1602 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1603 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1604 .unwrap();
1605 let (result, output) = run_account(
1606 &[
1607 "codewhale",
1608 "account",
1609 "api-keys",
1610 "create",
1611 "--name",
1612 "github-actions",
1613 "--expires-in-days",
1614 "90",
1615 ],
1616 &machine::MachineKeyEnv::default(),
1617 &secrets,
1618 &transport,
1619 );
1620 result.unwrap();
1621 assert_eq!(output.matches(MACHINE_TOKEN).count(), 1, "{output}");
1622 assert!(
1623 output.contains("ONLY TIME YOU WILL SEE THIS SECRET"),
1624 "{output}"
1625 );
1626
1627 let requests = transport.requests();
1628 assert_eq!(requests.len(), 1);
1629 assert_eq!(requests[0].path, "/api/account/api-keys");
1630 let body: serde_json::Value =
1631 serde_json::from_slice(requests[0].body.as_ref().unwrap()).unwrap();
1632 assert_eq!(body["name"], "github-actions");
1633 assert_eq!(body["expiresInDays"], 90);
1634 // Scopes omitted means every scope, stated explicitly so a key carries
1635 // exactly what this CLI's help promised.
1636 assert_eq!(
1637 body["scopes"],
1638 json!(["account:read", "agent:run", "models:infer"]),
1639 "{body}"
1640 );
1641
1642 // The plaintext exists in one response and nowhere else, ever: creating a
1643 // key must not write it into the session record on its way past.
1644 let stored = keyring
1645 .get(&cloud_auth_slot("default", "https://api.codewhale.net"))
1646 .unwrap()
1647 .expect("the session record is still there");
1648 assert!(
1649 !stored.contains(MACHINE_TOKEN),
1650 "the secret reached storage"
1651 );
1652 }
1653
1654 #[test]
1655 fn a_bad_key_name_is_rejected_locally_without_a_round_trip() {
1656 let (secrets, _) = test_secrets();
1657 let transport = FakeTransport::new(Vec::new());
1658 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1659 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1660 .unwrap();
1661 let (result, _) = run_account(
1662 &[
1663 "codewhale",
1664 "account",
1665 "api-keys",
1666 "create",
1667 "--name",
1668 "bad*name",
1669 ],
1670 &machine::MachineKeyEnv::default(),
1671 &secrets,
1672 &transport,
1673 );
1674 let err = result.expect_err("`*` is outside the server's name pattern");
1675 assert!(transport.requests().is_empty());
1676 assert!(
1677 err.to_string().contains("only letters, digits, spaces"),
1678 "{err}"
1679 );
1680 }
1681
1682 #[test]
1683 fn the_agent_precondition_surfaces_the_409_with_its_own_exit_class() {
1684 let (secrets, _) = test_secrets();
1685 let transport = FakeTransport::new(vec![response(
1686 409,
1687 json!({
1688 "error": "conflict",
1689 "message": "no agent model",
1690 "details": { "code": "account_agent_model_unconfigured" }
1691 }),
1692 )]);
1693 let (result, _) = run_account(
1694 &["codewhale", "account", "agent"],
1695 &machine_env(),
1696 &secrets,
1697 &transport,
1698 );
1699 let err = result.expect_err("machine work needs a model");
1700 let machine_error = err.downcast_ref::<machine::MachineError>().unwrap();
1701 // A configuration problem, not a credential problem — and CI must be able
1702 // to tell them apart from the exit code alone.
1703 assert_eq!(machine_error.exit_code, machine::EXIT_AGENT_UNCONFIGURED);
1704 assert_ne!(machine_error.exit_code, machine::EXIT_AUTH);
1705 assert_eq!(transport.requests().len(), 1, "409 must not be retried");
1706 assert!(
1707 err.to_string().contains("codewhale account keys set"),
1708 "{err}"
1709 );
1710 }
1711
1712 #[test]
1713 fn the_agent_command_has_no_session_fallback() {
1714 let (secrets, _) = test_secrets();
1715 let transport = FakeTransport::new(Vec::new());
1716 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1717 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1718 .unwrap();
1719 let (result, _) = run_account(
1720 &["codewhale", "account", "agent"],
1721 &machine::MachineKeyEnv::default(),
1722 &secrets,
1723 &transport,
1724 );
1725 let err = result.expect_err("the agent route is machine-key-only");
1726 assert!(transport.requests().is_empty());
1727 assert!(err.to_string().contains("CODEWHALE_API_KEY"), "{err}");
1728 }
1729
1730 #[test]
1731 fn whoami_without_a_machine_key_still_reports_the_interactive_session() {
1732 let (secrets, _) = test_secrets();
1733 let transport = FakeTransport::new(vec![response(200, account("acct-human"))]);
1734 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1735 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1736 .unwrap();
1737 let (result, output) = run_account(
1738 &["codewhale", "account", "whoami"],
1739 &machine::MachineKeyEnv::default(),
1740 &secrets,
1741 &transport,
1742 );
1743 result.unwrap();
1744 assert_eq!(transport.requests()[0].path, "/api/me");
1745 assert!(output.contains("acct-human"), "{output}");
1746 }
1747
1748 #[test]
1749 fn the_machine_token_surface_is_a_different_noun_from_the_provider_vault() {
1750 // `account keys` is the BYOK provider vault; `account api-keys` is the
1751 // machine tokens. Merging them would let one typo revoke the wrong thing.
1752 assert!(matches!(
1753 command(&["codewhale", "account", "keys", "list"]),
1754 CloudCommand::Keys(_)
1755 ));
1756 assert!(matches!(
1757 command(&["codewhale", "account", "api-keys", "list"]),
1758 CloudCommand::ApiKeys(_)
1759 ));
1760 }
1761
1762 struct ConcurrentSessionTransport {
1763 inner: FakeTransport,
1764 trigger: &'static str,
1765 hold_writes: bool,
1766 writes: std::sync::mpsc::Sender<()>,
1767 done: Mutex<std::sync::mpsc::Receiver<()>>,
1768 }
1769 impl CloudTransport for ConcurrentSessionTransport {
1770 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
1771 if request.path == self.trigger {
1772 self.writes.send(()).unwrap();
1773 if self.hold_writes {
1774 assert!(
1775 matches!(
1776 self.done
1777 .lock()
1778 .unwrap()
1779 .recv_timeout(Duration::from_millis(100)),
1780 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
1781 ),
1782 "account writer bypassed lifecycle transaction"
1783 );
1784 } else {
1785 self.done
1786 .lock()
1787 .unwrap()
1788 .recv_timeout(Duration::from_secs(5))
1789 .unwrap();
1790 }
1791 }
1792 self.inner.execute(request)
1793 }
1794 }
1795
1796 #[test]
1797 fn delayed_account_responses_never_replace_or_clear_a_new_sign_in() {
1798 for (operation, status) in [
1799 ("refresh", 200),
1800 ("refresh", 401),
1801 ("logout", 200),
1802 ("me", 200),
1803 ] {
1804 let (secrets, _) = test_secrets();
1805 let owner = AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE);
1806 owner
1807 .save(auth("old-access", "old-refresh", "old-account"))
1808 .unwrap();
1809 let (write_tx, write_rx) = std::sync::mpsc::channel();
1810 let (done_tx, done_rx) = std::sync::mpsc::channel();
1811 let writer = std::thread::spawn(move || {
1812 write_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1813 owner
1814 .save(auth("new-access", "new-refresh", "new-account"))
1815 .unwrap();
1816 done_tx.send(()).unwrap();
1817 });
1818 let (trigger, responses) = match operation {
1819 "refresh" => (
1820 "/api/auth/refresh",
1821 vec![
1822 response(401, json!({})),
1823 response(
1824 status,
1825 auth_json("rotated-access", "rotated-refresh", "old-account"),
1826 ),
1827 ],
1828 ),
1829 "logout" => ("/api/auth/logout", vec![response(status, json!({}))]),
1830 "me" => ("/api/me", vec![response(status, account("old-account"))]),
1831 _ => unreachable!(),
1832 };
1833 let transport = ConcurrentSessionTransport {
1834 inner: FakeTransport::new(responses),
1835 trigger,
1836 hold_writes: operation != "me",
1837 writes: write_tx,
1838 done: Mutex::new(done_rx),
1839 };
1840 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1841 if operation == "logout" {
1842 assert!(client.logout().unwrap());
1843 } else {
1844 assert!(client.me().is_err());
1845 }
1846 writer.join().unwrap();
1847 let current = client.load_auth().unwrap().unwrap();
1848 assert_eq!(current.bundle.access_token, "new-access");
1849 assert_eq!(current.bundle.refresh_token, "new-refresh");
1850 assert_eq!(current.bundle.user.unwrap().id, "new-account");
1851 assert_eq!(
1852 transport.inner.requests().len(),
1853 if operation == "refresh" {
1854 if status == 200 { 3 } else { 2 }
1855 } else {
1856 1
1857 }
1858 );
1859 }
1860 }
1861
1862 #[test]
1863 fn renewed_credentials_survive_retry_transport_failure() {
1864 let (secrets, _) = test_secrets();
1865 let transport = FakeTransport::new(vec![
1866 response(401, json!({})),
1867 response(
1868 200,
1869 auth_json("renewed-access", "renewed-refresh", "account"),
1870 ),
1871 ]);
1872 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1873 client
1874 .save_auth(auth("old-access", "old-refresh", "account"))
1875 .unwrap();
1876 assert!(client.me().is_err());
1877 let current = client.load_auth().unwrap().unwrap();
1878 assert_eq!(current.bundle.access_token, "renewed-access");
1879 assert_eq!(current.bundle.refresh_token, "renewed-refresh");
1880 }
1881
1882 #[test]
1883 fn concurrent_clients_spend_refresh_token_only_once() {
1884 use std::sync::{
1885 Barrier,
1886 atomic::{AtomicUsize, Ordering},
1887 };
1888 struct Transport {
1889 first_reads: AtomicUsize,
1890 refreshes: AtomicUsize,
1891 barrier: Barrier,
1892 }
1893 impl CloudTransport for Transport {
1894 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
1895 if request.path == "/api/auth/refresh" {
1896 self.refreshes.fetch_add(1, Ordering::SeqCst);
1897 return Ok(response(
1898 200,
1899 auth_json("new-access", "new-refresh", "account"),
1900 ));
1901 }
1902 if self.first_reads.fetch_add(1, Ordering::SeqCst) < 2 {
1903 self.barrier.wait();
1904 Ok(response(401, json!({})))
1905 } else {
1906 Ok(response(200, account("account")))
1907 }
1908 }
1909 }
1910 let dir = tempfile::tempdir().unwrap();
1911 let secrets = Secrets::new(Arc::new(codewhale_secrets::FileKeyringStore::new(
1912 dir.path().join("secrets.json"),
1913 )));
1914 let transport = Transport {
1915 first_reads: AtomicUsize::new(0),
1916 refreshes: AtomicUsize::new(0),
1917 barrier: Barrier::new(2),
1918 };
1919 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1920 client
1921 .save_auth(auth("old-access", "old-refresh", "account"))
1922 .unwrap();
1923 let successes = std::thread::scope(|scope| {
1924 let a = scope
1925 .spawn(|| CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE).me());
1926 let b = scope
1927 .spawn(|| CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE).me());
1928 [a.join().unwrap(), b.join().unwrap()]
1929 .into_iter()
1930 .filter(Result::is_ok)
1931 .count()
1932 });
1933 assert_eq!(successes, 1);
1934 assert_eq!(transport.refreshes.load(Ordering::SeqCst), 1);
1935 assert_eq!(
1936 client.load_auth().unwrap().unwrap().bundle.refresh_token,
1937 "new-refresh"
1938 );
1939 }
1940 #[test]
1941 fn logout_preserves_custody_until_server_confirms_revocation_or_dead_session() {
1942 for status in [
1943 None,
1944 Some(429),
1945 Some(500),
1946 Some(503),
1947 Some(200),
1948 Some(204),
1949 Some(401),
1950 Some(403),
1951 ] {
1952 let (secrets, _) = test_secrets();
1953 let responses = status
1954 .map(|code| vec![response(code, json!({}))])
1955 .unwrap_or_default();
1956 let transport = FakeTransport::new(responses);
1957 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1958 client
1959 .save_auth(auth("access-revoke", "refresh-revoke", "account"))
1960 .unwrap();
1961 let result = client.logout();
1962 if status.is_some_and(|s| (200..300).contains(&s) || matches!(s, 401 | 403)) {
1963 assert!(result.is_ok());
1964 assert!(client.load_auth().unwrap().is_none());
1965 } else {
1966 assert!(result.is_err());
1967 assert_eq!(
1968 client.load_auth().unwrap().unwrap().bundle.refresh_token,
1969 "refresh-revoke"
1970 );
1971 }
1972 }
1973 }
1974
1975 #[test]
1976 fn account_computers_use_the_same_account_api_and_report_queued_starts() {
1977 const ID: &str = "123e4567-e89b-42d3-a456-426614174000";
1978 let (_temp, config) = test_config();
1979 let (secrets, _) = test_secrets();
1980 let store = AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE);
1981 store
1982 .save(auth("access-secret", "refresh-secret", "acct-123"))
1983 .unwrap();
1984 let mut queued = computer(ID, "suspended");
1985 queued["startQueueReason"] = json!("active_limit");
1986 let transport = FakeTransport::new(vec![
1987 response(200, json!({"computers": [computer(ID, "suspended")]})),
1988 response(200, json!({"computer": computer(ID, "suspended")})),
1989 response(200, json!({"computer": computer(ID, "suspended")})),
1990 response(202, json!({"computer": queued, "queued": true})),
1991 response(200, json!({"computer": computer(ID, "suspended")})),
1992 response(200, json!({"deleted": true, "computerId": ID})),
1993 ]);
1994 let mut output = Vec::new();
1995 let mut key_reader = |_| bail!("unused");
1996 let mut opener = |_| true;
1997 let mut sleeper = |_| {};
1998 let commands = [
1999 vec!["codewhale", "account", "computers", "list"],
2000 vec![
2001 "codewhale",
2002 "account",
2003 "computers",
2004 "create",
2005 "Account pilot",
2006 ],
2007 vec!["codewhale", "account", "computers", "show", ID],
2008 vec!["codewhale", "account", "computers", "start", ID],
2009 vec!["codewhale", "account", "computers", "pause", ID],
2010 vec!["codewhale", "account", "computers", "delete", ID],
2011 ];
2012 for argv in commands {
2013 run_with(
2014 command(&argv),
2015 "default",
2016 DEFAULT_API_BASE,
2017 &config,
2018 &secrets,
2019 &secrets,
2020 &machine::MachineKeyEnv::default(),
2021 &transport,
2022 &mut output,
2023 &mut key_reader,
2024 &mut opener,
2025 &mut sleeper,
2026 )
2027 .unwrap();
2028 }
2029 let requests = transport.requests();
2030 assert_eq!(
2031 requests
2032 .iter()
2033 .map(|request| request.path.as_str())
2034 .collect::<Vec<_>>(),
2035 vec![
2036 "/api/computers",
2037 "/api/computers",
2038 "/api/computers/123e4567-e89b-42d3-a456-426614174000",
2039 "/api/computers/123e4567-e89b-42d3-a456-426614174000/start",
2040 "/api/computers/123e4567-e89b-42d3-a456-426614174000/pause",
2041 "/api/computers/123e4567-e89b-42d3-a456-426614174000",
2042 ]
2043 );
2044 assert!(
2045 requests
2046 .iter()
2047 .all(|request| request.bearer.as_deref() == Some("access-secret"))
2048 );
2049 assert!(requests[0].method == HttpMethod::Get);
2050 assert!(requests[1].method == HttpMethod::Post);
2051 assert!(requests[5].method == HttpMethod::Delete);
2052 assert_eq!(
2053 serde_json::from_slice::<serde_json::Value>(requests[1].body.as_ref().unwrap()).unwrap(),
2054 json!({"name":"Account pilot"})
2055 );
2056 let output = String::from_utf8(output).unwrap();
2057 assert!(output.contains("Account ID: acct-123"));
2058 assert!(output.contains("Saved Computer identity; compute is allocated when you start it."));
2059 assert!(output.contains("Computer start queued."));
2060 assert!(output.contains("Reason: active_limit"));
2061 assert!(output.contains(&format!("Deleted Computer {ID}.")));
2062 assert!(!output.contains("access-secret"));
2063 }
2064
2065 #[test]
2066 fn account_computers_refuse_unsafe_ids_machine_keys_and_unconfirmed_delete() {
2067 let (secrets, _) = test_secrets();
2068 let transport = FakeTransport::new(vec![]);
2069 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2070 client
2071 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2072 .unwrap();
2073 assert!(client.computer("../other").is_err());
2074 assert!(client.computer_action("id/other", "start").is_err());
2075 assert!(client.delete_computer("id?other").is_err());
2076 assert!(client.create_computer("un\nsafe", false, false).is_err());
2077 assert!(client.create_computer("Trial", true, false).is_err());
2078 assert!(client.create_computer("Trial", false, true).is_err());
2079 assert!(client.computer_usage("../other").is_err());
2080 let error = run_computers(
2081 CloudComputersCommand::List { json: false },
2082 &client,
2083 &machine::MachineKeyEnv::from_raw(Some("machine-key-present")),
2084 &mut Vec::new(),
2085 )
2086 .unwrap_err();
2087 assert!(
2088 error
2089 .to_string()
2090 .contains("interactive Codewhale account login")
2091 );
2092 assert!(transport.requests().is_empty());
2093
2094 let denied = FakeTransport::new(vec![response(
2095 200,
2096 json!({"deleted": false, "computerId": "123e4567-e89b-42d3-a456-426614174000"}),
2097 )]);
2098 let client = CloudClient::new(&denied, &secrets, "default", DEFAULT_API_BASE);
2099 assert!(
2100 client
2101 .delete_computer("123e4567-e89b-42d3-a456-426614174000")
2102 .is_err()
2103 );
2104 }
2105
2106 #[test]
2107 fn account_computers_json_preserves_server_metering_and_entitlement() {
2108 const ID: &str = "123e4567-e89b-42d3-a456-426614174000";
2109 let (_temp, config) = test_config();
2110 let (secrets, _) = test_secrets();
2111 let account = AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE);
2112 account
2113 .save(auth("access-secret", "refresh-secret", "acct-123"))
2114 .unwrap();
2115 let mut record = computer(ID, "running");
2116 record["allowanceMultiplier"] = json!(1);
2117 record["allowance"] = json!({
2118 "meter": "compute_cu", "usedCu": 2.5, "includedCu": 20,
2119 "remainingCu": 17.5, "state": "ok"
2120 });
2121 record["futureMeterField"] = json!({"value": 7});
2122 let listing = json!({
2123 "computers": [record.clone()],
2124 "entitlement": {"planId": "test", "seatActive": true}
2125 });
2126 let shown = json!({"computer": record});
2127 let transport = FakeTransport::new(vec![
2128 response(200, listing.clone()),
2129 response(200, shown.clone()),
2130 ]);
2131 let mut key_reader = |_| bail!("unused");
2132 let mut opener = |_| true;
2133 let mut sleeper = |_| {};
2134 for (argv, expected) in [
2135 (
2136 vec!["codewhale", "account", "computers", "list", "--json"],
2137 listing,
2138 ),
2139 (
2140 vec!["codewhale", "account", "computers", "show", ID, "--json"],
2141 shown,
2142 ),
2143 ] {
2144 let mut output = Vec::new();
2145 run_with(
2146 command(&argv),
2147 "default",
2148 DEFAULT_API_BASE,
2149 &config,
2150 &secrets,
2151 &secrets,
2152 &machine::MachineKeyEnv::default(),
2153 &transport,
2154 &mut output,
2155 &mut key_reader,
2156 &mut opener,
2157 &mut sleeper,
2158 )
2159 .unwrap();
2160 assert_eq!(
2161 serde_json::from_slice::<serde_json::Value>(&output).unwrap(),
2162 expected
2163 );
2164 }
2165 }
2166
2167 #[test]
2168 fn account_computers_boat_trial_and_usage_send_explicit_consent_and_read_receipts() {
2169 const ID: &str = "123e4567-e89b-42d3-a456-426614174000";
2170 let (_temp, config) = test_config();
2171 let (secrets, _) = test_secrets();
2172 AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE)
2173 .save(auth("access-secret", "refresh-secret", "acct-123"))
2174 .unwrap();
2175 let usage = json!({
2176 "computerId": ID,
2177 "provider": "boat",
2178 "meter": "provider_billable_seconds",
2179 "trialLimitSeconds": 7200,
2180 "usedSeconds": 360,
2181 "remainingSeconds": 6840,
2182 "customerChargeDollars": 0,
2183 "receipts": [{"operationId": "operation-1", "seconds": 360}]
2184 });
2185 let transport = FakeTransport::new(vec![
2186 response(201, json!({"computer": computer(ID, "suspended")})),
2187 response(200, usage.clone()),
2188 ]);
2189 let mut output = Vec::new();
2190 let mut key_reader = |_| bail!("unused");
2191 let mut opener = |_| true;
2192 let mut sleeper = |_| {};
2193 for (index, argv) in [
2194 vec![
2195 "codewhale",
2196 "account",
2197 "computers",
2198 "create",
2199 "Trial",
2200 "--boat-trial",
2201 "--eu-compute-opt-in",
2202 ],
2203 vec!["codewhale", "account", "computers", "usage", ID, "--json"],
2204 ]
2205 .into_iter()
2206 .enumerate()
2207 {
2208 run_with(
2209 command(&argv),
2210 "default",
2211 DEFAULT_API_BASE,
2212 &config,
2213 &secrets,
2214 &secrets,
2215 &machine::MachineKeyEnv::default(),
2216 &transport,
2217 &mut output,
2218 &mut key_reader,
2219 &mut opener,
2220 &mut sleeper,
2221 )
2222 .unwrap();
2223 if index == 0 {
2224 assert!(String::from_utf8_lossy(&output).contains("Saved Computer identity"));
2225 output.clear();
2226 }
2227 }
2228 let requests = transport.requests();
2229 assert_eq!(requests.len(), 2);
2230 assert_eq!(requests[0].method, HttpMethod::Post);
2231 assert_eq!(requests[0].path, "/api/computers");
2232 assert_eq!(
2233 serde_json::from_slice::<serde_json::Value>(requests[0].body.as_ref().unwrap()).unwrap(),
2234 json!({"name": "Trial", "provider": "boat", "boatEuComputeOptIn": true})
2235 );
2236 assert_eq!(requests[1].method, HttpMethod::Get);
2237 assert_eq!(requests[1].path, format!("/api/computers/{ID}/usage"));
2238 assert!(requests[1].body.is_none());
2239 assert!(
2240 requests
2241 .iter()
2242 .all(|request| request.bearer.as_deref() == Some("access-secret"))
2243 );
2244 assert_eq!(
2245 serde_json::from_slice::<serde_json::Value>(&output).unwrap(),
2246 usage
2247 );
2248 }
2249
2250 #[test]
2251 fn account_agents_create_model_bound_thread_and_send_with_same_session() {
2252 let (_temp, config) = test_config();
2253 let (secrets, _) = test_secrets();
2254 AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE)
2255 .save(auth("access-secret", "refresh-secret", "acct-123"))
2256 .unwrap();
2257 let a = agent("agent-1", "Whale");
2258 let t = agent_thread("thread-1", "agent-1", "Main");
2259 let transport = FakeTransport::new(vec![
2260 response(200, json!({ "agents": [a.clone()] })),
2261 response(201, json!({ "agent": a.clone() })),
2262 response(200, json!({ "agents": [a.clone()] })),
2263 response(200, route_catalog()),
2264 response(201, json!({ "thread": t.clone(), "id": "thread-1" })),
2265 response(200, json!({ "agents": [a.clone()] })),
2266 response(200, json!([t.clone()])),
2267 response(200, json!({ "agents": [a] })),
2268 response(200, json!({ "thread": t })),
2269 response(
2270 202,
2271 json!({ "turn": { "id": "turn-1", "status": "pending" } }),
2272 ),
2273 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
2274 response(
2275 200,
2276 json!({ "thread": agent_thread("thread-1", "agent-1", "Main") }),
2277 ),
2278 sse_response(&[
2279 json!({ "type": "turn.started", "seq": 1, "turnId": "turn-1", "payload": { "status": "running" } }),
2280 json!({ "type": "assistant.delta", "seq": 2, "turnId": "turn-1", "payload": { "text": "Done.\n" } }),
2281 json!({ "type": "turn.completed", "seq": 3, "turnId": "turn-1", "payload": { "status": "completed" } }),
2282 ]),
2283 ]);
2284 let mut output = Vec::new();
2285 let mut key_reader = |_| bail!("unused");
2286 let mut opener = |_| true;
2287 let mut sleeper = |_| {};
2288 for argv in [
2289 vec!["codewhale", "account", "agents", "list"],
2290 vec![
2291 "codewhale",
2292 "account",
2293 "agents",
2294 "create",
2295 "Whale",
2296 "--project-id",
2297 "project-codewhale",
2298 "--operation-key",
2299 "create-1",
2300 ],
2301 vec![
2302 "codewhale",
2303 "account",
2304 "agents",
2305 "new-thread",
2306 "Whale",
2307 "--operation-key",
2308 "thread-1",
2309 ],
2310 vec!["codewhale", "account", "agents", "threads", "Whale"],
2311 vec![
2312 "codewhale",
2313 "account",
2314 "agents",
2315 "send",
2316 "Whale",
2317 "Build this",
2318 "--thread",
2319 "thread-1",
2320 "--billing-mode",
2321 "byok_external",
2322 "--operation-key",
2323 "message-1",
2324 ],
2325 vec![
2326 "codewhale",
2327 "account",
2328 "agents",
2329 "result",
2330 "Whale",
2331 "thread-1",
2332 "turn-1",
2333 ],
2334 ] {
2335 run_with(
2336 command(&argv),
2337 "default",
2338 DEFAULT_API_BASE,
2339 &config,
2340 &secrets,
2341 &secrets,
2342 &machine::MachineKeyEnv::default(),
2343 &transport,
2344 &mut output,
2345 &mut key_reader,
2346 &mut opener,
2347 &mut sleeper,
2348 )
2349 .unwrap();
2350 }
2351 let requests = transport.requests();
2352 assert_eq!(
2353 requests
2354 .iter()
2355 .map(|request| request.path.as_str())
2356 .collect::<Vec<_>>(),
2357 [
2358 "/api/agents",
2359 "/api/agents",
2360 "/api/agents",
2361 "/api/model-providers",
2362 "/v1/threads",
2363 "/api/agents",
2364 "/v1/threads/summary?agentId=agent-1&limit=100",
2365 "/api/agents",
2366 "/v1/threads/thread-1",
2367 "/v1/threads/thread-1/turns",
2368 "/api/agents",
2369 "/v1/threads/thread-1",
2370 "/v1/threads/thread-1/events?since_seq=0",
2371 ]
2372 );
2373 // The model catalog is public: no credential rides along with that read.
2374 assert!(requests.iter().all(|request| {
2375 request.bearer.as_deref()
2376 == (request.path != "/api/model-providers").then_some("access-secret")
2377 }));
2378 assert_eq!(
2379 serde_json::from_slice::<serde_json::Value>(requests[1].body.as_ref().unwrap()).unwrap(),
2380 json!({ "name": "Whale", "projectId": "project-codewhale", "operationKey": "create-1" })
2381 );
2382 assert_eq!(
2383 serde_json::from_slice::<serde_json::Value>(requests[4].body.as_ref().unwrap()).unwrap(),
2384 json!({
2385 "title": "Main", "productMode": "chat", "mode": "chat",
2386 "agentId": "agent-1", "projectId": "project-codewhale",
2387 "modelProvider": "deepseek",
2388 "model": "deepseek-flash", "operationKey": "thread-1"
2389 })
2390 );
2391 assert_eq!(
2392 serde_json::from_slice::<serde_json::Value>(requests[9].body.as_ref().unwrap()).unwrap(),
2393 json!({
2394 "prompt": "Build this", "billingMode": "byok_external",
2395 "modelProvider": "deepseek", "modelProviderId": "",
2396 "model": "deepseek-flash", "requiresByok": true,
2397 "mode": "chat", "productMode": "chat", "operationKey": "message-1",
2398 "sourceMessageId": "message-1"
2399 })
2400 );
2401 let output = String::from_utf8(output).unwrap();
2402 assert!(output.contains("Turn ID: turn-1"));
2403 assert!(output.contains("Status: pending"));
2404 assert!(output.contains("Status: completed"));
2405 assert!(output.contains("Answer:\nDone."));
2406 assert!(!output.contains("access-secret"));
2407 }
2408
2409 #[test]
2410 fn account_projects_list_and_agent_binding_use_the_saved_revision() {
2411 let (secrets, _) = test_secrets();
2412 let auth_transport = FakeTransport::new(vec![]);
2413 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2414 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2415 .unwrap();
2416 let mut unbound = agent("agent-1", "Whale");
2417 unbound["projectId"] = json!("");
2418 let bound = agent("agent-1", "Whale");
2419 let projects = json!({ "projects": [{ "id": "project-codewhale", "name": "Codewhale" }] });
2420 let transport = FakeTransport::new(vec![
2421 response(200, projects.clone()),
2422 response(200, json!({ "agents": [unbound] })),
2423 response(200, projects),
2424 response(200, json!({ "agent": bound })),
2425 ]);
2426 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2427 let mut output = Vec::new();
2428 run_projects(
2429 CloudProjectsCommand::List { json: false },
2430 &client,
2431 &machine::MachineKeyEnv::default(),
2432 &mut output,
2433 )
2434 .unwrap();
2435 run_agents(
2436 CloudAgentsCommand::BindProject {
2437 agent: "Whale".into(),
2438 project_id: "project-codewhale".into(),
2439 },
2440 &client,
2441 &machine::MachineKeyEnv::default(),
2442 &mut output,
2443 )
2444 .unwrap();
2445 let requests = transport.requests();
2446 assert_eq!(
2447 requests
2448 .iter()
2449 .map(|request| request.path.as_str())
2450 .collect::<Vec<_>>(),
2451 [
2452 "/api/projects",
2453 "/api/agents",
2454 "/api/projects",
2455 "/api/agents/agent-1"
2456 ]
2457 );
2458 assert!(requests[3].method == HttpMethod::Patch);
2459 assert_eq!(
2460 serde_json::from_slice::<serde_json::Value>(requests[3].body.as_ref().unwrap()).unwrap(),
2461 json!({ "projectId": "project-codewhale", "revision": 1 })
2462 );
2463 let output = String::from_utf8(output).unwrap();
2464 assert!(output.contains("Codewhale — project-codewhale"));
2465 assert!(output.contains("Agent Whale is bound to Project project-codewhale."));
2466 }
2467
2468 #[test]
2469 fn account_github_binding_creates_a_project_from_the_same_repository() {
2470 let (secrets, _) = test_secrets();
2471 CloudClient::new(
2472 &FakeTransport::new(vec![]),
2473 &secrets,
2474 "default",
2475 DEFAULT_API_BASE,
2476 )
2477 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2478 .unwrap();
2479 let binding_id = "github:acct-123:987:owner/repo";
2480 let binding = json!({
2481 "id": binding_id,
2482 "provider": "github",
2483 "repo": "owner/repo",
2484 "status": "bound",
2485 "installationId": "987",
2486 });
2487 let transport = FakeTransport::new(vec![
2488 response(200, json!({ "bindings": [binding.clone()] })),
2489 response(200, json!({ "bindings": [binding] })),
2490 response(
2491 201,
2492 json!({ "project": {
2493 "id": "project-github", "name": "My Project",
2494 "defaultRepoProvider": "github", "defaultRepo": "owner/repo",
2495 } }),
2496 ),
2497 ]);
2498 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2499 let mut output = Vec::new();
2500 run_github(
2501 CloudGithubCommand::Bindings { json: false },
2502 &client,
2503 &machine::MachineKeyEnv::default(),
2504 &mut output,
2505 )
2506 .unwrap();
2507 run_projects(
2508 CloudProjectsCommand::Create {
2509 name: "My Project".into(),
2510 repo_binding_id: binding_id.into(),
2511 operation_key: "project-create-1".into(),
2512 },
2513 &client,
2514 &machine::MachineKeyEnv::default(),
2515 &mut output,
2516 )
2517 .unwrap();
2518 let requests = transport.requests();
2519 assert_eq!(requests.len(), 3);
2520 assert_eq!(requests[0].path, "/api/integrations/github/bindings");
2521 assert_eq!(requests[1].path, "/api/integrations/github/bindings");
2522 assert_eq!(requests[2].path, "/api/projects");
2523 assert_eq!(requests[2].method, HttpMethod::Post);
2524 assert_eq!(
2525 serde_json::from_slice::<serde_json::Value>(requests[2].body.as_ref().unwrap()).unwrap(),
2526 json!({
2527 "name": "My Project", "defaultMode": "chat", "chatFilesystem": "optional_scratch",
2528 "repoBindingId": binding_id, "operationKey": "project-create-1",
2529 })
2530 );
2531 assert!(
2532 requests
2533 .iter()
2534 .all(|request| request.bearer.as_deref() == Some("access-secret"))
2535 );
2536 let output = String::from_utf8(output).unwrap();
2537 assert!(output.contains("owner/repo — github:acct-123:987:owner/repo (bound)"));
2538 assert!(output.contains("GitHub repository: owner/repo"));
2539 assert!(output.contains("agents bind-project AGENT project-github"));
2540 assert!(!output.contains("access-secret"));
2541 }
2542
2543 #[test]
2544 fn account_github_project_setup_refuses_missing_or_inactive_bindings_before_write() {
2545 let (secrets, _) = test_secrets();
2546 CloudClient::new(
2547 &FakeTransport::new(vec![]),
2548 &secrets,
2549 "default",
2550 DEFAULT_API_BASE,
2551 )
2552 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2553 .unwrap();
2554 let command = |repo_binding_id: &str| CloudProjectsCommand::Create {
2555 name: "My Project".into(),
2556 repo_binding_id: repo_binding_id.into(),
2557 operation_key: "project-create-1".into(),
2558 };
2559 let missing = FakeTransport::new(vec![response(200, json!({ "bindings": [] }))]);
2560 let error = run_projects(
2561 command("github:missing:repo"),
2562 &CloudClient::new(&missing, &secrets, "default", DEFAULT_API_BASE),
2563 &machine::MachineKeyEnv::default(),
2564 &mut Vec::new(),
2565 )
2566 .unwrap_err();
2567 assert!(error.to_string().contains("not connected to this account"));
2568 assert_eq!(missing.requests().len(), 1);
2569
2570 let empty = FakeTransport::new(vec![response(200, json!({ "bindings": [] }))]);
2571 let mut output = Vec::new();
2572 run_github(
2573 CloudGithubCommand::Bindings { json: false },
2574 &CloudClient::new(&empty, &secrets, "default", DEFAULT_API_BASE),
2575 &machine::MachineKeyEnv::default(),
2576 &mut output,
2577 )
2578 .unwrap();
2579 assert!(
2580 String::from_utf8(output)
2581 .unwrap()
2582 .contains("No GitHub repositories are connected")
2583 );
2584
2585 let revoked = FakeTransport::new(vec![response(
2586 200,
2587 json!({ "bindings": [{
2588 "id": "github:revoked:repo", "provider": "github", "repo": "owner/repo",
2589 "status": "revoked", "installationId": "987",
2590 }] }),
2591 )]);
2592 let error = run_projects(
2593 command("github:revoked:repo"),
2594 &CloudClient::new(&revoked, &secrets, "default", DEFAULT_API_BASE),
2595 &machine::MachineKeyEnv::default(),
2596 &mut Vec::new(),
2597 )
2598 .unwrap_err();
2599 assert!(error.to_string().contains("unavailable for a Project"));
2600 assert_eq!(revoked.requests().len(), 1);
2601 }
2602
2603 #[test]
2604 fn account_github_setup_reports_unattached_api_and_rejects_wrong_project_receipt() {
2605 let (secrets, _) = test_secrets();
2606 CloudClient::new(
2607 &FakeTransport::new(vec![]),
2608 &secrets,
2609 "default",
2610 DEFAULT_API_BASE,
2611 )
2612 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2613 .unwrap();
2614 let unavailable = FakeTransport::new(vec![response(
2615 503,
2616 json!({ "code": "control_plane_not_attached" }),
2617 )]);
2618 let error = run_github(
2619 CloudGithubCommand::Bindings { json: false },
2620 &CloudClient::new(&unavailable, &secrets, "default", DEFAULT_API_BASE),
2621 &machine::MachineKeyEnv::default(),
2622 &mut Vec::new(),
2623 )
2624 .unwrap_err();
2625 assert!(
2626 error
2627 .to_string()
2628 .contains("GitHub repository bindings are unavailable")
2629 );
2630
2631 let machine_only = FakeTransport::new(vec![]);
2632 let error = run_github(
2633 CloudGithubCommand::Bindings { json: false },
2634 &CloudClient::new(&machine_only, &secrets, "default", DEFAULT_API_BASE),
2635 &machine::MachineKeyEnv::from_raw(Some("machine-key-present")),
2636 &mut Vec::new(),
2637 )
2638 .unwrap_err();
2639 assert!(
2640 error
2641 .to_string()
2642 .contains("interactive Codewhale account login")
2643 );
2644 assert!(machine_only.requests().is_empty());
2645
2646 let binding_id = "github:acct-123:987:owner/repo";
2647 let mismatch = FakeTransport::new(vec![
2648 response(
2649 200,
2650 json!({ "bindings": [{
2651 "id": binding_id, "provider": "github", "repo": "owner/repo",
2652 "status": "bound", "installationId": "987",
2653 }] }),
2654 ),
2655 response(
2656 201,
2657 json!({ "project": {
2658 "id": "project-other", "name": "My Project",
2659 "defaultRepoProvider": "github", "defaultRepo": "someone/else",
2660 } }),
2661 ),
2662 ]);
2663 let error = run_projects(
2664 CloudProjectsCommand::Create {
2665 name: "My Project".into(),
2666 repo_binding_id: binding_id.into(),
2667 operation_key: "project-create-1".into(),
2668 },
2669 &CloudClient::new(&mismatch, &secrets, "default", DEFAULT_API_BASE),
2670 &machine::MachineKeyEnv::default(),
2671 &mut Vec::new(),
2672 )
2673 .unwrap_err();
2674 assert!(error.to_string().contains("different GitHub repository"));
2675 assert_eq!(mismatch.requests().len(), 2);
2676 }
2677
2678 #[test]
2679 fn account_agent_binding_refuses_an_unavailable_project_before_writing() {
2680 let (secrets, _) = test_secrets();
2681 let auth_transport = FakeTransport::new(vec![]);
2682 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2683 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2684 .unwrap();
2685 let transport = FakeTransport::new(vec![
2686 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
2687 response(200, json!({ "projects": [] })),
2688 ]);
2689 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2690 let error = run_agents(
2691 CloudAgentsCommand::BindProject {
2692 agent: "Whale".into(),
2693 project_id: "project-unknown".into(),
2694 },
2695 &client,
2696 &machine::MachineKeyEnv::default(),
2697 &mut Vec::new(),
2698 )
2699 .unwrap_err();
2700 assert!(error.to_string().contains("not available on this account"));
2701 assert_eq!(transport.requests().len(), 2);
2702 }
2703
2704 #[test]
2705 fn account_agent_send_refuses_closed_billing_modes_before_network() {
2706 let (secrets, _) = test_secrets();
2707 let transport = FakeTransport::new(vec![]);
2708 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2709 let thread: AgentThread =
2710 serde_json::from_value(agent_thread("thread-1", "agent-1", "Main")).unwrap();
2711 for mode in ["membership_included", "managed_wallet"] {
2712 let error = client
2713 .send_agent_turn(&thread, "Build this", mode, "message-1")
2714 .err()
2715 .expect("billing mode should fail");
2716 assert!(
2717 error
2718 .to_string()
2719 .contains("unavailable under the current launch policy")
2720 );
2721 }
2722 assert!(transport.requests().is_empty());
2723 }
2724
2725 #[test]
2726 fn account_agent_send_uses_its_only_active_conversation_regardless_of_title() {
2727 let (secrets, _) = test_secrets();
2728 let auth_transport = FakeTransport::new(vec![]);
2729 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2730 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2731 .unwrap();
2732 let transport = FakeTransport::new(vec![
2733 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
2734 response(
2735 200,
2736 json!([agent_thread("thread-1", "agent-1", "Whale Trial · main")]),
2737 ),
2738 response(
2739 202,
2740 json!({ "turn": { "id": "turn-1", "status": "pending" } }),
2741 ),
2742 ]);
2743 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2744 let mut output = Vec::new();
2745 run_agents(
2746 CloudAgentsCommand::Send {
2747 agent: "Whale".into(),
2748 prompt: "Continue".into(),
2749 thread: None,
2750 billing_mode: "byok_external".into(),
2751 operation_key: "message-1".into(),
2752 },
2753 &client,
2754 &machine::MachineKeyEnv::default(),
2755 &mut output,
2756 )
2757 .unwrap();
2758 let requests = transport.requests();
2759 assert_eq!(requests.len(), 3);
2760 assert_eq!(requests[2].path, "/v1/threads/thread-1/turns");
2761 assert!(
2762 String::from_utf8(output)
2763 .unwrap()
2764 .contains("Turn ID: turn-1")
2765 );
2766 }
2767
2768 #[test]
2769 fn account_agent_work_records_one_idempotent_request_without_allocating_compute() {
2770 let (secrets, _) = test_secrets();
2771 let auth_transport = FakeTransport::new(vec![]);
2772 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2773 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2774 .unwrap();
2775 let transport = FakeTransport::new(vec![
2776 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
2777 response(
2778 200,
2779 json!({
2780 "intent": "actionable",
2781 "work": { "id": "run-1", "agentId": "agent-1", "status": "queued", "objective": "Fix the build" },
2782 "queuedWork": []
2783 }),
2784 ),
2785 response(
2786 200,
2787 json!({ "run": { "id": "run-1", "state": "queued", "title": "Fix the build" } }),
2788 ),
2789 ]);
2790 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2791 let mut output = Vec::new();
2792 run_agents(
2793 CloudAgentsCommand::Work {
2794 agent: "Whale".into(),
2795 objective: "Fix the build".into(),
2796 message_id: "work-request-1".into(),
2797 },
2798 &client,
2799 &machine::MachineKeyEnv::default(),
2800 &mut output,
2801 )
2802 .unwrap();
2803 run_agents(
2804 CloudAgentsCommand::WorkStatus { id: "run-1".into() },
2805 &client,
2806 &machine::MachineKeyEnv::default(),
2807 &mut output,
2808 )
2809 .unwrap();
2810 let requests = transport.requests();
2811 assert_eq!(
2812 requests
2813 .iter()
2814 .map(|request| request.path.as_str())
2815 .collect::<Vec<_>>(),
2816 [
2817 "/api/agents",
2818 "/api/agents/agent-1/messages",
2819 "/api/runs/run-1"
2820 ]
2821 );
2822 assert_eq!(
2823 serde_json::from_slice::<serde_json::Value>(requests[1].body.as_ref().unwrap()).unwrap(),
2824 json!({ "messageId": "work-request-1", "text": "Fix the build" })
2825 );
2826 let shown = String::from_utf8(output).unwrap();
2827 assert!(shown.contains("Work ID: run-1"));
2828 assert!(shown.contains("Work is recorded, not started."));
2829 assert!(shown.contains("work-quote run-1 --operation-key"));
2830 }
2831
2832 #[test]
2833 fn account_agent_send_allows_an_unbound_agent_conversation() {
2834 let (secrets, _) = test_secrets();
2835 let auth_transport = FakeTransport::new(vec![]);
2836 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2837 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2838 .unwrap();
2839 let mut unbound_agent = agent("agent-1", "Whale");
2840 unbound_agent["projectId"] = json!("");
2841 let mut default_project_thread = agent_thread("thread-1", "agent-1", "Whale Trial · main");
2842 default_project_thread["projectId"] = json!("project-general");
2843 let transport = FakeTransport::new(vec![
2844 response(200, json!({ "agents": [unbound_agent] })),
2845 response(200, json!([default_project_thread])),
2846 response(
2847 202,
2848 json!({ "turn": { "id": "turn-1", "status": "pending" } }),
2849 ),
2850 ]);
2851 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2852 let mut output = Vec::new();
2853 run_agents(
2854 CloudAgentsCommand::Send {
2855 agent: "Whale".into(),
2856 prompt: "Continue".into(),
2857 thread: None,
2858 billing_mode: "byok_external".into(),
2859 operation_key: "message-1".into(),
2860 },
2861 &client,
2862 &machine::MachineKeyEnv::default(),
2863 &mut output,
2864 )
2865 .unwrap();
2866 assert_eq!(transport.requests()[2].path, "/v1/threads/thread-1/turns");
2867 assert!(
2868 String::from_utf8(output)
2869 .unwrap()
2870 .contains("Turn ID: turn-1")
2871 );
2872 }
2873
2874 #[test]
2875 fn account_agent_send_refuses_a_conversation_from_a_previous_project() {
2876 let (secrets, _) = test_secrets();
2877 let auth_transport = FakeTransport::new(vec![]);
2878 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2879 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2880 .unwrap();
2881 let mut rebound = agent("agent-1", "Whale");
2882 rebound["projectId"] = json!("project-new");
2883 let transport = FakeTransport::new(vec![
2884 response(200, json!({ "agents": [rebound] })),
2885 response(
2886 200,
2887 json!({ "thread": agent_thread("thread-1", "agent-1", "Main") }),
2888 ),
2889 ]);
2890 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2891 let error = run_agents(
2892 CloudAgentsCommand::Send {
2893 agent: "Whale".into(),
2894 prompt: "Continue".into(),
2895 thread: Some("thread-1".into()),
2896 billing_mode: "byok_external".into(),
2897 operation_key: "message-1".into(),
2898 },
2899 &client,
2900 &machine::MachineKeyEnv::default(),
2901 &mut Vec::new(),
2902 )
2903 .unwrap_err();
2904 assert!(error.to_string().contains("different Project"));
2905 assert_eq!(transport.requests().len(), 2);
2906 }
2907
2908 #[test]
2909 fn account_agents_new_thread_omits_an_unbound_project_and_preserves_a_bound_one() {
2910 let (secrets, _) = test_secrets();
2911 let auth_transport = FakeTransport::new(vec![]);
2912 let client_session = CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE);
2913 client_session
2914 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2915 .unwrap();
2916 let new_thread = || CloudAgentsCommand::NewThread {
2917 agent: "Whale".into(),
2918 title: "Main".into(),
2919 provider: "deepseek".into(),
2920 model: "deepseek-flash".into(),
2921 operation_key: "thread-project-1".into(),
2922 };
2923
2924 let mut unbound_agent = agent("agent-1", "Whale");
2925 unbound_agent["projectId"] = json!("");
2926 let mut default_project_thread = agent_thread("thread-1", "agent-1", "Main");
2927 default_project_thread["projectId"] = json!("project-general");
2928 let unbound = FakeTransport::new(vec![
2929 response(200, json!({ "agents": [unbound_agent] })),
2930 response(200, route_catalog()),
2931 response(201, json!({ "thread": default_project_thread })),
2932 ]);
2933 let unbound_client = CloudClient::new(&unbound, &secrets, "default", DEFAULT_API_BASE);
2934 let mut unbound_output = Vec::new();
2935 run_agents(
2936 new_thread(),
2937 &unbound_client,
2938 &machine::MachineKeyEnv::default(),
2939 &mut unbound_output,
2940 )
2941 .unwrap();
2942 assert!(
2943 String::from_utf8(unbound_output)
2944 .unwrap()
2945 .contains("ID: thread-1")
2946 );
2947 assert_eq!(unbound.requests().len(), 3);
2948 let unbound_body: serde_json::Value =
2949 serde_json::from_slice(unbound.requests()[2].body.as_ref().unwrap()).unwrap();
2950 assert!(unbound_body.get("projectId").is_none());
2951
2952 let mut foreign_thread = agent_thread("thread-1", "agent-1", "Main");
2953 foreign_thread["projectId"] = json!("project-other");
2954 let foreign = FakeTransport::new(vec![
2955 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
2956 response(200, route_catalog()),
2957 response(201, json!({ "thread": foreign_thread })),
2958 ]);
2959 let foreign_client = CloudClient::new(&foreign, &secrets, "default", DEFAULT_API_BASE);
2960 let mut output = Vec::new();
2961 let error = run_agents(
2962 new_thread(),
2963 &foreign_client,
2964 &machine::MachineKeyEnv::default(),
2965 &mut output,
2966 )
2967 .unwrap_err();
2968 assert!(error.to_string().contains("different Project"));
2969 assert!(output.is_empty());
2970 let requests = foreign.requests();
2971 assert_eq!(requests.len(), 3);
2972 assert_eq!(
2973 serde_json::from_slice::<serde_json::Value>(requests[2].body.as_ref().unwrap()).unwrap()["projectId"],
2974 "project-codewhale"
2975 );
2976 }
2977
2978 #[test]
2979 fn account_agents_require_explicit_thread_when_recent_page_may_be_incomplete() {
2980 let (secrets, _) = test_secrets();
2981 let auth_transport = FakeTransport::new(vec![]);
2982 CloudClient::new(&auth_transport, &secrets, "default", DEFAULT_API_BASE)
2983 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
2984 .unwrap();
2985 let page = (0..100)
2986 .map(|index| agent_thread(&format!("thread-{index}"), "agent-1", "Main"))
2987 .collect::<Vec<_>>();
2988 let transport = FakeTransport::new(vec![
2989 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
2990 response(200, json!(page)),
2991 ]);
2992 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
2993 let error = run_agents(
2994 CloudAgentsCommand::Send {
2995 agent: "Whale".into(),
2996 prompt: "Do work".into(),
2997 thread: None,
2998 billing_mode: "byok_external".into(),
2999 operation_key: "message-100".into(),
3000 },
3001 &client,
3002 &machine::MachineKeyEnv::default(),
3003 &mut Vec::new(),
3004 )
3005 .unwrap_err();
3006 assert!(error.to_string().contains("pass --thread"));
3007 assert_eq!(transport.requests().len(), 2);
3008 assert_eq!(
3009 transport.requests()[1].path,
3010 "/v1/threads/summary?agentId=agent-1&limit=100"
3011 );
3012 }
3013
3014 #[test]
3015 fn account_agents_result_replays_only_the_selected_turn_after_cursor() {
3016 assert_eq!(validate_turn_id("turn:reply@1").unwrap(), "turn:reply@1");
3017 assert!(validate_turn_id("turn/other").is_err());
3018 let events = sse_response(&[
3019 json!({ "type": "assistant.delta", "seq": 11, "turnId": "other-turn", "payload": { "text": "Other answer" } }),
3020 json!({ "type": "assistant.delta", "seq": 12, "turnId": "turn-1", "payload": { "text": "Selected " } }),
3021 json!({ "type": "assistant.delta", "seq": 13, "turnId": "turn-1", "payload": { "text": "answer" } }),
3022 json!({ "type": "turn.completed", "seq": 14, "turnId": "turn-1", "payload": { "status": "completed" } }),
3023 ]);
3024 let result = parse_agent_turn_events(&events.body, "turn-1", 10).unwrap();
3025 assert_eq!(result.answer, "Selected answer");
3026 assert_eq!(result.status, "completed");
3027 assert_eq!(result.last_seq, 14);
3028 assert!(result.seen_turn);
3029 assert!(parse_agent_turn_events(&events.body, "turn-1", 12).is_err());
3030 }
3031
3032 #[test]
3033 fn account_agents_refuse_foreign_threads_ambiguous_main_and_machine_keys() {
3034 let (secrets, _) = test_secrets();
3035 let transport = FakeTransport::new(vec![]);
3036 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
3037 let error = run_agents(
3038 CloudAgentsCommand::List { json: false },
3039 &client,
3040 &machine::MachineKeyEnv::from_raw(Some("machine-key-present")),
3041 &mut Vec::new(),
3042 )
3043 .unwrap_err();
3044 assert!(
3045 error
3046 .to_string()
3047 .contains("interactive Codewhale account login")
3048 );
3049 assert!(transport.requests().is_empty());
3050 assert!(client.create_agent("Whale", None, "invalid/key").is_err());
3051 assert!(transport.requests().is_empty());
3052
3053 client
3054 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
3055 .unwrap();
3056 let foreign = FakeTransport::new(vec![
3057 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3058 response(
3059 200,
3060 json!({ "thread": agent_thread("thread-2", "agent-2", "Main") }),
3061 ),
3062 ]);
3063 let foreign_client = CloudClient::new(&foreign, &secrets, "default", DEFAULT_API_BASE);
3064 let error = run_agents(
3065 CloudAgentsCommand::Send {
3066 agent: "Whale".into(),
3067 prompt: "Do work".into(),
3068 thread: Some("thread-2".into()),
3069 billing_mode: "byok_external".into(),
3070 operation_key: "message-2".into(),
3071 },
3072 &foreign_client,
3073 &machine::MachineKeyEnv::default(),
3074 &mut Vec::new(),
3075 )
3076 .unwrap_err();
3077 assert!(error.to_string().contains("does not belong"));
3078 assert_eq!(foreign.requests().len(), 2);
3079
3080 let ambiguous = FakeTransport::new(vec![
3081 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3082 response(
3083 200,
3084 json!([
3085 agent_thread("thread-1", "agent-1", "Main"),
3086 agent_thread("thread-2", "agent-1", "Main")
3087 ]),
3088 ),
3089 ]);
3090 let ambiguous_client = CloudClient::new(&ambiguous, &secrets, "default", DEFAULT_API_BASE);
3091 let error = run_agents(
3092 CloudAgentsCommand::Send {
3093 agent: "Whale".into(),
3094 prompt: "Do work".into(),
3095 thread: None,
3096 billing_mode: "byok_external".into(),
3097 operation_key: "message-3".into(),
3098 },
3099 &ambiguous_client,
3100 &machine::MachineKeyEnv::default(),
3101 &mut Vec::new(),
3102 )
3103 .unwrap_err();
3104 assert!(error.to_string().contains("several Main conversations"));
3105 assert_eq!(ambiguous.requests().len(), 2);
3106 }
3107
3108 #[test]
3109 fn account_agents_do_not_claim_a_turn_when_runtime_is_unattached() {
3110 let (secrets, _) = test_secrets();
3111 let transport = FakeTransport::new(vec![
3112 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3113 response(
3114 200,
3115 json!({ "thread": agent_thread("thread-1", "agent-1", "Main") }),
3116 ),
3117 response(
3118 409,
3119 json!({ "error": { "code": "chat_runtime_not_attached" } }),
3120 ),
3121 ]);
3122 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
3123 client
3124 .save_auth(auth("access-secret", "refresh-secret", "acct-123"))
3125 .unwrap();
3126 let mut output = Vec::new();
3127 let error = run_agents(
3128 CloudAgentsCommand::Send {
3129 agent: "Whale".into(),
3130 prompt: "Do work".into(),
3131 thread: Some("thread-1".into()),
3132 billing_mode: "byok_external".into(),
3133 operation_key: "message-4".into(),
3134 },
3135 &client,
3136 &machine::MachineKeyEnv::default(),
3137 &mut output,
3138 )
3139 .unwrap_err();
3140 assert!(error.to_string().contains("chat_runtime_not_attached"));
3141 assert!(output.is_empty());
3142 assert_eq!(transport.requests().len(), 3);
3143 }
3144
3145 // ---- Account Work journey (work.rs) -------------------------------------
3146
3147 const WORK_ID: &str = "123e4567-e89b-42d3-a456-426614174000";
3148
3149 enum Scripted {
3150 Reply(CloudResponse),
3151 Unreachable,
3152 }
3153
3154 /// A transport that can also lose the connection, which `FakeTransport`
3155 /// cannot: a lost reply is the case mutating commands must report honestly.
3156 struct ScriptedTransport {
3157 steps: Mutex<VecDeque<Scripted>>,
3158 requests: Mutex<Vec<CloudRequest>>,
3159 }
3160
3161 impl ScriptedTransport {
3162 fn new(steps: Vec<Scripted>) -> Self {
3163 Self {
3164 steps: Mutex::new(steps.into()),
3165 requests: Mutex::new(Vec::new()),
3166 }
3167 }
3168
3169 fn requests(&self) -> std::sync::MutexGuard<'_, Vec<CloudRequest>> {
3170 self.requests.lock().unwrap()
3171 }
3172 }
3173
3174 impl CloudTransport for ScriptedTransport {
3175 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
3176 self.requests.lock().unwrap().push(request);
3177 match self.steps.lock().unwrap().pop_front() {
3178 Some(Scripted::Reply(reply)) => Ok(reply),
3179 Some(Scripted::Unreachable) => Err(CloudTransportError::new(
3180 "could not reach the Codewhale service",
3181 std::io::Error::other("connection reset"),
3182 )
3183 .into()),
3184 None => Err(anyhow!("scripted transport exhausted")),
3185 }
3186 }
3187 }
3188
3189 fn reply(status: u16, body: serde_json::Value) -> Scripted {
3190 Scripted::Reply(response(status, body))
3191 }
3192
3193 fn signed_in() -> Secrets {
3194 let (secrets, _) = test_secrets();
3195 AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE)
3196 .save(auth("access-secret", "refresh-secret", "acct-123"))
3197 .unwrap();
3198 secrets
3199 }
3200
3201 /// Run one agents subcommand and return its result with everything it printed.
3202 fn run_agent_command(
3203 transport: &impl CloudTransport,
3204 secrets: &Secrets,
3205 argv: &[&str],
3206 ) -> (Result<()>, String) {
3207 let CloudCommand::Agents(agents) = command(argv) else {
3208 panic!("expected an agents command");
3209 };
3210 let client = CloudClient::new(transport, secrets, "default", DEFAULT_API_BASE);
3211 let mut output = Vec::new();
3212 let result = run_agents(
3213 agents.command,
3214 &client,
3215 &machine::MachineKeyEnv::default(),
3216 &mut output,
3217 );
3218 (result, String::from_utf8(output).unwrap())
3219 }
3220
3221 fn run_github_command(
3222 transport: &impl CloudTransport,
3223 secrets: &Secrets,
3224 machine: &machine::MachineKeyEnv,
3225 argv: &[&str],
3226 ) -> (Result<()>, String) {
3227 let CloudCommand::Github(github) = command(argv) else {
3228 panic!("expected a github command");
3229 };
3230 let client = CloudClient::new(transport, secrets, "default", DEFAULT_API_BASE);
3231 let mut output = Vec::new();
3232 let result = run_github(github.command, &client, machine, &mut output);
3233 (result, String::from_utf8(output).unwrap())
3234 }
3235
3236 fn chain(err: &anyhow::Error) -> String {
3237 format!("{err:#}")
3238 }
3239
3240 fn body_of(request: &CloudRequest) -> serde_json::Value {
3241 serde_json::from_slice(request.body.as_ref().expect("request body")).unwrap()
3242 }
3243
3244 fn work_message(intent: &str, extra: serde_json::Value, work: serde_json::Value) -> CloudResponse {
3245 let mut body = json!({ "intent": intent, "work": work });
3246 for (key, value) in extra.as_object().unwrap() {
3247 body[key] = value.clone();
3248 }
3249 response(200, body)
3250 }
3251
3252 fn work_item(status: &str) -> serde_json::Value {
3253 json!({ "id": "run-1", "agentId": "agent-1", "status": status, "objective": "Fix the build" })
3254 }
3255
3256 fn agents_step() -> Scripted {
3257 reply(200, json!({ "agents": [agent("agent-1", "Whale")] }))
3258 }
3259
3260 fn work_command(message_id: &str) -> Vec<String> {
3261 [
3262 "codewhale",
3263 "account",
3264 "agents",
3265 "work",
3266 "Whale",
3267 "Fix the build",
3268 "--message-id",
3269 message_id,
3270 ]
3271 .map(String::from)
3272 .to_vec()
3273 }
3274
3275 fn run_work(transport: &impl CloudTransport, secrets: &Secrets) -> (Result<()>, String) {
3276 let argv = work_command("work-1");
3277 let argv = argv.iter().map(String::as_str).collect::<Vec<_>>();
3278 run_agent_command(transport, secrets, &argv)
3279 }
3280
3281 fn work_run(state: &str) -> serde_json::Value {
3282 json!({ "run": {
3283 "id": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
3284 "title": "Fix the build", "state": state, "repo": "octo-org/app", "repoProvider": "github"
3285 } })
3286 }
3287
3288 fn projects_step() -> Scripted {
3289 reply(
3290 200,
3291 json!({ "projects": [{
3292 "id": "project-codewhale", "name": "Codewhale",
3293 "defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
3294 }] }),
3295 )
3296 }
3297
3298 fn boat_quote() -> serde_json::Value {
3299 json!({
3300 "runner": {},
3301 "quote": { "sku": "boat-small", "adapter": "boat", "target": "eu", "pricingStatus": "provider_trial" },
3302 "disclosure": {
3303 "funding": "provider_trial", "customerCreditsChargedUsd": 0,
3304 "providerCostEstimateUsd": 0.0207, "sandboxTargetRegion": "eu",
3305 "euPlacementConsentRequired": true,
3306 "modelInference": { "billing": "byok_external" },
3307 "computerTime": { "estimatedSeconds": 300 }
3308 },
3309 "confirmation": {
3310 "token": "tok.abc-123_DEF", "expiresAt": "2026-09-30T12:00:00.000Z", "workRunId": WORK_ID
3311 },
3312 "confirmCopy": {
3313 "title": "Run this five-minute Boat trial Work?",
3314 "body": "Boat trial credit covers its computer time.\u{1b}[31m",
3315 "confirmLabel": "Start trial Work"
3316 }
3317 })
3318 }
3319
3320 /// POST /api/cloud-sessions replies `{ "session": {...} }`.
3321 fn cloud_session() -> serde_json::Value {
3322 json!({ "session": {
3323 "id": format!("session_{WORK_ID}"),
3324 "run": { "id": WORK_ID, "state": "planning" },
3325 "attempt": { "status": "accepted" },
3326 "sandbox": { "provider": "boat", "providerId": "bx_abcd1234", "status": "running" },
3327 "sandboxTargetRegion": "eu",
3328 "quote": { "customerCreditsChargedUsd": 0 },
3329 "initialTurn": { "status": "queued" }
3330 } })
3331 }
3332
3333 const QUOTE_ARGV: [&str; 7] = [
3334 "codewhale",
3335 "account",
3336 "agents",
3337 "work-quote",
3338 WORK_ID,
3339 "--operation-key",
3340 "launch-1",
3341 ];
3342 const LAUNCH_ARGV: [&str; 10] = [
3343 "codewhale",
3344 "account",
3345 "agents",
3346 "work-launch",
3347 WORK_ID,
3348 "--operation-key",
3349 "launch-1",
3350 "--confirmation",
3351 "tok.abc-123_DEF",
3352 "--confirm-eu-compute",
3353 ];
3354
3355 #[test]
3356 fn work_journey_commands_parse_and_are_documented_in_help() {
3357 assert!(matches!(
3358 command(&["codewhale", "account", "agents", "work-cancel", "run-1"]),
3359 CloudCommand::Agents(CloudAgentsArgs {
3360 command: CloudAgentsCommand::WorkCancel {
3361 queue: None,
3362 reason: None,
3363 ..
3364 }
3365 })
3366 ));
3367 assert!(matches!(
3368 command(&[
3369 "codewhale",
3370 "account",
3371 "agents",
3372 "work-cancel",
3373 "run-1",
3374 "--queue",
3375 "park",
3376 "--reason",
3377 "wrong repo"
3378 ]),
3379 CloudCommand::Agents(CloudAgentsArgs {
3380 command: CloudAgentsCommand::WorkCancel {
3381 queue: Some(WorkQueueChoice::Park),
3382 reason: Some(_),
3383 ..
3384 }
3385 })
3386 ));
3387 assert!(matches!(
3388 command(&[
3389 "codewhale",
3390 "account",
3391 "agents",
3392 "work-cancel",
3393 "run-1",
3394 "--queue",
3395 "discard"
3396 ]),
3397 CloudCommand::Agents(CloudAgentsArgs {
3398 command: CloudAgentsCommand::WorkCancel {
3399 queue: Some(WorkQueueChoice::Discard),
3400 ..
3401 }
3402 })
3403 ));
3404 assert!(
3405 Cli::try_parse_from([
3406 "codewhale",
3407 "account",
3408 "agents",
3409 "work-cancel",
3410 "run-1",
3411 "--queue",
3412 "keep"
3413 ])
3414 .is_err()
3415 );
3416 assert!(matches!(
3417 command(&[
3418 "codewhale",
3419 "account",
3420 "agents",
3421 "work-result",
3422 "run-1",
3423 "--json"
3424 ]),
3425 CloudCommand::Agents(CloudAgentsArgs {
3426 command: CloudAgentsCommand::WorkResult { json: true, .. }
3427 })
3428 ));
3429 assert!(matches!(
3430 command(&QUOTE_ARGV),
3431 CloudCommand::Agents(CloudAgentsArgs {
3432 command: CloudAgentsCommand::WorkQuote { .. }
3433 })
3434 ));
3435 assert!(matches!(
3436 command(&LAUNCH_ARGV),
3437 CloudCommand::Agents(CloudAgentsArgs {
3438 command: CloudAgentsCommand::WorkLaunch {
3439 confirm_eu_compute: true,
3440 ..
3441 }
3442 })
3443 ));
3444 assert!(matches!(
3445 command(&[
3446 "codewhale",
3447 "account",
3448 "agents",
3449 "work-launch",
3450 WORK_ID,
3451 "--operation-key",
3452 "k",
3453 "--confirmation",
3454 "t"
3455 ]),
3456 CloudCommand::Agents(CloudAgentsArgs {
3457 command: CloudAgentsCommand::WorkLaunch {
3458 confirm_eu_compute: false,
3459 ..
3460 }
3461 })
3462 ));
3463 for missing_key in [
3464 vec!["codewhale", "account", "agents", "work-quote", WORK_ID],
3465 vec![
3466 "codewhale",
3467 "account",
3468 "agents",
3469 "work-launch",
3470 WORK_ID,
3471 "--confirmation",
3472 "t",
3473 ],
3474 vec![
3475 "codewhale",
3476 "account",
3477 "agents",
3478 "work-launch",
3479 WORK_ID,
3480 "--operation-key",
3481 "k",
3482 ],
3483 ] {
3484 assert!(Cli::try_parse_from(missing_key).is_err());
3485 }
3486 assert!(matches!(
3487 command(&["codewhale", "account", "github", "bind", "octo-org/app"]),
3488 CloudCommand::Github(CloudGithubArgs {
3489 command: CloudGithubCommand::Bind {
3490 installation_id: None,
3491 ..
3492 }
3493 })
3494 ));
3495 assert!(matches!(
3496 command(&[
3497 "codewhale",
3498 "account",
3499 "github",
3500 "bind",
3501 "octo-org/app",
3502 "--installation-id",
3503 "987"
3504 ]),
3505 CloudCommand::Github(CloudGithubArgs {
3506 command: CloudGithubCommand::Bind {
3507 installation_id: Some(_),
3508 ..
3509 }
3510 })
3511 ));
3512
3513 let help = |argv: &[&str]| Cli::try_parse_from(argv).unwrap_err().to_string();
3514 let agents_help = help(&["codewhale", "account", "agents", "--help"]);
3515 for name in [
3516 "work-cancel",
3517 "work-result",
3518 "work-quote",
3519 "work-launch",
3520 "work-status",
3521 ] {
3522 assert!(agents_help.contains(name), "agents --help must list {name}");
3523 }
3524 assert!(
3525 help(&["codewhale", "account", "agents", "work-launch", "--help"])
3526 .contains("--confirm-eu-compute")
3527 );
3528 assert!(help(&["codewhale", "account", "agents", "work-cancel", "--help"]).contains("--queue"));
3529 assert!(help(&["codewhale", "account", "github", "--help"]).contains("bind"));
3530 }
3531
3532 #[test]
3533 fn work_reports_what_the_agent_actually_did_with_the_message() {
3534 // A control verb acts on existing Work: it must say which action was
3535 // applied and never claim new Work was recorded.
3536 let secrets = signed_in();
3537 let transport = FakeTransport::new(vec![
3538 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3539 work_message(
3540 "control",
3541 json!({ "reason": "control_verb:stop", "controlAction": "stop", "confident": true }),
3542 work_item("canceled"),
3543 ),
3544 ]);
3545 let (result, output) = run_work(&transport, &secrets);
3546 result.unwrap();
3547 assert!(output.contains("Intent: control"));
3548 assert!(output.contains("Applied control action: stop"));
3549 assert!(output.contains("Reason: control_verb:stop"));
3550 assert!(output.contains("Status: canceled"));
3551 assert!(!output.contains("Work is recorded"));
3552 assert!(!output.contains("work-quote"));
3553
3554 let transport = FakeTransport::new(vec![
3555 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3556 work_message(
3557 "correction",
3558 json!({ "reason": "correction_marker", "confident": true }),
3559 work_item("running"),
3560 ),
3561 ]);
3562 let (result, output) = run_work(&transport, &secrets);
3563 result.unwrap();
3564 assert!(output.contains("edited the objective of active Work"));
3565 assert!(output.contains("No new Work was created"));
3566 assert!(!output.contains("Work is recorded"));
3567
3568 let transport = FakeTransport::new(vec![
3569 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3570 work_message(
3571 "informational",
3572 json!({ "reason": "no_action_signal", "confident": false, "suggestion": "the build is red" }),
3573 json!(null),
3574 ),
3575 ]);
3576 let (result, output) = run_work(&transport, &secrets);
3577 result.unwrap();
3578 assert!(output.contains("No Work was created and nothing was changed."));
3579 assert!(output.contains("Suggestion: the build is red"));
3580 assert!(output.contains("not confident"));
3581 assert!(!output.contains("Work is recorded"));
3582
3583 let transport = FakeTransport::new(vec![
3584 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3585 work_message(
3586 "actionable",
3587 json!({ "reason": "imperative_verb", "confident": true }),
3588 work_item("queued"),
3589 ),
3590 ]);
3591 let (result, output) = run_work(&transport, &secrets);
3592 result.unwrap();
3593 assert!(output.contains("Work is recorded, not started."));
3594 assert!(output.contains("work-quote run-1 --operation-key"));
3595 assert!(output.contains("Message ID: work-1"));
3596
3597 // A control action with no Work record cannot be confirmed, so it fails.
3598 let transport = FakeTransport::new(vec![
3599 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3600 work_message("control", json!({ "controlAction": "stop" }), json!(null)),
3601 ]);
3602 let (result, output) = run_work(&transport, &secrets);
3603 assert!(chain(&result.unwrap_err()).contains("returned no Work record"));
3604 assert!(output.is_empty());
3605 }
3606
3607 #[test]
3608 fn work_transport_and_server_failures_point_at_the_same_message_id() {
3609 let secrets = signed_in();
3610 for lost in [
3611 vec![agents_step(), Scripted::Unreachable],
3612 vec![
3613 agents_step(),
3614 reply(503, json!({ "code": "runtime_unavailable" })),
3615 ],
3616 // A truncated 2xx: the service acted, so the same hint applies.
3617 vec![
3618 agents_step(),
3619 Scripted::Reply(CloudResponse {
3620 status: 201,
3621 body: b"{\"intent\":".to_vec(),
3622 retry_after: None,
3623 }),
3624 ],
3625 ] {
3626 let transport = ScriptedTransport::new(lost);
3627 let (result, output) = run_work(&transport, &secrets);
3628 let message = chain(&result.unwrap_err());
3629 assert!(
3630 message.contains("may or may not have reached Codewhale"),
3631 "{message}"
3632 );
3633 assert!(message.contains("--message-id work-1"), "{message}");
3634 assert!(
3635 message.contains("never creates a second Work for the same instruction"),
3636 "{message}"
3637 );
3638 assert!(output.is_empty());
3639 assert!(!message.contains("access-secret") && !message.contains("refresh-secret"));
3640 }
3641 // A definitive refusal is not an unknown outcome and gives no replay hint.
3642 let transport = ScriptedTransport::new(vec![
3643 agents_step(),
3644 reply(422, json!({ "code": "conversation_message_id_required" })),
3645 ]);
3646 let (result, _) = run_work(&transport, &secrets);
3647 let message = chain(&result.unwrap_err());
3648 assert!(message.contains("conversation_message_id_required"));
3649 assert!(!message.contains("may or may not"));
3650 }
3651
3652 #[test]
3653 fn work_cancel_sends_choices_and_reports_each_outcome_honestly() {
3654 let secrets = signed_in();
3655 let argv = [
3656 "codewhale",
3657 "account",
3658 "agents",
3659 "work-cancel",
3660 "run-1",
3661 "--queue",
3662 "park",
3663 "--reason",
3664 "wrong repo",
3665 ];
3666 let transport = FakeTransport::new(vec![response(
3667 200,
3668 json!({
3669 "run": { "id": "run-1", "state": "canceled" },
3670 "command": { "type": "run.control", "action": "cancel" },
3671 "promptQueue": { "queuedCount": 0 }
3672 }),
3673 )]);
3674 let (result, output) = run_agent_command(&transport, &secrets, &argv);
3675 result.unwrap();
3676 let requests = transport.requests();
3677 assert_eq!(requests[0].method, HttpMethod::Post);
3678 assert_eq!(requests[0].path, "/api/runs/run-1/cancel");
3679 assert_eq!(
3680 body_of(&requests[0]),
3681 json!({ "reason": "wrong repo", "queue": "park" })
3682 );
3683 assert_eq!(requests[0].bearer.as_deref(), Some("access-secret"));
3684 assert!(output.contains("Status: canceled"));
3685 assert!(output.contains("Work canceled."));
3686 assert!(output.contains("Queued prompts: parked (recorded)."));
3687 assert!(!output.contains("access-secret"));
3688 drop(requests);
3689
3690 // The runtime has been asked to stop but has not confirmed.
3691 let transport = FakeTransport::new(vec![response(
3692 200,
3693 json!({ "command": { "type": "run.control" }, "queued": { "seq": 4 } }),
3694 )]);
3695 let (result, output) = run_agent_command(
3696 &transport,
3697 &secrets,
3698 &["codewhale", "account", "agents", "work-cancel", "run-1"],
3699 );
3700 result.unwrap();
3701 assert!(output.contains("Cancel requested. The runtime has not confirmed the stop yet."));
3702 assert!(!output.contains("Work canceled."));
3703 assert_eq!(body_of(&transport.requests()[0]), json!({}));
3704
3705 // Already final is success, and says where to read the outcome.
3706 let transport = FakeTransport::new(vec![response(
3707 409,
3708 json!({ "code": "run_control_terminal", "message": "Run run-1 is already completed." }),
3709 )]);
3710 let (result, output) = run_agent_command(
3711 &transport,
3712 &secrets,
3713 &["codewhale", "account", "agents", "work-cancel", "run-1"],
3714 );
3715 result.unwrap();
3716 assert!(output.contains("already final"));
3717 assert!(output.contains("work-result run-1"));
3718
3719 // Queued prompts need an explicit choice; nothing was cancelled.
3720 let transport = FakeTransport::new(vec![response(
3721 422,
3722 json!({ "code": "run_prompt_queue_choice_required" }),
3723 )]);
3724 let (result, _) = run_agent_command(
3725 &transport,
3726 &secrets,
3727 &["codewhale", "account", "agents", "work-cancel", "run-1"],
3728 );
3729 let message = chain(&result.unwrap_err());
3730 assert!(
3731 message.contains("--queue discard or --queue park"),
3732 "{message}"
3733 );
3734 assert!(message.contains("was not cancelled"));
3735
3736 // A different definitive refusal is reported without an unknown-outcome claim.
3737 let transport = FakeTransport::new(vec![response(
3738 409,
3739 json!({ "code": "run_control_transition_invalid" }),
3740 )]);
3741 let (result, _) = run_agent_command(
3742 &transport,
3743 &secrets,
3744 &["codewhale", "account", "agents", "work-cancel", "run-1"],
3745 );
3746 let message = chain(&result.unwrap_err());
3747 assert!(message.contains("run_control_transition_invalid"));
3748 assert!(!message.contains("unknown"));
3749 }
3750
3751 #[test]
3752 fn work_cancel_with_a_lost_reply_is_an_unknown_outcome() {
3753 let secrets = signed_in();
3754 for lost in [
3755 Scripted::Unreachable,
3756 reply(502, json!({ "code": "bad_gateway" })),
3757 Scripted::Reply(CloudResponse {
3758 status: 200,
3759 body: b"not json".to_vec(),
3760 retry_after: None,
3761 }),
3762 ] {
3763 let transport = ScriptedTransport::new(vec![lost]);
3764 let (result, output) = run_agent_command(
3765 &transport,
3766 &secrets,
3767 &["codewhale", "account", "agents", "work-cancel", "run-1"],
3768 );
3769 let message = chain(&result.unwrap_err());
3770 assert!(message.contains("cancel outcome is unknown"), "{message}");
3771 assert!(message.contains("work-status run-1"), "{message}");
3772 assert!(output.is_empty());
3773 }
3774 // A bad id or reason never leaves the machine.
3775 let transport = ScriptedTransport::new(vec![]);
3776 let (result, _) = run_agent_command(
3777 &transport,
3778 &secrets,
3779 &["codewhale", "account", "agents", "work-cancel", "../x"],
3780 );
3781 result.unwrap_err();
3782 let (result, _) = run_agent_command(
3783 &transport,
3784 &secrets,
3785 &[
3786 "codewhale",
3787 "account",
3788 "agents",
3789 "work-cancel",
3790 "run-1",
3791 "--reason",
3792 "bad\u{7}bell",
3793 ],
3794 );
3795 result.unwrap_err();
3796 assert!(transport.requests().is_empty());
3797 }
3798
3799 fn result_envelope() -> serde_json::Value {
3800 json!({ "result": {
3801 "run": { "id": "run-1", "title": "Fix the\u{1b}[31m build", "state": "completed", "workspaceId": "ws-1", "projectId": "p-1" },
3802 "status": "ready",
3803 "summary": { "text": "Fixed the failing test.\nSecond line", "source": "run.result" },
3804 "repository": {
3805 "name": "octo-org/app", "branch": "codewhale/run-1", "revision": "0123456789abcdef",
3806 "pullRequest": { "url": "https://github.com/octo-org/app/pull/42", "number": 42, "state": "draft" }
3807 },
3808 "changes": { "files": [], "fileCount": 2, "additions": 10, "deletions": 3, "evidence": "recorded" },
3809 "checks": [
3810 { "name": "cargo test", "status": "passed", "passed": true },
3811 { "name": "lint", "status": "failed", "passed": false }
3812 ],
3813 "findings": [{ "id": "f1", "severity": "low", "title": "Style nit" }],
3814 "artifacts": [{ "id": "a1", "name": "patch.diff", "status": "stored", "contentType": "text/x-diff", "size": 512 }],
3815 "readiness": { "prReady": false, "blockers": ["checks_failed"] },
3816 "nextAction": { "kind": "open_github", "label": "Open in GitHub", "url": "https://github.com/octo-org/app/pull/42" },
3817 "modelRoute": { "provider": "deepseek", "model": "deepseek-flash" },
3818 "boatUsage": {
3819 "providerSeconds": 212, "providerListPriceDollars": 0.00106,
3820 "customerCreditsChargedUsd": 0, "funding": "provider_trial", "running": false,
3821 "cleanupConfirmed": true
3822 },
3823 "receipt": { "eventsThroughSeq": 57, "source": "run.result" }
3824 } })
3825 }
3826
3827 fn attempts_envelope() -> serde_json::Value {
3828 json!({
3829 "workId": "run-1", "attemptCount": 2,
3830 "attempts": [
3831 { "id": "attempt_a", "sequence": 1, "kind": "launch", "status": "failed", "errorCode": "boat_task_stop_unconfirmed" },
3832 { "id": "attempt_b", "sequence": 2, "kind": "recovery", "status": "settled", "errorCode": "" }
3833 ]
3834 })
3835 }
3836
3837 #[test]
3838 fn work_result_summarises_evidence_pr_route_and_boat_usage() {
3839 let secrets = signed_in();
3840 let transport = FakeTransport::new(vec![
3841 response(200, result_envelope()),
3842 response(200, attempts_envelope()),
3843 ]);
3844 let (result, output) = run_agent_command(
3845 &transport,
3846 &secrets,
3847 &["codewhale", "account", "agents", "work-result", "run-1"],
3848 );
3849 result.unwrap();
3850 let requests = transport.requests();
3851 assert_eq!(requests[0].path, "/api/runs/run-1/result");
3852 assert_eq!(requests[1].path, "/api/runs/run-1/attempts");
3853 assert!(
3854 requests
3855 .iter()
3856 .all(|request| request.method == HttpMethod::Get)
3857 );
3858 for expected in [
3859 "State: completed",
3860 "Result: ready",
3861 "Objective: Fix the[31m build",
3862 "Summary: Fixed the failing test.Second line",
3863 "Changes: 2 file(s), +10 -3 (recorded)",
3864 "Checks: 1 passed of 2",
3865 "not passed: lint (failed)",
3866 "Findings: 1",
3867 "low: Style nit",
3868 "patch.diff (stored, text/x-diff, 512 bytes)",
3869 "Branch: codewhale/run-1",
3870 "Draft PR: https://github.com/octo-org/app/pull/42",
3871 "Blockers: checks_failed",
3872 "Model route: deepseek/deepseek-flash",
3873 "Boat usage:",
3874 "Provider seconds: 212",
3875 "Provider list price: $0.0011",
3876 "Codewhale credits charged: $0",
3877 "Funding: provider_trial",
3878 "Provider VM: stopped",
3879 "Provider cleanup: confirmed",
3880 "Attempts: 2",
3881 "#1 launch failed (error boat_task_stop_unconfirmed)",
3882 "#2 recovery settled",
3883 "Evidence through event 57",
3884 ] {
3885 assert!(
3886 output.contains(expected),
3887 "missing {expected:?} in:\n{output}"
3888 );
3889 }
3890 assert!(!output.contains('\u{1b}'));
3891 assert!(!output.contains("access-secret") && !output.contains("refresh-secret"));
3892 }
3893
3894 #[test]
3895 fn work_result_never_shows_a_link_that_is_not_a_github_pull_request_and_survives_missing_parts() {
3896 let secrets = signed_in();
3897 for hostile in [
3898 "https://github.com/octo-org/app/pull/42/files",
3899 "https://github.com/other-org/app/pull/42",
3900 "https://evil.example/octo-org/app/pull/42",
3901 "javascript:alert(1)",
3902 "https://github.com/octo-org/app/pull/042",
3903 "https://github.com/octo-org/app/issues/42",
3904 ] {
3905 let mut envelope = result_envelope();
3906 envelope["result"]["repository"]["pullRequest"]["url"] = json!(hostile);
3907 let transport = FakeTransport::new(vec![
3908 response(200, envelope),
3909 response(503, json!({ "code": "work_attempt_lineage_unavailable" })),
3910 ]);
3911 let (result, output) = run_agent_command(
3912 &transport,
3913 &secrets,
3914 &["codewhale", "account", "agents", "work-result", "run-1"],
3915 );
3916 result.unwrap();
3917 assert!(!output.contains("Draft PR:"), "{hostile}");
3918 assert!(!output.contains(hostile), "{hostile}");
3919 assert!(output.contains("not a GitHub pull request"), "{hostile}");
3920 // Attempt lineage failing does not hide the result itself.
3921 assert!(output.contains("Attempts: unavailable"));
3922 }
3923 // A running Work with nothing recorded says it is not final and invents nothing.
3924 let transport = FakeTransport::new(vec![
3925 response(
3926 200,
3927 json!({ "result": {
3928 "run": { "id": "run-1", "state": "running" }, "status": "in_progress",
3929 "changes": { "evidence": "unavailable" }, "checks": [], "artifacts": []
3930 } }),
3931 ),
3932 response(200, json!({ "attempts": [] })),
3933 ]);
3934 let (result, output) = run_agent_command(
3935 &transport,
3936 &secrets,
3937 &["codewhale", "account", "agents", "work-result", "run-1"],
3938 );
3939 result.unwrap();
3940 assert!(output.contains("still running; nothing below is final"));
3941 assert!(output.contains("Changes: not verified (evidence: unavailable)"));
3942 assert!(output.contains("Checks: none recorded"));
3943 assert!(output.contains("Artifacts: none"));
3944 assert!(output.contains("Draft PR: none"));
3945 assert!(output.contains("Model route: not reported"));
3946 assert!(!output.contains("Boat usage:"));
3947 // Another run's record is refused.
3948 let transport = FakeTransport::new(vec![response(
3949 200,
3950 json!({ "result": { "run": { "id": "run-2", "state": "completed" } } }),
3951 )]);
3952 let (result, _) = run_agent_command(
3953 &transport,
3954 &secrets,
3955 &["codewhale", "account", "agents", "work-result", "run-1"],
3956 );
3957 assert!(chain(&result.unwrap_err()).contains("different Work result"));
3958 }
3959
3960 #[test]
3961 fn work_result_json_prints_the_raw_result_and_attempts() {
3962 let secrets = signed_in();
3963 let transport = FakeTransport::new(vec![
3964 response(200, result_envelope()),
3965 response(200, attempts_envelope()),
3966 ]);
3967 let (result, output) = run_agent_command(
3968 &transport,
3969 &secrets,
3970 &[
3971 "codewhale",
3972 "account",
3973 "agents",
3974 "work-result",
3975 "run-1",
3976 "--json",
3977 ],
3978 );
3979 result.unwrap();
3980 let printed: serde_json::Value = serde_json::from_str(&output).unwrap();
3981 assert_eq!(printed["result"], result_envelope());
3982 assert_eq!(printed["attempts"], attempts_envelope());
3983 assert!(!output.contains("access-secret"));
3984 }
3985
3986 #[test]
3987 fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosure() {
3988 let secrets = signed_in();
3989 let transport = FakeTransport::new(vec![
3990 response(200, work_run("queued")),
3991 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
3992 response(
3993 200,
3994 json!({ "projects": [{
3995 "id": "project-codewhale", "name": "Codewhale",
3996 "defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
3997 }] }),
3998 ),
3999 response(200, boat_quote()),
4000 ]);
4001 let (result, output) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4002 result.unwrap();
4003 let requests = transport.requests();
4004 assert_eq!(
4005 requests
4006 .iter()
4007 .map(|request| request.path.as_str())
4008 .collect::<Vec<_>>(),
4009 [
4010 format!("/api/runs/{WORK_ID}").as_str(),
4011 "/api/agents",
4012 "/api/projects",
4013 "/api/sandbox/launch-quote"
4014 ]
4015 );
4016 assert_eq!(requests[3].method, HttpMethod::Post);
4017 assert_eq!(
4018 body_of(&requests[3]),
4019 json!({
4020 "workRunId": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
4021 "repo": "octo-org/app", "provider": "github", "prompt": "Fix the build",
4022 "runnerKind": "hosted", "sandboxSku": "boat-small", "estimatedSeconds": 300,
4023 "modelProvider": "deepseek", "model": "deepseek-flash",
4024 "billingMode": "byok_external", "computeRegion": "eu",
4025 "sandboxTargetRegion": "eu", "crossRegionSandboxOptIn": true,
4026 "operationKey": "launch-1"
4027 })
4028 );
4029 for expected in [
4030 "Nothing has started and nothing is charged.",
4031 "Repository: octo-org/app",
4032 "deepseek/deepseek-flash",
4033 "boat-small on Boat in the EU, up to 300 seconds",
4034 "Funding: provider trial; Codewhale credits charged: $0",
4035 "Provider cost estimate: $0.0207",
4036 "EU placement",
4037 "Run this five-minute Boat trial Work?",
4038 "Boat trial credit covers its computer time.[31m",
4039 "Confirmation: tok.abc-123_DEF",
4040 "Operation key: launch-1",
4041 &format!(
4042 "work-launch {WORK_ID} --operation-key launch-1 --confirmation tok.abc-123_DEF --confirm-eu-compute"
4043 ),
4044 ] {
4045 assert!(
4046 output.contains(expected),
4047 "missing {expected:?} in:\n{output}"
4048 );
4049 }
4050 assert!(!output.contains('\u{1b}'));
4051 assert!(!output.contains("access-secret") && !output.contains("refresh-secret"));
4052 }
4053
4054 #[test]
4055 fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_for() {
4056 let secrets = signed_in();
4057 // Not queued: only the Work is read.
4058 let transport = FakeTransport::new(vec![response(200, work_run("running"))]);
4059 let (result, _) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4060 assert!(chain(&result.unwrap_err()).contains("only queued Work can be quoted"));
4061 assert_eq!(transport.requests().len(), 1);
4062
4063 // The Agent moved to another Project after the Work was created.
4064 let mut moved = agent("agent-1", "Whale");
4065 moved["projectId"] = json!("project-other");
4066 let transport = FakeTransport::new(vec![
4067 response(200, work_run("queued")),
4068 response(200, json!({ "agents": [moved] })),
4069 ]);
4070 let (result, _) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4071 assert!(chain(&result.unwrap_err()).contains("no longer bound to this Work's Project"));
4072
4073 // A non-GitHub Work cannot use the Boat trial.
4074 let mut cnb = work_run("queued");
4075 cnb["run"]["repoProvider"] = json!("cnb");
4076 let transport = FakeTransport::new(vec![response(200, cnb)]);
4077 let (result, _) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4078 assert!(chain(&result.unwrap_err()).contains("GitHub repositories only"));
4079
4080 // A quote that would charge Codewhale credits (or is not the EU trial)
4081 // yields no confirmation and no launch command.
4082 for tamper in [
4083 |quote: &mut serde_json::Value| {
4084 quote["disclosure"]["customerCreditsChargedUsd"] = json!(0.4)
4085 },
4086 |quote: &mut serde_json::Value| quote["disclosure"]["funding"] = json!("membership"),
4087 |quote: &mut serde_json::Value| quote["quote"]["sku"] = json!("boat-large"),
4088 |quote: &mut serde_json::Value| {
4089 quote["disclosure"]["sandboxTargetRegion"] = json!("us-west")
4090 },
4091 |quote: &mut serde_json::Value| quote["quote"]["adapter"] = json!("other"),
4092 |quote: &mut serde_json::Value| {
4093 quote["disclosure"]["computerTime"]["estimatedSeconds"] = json!(301)
4094 },
4095 |quote: &mut serde_json::Value| quote["disclosure"]["computerTime"] = json!({}),
4096 |quote: &mut serde_json::Value| {
4097 quote["disclosure"]["modelInference"]["billing"] = json!("managed_wallet")
4098 },
4099 ] {
4100 let mut quote = boat_quote();
4101 tamper(&mut quote);
4102 let transport = FakeTransport::new(vec![
4103 response(200, work_run("queued")),
4104 response(200, json!({ "agents": [agent("agent-1", "Whale")] })),
4105 response(
4106 200,
4107 json!({ "projects": [{
4108 "id": "project-codewhale", "name": "Codewhale",
4109 "defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
4110 }] }),
4111 ),
4112 response(200, quote),
4113 ]);
4114 let (result, output) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4115 assert!(chain(&result.unwrap_err()).contains("Refusing to print a confirmation"));
4116 assert!(!output.contains("tok.abc-123_DEF"));
4117 }
4118
4119 // Server refusals keep their code and gain the next step.
4120 let transport = ScriptedTransport::new(vec![
4121 reply(200, work_run("queued")),
4122 agents_step(),
4123 projects_step(),
4124 reply(404, json!({ "code": "boat_work_trial_unavailable" })),
4125 ]);
4126 let (result, _) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4127 let message = chain(&result.unwrap_err());
4128 assert!(message.contains("boat_work_trial_unavailable"));
4129 assert!(message.contains("not available for this account"));
4130
4131 // The printed retry command must not carry remote shell metacharacters.
4132 for token in ["tok.$(command)", "tok.`command`", "tok.abc;command"] {
4133 let mut quote = boat_quote();
4134 quote["confirmation"]["token"] = json!(token);
4135 let transport = ScriptedTransport::new(vec![
4136 reply(200, work_run("queued")),
4137 agents_step(),
4138 projects_step(),
4139 reply(200, quote),
4140 ]);
4141 let (result, output) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
4142 assert!(chain(&result.unwrap_err()).contains("unusable launch confirmation"));
4143 assert!(!output.contains(token));
4144 }
4145 }
4146
4147 #[test]
4148 fn work_confirmation_stdin_is_exposed_without_a_token_in_argv() {
4149 let parsed = command(&[
4150 "codewhale",
4151 "account",
4152 "agents",
4153 "work-launch",
4154 WORK_ID,
4155 "--operation-key",
4156 "launch-stdin",
4157 "--confirmation",
4158 "-",
4159 "--confirm-eu-compute",
4160 ]);
4161 assert!(matches!(parsed, CloudCommand::Agents(CloudAgentsArgs {
4162 command: CloudAgentsCommand::WorkLaunch { confirmation, confirm_eu_compute: true, .. }
4163 }) if confirmation == "-"));
4164 let help = Cli::try_parse_from(["codewhale", "account", "agents", "work-launch", "--help"])
4165 .unwrap_err()
4166 .to_string();
4167 assert!(help.contains("bounded piped stdin"));
4168 }
4169
4170 #[test]
4171 fn work_confirmation_stdin_accepts_a_single_proof_with_optional_line_ending() {
4172 for input in [
4173 "consent.abc-123_DEF",
4174 "consent.abc-123_DEF\n",
4175 "consent.abc-123_DEF\r\n",
4176 ] {
4177 assert_eq!(
4178 work::read_confirmation(input.as_bytes()).unwrap(),
4179 "consent.abc-123_DEF"
4180 );
4181 }
4182 let maximum = "A".repeat(4096);
4183 assert_eq!(
4184 work::read_confirmation(format!("{maximum}\r\n").as_bytes()).unwrap(),
4185 maximum
4186 );
4187 }
4188
4189 #[test]
4190 fn work_confirmation_stdin_rejects_untrusted_input_without_echo_and_bounds_reads() {
4191 for input in [
4192 "",
4193 "consent SECRET",
4194 "consent.$(SECRET)",
4195 "consent.SECRET\nsecond",
4196 "consent.SECRET\n\n",
4197 "consent.SECRET\r",
4198 ] {
4199 let error = chain(&work::read_confirmation(input.as_bytes()).unwrap_err());
4200 assert!(!error.contains("SECRET"));
4201 }
4202 assert!(
4203 work::read_confirmation(&[0xff][..])
4204 .unwrap_err()
4205 .to_string()
4206 .contains("UTF-8")
4207 );
4208 let mut huge = std::io::Cursor::new(vec![b'A'; 1_000_000]);
4209 let error = chain(&work::read_confirmation(&mut huge).unwrap_err());
4210 assert!(error.contains("too long"));
4211 assert_eq!(
4212 huge.position(),
4213 4099,
4214 "read is bounded even if the pipe contains arbitrarily much input"
4215 );
4216 }
4217
4218 #[test]
4219 fn work_confirmation_stdin_proof_reaches_only_the_confirmed_request() {
4220 let secrets = signed_in();
4221 let transport = ScriptedTransport::new(vec![
4222 reply(200, work_run("queued")),
4223 agents_step(),
4224 projects_step(),
4225 reply(201, cloud_session()),
4226 ]);
4227 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
4228 let proof = work::read_confirmation(&b"consent.private-proof\n"[..]).unwrap();
4229 let mut output = Vec::new();
4230 work::launch(&client, &mut output, WORK_ID, "launch-stdin", &proof, true).unwrap();
4231 assert_eq!(
4232 body_of(&transport.requests()[3])["launchQuoteConfirmation"],
4233 proof
4234 );
4235 let output = String::from_utf8(output).unwrap();
4236 assert!(!output.contains(&proof));
4237 assert!(!output.contains("access-secret") && !output.contains("refresh-secret"));
4238 }
4239
4240 #[test]
4241 fn work_launch_requires_eu_consent_and_sends_the_confirmed_c5_request() {
4242 let secrets = signed_in();
4243 // No consent flag: nothing is read or sent.
4244 let transport = ScriptedTransport::new(vec![]);
4245 let without_consent = &LAUNCH_ARGV[..LAUNCH_ARGV.len() - 1];
4246 let (result, output) = run_agent_command(&transport, &secrets, without_consent);
4247 let message = chain(&result.unwrap_err());
4248 assert!(message.contains("--confirm-eu-compute"));
4249 assert!(message.contains("Nothing was sent"));
4250 assert!(output.is_empty());
4251 assert!(transport.requests().is_empty());
4252
4253 for token in ["tok.$(command)", "tok.`command`", "tok.abc;command"] {
4254 let mut unsafe_token = LAUNCH_ARGV.to_vec();
4255 unsafe_token[8] = token;
4256 let (result, _) = run_agent_command(&transport, &secrets, &unsafe_token);
4257 assert!(chain(&result.unwrap_err()).contains("Confirmation must be"));
4258 assert!(transport.requests().is_empty());
4259 }
4260
4261 // A malformed confirmation is refused locally too.
4262 let mut spaced = LAUNCH_ARGV.to_vec();
4263 spaced[8] = "tok en";
4264 let (result, _) = run_agent_command(&transport, &secrets, &spaced);
4265 result.unwrap_err();
4266 assert!(transport.requests().is_empty());
4267
4268 let transport = ScriptedTransport::new(vec![
4269 reply(200, work_run("queued")),
4270 agents_step(),
4271 projects_step(),
4272 reply(201, cloud_session()),
4273 ]);
4274 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4275 result.unwrap();
4276 let requests = transport.requests();
4277 assert_eq!(requests[3].path, "/api/cloud-sessions");
4278 assert_eq!(requests[3].method, HttpMethod::Post);
4279 assert_eq!(
4280 body_of(&requests[3]),
4281 json!({
4282 "workRunId": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
4283 "repo": "octo-org/app", "provider": "github", "prompt": "Fix the build",
4284 "runnerKind": "hosted", "sandboxSku": "boat-small", "estimatedSeconds": 300,
4285 "modelProvider": "deepseek", "model": "deepseek-flash",
4286 "billingMode": "byok_external", "computeRegion": "eu",
4287 "sandboxTargetRegion": "eu", "crossRegionSandboxOptIn": true,
4288 "operationKey": "launch-1",
4289 "launchQuoteConfirmation": "tok.abc-123_DEF",
4290 "customerEuPlacementConsent": true
4291 })
4292 );
4293 for expected in [
4294 "Work launched on bounded Boat trial compute.",
4295 &format!("Work ID: {WORK_ID}"),
4296 "Status: planning",
4297 "Computer: boat (running)",
4298 "Compute region: eu",
4299 "Codewhale credits charged: $0",
4300 "Attempt: accepted",
4301 "First turn: queued (not complete yet)",
4302 "Operation key: launch-1",
4303 &format!("work-cancel {WORK_ID}"),
4304 ] {
4305 assert!(
4306 output.contains(expected),
4307 "missing {expected:?} in:\n{output}"
4308 );
4309 }
4310 assert!(!output.contains("access-secret") && !output.contains("refresh-secret"));
4311 }
4312
4313 #[test]
4314 fn work_launch_is_replay_safe_after_the_work_has_already_started() {
4315 // Once the first launch was accepted the Work is no longer queued. The same
4316 // command must still reach the operation ledger (which replays the receipt)
4317 // instead of dying on a local "not queued" check.
4318 let secrets = signed_in();
4319 let transport = ScriptedTransport::new(vec![
4320 reply(200, work_run("running")),
4321 reply(200, cloud_session()),
4322 ]);
4323 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4324 result.unwrap();
4325 let requests = transport.requests();
4326 assert_eq!(requests.len(), 2);
4327 assert_eq!(requests[1].path, "/api/cloud-sessions");
4328 assert_eq!(body_of(&requests[1])["operationKey"], "launch-1");
4329 assert!(output.contains("Work launched"));
4330 }
4331
4332 #[test]
4333 fn work_launch_reports_unknown_and_refused_outcomes_without_guessing() {
4334 let secrets = signed_in();
4335 let steps = |last: Scripted| vec![reply(200, work_run("running")), last];
4336 for lost in [
4337 Scripted::Unreachable,
4338 reply(504, json!({ "code": "gateway_timeout" })),
4339 reply(409, json!({ "code": "boat_task_outcome_unknown" })),
4340 reply(409, json!({ "code": "boat_task_receipt_invalid" })),
4341 reply(
4342 409,
4343 json!({ "code": "provider_receipt_conflict", "reconciliationRequired": true }),
4344 ),
4345 Scripted::Reply(CloudResponse {
4346 status: 201,
4347 body: b"{".to_vec(),
4348 retry_after: None,
4349 }),
4350 ] {
4351 let transport = ScriptedTransport::new(steps(lost));
4352 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4353 let message = chain(&result.unwrap_err());
4354 assert!(message.contains("launch outcome is unknown"), "{message}");
4355 assert!(
4356 message.contains("a computer may already be running"),
4357 "{message}"
4358 );
4359 assert!(
4360 message.contains(&format!("work-status {WORK_ID}")),
4361 "{message}"
4362 );
4363 assert!(
4364 message.contains(&format!("work-cancel {WORK_ID}")),
4365 "{message}"
4366 );
4367 assert!(
4368 message.contains("same --operation-key and --confirmation"),
4369 "{message}"
4370 );
4371 assert!(output.is_empty());
4372 assert!(!message.contains("tok.abc-123_DEF") && !message.contains("access-secret"));
4373 }
4374 let transport = ScriptedTransport::new(steps(reply(
4375 409,
4376 json!({ "code": "boat_task_replay_expired" }),
4377 )));
4378 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4379 let message = chain(&result.unwrap_err());
4380 assert!(message.contains("launch outcome is unknown"));
4381 assert!(message.contains("operator reconciliation is required"));
4382 assert!(message.contains("Do not submit a new operation key"));
4383 assert!(!message.contains("re-run this exact command"));
4384 assert!(output.is_empty());
4385
4386 let transport =
4387 ScriptedTransport::new(steps(reply(409, json!({ "code": "launch_in_progress" }))));
4388 let (result, _) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4389 let message = chain(&result.unwrap_err());
4390 assert!(message.contains("still being recorded"), "{message}");
4391 assert!(!message.contains("outcome is unknown"));
4392
4393 let transport = ScriptedTransport::new(steps(reply(
4394 422,
4395 json!({ "code": "hosted_launch_quote_expired" }),
4396 )));
4397 let (result, _) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4398 let message = chain(&result.unwrap_err());
4399 assert!(message.contains("hosted_launch_quote_expired"));
4400 assert!(message.contains("`work-quote` again with the same --operation-key"));
4401 assert!(!message.contains("outcome is unknown"));
4402
4403 let transport = ScriptedTransport::new(steps(reply(
4404 409,
4405 json!({ "code": "launch_operation_mismatch" }),
4406 )));
4407 let (result, _) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4408 let message = chain(&result.unwrap_err());
4409 assert!(message.contains("Check `work-status` and `work-result`"));
4410 assert!(message.contains("never replace the key to retry an unknown launch"));
4411
4412 // A reply about another Work is never presented as this launch.
4413 let mut other = cloud_session();
4414 other["session"]["run"]["id"] = json!("22222222-2222-4222-8222-222222222222");
4415 let transport = ScriptedTransport::new(steps(reply(201, other)));
4416 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4417 assert!(chain(&result.unwrap_err()).contains("launched a different Work"));
4418 assert!(!output.contains("Work launched"));
4419
4420 // A 2xx without the `session` document (or with a non-object one) means the
4421 // service acted but the client cannot say how: an unknown outcome, never a
4422 // success and never "a different Work".
4423 for missing in [
4424 json!({}),
4425 json!({ "session": "nope" }),
4426 cloud_session()["session"].clone(),
4427 ] {
4428 let transport = ScriptedTransport::new(steps(reply(201, missing)));
4429 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4430 let message = chain(&result.unwrap_err());
4431 assert!(message.contains("launch outcome is unknown"), "{message}");
4432 assert!(message.contains("without a session"), "{message}");
4433 assert!(!message.contains("different Work"), "{message}");
4434 assert!(!output.contains("Work launched"));
4435 }
4436
4437 for missing in [
4438 json!({ "session": {} }),
4439 json!({ "session": { "run": {} } }),
4440 ] {
4441 let transport = ScriptedTransport::new(steps(reply(201, missing)));
4442 let (result, output) = run_agent_command(&transport, &secrets, &LAUNCH_ARGV);
4443 let message = chain(&result.unwrap_err());
4444 assert!(message.contains("launch outcome is unknown"), "{message}");
4445 assert!(message.contains("without a Work ID"), "{message}");
4446 assert!(!message.contains("different Work"), "{message}");
4447 assert!(!output.contains("Work launched"));
4448 }
4449 }
4450
4451 fn binding(id: &str, repo: &str, installation: &str, status: &str) -> serde_json::Value {
4452 json!({ "id": id, "provider": "github", "repo": repo, "status": status, "installationId": installation })
4453 }
4454
4455 #[test]
4456 fn github_bind_connects_a_repository_through_the_known_installation() {
4457 let secrets = signed_in();
4458 let transport = ScriptedTransport::new(vec![
4459 reply(
4460 200,
4461 json!({ "bindings": [binding("github:acct-123:987:octo-org/other", "octo-org/other", "987", "connected")] }),
4462 ),
4463 reply(
4464 201,
4465 json!({ "binding": binding("github:acct-123:987:octo-org/app", "octo-org/app", "987", "connected") }),
4466 ),
4467 ]);
4468 let (result, output) = run_github_command(
4469 &transport,
4470 &secrets,
4471 &machine::MachineKeyEnv::default(),
4472 &["codewhale", "account", "github", "bind", "octo-org/app"],
4473 );
4474 result.unwrap();
4475 let requests = transport.requests();
4476 assert_eq!(requests[0].path, "/api/integrations/github/bindings");
4477 assert_eq!(requests[0].method, HttpMethod::Get);
4478 assert_eq!(requests[1].method, HttpMethod::Post);
4479 assert_eq!(requests[1].path, "/api/integrations/github/bindings");
4480 assert_eq!(
4481 body_of(&requests[1]),
4482 json!({ "installationId": "987", "repo": "octo-org/app" })
4483 );
4484 assert!(
4485 output.contains("Connected octo-org/app: github:acct-123:987:octo-org/app (connected)")
4486 );
4487 assert!(output.contains("--repo-binding-id github:acct-123:987:octo-org/app"));
4488
4489 // An explicit installation ID is used as given.
4490 let transport = ScriptedTransport::new(vec![
4491 reply(200, json!({ "bindings": [] })),
4492 reply(
4493 200,
4494 json!({ "binding": binding("github:acct-123:55:octo-org/app", "octo-org/app", "55", "connected") }),
4495 ),
4496 ]);
4497 let (result, _) = run_github_command(
4498 &transport,
4499 &secrets,
4500 &machine::MachineKeyEnv::default(),
4501 &[
4502 "codewhale",
4503 "account",
4504 "github",
4505 "bind",
4506 "octo-org/app",
4507 "--installation-id",
4508 "55",
4509 ],
4510 );
4511 result.unwrap();
4512 assert_eq!(body_of(&transport.requests()[1])["installationId"], "55");
4513 }
4514
4515 #[test]
4516 fn github_bind_refuses_ambiguity_bad_input_and_lost_replies() {
4517 let secrets = signed_in();
4518 let plain = machine::MachineKeyEnv::default();
4519 let bind = ["codewhale", "account", "github", "bind", "octo-org/app"];
4520
4521 // Already connected: no write at all.
4522 let transport = ScriptedTransport::new(vec![reply(
4523 200,
4524 json!({ "bindings": [binding("github:acct-123:987:octo-org/app", "Octo-Org/App", "987", "connected")] }),
4525 )]);
4526 let (result, output) = run_github_command(&transport, &secrets, &plain, &bind);
4527 result.unwrap();
4528 assert!(output.contains("is already connected"));
4529 assert_eq!(transport.requests().len(), 1);
4530
4531 // Several installations: the user must choose.
4532 let transport = ScriptedTransport::new(vec![reply(
4533 200,
4534 json!({ "bindings": [
4535 binding("b1", "octo-org/one", "111", "connected"),
4536 binding("b2", "octo-org/two", "222", "connected")
4537 ] }),
4538 )]);
4539 let (result, _) = run_github_command(&transport, &secrets, &plain, &bind);
4540 let message = chain(&result.unwrap_err());
4541 assert!(message.contains("several GitHub App installations (111, 222)"));
4542 assert_eq!(transport.requests().len(), 1);
4543
4544 // No installation known at all.
4545 let transport = ScriptedTransport::new(vec![reply(200, json!({ "bindings": [] }))]);
4546 let (result, _) = run_github_command(&transport, &secrets, &plain, &bind);
4547 assert!(chain(&result.unwrap_err()).contains("--installation-id"));
4548
4549 // Malformed repository and installation fail before any request.
4550 let transport = ScriptedTransport::new(vec![]);
4551 for argv in [
4552 vec!["codewhale", "account", "github", "bind", "octo-org"],
4553 vec![
4554 "codewhale",
4555 "account",
4556 "github",
4557 "bind",
4558 "octo-org/app/extra",
4559 ],
4560 vec!["codewhale", "account", "github", "bind", "../app"],
4561 vec![
4562 "codewhale",
4563 "account",
4564 "github",
4565 "bind",
4566 "octo-org/app",
4567 "--installation-id",
4568 "12x",
4569 ],
4570 ] {
4571 run_github_command(&transport, &secrets, &plain, &argv)
4572 .0
4573 .unwrap_err();
4574 }
4575 assert!(transport.requests().is_empty());
4576
4577 // The server refusing (not the owner, no repository access) keeps its code.
4578 let transport = ScriptedTransport::new(vec![
4579 reply(
4580 200,
4581 json!({ "bindings": [binding("b1", "octo-org/one", "111", "connected")] }),
4582 ),
4583 reply(
4584 403,
4585 json!({ "code": "github_repository_user_access_denied" }),
4586 ),
4587 ]);
4588 let (result, _) = run_github_command(&transport, &secrets, &plain, &bind);
4589 let message = chain(&result.unwrap_err());
4590 assert!(message.contains("github_repository_user_access_denied"));
4591 assert!(!message.contains("outcome is unknown"));
4592
4593 // A lost reply says to check the list before repeating.
4594 for last in [
4595 Scripted::Unreachable,
4596 reply(
4597 503,
4598 json!({ "code": "github_repository_bindings_unavailable" }),
4599 ),
4600 ] {
4601 let transport = ScriptedTransport::new(vec![
4602 reply(
4603 200,
4604 json!({ "bindings": [binding("b1", "octo-org/one", "111", "connected")] }),
4605 ),
4606 last,
4607 ]);
4608 let (result, _) = run_github_command(&transport, &secrets, &plain, &bind);
4609 let message = chain(&result.unwrap_err());
4610 assert!(message.contains("bind outcome is unknown"));
4611 assert!(message.contains("account github bindings"));
4612 }
4613
4614 // A binding for a different repository is not accepted as this one.
4615 let transport = ScriptedTransport::new(vec![
4616 reply(
4617 200,
4618 json!({ "bindings": [binding("b1", "octo-org/one", "111", "connected")] }),
4619 ),
4620 reply(
4621 201,
4622 json!({ "binding": binding("b9", "octo-org/elsewhere", "111", "connected") }),
4623 ),
4624 ]);
4625 let (result, output) = run_github_command(&transport, &secrets, &plain, &bind);
4626 assert!(chain(&result.unwrap_err()).contains("different repository"));
4627 assert!(!output.contains("Connected"));
4628
4629 // A machine key never binds repositories.
4630 let transport = ScriptedTransport::new(vec![]);
4631 let (result, _) = run_github_command(&transport, &secrets, &machine_env(), &bind);
4632 assert!(chain(&result.unwrap_err()).contains("interactive Codewhale account login"));
4633 assert!(transport.requests().is_empty());
4634 }
4635
4636 #[test]
4637 fn new_thread_refuses_a_route_the_live_catalog_does_not_list() {
4638 let secrets = signed_in();
4639 let argv = |provider: &'static str, model: &'static str| {
4640 vec![
4641 "codewhale",
4642 "account",
4643 "agents",
4644 "new-thread",
4645 "Whale",
4646 "--provider",
4647 provider,
4648 "--model",
4649 model,
4650 "--operation-key",
4651 "thread-1",
4652 ]
4653 };
4654 for (provider, model, expect) in [
4655 (
4656 "deepseek",
4657 "deepseek-v9",
4658 "catalog lists these models for `deepseek`: deepseek-v4-pro, deepseek-flash",
4659 ),
4660 (
4661 "openai",
4662 "gpt-x",
4663 "is not a hosted provider in the catalog. Known providers: deepseek",
4664 ),
4665 ] {
4666 let transport = ScriptedTransport::new(vec![agents_step(), reply(200, route_catalog())]);
4667 let (result, output) = run_agent_command(&transport, &secrets, &argv(provider, model));
4668 let message = chain(&result.unwrap_err());
4669 assert!(
4670 message.contains("live Codewhale model catalog"),
4671 "{message}"
4672 );
4673 assert!(message.contains("GET /api/model-providers"), "{message}");
4674 assert!(message.contains(expect), "{message}");
4675 assert!(output.is_empty());
4676 // Only reads happened: no conversation was created.
4677 let requests = transport.requests();
4678 assert_eq!(
4679 requests
4680 .iter()
4681 .map(|request| request.path.as_str())
4682 .collect::<Vec<_>>(),
4683 ["/api/agents", "/api/model-providers"]
4684 );
4685 assert!(
4686 requests
4687 .iter()
4688 .all(|request| request.method == HttpMethod::Get)
4689 );
4690 }
4691 // A runtime-only row is not a hosted route even when it lists the model.
4692 let mut catalog = route_catalog();
4693 catalog["providers"][0]["connectionAvailable"] = json!(false);
4694 let transport = ScriptedTransport::new(vec![agents_step(), reply(200, catalog)]);
4695 let (result, _) = run_agent_command(&transport, &secrets, &argv("deepseek", "deepseek-flash"));
4696 result.unwrap_err();
4697 assert_eq!(transport.requests().len(), 2);
4698 }
4699
4700 #[test]
4701 fn new_thread_sends_the_canonical_route_id_when_given_a_runtime_alias() {
4702 // `xiaomi-mimo` is the runtime name of the catalog row `xiaomi`; the server
4703 // stores `xiaomi`, so the preflight must resolve to it before creating.
4704 let secrets = signed_in();
4705 let argv = [
4706 "codewhale",
4707 "account",
4708 "agents",
4709 "new-thread",
4710 "Whale",
4711 "--provider",
4712 "xiaomi-mimo",
4713 "--model",
4714 "mimo-v2",
4715 "--operation-key",
4716 "thread-1",
4717 ];
4718 let catalog = json!({ "providers": [{
4719 "id": "xiaomi",
4720 "runtimeProvider": "xiaomi-mimo",
4721 "connectionAvailable": true,
4722 "models": ["mimo-v2"]
4723 }] });
4724 let mut created = agent_thread("thread-1", "agent-1", "Main");
4725 created["modelProvider"] = json!("xiaomi");
4726 created["model"] = json!("mimo-v2");
4727 let transport = ScriptedTransport::new(vec![
4728 agents_step(),
4729 reply(200, catalog),
4730 reply(201, json!({ "thread": created })),
4731 ]);
4732 let (result, output) = run_agent_command(&transport, &secrets, &argv);
4733 result.unwrap();
4734 let requests = transport.requests();
4735 assert_eq!(body_of(&requests[2])["modelProvider"], "xiaomi");
4736 assert!(output.contains("Model: xiaomi/mimo-v2"), "{output}");
4737 }
4738
4739 #[test]
4740 fn new_thread_asserts_the_created_route_equals_the_requested_one() {
4741 let secrets = signed_in();
4742 let argv = [
4743 "codewhale",
4744 "account",
4745 "agents",
4746 "new-thread",
4747 "Whale",
4748 "--operation-key",
4749 "thread-1",
4750 ];
4751 let mut swapped = agent_thread("thread-1", "agent-1", "Main");
4752 swapped["model"] = json!("deepseek-v4-pro");
4753 let transport = ScriptedTransport::new(vec![
4754 agents_step(),
4755 reply(200, route_catalog()),
4756 reply(201, json!({ "thread": swapped })),
4757 ]);
4758 let (result, output) = run_agent_command(&transport, &secrets, &argv);
4759 let message = chain(&result.unwrap_err());
4760 assert!(
4761 message
4762 .contains("deepseek/deepseek-v4-pro instead of the requested deepseek/deepseek-flash"),
4763 "{message}"
4764 );
4765 assert!(message.contains("Do not send to it"));
4766 assert!(output.is_empty());
4767
4768 let mut other_provider = agent_thread("thread-1", "agent-1", "Main");
4769 other_provider["modelProvider"] = json!("openrouter");
4770 let transport = ScriptedTransport::new(vec![
4771 agents_step(),
4772 reply(200, route_catalog()),
4773 reply(201, json!({ "thread": other_provider })),
4774 ]);
4775 let (result, _) = run_agent_command(&transport, &secrets, &argv);
4776 assert!(chain(&result.unwrap_err()).contains("instead of the requested"));
4777
4778 // The defaults are the DeepSeek V4.1 Flash route and are accepted when honored.
4779 let transport = ScriptedTransport::new(vec![
4780 agents_step(),
4781 reply(200, route_catalog()),
4782 reply(
4783 201,
4784 json!({ "thread": agent_thread("thread-1", "agent-1", "Main") }),
4785 ),
4786 ]);
4787 let (result, output) = run_agent_command(&transport, &secrets, &argv);
4788 result.unwrap();
4789 assert!(output.contains("Model: deepseek/deepseek-flash"));
4790 }
4791
4792 #[test]
4793 fn account_errors_stay_typed_so_callers_can_tell_refusals_from_unknown_outcomes() {
4794 let refusal = response_error(&response(
4795 422,
4796 json!({ "code": "boat_work_trial_seconds_invalid" }),
4797 ));
4798 assert_eq!(
4799 refusal.to_string(),
4800 "Codewhale account request failed (HTTP 422, code boat_work_trial_seconds_invalid)"
4801 );
4802 assert!(!outcome_unknown(&refusal));
4803 for status in [500, 502, 503, 504, 408] {
4804 assert!(
4805 outcome_unknown(&response_error(&response(status, json!({})))),
4806 "{status}"
4807 );
4808 }
4809 for status in [400, 401, 404, 409, 422, 429] {
4810 assert!(
4811 !outcome_unknown(&response_error(&response(status, json!({})))),
4812 "{status}"
4813 );
4814 }
4815 let lost: anyhow::Error = CloudTransportError::new(
4816 "could not reach the Codewhale service",
4817 std::io::Error::other("reset"),
4818 )
4819 .into();
4820 assert!(outcome_unknown(&lost));
4821 // Context layers do not hide the typed cause.
4822 assert!(outcome_unknown(&lost.context("while cancelling")));
4823 assert!(!outcome_unknown(&anyhow!("Not signed in")));
4824 // A hostile code is dropped, never echoed.
4825 let hostile = response_error(&response(422, json!({ "code": "bad code\u{1b}[31m" })));
4826 assert_eq!(
4827 hostile.to_string(),
4828 "Codewhale account request failed (HTTP 422)"
4829 );
4830 }
4831
4831 lines RUST