use std::io::Write;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use super::{CodingAssistant, DeliveryMode, InjectConfig, ResumeOpts, StartOpts};
mod embedded {
pub const SCRIPT_REGISTER: &str = include_str!("../../scripts/codex/codex-register.sh");
pub const SCRIPT_PROMPT_SUBMIT: &str =
include_str!("../../scripts/codex/codex-prompt-submit.sh");
pub const SCRIPT_STOP: &str = include_str!("../../scripts/codex/codex-stop.sh");
pub const SKILL_MD: &str = include_str!("../../skills/ouija/SKILL.md");
}
const HOOKS: &[(&str, &str, &str)] = &[
(
"SessionStart",
"codex-register.sh",
embedded::SCRIPT_REGISTER,
),
(
"UserPromptSubmit",
"codex-prompt-submit.sh",
embedded::SCRIPT_PROMPT_SUBMIT,
),
("Stop", "codex-stop.sh", embedded::SCRIPT_STOP),
];
fn hooks_dir(codex_home: &Path) -> PathBuf {
codex_home.join("ouija-hooks")
}
fn launch_credential_dir(codex_home: &Path) -> PathBuf {
codex_home.join("ouija-launch-credentials")
}
fn stage_launch_credential(codex_home: &Path, credential: &str) -> anyhow::Result<PathBuf> {
let dir = launch_credential_dir(codex_home);
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
for _ in 0..8 {
let bytes: [u8; 16] = ::rand::random();
let nonce = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let path = dir.join(format!("launch-{nonce}"));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
if let Err(error) = file.write_all(credential.as_bytes()) {
let _ = std::fs::remove_file(&path);
return Err(error.into());
}
return Ok(path);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
anyhow::bail!("could not allocate a unique Codex launch credential file")
}
fn merge_hooks_json(existing: Option<&str>, hooks_dir: &Path) -> Option<serde_json::Value> {
let dir_prefix = format!("{}/", hooks_dir.display());
let is_ouija_group = |group: &serde_json::Value| -> bool {
group["hooks"].as_array().is_some_and(|inner| {
inner.iter().any(|h| {
h["command"]
.as_str()
.is_some_and(|c| c.starts_with(&dir_prefix))
})
})
};
let mut root: serde_json::Value = match existing {
Some(s) => serde_json::from_str(s).ok()?,
None => serde_json::json!({}),
};
if !root.is_object() {
root = serde_json::json!({});
}
let hooks = root
.as_object_mut()
.expect("root normalized to object")
.entry("hooks")
.or_insert_with(|| serde_json::json!({}));
if !hooks.is_object() {
*hooks = serde_json::json!({});
}
let hooks = hooks.as_object_mut().expect("hooks normalized to object");
for (event, script, _) in HOOKS {
let command = hooks_dir.join(script).display().to_string();
let entry = hooks
.entry((*event).to_string())
.or_insert_with(|| serde_json::json!([]));
let arr = match entry.as_array_mut() {
Some(a) => a,
None => {
*entry = serde_json::json!([]);
entry.as_array_mut().expect("just set to array")
}
};
arr.retain(|g| !is_ouija_group(g));
arr.push(serde_json::json!({
"hooks": [ { "type": "command", "command": command } ]
}));
}
Some(root)
}
fn install_skill_to(codex_home: &Path) -> anyhow::Result<()> {
let skill_dir = codex_home.join("skills/ouija");
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(skill_dir.join("SKILL.md"), embedded::SKILL_MD)?;
Ok(())
}
fn install_to(codex_home: &Path) -> anyhow::Result<()> {
let dir = hooks_dir(codex_home);
std::fs::create_dir_all(&dir)?;
for (_, script, content) in HOOKS {
let dest = dir.join(script);
std::fs::write(&dest, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
}
}
let hooks_path = codex_home.join("hooks.json");
let existing = std::fs::read_to_string(&hooks_path).ok();
match merge_hooks_json(existing.as_deref(), &dir) {
Some(merged) => std::fs::write(&hooks_path, serde_json::to_string_pretty(&merged)?)?,
None => eprintln!(
"warning: {} is not valid JSON — leaving it untouched. Ouija Codex hooks were not \
merged; fix or remove the file and re-run to enable them.",
hooks_path.display()
),
}
install_skill_to(codex_home)?;
Ok(())
}
pub(crate) fn install_to_home(codex_home: &Path) -> anyhow::Result<()> {
install_to(codex_home)
}
pub(crate) fn install_configured_home(codex_home: Option<&str>) {
let Some(home) = codex_home.map(str::trim).filter(|h| !h.is_empty()) else {
return;
};
let expanded = crate::state::expand_tilde(home);
if let Err(e) = install_to_home(Path::new(&expanded)) {
tracing::warn!("failed to install Codex hooks into configured codex_home: {e}");
}
}
fn collect_rollout_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rollout_files(&path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
out.push(path);
}
}
}
fn first_line(path: &Path) -> Option<String> {
use std::io::BufRead;
let file = std::fs::File::open(path).ok()?;
let mut line = String::new();
std::io::BufReader::new(file).read_line(&mut line).ok()?;
(!line.trim().is_empty()).then_some(line)
}
fn latest_session_id_for_cwd(sessions_root: &Path, project_dir: &str) -> Option<String> {
let mut files = Vec::new();
collect_rollout_files(sessions_root, &mut files);
let mut best: Option<(String, String)> = None; for file in files {
let Some(line) = first_line(&file) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) != Some("session_meta") {
continue;
}
let payload = &v["payload"];
if payload.get("cwd").and_then(|c| c.as_str()) != Some(project_dir) {
continue;
}
let (Some(ts), Some(sid)) = (
payload.get("timestamp").and_then(|t| t.as_str()),
payload.get("session_id").and_then(|s| s.as_str()),
) else {
continue;
};
if best
.as_ref()
.is_none_or(|(best_ts, _)| ts > best_ts.as_str())
{
best = Some((ts.to_string(), sid.to_string()));
}
}
best.map(|(_, sid)| sid)
}
fn resolve_codex_home() -> Option<PathBuf> {
if let Ok(h) = std::env::var("CODEX_HOME") {
if !h.is_empty() {
return Some(PathBuf::from(h));
}
}
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".codex"))
}
fn codex_trust_root(project_dir: &str) -> PathBuf {
let project = Path::new(project_dir);
git_common_dir(project)
.map(|common_dir| trust_root_from_common_dir(&common_dir, project))
.unwrap_or_else(|| project.to_path_buf())
}
fn git_common_dir(project_dir: &Path) -> Option<PathBuf> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(project_dir)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let path = stdout.trim();
(!path.is_empty()).then(|| PathBuf::from(path))
}
fn trust_root_from_common_dir(common_dir: &Path, fallback: &Path) -> PathBuf {
if common_dir.file_name().and_then(|s| s.to_str()) == Some(".git") {
if let Some(parent) = common_dir.parent() {
return parent.to_path_buf();
}
}
fallback.to_path_buf()
}
fn toml_basic_string_escape(value: &str) -> String {
let mut escaped = String::new();
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c if c.is_control() => {
use std::fmt::Write as _;
let _ = write!(escaped, "\\u{:04X}", c as u32);
}
c => escaped.push(c),
}
}
escaped
}
fn format_trust_config_flag(project_dir: &str) -> String {
let trust_root = codex_trust_root(project_dir);
let root = toml_basic_string_escape(&trust_root.to_string_lossy());
let config = format!("projects={{\"{root}\"={{trust_level=\"trusted\"}}}}");
format!(" -c {}", crate::scheduler::shell_escape(&config))
}
const AUTONOMY_FLAGS: &str = "--dangerously-bypass-approvals-and-sandbox --no-alt-screen";
fn format_model_flag(model: Option<&str>) -> String {
match model.map(str::trim).filter(|m| !m.is_empty()) {
Some(m) => format!(" --model {}", crate::scheduler::shell_escape(m)),
None => String::new(),
}
}
fn format_reasoning_effort_config_flag(effort: Option<&str>) -> String {
match effort.map(str::trim).filter(|e| !e.is_empty()) {
Some(e) => {
let config = format!("model_reasoning_effort=\"{}\"", toml_basic_string_escape(e));
format!(" -c {}", crate::scheduler::shell_escape(&config))
}
None => String::new(),
}
}
fn format_codex_home_prefix(codex_home: Option<&str>) -> String {
match codex_home.map(str::trim).filter(|h| !h.is_empty()) {
Some(home) => {
let expanded = crate::state::expand_tilde(home);
format!("CODEX_HOME={} ", crate::scheduler::shell_escape(&expanded))
}
None => String::new(),
}
}
const SESSION_FLAGS_HOOK_KEY: &str = "/<session-flags>/config.toml:session_start:0:0";
fn session_flags_trusted_hash(command: &str) -> String {
let normalized = serde_json::json!({
"event_name": "session_start",
"hooks": [{
"async": false,
"command": command,
"timeout": 600,
"type": "command",
}],
});
let canonical = canonical_json(&normalized);
let mut hasher = Sha256::new();
hasher.update(serde_json::to_vec(&canonical).expect("JSON value serializes"));
format!("sha256:{:x}", hasher.finalize())
}
pub(crate) fn format_session_start_hook_flags(
codex_home: &str,
launch_session_id: &str,
launch_credential_file: &Path,
session_incarnation: crate::daemon_protocol::SessionIncarnation,
) -> String {
let script = hooks_dir(Path::new(codex_home)).join("codex-register.sh");
let command = format!(
"{} --launch-session-id {} --launch-credential-file {} --session-incarnation {}",
crate::scheduler::shell_escape(&script.to_string_lossy()),
crate::scheduler::shell_escape(launch_session_id),
crate::scheduler::shell_escape(&launch_credential_file.to_string_lossy()),
session_incarnation,
);
let trusted_hash = session_flags_trusted_hash(&command);
let handler = toml_basic_string_escape(&command);
let hook_config =
format!("hooks.SessionStart=[{{hooks=[{{type=\"command\",command=\"{handler}\"}}]}}]");
let state_config =
format!("hooks.state={{\"{SESSION_FLAGS_HOOK_KEY}\"={{trusted_hash=\"{trusted_hash}\"}}}}");
format!(
" -c {} -c {}",
crate::scheduler::shell_escape(&hook_config),
crate::scheduler::shell_escape(&state_config),
)
}
pub(crate) fn with_session_start_hook(
command: String,
codex_home: Option<&str>,
launch_session_id: &str,
launch_credential: &str,
session_incarnation: crate::daemon_protocol::SessionIncarnation,
) -> anyhow::Result<String> {
let home = codex_home
.map(crate::state::expand_tilde)
.map(PathBuf::from)
.or_else(resolve_codex_home)
.unwrap_or_else(|| PathBuf::from(".codex"));
let credential_file = stage_launch_credential(&home, launch_credential)?;
Ok(format!(
"{command}{}",
format_session_start_hook_flags(
&home.to_string_lossy(),
launch_session_id,
&credential_file,
session_incarnation,
)
))
}
fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted = serde_json::Map::new();
let mut keys = map.keys().collect::<Vec<_>>();
keys.sort_unstable();
for key in keys {
sorted.insert(key.clone(), canonical_json(&map[key]));
}
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(values) => {
serde_json::Value::Array(values.iter().map(canonical_json).collect())
}
value => value.clone(),
}
}
#[derive(Debug)]
pub struct Codex;
impl CodingAssistant for Codex {
fn name(&self) -> &str {
"codex-cli"
}
fn cli_name(&self) -> &str {
"codex"
}
fn process_names(&self) -> &[&str] {
&["codex"]
}
fn delivery_mode(&self) -> DeliveryMode {
DeliveryMode::TuiInjection
}
fn build_start_command(&self, opts: &StartOpts) -> String {
let escaped_dir = crate::scheduler::shell_escape(&opts.project_dir);
let model = format_model_flag(opts.model.as_deref());
let effort = format_reasoning_effort_config_flag(opts.effort.as_deref());
let codex_home = format_codex_home_prefix(opts.codex_home.as_deref());
let trust = format_trust_config_flag(&opts.project_dir);
format!("cd {escaped_dir} && {codex_home}codex {AUTONOMY_FLAGS}{trust}{effort}{model}")
}
fn build_resume_command(&self, opts: &ResumeOpts) -> Option<String> {
let escaped_dir = crate::scheduler::shell_escape(&opts.project_dir);
let model = format_model_flag(opts.model.as_deref());
let effort = format_reasoning_effort_config_flag(opts.effort.as_deref());
let codex_home = format_codex_home_prefix(opts.codex_home.as_deref());
let trust = format_trust_config_flag(&opts.project_dir);
let target = match &opts.session_id {
Some(sid) => crate::scheduler::shell_escape(sid),
None => "--last".to_string(),
};
Some(format!(
"cd {escaped_dir} && {codex_home}codex resume {target} {AUTONOMY_FLAGS}{trust}{effort}{model}"
))
}
fn detect_session_id(&self, project_dir: &str) -> Option<String> {
let sessions_root = resolve_codex_home()?.join("sessions");
latest_session_id_for_cwd(&sessions_root, project_dir)
}
fn caller_session_id(&self) -> Option<String> {
std::env::var("CODEX_THREAD_ID")
.ok()
.filter(|session_id| !session_id.is_empty())
}
fn tui_ready_pattern(&self) -> Option<&str> {
Some("\u{203A}")
}
fn inject_config(&self) -> InjectConfig {
InjectConfig {
paste_settle_ms: 300,
use_inner_bracketed_paste: true,
startup_inject_delay_secs: 5,
}
}
fn config_dir_name(&self) -> &str {
".codex"
}
fn has_project_history(&self, _dir: &Path) -> bool {
false
}
fn exit_command(&self) -> Option<&str> {
None
}
fn install(&self) -> anyhow::Result<()> {
match resolve_codex_home() {
Some(home) => install_to(&home),
None => Ok(()),
}
}
fn description_file_priority(&self) -> &[&str] {
&["AGENTS.md", "README.md"]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::{ResumeOpts, StartOpts, WorktreeMode};
fn backend() -> Codex {
Codex
}
fn start_opts(dir: &str) -> StartOpts {
StartOpts {
project_dir: dir.to_string(),
worktree: None,
model: None,
effort: None,
permission_mode: None,
codex_home: None,
}
}
fn resume_opts(dir: &str, session_id: Option<&str>) -> ResumeOpts {
ResumeOpts {
project_dir: dir.to_string(),
session_id: session_id.map(String::from),
worktree: None,
model: None,
effort: None,
permission_mode: None,
codex_home: None,
}
}
#[test]
fn identity_and_delivery() {
let b = backend();
assert_eq!(b.name(), "codex-cli");
assert_eq!(b.cli_name(), "codex");
assert_eq!(b.process_names(), &["codex"]);
assert!(matches!(b.delivery_mode(), DeliveryMode::TuiInjection));
assert_eq!(b.config_dir_name(), ".codex");
}
#[test]
fn start_command_basic() {
let cmd = backend().build_start_command(&start_opts("/home/user/myproject"));
assert_eq!(
cmd,
"cd '/home/user/myproject' && codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'"
);
}
#[test]
fn session_start_hook_flags_keep_credential_out_of_argv_and_toml() {
let home = tempfile::tempdir().unwrap();
let credential_file = stage_launch_credential(home.path(), "one-time-proof").unwrap();
let flags = format_session_start_hook_flags(
&home.path().to_string_lossy(),
"public-launch",
&credential_file,
crate::daemon_protocol::SessionIncarnation(42),
);
assert!(
flags.contains("codex-register.sh"),
"hook command must invoke the installed Codex registration hook: {flags}"
);
assert!(
flags.contains("public-launch"),
"hook command must carry the public launch id: {flags}"
);
assert!(
!flags.contains("one-time-proof"),
"the launch credential must never appear in generated Codex argv or TOML: {flags}"
);
assert!(
flags.contains("--launch-credential-file"),
"hook command must receive a credential-file path, not a credential value: {flags}"
);
assert!(
flags.contains(credential_file.to_string_lossy().as_ref()),
"hook command must carry the private credential-file path: {flags}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(launch_credential_dir(home.path()))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(&credential_file)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
assert!(
flags.contains("hooks.SessionStart="),
"hook must be installed through a session-flags override: {flags}"
);
assert!(
flags.contains(
"hooks.state={\"/<session-flags>/config.toml:session_start:0:0\"={trusted_hash="
),
"session-flags trust must be one inline table under Codex's exact state key: {flags}"
);
assert!(
!flags.contains("hooks.state.\""),
"a dotted -c state path is split on the `.` in `config.toml` and never trusts the hook: {flags}"
);
assert!(
!flags.contains("CODEX_THREAD_ID"),
"native identity must stay inside the Codex adapter/hook payload: {flags}"
);
}
#[test]
fn session_flags_trusted_hash_matches_codex_0_145() {
let command = "'/home/daniel/.codex/ouija-hooks/codex-register.sh' \
--launch-session-id 'feat/1980-resolve-worker-base-branches-without-guessing-main-posture-2-0-codex-cli-gpt-5-6-sol' \
--launch-credential-file '/home/daniel/.codex/ouija-launch-credentials/launch-30c9dd674603719f9e3d909e1ef0a29e' \
--session-incarnation 1784923380514536163";
assert_eq!(
session_flags_trusted_hash(command),
"sha256:4f541846dfb8fcdbac25659ea2530fdbeca4dbb6c6ad114a4eb0c809207d043f"
);
}
#[test]
fn start_command_with_model() {
let cmd = backend().build_start_command(&StartOpts {
model: Some("gpt-5.5".into()),
..start_opts("/home/user/myproject")
});
assert_eq!(
cmd,
"cd '/home/user/myproject' && codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}' --model 'gpt-5.5'"
);
}
#[test]
fn start_command_with_codex_home() {
let cmd = backend().build_start_command(&StartOpts {
model: Some("gemini-2.5-pro".into()),
codex_home: Some("/home/user/.cache/codex-gemini".into()),
..start_opts("/home/user/myproject")
});
assert_eq!(
cmd,
"cd '/home/user/myproject' && CODEX_HOME='/home/user/.cache/codex-gemini' codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}' --model 'gemini-2.5-pro'"
);
}
#[test]
fn start_command_ignores_worktree_and_maps_effort_to_config() {
let cmd = backend().build_start_command(&StartOpts {
worktree: Some(WorktreeMode::Named("feature-x".to_string())),
effort: Some("high".into()),
..start_opts("/home/user/myproject")
});
assert!(
!cmd.contains("--worktree"),
"must not emit --worktree: {cmd}"
);
assert!(!cmd.contains("--effort"), "must not emit --effort: {cmd}");
assert!(
!cmd.contains("feature-x"),
"must not emit worktree name: {cmd}"
);
assert!(
cmd.contains("-c 'model_reasoning_effort=\"high\"'"),
"must emit effort as Codex config: {cmd}"
);
}
#[test]
fn start_command_with_model_and_effort() {
let cmd = backend().build_start_command(&StartOpts {
model: Some("gpt-5.5".into()),
effort: Some("low".into()),
..start_opts("/home/user/myproject")
});
assert_eq!(
cmd,
"cd '/home/user/myproject' && codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}' -c 'model_reasoning_effort=\"low\"' --model 'gpt-5.5'"
);
}
#[test]
fn start_and_resume_use_full_power_worker_mode() {
let yolo = "--dangerously-bypass-approvals-and-sandbox";
let start = backend().build_start_command(&start_opts("/home/user/myproject"));
assert!(
start.contains(yolo),
"start must bypass approvals/sandbox: {start}"
);
assert!(
start.contains("-c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'"),
"start must pre-trust the Codex trust root: {start}"
);
let resume = backend()
.build_resume_command(&resume_opts("/home/user/myproject", None))
.unwrap();
assert!(
resume.contains(yolo),
"resume must bypass approvals/sandbox: {resume}"
);
assert!(
resume.contains("-c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'"),
"resume must pre-trust the Codex trust root: {resume}"
);
assert!(!start.contains("--sandbox workspace-write"), "{start}");
assert!(
!start.contains("sandbox_workspace_write.network_access"),
"{start}"
);
}
#[test]
fn trust_override_survives_spawned_worker_options() {
let expected_trust = " -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'";
let start = backend().build_start_command(&StartOpts {
model: Some("gemini-2.5-pro".into()),
effort: Some("low".into()),
codex_home: Some("/home/user/.cache/codex-gemini".into()),
..start_opts("/home/user/myproject")
});
assert!(
start.contains(expected_trust),
"spawn start must pre-trust the Codex project root: {start}"
);
assert_eq!(
start.matches("trust_level=\"trusted\"").count(),
1,
"spawn start must carry one trust override: {start}"
);
let resume = backend()
.build_resume_command(&ResumeOpts {
model: Some("gemini-2.5-pro".into()),
effort: Some("low".into()),
codex_home: Some("/home/user/.cache/codex-gemini".into()),
..resume_opts("/home/user/myproject", Some("abc-123"))
})
.unwrap();
assert!(
resume.contains(expected_trust),
"spawn resume must pre-trust the Codex project root: {resume}"
);
assert_eq!(
resume.matches("trust_level=\"trusted\"").count(),
1,
"spawn resume must carry one trust override: {resume}"
);
}
#[test]
fn resume_command_without_session_id_uses_last() {
let cmd = backend().build_resume_command(&resume_opts("/home/user/myproject", None));
assert_eq!(
cmd,
Some(
"cd '/home/user/myproject' && codex resume --last --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'"
.to_string()
)
);
}
#[test]
fn resume_command_with_session_id() {
let cmd =
backend().build_resume_command(&resume_opts("/home/user/myproject", Some("abc-123")));
assert_eq!(
cmd,
Some(
"cd '/home/user/myproject' && codex resume 'abc-123' --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'"
.to_string()
)
);
}
#[test]
fn resume_command_with_model() {
let cmd = backend().build_resume_command(&ResumeOpts {
model: Some("gpt-5.5".into()),
..resume_opts("/home/user/myproject", None)
});
assert_eq!(
cmd,
Some(
"cd '/home/user/myproject' && codex resume --last --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}' --model 'gpt-5.5'"
.to_string()
)
);
}
#[test]
fn resume_command_with_effort() {
let cmd = backend().build_resume_command(&ResumeOpts {
effort: Some("xhigh".into()),
..resume_opts("/home/user/myproject", Some("abc-123"))
});
assert_eq!(
cmd,
Some(
"cd '/home/user/myproject' && codex resume 'abc-123' --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}' -c 'model_reasoning_effort=\"xhigh\"'"
.to_string()
)
);
}
#[test]
fn resume_command_with_codex_home() {
let cmd = backend().build_resume_command(&ResumeOpts {
codex_home: Some("/home/user/.cache/codex-gemini".into()),
..resume_opts("/home/user/myproject", Some("abc-123"))
});
assert_eq!(
cmd,
Some(
"cd '/home/user/myproject' && CODEX_HOME='/home/user/.cache/codex-gemini' codex resume 'abc-123' --dangerously-bypass-approvals-and-sandbox --no-alt-screen -c 'projects={\"/home/user/myproject\"={trust_level=\"trusted\"}}'"
.to_string()
)
);
}
#[test]
fn resume_command_ignores_worktree() {
let cmd = backend().build_resume_command(&ResumeOpts {
worktree: Some(WorktreeMode::Disposable),
..resume_opts("/home/user/myproject", Some("abc-123"))
});
let cmd = cmd.unwrap();
assert!(
!cmd.contains("--worktree"),
"must not emit --worktree: {cmd}"
);
}
#[test]
fn format_model_flag_drops_empty() {
assert_eq!(format_model_flag(None), "");
assert_eq!(format_model_flag(Some("")), "");
assert_eq!(format_model_flag(Some(" ")), "");
assert_eq!(format_model_flag(Some("gpt-5.5")), " --model 'gpt-5.5'");
}
#[test]
fn format_reasoning_effort_config_flag_drops_empty_and_escapes() {
assert_eq!(format_reasoning_effort_config_flag(None), "");
assert_eq!(format_reasoning_effort_config_flag(Some("")), "");
assert_eq!(format_reasoning_effort_config_flag(Some(" ")), "");
assert_eq!(
format_reasoning_effort_config_flag(Some("medium")),
" -c 'model_reasoning_effort=\"medium\"'"
);
assert_eq!(
format_reasoning_effort_config_flag(Some("hi\"there")),
" -c 'model_reasoning_effort=\"hi\\\"there\"'"
);
}
#[test]
fn codex_home_prefix_drops_empty() {
assert_eq!(format_codex_home_prefix(None), "");
assert_eq!(format_codex_home_prefix(Some("")), "");
assert_eq!(format_codex_home_prefix(Some(" ")), "");
}
#[test]
fn trust_config_flag_uses_projects_table_form() {
assert_eq!(
format_trust_config_flag("/tmp/codex-trust-test-main"),
" -c 'projects={\"/tmp/codex-trust-test-main\"={trust_level=\"trusted\"}}'"
);
}
#[test]
fn trust_config_flag_escapes_toml_and_shell() {
assert_eq!(
format_trust_config_flag("/tmp/quote'and\"back\\slash"),
" -c 'projects={\"/tmp/quote'\\''and\\\"back\\\\slash\"={trust_level=\"trusted\"}}'"
);
}
#[test]
fn trust_root_uses_common_repo_parent_for_linked_worktree() {
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("main");
let linked = tmp.path().join("linked");
let output = std::process::Command::new("git")
.args(["init"])
.arg(&main)
.output()
.unwrap();
assert!(
output.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&output.stderr)
);
std::fs::write(main.join("README.md"), "root\n").unwrap();
let output = std::process::Command::new("git")
.arg("-C")
.arg(&main)
.args(["add", "README.md"])
.output()
.unwrap();
assert!(
output.status.success(),
"git add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = std::process::Command::new("git")
.arg("-C")
.arg(&main)
.args(["commit", "-m", "init"])
.env("GIT_AUTHOR_NAME", "Ouija Test")
.env("GIT_AUTHOR_EMAIL", "ouija@example.test")
.env("GIT_COMMITTER_NAME", "Ouija Test")
.env("GIT_COMMITTER_EMAIL", "ouija@example.test")
.output()
.unwrap();
assert!(
output.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = std::process::Command::new("git")
.arg("-C")
.arg(&main)
.args(["worktree", "add", "-b", "linked-test"])
.arg(&linked)
.output()
.unwrap();
assert!(
output.status.success(),
"git worktree add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
codex_trust_root(linked.to_str().unwrap()),
main.canonicalize().unwrap()
);
}
fn write_session_meta(path: &Path, cwd: &str, timestamp: &str, session_id: &str) {
let meta = serde_json::json!({
"type": "session_meta",
"payload": { "session_id": session_id, "cwd": cwd, "timestamp": timestamp }
});
std::fs::write(path, format!("{meta}\n{{\"type\":\"event\"}}\n")).unwrap();
}
#[test]
fn latest_session_id_for_cwd_picks_most_recent_match() {
let tmp = tempfile::tempdir().unwrap();
let day = tmp.path().join("2026/07/05");
std::fs::create_dir_all(&day).unwrap();
write_session_meta(
&day.join("a.jsonl"),
"/proj",
"2026-07-05T14:44:15.000Z",
"uuid-old",
);
write_session_meta(
&day.join("b.jsonl"),
"/proj",
"2026-07-05T15:00:00.000Z",
"uuid-new",
);
write_session_meta(
&day.join("c.jsonl"),
"/other",
"2026-07-05T16:00:00.000Z",
"uuid-other",
);
assert_eq!(
latest_session_id_for_cwd(tmp.path(), "/proj"),
Some("uuid-new".to_string())
);
assert_eq!(latest_session_id_for_cwd(tmp.path(), "/nope"), None);
}
#[test]
fn latest_session_id_for_cwd_none_for_missing_dir() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
latest_session_id_for_cwd(&tmp.path().join("sessions"), "/proj"),
None
);
}
#[test]
fn latest_session_id_for_cwd_skips_malformed_lines() {
let tmp = tempfile::tempdir().unwrap();
let day = tmp.path().join("2026/07/05");
std::fs::create_dir_all(&day).unwrap();
std::fs::write(day.join("bad.jsonl"), "not json at all\n").unwrap();
write_session_meta(
&day.join("good.jsonl"),
"/proj",
"2026-07-05T15:00:00.000Z",
"uuid-good",
);
assert_eq!(
latest_session_id_for_cwd(tmp.path(), "/proj"),
Some("uuid-good".to_string())
);
}
#[test]
fn tui_ready_pattern_is_prompt_glyph() {
assert_eq!(backend().tui_ready_pattern(), Some("\u{203A}"));
}
#[test]
fn inject_config_matches_live_verified_values() {
let cfg = backend().inject_config();
assert_eq!(cfg.paste_settle_ms, 300);
assert!(cfg.use_inner_bracketed_paste);
assert_eq!(cfg.startup_inject_delay_secs, 5);
}
#[test]
fn has_project_history_always_false() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".codex")).unwrap();
assert!(!backend().has_project_history(tmp.path()));
}
#[test]
fn register_script_wraps_output_as_session_start_context() {
let s = embedded::SCRIPT_REGISTER;
assert!(
s.contains("tmux display-message -p -t \"$PANE\" '#{pane_current_path}'"),
"register script must query the targeted pane cwd: {s}"
);
assert!(
s.contains("[ \"$PANE_CWD\" != \"$CWD\" ] && exit 0"),
"register script must skip a mismatched pane claim: {s}"
);
assert!(s.contains("/api/hooks/session-start"), "{s}");
assert!(s.contains(".session_id"), "{s}");
assert!(s.contains("backend_session_id"), "{s}");
assert!(s.contains("--arg adapter \"codex-cli\""), "{s}");
assert!(s.contains("launch_session_id"), "{s}");
assert!(s.contains("launch_credential"), "{s}");
assert!(
s.contains("LAUNCH_SESSION_ID=\"\"") && s.contains("LAUNCH_CREDENTIAL=\"\""),
"static hook must initialize managed proof empty: {s}"
);
assert!(
!s.contains("${OUIJA_SESSION_ID:-}")
&& !s.contains("${OUIJA_SESSION_START_CREDENTIAL:-}"),
"ambient launch variables must never become managed proof: {s}"
);
assert!(
s.contains("--launch-session-id")
&& s.contains("--launch-credential-file")
&& s.contains("--session-incarnation"),
"the per-launch SessionFlags hook must receive its proof by private file: {s}"
);
assert!(s.contains("session_incarnation"), "{s}");
assert!(
s.contains("mv -- \"$EXPLICIT_LAUNCH_CREDENTIAL_FILE\"")
&& s.contains("rm -f -- \"$CLAIMED_CREDENTIAL_FILE\""),
"the hook must atomically claim then remove the one-shot credential file: {s}"
);
assert!(
!s.contains("[ -z \"$PANE\" ] && exit 0"),
"a shared app-server has no pane; the hook must submit a paneless proof claim: {s}"
);
assert!(s.contains("hookSpecificOutput"), "{s}");
assert!(s.contains("additionalContext"), "{s}");
assert!(s.contains("SessionStart"), "{s}");
assert!(!s.contains("session-end"), "{s}");
}
#[test]
fn manual_register_script_does_not_claim_live_pane_incarnation() {
let root = tempfile::tempdir().unwrap();
let bin_dir = root.path().join("bin");
let script = root.path().join("codex-register.sh");
let capture = root.path().join("request.json");
std::fs::create_dir(&bin_dir).unwrap();
std::fs::write(&script, embedded::SCRIPT_REGISTER).unwrap();
std::fs::write(
bin_dir.join("tmux"),
r#"#!/bin/bash
case "$*" in
*'#{pane_current_path}'*) printf '/repo\n' ;;
*'#{@ouija_incarnation}'*) printf '77\n' ;;
*) exit 1 ;;
esac
"#,
)
.unwrap();
std::fs::write(
bin_dir.join("curl"),
r#"#!/bin/bash
while [ "$#" -gt 0 ]; do
case "$1" in
-d) printf '%s' "$2" > "$OUIJA_HOOK_CAPTURE"; shift 2 ;;
*) shift ;;
esac
done
printf '{"registered":"worker","session_incarnation":"77","output":""}'
"#,
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for path in [&script, &bin_dir.join("tmux"), &bin_dir.join("curl")] {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let mut child = std::process::Command::new("bash")
.arg(&script)
.env(
"PATH",
format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.env("TMUX_PANE", "%42")
.env("OUIJA_HOOK_CAPTURE", &capture)
.env_remove("OUIJA_SESSION_ID")
.env_remove("OUIJA_SESSION_START_CREDENTIAL")
.env_remove("OUIJA_SESSION_INCARNATION")
.env_remove("CODEX_THREAD_ID")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(br#"{"session_id":"thread-manual","cwd":"/repo"}"#)
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"register script failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let request: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(capture).unwrap()).unwrap();
assert!(
request.get("session_incarnation").is_none(),
"manual SessionStart must not treat a reusable pane variable as generation proof: {request}"
);
assert_eq!(request["backend_session_id"], "thread-manual");
assert!(request.get("launch_credential").is_none());
}
#[test]
fn prompt_submit_script_signals_activity() {
let s = embedded::SCRIPT_PROMPT_SUBMIT;
assert!(s.contains("/api/hooks/prompt-submit"), "{s}");
assert!(s.contains("UserPromptSubmit"), "{s}");
assert!(s.contains("session_incarnation"), "{s}");
}
#[test]
fn stop_script_is_turn_scoped_and_never_unregisters() {
let s = embedded::SCRIPT_STOP;
assert!(s.contains("/api/hooks/stop"), "{s}");
assert!(s.contains("session_incarnation"), "{s}");
assert!(s.contains(r#"{"continue":true}"#), "{s}");
assert!(!s.contains("session-end"), "{s}");
}
#[test]
fn merge_hooks_json_fresh_registers_all_three_events() {
let hooks_dir = Path::new("/home/user/.codex/ouija-hooks");
let merged = merge_hooks_json(None, hooks_dir).unwrap();
let hooks = &merged["hooks"];
for (event, script) in [
("SessionStart", "codex-register.sh"),
("UserPromptSubmit", "codex-prompt-submit.sh"),
("Stop", "codex-stop.sh"),
] {
let cmd = hooks[event][0]["hooks"][0]["command"]
.as_str()
.unwrap_or_else(|| panic!("missing command for {event}: {merged}"));
assert_eq!(cmd, format!("/home/user/.codex/ouija-hooks/{script}"));
assert_eq!(hooks[event][0]["hooks"][0]["type"], "command");
}
}
#[test]
fn merge_hooks_json_is_idempotent() {
let hooks_dir = Path::new("/home/user/.codex/ouija-hooks");
let once = merge_hooks_json(None, hooks_dir).unwrap();
let twice = merge_hooks_json(Some(&once.to_string()), hooks_dir).unwrap();
assert_eq!(once, twice, "second install must not duplicate ouija hooks");
assert_eq!(twice["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
}
#[test]
fn merge_hooks_json_preserves_user_hooks() {
let hooks_dir = Path::new("/home/user/.codex/ouija-hooks");
let existing = serde_json::json!({
"hooks": {
"PostToolUse": [
{ "matcher": "Write", "hooks": [
{ "type": "command", "command": "./scripts/user-thing.sh" }
] }
],
"SessionStart": [
{ "hooks": [
{ "type": "command", "command": "/opt/user/other-start.sh" }
] }
]
}
})
.to_string();
let merged = merge_hooks_json(Some(&existing), hooks_dir).unwrap();
assert_eq!(
merged["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
"./scripts/user-thing.sh"
);
let starts = merged["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(starts.len(), 2, "user + ouija groups: {merged}");
assert!(
starts
.iter()
.any(|g| g["hooks"][0]["command"] == "/opt/user/other-start.sh"),
"user SessionStart hook must be preserved: {merged}"
);
assert!(
starts
.iter()
.any(|g| g["hooks"][0]["command"]
== "/home/user/.codex/ouija-hooks/codex-register.sh"),
"ouija SessionStart hook must be present: {merged}"
);
}
#[test]
fn merge_hooks_json_replaces_stale_ouija_command() {
let hooks_dir = Path::new("/home/user/.codex/ouija-hooks");
let stale = serde_json::json!({
"hooks": {
"Stop": [
{ "hooks": [
{ "type": "command",
"command": "/home/user/.codex/ouija-hooks/old-stop.sh" }
] }
]
}
})
.to_string();
let merged = merge_hooks_json(Some(&stale), hooks_dir).unwrap();
let stops = merged["hooks"]["Stop"].as_array().unwrap();
assert_eq!(
stops.len(),
1,
"stale ouija group must be replaced: {merged}"
);
assert_eq!(
stops[0]["hooks"][0]["command"],
"/home/user/.codex/ouija-hooks/codex-stop.sh"
);
}
#[test]
fn install_to_writes_scripts_and_hooks_json() {
let home = tempfile::tempdir().unwrap();
install_to(home.path()).unwrap();
let hooks_dir = home.path().join("ouija-hooks");
for script in [
"codex-register.sh",
"codex-prompt-submit.sh",
"codex-stop.sh",
] {
let p = hooks_dir.join(script);
assert!(p.is_file(), "missing script {script}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&p).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111, "{script} must be executable");
}
}
assert_eq!(
std::fs::read_to_string(hooks_dir.join("codex-register.sh")).unwrap(),
embedded::SCRIPT_REGISTER,
"the installed register hook must retain its pane-cwd safety check"
);
let hooks_json = std::fs::read_to_string(home.path().join("hooks.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&hooks_json).unwrap();
assert_eq!(
parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"],
hooks_dir
.join("codex-register.sh")
.to_string_lossy()
.as_ref()
);
}
#[test]
fn install_to_is_idempotent_on_disk() {
let home = tempfile::tempdir().unwrap();
install_to(home.path()).unwrap();
install_to(home.path()).unwrap();
let hooks_json = std::fs::read_to_string(home.path().join("hooks.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&hooks_json).unwrap();
assert_eq!(
parsed["hooks"]["SessionStart"].as_array().unwrap().len(),
1,
"re-install must not duplicate hooks: {hooks_json}"
);
}
#[test]
fn merge_hooks_json_none_when_existing_unparseable() {
let hooks_dir = Path::new("/home/user/.codex/ouija-hooks");
assert!(merge_hooks_json(Some("{ not valid json"), hooks_dir).is_none());
assert!(merge_hooks_json(Some(""), hooks_dir).is_none());
assert!(merge_hooks_json(None, hooks_dir).is_some());
}
#[test]
fn install_to_writes_ouija_skill() {
let home = tempfile::tempdir().unwrap();
install_to(home.path()).unwrap();
let skill = home.path().join("skills/ouija/SKILL.md");
assert!(skill.is_file(), "ouija SKILL.md must be installed");
assert_eq!(std::fs::read_to_string(&skill).unwrap(), embedded::SKILL_MD);
}
#[test]
fn embedded_skill_teaches_opt_in_task_reminders_for_codex() {
crate::backend::assert_shared_task_reminder_guidance(embedded::SKILL_MD);
}
#[test]
fn install_skill_is_idempotent_and_preserves_unrelated_skills() {
let home = tempfile::tempdir().unwrap();
let other = home.path().join("skills/my-skill");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("SKILL.md"), "user skill body").unwrap();
install_skill_to(home.path()).unwrap();
install_skill_to(home.path()).unwrap();
assert_eq!(
std::fs::read_to_string(home.path().join("skills/ouija/SKILL.md")).unwrap(),
embedded::SKILL_MD
);
assert_eq!(
std::fs::read_to_string(other.join("SKILL.md")).unwrap(),
"user skill body"
);
}
#[test]
fn install_to_leaves_unparseable_hooks_json_untouched() {
let home = tempfile::tempdir().unwrap();
let hooks_path = home.path().join("hooks.json");
let garbage = "{ this is not valid json — user hand-edit in progress";
std::fs::write(&hooks_path, garbage).unwrap();
install_to(home.path()).unwrap();
assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), garbage);
assert!(home.path().join("ouija-hooks/codex-register.sh").is_file());
}
}