From 9d0605033828627596e947c4089179fe00551619 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Thu, 1 Oct 2026 16:50:13 +0200 Subject: [PATCH] fix: trace-hook.ps1 - Copilot hooks under PowerShell on Windows no longer open the choose-an-app dialog (#164) Files changed: - CHANGES.md - EVALS.md - VERSION - tools/README.md - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/trace-hook - tools/trace-hook.ps1 --- CHANGES.md | 27 +++++++++++++- EVALS.md | 10 +++++- VERSION | 2 +- tools/README.md | 1 + tools/chemenu/tests/test_preflight.py | 14 +++++++- tools/chemenu/tests/test_preflight_pwsh.py | 41 ++++++++++++++++++++-- tools/trace-hook | 3 ++ tools/trace-hook.ps1 | 27 ++++++++++++++ 8 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 tools/trace-hook.ps1 diff --git a/CHANGES.md b/CHANGES.md index 21be69b..c3992f6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 8.0.0-beta.17 - 2026-10-01 - preflight.ps1: the asset-mode error helper is Exit-Asset, so PSScriptAnalyzer passes +## 8.0.0-beta.18 - 2026-10-01 - trace-hook.ps1: Copilot hooks no longer open Windows' choose-an-app dialog **Author:** Torben Nehmer @@ -91,6 +91,7 @@ concern - readable here, never shipped as something to parse. - Path budget: a file's path stays at 160 characters or fewer so a Windows checkout works without long paths (#163) - PowerShell 7 preflight and launcher: tools/preflight.ps1, tools/wikitool.ps1, doctor checks for execution policy and Mark of the Web - Preflight as a release asset: download, verify and unpack the stack, then run the tree preflight +- trace-hook.ps1: Copilot hooks no longer open Windows' choose-an-app dialog **Low impact** - version bump no longer points at version release in its output @@ -128,6 +129,30 @@ concern - readable here, never shipped as something to parse. - preflight.ps1: the asset-mode error helper is Exit-Asset, so PSScriptAnalyzer passes +### trace-hook.ps1: Copilot hooks no longer open Windows' choose-an-app dialog + +On the Windows target machine, Copilot opened Windows' "choose an app" dialog for `trace-hook` +on hook events (Gitea #164). Some PowerShell had run `./tools/trace-hook ...`, and an +extensionless sh script has no program associated with it. The `bash` field of +`.github/hooks/wiki-trace.json` was not the path in: both Copilot clients take the `powershell` +field on Windows. Copilot CLI, however, also reads `.claude/settings.json`, whose +`UserPromptSubmit` hook has only a `command`, and it runs that `command` under PowerShell on +Windows. + +`tools/trace-hook.ps1` is the PowerShell twin of `tools/trace-hook`, after the +`wikitool`/`wikitool.ps1` pattern: PowerShell on Windows resolves `./tools/trace-hook` to the +`.ps1` first. It does what the sh script does: it runs `trace_ingest.py` with the venv's Python +in either layout, records nothing without a venv, and exits 0 whatever happens. It has no +`#Requires -Version 7`, because VS Code starts hooks under Windows PowerShell 5.1. No hook +command string changed, so Linux, macOS and Claude Code under Git Bash behave exactly as +before. The preflight's and `doctor`'s Mark of the Web check and CI's PSScriptAnalyzer step +already cover every `.ps1` under `tools/`. + +New tests: one guards that every extensionless hook target has a `.ps1` twin. The pwsh tests +cover arguments and stdin reaching the venv Python, silence without a venv, and exit 0 when +the Python fails. Whether the dialog is really gone in both clients is checked by hand on the +target machine. + ### preflight.ps1: the asset-mode error helper is Exit-Asset, so PSScriptAnalyzer passes The helper that ends asset mode with exit 1 was called `Stop-Asset`. `Stop` is one of the verbs diff --git a/EVALS.md b/EVALS.md index 5fd319b..4630cea 100644 --- a/EVALS.md +++ b/EVALS.md @@ -158,7 +158,7 @@ own decision-document schema verified against a live CLI first (this repo has no unverified, per the same rule that governed the Vibe adapter: an adapter that cannot be verified is not written. -Three details in that file are load-bearing, and the first holds for all three hook +Four details in that file are load-bearing, and the first two hold for all three hook configurations: - **The interpreter is the venv's, never the script's shebang.** Each `bash` command is @@ -169,6 +169,14 @@ configurations: (`python3`) was the Microsoft Store alias in Git Bash on Windows, which made every hook there a silent no-op. Before the preflight has created the venv, `trace-hook` records nothing and exits 0. +- **`./tools/trace-hook` has a PowerShell twin, `tools/trace-hook.ps1`.** PowerShell on + Windows resolves the string to the `.ps1` first; without one, it hands the sh script to a + file association and Windows asks which app should open it - on every hook event. A `bash` + field alone does not keep the string out of PowerShell: Copilot CLI also reads + `.claude/settings.json` and runs its single `command` under PowerShell on Windows. The twin + keeps the sh script's rules - venv Python, silent without a venv, exit 0 whatever happens - + and avoids `#Requires -Version 7`, because VS Code starts hooks under Windows PowerShell 5.1. + Each `powershell` command here is written so 5.1 can parse it, too. - **Every command ends in `|| true`.** `preToolUse` hooks are *fail-closed*: a non-zero exit denies the tool call. Without the guard, a missing interpreter would turn the observer into a blocker that refuses every tool call in the session. (Timeouts are fail-open, so the diff --git a/VERSION b/VERSION index 598636c..db04e2d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.17 +8.0.0-beta.18 diff --git a/tools/README.md b/tools/README.md index 6536abc..46c0aa6 100644 --- a/tools/README.md +++ b/tools/README.md @@ -66,6 +66,7 @@ tools/ preflight.ps1 the same for PowerShell 7; also checks the execution policy and the Mark of the Web prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor` trace-hook what the harness hooks call: trace_ingest.py under the venv's Python + trace-hook.ps1 the same for PowerShell, which resolves `./tools/trace-hook` to this file first - without it Windows asks which app opens the sh script chemenu/ cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index and CONTRACT.md's generated region render from diff --git a/tools/chemenu/tests/test_preflight.py b/tools/chemenu/tests/test_preflight.py index 2a36755..2b908bd 100644 --- a/tools/chemenu/tests/test_preflight.py +++ b/tools/chemenu/tests/test_preflight.py @@ -98,7 +98,7 @@ class Machine: self.script = self.tools / "preflight.sh" self.tools.mkdir(parents=True) for name in ("preflight.sh", "preflight.ps1", "prerequisites.txt", "wikitool", "wikitool.ps1", - "run_wikitool.py", "trace-hook"): + "run_wikitool.py", "trace-hook", "trace-hook.ps1"): shutil.copy2(TOOLS / name, self.tools / name) (self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8") self.sysbin = base / "sysbin" @@ -743,6 +743,18 @@ def test_no_hook_relies_on_a_shebang_or_a_bare_python(): assert command.startswith(("./tools/trace-hook ", ".\\tools\\.venv\\Scripts\\python.exe ")), command +def test_every_extensionless_hook_target_has_a_powershell_twin(): + """PowerShell on Windows resolves `./tools/trace-hook` to `trace-hook.ps1` first. Without + one it hands the sh script to a file association, and Windows asks which app should open + it, on every hook event (Gitea #164). Copilot CLI runs `.claude/settings.json`'s `command` + there under PowerShell, so not even a file's `bash` field is safe from it.""" + targets = {command.split()[0] for command in _hook_commands()} + extensionless = {t for t in targets if not Path(t.replace("\\", "/")).suffix} + assert extensionless == {"./tools/trace-hook"} + for target in extensionless: + assert (config._PACKAGE_ROOT / f"{target}.ps1").is_file(), target + + # --- toolpaths -------------------------------------------------------------------- diff --git a/tools/chemenu/tests/test_preflight_pwsh.py b/tools/chemenu/tests/test_preflight_pwsh.py index f7f49b4..9c1ffc6 100644 --- a/tools/chemenu/tests/test_preflight_pwsh.py +++ b/tools/chemenu/tests/test_preflight_pwsh.py @@ -37,7 +37,7 @@ pytestmark = pytest.mark.skipif(PWSH is None, reason="PowerShell 7 (pwsh) is not WINDOWS = {"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"} -def _pwsh(machine: Machine, script: str, *args: str) -> subprocess.CompletedProcess: +def _pwsh(machine: Machine, script: str, *args: str, stdin: str | None = None) -> subprocess.CompletedProcess: env = { "PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base), @@ -48,7 +48,7 @@ def _pwsh(machine: Machine, script: str, *args: str) -> subprocess.CompletedProc } return subprocess.run( [PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(machine.tools / script), *args], - capture_output=True, text=True, env=env, timeout=120, + input=stdin, capture_output=True, text=True, env=env, timeout=120, ) @@ -466,6 +466,43 @@ def test_both_launchers_exist_for_pwsh_to_resolve(): assert (TOOLS / "wikitool.ps1").is_file() and (TOOLS / "wikitool").is_file() +# --- trace-hook.ps1 ----------------------------------------------------------------- + +# Prints its arguments, then whatever arrived on stdin - with shell built-ins only, since the +# stub machine's PATH carries no `cat` of its own. +ECHO_STDIN_PYTHON = ECHO_PYTHON + "while IFS= read -r line; do printf '%s\\n' \"$line\"; done\n" +PAYLOAD = '{"sessionId": "s-1", "toolName": "bash"}' + + +def _hook(machine: Machine, *args: str) -> subprocess.CompletedProcess: + return _pwsh(machine, "trace-hook.ps1", *args, stdin=PAYLOAD + "\n") + + +@pytest.mark.parametrize("layout", [("Scripts", "python.exe"), ("bin", "python")]) +def test_trace_hook_ps1_hands_arguments_and_payload_to_the_venv_python(machine, layout): + machine.stub(layout[1], ECHO_STDIN_PYTHON, machine.tools / ".venv" / layout[0]) + result = _hook(machine, "--source", "copilot-cli", "--event", "tool.pre") + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + str(machine.tools / "trace_ingest.py"), "--source", "copilot-cli", "--event", "tool.pre", PAYLOAD, + ] + + +def test_trace_hook_ps1_is_silent_without_a_venv(machine): + result = _hook(machine, "--source", "claude-code", "--event", "prompt.submitted") + assert result.returncode == 0 + assert result.stdout == "" and result.stderr == "" + + +def test_trace_hook_ps1_never_fails_the_call_it_observes(machine): + """Copilot denies the tool call on a non-zero `preToolUse` hook - an observer that + passed a failing Python through would turn into a blocker.""" + machine.stub("python", "#!/bin/sh\necho 'Traceback: broken' >&2\nexit 2\n", machine.tools / ".venv" / "bin") + result = _hook(machine, "--source", "copilot-cli", "--event", "tool.pre") + assert result.returncode == 0 + assert result.stderr == "" + + def test_the_python_side_reads_what_the_hooks_say(monkeypatch): """The doctor checks use the same hook values the script does; the two parsers must agree on what a policy list means.""" diff --git a/tools/trace-hook b/tools/trace-hook index 95a1423..12fb4af 100755 --- a/tools/trace-hook +++ b/tools/trace-hook @@ -16,6 +16,9 @@ # # No venv yet - before the preflight has run - means no trace, silently. A hook # must never fail the call it observes. +# +# PowerShell on Windows never reaches this file: it resolves the same string to +# trace-hook.ps1 next to it, which does the same. DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 0 if [ -x "$DIR/.venv/bin/python" ]; then exec "$DIR/.venv/bin/python" "$DIR/trace_ingest.py" "$@" diff --git a/tools/trace-hook.ps1 b/tools/trace-hook.ps1 new file mode 100644 index 0000000..bae9240 --- /dev/null +++ b/tools/trace-hook.ps1 @@ -0,0 +1,27 @@ +# What the harness hooks call under PowerShell: tools/trace_ingest.py, run by the venv's Python. +# +# ./tools/trace-hook --source claude-code --event prompt.submitted +# +# The hook commands name only that string, and its POSIX half is tools/trace-hook. PowerShell +# on Windows resolves it to this file first. Without it, the string reaches the sh script +# itself, which Windows has no program for: it opens the "choose an app" dialog on every hook +# event. Copilot CLI takes that path through .claude/settings.json - it reads that file besides +# its own .github/hooks/, and runs its single `command` under PowerShell on Windows. +# +# Same rules as the sh twin: the venv's Python stands in for the recorded one, no venv means +# no trace, and the exit status is always 0 - a hook must never fail the call it observes. +# There is no `#Requires -Version 7` for the same reason: VS Code starts hooks under Windows +# PowerShell 5.1, so this file keeps to what both understand. + +$ErrorActionPreference = 'SilentlyContinue' +$PSNativeCommandArgumentPassing = 'Standard' +trap { exit 0 } + +$Dir = $PSScriptRoot +foreach ($candidate in @((Join-Path $Dir '.venv/Scripts/python.exe'), (Join-Path $Dir '.venv/bin/python'))) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + & $candidate (Join-Path $Dir 'trace_ingest.py') @args 2>$null + break + } +} +exit 0