mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(install): consolidate Windows fallback and cleanup safety (#2980)
## Description Consolidates two fully reviewed installation-safety fixes whose original PRs can no longer merge under current branch protection: Windows persistent-service deployments need a supported Task Scheduler fallback, and legacy context-tool cleanup must never delete user-owned RTK/lean-ctx artifacts. Closes #2552 Closes #2817 Supersedes #2600 and #2828 while preserving their authors' commits and review-driven corrections. ## Type of Change - [x] 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) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Convert Windows `persistent-service` plans to the supported `persistent-task` supervisor and make the fallback explicit in CLI output. - Restrict context-tool cleanup to artifacts proven to live under Headroom's managed directory. - Recognize wrapped, relative, and platform-specific managed commands without accepting prefixed/path-boundary lookalikes. - Scope cleanup completion state correctly across projects and alternate agent homes. - Stamp cleanup complete only after all managed remnants are settled. - Preserve the original focused regression suites and behavior-proof artifact. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest -q tests/test_install/test_planner.py tests/test_install/test_supervisors.py tests/test_cli/test_install_cli.py tests/test_context_tool_cleanup.py tests/test_cli/test_unwrap_claude.py 135 passed in 0.45s $ uv run ruff check <changed Python and test files> All checks passed! $ uv run ruff format --check <changed Python and test files> 8 files already formatted ``` ## Real Behavior Proof - Environment: macOS arm64 for consolidated current-main validation; the Windows fallback source PR was independently validated on Windows and includes its captured verification artifact. - Exact command / steps: run the planner, supervisor, install CLI, cleanup provenance, and unwrap suites on the rebased combined branch. - Observed result: 135/135 focused tests pass. Windows service requests resolve to `persistent-task`; cleanup rejects user-owned and path-prefix lookalikes while removing managed artifacts. - Not tested: a fresh privileged Windows host deployment in this local pass; #2600's accepted review contains the Windows-specific proof. ## Runtime Rollout Safety - Rollout-managed feature(s): Install supervisor selection and one-time legacy cleanup. - Minimum rollout channel: Stable/default; both prevent currently destructive or nonfunctional install paths. - Stable/default behavior changed: Windows service requests use Task Scheduler; cleanup requires managed provenance. - Kill switch / disable path: Select `persistent-task` explicitly; cleanup remains bounded by its completion stamp and provenance checks. - Unsafe override required: No. - Qualification impact: Windows native install and wrap/unwrap cleanup suites. - Rollback path: Revert this PR, restoring the two pre-fix behaviors. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) The Windows verification artifact from #2600 is retained at `.github/pr-images/issue-2552-windows-fallback-verification.png`. ## Additional Notes This is intentionally an installation-safety batch rather than two replacement PRs. Original commit authorship is preserved, and the combined diff was applied cleanly to current `main` after #2832 and #1628 landed. --------- Co-authored-by: Inference1 <68734681+Inference1@users.noreply.github.com> Co-authored-by: Dennis Alexis Valin Dittrich <dd+github@dr-dittrich.de>
This commit is contained in:
parent
a3fe5cb65b
commit
ddd2a259ec
9 changed files with 1080 additions and 55 deletions
BIN
.github/pr-images/issue-2552-windows-fallback-verification.png
vendored
Normal file
BIN
.github/pr-images/issue-2552-windows-fallback-verification.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
|
|
@ -139,6 +139,12 @@ def _require_manifest(profile: str) -> DeploymentManifest:
|
||||||
raise _missing_profile_error(profile, installed)
|
raise _missing_profile_error(profile, installed)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows() -> bool:
|
||||||
|
"""Return whether this command is running on Windows."""
|
||||||
|
|
||||||
|
return sys.platform.startswith("win")
|
||||||
|
|
||||||
|
|
||||||
def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = False) -> None:
|
def _start_deployment(manifest: DeploymentManifest, *, assume_start_lock: bool = False) -> None:
|
||||||
if not assume_start_lock:
|
if not assume_start_lock:
|
||||||
with acquire_runtime_start_lock(manifest.profile) as acquired:
|
with acquire_runtime_start_lock(manifest.profile) as acquired:
|
||||||
|
|
@ -658,6 +664,16 @@ def install_apply(
|
||||||
bedrock_profile=bedrock_profile,
|
bedrock_profile=bedrock_profile,
|
||||||
extra_env=combined_env,
|
extra_env=combined_env,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
preset == InstallPreset.PERSISTENT_SERVICE.value
|
||||||
|
and manifest.preset == InstallPreset.PERSISTENT_TASK.value
|
||||||
|
and _is_windows()
|
||||||
|
):
|
||||||
|
click.echo(
|
||||||
|
"Warning: persistent-service is not supported on Windows because the "
|
||||||
|
"Python runner cannot act as a Windows service. Falling back to "
|
||||||
|
"persistent-task with Task Scheduler."
|
||||||
|
)
|
||||||
|
|
||||||
_apply_manifest(manifest)
|
_apply_manifest(manifest)
|
||||||
_echo_installed(manifest)
|
_echo_installed(manifest)
|
||||||
|
|
|
||||||
|
|
@ -795,7 +795,7 @@ _RETIRED_CONTEXT_TOOL_MESSAGE = (
|
||||||
"rewrote shell commands through a third-party binary Headroom no longer "
|
"rewrote shell commands through a third-party binary Headroom no longer "
|
||||||
"manages. Drop --context-tool / --no-context-tool and unset "
|
"manages. Drop --context-tool / --no-context-tool and unset "
|
||||||
f"{_RETIRED_CONTEXT_TOOL_ENV}; `headroom wrap` uninstalls what they left "
|
f"{_RETIRED_CONTEXT_TOOL_ENV}; `headroom wrap` uninstalls what they left "
|
||||||
"behind on first run."
|
"behind automatically."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -855,8 +855,10 @@ def _report_context_tool_purge() -> None:
|
||||||
default: the Claude ``PreToolUse`` hook, the vendored binaries and the
|
default: the Claude ``PreToolUse`` hook, the vendored binaries and the
|
||||||
injected hint-file guidance are all durable on disk. Running this once per
|
injected hint-file guidance are all durable on disk. Running this once per
|
||||||
``wrap`` / ``unwrap`` invocation is what actually makes the tools go away.
|
``wrap`` / ``unwrap`` invocation is what actually makes the tools go away.
|
||||||
Silent when there is nothing to do, which is the steady state after the first
|
Silent when there is nothing to do — the common case once the machine-global
|
||||||
run, and never fatal — a cleanup failure must not block launching the tool.
|
half is stamped done, though the project- and config-directory-scoped half
|
||||||
|
still runs every launch — and never fatal: a cleanup failure must not block
|
||||||
|
launching the tool.
|
||||||
|
|
||||||
Reports on **stderr**: some subcommands (``wrap/unwrap openclaw
|
Reports on **stderr**: some subcommands (``wrap/unwrap openclaw
|
||||||
--prepare-only``) emit machine-readable JSON on stdout as their entire
|
--prepare-only``) emit machine-readable JSON on stdout as their entire
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,87 @@ Deleting the code is not enough: everything above is *durable state on the
|
||||||
user's disk*. Left alone, the Claude hooks keep rewriting every Bash command
|
user's disk*. Left alone, the Claude hooks keep rewriting every Bash command
|
||||||
through binaries Headroom no longer manages, and the injected guidance keeps
|
through binaries Headroom no longer manages, and the injected guidance keeps
|
||||||
telling agents to use tools that may not resolve. So ``headroom wrap`` /
|
telling agents to use tools that may not resolve. So ``headroom wrap`` /
|
||||||
``headroom unwrap`` call :func:`purge_context_tool_artifacts` once per run to
|
``headroom unwrap`` call :func:`purge_context_tool_artifacts` on every run to
|
||||||
remove what earlier versions installed.
|
remove what earlier versions installed — machine-global artifacts (hooks,
|
||||||
|
binaries, Claude Code's MCP registration) are removed once per workspace and
|
||||||
|
then stamped done (see the stamp below), while project- and config-directory-
|
||||||
|
scoped guidance (``CODEX_HOME`` / ``OPENCODE_HOME`` hint files, Continue's
|
||||||
|
config) is inspected on every launch, since a later launch can sit in a
|
||||||
|
different project or point at a different ``CODEX_HOME`` / ``OPENCODE_HOME``.
|
||||||
|
|
||||||
Everything here is idempotent, best-effort and deliberately conservative:
|
Everything here is idempotent, best-effort and deliberately conservative:
|
||||||
|
|
||||||
* only files Headroom installed (or caused a context tool to install) are
|
* only files Headroom installed (or caused a context tool to install) are
|
||||||
deleted;
|
deleted. An MCP entry's ``command`` or a hook script's body counts as
|
||||||
|
Headroom's only when it names a path inside :func:`paths.bin_dir`
|
||||||
|
(:func:`_references_managed_bin` — which is where the matching rules and
|
||||||
|
the reasons behind them live);
|
||||||
|
* a hook entry is Headroom's when it names such a path directly, or — the
|
||||||
|
common case, since a hook command names a script rather than the binary —
|
||||||
|
when it names one of the ``~/.claude/hooks`` scripts already classified as
|
||||||
|
Headroom's, whose verdict it inherits
|
||||||
|
(:func:`_references_context_tool`, :func:`_names_a_managed_script`). A
|
||||||
|
Cursor ``hooks.json`` entry naming a script under ``~/.cursor`` cannot
|
||||||
|
inherit a verdict this way, since the map covers ``~/.claude/hooks`` only;
|
||||||
|
it is still caught when its ``command`` names the managed directory;
|
||||||
|
* ``.rtk-hook.sha256`` is never read for its own provenance (it holds a hex
|
||||||
|
digest, not a path) and instead inherits ``rtk-rewrite.sh``'s
|
||||||
|
classification; a ``<name>.lean-ctx.bak`` backup inherits ``<name>``'s
|
||||||
|
(:func:`_classify_hook_scripts`);
|
||||||
|
* a hook script that exists but cannot be read is classified unknown —
|
||||||
|
deleted by nothing, and named in the report so the user can remove it by
|
||||||
|
hand;
|
||||||
* ``~/.local/bin/{rtk,lean-ctx}`` is unlinked only when it is a symlink into
|
* ``~/.local/bin/{rtk,lean-ctx}`` is unlinked only when it is a symlink into
|
||||||
Headroom's own bin directory — a user's own build is never touched;
|
Headroom's own bin directory — a user's own build is never touched;
|
||||||
* a JSON config that does not parse is reported and **skipped**, never
|
* a JSON config that does not parse is reported and **skipped**, never
|
||||||
overwritten (a hand-edited typo must not cost the user their settings);
|
overwritten (a hand-edited typo must not cost the user their settings);
|
||||||
* the tools' own backups of *config* files (``~/.claude.json.lean-ctx.bak`` and
|
* the tools' own backups of *config* files (``~/.claude.json.lean-ctx.bak`` and
|
||||||
friends) are left in place — they hold the user's real settings history. Only
|
friends) are left in place — they hold the user's real settings history. Only
|
||||||
backups of the hook scripts being deleted are cleaned up.
|
backups of the hook scripts proven to be Headroom's are cleaned up;
|
||||||
|
* two cases cannot be decided at all, and are accepted as limits rather than
|
||||||
|
fixed:
|
||||||
|
|
||||||
|
* ``get_lean_ctx_path`` used to check ``PATH`` before Headroom's own bin
|
||||||
|
directory, so on a machine that already had ``lean-ctx`` on ``PATH``,
|
||||||
|
the tool that ran was the user's own, and the config it wrote looks
|
||||||
|
exactly like config the user wrote by hand. That leftover survives the
|
||||||
|
purge — it still points at a binary that exists, so nothing dangles;
|
||||||
|
* an rtk hook written *after* #1698 execs a bare ``rtk`` and never mentions
|
||||||
|
:func:`paths.bin_dir`, so it reads exactly like a hook a user wrote by
|
||||||
|
hand, and ``rtk-rewrite.sh``, its ``.rtk-hook.sha256`` and its
|
||||||
|
``settings.json`` entry all survive while step 3 removes the managed
|
||||||
|
binary — leaving a hook that silently no-ops (#487, #1698). Earlier
|
||||||
|
hooks are decidable: Headroom patched the absolute managed path into
|
||||||
|
them (``_patch_rtk_hook_absolute_path``, removed by #1698), so the
|
||||||
|
window this misses is rtk setups run between #1698 and the tools'
|
||||||
|
removal in #2677;
|
||||||
|
* the marker-fenced guidance block is the one step with no provenance check
|
||||||
|
to make — ``<!-- headroom:rtk-instructions -->`` is Headroom's own fence,
|
||||||
|
and no third party writes it.
|
||||||
|
|
||||||
|
Removing the retired integration's machine-global footprint — hook
|
||||||
|
registrations, hook scripts, PATH symlinks, managed binaries and Claude
|
||||||
|
Code's own MCP registration — is a one-time migration: the first completed
|
||||||
|
run of that half stamps ``.context-tools-purged`` beside the managed bin
|
||||||
|
directory, and every later run skips that half outright. Without the stamp
|
||||||
|
this would keep rewriting the same machine-wide files on every ``wrap``
|
||||||
|
invocation forever, and a user who installs one of these tools *after* the
|
||||||
|
migration would have Headroom auditing files at each launch for a leftover
|
||||||
|
that cannot exist there.
|
||||||
|
|
||||||
|
Project- and config-directory-scoped state is not covered by that stamp: a
|
||||||
|
later invocation can sit in a different project, or point ``CODEX_HOME`` /
|
||||||
|
``OPENCODE_HOME`` somewhere the stamped run never inspected, and whatever
|
||||||
|
guidance an earlier Headroom left behind there is still worth removing — so
|
||||||
|
those steps run on every invocation instead (:func:`_purge_invocation_scoped`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -69,52 +130,138 @@ _HOOK_SCRIPTS = (
|
||||||
"lean-ctx-redirect-native",
|
"lean-ctx-redirect-native",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# rtk's integrity digest never names a path (see ``_classify_hook_scripts``)
|
||||||
|
# and rtk-rewrite.sh is the script it authenticates.
|
||||||
|
_RTK_DIGEST_NAME = ".rtk-hook.sha256"
|
||||||
|
_RTK_SCRIPT_NAME = "rtk-rewrite.sh"
|
||||||
|
|
||||||
# MCP server entries the tools registered, and the config files holding them.
|
# MCP server entries the tools registered, and the config files holding them.
|
||||||
# lean-ctx registers itself as an MCP server during ``lean-ctx init``; rtk never
|
# lean-ctx registers itself as an MCP server during ``lean-ctx init``; rtk never
|
||||||
# did, but it is matched too so a stale hand-added entry is cleaned up as well.
|
# did, but it is matched too so a stale hand-added entry is cleaned up as well.
|
||||||
_MCP_SERVER_NAMES = ("lean-ctx", "lean_ctx", "rtk")
|
_MCP_SERVER_NAMES = ("lean-ctx", "lean_ctx", "rtk")
|
||||||
|
|
||||||
|
# Report-line prefixes meaning "this one is not settled" — a config that would
|
||||||
|
# not parse, a script that would not read, a file that would not unlink. Such a
|
||||||
|
# run leaves a leftover behind, so it must not be stamped as the completed
|
||||||
|
# migration. Emitted by _purge_hook_config, _purge_mcp_entries,
|
||||||
|
# _purge_fenced_block, _purge_continue_system_messages and _remove_files.
|
||||||
|
_DEFERRED_PREFIXES = ("skipped ", "could not remove ")
|
||||||
|
|
||||||
|
|
||||||
def purge_context_tool_artifacts() -> list[str]:
|
def purge_context_tool_artifacts() -> list[str]:
|
||||||
"""Remove every rtk / lean-ctx artifact an earlier Headroom version installed.
|
"""Remove every rtk / lean-ctx artifact an earlier Headroom version installed.
|
||||||
|
|
||||||
Returns human-readable descriptions of what was removed — plus a line for
|
Returns human-readable descriptions of what was removed — plus a line for
|
||||||
any config that had to be skipped because the user must fix it by hand. An
|
any config that had to be skipped because the user must fix it by hand.
|
||||||
empty list means there was nothing to do, which is the steady state after
|
Machine-global cleanup (hooks, binaries, Claude Code's MCP registration)
|
||||||
the first run.
|
runs once and is then skipped via the stamp below; project- and config-
|
||||||
|
directory-scoped cleanup (hint files, ``CODEX_HOME`` / ``OPENCODE_HOME``,
|
||||||
|
Continue's config) runs on every call, so a later call in a different
|
||||||
|
project or a repointed ``CODEX_HOME`` / ``OPENCODE_HOME`` can still report
|
||||||
|
something even after the global half is long since stamped done.
|
||||||
"""
|
"""
|
||||||
|
marker = _purge_marker()
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
project = Path.cwd()
|
project = Path.cwd()
|
||||||
report: list[str] = []
|
report: list[str] = []
|
||||||
|
|
||||||
# 1. Hook registrations (Claude Code's settings.json, Cursor's hooks.json).
|
if not marker.exists():
|
||||||
for config in (home / ".claude" / "settings.json", home / ".cursor" / "hooks.json"):
|
# Classify every hook script's provenance once, up front: both step 1
|
||||||
report += _purge_hook_config(config)
|
# (is a settings.json entry pointing at *our* script?) and step 2 (is
|
||||||
|
# the script itself ours?) need the same answer, and each file is
|
||||||
|
# read once.
|
||||||
|
hooks_dir = home / ".claude" / "hooks"
|
||||||
|
hook_classification = _classify_hook_scripts(hooks_dir)
|
||||||
|
|
||||||
# 2. The generated hook scripts, their integrity digests and stale backups.
|
# 1. Hook registrations (Claude Code's settings.json, Cursor's hooks.json).
|
||||||
hooks_dir = home / ".claude" / "hooks"
|
for config in (home / ".claude" / "settings.json", home / ".cursor" / "hooks.json"):
|
||||||
report += _remove_files(
|
report += _purge_hook_config(config, hooks_dir, hook_classification)
|
||||||
*(hooks_dir / name for name in _HOOK_SCRIPTS),
|
|
||||||
*(hooks_dir / f"{name}.lean-ctx.bak" for name in _HOOK_SCRIPTS),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. The PATH symlinks, then the managed binaries they pointed at.
|
# 2. The generated hook scripts, their integrity digests and stale
|
||||||
for name in ("rtk", "lean-ctx"):
|
# backups — only the ones proven to reference Headroom's managed bin
|
||||||
report += _remove_managed_path_link(home / ".local" / "bin" / name)
|
# directory.
|
||||||
report += _remove_files(*(paths.bin_dir() / name for name in _BINARY_NAMES))
|
managed_names = [name for name in _HOOK_SCRIPTS if hook_classification.get(name)]
|
||||||
|
report += _remove_files(
|
||||||
|
*(hooks_dir / name for name in managed_names),
|
||||||
|
*(hooks_dir / f"{name}.lean-ctx.bak" for name in managed_names),
|
||||||
|
)
|
||||||
|
for name in _HOOK_SCRIPTS:
|
||||||
|
if hook_classification.get(name, False) is not None:
|
||||||
|
continue
|
||||||
|
if name == _RTK_DIGEST_NAME:
|
||||||
|
report.append(
|
||||||
|
f"skipped {hooks_dir / name} (inherits {_RTK_SCRIPT_NAME}'s unreadable verdict)"
|
||||||
|
" — remove any stale hook script by hand"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
report.append(
|
||||||
|
f"skipped {hooks_dir / name} (could not read to verify it was Headroom's)"
|
||||||
|
" — remove any stale hook script by hand"
|
||||||
|
)
|
||||||
|
|
||||||
# 4. MCP server registrations (lean-ctx registers itself during init).
|
# 3. The PATH symlinks, then the managed binaries they pointed at.
|
||||||
report += _purge_mcp_entries(home / ".claude.json", "mcpServers")
|
for name in ("rtk", "lean-ctx"):
|
||||||
|
report += _remove_managed_path_link(home / ".local" / "bin" / name)
|
||||||
|
report += _remove_files(*(paths.bin_dir() / name for name in _BINARY_NAMES))
|
||||||
|
|
||||||
|
# 4. Claude Code's own MCP server registration (lean-ctx registers
|
||||||
|
# itself during init). OpenCode's is invocation-scoped — see below.
|
||||||
|
report += _purge_mcp_entries(home / ".claude.json", "mcpServers")
|
||||||
|
|
||||||
|
# Only a global half that settled everything is the completed
|
||||||
|
# migration. One that could not read a script or parse a config left
|
||||||
|
# a leftover behind, and the user needs both the reminder on the next
|
||||||
|
# launch and the cleanup once the permissions or the typo are fixed.
|
||||||
|
# A deferral in the invocation-scoped half below must not withhold
|
||||||
|
# this stamp — that half re-runs every time regardless, so nothing is
|
||||||
|
# lost by stamping the global half done now.
|
||||||
|
if not any(line.startswith(_DEFERRED_PREFIXES) for line in report):
|
||||||
|
try:
|
||||||
|
# Cleanup must not become the first mutation on a pristine
|
||||||
|
# machine. In particular, ``wrap <missing-tool>`` validates
|
||||||
|
# the binary after the wrap-group migration hook; creating
|
||||||
|
# ``~/.headroom`` merely to stamp an empty scan violates that
|
||||||
|
# command's no-side-effects-on-failure contract. Established
|
||||||
|
# Headroom installs already have the state directory and get
|
||||||
|
# the one-time fast path; clean machines cheaply rescan until
|
||||||
|
# some real Headroom state exists.
|
||||||
|
if marker.parent.is_dir():
|
||||||
|
marker.touch()
|
||||||
|
except OSError:
|
||||||
|
pass # Unwritable workspace: the purge simply runs again next time.
|
||||||
|
|
||||||
|
report += _purge_invocation_scoped(home, project)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_invocation_scoped(home: Path, project: Path) -> list[str]:
|
||||||
|
"""Steps the one-time stamp must never withhold.
|
||||||
|
|
||||||
|
``OPENCODE_HOME``'s config, the hint files in ``project`` /
|
||||||
|
``CODEX_HOME`` / ``OPENCODE_HOME``, and Continue's config are all a
|
||||||
|
function of *this* invocation's cwd and environment, not of the machine —
|
||||||
|
a later run can sit in a different project or point ``CODEX_HOME`` /
|
||||||
|
``OPENCODE_HOME`` somewhere the global-half stamp never inspected. Each
|
||||||
|
step is cheap and side-effect-free when nothing matches, so re-running
|
||||||
|
them on every invocation costs a handful of reads in the steady state.
|
||||||
|
"""
|
||||||
|
report: list[str] = []
|
||||||
report += _purge_mcp_entries(_opencode_home(home) / "opencode.json", "mcp")
|
report += _purge_mcp_entries(_opencode_home(home) / "opencode.json", "mcp")
|
||||||
|
|
||||||
# 5. Marker-fenced guidance in every hint file the wrap harnesses wrote to.
|
|
||||||
for hint_file in _instruction_files(home, project):
|
for hint_file in _instruction_files(home, project):
|
||||||
report += _purge_fenced_block(hint_file)
|
report += _purge_fenced_block(hint_file)
|
||||||
report += _purge_continue_system_messages(project / ".continue" / "config.json")
|
report += _purge_continue_system_messages(project / ".continue" / "config.json")
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_marker() -> Path:
|
||||||
|
"""Path of the "already migrated" stamp.
|
||||||
|
|
||||||
|
Derived from :func:`paths.bin_dir` rather than ``workspace_dir`` so it
|
||||||
|
cannot escape a temporary tree through ``HEADROOM_WORKSPACE_DIR``.
|
||||||
|
"""
|
||||||
|
return paths.bin_dir().parent / ".context-tools-purged"
|
||||||
|
|
||||||
|
|
||||||
def _instruction_files(home: Path, project: Path) -> list[Path]:
|
def _instruction_files(home: Path, project: Path) -> list[Path]:
|
||||||
"""Hint files the wrap subcommands injected the context-tool block into.
|
"""Hint files the wrap subcommands injected the context-tool block into.
|
||||||
|
|
||||||
|
|
@ -151,15 +298,194 @@ def _opencode_home(home: Path) -> Path:
|
||||||
# --- hook registrations -------------------------------------------------------
|
# --- hook registrations -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _references_context_tool(entry: Any) -> bool:
|
# Characters that can never be part of a path: a match ending right before
|
||||||
"""Whether a hook entry's command is one a retired context tool registered."""
|
# one of these (or at end-of-string) sits at a real word boundary. Quotes,
|
||||||
|
# `=`/`:` (`export PATH="<dir>:$PATH"`, `BIN=<dir>/x`), `;`/`,`/`|` (command
|
||||||
|
# joiners) and `()` (subshells) all end a path the same way whitespace does.
|
||||||
|
_PATH_BOUNDARY_CHARS = frozenset("\"'=:;,()|")
|
||||||
|
|
||||||
|
# Splits a command into path-shaped tokens on whitespace plus the same
|
||||||
|
# boundary punctuation above — used by _names_a_managed_script, which (unlike
|
||||||
|
# _references_managed_bin's needle-anchored scan) tokenizes the whole command.
|
||||||
|
_PATH_TOKEN_SPLIT = re.compile(r"[\s" + re.escape("".join(_PATH_BOUNDARY_CHARS)) + r"]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_path_text(value: str) -> str:
|
||||||
|
"""Case-fold ``value`` and give it one separator, so paths compare as text."""
|
||||||
|
return os.path.normcase(value).replace("\\", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _references_managed_bin(text: str) -> bool:
|
||||||
|
"""Whether ``text`` names a path inside Headroom's managed bin directory.
|
||||||
|
|
||||||
|
A hook command or script body is free text we don't control, so this
|
||||||
|
scans ``text`` for raw occurrences of the managed directory — the
|
||||||
|
unresolved and resolved bin directory, and its ``~``-relative form
|
||||||
|
(home-relative, since a script may reference it unexpanded) — matched
|
||||||
|
against the text as given and as ``expanduser``'d, case-folded, with both
|
||||||
|
path separators. Deliberately *not* tokenized on whitespace first: a
|
||||||
|
quoted or ``$HOME``-derived path can itself contain a space, and slicing
|
||||||
|
the text into words before searching would sever it.
|
||||||
|
|
||||||
|
A hit is only a real reference at a path boundary on *both* ends. The
|
||||||
|
character immediately before the match, if any, must be whitespace or a
|
||||||
|
:data:`_PATH_BOUNDARY_CHARS` character, or the match is just the tail of
|
||||||
|
some longer, unrelated path segment (e.g. ``/prefix<bin_dir>/lean-ctx``)
|
||||||
|
and is rejected. The match must then be immediately followed by
|
||||||
|
end-of-string or a :data:`_PATH_BOUNDARY_CHARS` character (an exact
|
||||||
|
reference, e.g. a bare ``PATH=<dir>`` export), or by ``/`` — in which
|
||||||
|
case the run of characters up to the next boundary is lexically
|
||||||
|
normalized (``.``/``..`` collapsed) and re-compared, so neither a sibling
|
||||||
|
directory like ``bin-backup``/``binfoo`` nor a ``bin/../evil`` traversal
|
||||||
|
can borrow the managed prefix.
|
||||||
|
# ponytail: boundary-aware substring scan, not a shell parse — upgrade to
|
||||||
|
# shlex if a command ever embeds a managed path it does not execute.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
resolved_bin_dir = bin_dir.resolve()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
needles = {_norm_path_text(str(bin_dir)), _norm_path_text(str(resolved_bin_dir))}
|
||||||
|
home = Path.home()
|
||||||
|
for base in (bin_dir, resolved_bin_dir):
|
||||||
|
try:
|
||||||
|
needles.add(_norm_path_text(f"~/{base.relative_to(home).as_posix()}"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for haystack in (text, os.path.expanduser(text)):
|
||||||
|
normalized_haystack = _norm_path_text(haystack)
|
||||||
|
if any(_names_managed_dir(normalized_haystack, needle) for needle in needles):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _names_managed_dir(haystack: str, needle: str) -> bool:
|
||||||
|
"""Whether a normalized ``haystack`` names ``needle`` at a path boundary
|
||||||
|
on both ends: the character right before the match, if any, must be
|
||||||
|
whitespace or a :data:`_PATH_BOUNDARY_CHARS` character too, or a
|
||||||
|
user-owned path that merely has the managed directory as a substring
|
||||||
|
(e.g. ``/prefix<bin_dir>/lean-ctx``) would be misread as naming it.
|
||||||
|
"""
|
||||||
|
search_from = 0
|
||||||
|
while True:
|
||||||
|
index = haystack.find(needle, search_from)
|
||||||
|
if index < 0:
|
||||||
|
return False
|
||||||
|
end = index + len(needle)
|
||||||
|
search_from = index + 1 # keep scanning; occurrences may overlap
|
||||||
|
if index > 0 and not (
|
||||||
|
haystack[index - 1].isspace() or haystack[index - 1] in _PATH_BOUNDARY_CHARS
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
following = haystack[end : end + 1]
|
||||||
|
if not following or following.isspace() or following in _PATH_BOUNDARY_CHARS:
|
||||||
|
return True
|
||||||
|
if following != "/":
|
||||||
|
continue
|
||||||
|
tail_end = end
|
||||||
|
while tail_end < len(haystack) and not (
|
||||||
|
haystack[tail_end].isspace() or haystack[tail_end] in _PATH_BOUNDARY_CHARS
|
||||||
|
):
|
||||||
|
tail_end += 1
|
||||||
|
candidate = posixpath.normpath(haystack[index:tail_end])
|
||||||
|
if candidate == needle or candidate.startswith(needle + "/"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_hook_scripts(hooks_dir: Path) -> dict[str, bool | None]:
|
||||||
|
"""Classify each existing ``_HOOK_SCRIPTS`` file by whether it is Headroom's.
|
||||||
|
|
||||||
|
``True`` — the file exists and its body references the managed bin
|
||||||
|
directory. ``False`` — it exists and does not. ``None`` — it exists but
|
||||||
|
could not be read, so its provenance is unprovable. A basename with no
|
||||||
|
file on disk is simply absent from the map. Each file is read at most once.
|
||||||
|
|
||||||
|
``.rtk-hook.sha256`` holds a hex digest that can never reference a path,
|
||||||
|
so it is never read; it inherits ``rtk-rewrite.sh``'s classification.
|
||||||
|
"""
|
||||||
|
classification: dict[str, bool | None] = {}
|
||||||
|
for name in _HOOK_SCRIPTS:
|
||||||
|
if name == _RTK_DIGEST_NAME:
|
||||||
|
continue
|
||||||
|
path = hooks_dir / name
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
body = fsutil.read_text(path)
|
||||||
|
except OSError:
|
||||||
|
classification[name] = None
|
||||||
|
continue
|
||||||
|
classification[name] = _references_managed_bin(body)
|
||||||
|
|
||||||
|
if (hooks_dir / _RTK_DIGEST_NAME).is_file():
|
||||||
|
classification[_RTK_DIGEST_NAME] = classification.get(_RTK_SCRIPT_NAME, False)
|
||||||
|
|
||||||
|
return classification
|
||||||
|
|
||||||
|
|
||||||
|
def _references_context_tool(
|
||||||
|
entry: Any, hooks_dir: Path, hook_classification: dict[str, bool | None]
|
||||||
|
) -> bool:
|
||||||
|
"""Whether a hook entry is one a retired context tool registered.
|
||||||
|
|
||||||
|
A command-marker hit alone is not enough — a user could author a script
|
||||||
|
with a matching name. It must also either name the managed bin directory
|
||||||
|
directly, or name — as a path resolving to that exact file, not merely
|
||||||
|
sharing its basename, so a same-named script of the user's own in a
|
||||||
|
different directory is never caught — a hook script in ``hooks_dir`` the
|
||||||
|
classification map marks ``True`` (see :func:`_names_a_managed_script`).
|
||||||
|
"""
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return False
|
return False
|
||||||
command = str(entry.get("command", "")).lower()
|
command = str(entry.get("command", ""))
|
||||||
return any(marker in command for marker in _HOOK_COMMAND_MARKERS)
|
if not any(marker in command.lower() for marker in _HOOK_COMMAND_MARKERS):
|
||||||
|
return False
|
||||||
|
if _references_managed_bin(command):
|
||||||
|
return True
|
||||||
|
return _names_a_managed_script(command, hooks_dir, hook_classification)
|
||||||
|
|
||||||
|
|
||||||
def _prune_hooks(hooks: Any) -> tuple[Any, bool]:
|
def _names_a_managed_script(
|
||||||
|
command: str, hooks_dir: Path, hook_classification: dict[str, bool | None]
|
||||||
|
) -> bool:
|
||||||
|
"""Whether ``command`` names, by absolute path, a hook script the map marks ``True``.
|
||||||
|
|
||||||
|
A real command is rarely the bare script path: ``bash <script>`` wraps
|
||||||
|
it, a shell often quotes it, and it may carry a redundant ``./`` segment.
|
||||||
|
``command`` is split into path-shaped tokens on the same boundary
|
||||||
|
punctuation :data:`_PATH_BOUNDARY_CHARS` (and whitespace) mark as *not*
|
||||||
|
part of a path, each token is lexically normalized, and compared against
|
||||||
|
the script's absolute path only.
|
||||||
|
|
||||||
|
Deliberately not resolved against ``~``/:func:`Path.home` or against the
|
||||||
|
process's working directory: a *relative* hook command in
|
||||||
|
``~/.claude/settings.json`` is resolved by the harness against the
|
||||||
|
project's cwd, not against home — this module never writes or inspects a
|
||||||
|
project-relative path, so treating one as if it named a home-relative
|
||||||
|
script would delete a project-local hook this purge has no business
|
||||||
|
touching. A relative token, and a ``$VAR``-style unexpanded reference
|
||||||
|
(e.g. ``$HOME/...``), are both simply not recognised — unprovable, so
|
||||||
|
kept, per the guard's own rule.
|
||||||
|
"""
|
||||||
|
tokens = {
|
||||||
|
token
|
||||||
|
for haystack in (command, os.path.expanduser(command))
|
||||||
|
for token in _PATH_TOKEN_SPLIT.split(haystack)
|
||||||
|
if token
|
||||||
|
}
|
||||||
|
normalized_tokens = {posixpath.normpath(_norm_path_text(token)) for token in tokens}
|
||||||
|
return any(
|
||||||
|
verdict is True and _norm_path_text(str(hooks_dir / name)) in normalized_tokens
|
||||||
|
for name, verdict in hook_classification.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_hooks(
|
||||||
|
hooks: Any, hooks_dir: Path, hook_classification: dict[str, bool | None]
|
||||||
|
) -> tuple[Any, bool]:
|
||||||
"""Drop retired-tool entries from a ``hooks`` mapping; return ``(pruned, changed)``.
|
"""Drop retired-tool entries from a ``hooks`` mapping; return ``(pruned, changed)``.
|
||||||
|
|
||||||
Handles both shapes Headroom's installers produced: Claude Code nests
|
Handles both shapes Headroom's installers produced: Claude Code nests
|
||||||
|
|
@ -180,13 +506,17 @@ def _prune_hooks(hooks: Any) -> tuple[Any, bool]:
|
||||||
retained: list[Any] = []
|
retained: list[Any] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
# Cursor shape: the command sits on the entry itself.
|
# Cursor shape: the command sits on the entry itself.
|
||||||
if _references_context_tool(entry):
|
if _references_context_tool(entry, hooks_dir, hook_classification):
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
# Claude shape: a matcher entry holding a list of hooks.
|
# Claude shape: a matcher entry holding a list of hooks.
|
||||||
inner = entry.get("hooks") if isinstance(entry, dict) else None
|
inner = entry.get("hooks") if isinstance(entry, dict) else None
|
||||||
if isinstance(inner, list):
|
if isinstance(inner, list):
|
||||||
kept_inner = [item for item in inner if not _references_context_tool(item)]
|
kept_inner = [
|
||||||
|
item
|
||||||
|
for item in inner
|
||||||
|
if not _references_context_tool(item, hooks_dir, hook_classification)
|
||||||
|
]
|
||||||
if len(kept_inner) != len(inner):
|
if len(kept_inner) != len(inner):
|
||||||
changed = True
|
changed = True
|
||||||
if not kept_inner:
|
if not kept_inner:
|
||||||
|
|
@ -206,7 +536,9 @@ def _prune_hooks(hooks: Any) -> tuple[Any, bool]:
|
||||||
return pruned, changed
|
return pruned, changed
|
||||||
|
|
||||||
|
|
||||||
def _purge_hook_config(path: Path) -> list[str]:
|
def _purge_hook_config(
|
||||||
|
path: Path, hooks_dir: Path, hook_classification: dict[str, bool | None]
|
||||||
|
) -> list[str]:
|
||||||
"""Remove retired-tool hook registrations from a JSON hook config."""
|
"""Remove retired-tool hook registrations from a JSON hook config."""
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return []
|
return []
|
||||||
|
|
@ -217,7 +549,7 @@ def _purge_hook_config(path: Path) -> list[str]:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return [f"skipped {path} (not a JSON object) — remove any stale hook by hand"]
|
return [f"skipped {path} (not a JSON object) — remove any stale hook by hand"]
|
||||||
|
|
||||||
hooks, changed = _prune_hooks(payload.get("hooks"))
|
hooks, changed = _prune_hooks(payload.get("hooks"), hooks_dir, hook_classification)
|
||||||
if not changed:
|
if not changed:
|
||||||
return []
|
return []
|
||||||
if hooks:
|
if hooks:
|
||||||
|
|
@ -236,8 +568,10 @@ def _purge_mcp_entries(path: Path, container_key: str) -> list[str]:
|
||||||
|
|
||||||
``lean-ctx init`` registers lean-ctx as an MCP server in the harness's own
|
``lean-ctx init`` registers lean-ctx as an MCP server in the harness's own
|
||||||
config — Claude Code keeps them under ``mcpServers``, OpenCode under ``mcp``.
|
config — Claude Code keeps them under ``mcpServers``, OpenCode under ``mcp``.
|
||||||
Only the exactly-named entries are removed; every other server, and every
|
A name match alone is not enough — a user can register their own server
|
||||||
unrelated top-level key, is preserved byte-for-byte.
|
under the same name — so an entry is removed only when its ``command``
|
||||||
|
also names Headroom's managed bin directory. Every other server, and
|
||||||
|
every unrelated top-level key, is preserved byte-for-byte.
|
||||||
"""
|
"""
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return []
|
return []
|
||||||
|
|
@ -251,7 +585,13 @@ def _purge_mcp_entries(path: Path, container_key: str) -> list[str]:
|
||||||
servers = payload.get(container_key)
|
servers = payload.get(container_key)
|
||||||
if not isinstance(servers, dict):
|
if not isinstance(servers, dict):
|
||||||
return []
|
return []
|
||||||
removed = [name for name in _MCP_SERVER_NAMES if name in servers]
|
removed = [
|
||||||
|
name
|
||||||
|
for name in _MCP_SERVER_NAMES
|
||||||
|
if isinstance(servers.get(name), dict)
|
||||||
|
and isinstance(servers[name].get("command"), str)
|
||||||
|
and _references_managed_bin(servers[name]["command"])
|
||||||
|
]
|
||||||
if not removed:
|
if not removed:
|
||||||
return []
|
return []
|
||||||
for name in removed:
|
for name in removed:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
@ -142,9 +143,19 @@ def build_manifest(
|
||||||
|
|
||||||
normalized_profile = validate_profile_name(profile)
|
normalized_profile = validate_profile_name(profile)
|
||||||
|
|
||||||
if preset == InstallPreset.PERSISTENT_SERVICE.value:
|
# A Windows service must implement the Service Control Manager protocol.
|
||||||
|
# The Python runner is an ordinary console process, so registering it with
|
||||||
|
# ``sc.exe create`` always fails at start with SCM error 1053. Task
|
||||||
|
# Scheduler can run the same runner safely and already provides startup
|
||||||
|
# plus periodic health recovery, so make it the effective preset on
|
||||||
|
# Windows instead of creating a service that can never start (#2552).
|
||||||
|
effective_preset = preset
|
||||||
|
if sys.platform.startswith("win") and preset == InstallPreset.PERSISTENT_SERVICE.value:
|
||||||
|
effective_preset = InstallPreset.PERSISTENT_TASK.value
|
||||||
|
|
||||||
|
if effective_preset == InstallPreset.PERSISTENT_SERVICE.value:
|
||||||
supervisor_kind = SupervisorKind.SERVICE.value
|
supervisor_kind = SupervisorKind.SERVICE.value
|
||||||
elif preset == InstallPreset.PERSISTENT_TASK.value:
|
elif effective_preset == InstallPreset.PERSISTENT_TASK.value:
|
||||||
supervisor_kind = SupervisorKind.TASK.value
|
supervisor_kind = SupervisorKind.TASK.value
|
||||||
else:
|
else:
|
||||||
supervisor_kind = SupervisorKind.NONE.value
|
supervisor_kind = SupervisorKind.NONE.value
|
||||||
|
|
@ -234,7 +245,7 @@ def build_manifest(
|
||||||
container_name = f"headroom-{normalized_profile}"
|
container_name = f"headroom-{normalized_profile}"
|
||||||
return DeploymentManifest(
|
return DeploymentManifest(
|
||||||
profile=normalized_profile,
|
profile=normalized_profile,
|
||||||
preset=preset,
|
preset=effective_preset,
|
||||||
runtime_kind=runtime_kind,
|
runtime_kind=runtime_kind,
|
||||||
supervisor_kind=supervisor_kind,
|
supervisor_kind=supervisor_kind,
|
||||||
scope=scope,
|
scope=scope,
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,44 @@ def test_install_apply_starts_service_supervisor(monkeypatch) -> None:
|
||||||
assert calls == ["save", "start_service", "apply", "save"]
|
assert calls == ["save", "start_service", "apply", "save"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_apply_announces_windows_service_fallback(monkeypatch) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
class Manifest:
|
||||||
|
profile = "default"
|
||||||
|
preset = "persistent-task"
|
||||||
|
runtime_kind = "python"
|
||||||
|
supervisor_kind = "task"
|
||||||
|
scope = "user"
|
||||||
|
health_url = "http://127.0.0.1:8787/readyz"
|
||||||
|
mutations: list[object] = []
|
||||||
|
targets: list[str] = []
|
||||||
|
artifacts: list[object] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr("headroom.cli.install._is_windows", lambda: True)
|
||||||
|
monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: Manifest())
|
||||||
|
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
|
||||||
|
monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
|
||||||
|
monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None)
|
||||||
|
monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
|
||||||
|
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
||||||
|
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "stopped")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"headroom.cli.install.start_detached_agent", lambda profile: calls.append("start_agent")
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(main, ["install", "apply", "--preset", "persistent-service"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Falling back to persistent-task with Task Scheduler" in result.output
|
||||||
|
assert "sc.exe" not in result.output
|
||||||
|
assert calls == ["start_agent"]
|
||||||
|
|
||||||
|
|
||||||
def test_install_apply_forwards_no_http2_to_build_manifest(monkeypatch) -> None:
|
def test_install_apply_forwards_no_http2_to_build_manifest(monkeypatch) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from unittest.mock import patch
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from headroom import paths
|
||||||
from headroom.cli import wrap as wrap_cli
|
from headroom.cli import wrap as wrap_cli
|
||||||
from headroom.cli.main import main
|
from headroom.cli.main import main
|
||||||
|
|
||||||
|
|
@ -68,8 +69,14 @@ def test_unwrap_claude_removes_mcp_purges_retired_hook_and_stops_proxy(
|
||||||
home = str(tmp_path)
|
home = str(tmp_path)
|
||||||
monkeypatch.setenv("HOME", home)
|
monkeypatch.setenv("HOME", home)
|
||||||
monkeypatch.setenv("USERPROFILE", home)
|
monkeypatch.setenv("USERPROFILE", home)
|
||||||
|
monkeypatch.delenv("HEADROOM_WORKSPACE_DIR", raising=False)
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
claude_dir = tmp_path / ".claude"
|
claude_dir = tmp_path / ".claude"
|
||||||
claude_dir.mkdir()
|
claude_dir.mkdir()
|
||||||
|
hooks_dir = claude_dir / "hooks"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hook_script = hooks_dir / "rtk-rewrite.sh"
|
||||||
|
hook_script.write_text(f'#!/bin/sh\nexec {bin_dir / "rtk"} "$@"\n', encoding="utf-8")
|
||||||
settings = claude_dir / "settings.json"
|
settings = claude_dir / "settings.json"
|
||||||
settings.write_text(
|
settings.write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|
@ -78,9 +85,7 @@ def test_unwrap_claude_removes_mcp_purges_retired_hook_and_stops_proxy(
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "Bash",
|
"matcher": "Bash",
|
||||||
"hooks": [
|
"hooks": [{"type": "command", "command": str(hook_script)}],
|
||||||
{"type": "command", "command": str(claude_dir / "rtk-rewrite.sh")}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ else.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -37,6 +39,19 @@ def _write(path, content):
|
||||||
|
|
||||||
|
|
||||||
def test_removes_hooks_for_both_tools_but_keeps_user_hooks(home):
|
def test_removes_hooks_for_both_tools_but_keeps_user_hooks(home):
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
hooks_dir = home / ".claude" / "hooks"
|
||||||
|
# Managed: a script whose body execs the Headroom-installed binary.
|
||||||
|
managed_script = _write(
|
||||||
|
hooks_dir / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n',
|
||||||
|
)
|
||||||
|
# User-owned: same marker-matching filename, but the body execs the
|
||||||
|
# user's own install — no path inside bin_dir anywhere.
|
||||||
|
user_script = _write(
|
||||||
|
hooks_dir / "lean-ctx-redirect.sh",
|
||||||
|
'#!/bin/sh\nexec /usr/bin/lean-ctx "$@"\n',
|
||||||
|
)
|
||||||
settings = _write(
|
settings = _write(
|
||||||
home / ".claude" / "settings.json",
|
home / ".claude" / "settings.json",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|
@ -44,12 +59,16 @@ def test_removes_hooks_for_both_tools_but_keeps_user_hooks(home):
|
||||||
"permissions": {"allow": ["Bash"]},
|
"permissions": {"allow": ["Bash"]},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
|
{"hooks": [{"type": "command", "command": str(managed_script)}]},
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{"type": "command", "command": "~/.claude/hooks/rtk-rewrite.sh"}
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": f"{bin_dir / 'lean-ctx'} hook rewrite",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{"hooks": [{"type": "command", "command": "lean-ctx hook rewrite"}]},
|
{"hooks": [{"type": "command", "command": str(user_script)}]},
|
||||||
{"hooks": [{"type": "command", "command": "my-own-linter --check"}]},
|
{"hooks": [{"type": "command", "command": "my-own-linter --check"}]},
|
||||||
],
|
],
|
||||||
"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}],
|
"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}],
|
||||||
|
|
@ -64,7 +83,7 @@ def test_removes_hooks_for_both_tools_but_keeps_user_hooks(home):
|
||||||
commands = [
|
commands = [
|
||||||
item["command"] for entry in payload["hooks"]["PreToolUse"] for item in entry["hooks"]
|
item["command"] for entry in payload["hooks"]["PreToolUse"] for item in entry["hooks"]
|
||||||
]
|
]
|
||||||
assert commands == ["my-own-linter --check"]
|
assert commands == [str(user_script), "my-own-linter --check"]
|
||||||
# Unrelated events and unrelated top-level keys survive untouched.
|
# Unrelated events and unrelated top-level keys survive untouched.
|
||||||
assert payload["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "echo hi"
|
assert payload["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "echo hi"
|
||||||
assert payload["permissions"] == {"allow": ["Bash"]}
|
assert payload["permissions"] == {"allow": ["Bash"]}
|
||||||
|
|
@ -72,11 +91,21 @@ def test_removes_hooks_for_both_tools_but_keeps_user_hooks(home):
|
||||||
|
|
||||||
|
|
||||||
def test_removes_binaries_hook_scripts_and_backups(home):
|
def test_removes_binaries_hook_scripts_and_backups(home):
|
||||||
bin_dir = home / ".headroom" / "bin"
|
bin_dir = paths.bin_dir()
|
||||||
|
hooks_dir = home / ".claude" / "hooks"
|
||||||
rtk = _write(bin_dir / "rtk", "binary")
|
rtk = _write(bin_dir / "rtk", "binary")
|
||||||
lean = _write(bin_dir / "lean-ctx", "binary")
|
lean = _write(bin_dir / "lean-ctx", "binary")
|
||||||
script = _write(home / ".claude" / "hooks" / "lean-ctx-rewrite.sh", "#!/bin/sh\n")
|
script = _write(
|
||||||
backup = _write(home / ".claude" / "hooks" / "lean-ctx-rewrite.sh.lean-ctx.bak", "#!/bin/sh\n")
|
hooks_dir / "lean-ctx-rewrite.sh", f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n'
|
||||||
|
)
|
||||||
|
backup = _write(
|
||||||
|
hooks_dir / "lean-ctx-rewrite.sh.lean-ctx.bak",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n',
|
||||||
|
)
|
||||||
|
managed_rtk_script = _write(
|
||||||
|
hooks_dir / "rtk-rewrite.sh", f'#!/bin/sh\nexec {bin_dir / "rtk"} "$@"\n'
|
||||||
|
)
|
||||||
|
managed_rtk_digest = _write(hooks_dir / ".rtk-hook.sha256", "deadbeef\n")
|
||||||
|
|
||||||
context_tool_cleanup.purge_context_tool_artifacts()
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
|
@ -84,6 +113,22 @@ def test_removes_binaries_hook_scripts_and_backups(home):
|
||||||
assert not lean.exists()
|
assert not lean.exists()
|
||||||
assert not script.exists()
|
assert not script.exists()
|
||||||
assert not backup.exists()
|
assert not backup.exists()
|
||||||
|
# .rtk-hook.sha256 follows rtk-rewrite.sh's classification: both managed,
|
||||||
|
# both removed.
|
||||||
|
assert not managed_rtk_script.exists()
|
||||||
|
assert not managed_rtk_digest.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_users_own_rtk_digest_alone(home):
|
||||||
|
"""The digest is a hex hash, so it can only follow the script it authenticates."""
|
||||||
|
hooks_dir = home / ".claude" / "hooks"
|
||||||
|
script = _write(hooks_dir / "rtk-rewrite.sh", '#!/bin/sh\nexec /usr/bin/rtk "$@"\n')
|
||||||
|
digest = _write(hooks_dir / ".rtk-hook.sha256", "cafef00d\n")
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert script.exists()
|
||||||
|
assert digest.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_leaves_a_users_own_binary_on_path_alone(home):
|
def test_leaves_a_users_own_binary_on_path_alone(home):
|
||||||
|
|
@ -100,13 +145,14 @@ def test_leaves_a_users_own_binary_on_path_alone(home):
|
||||||
|
|
||||||
|
|
||||||
def test_removes_mcp_entry_and_preserves_siblings(home):
|
def test_removes_mcp_entry_and_preserves_siblings(home):
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
config = _write(
|
config = _write(
|
||||||
home / ".claude.json",
|
home / ".claude.json",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"projects": {"/some/path": {"history": []}},
|
"projects": {"/some/path": {"history": []}},
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"lean-ctx": {"command": "lean-ctx", "args": ["mcp"]},
|
"lean-ctx": {"command": str(bin_dir / "lean-ctx"), "args": ["mcp"]},
|
||||||
"headroom": {"command": "headroom", "args": ["mcp"]},
|
"headroom": {"command": "headroom", "args": ["mcp"]},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -146,13 +192,25 @@ def test_skips_malformed_json_instead_of_clobbering_it(home):
|
||||||
|
|
||||||
|
|
||||||
def test_is_idempotent(home):
|
def test_is_idempotent(home):
|
||||||
_write(home / ".headroom" / "bin" / "rtk", "binary")
|
"""Re-running the purge body reports nothing new.
|
||||||
|
|
||||||
|
Normally the completion stamp stops a second run, but a workspace the
|
||||||
|
stamp cannot be written to falls back to running every time — so the body
|
||||||
|
itself has to stay idempotent. Removing the stamp between runs is what
|
||||||
|
that machine does.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
_write(bin_dir / "rtk", "binary")
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "rtk-rewrite.sh", f'#!/bin/sh\nexec {bin_dir / "rtk"} "$@"\n'
|
||||||
|
)
|
||||||
_write(
|
_write(
|
||||||
home / ".claude" / "settings.json",
|
home / ".claude" / "settings.json",
|
||||||
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": "rtk rewrite"}]}]}}),
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert context_tool_cleanup.purge_context_tool_artifacts()
|
assert context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
(bin_dir.parent / ".context-tools-purged").unlink()
|
||||||
# Steady state after the first run: nothing left to report.
|
# Steady state after the first run: nothing left to report.
|
||||||
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
||||||
|
|
||||||
|
|
@ -161,6 +219,98 @@ def test_no_op_on_a_clean_machine(home):
|
||||||
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_completed_purge_never_runs_again(home):
|
||||||
|
"""Machine-global artifacts stay stamped-done: no per-launch re-audit.
|
||||||
|
|
||||||
|
A tool installed under the *global* half (hooks, binaries) after the
|
||||||
|
migration is not a leftover, so a later run must leave it alone without
|
||||||
|
even looking — this is what stops `headroom wrap` from re-litigating
|
||||||
|
machine-global state on every launch, forever. Project- and config-
|
||||||
|
directory-scoped guidance is a different story: see
|
||||||
|
`test_a_completed_purge_still_cleans_a_different_project`.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
bin_dir.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
||||||
|
assert (bin_dir.parent / ".context-tools-purged").exists()
|
||||||
|
|
||||||
|
# Artifacts that would otherwise be removed, installed after the migration.
|
||||||
|
binary = _write(bin_dir / "lean-ctx", "binary")
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
||||||
|
assert binary.exists()
|
||||||
|
assert script.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_completed_purge_still_cleans_a_different_project(home, monkeypatch):
|
||||||
|
"""The one-time stamp is machine-global; project guidance is not.
|
||||||
|
|
||||||
|
A completed run in project A must not leave project B's fenced guidance in
|
||||||
|
place forever — the stamp only ever covered a snapshot of ``Path.cwd()``.
|
||||||
|
"""
|
||||||
|
paths.bin_dir().parent.mkdir(parents=True)
|
||||||
|
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
||||||
|
assert (paths.bin_dir().parent / ".context-tools-purged").exists()
|
||||||
|
|
||||||
|
project_b = home / "project-b"
|
||||||
|
project_b.mkdir()
|
||||||
|
agents = _write(
|
||||||
|
project_b / "AGENTS.md",
|
||||||
|
"# Project B\n\n<!-- headroom:rtk-instructions -->\nAlways prefix with rtk.\n"
|
||||||
|
"<!-- /headroom:rtk-instructions -->\n",
|
||||||
|
)
|
||||||
|
monkeypatch.chdir(project_b)
|
||||||
|
|
||||||
|
report = context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert "rtk" not in agents.read_text()
|
||||||
|
assert any(str(agents) in line for line in report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_completed_purge_still_inspects_a_repointed_codex_home(home, monkeypatch):
|
||||||
|
"""``CODEX_HOME`` can point somewhere new after the stamp; that target is not exempt."""
|
||||||
|
paths.bin_dir().parent.mkdir(parents=True)
|
||||||
|
assert context_tool_cleanup.purge_context_tool_artifacts() == []
|
||||||
|
assert (paths.bin_dir().parent / ".context-tools-purged").exists()
|
||||||
|
|
||||||
|
new_codex_home = home / "elsewhere-codex"
|
||||||
|
new_codex_home.mkdir()
|
||||||
|
agents = _write(
|
||||||
|
new_codex_home / "AGENTS.md",
|
||||||
|
"<!-- headroom:rtk-instructions -->\nAlways prefix with rtk.\n"
|
||||||
|
"<!-- /headroom:rtk-instructions -->\n",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("CODEX_HOME", str(new_codex_home))
|
||||||
|
|
||||||
|
report = context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
# Nothing but the fence was in the file, so it is removed outright.
|
||||||
|
assert not agents.exists()
|
||||||
|
assert any(str(agents) in line for line in report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scoped_deferral_does_not_withhold_the_global_stamp(home):
|
||||||
|
"""A scoped step's leftover must not re-run the whole global half forever.
|
||||||
|
|
||||||
|
Only the invocation-scoped half is unprovable here (malformed Continue
|
||||||
|
config); the machine-global half has nothing to defer, so its stamp must
|
||||||
|
still be written — otherwise a permanently-broken ``.continue/config.json``
|
||||||
|
would force every hook/binary/MCP step to be re-walked on every launch.
|
||||||
|
"""
|
||||||
|
paths.bin_dir().parent.mkdir(parents=True)
|
||||||
|
_write(home / "project" / ".continue" / "config.json", "{not json")
|
||||||
|
|
||||||
|
report = context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert any(line.startswith("skipped ") for line in report)
|
||||||
|
assert (paths.bin_dir().parent / ".context-tools-purged").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_purge_reports_on_stderr_so_json_stdout_stays_parseable(home):
|
def test_purge_reports_on_stderr_so_json_stdout_stays_parseable(home):
|
||||||
"""`wrap openclaw --prepare-only` emits machine-readable JSON as its whole contract.
|
"""`wrap openclaw --prepare-only` emits machine-readable JSON as its whole contract.
|
||||||
|
|
||||||
|
|
@ -216,3 +366,442 @@ def test_selfheal_does_not_purge(home, monkeypatch):
|
||||||
CliRunner().invoke(main, ["wrap", "selfheal", "--marker", "headroom-wrap-selfheal"])
|
CliRunner().invoke(main, ["wrap", "selfheal", "--marker", "headroom-wrap-selfheal"])
|
||||||
|
|
||||||
assert binary.exists(), "selfheal performed filesystem cleanup"
|
assert binary.exists(), "selfheal performed filesystem cleanup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_users_own_mcp_entry_alone(home):
|
||||||
|
config = _write(
|
||||||
|
home / ".claude.json",
|
||||||
|
json.dumps({"mcpServers": {"lean-ctx": {"command": "lean-ctx", "args": ["mcp"]}}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
payload = json.loads(config.read_text())
|
||||||
|
assert payload["mcpServers"] == {"lean-ctx": {"command": "lean-ctx", "args": ["mcp"]}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_users_own_hook_script_and_hook_entry_alone(home):
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
'#!/bin/sh\nexec /usr/bin/lean-ctx "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == str(script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_removes_the_managed_hook_script_and_its_hook_entry(home):
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert not script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert "hooks" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_removes_a_hook_entry_pointing_at_the_managed_binary_directly(home):
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": f"{bin_dir / 'lean-ctx'} hook rewrite",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert "hooks" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_a_managed_path_written_in_tilde_form(home):
|
||||||
|
"""The dangling-hook regression test: bin_dir is <tmp>/.headroom/bin, and the
|
||||||
|
script references it in unexpanded tilde form — the guard must normalize
|
||||||
|
both sides before comparing, or it wrongly treats this as unprovable and
|
||||||
|
leaves a hook pointing at a script Headroom itself no longer manages.
|
||||||
|
"""
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
'#!/bin/sh\nexec ~/.headroom/bin/lean-ctx "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert not script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert "hooks" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_hook_script_in_a_sibling_bin_named_directory_alone(home):
|
||||||
|
"""A directory that merely starts with the bin dir's name is not the bin dir.
|
||||||
|
|
||||||
|
``<workspace>/.headroom/binaries`` shares a prefix with
|
||||||
|
``<workspace>/.headroom/bin`` but is a different, user-owned directory —
|
||||||
|
a naive substring match (no directory-boundary check) would treat the
|
||||||
|
shared prefix as a reference to the managed bin dir and wrongly delete
|
||||||
|
this script.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
sibling = bin_dir.parent / "binaries"
|
||||||
|
own_binary = _write(sibling / "lean-ctx", "my own build")
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {own_binary} "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == str(script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_an_mcp_entry_in_a_sibling_bin_named_directory_alone(home):
|
||||||
|
"""Same sibling-directory hazard as above, for the MCP-entry command check."""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
sibling_command = str(bin_dir.parent / "bin-backup" / "lean-ctx")
|
||||||
|
config = _write(
|
||||||
|
home / ".claude.json",
|
||||||
|
json.dumps({"mcpServers": {"lean-ctx": {"command": sibling_command, "args": ["mcp"]}}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
payload = json.loads(config.read_text())
|
||||||
|
assert payload["mcpServers"] == {"lean-ctx": {"command": sibling_command, "args": ["mcp"]}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_parent_traversal_path_through_the_bin_dir_alone(home):
|
||||||
|
"""``bin/../evil`` contains the managed prefix as literal text but does not
|
||||||
|
resolve inside it — the guard must collapse ``..`` before comparing, or a
|
||||||
|
crafted (or coincidental) traversal path would be treated as Headroom's.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
evil_binary = _write(bin_dir.parent / "evil" / "lean-ctx", "not ours")
|
||||||
|
traversal_command = f"{bin_dir}/../evil/lean-ctx"
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {traversal_command} "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert script.exists()
|
||||||
|
assert evil_binary.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == str(script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_hook_script_whose_body_has_the_bin_dir_as_a_path_segment_alone(home):
|
||||||
|
"""``/prefix<bin_dir>/lean-ctx`` contains the managed prefix as literal text
|
||||||
|
but at a position with no boundary before it — the run of characters
|
||||||
|
immediately preceding the match is a filename character (``x``), not
|
||||||
|
whitespace or a :data:`_PATH_BOUNDARY_CHARS` character, so this names a
|
||||||
|
different, user-owned directory that only happens to end in the managed
|
||||||
|
path's tail. Only checking the trailing boundary (the pre-fix behavior)
|
||||||
|
would misclassify this as Headroom's and delete the user's script.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
lookalike = f"/prefix{bin_dir}/lean-ctx"
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {lookalike} "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == str(script)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_an_mcp_entry_whose_command_has_the_bin_dir_as_a_path_segment_alone(home):
|
||||||
|
"""Same leading-boundary hazard as above, for the MCP-entry command check."""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
lookalike_command = f"/prefix{bin_dir}/lean-ctx"
|
||||||
|
config = _write(
|
||||||
|
home / ".claude.json",
|
||||||
|
json.dumps({"mcpServers": {"lean-ctx": {"command": lookalike_command, "args": ["mcp"]}}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
payload = json.loads(config.read_text())
|
||||||
|
assert payload["mcpServers"] == {"lean-ctx": {"command": lookalike_command, "args": ["mcp"]}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_hook_entry_whose_command_has_the_bin_dir_as_a_path_segment_alone(home):
|
||||||
|
"""Same hazard for a hook entry whose ``command`` names the managed
|
||||||
|
directory directly (no script indirection). The command still carries a
|
||||||
|
``_HOOK_COMMAND_MARKERS`` token (``lean-ctx hook``) so it reaches the
|
||||||
|
provenance guard rather than being filtered out earlier.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
lookalike_command = f"/prefix{bin_dir}/lean-ctx hook rewrite"
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [{"hooks": [{"type": "command", "command": lookalike_command}]}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == lookalike_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_removes_a_hook_script_whose_body_has_a_lookalike_prefix_before_a_genuine_managed_path(
|
||||||
|
home,
|
||||||
|
):
|
||||||
|
"""A body can contain both a rejected lookalike occurrence and a later
|
||||||
|
genuine, boundary-correct occurrence of the managed path. Rejecting the
|
||||||
|
first must not short-circuit the scan (``continue``, not
|
||||||
|
``return False``) or the genuine occurrence right after it would never
|
||||||
|
be seen.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
lookalike = f"/prefix{bin_dir}/lean-ctx-fake"
|
||||||
|
genuine = str(bin_dir / "lean-ctx")
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {lookalike} --check\nexec {genuine} "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert not script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert "hooks" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_removes_a_hook_script_whose_body_names_the_managed_dir_only_via_quoting_or_delimiters(
|
||||||
|
home,
|
||||||
|
):
|
||||||
|
"""Real shell scripts quote paths and join them with `=`/`:`/`()`, not bare
|
||||||
|
whitespace. The guard must not miss the managed directory just because it
|
||||||
|
sits inside a quoted string, a `VAR=` assignment, a `PATH=...:` join, or a
|
||||||
|
subshell — a naive whitespace-token split would sever every one of these
|
||||||
|
(and the quote/paren characters would still be glued onto the token).
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
f'exec "{bin_dir}/lean-ctx" "$@"\n'
|
||||||
|
f"# or: exec '{bin_dir}/lean-ctx'\n"
|
||||||
|
f'export PATH="{bin_dir}:$PATH"\n'
|
||||||
|
f"BIN={bin_dir}/lean-ctx\n"
|
||||||
|
f"({bin_dir}/lean-ctx)\n",
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert not script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert "hooks" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_a_managed_path_when_home_contains_a_space(home, monkeypatch):
|
||||||
|
"""A ``$HOME`` with a space is real, and yields a bin dir with a literal
|
||||||
|
space inside it. Splitting a script's body on whitespace before searching
|
||||||
|
would sever the path at that space and miss it entirely.
|
||||||
|
"""
|
||||||
|
bin_dir = home / "space here" / ".headroom" / "bin"
|
||||||
|
monkeypatch.setattr(paths, "bin_dir", lambda: bin_dir)
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir}/lean-ctx "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert not script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert "hooks" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_a_same_named_hook_script_in_a_different_directory_alone(home):
|
||||||
|
"""A hook entry's command must name the exact managed script in
|
||||||
|
``~/.claude/hooks`` — not merely share a basename with one. A user's own
|
||||||
|
``~/mytools/lean-ctx-rewrite.sh`` must never inherit the classification of
|
||||||
|
Headroom's ``~/.claude/hooks/lean-ctx-rewrite.sh`` just because the
|
||||||
|
filename matches — but a wrapper invocation (``bash <script>``), a quoted
|
||||||
|
command, or a redundant ``./`` segment naming the *managed* script by
|
||||||
|
absolute path must still be recognised, or the entry survives while step
|
||||||
|
2 deletes the very script it names (the same class of stale, silently
|
||||||
|
no-op hook the rtk case documents as an accepted limitation, not one to
|
||||||
|
introduce here).
|
||||||
|
|
||||||
|
A *relative* command (``.claude/hooks/lean-ctx-rewrite.sh``) must survive
|
||||||
|
even though it shares wording with the managed script's home-relative
|
||||||
|
form: a relative hook command is resolved by the harness against the
|
||||||
|
project's cwd, never against home, so it names a project-local script
|
||||||
|
this purge never inspects — treating it as a home-relative reference
|
||||||
|
would delete a different file than the one the guard just proved nothing
|
||||||
|
about.
|
||||||
|
"""
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
hooks_dir = home / ".claude" / "hooks"
|
||||||
|
# The managed script that gives "lean-ctx-rewrite.sh" a True verdict.
|
||||||
|
managed_script = _write(
|
||||||
|
hooks_dir / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n',
|
||||||
|
)
|
||||||
|
# The user's own script: same basename, different directory, own binary.
|
||||||
|
user_script = _write(
|
||||||
|
home / "mytools" / "lean-ctx-rewrite.sh",
|
||||||
|
'#!/bin/sh\nexec /usr/bin/lean-ctx "$@"\n',
|
||||||
|
)
|
||||||
|
relative_command = ".claude/hooks/lean-ctx-rewrite.sh"
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{"hooks": [{"command": f"bash {managed_script}"}]},
|
||||||
|
{"hooks": [{"command": f'"{managed_script}"'}]},
|
||||||
|
{"hooks": [{"command": f"{hooks_dir}/./lean-ctx-rewrite.sh"}]},
|
||||||
|
{"hooks": [{"command": relative_command}]},
|
||||||
|
{"hooks": [{"command": str(user_script)}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert user_script.exists()
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
commands = [
|
||||||
|
item["command"] for entry in payload["hooks"]["PreToolUse"] for item in entry["hooks"]
|
||||||
|
]
|
||||||
|
assert commands == [relative_command, str(user_script)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_an_unreadable_hook_script_instead_of_guessing(home):
|
||||||
|
if sys.platform.startswith("win") or os.geteuid() == 0:
|
||||||
|
pytest.skip("chmod 0o000 does not deny access on Windows or when running as root")
|
||||||
|
|
||||||
|
bin_dir = paths.bin_dir()
|
||||||
|
script = _write(
|
||||||
|
home / ".claude" / "hooks" / "lean-ctx-rewrite.sh",
|
||||||
|
f'#!/bin/sh\nexec {bin_dir / "lean-ctx"} "$@"\n',
|
||||||
|
)
|
||||||
|
settings = _write(
|
||||||
|
home / ".claude" / "settings.json",
|
||||||
|
json.dumps({"hooks": {"PreToolUse": [{"hooks": [{"command": str(script)}]}]}}),
|
||||||
|
)
|
||||||
|
script.chmod(0o000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
finally:
|
||||||
|
if script.exists():
|
||||||
|
script.chmod(0o644)
|
||||||
|
|
||||||
|
# Unprovable, so kept — not deleted on a guess — but named in the report.
|
||||||
|
assert script.exists()
|
||||||
|
assert any(str(script) in line for line in report)
|
||||||
|
payload = json.loads(settings.read_text())
|
||||||
|
assert payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == str(script)
|
||||||
|
# A run that could not decide is not the completed migration: leaving the
|
||||||
|
# stamp off is what gets this script looked at again once it is readable.
|
||||||
|
assert not (paths.bin_dir().parent / ".context-tools-purged").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unparseable_config_defers_the_migration_stamp(home):
|
||||||
|
"""A config the user must fix by hand still holds a leftover.
|
||||||
|
|
||||||
|
Stamping the migration complete here would retire the only reminder they
|
||||||
|
get, and the entry would never be cleaned once the typo is fixed.
|
||||||
|
"""
|
||||||
|
_write(home / ".claude" / "settings.json", "{not json")
|
||||||
|
|
||||||
|
report = context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
assert any(line.startswith("skipped ") for line in report)
|
||||||
|
assert not (paths.bin_dir().parent / ".context-tools-purged").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_an_mcp_entry_without_a_command_alone(home):
|
||||||
|
config = _write(
|
||||||
|
home / ".claude.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lean-ctx": {"args": ["mcp"]},
|
||||||
|
"rtk": "not-a-dict",
|
||||||
|
"lean_ctx": {"command": ["lean-ctx", "mcp"]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
context_tool_cleanup.purge_context_tool_artifacts()
|
||||||
|
|
||||||
|
payload = json.loads(config.read_text())
|
||||||
|
assert set(payload["mcpServers"]) == {"lean-ctx", "rtk", "lean_ctx"}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,30 @@ def test_build_manifest_python_runtime_keeps_explicit_memory_db_path() -> None:
|
||||||
assert "--memory-db-path" in manifest.proxy_args
|
assert "--memory-db-path" in manifest.proxy_args
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_manifest_falls_back_from_windows_service_to_task(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("headroom.install.planner.sys.platform", "win32")
|
||||||
|
|
||||||
|
manifest = build_manifest(
|
||||||
|
profile="default",
|
||||||
|
preset=InstallPreset.PERSISTENT_SERVICE.value,
|
||||||
|
runtime_kind="python",
|
||||||
|
scope="user",
|
||||||
|
provider_mode="manual",
|
||||||
|
targets=["claude"],
|
||||||
|
port=8787,
|
||||||
|
backend="anthropic",
|
||||||
|
anyllm_provider=None,
|
||||||
|
region=None,
|
||||||
|
proxy_mode="token",
|
||||||
|
memory_enabled=False,
|
||||||
|
telemetry_enabled=False,
|
||||||
|
image="ghcr.io/headroomlabs-ai/headroom:latest",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manifest.preset == InstallPreset.PERSISTENT_TASK.value
|
||||||
|
assert manifest.supervisor_kind == "task"
|
||||||
|
|
||||||
|
|
||||||
def test_build_manifest_uses_provider_slice_env_builders_for_all_supported_targets() -> None:
|
def test_build_manifest_uses_provider_slice_env_builders_for_all_supported_targets() -> None:
|
||||||
manifest = build_manifest(
|
manifest = build_manifest(
|
||||||
profile="default",
|
profile="default",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue