1use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use super::{
13 home_dir, hook_asset_path as asset_path_for, hook_command_with_action, merge_session_start,
14 remove_marked_file, save_settings, unmerge_session_start, 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_session_start(&hooks_content, &hooks_path, &command, None)?;
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) = unmerge_session_start(&content, &hooks_path, &command)? {
155 save_settings(&hooks_path, &updated)?;
156 hooks_changed = true;
157 }
158 }
159
160 let hook_removed = remove_marked_file(hook_path, CODEX_HOOK_MARKER)?;
161
162 Ok(CodexHookUninstall {
163 hook_path: hook_path.to_path_buf(),
164 hooks_path,
165 config_path,
166 hooks_changed,
167 hook_removed,
168 })
169}
170
171fn codex_hook_command(hook_path: &Path) -> String {
175 hook_command_with_action(hook_path, "session")
176}
177
178fn read_or_default(path: &Path, default: &str) -> io::Result<String> {
179 match fs::read_to_string(path) {
180 Ok(content) => Ok(content),
181 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(default.to_string()),
182 Err(err) => Err(err),
183 }
184}
185
186fn ensure_features_hooks_true(content: &str, config_path: &Path) -> io::Result<Option<String>> {
191 let parsed = toml::from_str::<toml::Table>(content).map_err(|err| {
194 io::Error::other(format!("failed to parse {}: {err}", config_path.display()))
195 })?;
196 if features_hooks_enabled(&parsed) {
197 return Ok(None);
198 }
199
200 let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
201 let trailing_newline = content.ends_with('\n');
202 let mut features_header_index = None;
203 let mut hooks_line_index = None;
204 let mut in_features = false;
205
206 for (index, line) in lines.iter().enumerate() {
207 if let Some(header) = toml_table_header(line) {
208 in_features = header == "[features]";
209 if in_features && features_header_index.is_none() {
210 features_header_index = Some(index);
211 }
212 continue;
213 }
214 if in_features && hooks_line_index.is_none() && is_toml_key(line, "hooks") {
215 hooks_line_index = Some(index);
216 }
217 }
218
219 let updated = if let Some(hooks_index) = hooks_line_index {
220 let line = &lines[hooks_index];
221 let comment = toml_comment_start(line).map(str::to_string);
222 lines[hooks_index] = match comment {
223 Some(comment) => format!("hooks = true {comment}"),
224 None => "hooks = true".to_string(),
225 };
226 join_toml_lines(lines, trailing_newline)
227 } else if let Some(header_index) = features_header_index {
228 lines.insert(header_index + 1, "hooks = true".to_string());
229 join_toml_lines(lines, trailing_newline)
230 } else {
231 let mut result = content.trim_end_matches('\n').to_string();
232 if !result.is_empty() {
233 result.push('\n');
234 result.push('\n');
235 }
236 result.push_str("[features]\nhooks = true\n");
237 result
238 };
239
240 let reparsed = toml::from_str::<toml::Table>(&updated).map_err(|err| {
245 io::Error::other(format!(
246 "failed to safely update codex config at {}: {err}",
247 config_path.display()
248 ))
249 })?;
250 if !features_hooks_enabled(&reparsed) {
251 return Err(io::Error::other(format!(
252 "failed to safely update codex config at {}",
253 config_path.display()
254 )));
255 }
256 Ok(Some(updated))
257}
258
259fn features_hooks_enabled(config: &toml::Table) -> bool {
260 config
261 .get("features")
262 .and_then(|features| features.get("hooks"))
263 .and_then(|hooks| hooks.as_bool())
264 == Some(true)
265}
266
267fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
268 let mut result = lines.join("\n");
269 if trailing_newline || result.is_empty() {
270 result.push('\n');
271 }
272 result
273}
274
275fn toml_table_header(line: &str) -> Option<&str> {
279 let trimmed = line.trim_start();
280 if trimmed.starts_with('#') || !trimmed.starts_with('[') {
281 return None;
282 }
283
284 let header_end = if trimmed.starts_with("[[") {
285 trimmed.find("]]").map(|index| index + 2)?
286 } else {
287 trimmed.find(']').map(|index| index + 1)?
288 };
289 let header = &trimmed[..header_end];
290 let rest = trimmed[header_end..].trim_start();
291 if !rest.is_empty() && !rest.starts_with('#') {
292 return None;
293 }
294
295 Some(header)
296}
297
298fn is_toml_key(line: &str, key: &str) -> bool {
301 let trimmed = line.trim();
302 if trimmed.starts_with('#') || !trimmed.starts_with(key) {
303 return false;
304 }
305
306 trimmed[key.len()..].trim_start().starts_with('=')
307}
308
309fn toml_comment_start(value: &str) -> Option<&str> {
312 let mut quote = None;
313 let mut escaped = false;
314 for (index, ch) in value.char_indices() {
315 if let Some(quote_char) = quote {
316 if escaped {
317 escaped = false;
318 continue;
319 }
320 if ch == '\\' {
321 escaped = true;
322 continue;
323 }
324 if ch == quote_char {
325 quote = None;
326 }
327 continue;
328 }
329
330 match ch {
331 '"' | '\'' => quote = Some(ch),
332 '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
333 return Some(&value[index..]);
334 }
335 _ => {}
336 }
337 }
338
339 None
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use serde_json::Value as JsonValue;
346 use std::fs;
347 use std::path::Path;
348
349 use super::super::parse_serde;
350
351 fn temp_root() -> tempfile::TempDir {
352 tempfile::tempdir().expect("temp dir")
353 }
354
355 const REAL_WORLD_CONFIG: &str = r#"# codex configuration
359model = "gpt-5-codex"
360notify = ["notify-send", "--app-name", "codex"]
361
362[profiles.work.features]
363hooks = false
364
365[features]
366# experimental toggles
367web_search = true
368
369[mcp_servers.github]
370command = "gh"
371args = ["mcp", "serve"]
372"#;
373
374 const REAL_WORLD_HOOKS: &str = r#"{
377 "hooks": {
378 "SessionStart": [
379 {
380 "hooks": [
381 {
382 "type": "command",
383 "command": "/usr/local/bin/codex-motd"
384 }
385 ]
386 }
387 ],
388 "Stop": [
389 {
390 "hooks": [
391 {
392 "type": "command",
393 "command": "/usr/local/bin/codex-done"
394 }
395 ]
396 }
397 ]
398 }
399}
400"#;
401
402 fn installed_command(hook_path: &Path) -> String {
403 codex_hook_command(hook_path)
404 }
405
406 fn session_start_commands(content: &str) -> Vec<String> {
407 let value: JsonValue = parse_serde(content, Path::new("<test>")).expect("parses");
408 value
409 .get("hooks")
410 .and_then(|hooks| hooks.get("SessionStart"))
411 .and_then(JsonValue::as_array)
412 .expect("SessionStart array")
413 .iter()
414 .flat_map(|group| {
415 group
416 .get("hooks")
417 .and_then(JsonValue::as_array)
418 .map(|entries| {
419 entries
420 .iter()
421 .filter_map(|entry| entry.get("command").and_then(JsonValue::as_str))
422 .collect::<Vec<_>>()
423 })
424 .unwrap_or_default()
425 })
426 .map(String::from)
427 .collect()
428 }
429
430 fn features_hooks(config: &str) -> Option<bool> {
431 toml::from_str::<toml::Table>(config)
432 .expect("parses")
433 .get("features")
434 .and_then(|f| f.get("hooks"))
435 .and_then(|v| v.as_bool())
436 }
437
438 #[test]
439 fn install_preserves_user_toml_entries_comments_and_formatting() {
440 let root = temp_root();
441 let codex_dir = root.path().join("codex");
442 fs::create_dir_all(&codex_dir).unwrap();
443 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
444 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
445 let hook = root.path().join(codex_hook_install_name());
446
447 let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
448
449 assert!(result.hooks_changed && result.config_changed);
450
451 let config = fs::read_to_string(&result.config_path).unwrap();
453 for line in REAL_WORLD_CONFIG.lines() {
454 assert!(
455 config.contains(line),
456 "user toml line must survive untouched: {line}"
457 );
458 }
459 assert_eq!(features_hooks(&config), Some(true));
460 assert_eq!(
461 toml::from_str::<toml::Table>(&config)
462 .unwrap()
463 .get("profiles")
464 .and_then(|p| p.get("work"))
465 .and_then(|w| w.get("features"))
466 .and_then(|f| f.get("hooks"))
467 .and_then(|v| v.as_bool()),
468 Some(false),
469 "the profile-scoped features table is not ours to touch"
470 );
471
472 let hooks = fs::read_to_string(&result.hooks_path).unwrap();
474 for line in REAL_WORLD_HOOKS.lines() {
475 assert!(
476 hooks.contains(line),
477 "user hooks line must survive untouched: {line}"
478 );
479 }
480 let mut commands = session_start_commands(&hooks);
481 commands.sort();
482 let mut expected = vec![
483 "/usr/local/bin/codex-motd".to_string(),
484 installed_command(&hook),
485 ];
486 expected.sort();
487 assert_eq!(commands, expected);
488
489 let value: JsonValue = parse_serde(&hooks, Path::new("<test>")).unwrap();
491 let ours = &value["hooks"]["SessionStart"]
492 .as_array()
493 .unwrap()
494 .iter()
495 .find(|group| {
496 group
497 .get("hooks")
498 .and_then(JsonValue::as_array)
499 .is_some_and(|entries| {
500 entries.iter().any(|entry| {
501 entry.get("command").and_then(JsonValue::as_str)
502 == Some(installed_command(&hook).as_str())
503 })
504 })
505 })
506 .expect("our group");
507 assert!(ours.get("matcher").is_none());
508
509 let asset = fs::read_to_string(&hook).unwrap();
510 assert_eq!(asset, codex_hook_asset());
511 assert!(asset.contains(CODEX_HOOK_MARKER));
512 assert!(asset.contains("CODEX_THREAD_ID"));
513 let resume_argv = if cfg!(windows) {
514 r#"@("codex", "resume", "$sessionId")"#
515 } else {
516 r#""codex", "resume", session_id"#
517 };
518 assert!(asset.contains(resume_argv));
519 assert!(!asset.contains("HERDR_"), "fully env-renamed port");
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::PermissionsExt;
523 let mode = fs::metadata(&hook).unwrap().permissions().mode() & 0o777;
524 assert_eq!(mode, 0o755, "the hook must be executable");
525 }
526 }
527
528 #[test]
529 fn a_disabled_flag_is_flipped_and_its_comment_preserved() {
530 let root = temp_root();
531 let codex_dir = root.path().join("codex");
532 fs::create_dir_all(&codex_dir).unwrap();
533 fs::write(
534 codex_dir.join("config.toml"),
535 "[features]\nhooks = false # keep me\n",
536 )
537 .unwrap();
538 let hook = root.path().join(codex_hook_install_name());
539
540 install_codex_hook_into(&codex_dir, &hook).unwrap();
541
542 let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
543 assert_eq!(config, "[features]\nhooks = true # keep me\n");
544 }
545
546 #[test]
547 fn a_config_without_features_gets_the_table_appended() {
548 let root = temp_root();
549 let codex_dir = root.path().join("codex");
550 fs::create_dir_all(&codex_dir).unwrap();
551 fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
552 let hook = root.path().join(codex_hook_install_name());
553
554 install_codex_hook_into(&codex_dir, &hook).unwrap();
555
556 let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
557 assert!(config.starts_with("model = \"gpt-5\"\n\n[features]\nhooks = true\n"));
558 assert_eq!(features_hooks(&config), Some(true));
559 }
560
561 #[test]
562 fn a_missing_config_and_hooks_file_are_created() {
563 let root = temp_root();
564 let codex_dir = root.path().join("codex");
565 fs::create_dir_all(&codex_dir).unwrap();
566 let hook = root.path().join(codex_hook_install_name());
567
568 let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
569
570 assert!(result.hooks_changed && result.config_changed);
571 let config = fs::read_to_string(&result.config_path).unwrap();
572 assert_eq!(config, "[features]\nhooks = true\n");
573 let commands = session_start_commands(&fs::read_to_string(&result.hooks_path).unwrap());
574 assert_eq!(commands, vec![installed_command(&hook)]);
575 }
576
577 #[test]
578 fn reinstall_is_a_byte_exact_noop() {
579 let root = temp_root();
580 let codex_dir = root.path().join("codex");
581 fs::create_dir_all(&codex_dir).unwrap();
582 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
583 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
584 let hook = root.path().join(codex_hook_install_name());
585
586 install_codex_hook_into(&codex_dir, &hook).unwrap();
587 let config_once = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
588 let hooks_once = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
589
590 let second = install_codex_hook_into(&codex_dir, &hook).unwrap();
591 assert!(
592 !second.hooks_changed && !second.config_changed,
593 "reinstall is a no-op on both files"
594 );
595 assert_eq!(
596 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
597 config_once
598 );
599 assert_eq!(
600 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
601 hooks_once
602 );
603 }
604
605 #[test]
606 fn a_dotted_features_table_fails_closed_and_writes_nothing() {
607 let root = temp_root();
608 let codex_dir = root.path().join("codex");
609 fs::create_dir_all(&codex_dir).unwrap();
610 fs::write(
613 codex_dir.join("config.toml"),
614 "model = \"gpt-5\"\nfeatures = { hooks = false }\n",
615 )
616 .unwrap();
617 let original_hooks = "{}\n";
618 fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
619 let hook = root.path().join(codex_hook_install_name());
620
621 let err = install_codex_hook_into(&codex_dir, &hook)
622 .unwrap_err()
623 .to_string();
624
625 assert!(
626 err.contains("failed to safely update codex config"),
627 "error names the file and the refusal: {err}"
628 );
629 assert_eq!(
630 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
631 "model = \"gpt-5\"\nfeatures = { hooks = false }\n"
632 );
633 assert_eq!(
634 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
635 original_hooks
636 );
637 assert!(!hook.exists(), "not even the asset is written");
638 }
639
640 #[test]
641 fn malformed_toml_fails_and_writes_nothing() {
642 let root = temp_root();
643 let codex_dir = root.path().join("codex");
644 fs::create_dir_all(&codex_dir).unwrap();
645 let original_config = "model = \n";
646 fs::write(codex_dir.join("config.toml"), original_config).unwrap();
647 let hook = root.path().join(codex_hook_install_name());
648
649 let err = install_codex_hook_into(&codex_dir, &hook)
650 .unwrap_err()
651 .to_string();
652
653 assert!(
654 err.contains("failed to parse"),
655 "error names the file: {err}"
656 );
657 assert_eq!(
658 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
659 original_config
660 );
661 assert!(!hook.exists());
662 }
663
664 #[test]
665 fn malformed_hooks_json_fails_and_writes_nothing() {
666 let root = temp_root();
667 let codex_dir = root.path().join("codex");
668 fs::create_dir_all(&codex_dir).unwrap();
669 fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
670 let original_hooks = "{oops";
671 fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
672 let hook = root.path().join(codex_hook_install_name());
673
674 let err = install_codex_hook_into(&codex_dir, &hook)
675 .unwrap_err()
676 .to_string();
677
678 assert!(
679 err.contains("failed to parse"),
680 "error names the file: {err}"
681 );
682 assert_eq!(
683 fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
684 original_hooks
685 );
686 assert_eq!(
687 fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
688 "model = \"gpt-5\"\n",
689 "the verified-but-unwritten config edit never reaches disk"
690 );
691 assert!(!hook.exists());
692 }
693
694 #[test]
695 fn missing_codex_directory_is_an_actionable_error() {
696 let root = temp_root();
697 let hook = root.path().join(codex_hook_install_name());
698
699 let err = install_codex_hook_into(&root.path().join("never"), &hook)
700 .unwrap_err()
701 .to_string();
702
703 assert!(
704 err.contains("codex config directory not found at"),
705 "error must name the path: {err}"
706 );
707 assert!(
708 err.contains("install codex first"),
709 "error must say what to do: {err}"
710 );
711 }
712
713 #[test]
714 fn uninstall_restores_hooks_json_and_leaves_the_flag() {
715 let root = temp_root();
716 let codex_dir = root.path().join("codex");
717 fs::create_dir_all(&codex_dir).unwrap();
718 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
719 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
720 let hook = root.path().join(codex_hook_install_name());
721 install_codex_hook_into(&codex_dir, &hook).unwrap();
722
723 let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
724
725 assert!(result.hooks_changed && result.hook_removed);
726 assert_eq!(
728 fs::read_to_string(&result.hooks_path).unwrap(),
729 REAL_WORLD_HOOKS
730 );
731 assert_eq!(
734 features_hooks(&fs::read_to_string(&result.config_path).unwrap()),
735 Some(true)
736 );
737 assert!(!hook.exists());
738 }
739
740 #[test]
741 fn uninstall_removes_our_entry_from_a_mixed_group_and_keeps_the_users() {
742 let root = temp_root();
743 let codex_dir = root.path().join("codex");
744 fs::create_dir_all(&codex_dir).unwrap();
745 let command = installed_command(&root.path().join(codex_hook_install_name()));
746 let mixed = format!(
748 r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"/usr/local/bin/codex-motd"}},{{"type":"command","command":{command}}}]}}]}}}}"#,
749 command = serde_json::to_string(&command).unwrap()
750 );
751 fs::write(codex_dir.join("hooks.json"), mixed).unwrap();
752 let hook = root.path().join(codex_hook_install_name());
753
754 uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
755
756 let after = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
757 assert_eq!(
758 session_start_commands(&after),
759 vec!["/usr/local/bin/codex-motd".to_string()],
760 "the user's entry survives, ours is gone"
761 );
762 }
763
764 #[test]
765 fn uninstall_noops_when_absent_and_leaves_foreign_assets_alone() {
766 let root = temp_root();
767 let codex_dir = root.path().join("codex");
768 fs::create_dir_all(&codex_dir).unwrap();
769 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
770 let hook = root.path().join(codex_hook_install_name());
772 fs::write(&hook, "// user's own script, no marker\n").unwrap();
773
774 let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
775
776 assert!(!result.hooks_changed);
777 assert!(
778 !result.hook_removed,
779 "a file without our marker is not ours"
780 );
781 assert_eq!(
782 fs::read_to_string(&result.hooks_path).unwrap(),
783 REAL_WORLD_HOOKS
784 );
785 assert_eq!(
786 fs::read_to_string(&hook).unwrap(),
787 "// user's own script, no marker\n"
788 );
789 }
790
791 #[test]
792 fn install_preserves_both_files_modes() {
793 let root = temp_root();
794 let codex_dir = root.path().join("codex");
795 fs::create_dir_all(&codex_dir).unwrap();
796 fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
797 fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
798 #[cfg(unix)]
799 {
800 use std::os::unix::fs::PermissionsExt;
801 fs::set_permissions(
802 codex_dir.join("config.toml"),
803 fs::Permissions::from_mode(0o600),
804 )
805 .unwrap();
806 fs::set_permissions(
807 codex_dir.join("hooks.json"),
808 fs::Permissions::from_mode(0o600),
809 )
810 .unwrap();
811 let hook = root.path().join(codex_hook_install_name());
812
813 install_codex_hook_into(&codex_dir, &hook).unwrap();
814
815 let config_mode = fs::metadata(codex_dir.join("config.toml"))
816 .unwrap()
817 .permissions()
818 .mode()
819 & 0o777;
820 let hooks_mode = fs::metadata(codex_dir.join("hooks.json"))
821 .unwrap()
822 .permissions()
823 .mode()
824 & 0o777;
825 assert_eq!(config_mode, 0o600, "the atomic rewrite preserves the mode");
826 assert_eq!(hooks_mode, 0o600, "the atomic rewrite preserves the mode");
827 }
828 }
829
830 #[test]
831 fn the_windows_ps1_asset_is_a_marked_reporter_port() {
832 assert!(CODEX_HOOK_ASSET_WINDOWS.contains(CODEX_HOOK_MARKER));
837 assert!(
838 CODEX_HOOK_ASSET_WINDOWS.contains("pane.report_agent_session"),
839 "speaks the same control-socket method"
840 );
841 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("par-mux:codex"));
842 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("session_resume_argv"));
843 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("CODEX_THREAD_ID"));
845 assert!(CODEX_HOOK_ASSET_WINDOWS.contains("SessionStart"));
846 }
847}