@setoelkahfi / sigit / commits / 98ed89a

Add headless -p mode: run one prompt and exit

CI, scripts, and the Cloud Agent sandbox runner all want the same thing: hand sigit a prompt, let it run one agent turn with tools, and exit with a meaningful code. Until now that required an ACP client or a TTY. sigit -p "prompt" dispatches before the TTY/ACP split, streams the assistant text to stdout while logs and tool progress stay on stderr, and exits 0 on a completed turn, 1 on inference errors, 2 on bad invocations. --cwd sets the working directory before instruction files and project MCP config load; --quiet prints only the final message. Backends resolve like ACP: provider override, then the signed-in cloud tier when local inference is off, and a clear error instead of silently downloading a model. Nobody can answer a permission prompt in a headless run, so ask collapses to a denial that names the fix: --allow-tool grants a tool for the run, --deny-tool blocks one even over a settings allow, and SIGIT_PERMISSIONS=allow stays the blunt instrument. The conversation is saved to the session store either way, since a partial transcript beats none for a future resume.

paydii committed Jul 5, 2026 at 08:48 UTC 98ed89ac08e6b9dc8420b717d2be52f4faa851da
3 files changed +819
src/headless.rs new
+471
@@ -0,0 +1,471 @@
1 +//! Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and exits.
2 +//!
3 +//! This is the entry point for CI, scripts, cron, and the Cloud Agent sandbox
4 +//! runner: no client, no TTY, just plain stdio. Assistant text streams to
5 +//! stdout as it is generated (`--quiet` restricts stdout to the final message);
6 +//! logs and tool progress go to stderr.
7 +//!
8 +//! Cross-platform by design — unlike the ratatui TUI this is NOT gated on
9 +//! `#[cfg(unix)]`, so Windows gets it too.
10 +//!
11 +//! Permission model: nobody is around to answer `ask`, so `ask` collapses to a
12 +//! denial telling the model the tool was not pre-approved (mentioning
13 +//! `--allow-tool`). `--allow-tool <name>` pre-grants a tool for the run's
14 +//! session; `--deny-tool <name>` blocks a tool even if settings would allow it.
15 +//! `SIGIT_PERMISSIONS=allow` remains the blunt instrument.
16 +//!
17 +//! Exit codes: 0 — turn completed; 1 — inference/tool-loop or backend
18 +//! resolution error; 2 — bad invocation (handled by the caller in `main`).
19 +
20 +use std::collections::HashSet;
21 +use std::io::Write;
22 +use std::path::PathBuf;
23 +use std::sync::Arc;
24 +
25 +use crate::backend::{
26 + self, InferenceBackend, OpenAiBackend, ToolResult as BackendToolResult, TurnResult,
27 +};
28 +use crate::{permissions, provider, session_store, settings, tools};
29 +
30 +/// Session id for headless runs: permission grants and the saved conversation
31 +/// live under this key, so a follow-up feature can resume it.
32 +pub const HEADLESS_SESSION: &str = "headless";
33 +
34 +pub const USAGE: &str = "Usage: sigit -p \"<prompt>\" [--cwd <dir>] [--quiet] \
35 + [--allow-tool <name>]... [--deny-tool <name>]...";
36 +
37 +/// Parsed `sigit -p` invocation.
38 +#[derive(Debug, Clone, PartialEq, Eq)]
39 +pub struct HeadlessConfig {
40 + pub prompt: String,
41 + /// Working directory to enter before anything loads (instruction files and
42 + /// project-local MCP config then resolve from it).
43 + pub cwd: Option<PathBuf>,
44 + /// Print only the final assistant message to stdout (no streaming).
45 + pub quiet: bool,
46 + /// Tools pre-approved for the run (fed to `permissions::grant_for_session`).
47 + pub allow_tools: Vec<String>,
48 + /// Tools blocked for the run, overriding even settings-level allow.
49 + pub deny_tools: Vec<String>,
50 +}
51 +
52 +/// Parse the process arguments (without argv[0]) for headless mode.
53 +///
54 +/// Returns `Ok(None)` when `-p`/`--prompt` is absent — the invocation is not
55 +/// headless and falls through to the TTY/ACP dispatch. Once `-p` is present,
56 +/// every remaining argument must be a recognized flag (position-insensitive);
57 +/// anything else is a usage error the caller reports on stderr with exit 2.
58 +pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
59 + if !args.iter().any(|arg| arg == "-p" || arg == "--prompt") {
60 + return Ok(None);
61 + }
62 +
63 + let mut prompt: Option<String> = None;
64 + let mut cwd: Option<PathBuf> = None;
65 + let mut quiet = false;
66 + let mut allow_tools: Vec<String> = Vec::new();
67 + let mut deny_tools: Vec<String> = Vec::new();
68 +
69 + let mut iter = args.iter();
70 + while let Some(arg) = iter.next() {
71 + match arg.as_str() {
72 + "-p" | "--prompt" => {
73 + let value = iter
74 + .next()
75 + .ok_or_else(|| format!("{arg} requires a prompt argument"))?;
76 + if prompt.is_some() {
77 + return Err(format!("{arg} was given more than once"));
78 + }
79 + prompt = Some(value.clone());
80 + }
81 + "--cwd" => {
82 + let value = iter
83 + .next()
84 + .ok_or_else(|| "--cwd requires a directory argument".to_string())?;
85 + cwd = Some(PathBuf::from(value));
86 + }
87 + "--quiet" => quiet = true,
88 + "--allow-tool" => {
89 + let value = iter
90 + .next()
91 + .ok_or_else(|| "--allow-tool requires a tool name".to_string())?;
92 + allow_tools.push(value.clone());
93 + }
94 + "--deny-tool" => {
95 + let value = iter
96 + .next()
97 + .ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
98 + deny_tools.push(value.clone());
99 + }
100 + other => return Err(format!("unknown argument: {other}")),
101 + }
102 + }
103 +
104 + // Unreachable in practice (the pre-check saw a `-p` token), unless that
105 + // token was consumed as another flag's value — still a usage error.
106 + let prompt = prompt.ok_or_else(|| "missing -p/--prompt".to_string())?;
107 + if prompt.trim().is_empty() {
108 + return Err("the prompt must not be empty".to_string());
109 + }
110 +
111 + Ok(Some(HeadlessConfig {
112 + prompt,
113 + cwd,
114 + quiet,
115 + allow_tools,
116 + deny_tools,
117 + }))
118 +}
119 +
120 +/// Denial fed to the model when a tool at `ask` level fires in a headless run.
121 +fn not_preapproved_denial(tool_name: &str) -> String {
122 + format!(
123 + "`{tool_name}` was not executed: this is a non-interactive headless run and \
124 + nobody can answer a permission prompt, so tools at the `ask` level are denied \
125 + unless pre-approved. The user can re-run with `--allow-tool {tool_name}` to \
126 + approve it (or set SIGIT_PERMISSIONS=allow). Do not retry the same call; \
127 + continue without this tool or report what remains to be done."
128 + )
129 +}
130 +
131 +/// Denial fed to the model when a tool was blocked with `--deny-tool`.
132 +fn deny_flag_denial(tool_name: &str) -> String {
133 + format!(
134 + "`{tool_name}` is blocked for this headless run (--deny-tool). Do not retry it; \
135 + continue without this tool or report what remains to be done."
136 + )
137 +}
138 +
139 +/// Run one headless prompt to completion. Returns the process exit code.
140 +///
141 +/// The caller (`main`) has already applied `--cwd`, initialized logging to
142 +/// stderr, set up the model cache, and run MCP discovery.
143 +pub async fn run(config: HeadlessConfig) -> i32 {
144 + // Fresh permission state for the run, then apply the flag grants.
145 + permissions::reset_session(HEADLESS_SESSION);
146 + for tool in &config.allow_tools {
147 + permissions::grant_for_session(HEADLESS_SESSION, tool);
148 + }
149 + let denied: HashSet<&str> = config.deny_tools.iter().map(String::as_str).collect();
150 +
151 + // Backend resolution mirrors the ACP server: the explicit provider override
152 + // first, else the signed-in cloud tier when local inference is off (what
153 + // `apply_startup_inference_mode` does at every ACP session entry). There is
154 + // never an implicit on-device load — a fresh process has no model in memory
155 + // and headless mode must not silently download gigabytes.
156 + let provider_cfg = provider::active_provider().or_else(|| {
157 + if settings::local_inference_enabled() {
158 + None
159 + } else {
160 + provider::cloud_tier_provider("balanced")
161 + }
162 + });
163 + let Some(cfg) = provider_cfg else {
164 + eprintln!(
165 + "sigit: headless mode needs a remote inference provider — running on-device \
166 + would require loading (and possibly downloading) a local model, which \
167 + headless mode never does implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY \
168 + (or configure providers.toml), or sign in with `sigit login` and turn local \
169 + inference off."
170 + );
171 + return 1;
172 + };
173 +
174 + log::info!(
175 + "headless: using {} (model {}) at {}",
176 + cfg.display_name,
177 + cfg.model,
178 + cfg.base_url
179 + );
180 + crate::register_subagent_factory_for(&cfg);
181 +
182 + // Same always-on project context the other surfaces inject: cwd guidance
183 + // plus AGENTS.md / CLAUDE.md instruction files.
184 + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
185 + let mut system_prompt = crate::system_prompt_for_model(true).to_string();
186 + system_prompt.push_str("\n\n");
187 + system_prompt.push_str(&crate::session_context_message(&cwd));
188 +
189 + let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
190 + cfg.base_url,
191 + cfg.api_key,
192 + cfg.model,
193 + Some(system_prompt),
194 + ));
195 +
196 + let outcome = run_prompt(backend.as_ref(), &config, &denied).await;
197 +
198 + // Persist the conversation like the other surfaces, so a follow-up feature
199 + // can resume it. Saved even on error: a partial transcript beats none.
200 + let snapshot = backend.history_snapshot().await;
201 + if let Err(error) = session_store::save(HEADLESS_SESSION, &snapshot) {
202 + log::warn!("headless: session save failed: {error}");
203 + }
204 +
205 + match outcome {
206 + Ok(()) => 0,
207 + Err(error) => {
208 + eprintln!("sigit: {error}");
209 + 1
210 + }
211 + }
212 +}
213 +
214 +/// The turn loop: send the prompt, execute tool calls under the permission
215 +/// policy, feed results back, repeat up to `MAX_TOOL_ROUNDS` — the same shape
216 +/// as the ACP `handle_prompt`, minus the ACP notifications.
217 +async fn run_prompt(
218 + backend: &dyn InferenceBackend,
219 + config: &HeadlessConfig,
220 + denied: &HashSet<&str>,
221 +) -> Result<(), backend::BackendError> {
222 + let tools = crate::agent_tools_as_specs();
223 +
224 + // Token sink: assistant text streams through this while a turn runs; the
225 + // drain loop forwards the visible portion to stdout live. In quiet mode no
226 + // sink is passed and only the final message is printed.
227 + let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
228 + let sink_opt = if config.quiet { None } else { Some(&sink) };
229 + let mut assembled = String::new();
230 + let mut sent = String::new();
231 + let mut streamed_any = false;
232 +
233 + let mut result = drain_to_stdout(
234 + backend.send_message_with_tools(&config.prompt, &tools, sink_opt),
235 + &mut sink_rx,
236 + &mut assembled,
237 + &mut sent,
238 + &mut streamed_any,
239 + )
240 + .await?;
241 +
242 + let mut round = 0;
243 +
244 + while !result.tool_calls.is_empty() && round < crate::MAX_TOOL_ROUNDS {
245 + round += 1;
246 +
247 + // Auto-compaction: long tool runs grow history fast; fold it into a
248 + // summary before the next round rather than blowing the window.
249 + let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
250 + if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
251 + log::info!(
252 + "headless: history ≈{estimate} tokens exceeds budget {} — compacting",
253 + backend::DEFAULT_CONTEXT_TOKEN_BUDGET
254 + );
255 + if let Err(error) = backend.compact_history(backend::COMPACT_KEEP_LAST).await {
256 + log::warn!("headless: compaction failed: {error}");
257 + }
258 + }
259 +
260 + let mut tool_results = Vec::new();
261 +
262 + for tc in &result.tool_calls {
263 + let args_preview: String = tc.arguments.chars().take(120).collect();
264 + eprintln!("→ {}({args_preview})", tc.name);
265 +
266 + // The headless deny set outranks everything, including a
267 + // settings-level allow and a --allow-tool grant for the same name.
268 + let output = if denied.contains(tc.name.as_str()) {
269 + log::info!("headless: {} blocked by --deny-tool", tc.name);
270 + deny_flag_denial(&tc.name)
271 + } else {
272 + match permissions::decision_for(HEADLESS_SESSION, &tc.name) {
273 + permissions::Decision::Allow => {
274 + tools::execute_tool(&tc.name, &tc.arguments).await
275 + }
276 + permissions::Decision::Deny(reason) => {
277 + log::info!("headless: {} denied by policy", tc.name);
278 + reason
279 + }
280 + // Nobody can answer an interactive prompt here: ask
281 + // collapses to a denial pointing at --allow-tool.
282 + permissions::Decision::Ask => {
283 + log::info!("headless: {} not pre-approved — denied", tc.name);
284 + not_preapproved_denial(&tc.name)
285 + }
286 + }
287 + };
288 +
289 + tool_results.push(BackendToolResult {
290 + tool_call_id: tc.id.clone(),
291 + content: output,
292 + });
293 + }
294 +
295 + let next_tools = if round < crate::MAX_TOOL_ROUNDS {
296 + Some(tools.as_slice())
297 + } else {
298 + None // last round: force text
299 + };
300 +
301 + result = drain_to_stdout(
302 + backend.send_tool_results(tool_results, next_tools, sink_opt),
303 + &mut sink_rx,
304 + &mut assembled,
305 + &mut sent,
306 + &mut streamed_any,
307 + )
308 + .await?;
309 + }
310 +
311 + // Final text: in quiet mode nothing streamed, so print the final assistant
312 + // message now; otherwise print only what streaming did not already cover.
313 + let (_think, final_visible) = crate::chat::strip_think_blocks(result.text.trim());
314 + let mut stdout = std::io::stdout();
315 + if config.quiet {
316 + if !final_visible.is_empty() {
317 + let _ = writeln!(stdout, "{final_visible}");
318 + }
319 + } else if streamed_any {
320 + // The reply is already on stdout; end the line for the shell.
321 + let _ = writeln!(stdout);
322 + } else if !final_visible.is_empty() {
323 + let _ = writeln!(stdout, "{final_visible}");
324 + }
325 + let _ = stdout.flush();
326 +
327 + log::info!("headless: prompt complete — {round} tool round(s)");
328 + Ok(())
329 +}
330 +
331 +/// Run one inference turn while forwarding streamed tokens to stdout as they
332 +/// arrive — the stdio counterpart of `SiGitAgent::drain_turn`.
333 +async fn drain_to_stdout<F>(
334 + fut: F,
335 + sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
336 + assembled: &mut String,
337 + sent: &mut String,
338 + streamed_any: &mut bool,
339 +) -> Result<TurnResult, backend::BackendError>
340 +where
341 + F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
342 +{
343 + tokio::pin!(fut);
344 + let result = loop {
345 + tokio::select! {
346 + done = &mut fut => break done,
347 + Some(piece) = sink_rx.recv() => {
348 + emit_visible_chunk(&piece, assembled, sent, streamed_any);
349 + }
350 + }
351 + };
352 + // Flush tokens that landed between the last poll and the future resolving.
353 + while let Ok(piece) = sink_rx.try_recv() {
354 + emit_visible_chunk(&piece, assembled, sent, streamed_any);
355 + }
356 + result
357 +}
358 +
359 +/// Append a streamed fragment, strip `<think>` reasoning from the running
360 +/// text, and print only the newly revealed visible suffix. Tracking the
361 +/// assembled text (not just deltas) keeps think-block stripping correct even
362 +/// when a tag spans chunk boundaries — same approach as the ACP path.
363 +fn emit_visible_chunk(
364 + piece: &str,
365 + assembled: &mut String,
366 + sent: &mut String,
367 + streamed_any: &mut bool,
368 +) {
369 + assembled.push_str(piece);
370 + let (_think, visible) = crate::chat::strip_think_blocks(assembled);
371 + match visible.strip_prefix(sent.as_str()) {
372 + Some(extra) if !extra.is_empty() => {
373 + print!("{extra}");
374 + let _ = std::io::stdout().flush();
375 + *sent = visible;
376 + *streamed_any = true;
377 + }
378 + // No new visible text, or the visible prefix changed retroactively
379 + // (rare, e.g. a late-closing think tag): resync without reprinting.
380 + _ => *sent = visible,
381 + }
382 +}
383 +
384 +#[cfg(test)]
385 +mod tests {
386 + use super::*;
387 +
388 + fn args(list: &[&str]) -> Vec<String> {
389 + list.iter().map(|s| s.to_string()).collect()
390 + }
391 +
392 + #[test]
393 + fn absent_prompt_flag_is_not_headless() {
394 + assert_eq!(parse_args(&args(&[])), Ok(None));
395 + assert_eq!(parse_args(&args(&["login"])), Ok(None));
396 + // Flags alone don't trigger headless mode; only -p/--prompt does.
397 + assert_eq!(parse_args(&args(&["--quiet"])), Ok(None));
398 + }
399 +
400 + #[test]
401 + fn parses_minimal_invocation() {
402 + let config = parse_args(&args(&["-p", "do the thing"])).unwrap().unwrap();
403 + assert_eq!(config.prompt, "do the thing");
404 + assert_eq!(config.cwd, None);
405 + assert!(!config.quiet);
406 + assert!(config.allow_tools.is_empty());
407 + assert!(config.deny_tools.is_empty());
408 + }
409 +
410 + #[test]
411 + fn long_form_prompt_flag_works() {
412 + let config = parse_args(&args(&["--prompt", "hello"])).unwrap().unwrap();
413 + assert_eq!(config.prompt, "hello");
414 + }
415 +
416 + #[test]
417 + fn parses_all_flags_position_insensitively() {
418 + let config = parse_args(&args(&[
419 + "--quiet",
420 + "--allow-tool",
421 + "run_command",
422 + "--cwd",
423 + "/tmp/project",
424 + "-p",
425 + "build it",
426 + "--deny-tool",
427 + "delete_file",
428 + "--allow-tool",
429 + "edit_file",
430 + ]))
431 + .unwrap()
432 + .unwrap();
433 + assert_eq!(config.prompt, "build it");
434 + assert_eq!(config.cwd, Some(PathBuf::from("/tmp/project")));
435 + assert!(config.quiet);
436 + assert_eq!(config.allow_tools, vec!["run_command", "edit_file"]);
437 + assert_eq!(config.deny_tools, vec!["delete_file"]);
438 + }
439 +
440 + #[test]
441 + fn prompt_value_may_look_like_a_flag() {
442 + // -p consumes the next argument verbatim.
443 + let config = parse_args(&args(&["-p", "--quiet"])).unwrap().unwrap();
444 + assert_eq!(config.prompt, "--quiet");
445 + assert!(!config.quiet);
446 + }
447 +
448 + #[test]
449 + fn unknown_flag_is_a_usage_error() {
450 + let error = parse_args(&args(&["-p", "x", "--frobnicate"])).unwrap_err();
451 + assert!(error.contains("--frobnicate"), "{error}");
452 + }
453 +
454 + #[test]
455 + fn missing_values_are_usage_errors() {
456 + assert!(parse_args(&args(&["-p"])).is_err());
457 + assert!(parse_args(&args(&["-p", "x", "--cwd"])).is_err());
458 + assert!(parse_args(&args(&["-p", "x", "--allow-tool"])).is_err());
459 + assert!(parse_args(&args(&["-p", "x", "--deny-tool"])).is_err());
460 + }
461 +
462 + #[test]
463 + fn duplicate_prompt_is_a_usage_error() {
464 + assert!(parse_args(&args(&["-p", "a", "--prompt", "b"])).is_err());
465 + }
466 +
467 + #[test]
468 + fn empty_prompt_is_a_usage_error() {
469 + assert!(parse_args(&args(&["-p", " "])).is_err());
470 + }
471 +}
src/main.rs
+36
@@ -32,6 +32,7 @@ mod account;
32 mod backend;
33 mod chat;
34 mod credentials;
35 +mod headless;
36 mod instructions;
37 mod mcp;
38 mod models;
@@ -3134,6 +3135,41 @@ async fn main() -> anyhow::Result<()> {
3135 }
3136 }
3137
3138 + // Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and
3139 + // exits. Plain stdio and cross-platform (unlike the Unix-only TUI), so it
3140 + // is dispatched here, before the TTY/ACP split, like the account
3141 + // subcommands above.
3142 + let cli_args: Vec<String> = std::env::args().skip(1).collect();
3143 + match headless::parse_args(&cli_args) {
3144 + Ok(None) => {}
3145 + Ok(Some(config)) => {
3146 + // Logs to stderr; stdout stays clean for the assistant's answer.
3147 + init_logging(false);
3148 + // Enter --cwd before anything loads, so instruction files and
3149 + // project-local MCP config resolve from it (the headless
3150 + // counterpart of the ACP session cwd handling).
3151 + if let Some(dir) = &config.cwd
3152 + && let Err(error) = std::env::set_current_dir(dir)
3153 + {
3154 + eprintln!("sigit: cannot use --cwd {}: {error}", dir.display());
3155 + std::process::exit(2);
3156 + }
3157 + setup::setup_shared_model_cache();
3158 + // Best-effort MCP discovery (incl. the official server), like the
3159 + // other entry points, so MCP tools are offered to the model.
3160 + mcp::init().await;
3161 + log::info!(
3162 + "siGit v{} starting (headless mode)",
3163 + env!("CARGO_PKG_VERSION")
3164 + );
3165 + std::process::exit(headless::run(config).await);
3166 + }
3167 + Err(error) => {
3168 + eprintln!("sigit: {error}\n{}", headless::USAGE);
3169 + std::process::exit(2);
3170 + }
3171 + }
3172 +
3173 let is_tty = std::io::stdin().is_terminal();
3174
3175 if is_tty {
tests/headless_mode.rs new
+312
@@ -0,0 +1,312 @@
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 +}