use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}
fn fresh_home(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"supercode-ux28-hooks-cli-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn drain_request(sock: &mut std::net::TcpStream) -> String {
sock.set_read_timeout(Some(Duration::from_millis(200)))
.expect("set read timeout");
let mut buf = Vec::new();
let mut chunk = [0u8; 65536];
loop {
match sock.read(&mut chunk) {
Ok(0) => break,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
break
}
Err(_) => break,
}
}
sock.set_read_timeout(None).expect("clear read timeout");
String::from_utf8_lossy(&buf).into_owned()
}
fn write_sse(sock: &mut std::net::TcpStream, sse: &str) {
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
sse.len(),
sse
);
sock.write_all(resp.as_bytes())
.expect("write stub response");
sock.flush().ok();
}
fn spawn_text_stub(text: &str) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
let addr = listener.local_addr().unwrap();
let text = text.to_string();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept one connection");
drain_request(&mut sock);
let sse = format!(
"data: {{\"choices\":[{{\"delta\":{{\"content\":{}}}}}]}}\n\n\
data: [DONE]\n\n",
serde_json::to_string(&text).unwrap()
);
write_sse(&mut sock, &sse);
});
(addr, handle)
}
fn spawn_tool_round_trip_stub(
tool_name: &str,
tool_args_json: &str,
) -> (
std::net::SocketAddr,
std::thread::JoinHandle<()>,
mpsc::Receiver<String>,
) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
let tool_name = tool_name.to_string();
let tool_args_json = tool_args_json.to_string();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept round-trip 1");
drain_request(&mut sock);
let escaped_args = tool_args_json.replace('"', "\\\"");
let sse = format!(
"data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"call_1\",\"function\":{{\"name\":\"{tool_name}\",\"arguments\":\"{escaped_args}\"}}}}]}}}}]}}\n\n\
data: {{\"choices\":[{{\"delta\":{{}}}}],\"usage\":{{\"prompt_tokens\":11,\"completion_tokens\":3,\"total_tokens\":14}}}}\n\n\
data: [DONE]\n\n"
);
write_sse(&mut sock, &sse);
drop(sock);
let (mut sock, _) = listener.accept().expect("accept round-trip 2");
let body = drain_request(&mut sock);
let _ = tx.send(body);
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n\
data: [DONE]\n\n";
write_sse(&mut sock, sse);
});
(addr, handle, rx)
}
fn write_hooks_config(home: &Path, body: &str) {
std::fs::write(home.join("config.toml"), body).expect("write config.toml");
}
fn run_piped(home: &Path, base_url: &str, extra: &[&str], envs: &[(&str, &str)]) -> Output {
let mut args = vec!["--api-key", "x", "--base-url", base_url];
args.extend_from_slice(extra);
let mut cmd = Command::new(bin());
cmd.env("SUPERCODE_HOME", home)
.env_remove("OPENROUTER_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.env_remove("NO_COLOR")
.env_remove("SUPERCODE_QUIET")
.env_remove("SUPERCODE_HOOK_PRE_TOOL")
.env_remove("SUPERCODE_HOOK_POST_TOOL")
.env_remove("SUPERCODE_HOOK_SESSION_START")
.env_remove("SUPERCODE_HOOK_SESSION_END")
.env_remove("SUPERCODE_HOOK_STOP")
.env_remove("SUPERCODE_HOOK_USER_PROMPT_SUBMIT")
.env_remove("SUPERCODE_HOOK_NOTIFICATION")
.env_remove("SUPERCODE_HOOK_SUBAGENT_START")
.env_remove("SUPERCODE_HOOK_SUBAGENT_STOP")
.env_remove("SUPERCODE_HOOK_PRE_COMPACT")
.env_remove("SUPERCODE_HOOK_POST_COMPACT")
.env_remove("SUPERCODE_HOOK_TIMEOUT_MS");
for (k, v) in envs {
cmd.env(k, v);
}
cmd.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn the supercode binary")
.wait_with_output()
.expect("child process failed")
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
#[test]
fn dev01_all_four_events_fire_in_lifecycle_order_and_stdout_stays_clean() {
let home = fresh_home("order");
let log = home.join("hooks.log");
write_hooks_config(
&home,
r#"
[hooks]
session_start = "echo session_start >> $HOOKS_LOG"
pre_tool = "echo \"pre_tool $SUPERCODE_HOOK_TOOL\" >> $HOOKS_LOG; exit 0"
post_tool = "echo \"post_tool $SUPERCODE_HOOK_TOOL\" >> $HOOKS_LOG"
session_end = "echo session_end >> $HOOKS_LOG"
timeout_ms = 5000
"#,
);
let (addr, server, _rx) = spawn_tool_round_trip_stub("list_dir", "{}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&[
"--disallow-tool",
"bash",
"--disallow-tool",
"shell",
"run",
"list the dir",
],
&[("HOOKS_LOG", log.to_str().unwrap())],
);
server.join().expect("stub thread panicked");
assert!(
out.status.success(),
"run failed: status={:?} stdout={} stderr={}",
out.status,
stdout(&out),
stderr(&out)
);
assert_eq!(
stdout(&out).trim(),
"done",
"stdout must be exactly the model's reply — no hook chrome, got: {}",
stdout(&out)
);
let log_text = std::fs::read_to_string(&log).unwrap_or_default();
let lines: Vec<&str> = log_text.lines().collect();
assert_eq!(
lines,
vec![
"session_start",
"pre_tool list_dir",
"post_tool list_dir",
"session_end"
],
"hooks must fire in exactly this lifecycle order, got: {log_text:?}"
);
}
#[test]
fn dev02_pre_tool_hook_denies_a_call_reason_reaches_the_model_and_run_still_succeeds() {
let home = fresh_home("deny");
let marker = home.join("pre-tool-ran.marker");
write_hooks_config(
&home,
r#"
[hooks]
pre_tool = "touch $MARKER; echo 'no shells allowed' >&2; exit 1"
"#,
);
let (addr, server, rx) = spawn_tool_round_trip_stub("bash", "{\"command\":\"ls\"}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "list files"],
&[("MARKER", marker.to_str().unwrap())],
);
server.join().expect("stub thread panicked");
assert!(
out.status.success(),
"a denied tool call must NOT crash the run: status={:?} stdout={} stderr={}",
out.status,
stdout(&out),
stderr(&out)
);
assert!(marker.exists(), "the pre_tool hook must actually have run");
assert_eq!(
stdout(&out).trim(),
"done",
"the run must still complete normally after the denial"
);
assert!(
stderr(&out).contains("[hook:pre_tool] denied"),
"a denial must be reported on stderr, got: {}",
stderr(&out)
);
assert!(
!stdout(&out).contains("[hook:"),
"hook chrome must never reach stdout, got: {}",
stdout(&out)
);
let round2_body = rx
.recv_timeout(Duration::from_secs(5))
.expect("round 2 request");
assert!(
round2_body.contains("blocked by pre-tool hook"),
"round-2 request must show the call was blocked, got: {round2_body}"
);
}
#[test]
fn dev02_pre_tool_hook_timeout_denies_and_stays_bounded() {
let home = fresh_home("timeout");
write_hooks_config(
&home,
r#"
[hooks]
pre_tool = "sleep 5"
timeout_ms = 200
"#,
);
let (addr, server, rx) = spawn_tool_round_trip_stub("bash", "{\"command\":\"ls\"}");
let start = Instant::now();
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "list files"],
&[],
);
let elapsed = start.elapsed();
server.join().expect("stub thread panicked");
assert!(
out.status.success(),
"a timed-out pre_tool hook must NOT crash the run: status={:?} stderr={}",
out.status,
stderr(&out)
);
assert!(
elapsed < Duration::from_secs(4),
"the run must be bounded by timeout_ms (200ms), not the hook's 5s sleep; took {elapsed:?}"
);
assert!(
stderr(&out).contains("timed out"),
"the timeout must be reported on stderr, got: {}",
stderr(&out)
);
let round2_body = rx
.recv_timeout(Duration::from_secs(5))
.expect("round 2 request");
assert!(
round2_body.contains("blocked by pre-tool hook"),
"a timed-out pre_tool hook must fail CLOSED (deny), got round-2 body: {round2_body}"
);
}
#[test]
fn dev03_no_hooks_configured_means_nothing_ever_spawns() {
let home = fresh_home("empty");
let (addr, server, rx) = spawn_tool_round_trip_stub("list_dir", "{}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "list the dir"],
&[],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert_eq!(stdout(&out).trim(), "done");
let round2_body = rx
.recv_timeout(Duration::from_secs(5))
.expect("round 2 request");
assert!(
!round2_body.contains("blocked by pre-tool hook"),
"with no [hooks] configured nothing may be denied, got: {round2_body}"
);
assert!(
!stderr(&out).contains("[hook:"),
"with no [hooks] configured, no hook diagnostic of any kind may appear, got: {}",
stderr(&out)
);
}
#[test]
fn dev03_project_local_hooks_config_is_ignored() {
let home = fresh_home("project-ignored");
let project_dir = home.join("untrusted-repo");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join(".supercode.toml"),
"[hooks]\npre_tool = \"exit 1\"\n",
)
.unwrap();
let (addr, server, rx) = spawn_tool_round_trip_stub("list_dir", "{}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&[
"--cwd",
project_dir.to_str().unwrap(),
"run",
"list the dir",
],
&[],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
let round2_body = rx
.recv_timeout(Duration::from_secs(5))
.expect("round 2 request");
assert!(
!round2_body.contains("blocked by pre-tool hook"),
"a project-local .supercode.toml must NEVER be able to register a hook, got: {round2_body}"
);
assert!(
stderr(&out).contains("ignoring untrusted field(s)") && stderr(&out).contains("hooks"),
"the existing untrusted-project-config warning must name `hooks`, got: {}",
stderr(&out)
);
}
#[test]
fn quiet_suppresses_successful_hook_chrome_but_not_failure_diagnostics() {
let home = fresh_home("quiet");
write_hooks_config(
&home,
r#"
[hooks]
session_start = "echo 'hello from a happy hook'"
session_end = "echo bye-broken >&2; exit 3"
"#,
);
let (addr, server) = spawn_text_stub("hi there");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["--quiet", "run", "say hi"],
&[],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert_eq!(stdout(&out).trim(), "hi there");
assert!(
!stderr(&out).contains("hello from a happy hook"),
"--quiet must suppress a SUCCESSFUL hook's own chrome, got: {}",
stderr(&out)
);
assert!(
stderr(&out).contains("[hook:session_end] exited 3"),
"--quiet must NOT suppress a FAILING hook's diagnostic, got: {}",
stderr(&out)
);
}
#[test]
fn p57_user_prompt_submit_fires_after_session_start_before_pre_tool_with_prompt() {
let home = fresh_home("ups-order");
let log = home.join("hooks.log");
write_hooks_config(
&home,
r#"
[hooks]
session_start = "echo session_start >> $HOOKS_LOG"
user_prompt_submit = "echo \"user_prompt_submit $SUPERCODE_HOOK_PROMPT\" >> $HOOKS_LOG"
pre_tool = "echo pre_tool >> $HOOKS_LOG; exit 0"
post_tool = "echo post_tool >> $HOOKS_LOG"
session_end = "echo session_end >> $HOOKS_LOG"
timeout_ms = 5000
"#,
);
let (addr, server, _rx) = spawn_tool_round_trip_stub("list_dir", "{}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&[
"--disallow-tool",
"bash",
"--disallow-tool",
"shell",
"run",
"list the dir",
],
&[("HOOKS_LOG", log.to_str().unwrap())],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert_eq!(stdout(&out).trim(), "done", "stdout must stay clean");
let log_text = std::fs::read_to_string(&log).unwrap_or_default();
let lines: Vec<&str> = log_text.lines().collect();
assert_eq!(
lines,
vec![
"session_start",
"user_prompt_submit list the dir",
"pre_tool",
"post_tool",
"session_end",
],
"user_prompt_submit must fire after session_start, before the tool call, and carry \
the prompt via env; got: {log_text:?}"
);
}
#[test]
fn p57_notification_hook_fires_on_turn_finish_with_agent_completed_kind() {
let home = fresh_home("notif");
let log = home.join("hooks.log");
write_hooks_config(
&home,
r#"
[hooks]
notification = "echo \"notification $SUPERCODE_HOOK_NOTIFICATION_KIND $SUPERCODE_HOOK_MODEL\" >> $HOOKS_LOG"
"#,
);
let (addr, server) = spawn_text_stub("hi there");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "say hi"],
&[("HOOKS_LOG", log.to_str().unwrap())],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert_eq!(stdout(&out).trim(), "hi there", "stdout must stay clean");
let log_text = std::fs::read_to_string(&log).unwrap_or_default();
assert!(
log_text.contains("notification agent_completed"),
"the notification hook must fire at turn-finish with kind=agent_completed, got: {log_text:?}"
);
}
#[test]
fn p57_project_local_new_event_hooks_are_ignored() {
let home = fresh_home("p57-project-ignored");
let log = home.join("hooks.log");
let project_dir = home.join("untrusted-repo");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join(".supercode.toml"),
"[hooks]\nuser_prompt_submit = \"echo pwned >> $HOOKS_LOG\"\nsubagent_stop = \"echo pwned2 >> $HOOKS_LOG\"\nnotification = \"echo pwned3 >> $HOOKS_LOG\"\n",
)
.unwrap();
let (addr, server) = spawn_text_stub("hi");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["--cwd", project_dir.to_str().unwrap(), "run", "say hi"],
&[("HOOKS_LOG", log.to_str().unwrap())],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert!(
!log.exists(),
"a project-local .supercode.toml must NEVER register ANY hook (old or new event); \
the log file was created, meaning a stripped hook ran"
);
assert!(
stderr(&out).contains("ignoring untrusted field(s)") && stderr(&out).contains("hooks"),
"the untrusted-project-config warning must name `hooks`, got: {}",
stderr(&out)
);
}
#[test]
fn json_output_stays_byte_clean_even_with_a_noisy_pre_tool_hook() {
let home = fresh_home("json-clean");
write_hooks_config(
&home,
r#"
[hooks]
pre_tool = "echo 'NOISE ON STDOUT FROM THE HOOK'; exit 0"
post_tool = "echo 'MORE NOISE' >&2"
"#,
);
let (addr, server, _rx) = spawn_tool_round_trip_stub("list_dir", "{}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "--output-format", "json", "list the dir"],
&[],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
let text = stdout(&out);
let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap_or_else(|e| {
panic!("stdout must be exactly one JSON envelope: {e}\nstdout: {text}")
});
assert!(parsed.is_object(), "expected a JSON object envelope");
assert!(
!text.contains("NOISE"),
"a hook's own stdout must never reach --output-format json's stdout, got: {text}"
);
}
#[test]
fn env_var_hook_command_fires_and_overrides_the_config_file_value() {
let home = fresh_home("env-override");
let config_marker = home.join("config-notification.marker");
let env_marker = home.join("env-notification.marker");
write_hooks_config(
&home,
&format!(
"[hooks]\nnotification = \"touch {}\"\n",
config_marker.to_str().unwrap()
),
);
let (addr, server) = spawn_text_stub("hi there");
let env_cmd = format!("touch {}", env_marker.to_str().unwrap());
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "say hi"],
&[("SUPERCODE_HOOK_NOTIFICATION", env_cmd.as_str())],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert!(
env_marker.exists(),
"SUPERCODE_HOOK_NOTIFICATION must fire, proving env-var hook resolution works end to end"
);
assert!(
!config_marker.exists(),
"the env var must WIN over the config file's notification command (env > file precedence)"
);
}
#[test]
fn deferred_event_registered_via_config_emits_one_time_stderr_warning() {
let home = fresh_home("deferred-warn-config");
write_hooks_config(&home, "[hooks]\npre_compact = \"true\"\n");
let (addr, server) = spawn_text_stub("hi there");
let out = run_piped(&home, &format!("http://{addr}"), &["run", "say hi"], &[]);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
let err = stderr(&out);
assert!(
err.contains("pre_compact is registered but is not yet emitted"),
"registering a deferred event must warn once naming it, got: {err}"
);
let occurrences = err
.matches("pre_compact is registered but is not yet emitted")
.count();
assert_eq!(
occurrences, 1,
"the warning must fire exactly once per process (not once per HookSet::resolve call, \
which happens repeatedly across session_start/session_end/each turn), got stderr: {err}"
);
}
#[test]
fn deferred_event_registered_via_env_var_also_warns() {
let home = fresh_home("deferred-warn-env");
let (addr, server) = spawn_text_stub("hi there");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "say hi"],
&[("SUPERCODE_HOOK_SUBAGENT_STOP", "true")],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert!(
stderr(&out).contains("subagent_stop is registered but is not yet emitted"),
"an env-var-registered deferred event must also warn, got: {}",
stderr(&out)
);
}
#[test]
fn live_events_never_emit_the_deferred_no_op_warning() {
let home = fresh_home("deferred-warn-live-events");
write_hooks_config(
&home,
r#"
[hooks]
session_start = "true"
session_end = "true"
pre_tool = "exit 0"
post_tool = "true"
stop = "exit 0"
user_prompt_submit = "true"
notification = "true"
"#,
);
let (addr, server, _rx) = spawn_tool_round_trip_stub("list_dir", "{}");
let out = run_piped(
&home,
&format!("http://{addr}"),
&["run", "list the dir"],
&[],
);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert!(
!stderr(&out).contains("is registered but is not yet emitted"),
"none of the 7 LIVE events may ever trigger the deferred-no-op warning, got: {}",
stderr(&out)
);
}
#[test]
fn unconfigured_hooks_produce_zero_deferred_warning_stderr() {
let home = fresh_home("deferred-warn-unconfigured");
let (addr, server) = spawn_text_stub("hi there");
let out = run_piped(&home, &format!("http://{addr}"), &["run", "say hi"], &[]);
server.join().expect("stub thread panicked");
assert!(out.status.success(), "stderr={}", stderr(&out));
assert!(
!stderr(&out).contains("is registered but is not yet emitted"),
"an unconfigured [hooks] table must produce zero deferred-warning stderr, got: {}",
stderr(&out)
);
}