diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 9b9a7dfdc0..e2b8d32d97 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -22,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true import json import os import random diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index 2931936a18..674fe3cb96 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -22,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true import json import os import random diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh index 82522ac1bf..d54d333935 100644 --- a/src/integration/assets/copilot/herdr-agent-state.sh +++ b/src/integration/assets/copilot/herdr-agent-state.sh @@ -16,7 +16,7 @@ cat >"$hook_input_file" 2>/dev/null || true [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true import json import os import random diff --git a/src/integration/assets/devin/herdr-agent-state.ps1 b/src/integration/assets/devin/herdr-agent-state.ps1 index 7d14ad7575..70f21460b9 100644 --- a/src/integration/assets/devin/herdr-agent-state.ps1 +++ b/src/integration/assets/devin/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=devin -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") diff --git a/src/integration/assets/devin/herdr-agent-state.sh b/src/integration/assets/devin/herdr-agent-state.sh index 86249b8893..5dd8256648 100644 --- a/src/integration/assets/devin/herdr-agent-state.sh +++ b/src/integration/assets/devin/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=devin -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu @@ -22,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true from __future__ import annotations import json diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/src/integration/assets/droid/herdr-agent-state.sh index d777b2c113..44d57fb9cd 100644 --- a/src/integration/assets/droid/herdr-agent-state.sh +++ b/src/integration/assets/droid/herdr-agent-state.sh @@ -22,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true import json import os import random diff --git a/src/integration/assets/grok/herdr-agent-state.ps1 b/src/integration/assets/grok/herdr-agent-state.ps1 index 69c4f51512..c8a30282f5 100644 --- a/src/integration/assets/grok/herdr-agent-state.ps1 +++ b/src/integration/assets/grok/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") diff --git a/src/integration/assets/grok/herdr-agent-state.sh b/src/integration/assets/grok/herdr-agent-state.sh index bdf0f6715b..80dade4412 100644 --- a/src/integration/assets/grok/herdr-agent-state.sh +++ b/src/integration/assets/grok/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu @@ -22,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true import json import os import random diff --git a/src/integration/assets/mastracode/herdr-agent-state.ps1 b/src/integration/assets/mastracode/herdr-agent-state.ps1 index b0cca059e2..1941045929 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.ps1 +++ b/src/integration/assets/mastracode/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/src/integration/assets/mastracode/herdr-agent-state.sh index 39a8461b18..d52b3b48d3 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.sh +++ b/src/integration/assets/mastracode/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu @@ -22,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' >/dev/null 2>&1 || true import json import os import random diff --git a/src/integration/mod.rs b/src/integration/mod.rs index c87ccac6cc..1b9e65cda8 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -125,7 +125,7 @@ const DEVIN_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/devin/herdr-agent-state.sh") }; -const DEVIN_INTEGRATION_VERSION: u32 = 2; +const DEVIN_INTEGRATION_VERSION: u32 = 3; const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [ ("SessionStart", "session"), ("UserPromptSubmit", "session"), @@ -265,7 +265,7 @@ const MASTRACODE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/mastracode/herdr-agent-state.sh") }; -const MASTRACODE_INTEGRATION_VERSION: u32 = 2; +const MASTRACODE_INTEGRATION_VERSION: u32 = 3; const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] = [("SessionStart", "idle"), ("SessionEnd", "release")]; @@ -293,7 +293,7 @@ const GROK_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/grok/herdr-agent-state.sh") }; -const GROK_INTEGRATION_VERSION: u32 = 1; +const GROK_INTEGRATION_VERSION: u32 = 2; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/tests.rs b/src/integration/tests.rs index d54183f295..bf7ec78358 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -2736,6 +2736,7 @@ fn bundled_integration_asset_versions_match_expected_versions() { MASTRACODE_HOOK_ASSET, MASTRACODE_INTEGRATION_VERSION, ), + ("grok", GROK_HOOK_ASSET, GROK_INTEGRATION_VERSION), ] { assert_eq!( parse_integration_version(asset), @@ -2850,7 +2851,7 @@ fn bundled_integration_assets_report_session_refs() { assert!(!CURSOR_HOOK_ASSET.contains("\"state\":")); assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent")); assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=mastracode")); - assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=2")); + assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=3")); assert!(MASTRACODE_HOOK_ASSET.contains("session_id")); assert!(!MASTRACODE_HOOK_ASSET.contains("run_id")); assert!(MASTRACODE_HOOK_ASSET.contains("agent_session_id")); diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs index 21e7036b96..f14943a885 100644 --- a/tests/cli/hooks.rs +++ b/tests/cli/hooks.rs @@ -1,4 +1,6 @@ use super::harness::*; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; fn run_claude_hook(action: &str, hook_input: &str) -> Option { run_shell_hook( @@ -109,6 +111,99 @@ fn run_shell_hook_with_env( request.map(|line| serde_json::from_str(&line).unwrap()) } +#[cfg(unix)] +#[test] +fn shell_hooks_ignore_unusable_python_on_path() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let python_path = base.join("python3"); + fs::write( + &python_path, + r#"#!/bin/sh +printf invoked >"$PYTHON_INVOKED" +printf 'error: tool python3 not found on stdout\n' +printf 'error: tool python3 not found on stderr\n' >&2 +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&python_path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&python_path, permissions).unwrap(); + + let hooks: &[(&str, &[&str])] = &[ + ( + "src/integration/assets/claude/herdr-agent-state.sh", + &["session"], + ), + ( + "src/integration/assets/codex/herdr-agent-state.sh", + &["session"], + ), + ("src/integration/assets/copilot/herdr-agent-state.sh", &[]), + ( + "src/integration/assets/devin/herdr-agent-state.sh", + &["session"], + ), + ( + "src/integration/assets/droid/herdr-agent-state.sh", + &["session"], + ), + ( + "src/integration/assets/grok/herdr-agent-state.sh", + &["session"], + ), + ( + "src/integration/assets/mastracode/herdr-agent-state.sh", + &["session"], + ), + ]; + + let invoked_path = base.join("python-invoked"); + for (asset_path, args) in hooks { + let _ = fs::remove_file(&invoked_path); + let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path); + let mut child = Command::new("sh") + .arg(hook_path) + .args(*args) + .env("PATH", format!("{}:/usr/bin:/bin", base.display())) + .env("PYTHON_INVOKED", &invoked_path) + .env("HERDR_ENV", "1") + .env("HERDR_SOCKET_PATH", base.join("missing.sock")) + .env("HERDR_PANE_ID", "p_test") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(b"{}").unwrap(); + let output = child.wait_with_output().unwrap(); + + assert!( + invoked_path.exists(), + "{asset_path} did not attempt to invoke python" + ); + assert!( + output.status.success(), + "{asset_path} exposed unusable python: status={:?} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stdout.is_empty(), + "{asset_path} exposed unusable python stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + output.stderr.is_empty(), + "{asset_path} exposed unusable python stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + cleanup_test_base(&base); +} + #[test] fn claude_hook_ignores_state_actions() { let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#;