1use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use super::{
13 has_command, home_dir, hook_asset_path as asset_path_for, hook_command_with_action,
14 merge_hook_event, remove_marked_file, save_settings, unmerge_hook_entries, write_hook_asset,
15};
16
17const CODEX_HOOK_ASSET_POSIX: &str = include_str!("../../mux_hooks/par-mux-codex-session-hook.sh");
18const CODEX_HOOK_ASSET_WINDOWS: &str =
19 include_str!("../../mux_hooks/par-mux-codex-session-hook.ps1");
20
21fn codex_hook_install_name() -> &'static str {
26 if cfg!(windows) {
27 "par-mux-codex-session-hook.ps1"
28 } else {
29 "par-mux-codex-session-hook.sh"
30 }
31}
32
33fn codex_hook_asset() -> &'static str {
35 if cfg!(windows) {
36 CODEX_HOOK_ASSET_WINDOWS
37 } else {
38 CODEX_HOOK_ASSET_POSIX
39 }
40}
41
42pub const CODEX_HOOK_MARKER: &str = "PAR_MUX_INTEGRATION_ID=codex";
44
45const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
46const CODEX_HOOKS_FILE_NAME: &str = "hooks.json";
47const CODEX_CONFIG_FILE_NAME: &str = "config.toml";
48
49#[derive(Debug)]
51pub struct CodexHookInstall {
52 pub hook_path: PathBuf,
54 pub hooks_path: PathBuf,
56 pub config_path: PathBuf,
58 pub hooks_changed: bool,
60 pub config_changed: bool,
62}
63
64#[derive(Debug)]
66pub struct CodexHookUninstall {
67 pub hook_path: PathBuf,
68 pub hooks_path: PathBuf,
69 pub config_path: PathBuf,
71 pub hooks_changed: bool,
72 pub hook_removed: bool,
73}
74
75pub fn codex_config_dir() -> io::Result<PathBuf> {
78 if let Some(dir) = std::env::var_os(CODEX_HOME_ENV_VAR).filter(|value| !value.is_empty()) {
79 return Ok(PathBuf::from(dir));
80 }
81 Ok(home_dir()?.join(".codex"))
82}
83
84fn hook_asset_path() -> PathBuf {
87 asset_path_for(codex_hook_install_name())
88}
89
90pub fn install_codex_hook() -> io::Result<CodexHookInstall> {
93 install_codex_hook_into(&codex_config_dir()?, &hook_asset_path())
94}
95
96pub fn install_codex_hook_into(codex_dir: &Path, hook_path: &Path) -> io::Result<CodexHookInstall> {
98 if !codex_dir.is_dir() {
99 return Err(io::Error::other(format!(
100 "codex config directory not found at {}. install codex first",
101 codex_dir.display()
102 )));
103 }
104 let hooks_path = codex_dir.join(CODEX_HOOKS_FILE_NAME);
105 let config_path = codex_dir.join(CODEX_CONFIG_FILE_NAME);
106
107 let hooks_content = read_or_default(&hooks_path, "{}\n")?;
108 let config_content = read_or_default(&config_path, "")?;
109
110 let command = codex_hook_command(hook_path);
113 let merged = merge_hook_event(&hooks_content, &hooks_path, &command, None, "SessionStart")?;
114 let config_updated = ensure_features_hooks_true(&config_content, &config_path)?;
115
116 write_hook_asset(hook_path, codex_hook_asset())?;
117
118 if let Some(updated) = &merged {
119 save_settings(&hooks_path, updated)?;
120 }
121 if let Some(updated) = &config_updated {
122 save_settings(&config_path, updated)?;
123 }
124
125 Ok(CodexHookInstall {
126 hook_path: hook_path.to_path_buf(),
127 hooks_path,
128 config_path,
129 hooks_changed: merged.is_some(),
130 config_changed: config_updated.is_some(),
131 })
132}
133
134pub fn uninstall_codex_hook() -> io::Result<CodexHookUninstall> {
139 uninstall_codex_hook_into(&codex_config_dir()?, &hook_asset_path())
140}
141
142pub fn uninstall_codex_hook_into(
144 codex_dir: &Path,
145 hook_path: &Path,
146) -> io::Result<CodexHookUninstall> {
147 let hooks_path = codex_dir.join(CODEX_HOOKS_FILE_NAME);
148 let config_path = codex_dir.join(CODEX_CONFIG_FILE_NAME);
149
150 let mut hooks_changed = false;
151 if hooks_path.is_file() {
152 let content = fs::read_to_string(&hooks_path)?;
153 let command = codex_hook_command(hook_path);
154 if let Some(updated) =
155 unmerge_hook_entries(&content, &hooks_path, &[("SessionStart", command.as_str())])?
156 {
157 save_settings(&hooks_path, &updated)?;
158 hooks_changed = true;
159 }
160 }
161
162 let hook_removed = remove_marked_file(hook_path, CODEX_HOOK_MARKER)?;
163
164 Ok(CodexHookUninstall {
165 hook_path: hook_path.to_path_buf(),
166 hooks_path,
167 config_path,
168 hooks_changed,
169 hook_removed,
170 })
171}
172
173fn codex_hook_command(hook_path: &Path) -> String {
177 hook_command_with_action(hook_path, "session")
178}
179
180fn read_or_default(path: &Path, default: &str) -> io::Result<String> {
181 match fs::read_to_string(path) {
182 Ok(content) => Ok(content),
183 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(default.to_string()),
184 Err(err) => Err(err),
185 }
186}
187
188fn ensure_features_hooks_true(content: &str, config_path: &Path) -> io::Result<Option<String>> {
193 let parsed = toml::from_str::<toml::Table>(content).map_err(|err| {
196 io::Error::other(format!("failed to parse {}: {err}", config_path.display()))
197 })?;
198 if features_hooks_enabled(&parsed) {
199 return Ok(None);
200 }
201
202 let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
203 let trailing_newline = content.ends_with('\n');
204 let mut features_header_index = None;
205 let mut hooks_line_index = None;
206 let mut in_features = false;
207
208 for (index, line) in lines.iter().enumerate() {
209 if let Some(header) = toml_table_header(line) {
210 in_features = header == "[features]";
211 if in_features && features_header_index.is_none() {
212 features_header_index = Some(index);
213 }
214 continue;
215 }
216 if in_features && hooks_line_index.is_none() && is_toml_key(line, "hooks") {
217 hooks_line_index = Some(index);
218 }
219 }
220
221 let updated = if let Some(hooks_index) = hooks_line_index {
222 let line = &lines[hooks_index];
223 let comment = toml_comment_start(line).map(str::to_string);
224 lines[hooks_index] = match comment {
225 Some(comment) => format!("hooks = true {comment}"),
226 None => "hooks = true".to_string(),
227 };
228 join_toml_lines(lines, trailing_newline)
229 } else if let Some(header_index) = features_header_index {
230 lines.insert(header_index + 1, "hooks = true".to_string());
231 join_toml_lines(lines, trailing_newline)
232 } else {
233 let mut result = content.trim_end_matches('\n').to_string();
234 if !result.is_empty() {
235 result.push('\n');
236 result.push('\n');
237 }
238 result.push_str("[features]\nhooks = true\n");
239 result
240 };
241
242 let reparsed = toml::from_str::<toml::Table>(&updated).map_err(|err| {
247 io::Error::other(format!(
248 "failed to safely update codex config at {}: {err}",
249 config_path.display()
250 ))
251 })?;
252 if !features_hooks_enabled(&reparsed) {
253 return Err(io::Error::other(format!(
254 "failed to safely update codex config at {}",
255 config_path.display()
256 )));
257 }
258 Ok(Some(updated))
259}
260
261fn features_hooks_enabled(config: &toml::Table) -> bool {
262 config
263 .get("features")
264 .and_then(|features| features.get("hooks"))
265 .and_then(|hooks| hooks.as_bool())
266 == Some(true)
267}
268
269fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
270 let mut result = lines.join("\n");
271 if trailing_newline || result.is_empty() {
272 result.push('\n');
273 }
274 result
275}
276
277fn toml_table_header(line: &str) -> Option<&str> {
281 let trimmed = line.trim_start();
282 if trimmed.starts_with('#') || !trimmed.starts_with('[') {
283 return None;
284 }
285
286 let header_end = if trimmed.starts_with("[[") {
287 trimmed.find("]]").map(|index| index + 2)?
288 } else {
289 trimmed.find(']').map(|index| index + 1)?
290 };
291 let header = &trimmed[..header_end];
292 let rest = trimmed[header_end..].trim_start();
293 if !rest.is_empty() && !rest.starts_with('#') {
294 return None;
295 }
296
297 Some(header)
298}
299
300fn is_toml_key(line: &str, key: &str) -> bool {
303 let trimmed = line.trim();
304 if trimmed.starts_with('#') || !trimmed.starts_with(key) {
305 return false;
306 }
307
308 trimmed[key.len()..].trim_start().starts_with('=')
309}
310
311fn toml_comment_start(value: &str) -> Option<&str> {
314 let mut quote = None;
315 let mut escaped = false;
316 for (index, ch) in value.char_indices() {
317 if let Some(quote_char) = quote {
318 if escaped {
319 escaped = false;
320 continue;
321 }
322 if ch == '\\' {
323 escaped = true;
324 continue;
325 }
326 if ch == quote_char {
327 quote = None;
328 }
329 continue;
330 }
331
332 match ch {
333 '"' | '\'' => quote = Some(ch),
334 '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
335 return Some(&value[index..]);
336 }
337 _ => {}
338 }
339 }
340
341 None
342}
343
344pub fn heal_codex_hook_asset() {
349 let Ok(codex_dir) = codex_config_dir() else {
350 return; };
352 if let Err(err) = heal_codex_hook_asset_into(&codex_dir, &hook_asset_path()) {
353 log::warn!("codex mux-hook heal skipped: {err}");
354 }
355}
356
357pub(crate) fn heal_codex_hook_asset_into(codex_dir: &Path, hook_path: &Path) -> io::Result<()> {
362 if hook_path.exists() {
363 return Ok(());
364 }
365 let hooks_path = codex_dir.join(CODEX_HOOKS_FILE_NAME);
366 let Ok(content) = fs::read_to_string(&hooks_path) else {
367 return Ok(()); };
369 let command = codex_hook_command(hook_path);
370 if has_command(&content, &hooks_path, &command, "SessionStart")? {
371 write_hook_asset(hook_path, codex_hook_asset())?;
372 log::info!("mux hook heal: restored {}", hook_path.display());
374 }
375 Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use serde_json::Value as JsonValue;
382 use std::fs;
383 use std::path::Path;
384
385 use super::super::parse_serde;
386
387 fn temp_root() -> tempfile::TempDir {
388 tempfile::tempdir().expect("temp dir")
389 }
390
391 #[test]
392 fn heal_rewrites_a_deleted_script_and_leaves_agent_files_untouched() {
393 let root = temp_root();
394 let codex_dir = root.path().join(".codex");
395 fs::create_dir_all(&codex_dir).unwrap();
396 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
397 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
398 let hook = root.path().join("par-mux-codex-session-hook.sh");
399 install_codex_hook_into(&codex_dir, &hook).unwrap();
400 let hooks_after_install = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
401 let config_after_install = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
402 fs::remove_file(&hook).unwrap();
403
404 heal_codex_hook_asset_into(&codex_dir, &hook).unwrap();
405
406 assert_eq!(fs::read_to_string(&hook).unwrap(), codex_hook_asset());
407 assert_eq!(
408 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
409 hooks_after_install
410 );
411 assert_eq!(
412 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
413 config_after_install
414 );
415 }
416
417 #[test]
418 fn heal_does_not_create_a_script_without_registration() {
419 let root = temp_root();
420 let codex_dir = root.path().join(".codex");
421 fs::create_dir_all(&codex_dir).unwrap();
422 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
423 let hook = root.path().join("par-mux-codex-session-hook.sh");
424
425 heal_codex_hook_asset_into(&codex_dir, &hook).unwrap();
426
427 assert!(!hook.exists());
428 }
429
430 const REAL_WORLD_CONFIG: &str = r#"# codex configuration
434model = "gpt-5-codex"
435notify = ["notify-send", "--app-name", "codex"]
436
437[profiles.work.features]
438hooks = false
439
440[features]
441# experimental toggles
442web_search = true
443
444[mcp_servers.github]
445command = "gh"
446args = ["mcp", "serve"]
447"#;
448
449 const REAL_WORLD_HOOKS: &str = r#"{
452 "hooks": {
453 "SessionStart": [
454 {
455 "hooks": [
456 {
457 "type": "command",
458 "command": "/usr/local/bin/codex-motd"
459 }
460 ]
461 }
462 ],
463 "Stop": [
464 {
465 "hooks": [
466 {
467 "type": "command",
468 "command": "/usr/local/bin/codex-done"
469 }
470 ]
471 }
472 ]
473 }
474}
475"#;
476
477 fn installed_command(hook_path: &Path) -> String {
478 codex_hook_command(hook_path)
479 }
480
481 fn session_start_commands(content: &str) -> Vec<String> {
482 let value: JsonValue = parse_serde(content, Path::new("<test>")).expect("parses");
483 value
484 .get("hooks")
485 .and_then(|hooks| hooks.get("SessionStart"))
486 .and_then(JsonValue::as_array)
487 .expect("SessionStart array")
488 .iter()
489 .flat_map(|group| {
490 group
491 .get("hooks")
492 .and_then(JsonValue::as_array)
493 .map(|entries| {
494 entries
495 .iter()
496 .filter_map(|entry| entry.get("command").and_then(JsonValue::as_str))
497 .collect::<Vec<_>>()
498 })
499 .unwrap_or_default()
500 })
501 .map(String::from)
502 .collect()
503 }
504
505 fn features_hooks(config: &str) -> Option<bool> {
506 toml::from_str::<toml::Table>(config)
507 .expect("parses")
508 .get("features")
509 .and_then(|f| f.get("hooks"))
510 .and_then(|v| v.as_bool())
511 }
512
513 #[test]
514 fn install_preserves_user_toml_entries_comments_and_formatting() {
515 let root = temp_root();
516 let codex_dir = root.path().join("codex");
517 fs::create_dir_all(&codex_dir).unwrap();
518 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
519 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
520 let hook = root.path().join(codex_hook_install_name());
521
522 let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
523
524 assert!(result.hooks_changed && result.config_changed);
525
526 let config = fs::read_to_string(&result.config_path).unwrap();
528 for line in REAL_WORLD_CONFIG.lines() {
529 assert!(
530 config.contains(line),
531 "user toml line must survive untouched: {line}"
532 );
533 }
534 assert_eq!(features_hooks(&config), Some(true));
535 assert_eq!(
536 toml::from_str::<toml::Table>(&config)
537 .unwrap()
538 .get("profiles")
539 .and_then(|p| p.get("work"))
540 .and_then(|w| w.get("features"))
541 .and_then(|f| f.get("hooks"))
542 .and_then(|v| v.as_bool()),
543 Some(false),
544 "the profile-scoped features table is not ours to touch"
545 );
546
547 let hooks = fs::read_to_string(&result.hooks_path).unwrap();
549 for line in REAL_WORLD_HOOKS.lines() {
550 assert!(
551 hooks.contains(line),
552 "user hooks line must survive untouched: {line}"
553 );
554 }
555 let mut commands = session_start_commands(&hooks);
556 commands.sort();
557 let mut expected = vec![
558 "/usr/local/bin/codex-motd".to_string(),
559 installed_command(&hook),
560 ];
561 expected.sort();
562 assert_eq!(commands, expected);
563
564 let value: JsonValue = parse_serde(&hooks, Path::new("<test>")).unwrap();
566 let ours = &value["hooks"]["SessionStart"]
567 .as_array()
568 .unwrap()
569 .iter()
570 .find(|group| {
571 group
572 .get("hooks")
573 .and_then(JsonValue::as_array)
574 .is_some_and(|entries| {
575 entries.iter().any(|entry| {
576 entry.get("command").and_then(JsonValue::as_str)
577 == Some(installed_command(&hook).as_str())
578 })
579 })
580 })
581 .expect("our group");
582 assert!(ours.get("matcher").is_none());
583
584 let asset = fs::read_to_string(&hook).unwrap();
585 assert_eq!(asset, codex_hook_asset());
586 assert!(asset.contains(CODEX_HOOK_MARKER));
587 assert!(asset.contains("CODEX_THREAD_ID"));
588 let resume_argv = if cfg!(windows) {
589 r#"@("codex", "resume", "$sessionId")"#
590 } else {
591 r#""codex", "resume", session_id"#
592 };
593 assert!(asset.contains(resume_argv));
594 assert!(!asset.contains("HERDR_"), "fully env-renamed port");
595 #[cfg(unix)]
596 {
597 use std::os::unix::fs::PermissionsExt;
598 let mode = fs::metadata(&hook).unwrap().permissions().mode() & 0o777;
599 assert_eq!(mode, 0o755, "the hook must be executable");
600 }
601 }
602
603 #[test]
604 fn a_disabled_flag_is_flipped_and_its_comment_preserved() {
605 let root = temp_root();
606 let codex_dir = root.path().join("codex");
607 fs::create_dir_all(&codex_dir).unwrap();
608 fs::write(
609 codex_dir.join("config.toml"),
610 "[features]\nhooks = false # keep me\n",
611 )
612 .unwrap();
613 let hook = root.path().join(codex_hook_install_name());
614
615 install_codex_hook_into(&codex_dir, &hook).unwrap();
616
617 let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
618 assert_eq!(config, "[features]\nhooks = true # keep me\n");
619 }
620
621 #[test]
622 fn a_config_without_features_gets_the_table_appended() {
623 let root = temp_root();
624 let codex_dir = root.path().join("codex");
625 fs::create_dir_all(&codex_dir).unwrap();
626 fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
627 let hook = root.path().join(codex_hook_install_name());
628
629 install_codex_hook_into(&codex_dir, &hook).unwrap();
630
631 let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
632 assert!(config.starts_with("model = \"gpt-5\"\n\n[features]\nhooks = true\n"));
633 assert_eq!(features_hooks(&config), Some(true));
634 }
635
636 #[test]
637 fn a_missing_config_and_hooks_file_are_created() {
638 let root = temp_root();
639 let codex_dir = root.path().join("codex");
640 fs::create_dir_all(&codex_dir).unwrap();
641 let hook = root.path().join(codex_hook_install_name());
642
643 let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
644
645 assert!(result.hooks_changed && result.config_changed);
646 let config = fs::read_to_string(&result.config_path).unwrap();
647 assert_eq!(config, "[features]\nhooks = true\n");
648 let commands = session_start_commands(&fs::read_to_string(&result.hooks_path).unwrap());
649 assert_eq!(commands, vec![installed_command(&hook)]);
650 }
651
652 #[test]
653 fn reinstall_is_a_byte_exact_noop() {
654 let root = temp_root();
655 let codex_dir = root.path().join("codex");
656 fs::create_dir_all(&codex_dir).unwrap();
657 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
658 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
659 let hook = root.path().join(codex_hook_install_name());
660
661 install_codex_hook_into(&codex_dir, &hook).unwrap();
662 let config_once = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
663 let hooks_once = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
664
665 let second = install_codex_hook_into(&codex_dir, &hook).unwrap();
666 assert!(
667 !second.hooks_changed && !second.config_changed,
668 "reinstall is a no-op on both files"
669 );
670 assert_eq!(
671 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
672 config_once
673 );
674 assert_eq!(
675 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
676 hooks_once
677 );
678 }
679
680 #[test]
681 fn a_dotted_features_table_fails_closed_and_writes_nothing() {
682 let root = temp_root();
683 let codex_dir = root.path().join("codex");
684 fs::create_dir_all(&codex_dir).unwrap();
685 fs::write(
688 codex_dir.join("config.toml"),
689 "model = \"gpt-5\"\nfeatures = { hooks = false }\n",
690 )
691 .unwrap();
692 let original_hooks = "{}\n";
693 fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
694 let hook = root.path().join(codex_hook_install_name());
695
696 let err = install_codex_hook_into(&codex_dir, &hook)
697 .unwrap_err()
698 .to_string();
699
700 assert!(
701 err.contains("failed to safely update codex config"),
702 "error names the file and the refusal: {err}"
703 );
704 assert_eq!(
705 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
706 "model = \"gpt-5\"\nfeatures = { hooks = false }\n"
707 );
708 assert_eq!(
709 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
710 original_hooks
711 );
712 assert!(!hook.exists(), "not even the asset is written");
713 }
714
715 #[test]
716 fn malformed_toml_fails_and_writes_nothing() {
717 let root = temp_root();
718 let codex_dir = root.path().join("codex");
719 fs::create_dir_all(&codex_dir).unwrap();
720 let original_config = "model = \n";
721 fs::write(codex_dir.join("config.toml"), original_config).unwrap();
722 let hook = root.path().join(codex_hook_install_name());
723
724 let err = install_codex_hook_into(&codex_dir, &hook)
725 .unwrap_err()
726 .to_string();
727
728 assert!(
729 err.contains("failed to parse"),
730 "error names the file: {err}"
731 );
732 assert_eq!(
733 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
734 original_config
735 );
736 assert!(!hook.exists());
737 }
738
739 #[test]
740 fn malformed_hooks_json_fails_and_writes_nothing() {
741 let root = temp_root();
742 let codex_dir = root.path().join("codex");
743 fs::create_dir_all(&codex_dir).unwrap();
744 fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
745 let original_hooks = "{oops";
746 fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
747 let hook = root.path().join(codex_hook_install_name());
748
749 let err = install_codex_hook_into(&codex_dir, &hook)
750 .unwrap_err()
751 .to_string();
752
753 assert!(
754 err.contains("failed to parse"),
755 "error names the file: {err}"
756 );
757 assert_eq!(
758 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
759 original_hooks
760 );
761 assert_eq!(
762 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
763 "model = \"gpt-5\"\n",
764 "the verified-but-unwritten config edit never reaches disk"
765 );
766 assert!(!hook.exists());
767 }
768
769 #[test]
770 fn missing_codex_directory_is_an_actionable_error() {
771 let root = temp_root();
772 let hook = root.path().join(codex_hook_install_name());
773
774 let err = install_codex_hook_into(&root.path().join("never"), &hook)
775 .unwrap_err()
776 .to_string();
777
778 assert!(
779 err.contains("codex config directory not found at"),
780 "error must name the path: {err}"
781 );
782 assert!(
783 err.contains("install codex first"),
784 "error must say what to do: {err}"
785 );
786 }
787
788 #[test]
789 fn uninstall_restores_hooks_json_and_leaves_the_flag() {
790 let root = temp_root();
791 let codex_dir = root.path().join("codex");
792 fs::create_dir_all(&codex_dir).unwrap();
793 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
794 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
795 let hook = root.path().join(codex_hook_install_name());
796 install_codex_hook_into(&codex_dir, &hook).unwrap();
797
798 let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
799
800 assert!(result.hooks_changed && result.hook_removed);
801 assert_eq!(
803 fs::read_to_string(&result.hooks_path).unwrap(),
804 REAL_WORLD_HOOKS
805 );
806 assert_eq!(
809 features_hooks(&fs::read_to_string(&result.config_path).unwrap()),
810 Some(true)
811 );
812 assert!(!hook.exists());
813 }
814
815 #[test]
816 fn uninstall_removes_our_entry_from_a_mixed_group_and_keeps_the_users() {
817 let root = temp_root();
818 let codex_dir = root.path().join("codex");
819 fs::create_dir_all(&codex_dir).unwrap();
820 let command = installed_command(&root.path().join(codex_hook_install_name()));
821 let mixed = format!(
823 r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"/usr/local/bin/codex-motd"}},{{"type":"command","command":{command}}}]}}]}}}}"#,
824 command = serde_json::to_string(&command).unwrap()
825 );
826 fs::write(codex_dir.join("hooks.json"), mixed).unwrap();
827 let hook = root.path().join(codex_hook_install_name());
828
829 uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
830
831 let after = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
832 assert_eq!(
833 session_start_commands(&after),
834 vec!["/usr/local/bin/codex-motd".to_string()],
835 "the user's entry survives, ours is gone"
836 );
837 }
838
839 #[test]
840 fn uninstall_noops_when_absent_and_leaves_foreign_assets_alone() {
841 let root = temp_root();
842 let codex_dir = root.path().join("codex");
843 fs::create_dir_all(&codex_dir).unwrap();
844 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
845 let hook = root.path().join(codex_hook_install_name());
847 fs::write(&hook, "// user's own script, no marker\n").unwrap();
848
849 let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
850
851 assert!(!result.hooks_changed);
852 assert!(
853 !result.hook_removed,
854 "a file without our marker is not ours"
855 );
856 assert_eq!(
857 fs::read_to_string(&result.hooks_path).unwrap(),
858 REAL_WORLD_HOOKS
859 );
860 assert_eq!(
861 fs::read_to_string(&hook).unwrap(),
862 "// user's own script, no marker\n"
863 );
864 }
865
866 #[test]
867 fn install_preserves_both_files_modes() {
868 let root = temp_root();
869 let codex_dir = root.path().join("codex");
870 fs::create_dir_all(&codex_dir).unwrap();
871 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
872 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
873 #[cfg(unix)]
874 {
875 use std::os::unix::fs::PermissionsExt;
876 fs::set_permissions(
877 codex_dir.join("config.toml"),
878 fs::Permissions::from_mode(0o600),
879 )
880 .unwrap();
881 fs::set_permissions(
882 codex_dir.join("hooks.json"),
883 fs::Permissions::from_mode(0o600),
884 )
885 .unwrap();
886 let hook = root.path().join(codex_hook_install_name());
887
888 install_codex_hook_into(&codex_dir, &hook).unwrap();
889
890 let config_mode = fs::metadata(codex_dir.join("config.toml"))
891 .unwrap()
892 .permissions()
893 .mode()
894 & 0o777;
895 let hooks_mode = fs::metadata(codex_dir.join("hooks.json"))
896 .unwrap()
897 .permissions()
898 .mode()
899 & 0o777;
900 assert_eq!(config_mode, 0o600, "the atomic rewrite preserves the mode");
901 assert_eq!(hooks_mode, 0o600, "the atomic rewrite preserves the mode");
902 }
903 }
904
905 #[test]
906 fn the_windows_ps1_asset_is_a_marked_reporter_port() {
907 assert!(CODEX_HOOK_ASSET_WINDOWS.contains(CODEX_HOOK_MARKER));
912 assert!(
913 CODEX_HOOK_ASSET_WINDOWS.contains("pane.report_agent_session"),
914 "speaks the same control-socket method"
915 );
916 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("par-mux:codex"));
917 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("session_resume_argv"));
918 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("CODEX_THREAD_ID"));
920 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("SessionStart"));
921 }
922}