From 856cc3f5491eb7bb7ae536bc9eb30c5e8b65c108 Mon Sep 17 00:00:00 2001 From: adit chandra Date: Thu, 22 Oct 2026 17:32:07 -0700 Subject: [PATCH 1/3] Keep portable hook failures from blocking agent sessions --- README.md | 10 +++-- .../hook_definitions.py | 5 ++- .../hook_launcher.py | 31 +++++++++++++++ .../instruction_hub/test_hook_declarations.py | 39 +++++++++++++++++++ tests/instruction_hub/test_hooks.py | 11 ++++++ 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/promptless_instruction_hub/hook_launcher.py diff --git a/README.md b/README.md index d44e264..e514845 100644 --- a/README.md +++ b/README.md @@ -698,9 +698,13 @@ its first workspace root. A missing working directory remains null. Emit `{"context": "Message for the agent"}` on stdout, or leave stdout empty to stay quiet. Use stderr for diagnostics. The adapter wraps context in the native -response envelope and preserves a nonzero script exit. Scripts execute in the -adapter's process, so cancellation signals reach their handlers directly. The -adapter does not retry commands, start background workers, or infer recovery +response envelope. Portable hooks supply context; they cannot veto a tool or +turn. Their generated inline launcher reports failures on stderr and exits +successfully, including when an update removes the adapter or helper files. +Python's missing-script exit code 2 must not become an accidental host veto. +Use native JSON hooks for intentional blocking or other host decision semantics. +Scripts execute in the adapter's process, so cancellation signals reach their +handlers directly. The adapter does not retry commands, start background workers, or infer recovery policy; those decisions belong to the hook script. ### Native JSON escape hatch diff --git a/src/promptless_instruction_hub/hook_definitions.py b/src/promptless_instruction_hub/hook_definitions.py index 6c2cf18..4b70e97 100644 --- a/src/promptless_instruction_hub/hook_definitions.py +++ b/src/promptless_instruction_hub/hook_definitions.py @@ -3,7 +3,9 @@ from __future__ import annotations import shlex +from pathlib import Path +from promptless_instruction_hub import hook_launcher from promptless_instruction_hub.errors import InstructionHubError from promptless_instruction_hub.fs import JsonValue from promptless_instruction_hub.hook_runtime import EVENTS @@ -48,10 +50,11 @@ def render_hook_definition(asset: LoadedAsset, target: Harness) -> dict[str, Jso root = PLUGIN_ROOTS[target] runner = f'"${{{root}}}"/{shlex.quote("hooks/" + RUNNER_NAME)}' entrypoint = f'"${{{root}}}"/{shlex.quote(f"hooks/{asset.id}/{spec.entrypoint}")}' + launcher = shlex.quote(Path(hook_launcher.__file__).read_text(encoding="utf-8")) command = ( "if command -v python3 >/dev/null 2>&1 " "&& python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then " - f"exec python3 {runner} --host {target} --entrypoint {entrypoint}; " + f"exec python3 -c {launcher} {runner} --host {target} --entrypoint {entrypoint}; " "else printf '%s\\n' 'Instruction Hub hooks require Python 3.9 or newer.' >&2; fi" ) events: dict[str, JsonValue] = {} diff --git a/src/promptless_instruction_hub/hook_launcher.py b/src/promptless_instruction_hub/hook_launcher.py new file mode 100644 index 0000000..39c68e7 --- /dev/null +++ b/src/promptless_instruction_hub/hook_launcher.py @@ -0,0 +1,31 @@ +"""Python 3.9+ launcher embedded in hook commands, independent of plugin files. + +Portable hooks provide context, not policy decisions. Isolate their failures +here so Python's missing-script exit code 2 cannot become a native hook veto. +Keep this module standard-library-only and preserve in-process signal delivery. +""" + +from __future__ import annotations + +import runpy +import sys + + +def main() -> None: + sys.dont_write_bytecode = True + runner, *arguments = sys.argv[1:] + sys.argv = [runner, *arguments] + try: + runpy.run_path(runner, run_name="__main__") + except SystemExit as exc: + if exc.code not in (None, 0): + print(f"Instruction Hub context hook skipped: adapter exited {exc.code!s:.100}.", file=sys.stderr) + except Exception as exc: + # This is the boundary for arbitrary authored Python, including import + # failures while an updater removes a bundle. Never swallow cancellation. + detail = str(exc).replace("\n", " ")[:500] + print(f"Instruction Hub context hook skipped: {type(exc).__name__}: {detail}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tests/instruction_hub/test_hook_declarations.py b/tests/instruction_hub/test_hook_declarations.py index 8fc18d8..8844101 100644 --- a/tests/instruction_hub/test_hook_declarations.py +++ b/tests/instruction_hub/test_hook_declarations.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +import shutil import subprocess import sys @@ -104,6 +105,44 @@ def test_generated_launcher_executes_packaged_adapter_and_script(tmp_path: Path, assert result.returncode == 0 and "require Python 3.9" in result.stderr +@pytest.mark.parametrize( + "host,root_var", [("claude", "CLAUDE_PLUGIN_ROOT"), ("codex", "PLUGIN_ROOT"), ("cursor", "CURSOR_PLUGIN_ROOT")] +) +@pytest.mark.parametrize("failure", ["plugin", "adapter", "entrypoint", "helper", "exit-two", "exception"]) +def test_loaded_portable_hook_cannot_block_on_bundle_failure( + tmp_path: Path, host: str, root_var: str, failure: str +) -> None: + declaration(tmp_path) + build_hub(tmp_path) + plugin = tmp_path / "dist" / host / "pig" + hooks = json.loads((plugin / "hooks/hooks.json").read_text())["hooks"] + entry = hooks[event(host)["hook_event_name"]][0] + command = (entry if host == "cursor" else entry["hooks"][0])["command"] + # The host already loaded this command before the updater removes files. + if failure == "plugin": + shutil.rmtree(plugin) + elif failure in {"adapter", "entrypoint", "helper"}: + relative = { + "adapter": "_instruction_hub_hook.py", + "entrypoint": "example/handler's $example.py", + "helper": "example/helper.py", + }[failure] + (plugin / "hooks" / relative).unlink() + else: + source = "raise SystemExit(2)" if failure == "exit-two" else "raise RuntimeError('broken hook')" + (plugin / "hooks/example/handler's $example.py").write_text(source) + result = subprocess.run( + ["/bin/sh", "-c", command], + input=json.dumps(event(host)), + capture_output=True, + text=True, + env={**os.environ, root_var: str(plugin)}, + ) + assert result.returncode == 0 + assert result.stdout == "" + assert "Instruction Hub context hook skipped" in result.stderr + + @pytest.mark.parametrize( "field,value", [ diff --git a/tests/instruction_hub/test_hooks.py b/tests/instruction_hub/test_hooks.py index a90a743..442f353 100644 --- a/tests/instruction_hub/test_hooks.py +++ b/tests/instruction_hub/test_hooks.py @@ -2,6 +2,7 @@ import json from pathlib import Path +import subprocess import pytest @@ -26,6 +27,16 @@ def config(event: str, command: str) -> dict[str, object]: return {"hooks": {event: [{"hooks": [{"type": "command", "command": command}]}]}} +def test_native_hook_retains_intentional_blocking_exit(tmp_path: Path) -> None: + init_hub(tmp_path, org="Promptless") + bundle(tmp_path, "policy", {"hooks.json": config("PreToolUse", "exit 2")}) + (tmp_path / "plugins/pig.yaml").write_text("id: pig\nname: PIG\nincludes: [hook:policy]\n") + build_hub(tmp_path) + hooks = json.loads((tmp_path / "dist/codex/pig/hooks/hooks.json").read_text())["hooks"] + command = hooks["PreToolUse"][0]["hooks"][0]["command"] + assert subprocess.run(["/bin/sh", "-c", command]).returncode == 2 + + def test_native_bundles_select_merge_and_preserve_managed_hooks(tmp_path: Path) -> None: init_hub(tmp_path, org="Promptless") enable_trace_ingestion(tmp_path) From 7eb945a41711efd76c82af62852b6c6516b5f981 Mon Sep 17 00:00:00 2001 From: adit chandra Date: Thu, 22 Oct 2026 17:39:12 -0700 Subject: [PATCH 2/3] Isolate inline hook imports from the caller directory --- src/promptless_instruction_hub/hook_definitions.py | 2 +- tests/instruction_hub/test_hook_declarations.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/promptless_instruction_hub/hook_definitions.py b/src/promptless_instruction_hub/hook_definitions.py index 4b70e97..a859e17 100644 --- a/src/promptless_instruction_hub/hook_definitions.py +++ b/src/promptless_instruction_hub/hook_definitions.py @@ -54,7 +54,7 @@ def render_hook_definition(asset: LoadedAsset, target: Harness) -> dict[str, Jso command = ( "if command -v python3 >/dev/null 2>&1 " "&& python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then " - f"exec python3 -c {launcher} {runner} --host {target} --entrypoint {entrypoint}; " + f"exec python3 -I -c {launcher} {runner} --host {target} --entrypoint {entrypoint}; " "else printf '%s\\n' 'Instruction Hub hooks require Python 3.9 or newer.' >&2; fi" ) events: dict[str, JsonValue] = {} diff --git a/tests/instruction_hub/test_hook_declarations.py b/tests/instruction_hub/test_hook_declarations.py index 8844101..9857424 100644 --- a/tests/instruction_hub/test_hook_declarations.py +++ b/tests/instruction_hub/test_hook_declarations.py @@ -76,12 +76,16 @@ def test_generated_launcher_executes_packaged_adapter_and_script(tmp_path: Path, if host != "cursor": assert "SessionStart" in hooks["hooks"] # Managed hooks coexist with the adapter. assert not (root / "dist/gemini/pig/hooks/_instruction_hub_hook.py").exists() + caller = tmp_path / "caller" + caller.mkdir() + (caller / "json.py").write_text("raise RuntimeError('caller module must not shadow the hook runtime')") result = subprocess.run( ["/bin/sh", "-c", action["command"]], input=json.dumps(native_event), capture_output=True, text=True, check=True, + cwd=caller, env={**os.environ, root_var: str(plugin), "PYTHONDONTWRITEBYTECODE": "1"}, ) response = json.loads(result.stdout) From 41ac2eb71c13dd0da2ee519618e69247b928f6e6 Mon Sep 17 00:00:00 2001 From: adit chandra Date: Thu, 22 Oct 2026 17:43:17 -0700 Subject: [PATCH 3/3] Retain pinned hook bundles across plugin cache updates --- .github/workflows/ci.yml | 8 + README.md | 32 +++ .../hook_bundles.py | 38 ++++ .../hook_definitions.py | 24 +- .../hook_launcher.py | 125 ++++++++++- .../render/hooks.py | 10 +- tests/instruction_hub/test_hook_bundles.py | 210 ++++++++++++++++++ .../instruction_hub/test_hook_declarations.py | 13 +- 8 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 src/promptless_instruction_hub/hook_bundles.py create mode 100644 tests/instruction_hub/test_hook_bundles.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ac0274..0830f79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,14 @@ jobs: assert context == "session_start", output PY + - name: Smoke test retained hook launcher on Python 3.9 + env: + HOOK_TEST_PYTHON: ${{ steps.runtime-python.outputs.python-path }} + TOOLCHAIN_PYTHON: ${{ steps.toolchain-python.outputs.python-path }} + run: >- + uv run --frozen --python "$TOOLCHAIN_PYTHON" + pytest tests/instruction_hub/test_hook_bundles.py -q -k python_floor + managed-runtime-windows: runs-on: windows-latest steps: diff --git a/README.md b/README.md index e514845..8fb1137 100644 --- a/README.md +++ b/README.md @@ -707,6 +707,38 @@ Scripts execute in the adapter's process, so cancellation signals reach their handlers directly. The adapter does not retry commands, start background workers, or infer recovery policy; those decisions belong to the hook script. +### Portable hook lifetime + +Generated commands pin a SHA-256 manifest covering the adapter, hook declaration, +and every file in the hook bundle. A generated SessionStart handler prepares a +private, complete copy without executing authored code. The first ordinary hook +invocation also prepares the copy if needed. Files are verified before an atomic +directory rename makes the bundle available; concurrent preparations can share +only a verified copy of the same digest. + +Retained bundles live under `$XDG_DATA_HOME/promptless/hook-runtime/v1`, defaulting +to `~/.local/share/promptless/hook-runtime/v1`. Set `PROMPTLESS_HOOK_DATA_HOME` to +override the base directory (the launcher appends `v1`). This store is independent +of each host's disposable plugin cache. A loaded command keeps using its exact +bundle after updates, rollback, or same-version replacement. It never follows a +"latest" pointer or loads code from a sibling plugin version. Hook dependencies +must be inside the authored bundle and located relative to `__file__`; the working +directory and host plugin-root environment variables retain their host meanings. +Use a separate writable location for hook state, not the retained code directory. + +The launcher validates retained files before execution. Missing, changed, or +unexpected files skip the context hook with a nonblocking diagnostic, emitted +once until the bundle becomes usable again. No hook input or output is persisted +by the launcher. If the plugin disappears before either preparation or the first +invocation, the hook skips rather than executing a different version. + +There is no automatic eviction: an idle or resumable session may still reference +any retained digest. Remove retained bundles only after closing sessions that +could use them, including sessions you intend to resume with old registrations. +Safe automated cleanup requires host-provided lifetime/lease information; an +age-only cleanup would recreate the update race. This retention applies to +portable declarations, not authored native JSON or managed trace-runtime hooks. + ### Native JSON escape hatch For other events, runtimes, or host features, keep `hooks..json` and shared diff --git a/src/promptless_instruction_hub/hook_bundles.py b/src/promptless_instruction_hub/hook_bundles.py new file mode 100644 index 0000000..8ddf601 --- /dev/null +++ b/src/promptless_instruction_hub/hook_bundles.py @@ -0,0 +1,38 @@ +"""Describe the complete portable hook bundle pinned by a generated command.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from promptless_instruction_hub.fs import JsonValue +from promptless_instruction_hub.hook_launcher import RUNNER_NAME +from promptless_instruction_hub.models import Harness, LoadedAsset + + +def write_bundle_manifest(hook_root: Path, asset: LoadedAsset, target: Harness) -> str: + spec = asset.metadata.hook + assert spec is not None + paths = [hook_root / RUNNER_NAME, *(path for path in (hook_root / asset.id).rglob("*") if path.is_file())] + files: dict[str, JsonValue] = { + path.relative_to(hook_root).as_posix(): { + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "executable": bool(path.stat().st_mode & 0o111), + } + for path in sorted(paths) + } + manifest = { + "schema": 1, + "host": target, + "hook": asset.id, + "entrypoint": f"{asset.id}/{spec.entrypoint}", + "config": spec.model_dump(mode="json"), + "files": files, + } + payload = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + digest = hashlib.sha256(payload).hexdigest() + path = hook_root / "_bundles" / f"{digest}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + return digest diff --git a/src/promptless_instruction_hub/hook_definitions.py b/src/promptless_instruction_hub/hook_definitions.py index a859e17..b0b2d85 100644 --- a/src/promptless_instruction_hub/hook_definitions.py +++ b/src/promptless_instruction_hub/hook_definitions.py @@ -11,7 +11,6 @@ from promptless_instruction_hub.hook_runtime import EVENTS from promptless_instruction_hub.models import Harness, LoadedAsset -RUNNER_NAME = "_instruction_hub_hook.py" PLUGIN_ROOTS = {"claude": "CLAUDE_PLUGIN_ROOT", "codex": "PLUGIN_ROOT", "cursor": "CURSOR_PLUGIN_ROOT"} @@ -44,19 +43,17 @@ def validate_hook_definition(asset: LoadedAsset) -> None: seen.add(key) -def render_hook_definition(asset: LoadedAsset, target: Harness) -> dict[str, JsonValue]: +def render_hook_definition(asset: LoadedAsset, target: Harness, digest: str) -> dict[str, JsonValue]: spec = asset.metadata.hook assert spec is not None root = PLUGIN_ROOTS[target] - runner = f'"${{{root}}}"/{shlex.quote("hooks/" + RUNNER_NAME)}' - entrypoint = f'"${{{root}}}"/{shlex.quote(f"hooks/{asset.id}/{spec.entrypoint}")}' launcher = shlex.quote(Path(hook_launcher.__file__).read_text(encoding="utf-8")) - command = ( - "if command -v python3 >/dev/null 2>&1 " - "&& python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then " - f"exec python3 -I -c {launcher} {runner} --host {target} --entrypoint {entrypoint}; " - "else printf '%s\\n' 'Instruction Hub hooks require Python 3.9 or newer.' >&2; fi" + invocation = f'exec python3 -I -c {launcher} "${{{root}}}/hooks" {shlex.quote(digest)}' + prefix = ( + "if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then " ) + suffix = "; else printf '%s\\n' 'Instruction Hub hooks require Python 3.9 or newer.' >&2; fi" + command = prefix + invocation + suffix events: dict[str, JsonValue] = {} for binding in spec.bindings[target]: action: dict[str, JsonValue] = {"command": command, "timeout": spec.timeout} @@ -72,4 +69,13 @@ def render_hook_definition(asset: LoadedAsset, target: Harness) -> dict[str, Jso handlers = events.setdefault(binding.event, []) assert isinstance(handlers, list) handlers.append(entry) + # Pin dependencies at session start, even for hooks that first run much + # later. Preparing only copies verified files; it never runs authored code. + startup = "sessionStart" if target == "cursor" else "SessionStart" + prepare: dict[str, JsonValue] = {"command": prefix + invocation + " --prepare" + suffix, "timeout": 5} + if target != "cursor": + prepare = {"hooks": [{"type": "command", **prepare}]} + startup_handlers = events.setdefault(startup, []) + assert isinstance(startup_handlers, list) + startup_handlers.insert(0, prepare) return {"version": 1, "hooks": events} if target == "cursor" else {"hooks": events} diff --git a/src/promptless_instruction_hub/hook_launcher.py b/src/promptless_instruction_hub/hook_launcher.py index 39c68e7..575e95e 100644 --- a/src/promptless_instruction_hub/hook_launcher.py +++ b/src/promptless_instruction_hub/hook_launcher.py @@ -7,14 +7,135 @@ from __future__ import annotations +import errno +import hashlib +import json +import os +from pathlib import Path, PurePosixPath import runpy +import stat import sys +import tempfile + +RUNNER_NAME = "_instruction_hub_hook.py" + + +def data_root() -> Path: + """Use a host-independent location that plugin cache cleanup does not own.""" + override = os.environ.get("PROMPTLESS_HOOK_DATA_HOME") + if override: + return Path(override).expanduser() / "v1" + base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") + return base / "promptless/hook-runtime/v1" + + +def read_manifest(path: Path, digest: str) -> tuple[bytes, dict]: + payload = path.read_bytes() + if hashlib.sha256(payload).hexdigest() != digest: + raise ValueError("hook manifest digest mismatch") + manifest = json.loads(payload) + if manifest["schema"] != 1 or manifest["host"] not in {"claude", "codex", "cursor"}: + raise ValueError("unsupported hook bundle") + files = manifest["files"] + if RUNNER_NAME not in files or manifest["entrypoint"] not in files: + raise ValueError("incomplete hook manifest") + for name in files: + path_name = PurePosixPath(name) + if path_name.is_absolute() or ".." in path_name.parts or "\\" in name or name != path_name.as_posix(): + raise ValueError("invalid hook bundle path") + return payload, manifest + + +def checked_file(root: Path, name: str, expected: dict) -> bytes: + path = root / name + # Source assets reject symlinks at build time. Check installed bundles too, + # including parent directories, before copying or executing their contents. + current = root + for part in ("", *PurePosixPath(name).parts): + current = current / part + if current.is_symlink(): + raise ValueError("symlink in hook bundle") + if not stat.S_ISREG(path.stat().st_mode): + raise ValueError("non-regular file in hook bundle") + payload = path.read_bytes() + if hashlib.sha256(payload).hexdigest() != expected["sha256"]: + raise ValueError(f"hook file digest mismatch: {name}") + return payload + + +def retained_bundle(source: Path, digest: str) -> tuple[Path, dict]: + """Publish a complete immutable bundle; never resolve to a newer version.""" + if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + raise ValueError("invalid hook bundle digest") + bundles = data_root() / "bundles" + destination = bundles / digest + if not destination.exists(): + payload, manifest = read_manifest(source / "_bundles" / f"{digest}.json", digest) + bundles.mkdir(parents=True, exist_ok=True, mode=0o700) + with tempfile.TemporaryDirectory(prefix=".staging-", dir=bundles) as temporary: + staging = Path(temporary) / "bundle" + staging.mkdir(mode=0o700) + for name, expected in manifest["files"].items(): + content = checked_file(source, name, expected) + target = staging / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + target.chmod(0o700 if expected["executable"] else 0o600) + (staging / "manifest.json").write_bytes(payload) + try: + staging.rename(destination) + except OSError as exc: + # Another hook may have published the same digest while we + # staged. It is safe to use only after validating that winner. + if exc.errno not in {errno.EEXIST, errno.ENOTEMPTY}: + raise + _, manifest = read_manifest(destination / "manifest.json", digest) + expected_paths = set(manifest["files"]) | {"manifest.json"} + for path in destination.rglob("*"): + if path.is_symlink() or path.is_file() and path.relative_to(destination).as_posix() not in expected_paths: + raise ValueError("unexpected file in retained hook bundle") + for name, expected in manifest["files"].items(): + checked_file(destination, name, expected) + return destination, manifest + + +def report_unavailable(digest: str, exc: Exception) -> None: + """Warn once per unavailable bundle; do not persist hook input or output.""" + try: + warnings = data_root() / "warnings" + warnings.mkdir(parents=True, exist_ok=True, mode=0o700) + # Hash even malformed digests before using them as a filesystem key. + key = hashlib.sha256(digest.encode()).hexdigest() + with (warnings / key).open("x"): + pass + except FileExistsError: + return + except OSError: + # An unwritable data directory must remain visible and nonblocking. + pass + detail = str(exc).replace("\n", " ")[:500] + print(f"Instruction Hub context hook skipped: {type(exc).__name__}: {detail}", file=sys.stderr) def main() -> None: sys.dont_write_bytecode = True - runner, *arguments = sys.argv[1:] - sys.argv = [runner, *arguments] + source, digest, *options = sys.argv[1:] + try: + root, manifest = retained_bundle(Path(source), digest) + except Exception as exc: + # Cache removal, incomplete installs, and corrupt/unwritable storage + # must never turn this context hook into a host policy decision. + report_unavailable(digest, exc) + return + try: + key = hashlib.sha256(digest.encode()).hexdigest() + (data_root() / "warnings" / key).unlink(missing_ok=True) + except OSError: + pass # Diagnostic bookkeeping cannot prevent an otherwise healthy hook. + if options == ["--prepare"]: + return + runner = str(root / RUNNER_NAME) + sys.argv = [runner, "--host", manifest["host"], "--entrypoint", str(root / manifest["entrypoint"])] try: runpy.run_path(runner, run_name="__main__") except SystemExit as exc: diff --git a/src/promptless_instruction_hub/render/hooks.py b/src/promptless_instruction_hub/render/hooks.py index 7457c65..c075cfe 100644 --- a/src/promptless_instruction_hub/render/hooks.py +++ b/src/promptless_instruction_hub/render/hooks.py @@ -9,7 +9,9 @@ from promptless_instruction_hub.assets import METADATA_FILE from promptless_instruction_hub.errors import InstructionHubError from promptless_instruction_hub.fs import JsonValue, copy_tree, read_json_mapping, write_json -from promptless_instruction_hub.hook_definitions import RUNNER_NAME, render_hook_definition +from promptless_instruction_hub.hook_bundles import write_bundle_manifest +from promptless_instruction_hub.hook_definitions import render_hook_definition +from promptless_instruction_hub.hook_launcher import RUNNER_NAME from promptless_instruction_hub.models import Harness, LoadedAsset @@ -35,9 +37,11 @@ def render_native_hooks(target_root: Path, target: Harness, assets: list[LoadedA continue if asset.metadata.hook is not None: source = asset.path / METADATA_FILE - config = render_hook_definition(asset, target) hook_root.mkdir(parents=True, exist_ok=True) shutil.copy2(hook_runtime.__file__, hook_root / RUNNER_NAME) + copy_tree(asset.path, hook_root / asset.id, skip_names={METADATA_FILE}) + digest = write_bundle_manifest(hook_root, asset, target) + config = render_hook_definition(asset, target, digest) else: source = _config_path(asset, target) config = _read_config(source) @@ -58,6 +62,8 @@ def render_native_hooks(target_root: Path, target: Harness, assets: list[LoadedA raise InstructionHubError(f"{source}: conflicting hook configuration field {key!r} for {target}") else: merged[key] = value + if asset.metadata.hook is not None: + continue # Already copied and hashed before rendering its command. if asset.path.is_dir(): copy_tree(asset.path, hook_root / asset.id, skip_names={METADATA_FILE}) else: diff --git a/tests/instruction_hub/test_hook_bundles.py b/tests/instruction_hub/test_hook_bundles.py new file mode 100644 index 0000000..bd414e2 --- /dev/null +++ b/tests/instruction_hub/test_hook_bundles.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import time + +import pytest + +from promptless_instruction_hub import hook_launcher +from promptless_instruction_hub.compiler import build_hub +from tests.instruction_hub.test_hook_declarations import declaration, event + +ROOT_VARS = {"codex": "PLUGIN_ROOT", "claude": "CLAUDE_PLUGIN_ROOT", "cursor": "CURSOR_PLUGIN_ROOT"} + + +@pytest.fixture(autouse=True) +def isolate_storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PROMPTLESS_HOOK_DATA_HOME", str(tmp_path / "data")) + + +def snapshot(root: Path, host: str, version: str) -> tuple[Path, str, str]: + hub = root / f"hub-{version}" + metadata = declaration(hub) + (metadata.parent / "helper.py").write_text(f"def echo(event): return {version!r}\n") + (metadata.parent / "settings.json").write_text(json.dumps({"version": version})) + (metadata.parent / "handler's $example.py").write_text( + "import json\nfrom pathlib import Path\nfrom helper import echo\n" + "settings = json.loads(Path(__file__).with_name('settings.json').read_text())\n" + "assert settings['version'] == echo(None)\n" + "print(json.dumps({'context': echo(None)}))\n" + ) + build_hub(hub) + plugin = root / "cache with $spaces" / version + shutil.copytree(hub / "dist" / host / "pig", plugin) + hooks = json.loads((plugin / "hooks/hooks.json").read_text())["hooks"] + + def command(name: str) -> str: + entry = hooks[name][0] + return (entry if host == "cursor" else entry["hooks"][0])["command"] + + return ( + plugin, + command(event(host)["hook_event_name"]), + command("sessionStart" if host == "cursor" else "SessionStart"), + ) + + +def invoke(plugin: Path, host: str, command: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["/bin/sh", "-c", command], + input=json.dumps({**event(host), "session_id": "test-session"}), + env={**os.environ, ROOT_VARS[host]: str(plugin)}, + capture_output=True, + text=True, + timeout=10, + ) + + +def context(result: subprocess.CompletedProcess[str], host: str) -> str: + assert result.returncode == 0 and not result.stderr + response = json.loads(result.stdout) + return response["additional_context"] if host == "cursor" else response["hookSpecificOutput"]["additionalContext"] + + +@pytest.mark.parametrize("host", ROOT_VARS) +def test_loaded_hook_keeps_complete_bundle_across_update_and_resume(tmp_path: Path, host: str) -> None: + old, old_command, prepare_old = snapshot(tmp_path, host, "0.1.0") + prepared = invoke(old, host, prepare_old) + assert prepared.returncode == 0 and not prepared.stdout and not prepared.stderr + new, new_command, prepare_new = snapshot(tmp_path, host, "0.2.0") + assert invoke(new, host, prepare_new).returncode == 0 + shutil.rmtree(old) + # The first tool event can arrive after the update removed the old cache. + assert context(invoke(old, host, old_command), host) == "0.1.0" + assert context(invoke(new, host, new_command), host) == "0.2.0" + shutil.rmtree(new) + # Later invocations/resumes and a rollback still resolve the exact digest. + assert context(invoke(old, host, old_command), host) == "0.1.0" + assert context(invoke(new, host, new_command), host) == "0.2.0" + assert len(list((hook_launcher.data_root() / "bundles").iterdir())) == 2 + + +def test_concurrent_first_invocations_publish_one_complete_bundle(tmp_path: Path) -> None: + plugin, command, _ = snapshot(tmp_path, "codex", "0.1.0") + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(lambda _: invoke(plugin, "codex", command), range(8))) + assert all(context(result, "codex") == "0.1.0" for result in results) + assert len(list((hook_launcher.data_root() / "bundles").iterdir())) == 1 + + +@pytest.mark.parametrize("damage", ["missing", "changed", "extra", "symlink"]) +def test_corrupt_retained_bundle_is_never_executed(tmp_path: Path, damage: str) -> None: + plugin, command, prepare = snapshot(tmp_path, "codex", "0.1.0") + assert invoke(plugin, "codex", prepare).returncode == 0 + retained = next((hook_launcher.data_root() / "bundles").iterdir()) + helper = retained / "example/helper.py" + if damage == "missing": + helper.unlink() + elif damage == "changed": + helper.write_text("def echo(event): return 'wrong version'") + elif damage == "extra": + (retained / "example/untracked.py").write_text("raise RuntimeError('untracked')") + else: + helper.unlink() + helper.symlink_to(plugin / "hooks/example/helper.py") + first = invoke(plugin, "codex", command) + second = invoke(plugin, "codex", command) + assert first.returncode == second.returncode == 0 + assert not first.stdout and not second.stdout + assert "context hook skipped" in first.stderr + assert not second.stderr # Missing/corrupt bundle diagnostics do not flood every tool call. + + +def test_cache_disappearing_during_staging_does_not_publish_partial_bundle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + plugin, _, _ = snapshot(tmp_path, "codex", "0.1.0") + digest = next((plugin / "hooks/_bundles").iterdir()).stem + original = hook_launcher.checked_file + + def remove_source(root: Path, name: str, expected: dict) -> bytes: + payload = original(root, name, expected) + shutil.rmtree(plugin) + return payload + + monkeypatch.setattr(hook_launcher, "checked_file", remove_source) + with pytest.raises(FileNotFoundError): + hook_launcher.retained_bundle(plugin / "hooks", digest) + assert not list((hook_launcher.data_root() / "bundles").iterdir()) + + +def test_same_version_replacement_cannot_change_a_loaded_command(tmp_path: Path) -> None: + plugin, command, prepare = snapshot(tmp_path, "codex", "0.1.0") + assert invoke(plugin, "codex", prepare).returncode == 0 + replacement, replacement_command, _ = snapshot(tmp_path, "codex", "replacement") + shutil.rmtree(plugin) + replacement.rename(plugin) + assert context(invoke(plugin, "codex", command), "codex") == "0.1.0" + assert context(invoke(plugin, "codex", replacement_command), "codex") == "replacement" + + +def test_unwritable_storage_is_nonblocking(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + plugin, command, _ = snapshot(tmp_path, "codex", "0.1.0") + occupied = tmp_path / "not-a-directory" + occupied.write_text("occupied") + monkeypatch.setenv("PROMPTLESS_HOOK_DATA_HOME", str(occupied)) + result = invoke(plugin, "codex", command) + assert result.returncode == 0 and not result.stdout + assert "context hook skipped" in result.stderr + + +def test_generated_launcher_python_floor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + interpreter = os.environ.get("HOOK_TEST_PYTHON") or shutil.which("python3.9") + if not interpreter: + pytest.skip("Python 3.9 is exercised by the release-artifacts job") + plugin, command, prepare = snapshot(tmp_path, "codex", "0.1.0") + binaries = tmp_path / "bin" + binaries.mkdir() + (binaries / "python3").symlink_to(interpreter) + monkeypatch.setenv("PATH", f"{binaries}{os.pathsep}{os.environ['PATH']}") + assert invoke(plugin, "codex", prepare).returncode == 0 + shutil.rmtree(plugin) + assert context(invoke(plugin, "codex", command), "codex") == "0.1.0" + + +def test_generated_launcher_preserves_signal_delivery_after_cache_deletion(tmp_path: Path) -> None: + hub = tmp_path / "hub" + metadata = declaration(hub) + ready = tmp_path / "ready" + (metadata.parent / "handler's $example.py").write_text( + "import json, os, signal, time\nfrom pathlib import Path\n" + "def cancel(*args):\n print(json.dumps({'context': 'cancelled'}))\n raise SystemExit(0)\n" + "signal.signal(signal.SIGTERM, cancel)\n" + f"Path({str(ready)!r}).write_text(str(os.getpid()))\n" + "while True: time.sleep(0.05)\n" + ) + build_hub(hub) + plugin = hub / "dist/codex/pig" + hooks = json.loads((plugin / "hooks/hooks.json").read_text())["hooks"] + command = hooks["PostToolUse"][0]["hooks"][0]["command"] + payload = tmp_path / "payload.json" + payload.write_text(json.dumps(event("codex"))) + with payload.open() as stdin: + process = subprocess.Popen( + ["/bin/sh", "-c", command], + stdin=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={**os.environ, "PLUGIN_ROOT": str(plugin)}, + ) + try: + deadline = time.monotonic() + 5 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert int(ready.read_text()) == process.pid + shutil.rmtree(plugin) + process.send_signal(signal.SIGTERM) + output, error = process.communicate(timeout=5) + assert process.returncode == 0 and not error + assert json.loads(output)["hookSpecificOutput"]["additionalContext"] == "cancelled" + finally: + if process.poll() is None: + process.kill() + process.communicate() diff --git a/tests/instruction_hub/test_hook_declarations.py b/tests/instruction_hub/test_hook_declarations.py index 9857424..6e7d424 100644 --- a/tests/instruction_hub/test_hook_declarations.py +++ b/tests/instruction_hub/test_hook_declarations.py @@ -16,6 +16,11 @@ from tests.config_helpers import enable_trace_ingestion +@pytest.fixture(autouse=True) +def isolate_retained_hooks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PROMPTLESS_HOOK_DATA_HOME", str(tmp_path / "hook-data")) + + def declaration(root: Path) -> Path: init_hub(root, org="Promptless") bundle = root / "assets/hooks/example" @@ -116,7 +121,10 @@ def test_generated_launcher_executes_packaged_adapter_and_script(tmp_path: Path, def test_loaded_portable_hook_cannot_block_on_bundle_failure( tmp_path: Path, host: str, root_var: str, failure: str ) -> None: - declaration(tmp_path) + metadata = declaration(tmp_path) + if failure in {"exit-two", "exception"}: + source = "raise SystemExit(2)" if failure == "exit-two" else "raise RuntimeError('broken hook')" + (metadata.parent / "handler's $example.py").write_text(source) build_hub(tmp_path) plugin = tmp_path / "dist" / host / "pig" hooks = json.loads((plugin / "hooks/hooks.json").read_text())["hooks"] @@ -132,9 +140,6 @@ def test_loaded_portable_hook_cannot_block_on_bundle_failure( "helper": "example/helper.py", }[failure] (plugin / "hooks" / relative).unlink() - else: - source = "raise SystemExit(2)" if failure == "exit-two" else "raise RuntimeError('broken hook')" - (plugin / "hooks/example/handler's $example.py").write_text(source) result = subprocess.run( ["/bin/sh", "-c", command], input=json.dumps(event(host)),