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 /// Serves one scripted SSE response per request and records each request body.
50 struct FakeEndpoint {
51 port: u16,
52 requests: Arc<Mutex<Vec<Value>>>,
53 }
54
55 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
56 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
57 let port = listener.local_addr().unwrap().port();
58 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
59 let recorded = Arc::clone(&requests);
60 let queue = Mutex::new(VecDeque::from(responses));
61
62 std::thread::spawn(move || {
63 // `connection: close` below means one request per connection, so the
64 // serial accept loop matches the agent's serial completion requests.
65 for stream in listener.incoming() {
66 let Ok(mut stream) = stream else { continue };
67 let mut reader = BufReader::new(match stream.try_clone() {
68 Ok(clone) => clone,
69 Err(_) => continue,
70 });
71 let mut content_length = 0usize;
72 loop {
73 let mut line = String::new();
74 if reader.read_line(&mut line).unwrap_or(0) == 0 {
75 break;
76 }
77 let line = line.trim();
78 if line.is_empty() {
79 break;
80 }
81 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
82 content_length = length.trim().parse().unwrap_or(0);
83 }
84 }
85 let mut body = vec![0u8; content_length];
86 if reader.read_exact(&mut body).is_err() {
87 continue;
88 }
89 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
90 recorded.lock().unwrap().push(request);
91 }
92 let payload = queue
93 .lock()
94 .unwrap()
95 .pop_front()
96 .unwrap_or_else(|| sse_text("out of scripted responses"));
97 let response = format!(
98 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
99 content-length: {}\r\nconnection: close\r\n\r\n{}",
100 payload.len(),
101 payload
102 );
103 let _ = stream.write_all(response.as_bytes());
104 }
105 });
106
107 FakeEndpoint { port, requests }
108 }
109
110 // ── Running the binary ──────────────────────────────────────────────────────
111
112 /// A scratch layout for one test: an isolated config dir and working dir.
113 struct Scratch {
114 root: std::path::PathBuf,
115 config_dir: std::path::PathBuf,
116 cwd: std::path::PathBuf,
117 }
118
119 fn scratch(tag: &str) -> Scratch {
120 let root = std::env::temp_dir().join(format!("sigit_headless_{tag}_{}", std::process::id()));
121 let config_dir = root.join("config");
122 let cwd = root.join("cwd");
123 std::fs::create_dir_all(&config_dir).unwrap();
124 std::fs::create_dir_all(&cwd).unwrap();
125 Scratch {
126 root,
127 config_dir,
128 cwd,
129 }
130 }
131
132 impl Drop for Scratch {
133 fn drop(&mut self) {
134 let _ = std::fs::remove_dir_all(&self.root);
135 }
136 }
137
138 /// Run `sigit` headless with `args` against the fake endpoint and collect the
139 /// full output. Stdin is null: headless mode must not depend on a TTY.
140 fn run_headless(port: u16, scratch: &Scratch, args: &[&str]) -> Output {
141 Command::new(env!("CARGO_BIN_EXE_sigit"))
142 .args(args)
143 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
144 .env("OPENAI_API_KEY", "test-key")
145 .env("SIGIT_MODEL", "scripted-model")
146 .env("SIGIT_CONFIG_DIR", &scratch.config_dir)
147 .env("SIGIT_MCP", "off")
148 // A fresh config dir means the default permission mode, `ask` — make
149 // sure the environment can't turn the gate off underneath the test.
150 .env_remove("SIGIT_PERMISSIONS")
151 .env_remove("SIGIT_LOCAL_INFERENCE")
152 .stdin(Stdio::null())
153 .stdout(Stdio::piped())
154 .stderr(Stdio::piped())
155 .output()
156 .expect("run sigit -p")
157 }
158
159 fn stdout_of(output: &Output) -> String {
160 String::from_utf8_lossy(&output.stdout).to_string()
161 }
162
163 // ── The runs ────────────────────────────────────────────────────────────────
164
165 #[test]
166 fn allowed_tool_runs_and_result_reaches_the_endpoint() {
167 let endpoint = start_fake_endpoint(vec![
168 sse_tool_call("call_1", "run_command", r#"{"command":"echo headless-ok"}"#),
169 sse_text("finished: headless-final-answer"),
170 ]);
171 let scratch = scratch("allow");
172 let cwd = scratch.cwd.to_str().unwrap().to_string();
173
174 let output = run_headless(
175 endpoint.port,
176 &scratch,
177 &[
178 "-p",
179 "do the thing",
180 "--allow-tool",
181 "run_command",
182 "--cwd",
183 &cwd,
184 ],
185 );
186
187 assert_eq!(
188 output.status.code(),
189 Some(0),
190 "stderr: {}",
191 String::from_utf8_lossy(&output.stderr)
192 );
193 assert!(
194 stdout_of(&output).contains("finished: headless-final-answer"),
195 "final answer must reach stdout, got: {}",
196 stdout_of(&output)
197 );
198 // Tool progress goes to stderr, not stdout.
199 assert!(
200 String::from_utf8_lossy(&output.stderr).contains("run_command"),
201 "tool progress should be reported on stderr"
202 );
203
204 let requests = endpoint.requests.lock().unwrap();
205 assert_eq!(requests.len(), 2, "expected exactly two completions");
206
207 // The first request carries the user's prompt.
208 let messages = requests[0]["messages"].as_array().expect("messages");
209 assert!(
210 messages
211 .iter()
212 .any(|m| m["role"] == "user" && m["content"] == "do the thing"),
213 "prompt must reach the endpoint"
214 );
215
216 // The second request carries the executed tool's real output.
217 let messages = requests[1]["messages"].as_array().expect("messages");
218 let result = messages
219 .iter()
220 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
221 .expect("tool result for the approved call");
222 assert!(
223 result["content"]
224 .as_str()
225 .unwrap_or_default()
226 .contains("headless-ok"),
227 "the command's output should reach the endpoint: {result}"
228 );
229
230 // The conversation is saved under the "headless" session id for resume.
231 assert!(
232 scratch
233 .config_dir
234 .join("sessions")
235 .join("headless.jsonl")
236 .is_file(),
237 "headless session must be persisted"
238 );
239 }
240
241 #[test]
242 fn ask_level_tool_without_allow_flag_is_denied_but_run_completes() {
243 let endpoint = start_fake_endpoint(vec![
244 sse_tool_call(
245 "call_1",
246 "run_command",
247 r#"{"command":"echo must-not-run"}"#,
248 ),
249 sse_text("understood, stopping"),
250 ]);
251 let scratch = scratch("deny");
252 let cwd = scratch.cwd.to_str().unwrap().to_string();
253
254 let output = run_headless(
255 endpoint.port,
256 &scratch,
257 &["-p", "try the command", "--cwd", &cwd],
258 );
259
260 // A denial is fed to the model, not treated as a failure: the turn still
261 // completes, so the exit code is 0.
262 assert_eq!(
263 output.status.code(),
264 Some(0),
265 "stderr: {}",
266 String::from_utf8_lossy(&output.stderr)
267 );
268 assert!(
269 stdout_of(&output).contains("understood, stopping"),
270 "final answer must reach stdout, got: {}",
271 stdout_of(&output)
272 );
273
274 let requests = endpoint.requests.lock().unwrap();
275 assert_eq!(requests.len(), 2, "expected exactly two completions");
276
277 // The endpoint sees the denial as the tool result — including the
278 // --allow-tool hint — and never the command's real output.
279 let messages = requests[1]["messages"].as_array().expect("messages");
280 let result = messages
281 .iter()
282 .find(|m| m["role"] == "tool" && m["tool_call_id"] == "call_1")
283 .expect("tool result for the denied call");
284 let content = result["content"].as_str().unwrap_or_default();
285 assert!(
286 content.contains("was not executed") && content.contains("--allow-tool"),
287 "denial text should reach the model and mention --allow-tool: {content}"
288 );
289 assert!(
290 !content.contains("must-not-run"),
291 "the denied command must not have executed: {content}"
292 );
293 }
294
295 #[test]
296 fn unknown_flag_exits_2_with_usage() {
297 let endpoint = start_fake_endpoint(vec![]);
298 let scratch = scratch("usage");
299
300 let output = run_headless(endpoint.port, &scratch, &["-p", "x", "--frobnicate"]);
301
302 assert_eq!(output.status.code(), Some(2));
303 let stderr = String::from_utf8_lossy(&output.stderr);
304 assert!(
305 stderr.contains("--frobnicate") && stderr.contains("Usage:"),
306 "usage must be printed to stderr: {stderr}"
307 );
308 assert!(
309 endpoint.requests.lock().unwrap().is_empty(),
310 "a bad invocation must never reach the endpoint"
311 );
312 }