返回 CodeWhale
diagnostic_read_only.rs
根目录 / crates / tui / tests / integration / diagnostic_read_only.rs
1 //! Process-level regression coverage for read-only diagnostic commands.
2
3 use std::fs;
4 use std::path::PathBuf;
5 use std::process::{Command, Output};
6 use std::sync::{Arc, Mutex, mpsc};
7 use std::thread;
8 use std::time::Duration;
9
10 use axum::body::Bytes;
11 use axum::http::HeaderMap;
12 use axum::routing::post;
13 use axum::{Json, Router};
14 use codewhale_secrets::{FileKeyringStore, KeyringStore};
15 use tempfile::TempDir;
16
17 #[test]
18 fn doctor_text_leaves_a_sealed_home_untouched() {
19 let output = run_sealed_diagnostic(["doctor"]);
20 let stdout = String::from_utf8_lossy(&output.stdout);
21 assert!(stdout.contains("codewhale Doctor"), "stdout:\n{stdout}");
22 }
23
24 #[test]
25 fn doctor_json_leaves_a_sealed_home_untouched() {
26 let output = run_sealed_diagnostic(["doctor", "--json"]);
27 let report: serde_json::Value =
28 serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
29 panic!(
30 "doctor --json must remain machine-readable: {error}\nstdout:\n{}\nstderr:\n{}",
31 String::from_utf8_lossy(&output.stdout),
32 String::from_utf8_lossy(&output.stderr)
33 )
34 });
35 assert_eq!(report["api_connectivity"]["checked"], false);
36 }
37
38 #[test]
39 fn doctor_json_rejects_kimi_code_claude_alias_with_machine_readable_guidance() {
40 let fixture = TempDir::new().expect("fixture root");
41 let workspace = fixture.path().join("workspace");
42 let home = fixture.path().join("home");
43 let codewhale_home = fixture.path().join("isolated-codewhale-home");
44 fs::create_dir_all(&workspace).expect("workspace");
45 let config = workspace.join("kimi-invalid.toml");
46 let config_bytes = br#"provider = "moonshot"
47
48 [providers.moonshot]
49 api_key = "doctor-json-kimi-secret"
50 base_url = "https://api.kimi.com/coding/v1"
51 model = "k3[1m]"
52 "#;
53 fs::write(&config, config_bytes).expect("write invalid Kimi config");
54
55 let mut command = diagnostic_command(&workspace, &home);
56 command
57 .args([
58 "--config",
59 config.to_str().expect("config path"),
60 "doctor",
61 "--json",
62 ])
63 .env("CODEWHALE_HOME", &codewhale_home);
64 let output = command.output().expect("run invalid Kimi doctor json");
65
66 assert!(
67 !output.status.success(),
68 "invalid configuration must return nonzero\nstdout:\n{}\nstderr:\n{}",
69 String::from_utf8_lossy(&output.stdout),
70 String::from_utf8_lossy(&output.stderr)
71 );
72 let report: serde_json::Value = serde_json::from_slice(&output.stdout)
73 .expect("invalid doctor --json output must remain machine-readable");
74 assert_eq!(report["status"], "error");
75 assert_eq!(report["error"]["kind"], "config_validation");
76 let message = report["error"]["message"]
77 .as_str()
78 .expect("config error message");
79 assert!(message.contains("model = \"k3\""), "{message}");
80 assert!(message.contains("context_window = 1048576"), "{message}");
81 assert!(message.contains("plan includes 1M context"), "{message}");
82
83 let stderr = String::from_utf8_lossy(&output.stderr);
84 assert!(
85 stderr.contains("doctor configuration validation failed; see JSON output"),
86 "stderr must point only to the JSON envelope: {stderr}"
87 );
88 for stdout_only_detail in ["k3[1m]", "context_window", "plan includes 1M context"] {
89 assert!(
90 !stderr.contains(stdout_only_detail),
91 "actionable validation details belong only in redacted stdout JSON: {stderr}"
92 );
93 }
94
95 let all_output = format!(
96 "{}\n{}",
97 String::from_utf8_lossy(&output.stdout),
98 String::from_utf8_lossy(&output.stderr)
99 );
100 assert!(!all_output.contains("doctor-json-kimi-secret"));
101 assert_eq!(
102 fs::read(&config).expect("read config after doctor"),
103 config_bytes,
104 "doctor must not rewrite an invalid config"
105 );
106 assert!(!home.exists(), "doctor must not create HOME state");
107 assert!(
108 !codewhale_home.exists(),
109 "doctor must not create CODEWHALE_HOME state"
110 );
111 }
112
113 #[test]
114 fn doctor_json_parse_failure_never_enters_provider_dispatch() {
115 let fixture = TempDir::new().expect("fixture root");
116 let workspace = fixture.path().join("workspace");
117 let home = fixture.path().join("home");
118 let codewhale_home = fixture.path().join("isolated-codewhale-home");
119 fs::create_dir_all(&workspace).expect("workspace");
120 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("owned provider listener");
121 listener
122 .set_nonblocking(true)
123 .expect("nonblocking listener");
124 let endpoint = listener.local_addr().expect("provider address");
125 let config = workspace.join("malformed.toml");
126 let config_bytes = format!(
127 "provider = \"openai\"\n[providers.openai]\nbase_url = \"http://{endpoint}/v1\"\napi_key = \"doctor-malformed-secret\"\nmodel =\n"
128 );
129 fs::write(&config, &config_bytes).expect("write malformed source");
130 let output = diagnostic_command(&workspace, &home)
131 .args([
132 "--config",
133 config.to_str().expect("config path"),
134 "doctor",
135 "--json",
136 ])
137 .env("CODEWHALE_HOME", &codewhale_home)
138 .output()
139 .expect("run malformed doctor json");
140
141 assert!(!output.status.success());
142 let report: serde_json::Value =
143 serde_json::from_slice(&output.stdout).expect("machine-readable parse error");
144 assert_eq!(report["error"]["kind"], "config_validation");
145 let all_output = format!(
146 "{}\n{}",
147 String::from_utf8_lossy(&output.stdout),
148 String::from_utf8_lossy(&output.stderr)
149 );
150 assert!(!all_output.contains("doctor-malformed-secret"));
151 assert!(
152 matches!(listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock),
153 "a rejected diagnostic source must not contact its provider"
154 );
155 assert_eq!(
156 fs::read(&config).expect("source after report"),
157 config_bytes.as_bytes()
158 );
159 assert!(!home.exists(), "doctor must not create HOME state");
160 assert!(
161 !codewhale_home.exists(),
162 "doctor must not create Codewhale state"
163 );
164 }
165
166 #[test]
167 fn doctor_json_omits_untrusted_config_validation_details() {
168 let fixture = TempDir::new().expect("fixture root");
169 let workspace = fixture.path().join("workspace");
170 let home = fixture.path().join("home");
171 let codewhale_home = fixture.path().join("isolated-codewhale-home");
172 fs::create_dir_all(&workspace).expect("workspace");
173 let config = workspace.join("untrusted-invalid.toml");
174 let config_bytes = br#"provider = "doctor-untrusted-provider-secret"
175 api_key = "doctor-json-arbitrary-secret"
176 "#;
177 fs::write(&config, config_bytes).expect("write invalid config");
178
179 let mut command = diagnostic_command(&workspace, &home);
180 command
181 .args([
182 "--config",
183 config.to_str().expect("config path"),
184 "doctor",
185 "--json",
186 ])
187 .env("CODEWHALE_HOME", &codewhale_home);
188 let output = command.output().expect("run invalid doctor json");
189
190 assert!(!output.status.success());
191 let report: serde_json::Value =
192 serde_json::from_slice(&output.stdout).expect("machine-readable doctor error");
193 assert_eq!(report["error"]["kind"], "config_validation");
194 // A plain value error names the key and the valid values, never the
195 // rejected value itself (it may be a pasted secret).
196 let message = report["error"]["message"].as_str().expect("message");
197 assert!(
198 message.starts_with("Invalid provider (value not shown): expected deepseek"),
199 "{message}"
200 );
201 let fix = report["error"]["fix"].as_str().expect("fix");
202 assert!(
203 fix.starts_with("codewhale config set provider deepseek (if CODEWHALE_PROVIDER"),
204 "{fix}"
205 );
206 let all_output = format!(
207 "{}\n{}",
208 String::from_utf8_lossy(&output.stdout),
209 String::from_utf8_lossy(&output.stderr)
210 );
211 assert!(!all_output.contains("doctor-untrusted-provider-secret"));
212 assert!(!all_output.contains("doctor-json-arbitrary-secret"));
213 assert_eq!(
214 fs::read(&config).expect("read config after doctor"),
215 config_bytes
216 );
217 assert!(!home.exists());
218 assert!(!codewhale_home.exists());
219 }
220
221 #[test]
222 fn doctor_json_reports_valid_kimi_code_k3_context_override_from_runtime_route() {
223 let fixture = TempDir::new().expect("fixture root");
224 let workspace = fixture.path().join("workspace");
225 let home = fixture.path().join("home");
226 let codewhale_home = fixture.path().join("isolated-codewhale-home");
227 fs::create_dir_all(&workspace).expect("workspace");
228 let config = workspace.join("kimi-valid.toml");
229 let config_bytes = br#"provider = "moonshot"
230
231 [providers.moonshot]
232 api_key = "doctor-json-valid-kimi-secret"
233 base_url = "https://api.kimi.com/coding/v1"
234 model = "k3"
235 context_window = 1048576
236 "#;
237 fs::write(&config, config_bytes).expect("write valid Kimi config");
238
239 let mut command = diagnostic_command(&workspace, &home);
240 command
241 .args([
242 "--config",
243 config.to_str().expect("config path"),
244 "doctor",
245 "--json",
246 ])
247 .env("CODEWHALE_HOME", &codewhale_home);
248 let output = command.output().expect("run valid Kimi doctor json");
249
250 assert!(
251 output.status.success(),
252 "valid Kimi doctor --json failed\nstdout:\n{}\nstderr:\n{}",
253 String::from_utf8_lossy(&output.stdout),
254 String::from_utf8_lossy(&output.stderr)
255 );
256 let report: serde_json::Value =
257 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
258 assert_eq!(report["route"]["model"], "k3");
259 assert_eq!(report["route"]["context_window"]["tokens"], 1_048_576);
260 assert_eq!(report["route"]["context_window"]["source"], "configured");
261 assert!(report["route"]["route_error"].is_null());
262 assert_eq!(report["capability"]["resolved_model"], "k3");
263 assert_eq!(report["capability"]["context_window"], 1_048_576);
264 assert_eq!(report["capability"]["context_window_source"], "configured");
265 assert!(report["capability"]["route_error"].is_null());
266
267 let all_output = format!(
268 "{}\n{}",
269 String::from_utf8_lossy(&output.stdout),
270 String::from_utf8_lossy(&output.stderr)
271 );
272 assert!(!all_output.contains("doctor-json-valid-kimi-secret"));
273 assert_eq!(
274 fs::read(&config).expect("read config after doctor"),
275 config_bytes,
276 "doctor must not rewrite a valid config"
277 );
278 assert!(!home.exists(), "doctor must not create HOME state");
279 assert!(
280 !codewhale_home.exists(),
281 "doctor must not create CODEWHALE_HOME state"
282 );
283 }
284
285 #[test]
286 fn doctor_context_json_leaves_a_sealed_home_untouched() {
287 let output = run_sealed_diagnostic(["doctor", "--context-json"]);
288 let report: serde_json::Value =
289 serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
290 panic!(
291 "doctor --context-json must remain machine-readable: {error}\nstdout:\n{}\nstderr:\n{}",
292 String::from_utf8_lossy(&output.stdout),
293 String::from_utf8_lossy(&output.stderr)
294 )
295 });
296 assert!(
297 report["entries"].is_array(),
298 "doctor --context-json must emit a source map\nstdout:\n{}",
299 String::from_utf8_lossy(&output.stdout)
300 );
301 }
302
303 #[test]
304 fn setup_status_leaves_a_sealed_home_untouched() {
305 let output = run_sealed_diagnostic(["setup", "--status"]);
306 let stdout = String::from_utf8_lossy(&output.stdout);
307 assert!(stdout.contains("Codewhale Status"), "stdout:\n{stdout}");
308 }
309
310 #[test]
311 fn diagnostics_read_home_legacy_settings_without_migrating_them() {
312 for args in [
313 &["doctor"][..],
314 &["doctor", "--json"][..],
315 &["setup", "--status"][..],
316 ] {
317 let fixture = TempDir::new().expect("fixture root");
318 let workspace = fixture.path().join("workspace");
319 let home = fixture.path().join("home");
320 let legacy = home.join(".deepseek").join("settings.toml");
321 let primary_home = home.join(".codewhale");
322 let legacy_bytes = b"default_mode = \"plan\"\nprefer_external_pdftotext = true\n";
323 fs::create_dir_all(&workspace).expect("workspace");
324 fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy directory");
325 fs::write(&legacy, legacy_bytes).expect("legacy settings");
326
327 let output = diagnostic_command(&workspace, &home)
328 .args(args)
329 .output()
330 .expect("run diagnostic against legacy settings");
331 assert!(
332 output.status.success(),
333 "diagnostic {args:?} failed\nstdout:\n{}\nstderr:\n{}",
334 String::from_utf8_lossy(&output.stdout),
335 String::from_utf8_lossy(&output.stderr)
336 );
337
338 match args {
339 ["doctor"] => {
340 let stdout = String::from_utf8_lossy(&output.stdout);
341 assert!(
342 stdout.contains("default_mode=plan (settings)"),
343 "doctor must report the legacy default mode\nstdout:\n{stdout}"
344 );
345 assert!(
346 !stdout.contains("prefer_external_pdftotext"),
347 "doctor must not advertise the removed PDF preference\nstdout:\n{stdout}"
348 );
349 }
350 ["doctor", "--json"] => {
351 let report: serde_json::Value =
352 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
353 assert_eq!(
354 report["setup"]["runtime_posture"]["default_mode"]["value"],
355 "plan"
356 );
357 assert_eq!(
358 report["setup"]["runtime_posture"]["default_mode"]["source"],
359 "settings"
360 );
361 }
362 ["setup", "--status"] => {
363 let stdout = String::from_utf8_lossy(&output.stdout);
364 assert!(
365 stdout.contains("default_mode: plan (settings)"),
366 "setup status must report the legacy default mode\nstdout:\n{stdout}"
367 );
368 }
369 _ => unreachable!("fixed diagnostic command list"),
370 }
371
372 assert_eq!(
373 fs::read(&legacy).expect("legacy settings after diagnostic"),
374 legacy_bytes,
375 "diagnostic {args:?} must not rewrite legacy settings"
376 );
377 assert!(
378 !primary_home.exists(),
379 "diagnostic {args:?} must not create a primary Codewhale home"
380 );
381 }
382 }
383
384 #[test]
385 fn doctor_json_does_not_inherit_an_ambient_legacy_secret_from_an_explicit_home() {
386 let fixture = TempDir::new().expect("fixture root");
387 let workspace = fixture.path().join("workspace");
388 let home = fixture.path().join("home");
389 let codewhale_home = fixture.path().join("isolated-codewhale-home");
390 fs::create_dir_all(&workspace).expect("workspace");
391 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
392 FileKeyringStore::new(&legacy)
393 .set("deepseek", "synthetic-ambient-legacy-value")
394 .expect("seed ambient legacy secret");
395 let legacy_before = fs::read(&legacy).expect("read legacy secret before doctor");
396
397 let mut command = Command::new(crate::binary::codewhale());
398 command
399 .current_dir(&workspace)
400 .args(["doctor", "--json"])
401 .env_clear()
402 .env("PATH", std::env::var_os("PATH").expect("PATH"))
403 .env("HOME", &home)
404 .env("USERPROFILE", &home)
405 .env("CODEWHALE_HOME", &codewhale_home)
406 .env("CODEWHALE_SECRET_BACKEND", "file")
407 .env(
408 "CODEWHALE_RELEASE_BASE_URL",
409 "https://example.invalid/releases",
410 )
411 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"));
412 preserve_host_rustup_home(&mut command);
413 preserve_host_platform_runtime(&mut command);
414
415 let output = command.output().expect("run isolated doctor --json");
416 assert!(
417 output.status.success(),
418 "isolated doctor --json failed\nstdout:\n{}\nstderr:\n{}",
419 String::from_utf8_lossy(&output.stdout),
420 String::from_utf8_lossy(&output.stderr)
421 );
422 let report: serde_json::Value =
423 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
424 assert_eq!(
425 report["api_key"]["source"], "secret_store_unprobed",
426 "doctor must report only structural eligibility, not an ambient legacy secret from outside an explicit home"
427 );
428 assert_eq!(report["api_key"]["availability"], "not_probed");
429 assert_eq!(
430 fs::read(&legacy).expect("read legacy secret after doctor"),
431 legacy_before,
432 "doctor must not rewrite the ambient legacy secret"
433 );
434 assert!(
435 !codewhale_home.exists(),
436 "doctor must not create an isolated Codewhale home or secret store"
437 );
438 }
439
440 #[test]
441 fn doctor_text_probe_uses_a_legacy_key_without_migrating_it() {
442 let fixture = TempDir::new().expect("fixture root");
443 let workspace = fixture.path().join("workspace");
444 let home = fixture.path().join("home");
445 fs::create_dir_all(&workspace).expect("workspace");
446 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
447 let primary = home.join(".codewhale").join("secrets").join("secrets.json");
448 FileKeyringStore::new(&legacy)
449 .set("deepseek", "diagnostic-legacy-key")
450 .expect("seed legacy secret");
451 let legacy_before = fs::read(&legacy).expect("read legacy secret before doctor");
452 let server = CompletionServer::start();
453 let base_url = server.base_url();
454 let config = workspace.join("doctor.toml");
455 fs::write(
456 &config,
457 format!(
458 "provider = \"deepseek\"\n[providers.deepseek]\nbase_url = \"{base_url}\"\nmodel = \"deepseek-chat\"\nauth_mode = \"api_key\"\n"
459 ),
460 )
461 .expect("write doctor config");
462
463 let output = diagnostic_command(&workspace, &home)
464 .args([
465 "--config",
466 config.to_str().expect("config path"),
467 "doctor",
468 "--probe-local",
469 ])
470 .output()
471 .expect("run doctor probe");
472 assert!(
473 output.status.success(),
474 "doctor probe failed\nstdout:\n{}\nstderr:\n{}",
475 String::from_utf8_lossy(&output.stdout),
476 String::from_utf8_lossy(&output.stderr)
477 );
478 assert!(
479 String::from_utf8_lossy(&output.stdout).contains("API connection successful"),
480 "stdout:\n{}",
481 String::from_utf8_lossy(&output.stdout)
482 );
483 let requests = server.received_requests();
484 assert_eq!(
485 requests.len(),
486 1,
487 "doctor must make one local probe request"
488 );
489 let authorization = requests[0]
490 .get("authorization")
491 .and_then(|value| value.to_str().ok());
492 assert_eq!(
493 authorization,
494 Some("Bearer diagnostic-legacy-key"),
495 "doctor probe must use the legacy credential without printing it"
496 );
497 assert!(
498 !primary.exists(),
499 "doctor's text connectivity probe must not create a migrated primary secret store"
500 );
501 assert_eq!(
502 fs::read(&legacy).expect("read legacy secret after doctor"),
503 legacy_before,
504 "doctor must not rewrite the legacy secret store"
505 );
506 }
507
508 #[test]
509 fn doctor_json_reports_a_legacy_store_without_reading_or_migrating_it() {
510 let fixture = TempDir::new().expect("fixture root");
511 let workspace = fixture.path().join("workspace");
512 let home = fixture.path().join("home");
513 fs::create_dir_all(&workspace).expect("workspace");
514 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
515 let primary = home.join(".codewhale").join("secrets").join("secrets.json");
516 FileKeyringStore::new(&legacy)
517 .set("xiaomi-mimo", "tp-diagnostic-legacy-key")
518 .expect("seed legacy Xiaomi secret");
519 let legacy_before = fs::read(&legacy).expect("read legacy secret before doctor");
520 let config = workspace.join("doctor.toml");
521 fs::write(
522 &config,
523 "provider = \"xiaomi-mimo\"\n[providers.xiaomi_mimo]\nmode = \"standard\"\n",
524 )
525 .expect("write doctor config");
526
527 let output = diagnostic_command(&workspace, &home)
528 .args([
529 "--config",
530 config.to_str().expect("config path"),
531 "doctor",
532 "--json",
533 ])
534 .output()
535 .expect("run doctor json");
536 assert!(
537 output.status.success(),
538 "doctor --json failed\nstdout:\n{}\nstderr:\n{}",
539 String::from_utf8_lossy(&output.stdout),
540 String::from_utf8_lossy(&output.stderr)
541 );
542 let report: serde_json::Value =
543 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
544 assert_eq!(report["api_key"]["source"], "secret_store_unprobed");
545 assert_eq!(report["api_key"]["availability"], "not_probed");
546 assert_eq!(
547 report["route"]["auth"]["scheme"], "unknown",
548 "ordinary JSON doctor must not read the legacy key prefix to refine the Xiaomi scheme"
549 );
550 assert_eq!(report["route"]["auth"]["source"], "secret_store_unprobed");
551 assert_eq!(report["route"]["auth"]["availability"], "not_probed");
552 assert!(
553 !primary.exists(),
554 "doctor --json must not migrate a legacy secret while classifying auth"
555 );
556 assert_eq!(
557 fs::read(&legacy).expect("read legacy secret after doctor"),
558 legacy_before,
559 "doctor --json must not rewrite the legacy secret store"
560 );
561 }
562
563 #[test]
564 fn setup_status_reports_a_legacy_store_without_reading_or_migrating_it() {
565 let fixture = TempDir::new().expect("fixture root");
566 let workspace = fixture.path().join("workspace");
567 let home = fixture.path().join("home");
568 fs::create_dir_all(&workspace).expect("workspace");
569 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
570 let primary = home.join(".codewhale").join("secrets").join("secrets.json");
571 FileKeyringStore::new(&legacy)
572 .set("deepseek", "setup-status-legacy-key")
573 .expect("seed legacy secret");
574 let legacy_before = fs::read(&legacy).expect("read legacy secret before setup");
575
576 let output = diagnostic_command(&workspace, &home)
577 .args(["setup", "--status"])
578 .output()
579 .expect("run setup status");
580 assert!(
581 output.status.success(),
582 "setup --status failed\nstdout:\n{}\nstderr:\n{}",
583 String::from_utf8_lossy(&output.stdout),
584 String::from_utf8_lossy(&output.stderr)
585 );
586 let stdout = String::from_utf8_lossy(&output.stdout);
587 assert!(
588 stdout.contains("api_key: secret store eligible (store not probed)"),
589 "stdout:\n{stdout}"
590 );
591 assert!(
592 stdout.contains("credential availability: not_probed"),
593 "stdout:\n{stdout}"
594 );
595 assert!(
596 !primary.exists(),
597 "setup --status must not create a migrated primary secret store"
598 );
599 assert_eq!(
600 fs::read(&legacy).expect("read legacy secret after setup"),
601 legacy_before,
602 "setup --status must not rewrite the legacy secret store"
603 );
604 }
605
606 #[test]
607 fn doctor_json_stash_honors_an_explicit_codewhale_home() {
608 let fixture = TempDir::new().expect("fixture root");
609 let workspace = fixture.path().join("workspace");
610 let home = fixture.path().join("home");
611 let codewhale_home = fixture.path().join("isolated-codewhale-home");
612 fs::create_dir_all(&workspace).expect("workspace");
613 let ambient_stash = home.join(".codewhale").join("composer_stash.jsonl");
614 fs::create_dir_all(ambient_stash.parent().expect("ambient stash parent"))
615 .expect("ambient stash parent");
616 fs::write(
617 &ambient_stash,
618 r#"{"text":"ambient draft must not be inspected"}"#,
619 )
620 .expect("ambient stash");
621 let ambient_before = fs::read(&ambient_stash).expect("read ambient stash before doctor");
622
623 let mut command = diagnostic_command(&workspace, &home);
624 command
625 .args(["doctor", "--json"])
626 .env("CODEWHALE_HOME", &codewhale_home);
627 let output = command.output().expect("run isolated doctor json");
628 assert!(
629 output.status.success(),
630 "isolated doctor --json failed\nstdout:\n{}\nstderr:\n{}",
631 String::from_utf8_lossy(&output.stdout),
632 String::from_utf8_lossy(&output.stderr)
633 );
634 let report: serde_json::Value =
635 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
636 assert_eq!(
637 report["storage"]["stash"]["path"],
638 codewhale_home
639 .join("composer_stash.jsonl")
640 .display()
641 .to_string()
642 );
643 assert_eq!(report["storage"]["stash"]["present"], false);
644 assert_eq!(report["storage"]["stash"]["count"], 0);
645 assert!(report["storage"]["stash"]["error"].is_null());
646 assert!(
647 !String::from_utf8_lossy(&output.stdout).contains("ambient draft must not be inspected"),
648 "doctor must not inspect an ambient stash outside explicit CODEWHALE_HOME"
649 );
650 assert_eq!(
651 fs::read(&ambient_stash).expect("read ambient stash after doctor"),
652 ambient_before,
653 "doctor must not rewrite the ambient stash"
654 );
655 assert!(
656 !codewhale_home.exists(),
657 "a diagnostic must not create an explicit stash home"
658 );
659 }
660
661 fn run_sealed_diagnostic<const N: usize>(args: [&str; N]) -> Output {
662 let fixture = TempDir::new().expect("fixture root");
663 let workspace = fixture.path().join("workspace");
664 let sealed_home = fixture.path().join("sealed-home");
665 let codewhale_home = fixture.path().join("sealed-codewhale-home");
666 std::fs::create_dir_all(&workspace).expect("workspace");
667
668 let mut command = Command::new(crate::binary::codewhale());
669 command
670 .current_dir(&workspace)
671 .args(args)
672 .env_clear()
673 .env("PATH", std::env::var_os("PATH").expect("PATH"))
674 .env("HOME", &sealed_home)
675 .env("USERPROFILE", &sealed_home)
676 .env("CODEWHALE_HOME", &codewhale_home)
677 .env("CODEWHALE_SECRET_BACKEND", "file")
678 // Keep the text doctor command offline: the release crate treats this
679 // as a pinned mirror version and does not issue a metadata request.
680 .env(
681 "CODEWHALE_RELEASE_BASE_URL",
682 "https://example.invalid/releases",
683 )
684 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"));
685 preserve_host_rustup_home(&mut command);
686 preserve_host_platform_runtime(&mut command);
687
688 let output = command.output().expect("run sealed diagnostic");
689 assert!(
690 output.status.success(),
691 "diagnostic {args:?} failed\nstdout:\n{}\nstderr:\n{}",
692 String::from_utf8_lossy(&output.stdout),
693 String::from_utf8_lossy(&output.stderr)
694 );
695 assert!(
696 !sealed_home.exists(),
697 "diagnostic {args:?} must not create a HOME tree at {}",
698 sealed_home.display()
699 );
700 assert!(
701 !codewhale_home.exists(),
702 "diagnostic {args:?} must not create CODEWHALE_HOME or a secrets store at {}",
703 codewhale_home.display()
704 );
705 output
706 }
707
708 fn diagnostic_command(workspace: &std::path::Path, home: &std::path::Path) -> Command {
709 let mut command = Command::new(crate::binary::codewhale());
710 command
711 .current_dir(workspace)
712 .env_clear()
713 .env("PATH", std::env::var_os("PATH").expect("PATH"))
714 .env("HOME", home)
715 .env("USERPROFILE", home)
716 .env("CODEWHALE_SECRET_BACKEND", "file")
717 .env(
718 "CODEWHALE_RELEASE_BASE_URL",
719 "https://example.invalid/releases",
720 )
721 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"));
722 preserve_host_rustup_home(&mut command);
723 preserve_host_platform_runtime(&mut command);
724 command
725 }
726
727 struct CompletionServer {
728 base_url: String,
729 requests: Arc<Mutex<Vec<HeaderMap>>>,
730 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
731 owner: Option<thread::JoinHandle<()>>,
732 }
733
734 impl CompletionServer {
735 fn start() -> Self {
736 let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
737 let (shutdown, shutdown_receiver) = tokio::sync::oneshot::channel();
738 let requests = Arc::new(Mutex::new(Vec::new()));
739 let server_requests = Arc::clone(&requests);
740 let owner = thread::spawn(move || {
741 let runtime = tokio::runtime::Builder::new_multi_thread()
742 .worker_threads(2)
743 .enable_all()
744 .build()
745 .expect("local probe runtime");
746 runtime.block_on(async move {
747 let app = Router::new().route(
748 "/v1/chat/completions",
749 post(move |headers: HeaderMap, body: Bytes| {
750 let requests = Arc::clone(&server_requests);
751 async move {
752 // Extracting Bytes makes Axum drain the complete request body
753 // before replying. Preserve only headers for the credential
754 // assertion; the request payload itself is intentionally dropped.
755 drop(body);
756 requests
757 .lock()
758 .expect("local probe request lock")
759 .push(headers);
760 Json(serde_json::json!({
761 "id": "doctor",
762 "object": "chat.completion",
763 "created": 0,
764 "model": "deepseek-chat",
765 "choices": [{
766 "index": 0,
767 "message": {"role": "assistant", "content": "ok"},
768 "finish_reason": "stop"
769 }],
770 "usage": {
771 "prompt_tokens": 1,
772 "completion_tokens": 1,
773 "total_tokens": 2
774 }
775 }))
776 }
777 }),
778 );
779 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await;
780 let listener = listener.expect("bind local probe server");
781 let address = listener.local_addr().expect("local probe address");
782 ready_sender
783 .send(format!("http://{address}/v1"))
784 .expect("publish local probe address");
785 axum::serve(listener, app)
786 .with_graceful_shutdown(async {
787 let _ = shutdown_receiver.await;
788 })
789 .await
790 .expect("serve local probe request");
791 });
792 });
793 let base_url = ready_receiver
794 .recv_timeout(Duration::from_secs(10))
795 .expect("local probe server must start");
796 Self {
797 base_url,
798 requests,
799 shutdown: Some(shutdown),
800 owner: Some(owner),
801 }
802 }
803
804 fn base_url(&self) -> String {
805 self.base_url.clone()
806 }
807
808 fn received_requests(&self) -> Vec<HeaderMap> {
809 self.requests
810 .lock()
811 .expect("local probe request lock")
812 .clone()
813 }
814 }
815
816 impl Drop for CompletionServer {
817 fn drop(&mut self) {
818 if let Some(shutdown) = self.shutdown.take() {
819 let _ = shutdown.send(());
820 }
821 if let Some(owner) = self.owner.take() {
822 let result = owner.join();
823 if !thread::panicking() {
824 result.expect("stop local probe server");
825 }
826 }
827 }
828 }
829
830 /// A rustup shim may initialize its own toolchain state below `$HOME` when
831 /// `doctor` asks `rustc --version`. Preserve an already-configured toolchain
832 /// root so this test isolates Codewhale's own state contract.
833 fn preserve_host_rustup_home(command: &mut Command) {
834 let rustup_home = std::env::var_os("RUSTUP_HOME")
835 .map(PathBuf::from)
836 .or_else(|| {
837 std::env::var_os("HOME")
838 .map(PathBuf::from)
839 .map(|home| home.join(".rustup"))
840 .filter(|path| path.is_dir())
841 });
842 if let Some(rustup_home) = rustup_home {
843 command.env("RUSTUP_HOME", rustup_home);
844 }
845 }
846
847 /// `env_clear` is part of these tests' credential-isolation boundary, but a
848 /// Windows child still needs the non-secret OS root variables used to locate
849 /// platform networking components. Without them a reqwest client can fail its
850 /// loopback connection before the local fixture ever receives a request.
851 fn preserve_host_platform_runtime(_command: &mut Command) {
852 #[cfg(windows)]
853 for name in ["SystemRoot", "WINDIR"] {
854 if let Some(value) = std::env::var_os(name) {
855 _command.env(name, value);
856 }
857 }
858 }
859
859 lines RUST