1 //! End-to-end headless (`sigit -p`) runs against the real binary.
2 //!
3 //! Same harness idea as `tests/acp_permissions.rs`: a scripted OpenAI-compatible
4 //! SSE endpoint stands in for the model via the `OPENAI_BASE_URL` override, and
5 //! the BUILT binary is driven like CI would drive it — one `-p` invocation, then
6 //! assertions on the exit code, stdout, and what the endpoint saw.
7 //!
8 //! 1. With `--allow-tool run_command`, the scripted tool call executes and its
9 //! output travels back to the endpoint as a `role: "tool"` result.
10 //! 2. Without `--allow-tool`, the ask-level tool is denied, the denial is fed
11 //! to the model, and the run still exits 0 (the turn completed).
12 //! 3. An unknown flag is a usage error: exit 2, nothing hits the endpoint.
13
14 use std::collections::VecDeque;
15 use std::io::{BufRead, BufReader, Read, Write};
16 use std::net::TcpListener;
17 use std::process::{Command, Output, Stdio};
18 use std::sync::{Arc, Mutex};
19
20 use serde_json::{Value, json};
21
22 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
23
24 fn sse_body(events: &[Value]) -> String {
25 let mut body = String::new();
26 for event in events {
27 body.push_str("data: ");
28 body.push_str(&event.to_string());
29 body.push_str("\n\n");
30 }
31 body.push_str("data: [DONE]\n\n");
32 body
33 }
34
35 fn sse_tool_call(id: &str, name: &str, arguments: &str) -> String {
36 sse_body(&[json!({
37 "choices": [{"delta": {"tool_calls": [{
38 "index": 0,
39 "id": id,
40 "function": {"name": name, "arguments": arguments},
41 }]}}]
42 })])
43 }
44
45 fn sse_text(text: &str) -> String {
46 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
47 }
48
49 /// One round that says something and then asks for a tool — the shape that used
50 /// to leave the next round's text glued to this sentence.
51 fn sse_text_then_tool_call(text: &str, id: &str, name: &str, arguments: &str) -> String {
52 sse_body(&[
53 json!({"choices": [{"delta": {"content": text}}]}),
54 json!({
55 "choices": [{"delta": {"tool_calls": [{
56 "index": 0,
57 "id": id,
58 "function": {"name": name, "arguments": arguments},
59 }]}}]
60 }),
61 ])
62 }
63
64 /// Serves one scripted SSE response per request and records each request body.
65 struct FakeEndpoint {
66 port: u16,
67 requests: Arc<Mutex<Vec<Value>>>,
68 }
69
70 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
71 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
72 let port = listener.local_addr().unwrap().port();
73 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
74 let recorded = Arc::clone(&requests);
75 let queue = Mutex::new(VecDeque::from(responses));
76
77 std::thread::spawn(move || {
78 // `connection: close` below means one request per connection, so the
79 // serial accept loop matches the agent's serial completion requests.
80 for stream in listener.incoming() {
81 let Ok(mut stream) = stream else { continue };
82 let mut reader = BufReader::new(match stream.try_clone() {
83 Ok(clone) => clone,
84 Err(_) => continue,
85 });
86 let mut content_length = 0usize;
87 loop {
88 let mut line = String::new();
89 if reader.read_line(&mut line).unwrap_or(0) == 0 {
90 break;
91 }
92 let line = line.trim();
93 if line.is_empty() {
94 break;
95 }
96 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
97 content_length = length.trim().parse().unwrap_or(0);
98 }
99 }
100 let mut body = vec![0u8; content_length];
101 if reader.read_exact(&mut body).is_err() {
102 continue;
103 }
104 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
105 recorded.lock().unwrap().push(request);
106 }
107 let payload = queue
108 .lock()
109 .unwrap()
110 .pop_front()
111 .unwrap_or_else(|| sse_text("out of scripted responses"));
112 let response = format!(
113 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
114 content-length: {}\r\nconnection: close\r\n\r\n{}",
115 payload.len(),
116 payload
117 );
118 let _ = stream.write_all(response.as_bytes());
119 }
120 });
121
122 FakeEndpoint { port, requests }
123 }
124
125 // ── Running the binary ──────────────────────────────────────────────────────
126
127 /// A scratch layout for one test: an isolated config dir and working dir.
128 struct Scratch {
129 root: std::path::PathBuf,
130 config_dir: std::path::PathBuf,
131 cwd: std::path::PathBuf,
132 }
133
134 fn scratch(tag: &str) -> Scratch {
135 let root = std::env::temp_dir().join(format!("sigit_headless_{tag}_{}", std::process::id()));
136 let config_dir = root.join("config");
137 let cwd = root.join("cwd");
138 std::fs::create_dir_all(&config_dir).unwrap();
139 std::fs::create_dir_all(&cwd).unwrap();
140 Scratch {
141 root,
142 config_dir,
143 cwd,
144 }
145 }
146
147 impl Drop for Scratch {
148 fn drop(&mut self) {
149 let _ = std::fs::remove_dir_all(&self.root);
150 }
151 }
152
153 /// Run `sigit` headless with `args` against the fake endpoint and collect the
154 /// full output. Stdin is null: headless mode must not depend on a TTY.
155 fn run_headless(port: u16, scratch: &Scratch, args: &[&str]) -> Output {
156 Command::new(env!("CARGO_BIN_EXE_sigit"))
157 .args(args)
158 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
159 .env("OPENAI_API_KEY", "test-key")
160 .env("SIGIT_MODEL", "scripted-model")
161 .env("SIGIT_CONFIG_DIR", &scratch.config_dir)
162 .env("SIGIT_MCP", "off")
163 // A fresh config dir means the default permission mode, `ask` — make
164 // sure the environment can't turn the gate off underneath the test.
165 .env_remove("SIGIT_PERMISSIONS")
166 .env_remove("SIGIT_LOCAL_INFERENCE")
167 .stdin(Stdio::null())
168 .stdout(Stdio::piped())
169 .stderr(Stdio::piped())
170 .output()
171 .expect("run sigit -p")
172 }
173
174 fn stdout_of(output: &Output) -> String {
175 String::from_utf8_lossy(&output.stdout).to_string()
176 }
177
178 // ── The runs ────────────────────────────────────────────────────────────────
179
180 #[test]
181 fn allowed_tool_runs_and_result_reaches_the_endpoint() {
182 let endpoint = start_fake_endpoint(vec![
183 sse_tool_call("call_1", "run_command", r#"{"command":"echo headless-ok"}"#),
184 sse_text("finished: headless-final-answer"),
185 ]);
186 let scratch = scratch("allow");
187 let cwd = scratch.cwd.to_str().unwrap().to_string();
188
189 let output = run_headless(
190 endpoint.port,
191 &scratch,
192 &[
193 "-p",
194 "do the thing",
195 "--allow-tool",
196 "run_command",
197 "--cwd",
198 &cwd,
199 ],
200 );
201
202 assert_eq!(
203 output.status.code(),
204 Some(0),
205 "stderr: {}",
206 String::from_utf8_lossy(&output.stderr)
207 );
208 assert!(
209 stdout_of(&output).contains("finished: headless-final-answer"),
210 "final answer must reach stdout, got: {}",
211 stdout_of(&output)
212 );
213 // Tool progress goes to stderr, not stdout.
214 assert!(
215 String::from_utf8_lossy(&output.stderr).contains("run_command"),
216 "tool progress should be reported on stderr"
217 );
218
219 let requests = endpoint.requests.lock().unwrap();
220 assert_eq!(requests.len(), 2, "expected exactly two completions");
221
222 // The first request carries the user's prompt.
223 let messages = requests[0]["messages"].as_array().expect("messages");
224 assert!(
225 messages
226 .iter()
227 .any(|m| m["role"] == "user" && m["content"] == "do the thing"),
228 "prompt must reach the endpoint"
229 );
230
231 // The second request carries the executed tool's real output.
232 let messages = requests[1]["messages"].as_array().expect("messages");
233 let result = messages
234 .iter()
235 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
236 .expect("tool result for the approved call");
237 assert!(
238 result["content"]
239 .as_str()
240 .unwrap_or_default()
241 .contains("headless-ok"),
242 "the command's output should reach the endpoint: {result}"
243 );
244
245 // The conversation is saved under the "headless" session id for resume.
246 assert!(
247 scratch
248 .config_dir
249 .join("sessions")
250 .join("headless.jsonl")
251 .is_file(),
252 "headless session must be persisted"
253 );
254 }
255
256 #[test]
257 fn ask_level_tool_without_allow_flag_is_denied_but_run_completes() {
258 let endpoint = start_fake_endpoint(vec![
259 sse_tool_call(
260 "call_1",
261 "run_command",
262 r#"{"command":"echo must-not-run"}"#,
263 ),
264 sse_text("understood, stopping"),
265 ]);
266 let scratch = scratch("deny");
267 let cwd = scratch.cwd.to_str().unwrap().to_string();
268
269 let output = run_headless(
270 endpoint.port,
271 &scratch,
272 &["-p", "try the command", "--cwd", &cwd],
273 );
274
275 // A denial is fed to the model, not treated as a failure: the turn still
276 // completes, so the exit code is 0.
277 assert_eq!(
278 output.status.code(),
279 Some(0),
280 "stderr: {}",
281 String::from_utf8_lossy(&output.stderr)
282 );
283 assert!(
284 stdout_of(&output).contains("understood, stopping"),
285 "final answer must reach stdout, got: {}",
286 stdout_of(&output)
287 );
288
289 let requests = endpoint.requests.lock().unwrap();
290 assert_eq!(requests.len(), 2, "expected exactly two completions");
291
292 // The endpoint sees the denial as the tool result — including the
293 // --allow-tool hint — and never the command's real output.
294 let messages = requests[1]["messages"].as_array().expect("messages");
295 let result = messages
296 .iter()
297 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
298 .expect("tool result for the denied call");
299 let content = result["content"].as_str().unwrap_or_default();
300 assert!(
301 content.contains("was not executed") && content.contains("--allow-tool"),
302 "denial text should reach the model and mention --allow-tool: {content}"
303 );
304 assert!(
305 !content.contains("must-not-run"),
306 "the denied command must not have executed: {content}"
307 );
308 }
309
310 #[test]
311 fn text_from_two_tool_rounds_is_not_run_together() {
312 let endpoint = start_fake_endpoint(vec![
313 sse_text_then_tool_call(
314 "I'll search for this pattern.",
315 "call_1",
316 "run_command",
317 r#"{"command":"echo first"}"#,
318 ),
319 sse_text("Let me broaden the search:"),
320 ]);
321 let scratch = scratch("spacing");
322 let cwd = scratch.cwd.to_str().unwrap().to_string();
323
324 let output = run_headless(
325 endpoint.port,
326 &scratch,
327 &[
328 "-p",
329 "find it",
330 "--allow-tool",
331 "run_command",
332 "--cwd",
333 &cwd,
334 ],
335 );
336
337 assert_eq!(
338 output.status.code(),
339 Some(0),
340 "stderr: {}",
341 String::from_utf8_lossy(&output.stderr)
342 );
343 let stdout = stdout_of(&output);
344 assert!(
345 stdout.contains("pattern.\n\nLet me broaden"),
346 "the second round must open a new paragraph, got: {stdout:?}"
347 );
348 assert!(
349 !stdout.contains("pattern.Let me"),
350 "rounds must not run together into one sentence, got: {stdout:?}"
351 );
352 }
353
354 #[test]
355 fn unknown_flag_exits_2_with_usage() {
356 let endpoint = start_fake_endpoint(vec![]);
357 let scratch = scratch("usage");
358
359 let output = run_headless(endpoint.port, &scratch, &["-p", "x", "--frobnicate"]);
360
361 assert_eq!(output.status.code(), Some(2));
362 let stderr = String::from_utf8_lossy(&output.stderr);
363 assert!(
364 stderr.contains("--frobnicate") && stderr.contains("Usage:"),
365 "usage must be printed to stderr: {stderr}"
366 );
367 assert!(
368 endpoint.requests.lock().unwrap().is_empty(),
369 "a bad invocation must never reach the endpoint"
370 );
371 }