use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn lifeloop_bin() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop"))
}
fn write_fake_ccd(dir: &std::path::Path) -> std::path::PathBuf {
let path = dir.join("fake-ccd.sh");
fs::write(
&path,
r#"#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_CCD_LOG"
case "$*" in
*"start --refresh --continuation cnt-test-1"*)
if [ "$FAKE_CCD_MODE" = "continuation-unbound" ]; then
printf '%s\n' '{"command":"start","ok":true,"activation":{"thread_id":"thread-test-1"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":false}}'
else
printf '%s\n' '{"command":"start","ok":true,"activation":{"thread_id":"thread-test-1"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":true}}'
fi
;;
*"session renew prepare"*)
if [ "$FAKE_CCD_MODE" = "missing-token" ]; then
printf '%s\n' '{"command":"session-renew-prepare","ok":true,"renewal":{"renewal_lease_id":"lease-test-1","thread_id":"thread-test-1"},"continuation":{"path":"/tmp/test","command":"ccd start --refresh --continuation <missing>"}}'
elif [ "$FAKE_CCD_MODE" = "missing-thread" ]; then
printf '%s\n' '{"command":"session-renew-prepare","ok":true,"renewal":{"renewal_lease_id":"lease-test-1"},"continuation":{"token":"cnt-test-1","path":"/tmp/test","command":"ccd start --refresh --continuation cnt-test-1"}}'
else
printf '%s\n' '{"command":"session-renew-prepare","ok":true,"renewal":{"renewal_lease_id":"lease-test-1","thread_id":"thread-test-1"},"continuation":{"token":"cnt-test-1","path":"/tmp/test","command":"ccd start --refresh --continuation cnt-test-1"}}'
fi
;;
*"start --refresh --fields command,ok,session_boundary,thread"*)
if [ "$FAKE_CCD_MODE" = "boundary-ok-false" ]; then
printf '%s\n' '{"command":"start","ok":false,"error":"boundary denied"}'
else
printf '%s\n' '{"command":"start","ok":true,"session_boundary":{"action":"renew","summary":"renew now"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":true}}'
fi
;;
*)
printf '%s\n' '{"command":"unknown","ok":true}'
;;
esac
"#,
)
.expect("write fake ccd");
#[cfg(unix)]
{
let mut perms = fs::metadata(&path).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("chmod");
}
path
}
fn run_host_hook(
fake_ccd: &std::path::Path,
log: &std::path::Path,
mode: &str,
args: &[&str],
stdin: &str,
) -> (i32, String, String) {
let mut child = Command::new(lifeloop_bin())
.args(args)
.env("FAKE_CCD_LOG", log)
.env("FAKE_CCD_MODE", mode)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn lifeloop");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(stdin.as_bytes())
.expect("write stdin");
let output = child.wait_with_output().expect("wait");
let _ = fake_ccd;
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn codex_stop_prepares_renewal_and_stores_continuation_out_of_band() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let state_dir = tmp.path().join("state");
let repo = tmp.path().join("repo");
fs::create_dir(&repo).expect("repo");
let state_dir_s = state_dir.display().to_string();
let repo_s = repo.display().to_string();
let fake_s = fake_ccd.display().to_string();
let (code, stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&[
"--output",
"hook-protocol",
"host-hook",
"--path",
&repo_s,
"--state-dir",
&state_dir_s,
"--host",
"codex",
"--hook",
"on-agent-end",
"--client-cmd",
&fake_s,
],
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","turn_id":"turn-1","stop_hook_active":false}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
assert!(
!stdout.contains("cnt-test-1"),
"continuation token must not be printed to hook stdout: {stdout}"
);
let rendered: serde_json::Value = serde_json::from_str(&stdout).expect("hook JSON");
assert_eq!(rendered["decision"], "block");
assert!(
rendered["reason"]
.as_str()
.expect("reason")
.contains("stored out of band")
);
let pending_path = state_dir.join("ccd-renewal-pending.json");
let pending: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pending_path).expect("pending")).unwrap();
assert_eq!(pending["continuation_token"], "cnt-test-1");
assert_eq!(pending["thread_id"], "thread-test-1");
#[cfg(unix)]
{
let mode = fs::metadata(&pending_path)
.expect("pending metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "pending token file must be user-only");
}
let receipt_path = pending["reset_prepare_receipt_path"]
.as_str()
.expect("receipt path");
let receipt: lifeloop::LifecycleReceipt =
serde_json::from_str(&fs::read_to_string(receipt_path).expect("receipt")).unwrap();
receipt.validate().expect("receipt validates");
assert_eq!(receipt.event, lifeloop::LifecycleEventKind::SessionEnding);
assert_eq!(receipt.status, lifeloop::ReceiptStatus::Observed);
assert_eq!(
receipt.payload_receipts[0].payload_kind,
"lifeloop.renewal.reset_prepare"
);
let commands = fs::read_to_string(&log).expect("log");
assert!(commands.contains("session renew prepare"));
assert!(commands.contains("--adapter codex"));
assert!(commands.contains("--reset-path wrapper"));
assert!(commands.contains("--lifeloop-receipt"));
assert!(!commands.contains("--continuation cnt-test-1"));
}
#[test]
fn codex_stop_refuses_to_overwrite_existing_pending_continuation() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let state_dir = tmp.path().join("state");
let repo = tmp.path().join("repo");
fs::create_dir(&repo).expect("repo");
let state_dir_s = state_dir.display().to_string();
let repo_s = repo.display().to_string();
let fake_s = fake_ccd.display().to_string();
let args = [
"--output",
"hook-protocol",
"host-hook",
"--path",
&repo_s,
"--state-dir",
&state_dir_s,
"--host",
"codex",
"--hook",
"on-agent-end",
"--client-cmd",
&fake_s,
];
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&args,
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
let pending_path = state_dir.join("ccd-renewal-pending.json");
let original = fs::read_to_string(&pending_path).expect("pending");
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&args,
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 1);
assert!(stderr.contains("refusing to overwrite continuation token"));
assert_eq!(
fs::read_to_string(&pending_path).expect("pending after retry"),
original,
"existing one-shot continuation token must remain intact"
);
}
#[test]
fn default_state_dir_uses_parent_git_directory_for_nested_paths() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let repo = tmp.path().join("repo");
let nested = repo.join("src").join("module");
fs::create_dir_all(repo.join(".git")).expect("git dir");
fs::create_dir_all(&nested).expect("nested");
let nested_s = nested.display().to_string();
let fake_s = fake_ccd.display().to_string();
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&[
"--output",
"hook-protocol",
"host-hook",
"--path",
&nested_s,
"--host",
"codex",
"--hook",
"on-agent-end",
"--client-cmd",
&fake_s,
],
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
assert!(
repo.join(".git")
.join("lifeloop")
.join("renewal")
.join("ccd-renewal-pending.json")
.exists(),
"pending state should live under the repository git dir"
);
assert!(
!nested.join(".lifeloop").exists(),
"nested worktree paths must not receive token-bearing fallback state"
);
}
#[test]
fn codex_session_start_consumes_pending_continuation_and_preserves_thread() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let state_dir = tmp.path().join("state");
let repo = tmp.path().join("repo");
fs::create_dir(&repo).expect("repo");
let state_dir_s = state_dir.display().to_string();
let repo_s = repo.display().to_string();
let fake_s = fake_ccd.display().to_string();
let common = [
"--output",
"hook-protocol",
"host-hook",
"--path",
&repo_s,
"--state-dir",
&state_dir_s,
"--host",
"codex",
"--client-cmd",
&fake_s,
];
let mut stop_args = common.to_vec();
stop_args.extend(["--hook", "on-agent-end"]);
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&stop_args,
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
let mut start_args = common.to_vec();
start_args.extend(["--hook", "on-session-start"]);
let (code, stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&start_args,
r#"{"hook_event_name":"SessionStart","session_id":"codex-session-after"}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
assert!(
!stdout.contains("cnt-test-1"),
"continuation token must not be printed to hook stdout: {stdout}"
);
let rendered: serde_json::Value = serde_json::from_str(&stdout).expect("hook JSON");
assert_eq!(
rendered["hookSpecificOutput"]["hookEventName"],
"SessionStart"
);
let additional = rendered["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("additional context");
assert!(additional.contains("lifeloop.renewal.continuation"));
assert!(additional.contains("thread-test-1"));
assert!(!state_dir.join("ccd-renewal-pending.json").exists());
let commands = fs::read_to_string(&log).expect("log");
assert!(commands.contains("start --refresh --continuation cnt-test-1"));
}
#[test]
fn codex_stop_fails_closed_when_prepare_loses_continuation_token() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let state_dir = tmp.path().join("state");
let repo = tmp.path().join("repo");
fs::create_dir(&repo).expect("repo");
let state_dir_s = state_dir.display().to_string();
let repo_s = repo.display().to_string();
let fake_s = fake_ccd.display().to_string();
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"missing-token",
&[
"--output",
"hook-protocol",
"host-hook",
"--path",
&repo_s,
"--state-dir",
&state_dir_s,
"--host",
"codex",
"--hook",
"on-agent-end",
"--client-cmd",
&fake_s,
],
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 1);
assert!(stderr.contains("continuation payload lost"));
assert!(!state_dir.join("ccd-renewal-pending.json").exists());
}
#[test]
fn codex_stop_fails_closed_when_prepare_omits_thread_binding() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let state_dir = tmp.path().join("state");
let repo = tmp.path().join("repo");
fs::create_dir(&repo).expect("repo");
let state_dir_s = state_dir.display().to_string();
let repo_s = repo.display().to_string();
let fake_s = fake_ccd.display().to_string();
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"missing-thread",
&[
"--output",
"hook-protocol",
"host-hook",
"--path",
&repo_s,
"--state-dir",
&state_dir_s,
"--host",
"codex",
"--hook",
"on-agent-end",
"--client-cmd",
&fake_s,
],
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 1);
assert!(stderr.contains("renewal.thread_id"));
assert!(!state_dir.join("ccd-renewal-pending.json").exists());
}
#[test]
fn codex_session_start_preserves_pending_when_thread_binding_is_not_active() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let state_dir = tmp.path().join("state");
let repo = tmp.path().join("repo");
fs::create_dir(&repo).expect("repo");
let state_dir_s = state_dir.display().to_string();
let repo_s = repo.display().to_string();
let fake_s = fake_ccd.display().to_string();
let common = [
"--output",
"hook-protocol",
"host-hook",
"--path",
&repo_s,
"--state-dir",
&state_dir_s,
"--host",
"codex",
"--client-cmd",
&fake_s,
];
let mut stop_args = common.to_vec();
stop_args.extend(["--hook", "on-agent-end"]);
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&stop_args,
r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
let pending_path = state_dir.join("ccd-renewal-pending.json");
assert!(pending_path.exists());
let mut start_args = common.to_vec();
start_args.extend(["--hook", "on-session-start"]);
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"continuation-unbound",
&start_args,
r#"{"hook_event_name":"SessionStart","session_id":"codex-session-after"}"#,
);
assert_eq!(code, 1);
assert!(stderr.contains("active thread binding"));
assert!(
pending_path.exists(),
"failed continuation must leave the one-shot token available for recovery"
);
}
#[test]
fn host_hook_accepts_command_local_output_flag() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let (code, stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&[
"host-hook",
"--output",
"hook-protocol",
"--host",
"codex",
"--hook",
"on-agent-end",
],
r#"{"hook_event_name":"Stop"}"#,
);
assert_eq!(code, 0, "stderr=`{stderr}`");
assert_eq!(stdout.trim(), "{}");
}
#[test]
fn global_output_is_rejected_for_non_host_hook_commands() {
let tmp = tempfile::tempdir().expect("tempdir");
let fake_ccd = write_fake_ccd(tmp.path());
let log = tmp.path().join("ccd.log");
let (code, _stdout, stderr) = run_host_hook(
&fake_ccd,
&log,
"",
&["--output", "hook-protocol", "manifest", "list"],
"",
);
assert_eq!(code, 2);
assert!(stderr.contains("--output is only supported for `host-hook`"));
}