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
This commit is contained in:
1 parent
d6e973c3ce
commit
9d06050338
8 files changed
+119
-6
No files matched your search
+26
-1
@@ -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
|
**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)
|
- 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
|
- 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
|
- 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**
|
**Low impact**
|
||||||
- version bump no longer points at version release in its output
|
- 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
|
- preflight.ps1: the asset-mode error helper is Exit-Asset, so PSScriptAnalyzer passes
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### 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
|
### 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
|
The helper that ends asset mode with exit 1 was called `Stop-Asset`. `Stop` is one of the verbs
|
||||||
|
|||||||
@@ -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
|
unverified, per the same rule that governed the Vibe adapter: an adapter that cannot be verified
|
||||||
is not written.
|
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:
|
configurations:
|
||||||
|
|
||||||
- **The interpreter is the venv's, never the script's shebang.** Each `bash` command is
|
- **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
|
(`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
|
a silent no-op. Before the preflight has created the venv, `trace-hook` records nothing and
|
||||||
exits 0.
|
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
|
- **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
|
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
|
a blocker that refuses every tool call in the session. (Timeouts are fail-open, so the
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ tools/
|
|||||||
preflight.ps1 the same for PowerShell 7; also checks the execution policy and the Mark of the Web
|
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`
|
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 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/
|
chemenu/
|
||||||
cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract
|
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
|
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
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class Machine:
|
|||||||
self.script = self.tools / "preflight.sh"
|
self.script = self.tools / "preflight.sh"
|
||||||
self.tools.mkdir(parents=True)
|
self.tools.mkdir(parents=True)
|
||||||
for name in ("preflight.sh", "preflight.ps1", "prerequisites.txt", "wikitool", "wikitool.ps1",
|
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)
|
shutil.copy2(TOOLS / name, self.tools / name)
|
||||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||||
self.sysbin = base / "sysbin"
|
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
|
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 --------------------------------------------------------------------
|
# --- toolpaths --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
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 = {
|
env = {
|
||||||
"PATH": os.pathsep.join(str(d) for d in machine.path_dirs),
|
"PATH": os.pathsep.join(str(d) for d in machine.path_dirs),
|
||||||
"HOME": str(machine.base),
|
"HOME": str(machine.base),
|
||||||
@@ -48,7 +48,7 @@ def _pwsh(machine: Machine, script: str, *args: str) -> subprocess.CompletedProc
|
|||||||
}
|
}
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(machine.tools / script), *args],
|
[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()
|
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):
|
def test_the_python_side_reads_what_the_hooks_say(monkeypatch):
|
||||||
"""The doctor checks use the same hook values the script does; the two
|
"""The doctor checks use the same hook values the script does; the two
|
||||||
parsers must agree on what a policy list means."""
|
parsers must agree on what a policy list means."""
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
#
|
#
|
||||||
# No venv yet - before the preflight has run - means no trace, silently. A hook
|
# No venv yet - before the preflight has run - means no trace, silently. A hook
|
||||||
# must never fail the call it observes.
|
# 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
|
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 0
|
||||||
if [ -x "$DIR/.venv/bin/python" ]; then
|
if [ -x "$DIR/.venv/bin/python" ]; then
|
||||||
exec "$DIR/.venv/bin/python" "$DIR/trace_ingest.py" "$@"
|
exec "$DIR/.venv/bin/python" "$DIR/trace_ingest.py" "$@"
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user