headroom/tests/test_install/test_native_installers.py
Parideboy c3c921f2f7
test(install/windows): verify the PATH guard against the real HKCU registry (#3068)
## Description

Follow-up requested in review of #2972, on top of the merged fix for
#2970 (#2985). Test-only; no
production code is touched and the `HEADROOM_INSTALL_PATH_SCOPE`
mechanism is unchanged.

`test_powershell_installer_does_not_leak_into_user_path` currently
guards the fix by comparing the
entry count of `[Environment]::GetEnvironmentVariable('Path','User')`
across an installer run. That
infers success from the environment variable rather than verifying it,
and it leaves three gaps:

- The .NET getter expands `%USERPROFILE%`-style references, so it cannot
observe a change of the
  registry value kind (`REG_EXPAND_SZ` vs `REG_SZ`) at all.
- A count comparison passes when an entry is replaced or reordered
rather than appended.
- There is no restore path. If the guard regresses, the test reports the
leak and then leaves the
polluted value behind in the contributor's registry, which is precisely
the damage #2970
  described: the test that detects the pollution also causes it.

This PR reads `HKCU\Environment` directly instead, so the assertion
verifies the guard rather than
assuming it.

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

- `tests/test_install/test_native_installers.py`: new
`_read_user_path_entry` helper returning the
raw `HKCU\Environment` `Path` value together with its registry kind (or
`None` when the value is
absent), and `_restore_user_path_entry` writing that exact value and
kind back. Both import
`winreg` inside the function body, so the module still imports on
non-Windows hosts.
- `tests/test_install/test_native_installers.py`:
`test_powershell_installer_does_not_leak_into_user_path`
now records the raw value before the run and asserts both that the
throwaway install dir is absent
from the value afterwards (naming the #2970 symptom in the failure
message) and that value and
kind are byte-identical. The PowerShell subprocess that counted PATH
entries is gone, so the test
  also spawns one process fewer.
- `tests/test_install/test_native_installers.py`: the test now runs
under `try/finally`. The
`finally` cleans up the fake docker state, which this test was missing
relative to its sibling

`test_powershell_native_installer_supports_persistent_docker_lifecycle`,
and restores the recorded
registry value only when it actually changed, so a passing run performs
zero registry writes and a
  regressed run cannot leave the contributor's PATH polluted.

The scope allow-list tests added by #2985 (`_ENSURE_PATH_SCOPE_HARNESS`,
`test_path_scope_accepts_process_case_insensitively`,
`test_path_scope_rejects_machine_and_invalid_values`)
are untouched.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_install/test_native_installers.py -q
platform win32 -- Python 3.13.11, pytest-9.0.3, pluggy-1.6.0
collected 5 items

tests\test_install\test_native_installers.py s....                       [100%]

======================== 4 passed, 1 skipped in 23.59s ========================

$ uv run ruff check .
All checks passed!

$ uv run ruff format --check tests/test_install/test_native_installers.py
1 file already formatted

$ uv run mypy headroom --ignore-missing-imports
Success: no issues found in 521 source files
```

The strengthened assertion was proven to detect a regression by
temporarily neutralising the scope
override in `scripts/install.ps1` (`if ($false -and
$env:HEADROOM_INSTALL_PATH_SCOPE)`), so
`Ensure-PathEntry` writes the `User` scope unconditionally again:

```text
$ uv run pytest tests/test_install/test_native_installers.py -q -k does_not_leak_into_user_path
tests\test_install\test_native_installers.py:638: in test_powershell_installer_does_not_leak_into_user_path
    assert str(home) not in (after[0] if after else ""), (
E   AssertionError: installer leaked the throwaway install dir into the real User PATH:
E     C:\Users\<user>\AppData\Local\Temp\pytest-of-<user>\pytest-154\test_powershell_installer_does0\home

======================= 1 failed, 4 deselected in 2.72s =======================
```

## Real Behavior Proof

- Environment: Windows 11 Pro 10.0.26200, PowerShell 7, Python 3.13.11,
pytest 9.0.3, headroom at
  `main` (`a6ab359a`), provider Anthropic
- Exact command / steps: recorded the raw `HKCU\Environment` `Path`
value with
`python -c "import winreg; ...QueryValueEx(k,'Path')"`, capturing its
registry kind, entry count
and a SHA-256 of the value; ran the full installer test file on the
patched tree; re-read the
registry; then neutralised the scope override in `scripts/install.ps1`
as shown above, re-ran the
single leak test, and re-read the registry a third time to confirm the
failure path restored it.
- Observed result: baseline `kind 1 entries 21 sha256 683ee646a95b8a28`.
After the passing run the
value was identical (`kind 1 entries 21 sha256 683ee646a95b8a28`), so a
passing run writes nothing.
With the override neutralised the test failed as quoted above and the
registry read afterwards was
again byte-identical to the recorded backup (compared as an exact
`{value, kind}` match, `True`),
confirming the `finally` restore. After reverting `scripts/install.ps1`,
the full file is back to
  4 passed, 1 skipped with the registry still unchanged.
- Not tested: non-Windows hosts (the changed test is Windows-only and
already skipped elsewhere;
`scripts/install.sh` is untouched), elevated/admin installs, and the
`Machine` scope, which
  `Ensure-PathEntry` rejects outright.

One open question this change is positioned to catch but does not
resolve: on this host the
`HKCU\Environment` `Path` value is `REG_SZ` (kind `1`), not
`REG_EXPAND_SZ`. A real install persists
through `[Environment]::SetEnvironmentVariable(..., 'User')`, which is
the API class known to rewrite
that value, so it is possible that a production install silently
downgrades an expandable PATH and
freezes `%USERPROFILE%`-style entries. I have not verified whether
headroom's installer caused it on
this machine or whether the value was always `REG_SZ`, and this PR
deliberately does not chase it.
Happy to open a separate issue if that is worth investigating.

## Runtime Rollout Safety

- Rollout-managed feature(s): none (test-only change)
- Minimum rollout channel: n/a
- Stable/default behavior changed: no; no production code path is
modified
- Kill switch / disable path: n/a
- Unsafe override required: no
- Qualification impact: none
- Rollback path: revert this commit; the test returns to the entry-count
comparison

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:21:03 -07:00

1079 lines
36 KiB
Python

from __future__ import annotations
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
FAKE_DOCKER = r"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
from pathlib import Path
STATE_PATH = Path(os.environ["FAKE_DOCKER_STATE"])
LOG_PATH = Path(os.environ["FAKE_DOCKER_LOG"])
def load_state() -> dict[str, dict[str, dict[str, int]]]:
if not STATE_PATH.exists():
return {"containers": {}}
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
def save_state(state: dict[str, dict[str, dict[str, int]]]) -> None:
STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
def cleanup_dead(state: dict[str, dict[str, dict[str, int]]]) -> dict[str, dict[str, dict[str, int]]]:
save_state(state)
return state
def host_port_from_publish(value: str) -> int:
parts = value.split(":")
if len(parts) == 2:
return int(parts[0])
if len(parts) >= 3:
return int(parts[-2])
raise ValueError(f"Unsupported publish value: {value}")
def start_server(port: int) -> int:
code = '''
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port = int(sys.argv[1])
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
return
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
'''
process = subprocess.Popen(
[sys.executable, "-c", code, str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return process.pid
def stop_container(state: dict[str, dict[str, dict[str, int]]], name: str) -> None:
data = state["containers"].pop(name, None)
if not data:
return
try:
os.kill(int(data["pid"]), signal.SIGTERM)
except OSError:
pass
save_state(state)
def main() -> int:
args = sys.argv[1:]
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOG_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(args) + "\n")
if not args:
return 0
state = cleanup_dead(load_state())
command = args[0]
if command == "pull":
return 0
if command == "network" and len(args) > 1 and args[1] == "inspect":
# Match the default bridge gateway used by the native installer when
# it configures the dashboard metadata allowlist.
gateway = os.environ.get("FAKE_DOCKER_GATEWAY", "172.17.0.1")
if gateway == "FAIL":
return 1
if "--format" in args and gateway:
print(gateway)
return 0
if command == "run":
detached = "-d" in args
if not detached:
return 0
name = None
publish = None
container_env = {}
for index, arg in enumerate(args):
if arg == "--name":
name = args[index + 1]
elif arg == "-p":
publish = args[index + 1]
elif arg == "--env":
spec = args[index + 1]
if "=" in spec:
env_name, value = spec.split("=", 1)
container_env[env_name] = value
elif spec in os.environ:
container_env[spec] = os.environ[spec]
if name is None or publish is None:
raise SystemExit("missing --name or -p in fake docker run")
port = host_port_from_publish(publish)
state["containers"][name] = {
"pid": start_server(port),
"port": port,
"env": container_env,
}
save_state(state)
print(name)
return 0
if command == "ps":
names = sorted(state["containers"])
if "--format" in args:
print("\n".join(names))
return 0
if command == "stop":
for name in args[1:]:
if not name.startswith("-"):
stop_container(state, name)
return 0
if command == "rm":
for name in args[1:]:
if not name.startswith("-"):
stop_container(state, name)
return 0
if command == "logs":
if len(args) > 1:
print(f"fake logs for {args[1]}")
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _write_fake_docker_shims(tmp_path: Path) -> Path:
shim_dir = tmp_path / "fake-docker"
shim_dir.mkdir()
fake_docker = shim_dir / "fake_docker.py"
fake_docker.write_text(FAKE_DOCKER, encoding="utf-8")
docker_sh = shim_dir / "docker"
docker_sh.write_text(
f'#!/usr/bin/env bash\nexec "{sys.executable}" "{fake_docker}" "$@"\n',
encoding="utf-8",
)
docker_sh.chmod(0o755)
docker_cmd = shim_dir / "docker.cmd"
docker_cmd.write_text(
f'@echo off\r\n"{sys.executable}" "{fake_docker}" %*\r\n',
encoding="utf-8",
)
openclaw_sh = shim_dir / "openclaw"
openclaw_sh.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
openclaw_sh.chmod(0o755)
openclaw_cmd = shim_dir / "openclaw.cmd"
openclaw_cmd.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8")
return shim_dir
def _build_env(home: Path, tmp_path: Path) -> dict[str, str]:
env = os.environ.copy()
shim_dir = _write_fake_docker_shims(tmp_path)
env["HOME"] = str(home)
env["USERPROFILE"] = str(home)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json")
env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log")
# #2970: the PowerShell installer's Ensure-PathEntry persists to the 'User'
# PATH scope (HKCU\Environment), which a HOME/USERPROFILE override does not
# redirect. Keep the PATH update ephemeral (Process scope) so running these
# tests never leaks the throwaway shim dir into the developer's real PATH.
env["HEADROOM_INSTALL_PATH_SCOPE"] = "Process"
return env
def _cleanup_fake_docker(env: dict[str, str]) -> None:
state_path = Path(env["FAKE_DOCKER_STATE"])
if not state_path.exists():
return
state = json.loads(state_path.read_text(encoding="utf-8"))
for container in state.get("containers", {}).values():
try:
os.kill(int(container["pid"]), signal.SIGTERM)
except OSError:
pass
def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]:
log_path = Path(env["FAKE_DOCKER_LOG"])
if not log_path.exists():
return []
return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line]
def _persistent_run_call(env: dict[str, str], profile: str) -> list[str]:
container_name = f"headroom-{profile}"
return next(
call
for call in _read_fake_docker_log(env)
if call[:2] == ["run", "-d"]
and "--name" in call
and call[call.index("--name") + 1] == container_name
)
def _persistent_container_env(env: dict[str, str], profile: str) -> dict[str, str]:
state = json.loads(Path(env["FAKE_DOCKER_STATE"]).read_text(encoding="utf-8"))
return state["containers"][f"headroom-{profile}"]["env"]
def _exercise_dashboard_gateway_overrides(wrapper_command: list[str], env: dict[str, str]) -> None:
trusted_cidrs = "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS"
try:
for profile, configured_value in (("configured", "10.20.0.0/16"), ("empty", "")):
env[trusted_cidrs] = configured_value
port = _free_port()
_run(
[
*wrapper_command,
"install",
"apply",
"--profile",
profile,
"--port",
str(port),
"--image",
"fake/headroom:test",
],
env=env,
)
install_call = _persistent_run_call(env, profile)
assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}"
# Docker's name-only --env form preserves the caller's value,
# including an explicitly empty value, instead of installing the
# discovered bridge gateway default.
assert trusted_cidrs in install_call
assert not any(arg.startswith(f"{trusted_cidrs}=") for arg in install_call)
assert _persistent_container_env(env, profile)[trusted_cidrs] == configured_value
_run([*wrapper_command, "install", "remove", "--profile", profile], env=env)
env.pop(trusted_cidrs, None)
env["FAKE_DOCKER_GATEWAY"] = "FAIL"
port = _free_port()
result = _run(
[
*wrapper_command,
"install",
"apply",
"--profile",
"no-gateway",
"--port",
str(port),
"--image",
"fake/headroom:test",
],
env=env,
)
install_call = _persistent_run_call(env, "no-gateway")
assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}"
assert not any(
arg == trusted_cidrs or arg.startswith(f"{trusted_cidrs}=") for arg in install_call
)
assert trusted_cidrs not in _persistent_container_env(env, "no-gateway")
assert "dashboard metadata remains restricted" in (result.stdout + result.stderr)
_run([*wrapper_command, "install", "remove", "--profile", "no-gateway"], env=env)
finally:
env.pop(trusted_cidrs, None)
env.pop("FAKE_DOCKER_GATEWAY", None)
def _run(
command: list[str],
*,
env: dict[str, str],
cwd: Path | None = None,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd,
env=env,
capture_output=True,
text=True,
check=check,
)
def _bash_supports_4_3() -> bool:
"""The Docker-native installer requires bash >= 4.3. macOS ships 3.2."""
bash = shutil.which("bash")
if not bash:
return False
try:
out = subprocess.run(
[bash, "-c", 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'],
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.SubprocessError, OSError):
return False
parts = out.stdout.strip().split(".")
if len(parts) < 2:
return False
try:
major, minor = int(parts[0]), int(parts[1])
except ValueError:
return False
return (major, minor) >= (4, 3)
@pytest.mark.skipif(
os.name == "nt" or shutil.which("bash") is None or not _bash_supports_4_3(),
reason="installer requires bash >= 4.3 (macOS system bash is 3.2)",
)
def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
try:
_run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
wrapper = home / ".local" / "bin" / "headroom"
assert wrapper.exists()
assert "HEADROOM_IMAGE_DEFAULT=headroom:test-image" in wrapper.read_text(encoding="utf-8")
help_result = _run([str(wrapper), "install", "-?"], env=env)
assert "persistent-docker preset only" in help_result.stdout
_run([str(wrapper), "--help"], env=env)
wrap_help = _run([str(wrapper), "wrap", "--help"], env=env)
assert "Supported commands:" in wrap_help.stdout
assert "copilot" not in wrap_help.stdout
unsupported_wrap = _run(
[str(wrapper), "wrap", "copilot", "--help"],
env=env,
check=False,
)
assert unsupported_wrap.returncode != 0
assert "does not support 'wrap copilot'" in unsupported_wrap.stderr
invalid_profile = _run(
[str(wrapper), "install", "status", "--profile", ".."],
env=env,
check=False,
)
assert invalid_profile.returncode != 0
assert "Invalid profile name '..'" in invalid_profile.stderr
missing_profile_value = _run(
[str(wrapper), "install", "apply", "--profile"],
env=env,
check=False,
)
assert missing_profile_value.returncode != 0
assert "Option --profile requires a value" in missing_profile_value.stderr
missing_proxy_port = _run(
[str(wrapper), "proxy", "--port"],
env=env,
check=False,
)
assert missing_proxy_port.returncode != 0
assert "Option --port requires a value" in missing_proxy_port.stderr
invalid_proxy_port = _run(
[str(wrapper), "proxy", "--port", "abc"],
env=env,
check=False,
)
assert invalid_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
missing_wrap_port = _run(
[str(wrapper), "wrap", "claude", "--port"],
env=env,
check=False,
)
assert missing_wrap_port.returncode != 0
assert "Option --port requires a value" in missing_wrap_port.stderr
invalid_wrap_port = _run(
[str(wrapper), "wrap", "claude", "--port", "abc"],
env=env,
check=False,
)
assert invalid_wrap_port.returncode != 0
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
missing_openclaw_proxy_port = _run(
[str(wrapper), "wrap", "openclaw", "--proxy-port"],
env=env,
check=False,
)
assert missing_openclaw_proxy_port.returncode != 0
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
invalid_openclaw_proxy_port = _run(
[str(wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
env=env,
check=False,
)
assert invalid_openclaw_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
for invalid_port in ("abc", "0", "65536"):
invalid_port_result = _run(
[str(wrapper), "install", "apply", "--port", invalid_port],
env=env,
check=False,
)
assert invalid_port_result.returncode != 0
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
port = _free_port()
_run(
[
str(wrapper),
"install",
"apply",
"--profile",
"smoke",
"--port",
str(port),
"--memory",
"--no-telemetry",
"--image",
"fake/headroom:test",
],
env=env,
)
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["memory_enabled"] is True
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
assert manifest["telemetry_enabled"] is False
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.env"
state_text = state_path.read_text(encoding="utf-8")
assert f"PORT={port!r}" in state_text
docker_calls = _read_fake_docker_log(env)
help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
)
assert "-it" not in help_call
install_call = next(
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
)
assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}"
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
# Canonical filesystem contract env vars (issue #175) forwarded into
# the container so the proxy resolves state/config to the bind mount.
assert "HEADROOM_WORKSPACE_DIR=/tmp/headroom-home/.headroom" in install_call
assert "HEADROOM_CONFIG_DIR=/tmp/headroom-home/.headroom/config" in install_call
assert "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32" in install_call
assert (
_persistent_container_env(env, "smoke")["HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS"]
== "172.17.0.1/32"
)
_exercise_dashboard_gateway_overrides([str(wrapper)], env)
status_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: running" in status_result.stdout
_run([str(wrapper), "install", "stop", "--profile", "smoke"], env=env)
stopped_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: stopped" in stopped_result.stdout
_run([str(wrapper), "install", "start", "--profile", "smoke"], env=env)
restarted_result = _run(
[str(wrapper), "install", "status", "--profile", "smoke"],
env=env,
)
assert "Status: running" in restarted_result.stdout
rejected = _run(
[str(wrapper), "install", "apply", "--scope", "user"],
env=env,
check=False,
)
assert rejected.returncode != 0
assert "does not support provider/user/system mutation flags" in rejected.stderr
_run([str(wrapper), "install", "restart", "--profile", "smoke"], env=env)
_run([str(wrapper), "install", "remove", "--profile", "smoke"], env=env)
assert not manifest_path.parent.exists()
finally:
_cleanup_fake_docker(env)
def _powershell_executable() -> str | None:
return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe")
def _read_user_path_entry() -> tuple[str, int] | None:
"""Read the raw ``HKCU\\Environment`` PATH value and its registry kind, if it exists.
Reading the registry directly rather than through
``[Environment]::GetEnvironmentVariable('Path','User')`` keeps two things visible that the
.NET getter hides: the unexpanded value (the getter expands ``%USERPROFILE%``-style
references) and the value kind, so a ``REG_EXPAND_SZ`` -> ``REG_SZ`` downgrade cannot pass
unnoticed.
"""
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
try:
value, kind = winreg.QueryValueEx(key, "Path")
except FileNotFoundError:
return None
return str(value), int(kind)
def _restore_user_path_entry(previous: tuple[str, int] | None) -> None:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_SET_VALUE) as key:
if previous is None:
try:
winreg.DeleteValue(key, "Path")
except FileNotFoundError:
pass
return
value, kind = previous
winreg.SetValueEx(key, "Path", 0, kind, value)
@pytest.mark.skipif(
os.name != "nt" or _powershell_executable() is None,
reason="Windows PowerShell coverage runs on Windows hosts only",
)
def test_powershell_installer_does_not_leak_into_user_path(tmp_path: Path) -> None:
"""The installer must not mutate the real HKCU User PATH (#2970).
``Ensure-PathEntry`` persists to the 'User' scope, which a HOME/USERPROFILE
override does not redirect, so running the installer against a throwaway home
used to leak the temp shim dir into the developer's real PATH. ``_build_env``
now sets ``HEADROOM_INSTALL_PATH_SCOPE=Process`` to keep the update
ephemeral; the real User PATH must be unchanged across the run.
The assertion reads ``HKCU\\Environment`` itself instead of counting the entries the .NET
getter reports, so it verifies the guard rather than trusting the environment variable to
have taken effect: it catches a count-preserving mutation and a change of the value kind,
neither of which an entry count can see.
"""
powershell = _powershell_executable()
assert powershell is not None
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
before = _read_user_path_entry()
try:
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(REPO_ROOT / "scripts" / "install.ps1"),
],
env=env,
cwd=REPO_ROOT,
)
after = _read_user_path_entry()
assert str(home) not in (after[0] if after else ""), (
f"installer leaked the throwaway install dir into the real User PATH: {home}"
)
assert after == before, "installer mutated the real User PATH"
finally:
_cleanup_fake_docker(env)
# A passing run never writes to the registry; this only fires if the guard regresses, so
# that a failing test cannot leave the developer's PATH polluted.
if _read_user_path_entry() != before:
_restore_user_path_entry(before)
# AST-extract Ensure-PathEntry from install.ps1 and invoke it in isolation under
# a given HEADROOM_INSTALL_PATH_SCOPE, so the scope allow-list is exercised
# without running the whole installer. Parsing via the PowerShell AST (not a
# regex) keeps this pinned to the real function body. Only 'Process' (ephemeral)
# and the throwing paths are driven — never 'User', which would mutate the real
# HKCU PATH.
_ENSURE_PATH_SCOPE_HARNESS = r"""
param([string]$InstallScript, [string]$ScopeValue)
$ErrorActionPreference = 'Stop'
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$InstallScript, [ref]$null, [ref]$null)
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$n.Name -eq 'Ensure-PathEntry'
}, $true) | Select-Object -First 1
if (-not $fn) { Write-Output 'NOFUNC'; exit 3 }
Invoke-Expression $fn.Extent.Text
$env:HEADROOM_INSTALL_PATH_SCOPE = $ScopeValue
try {
Ensure-PathEntry -PathEntry 'C:\headroom-scope-test-marker'
Write-Output 'OK'
} catch {
Write-Output ('ERR:' + $_.Exception.Message)
}
"""
def _invoke_scope_harness(scope_value: str, tmp_path: Path) -> str:
powershell = _powershell_executable()
assert powershell is not None
harness = tmp_path / "scope_harness.ps1"
harness.write_text(_ENSURE_PATH_SCOPE_HARNESS, encoding="utf-8")
result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(harness),
"-InstallScript",
str(REPO_ROOT / "scripts" / "install.ps1"),
"-ScopeValue",
scope_value,
],
env=os.environ.copy(),
check=False,
)
return result.stdout.strip()
@pytest.mark.skipif(
os.name != "nt" or _powershell_executable() is None,
reason="Windows PowerShell coverage runs on Windows hosts only",
)
def test_path_scope_accepts_process_case_insensitively(tmp_path: Path) -> None:
"""'Process' (any case) is a supported ephemeral target: Ensure-PathEntry runs."""
assert _invoke_scope_harness("process", tmp_path).endswith("OK")
assert _invoke_scope_harness("Process", tmp_path).endswith("OK")
@pytest.mark.skipif(
os.name != "nt" or _powershell_executable() is None,
reason="Windows PowerShell coverage runs on Windows hosts only",
)
def test_path_scope_rejects_machine_and_invalid_values(tmp_path: Path) -> None:
"""'Machine' (system-wide) and typos must fail early, before any PATH write."""
for bad in ("Machine", "machine", "system", "bogus"):
out = _invoke_scope_harness(bad, tmp_path)
assert out.startswith("ERR:"), f"scope {bad!r} was not rejected: {out!r}"
assert "User" in out and "Process" in out, out
@pytest.mark.skipif(
os.name != "nt" or _powershell_executable() is None,
reason="Windows PowerShell coverage runs on Windows hosts only",
)
def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
powershell = _powershell_executable()
assert powershell is not None
home = tmp_path / "home"
(home / ".local").mkdir(parents=True)
env = _build_env(home, tmp_path)
env["HEADROOM_DOCKER_IMAGE"] = "headroom:test-image"
try:
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(REPO_ROOT / "scripts" / "install.ps1"),
],
env=env,
cwd=REPO_ROOT,
)
wrapper = home / ".local" / "bin" / "headroom.ps1"
assert wrapper.exists()
assert "__HEADROOM_INSTALL_IMAGE__" not in wrapper.read_text(encoding="utf-8")
assert "headroom:test-image" in wrapper.read_text(encoding="utf-8")
cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
assert cmd_wrapper.exists()
help_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"-?",
],
env=env,
)
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"proxy",
"--help",
],
env=env,
)
assert "persistent-docker preset only" in help_result.stdout
cmd_help_result = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "-?"],
env=env,
)
assert "persistent-docker preset only" in cmd_help_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"--help",
],
env=env,
)
wrap_help = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "--help"],
env=env,
)
assert "Supported commands:" in wrap_help.stdout
assert "copilot" not in wrap_help.stdout
unsupported_wrap = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "copilot", "--help"],
env=env,
check=False,
)
assert unsupported_wrap.returncode != 0
assert "does not support 'wrap copilot'" in unsupported_wrap.stderr
invalid_profile = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
env=env,
check=False,
)
assert invalid_profile.returncode != 0
assert "Invalid profile name '..'" in invalid_profile.stderr
missing_profile_value = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--profile"],
env=env,
check=False,
)
assert missing_profile_value.returncode != 0
assert "Option --profile requires a value" in missing_profile_value.stderr
missing_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port"],
env=env,
check=False,
)
assert missing_proxy_port.returncode != 0
assert "Option --port requires a value" in missing_proxy_port.stderr
invalid_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port", "abc"],
env=env,
check=False,
)
assert invalid_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
missing_wrap_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port"],
env=env,
check=False,
)
assert missing_wrap_port.returncode != 0
assert "Option --port requires a value" in missing_wrap_port.stderr
invalid_wrap_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port", "abc"],
env=env,
check=False,
)
assert invalid_wrap_port.returncode != 0
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
missing_openclaw_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port"],
env=env,
check=False,
)
assert missing_openclaw_proxy_port.returncode != 0
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
invalid_openclaw_proxy_port = _run(
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
env=env,
check=False,
)
assert invalid_openclaw_proxy_port.returncode != 0
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
for invalid_port in ("abc", "0", "65536"):
invalid_port_result = _run(
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--port", invalid_port],
env=env,
check=False,
)
assert invalid_port_result.returncode != 0
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
port = _free_port()
_run(
[
"cmd.exe",
"/c",
str(cmd_wrapper),
"install",
"apply",
"--profile",
"smoke",
"--port",
str(port),
"--memory",
"--no-telemetry",
"--image",
"fake/headroom:test",
],
env=env,
)
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
manifest_bytes = manifest_path.read_bytes()
state_bytes = state_path.read_bytes()
assert not manifest_bytes.startswith(b"\xef\xbb\xbf")
assert not state_bytes.startswith(b"\xef\xbb\xbf")
manifest = json.loads(manifest_bytes.decode("utf-8"))
state = json.loads(state_bytes.decode("utf-8"))
assert manifest["preset"] == "persistent-docker"
assert manifest["port"] == port
assert manifest["memory_enabled"] is True
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
assert manifest["telemetry_enabled"] is False
assert state["container_name"] == "headroom-smoke"
docker_calls = _read_fake_docker_log(env)
help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
)
assert "-it" not in help_call
proxy_help_call = next(
call
for call in docker_calls
if call[:2] == ["run", "--rm"] and "-p" in call and "proxy" in call and "--help" in call
)
assert "-it" not in proxy_help_call
install_call = next(
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
)
assert install_call[install_call.index("-p") + 1] == f"127.0.0.1:{port}:{port}"
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
# Canonical filesystem contract env vars (issue #175).
assert "HEADROOM_WORKSPACE_DIR=/tmp/headroom-home/.headroom" in install_call
assert "HEADROOM_CONFIG_DIR=/tmp/headroom-home/.headroom/config" in install_call
assert "HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS=172.17.0.1/32" in install_call
assert (
_persistent_container_env(env, "smoke")["HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS"]
== "172.17.0.1/32"
)
_exercise_dashboard_gateway_overrides(
[powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(wrapper)],
env,
)
status_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: running" in status_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"stop",
"--profile",
"smoke",
],
env=env,
)
stopped_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: stopped" in stopped_result.stdout
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"start",
"--profile",
"smoke",
],
env=env,
)
started_result = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"status",
"--profile",
"smoke",
],
env=env,
)
assert "Status: running" in started_result.stdout
rejected = _run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"apply",
"--scope",
"user",
],
env=env,
check=False,
)
assert rejected.returncode != 0
assert "does not support provider/user/system mutation flags" in rejected.stderr
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"restart",
"--profile",
"smoke",
],
env=env,
)
_run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(wrapper),
"install",
"remove",
"--profile",
"smoke",
],
env=env,
)
assert not manifest_path.parent.exists()
finally:
_cleanup_fake_docker(env)