Skip to content

[HIGH] wrap selfheal hook is uninstallable: wrong binary, nonexistent path, safety net inert #16

Description

@smsmatt

Summary

headroom wrap claude installs a SessionStart self-heal hook that cannot ever run. The command it writes names the wrong binary for the subcommand, and on this machine also names a path that does not exist. Two independent defects in one generated line.

Found live on 2026-07-26 while wiring the proxy for real use.

What got written

headroom wrap claude created .claude/settings.local.json in the repo working tree:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/claude wrap selfheal --marker headroom-wrap-selfheal",
            "timeout": 10
          }
        ]
      }
    ]
  },
  "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787" }
}

Defect 1: the path does not exist

/bin/sh: 1: /usr/bin/claude: not found

The real binary on this host is /config/.local/bin/claude (a symlink to Claude Code 2.1.220). Nothing is installed at /usr/bin/claude. Claude Code reported this as a non-blocking hook failure at every session start.

Defect 2: wrong binary entirely, which the path fix exposes

Correcting the path is not enough. Running the fixed command:

$ /config/.local/bin/claude wrap selfheal --marker headroom-wrap-selfheal
error: unknown option '--marker'
(Did you mean --bare?)

wrap selfheal is a headroom subcommand, not a claude one. The generated hook points a Claude Code binary at a Headroom command. Even on a host where /usr/bin/claude does exist, this hook could never have worked.

The correct form resolves cleanly:

$ /path/to/.venv/bin/headroom wrap selfheal --marker headroom-wrap-selfheal
$ echo $?
0

Why this matters more than a noisy log line

The hook is not cosmetic. Per its own docstring at headroom/cli/wrap.py:1331-1345, _selfheal_dead_wrap_base_url() exists to clear a project-local ANTHROPIC_BASE_URL left pointing at a dead proxy:

When wrap claude persists ANTHROPIC_BASE_URL=<proxy> into .claude/settings.local.json and the proxy later dies via hard reboot / SIGKILL, no signal/atexit cleanup fires, so the stale URL lingers and bricks a later bare claude with ConnectionRefused.

So the failure mode is exactly the one the hook was written to prevent. wrap claude persists a base URL into the repo, the hook that would clean it up cannot run, and after any hard proxy death every subsequent claude invocation in that directory fails with ConnectionRefused until someone finds and edits the file by hand. The safety net is installed and inert.

Where it comes from

  • headroom/cli/wrap.py:1357 _wrap_selfheal_hook_command() builds the string from resolve_headroom_command().
  • headroom/install/runtime.py:69-75 resolve_headroom_command() returns shutil.which("headroom"), falling back to [sys.executable, "-m", "headroom.cli"].

Neither of those should be able to yield /usr/bin/claude. Verified on this host:

lookup result
shutil.which("headroom") inside uv run <repo>/.venv/bin/headroom
command -v headroom /usr/local/bin/headroom/opt/uv-tools/headroom-ai/bin/headroom
command -v claude /config/.local/bin/claude
/usr/bin/headroom does not exist
/usr/bin/claude does not exist

So the written value matches no resolver output available here. Either another code path writes this hook, or the value is inherited from an earlier install and rewritten without re-resolution. Root cause is not fully established and is the first thing to pin down.

Acceptance criteria

  • Identify the code path that produced /usr/bin/claude; the two resolvers above cannot account for it.
  • The generated hook invokes the headroom binary, never the claude binary.
  • The binary path is resolved at write time and verified to exist; refuse to install the hook rather than write one that cannot run.
  • A test asserts the generated command's argv[0] resolves to an existing executable AND that executable accepts wrap selfheal --marker.
  • Existing broken hooks are repaired or removed on the next wrap, not left behind.
  • Verified by running the generated command directly and checking exit status, not by inspecting the string.

Workaround applied locally

.claude/settings.local.json hand-edited to point at <repo>/.venv/bin/headroom. Confirmed exit 0. Not committed; the file is untracked.

Related

Separate concern, same file, worth its own issue: wrap claude writes env.ANTHROPIC_BASE_URL into a repo-scoped .claude/settings.local.json without prompting, so every later Claude Code session started in that directory silently routes through the proxy. That is a durable, directory-scoped change to a working tree made as a side effect of one command.

Found while remediating #15.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions