返回 CodeWhale
runtime_set_dispatch.rs
根目录 / crates / cli / tests / runtime_set_dispatch.rs
1 //! Exercise the public CLI boundary: the runtime reloads configuration after
2 //! dispatch, so testing the outer store alone cannot catch lost overrides.
3
4 use std::fs;
5 use std::io::{BufRead, BufReader, Read, Write};
6 use std::net::TcpListener;
7 use std::path::PathBuf;
8 use std::process::{Command, Output, Stdio};
9 use std::time::Duration;
10
11 use serde_json::{Value, json};
12 use tempfile::TempDir;
13
14 const CONFIG: &str = r#"
15 provider = "deepseek"
16 default_text_model = "deepseek-v4-flash"
17 sandbox_mode = "workspace-write"
18 approval_policy = "never"
19 telemetry = false
20
21 [profiles.review]
22 provider = "openrouter"
23 default_text_model = "profile-model"
24 sandbox_mode = "danger-full-access"
25 approval_policy = "untrusted"
26 "#;
27
28 struct Fixture {
29 root: TempDir,
30 config: PathBuf,
31 }
32
33 impl Fixture {
34 fn new(config: &str) -> Self {
35 let root = TempDir::new().unwrap();
36 let config_path = root.path().join("config.toml");
37 fs::write(&config_path, config).unwrap();
38 Self {
39 root,
40 config: config_path,
41 }
42 }
43
44 fn command(&self, args: &[&str]) -> Command {
45 let mut command = Command::new(env!("CARGO_BIN_EXE_codewhale"));
46 command
47 .current_dir(self.root.path())
48 .env_clear()
49 .env("HOME", self.root.path().join("home"))
50 .env("USERPROFILE", self.root.path().join("home"))
51 .env("CODEWHALE_HOME", self.root.path().join("state"))
52 .env("CODEWHALE_SECRET_BACKEND", "file")
53 .env("CODEWHALE_TELEMETRY", "0")
54 .stdin(Stdio::null())
55 .arg("--config")
56 .arg(&self.config)
57 .arg("--no-project-config")
58 .args(args);
59 // Doctor may inspect rustc. Keep rustup's initialization outside the
60 // sealed fixture, while withholding all provider/account variables.
61 for name in ["PATH", "RUSTUP_HOME", "SystemRoot", "WINDIR"] {
62 if let Some(value) = std::env::var_os(name) {
63 command.env(name, value);
64 }
65 }
66 command
67 }
68
69 fn run(&self, args: &[&str]) -> Output {
70 self.command(args).output().expect("run public codewhale")
71 }
72
73 fn doctor(&self, args: &[&str]) -> Value {
74 let output = self
75 .command(args)
76 .args(["doctor", "--json"])
77 .output()
78 .unwrap();
79 success(&output);
80 serde_json::from_slice(&output.stdout).unwrap()
81 }
82
83 fn unchanged(&self) {
84 assert_eq!(fs::read_to_string(&self.config).unwrap(), CONFIG);
85 assert!(
86 !self.root.path().join("state").exists(),
87 "diagnostic must not create state"
88 );
89 }
90 }
91
92 fn success(output: &Output) {
93 assert!(
94 output.status.success(),
95 "status: {}\nstdout: {}\nstderr: {}",
96 output.status,
97 String::from_utf8_lossy(&output.stdout),
98 String::from_utf8_lossy(&output.stderr)
99 );
100 }
101
102 fn route(report: &Value) -> (&str, &str) {
103 let route = &report["setup"]["provider_model"];
104 (
105 route["provider"]["id"].as_str().unwrap(),
106 route["model"]["resolved"].as_str().unwrap(),
107 )
108 }
109
110 fn posture<'a>(report: &'a Value, key: &str) -> &'a Value {
111 &report["setup"]["runtime_posture"][key]["value"]
112 }
113
114 #[test]
115 fn runtime_set_reaches_the_public_runtime_and_does_not_persist() {
116 let fixture = Fixture::new(CONFIG);
117 let baseline = fixture.doctor(&[]);
118 assert_eq!(route(&baseline), ("deepseek", "deepseek-v4-flash"));
119 assert_eq!(posture(&baseline, "sandbox_mode"), "workspace-write");
120 let report = fixture.doctor(&[
121 "--set",
122 "sandbox_mode=read-only",
123 "--set",
124 "approval_policy=on-request",
125 "--set",
126 "model=deepseek-v4-pro",
127 "--set",
128 "telemetry=false",
129 ]);
130 assert_eq!(route(&report), ("deepseek", "deepseek-v4-pro"));
131 assert_eq!(posture(&report, "sandbox_mode"), "read-only");
132 assert_eq!(posture(&report, "approval_policy"), "on-request");
133 assert_eq!(posture(&report, "telemetry"), false);
134 let after = fixture.doctor(&[]);
135 assert_eq!(route(&after), route(&baseline));
136 assert_eq!(posture(&after, "sandbox_mode"), "workspace-write");
137 fixture.unchanged();
138 }
139
140 #[test]
141 fn profile_defaults_survive_unrelated_overrides_and_yield_to_explicit_routes() {
142 let fixture = Fixture::new(CONFIG);
143 let report = fixture.doctor(&["--profile", "review", "--set", "sandbox_mode=read-only"]);
144 assert_eq!(route(&report), ("openrouter", "profile-model"));
145 assert_eq!(posture(&report, "approval_policy"), "untrusted");
146 assert_eq!(posture(&report, "sandbox_mode"), "read-only");
147 let report = fixture.doctor(&[
148 "--profile",
149 "review",
150 "--set",
151 "provider=deepseek",
152 "--set",
153 "default_text_model=deepseek-v4-pro",
154 ]);
155 assert_eq!(route(&report), ("deepseek", "deepseek-v4-pro"));
156 assert_eq!(posture(&report, "sandbox_mode"), "danger-full-access");
157 fixture.unchanged();
158 }
159
160 #[test]
161 fn dedicated_flags_win_in_either_order_and_repeated_aliases_use_the_last_value() {
162 let fixture = Fixture::new(CONFIG);
163 let flags = [
164 "--provider",
165 "deepseek",
166 "--model",
167 "deepseek-v4-flash",
168 "--sandbox-mode",
169 "workspace-write",
170 "--approval-policy",
171 "on-request",
172 ];
173 let overrides = [
174 "--set",
175 "provider=openrouter",
176 "--set",
177 "model=other-model",
178 "--set",
179 "sandbox_mode=read-only",
180 "--set",
181 "approval_policy=never",
182 ];
183 for args in [
184 flags.iter().chain(&overrides).copied().collect::<Vec<_>>(),
185 overrides.iter().chain(&flags).copied().collect::<Vec<_>>(),
186 ] {
187 let report = fixture.doctor(&args);
188 assert_eq!(route(&report), ("deepseek", "deepseek-v4-flash"));
189 assert_eq!(posture(&report, "sandbox_mode"), "workspace-write");
190 assert_eq!(posture(&report, "approval_policy"), "on-request");
191 }
192 let report = fixture.doctor(&[
193 "--set",
194 "model=deepseek-v4-flash",
195 "--set",
196 "default_text_model=deepseek-v4-pro",
197 "--set",
198 "sandbox_mode=workspace-write",
199 "--set",
200 "sandbox_mode=read-only",
201 ]);
202 assert_eq!(route(&report), ("deepseek", "deepseek-v4-pro"));
203 assert_eq!(posture(&report, "sandbox_mode"), "read-only");
204 fixture.unchanged();
205 }
206
207 #[test]
208 fn managed_policy_keeps_authority_over_runtime_set_and_dedicated_flags() {
209 let fixture = Fixture::new(CONFIG);
210 let managed = fixture.root.path().join("managed.toml");
211 fs::write(&managed, "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-flash\"\nsandbox_mode = \"read-only\"\napproval_policy = \"untrusted\"\n").unwrap();
212 let output = fixture
213 .command(&[
214 "--profile",
215 "review",
216 "--set",
217 "provider=openrouter",
218 "--set",
219 "model=other-model",
220 "--set",
221 "sandbox_mode=danger-full-access",
222 "--approval-policy",
223 "never",
224 ])
225 .env("CODEWHALE_MANAGED_CONFIG_PATH", &managed)
226 .args(["doctor", "--json"])
227 .output()
228 .unwrap();
229 success(&output);
230 let report: Value = serde_json::from_slice(&output.stdout).unwrap();
231 assert_eq!(route(&report), ("deepseek", "deepseek-v4-flash"));
232 assert_eq!(posture(&report, "sandbox_mode"), "read-only");
233 assert_eq!(posture(&report, "approval_policy"), "untrusted");
234 fixture.unchanged();
235 }
236
237 #[test]
238 fn managed_requirements_reject_an_incompatible_temporary_sandbox() {
239 let fixture = Fixture::new(CONFIG);
240 let requirements = fixture.root.path().join("requirements.toml");
241 fs::write(&requirements, "allowed_sandbox_modes = [\"read-only\"]\n").unwrap();
242 let output = fixture
243 .command(&[
244 "--set",
245 "sandbox_mode=danger-full-access",
246 "doctor",
247 "--json",
248 ])
249 .env("CODEWHALE_REQUIREMENTS_PATH", requirements)
250 .output()
251 .unwrap();
252 assert!(!output.status.success());
253 let report: Value = serde_json::from_slice(&output.stdout).unwrap();
254 assert_eq!(report["error"]["kind"], "config_validation");
255 fixture.unchanged();
256 }
257
258 #[test]
259 fn unsupported_values_auth_and_legacy_transports_fail_before_config_or_secret_access() {
260 const SENTINEL: &str = "synthetic-override-secret";
261 let fixture = Fixture::new("invalid = [synthetic-config-secret\n");
262 for key in [
263 "api_key",
264 "auth.mode",
265 "base_url",
266 "providers.openai.api_key",
267 "not_a_key",
268 ] {
269 let output = fixture.run(&["--set", &format!("{key}={SENTINEL}"), "doctor", "--json"]);
270 assert!(!output.status.success());
271 assert!(String::from_utf8_lossy(&output.stderr).contains("unsupported runtime --set key"));
272 assert!(!String::from_utf8_lossy(&output.stderr).contains(SENTINEL));
273 assert!(!String::from_utf8_lossy(&output.stderr).contains("synthetic-config-secret"));
274 }
275 for args in [
276 vec!["auth", "status"],
277 vec!["auth", "print-api-key", "--provider", "deepseek"],
278 vec!["app-server"],
279 vec!["app-server", "--stdio"],
280 vec!["app-server", "--socket"],
281 ] {
282 let output = fixture
283 .command(&["--set", "sandbox_mode=read-only"])
284 .args(&args)
285 .output()
286 .unwrap();
287 assert!(!output.status.success());
288 assert!(
289 String::from_utf8_lossy(&output.stderr).contains("--set is not supported"),
290 "{output:?}"
291 );
292 assert!(!String::from_utf8_lossy(&output.stderr).contains("synthetic-config-secret"));
293 }
294 for spec in [
295 "missing-equals",
296 "model=",
297 "model= ",
298 "telemetry=maybe",
299 "provider=bad/id",
300 ] {
301 let output = fixture.run(&["--set", spec, "doctor", "--json"]);
302 assert!(!output.status.success());
303 assert!(String::from_utf8_lossy(&output.stderr).contains("invalid"));
304 assert!(!String::from_utf8_lossy(&output.stderr).contains("synthetic-config-secret"));
305 }
306 assert!(!fixture.root.path().join("state").exists());
307 }
308
309 #[test]
310 fn runtime_values_cannot_leak_through_a_command_that_saves_the_store() {
311 let fixture = Fixture::new(CONFIG);
312 let output = fixture.run(&[
313 "--set",
314 "sandbox_mode=read-only",
315 "--set",
316 "provider=openrouter",
317 "--set",
318 "model=temporary-model",
319 "model",
320 "set",
321 "deepseek-v4-pro",
322 ]);
323 success(&output);
324 let saved: Value = serde_json::to_value(
325 toml::from_str::<toml::Value>(&fs::read_to_string(&fixture.config).unwrap()).unwrap(),
326 )
327 .unwrap();
328 assert_eq!(saved["provider"], "deepseek");
329 assert_eq!(saved["sandbox_mode"], "workspace-write");
330 // Selected models persist in the canonical per-provider slot; root
331 // default_text_model is legacy fallback only.
332 assert_eq!(saved["providers"]["deepseek"]["model"], "deepseek-v4-pro");
333 assert!(
334 !fs::read_to_string(&fixture.config)
335 .unwrap()
336 .contains("temporary-model")
337 );
338 }
339
340 #[test]
341 fn named_provider_and_model_reach_an_actual_exec_request_with_a_profile() {
342 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
343 let endpoint = format!("http://{}/v1", listener.local_addr().unwrap());
344 let mock = std::thread::spawn(move || {
345 let (stream, _) = listener.accept().unwrap();
346 stream
347 .set_read_timeout(Some(Duration::from_secs(10)))
348 .unwrap();
349 let mut reader = BufReader::new(stream);
350 let mut request = String::new();
351 reader.read_line(&mut request).unwrap();
352 assert_eq!(request.trim(), "POST /v1/chat/completions HTTP/1.1");
353 let mut length = None;
354 loop {
355 let mut line = String::new();
356 assert!(reader.read_line(&mut line).unwrap() > 0);
357 if line == "\r\n" {
358 break;
359 }
360 if let Some((name, value)) = line.split_once(':')
361 && name.eq_ignore_ascii_case("content-length")
362 {
363 length = Some(value.trim().parse::<usize>().unwrap());
364 }
365 }
366 let length = length.expect("request content length");
367 assert!(length <= 1024 * 1024);
368 let mut body = vec![0; length];
369 reader.read_exact(&mut body).unwrap();
370 let body: Value = serde_json::from_slice(&body).unwrap();
371 let response = format!(
372 "data: {}\n\ndata: [DONE]\n\n",
373 json!({
374 "id": "fixture", "object": "chat.completion.chunk", "model": "temporary-model",
375 "choices": [{"index": 0, "delta": {"content": "LOCAL_SET_ACCEPTED"}, "finish_reason": "stop"}]
376 })
377 );
378 write!(reader.get_mut(), "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap();
379 body
380 });
381 let config = format!(
382 "{CONFIG}\n[providers.fixture_route]\nkind = \"openai-compatible\"\nbase_url = \"{endpoint}\"\nmodel = \"saved-model\"\napi_key = \"synthetic-fixture-key\"\n"
383 );
384 let fixture = Fixture::new(&config);
385 let output = fixture.run(&[
386 "--profile",
387 "review",
388 "--set",
389 "provider=fixture_route",
390 "--set",
391 "model=temporary-model",
392 "--set",
393 "sandbox_mode=read-only",
394 "exec",
395 "--max-turns",
396 "1",
397 "--output-format",
398 "stream-json",
399 "reply briefly",
400 ]);
401 success(&output);
402 let request = mock.join().unwrap();
403 assert_eq!(request["model"], "temporary-model");
404 assert!(
405 !request["tools"]
406 .as_array()
407 .unwrap()
408 .iter()
409 .any(|tool| tool["function"]["name"] == "Bash")
410 );
411 let events: Vec<Value> = String::from_utf8(output.stdout)
412 .unwrap()
413 .lines()
414 .map(|line| serde_json::from_str(line).unwrap())
415 .collect();
416 assert!(
417 events
418 .iter()
419 .any(|event| event["type"] == "content" && event["content"] == "LOCAL_SET_ACCEPTED"),
420 "{events:?}"
421 );
422 assert_eq!(events.last().unwrap()["type"], "done");
423 let receipt = events
424 .iter()
425 .find(|event| event["type"] == "metadata")
426 .unwrap();
427 assert_eq!(receipt["meta"]["provider_id"], "fixture_route");
428 assert_eq!(receipt["meta"]["model"], "temporary-model");
429 assert_eq!(fs::read_to_string(&fixture.config).unwrap(), config);
430 }
431
431 lines RUST