Skip to content

Commit 4f7b674

Browse files
code-yeongyugaebal-gajae
andcommitted
fix(plugins): chmod +x generated hook scripts in write_hook_plugin — closes ROADMAP #25 hotfix lane
Linux CI was failing 'hooks::tests::collects_and_runs_hooks_from_enabled_plugins' with 'PostToolUse hook .../hooks/post.sh failed to start for "Read": Broken pipe (os error 32)'. Three reproductions today on main: 24120271422, 24120538408, 24121392171 — escalated from first-attempt flake to deterministic-red on the third push. Root cause (per ROADMAP #25, recorded at 79da4b8): the test helper write_hook_plugin in rust/crates/plugins/src/hooks.rs writes pre.sh, post.sh, and failure.sh via fs::write with #!/bin/sh shebangs but never sets the execute bit. On Linux, Command::new(path).spawn() on a shebang script without +x can race in fork/exec — the pipe setup succeeds, the post-fork exec fails, and the parent observes 'Broken pipe (os error 32)' instead of a clean 'Permission denied'. macOS fork/exec ordering happens to paper over it, which is why the test passed locally and only flaked on Linux CI. Surgical hotfix per gaebal-gajae's direction (message 1491325062031343787 in #clawcode-building-in-public): only the chmod-+x in write_hook_plugin and a regression guard. No broader harness cleanup. The deeper plugin-test sealing pass for ROADMAP #25 + #27 stays in gaebal-gajae's OMX lane omx-issue-149131850982-flaky-hooks-test. Changes: 1. New helper make_executable(&Path) in the test module that sets mode 0o755 via std::os::unix::fs::PermissionsExt under #[cfg(unix)] and is a no-op on non-unix targets. 2. write_hook_plugin now binds each script's path to a local, calls fs::write on it, then calls make_executable on it before moving on. Three call sites: pre.sh, post.sh, failure.sh. 3. New regression guard test generated_hook_scripts_are_executable under #[cfg(unix)] that calls write_hook_plugin and asserts each of the three generated .sh files has at least one execute bit set (mode & 0o111 != 0). Future tweaks to write_hook_plugin cannot silently regress the chmod without breaking CI. Verification: - cargo test --release -p plugins hooks:: → 4 passing (3 existing hooks tests + new generated_hook_scripts_are_executable), including the previously-flaking collects_and_runs_hooks_from_enabled_plugins - cargo test --release -p plugins → 35 passing, 0 failing - cargo fmt -p plugins → clean - cargo clippy -p plugins -- -D warnings → clean Co-authored-by: gaebal-gajae <gaebal-gajae@layofflabs.com>
1 parent 647ff37 commit 4f7b674

1 file changed

Lines changed: 47 additions & 3 deletions

File tree

rust/crates/plugins/src/hooks.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,18 @@ mod tests {
359359
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
360360
}
361361

362+
fn make_executable(path: &Path) {
363+
#[cfg(unix)]
364+
{
365+
use std::os::unix::fs::PermissionsExt;
366+
let perms = fs::Permissions::from_mode(0o755);
367+
fs::set_permissions(path, perms)
368+
.unwrap_or_else(|e| panic!("chmod +x {}: {e}", path.display()));
369+
}
370+
#[cfg(not(unix))]
371+
let _ = path;
372+
}
373+
362374
fn write_hook_plugin(
363375
root: &Path,
364376
name: &str,
@@ -368,21 +380,30 @@ mod tests {
368380
) {
369381
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
370382
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
383+
384+
let pre_path = root.join("hooks").join("pre.sh");
371385
fs::write(
372-
root.join("hooks").join("pre.sh"),
386+
&pre_path,
373387
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
374388
)
375389
.expect("write pre hook");
390+
make_executable(&pre_path);
391+
392+
let post_path = root.join("hooks").join("post.sh");
376393
fs::write(
377-
root.join("hooks").join("post.sh"),
394+
&post_path,
378395
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
379396
)
380397
.expect("write post hook");
398+
make_executable(&post_path);
399+
400+
let failure_path = root.join("hooks").join("failure.sh");
381401
fs::write(
382-
root.join("hooks").join("failure.sh"),
402+
&failure_path,
383403
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
384404
)
385405
.expect("write failure hook");
406+
make_executable(&failure_path);
386407
fs::write(
387408
root.join(".claude-plugin").join("plugin.json"),
388409
format!(
@@ -496,4 +517,27 @@ mod tests {
496517
.iter()
497518
.any(|message| message == "later plugin hook"));
498519
}
520+
521+
#[test]
522+
#[cfg(unix)]
523+
fn generated_hook_scripts_are_executable() {
524+
use std::os::unix::fs::PermissionsExt;
525+
526+
// given
527+
let root = temp_dir("exec-guard");
528+
write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
529+
530+
// then
531+
for script in ["pre.sh", "post.sh", "failure.sh"] {
532+
let path = root.join("hooks").join(script);
533+
let mode = fs::metadata(&path)
534+
.unwrap_or_else(|e| panic!("{script} metadata: {e}"))
535+
.permissions()
536+
.mode();
537+
assert!(
538+
mode & 0o111 != 0,
539+
"{script} must have at least one execute bit set, got mode {mode:#o}"
540+
);
541+
}
542+
}
499543
}

0 commit comments

Comments
 (0)