fix(install): stop duplicating ENTRYPOINT in persistent-docker runtime command (#833) (#1348)

## Description

`headroom install apply --preset persistent-docker` pulls the image,
starts the container, then fails after ~45s with "Deployment 'default'
did not become ready after start." The rollback removes the container
and manifest, leaving nothing running and no logs.

Root cause: the published image already bakes the proxy invocation into
its ENTRYPOINT (`Dockerfile`: `ENTRYPOINT ["headroom", "proxy"]`), but
`build_runtime_command()` in `headroom/install/runtime.py` re-added
`headroom proxy` after the image name. Docker concatenates ENTRYPOINT +
args, so the container ran `headroom proxy headroom proxy --host 0.0.0.0
...` and Click aborted with `Got unexpected extra arguments (headroom
proxy)`.

The runtime command now appends only the proxy flags after the image
name, substituting the all-interface container bind host for the host
pair carried in `proxy_args`.

Closes #833

## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/install/runtime.py`: drop the duplicated `headroom proxy`
from the docker `build_runtime_command` output; append only `--host
<bind> *proxy_args[2:]`. Extracted `CONTAINER_BIND_HOST` and
`_PROXY_ARGS_HOST_PAIR_LEN` named constants.
- `tests/test_install/test_runtime.py`: new regression test asserting
the args appended after the image name never re-add the `headroom proxy`
ENTRYPOINT.
- `CHANGELOG.md`: Unreleased → Bug Fixes entry.

## 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/ -q
91 passed, 1 skipped in 5.48s

$ uv run ruff check headroom/install/runtime.py tests/test_install/test_runtime.py
All checks passed!

$ uv run mypy headroom/install/runtime.py
Success: no issues found in 1 source file
```

#### RED → GREEN proof

RED — new test with the prod fix reverted (test kept):

```text
E   AssertionError: container args re-add the ENTRYPOINT — got
    ['headroom', 'proxy', '--host', '0.0.0.0', '--port', '8787', '--backend', 'anthropic']
FAILED tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_does_not_duplicate_entrypoint
1 failed in 0.17s
```

GREEN — with the fix applied:

```text
tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_does_not_duplicate_entrypoint
1 passed in 0.11s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, headroom @ this branch.
- Exact command / steps: reproduce the exact concatenation Docker
performs (ENTRYPOINT `headroom proxy` + the buggy CMD `headroom proxy
--host 0.0.0.0 --port 8787`):

  ```text
  $ headroom proxy headroom proxy --host 0.0.0.0 --port 8787
  Usage: headroom proxy [OPTIONS]
  Try 'headroom proxy --help' for help.
  Error: Got unexpected extra arguments (headroom proxy)
  ```

This is the exact error from the issue. After the fix,
`build_runtime_command` appends only the flags after the image name:

  ```text
args after image: ['--host', '0.0.0.0', '--port', '8787', '--backend',
'anthropic']
  ```

so the container runs `headroom proxy --host 0.0.0.0 --port 8787
--backend anthropic` (ENTRYPOINT + flags) and Click accepts it.
- Observed result: pre-fix Click aborts with the unexpected-arguments
error (container crash-loops); post-fix the command line is valid.
- Not tested: pulling and running the real `ghcr.io` image end-to-end
(requires the published image + Docker host); the failure is fully
determined by the generated argv, which is covered above and by the unit
test.

## 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 have updated the CHANGELOG.md if applicable

## Additional Notes

Scope is limited to the docker runtime command construction. The Python
(`runtime_kind=python`) path was already correct and is unchanged.
Screenshots N/A (CLI-only change).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Younes 2026-06-24 17:13:38 +02:00 committed by GitHub
parent 0c9b42a919
commit feedead077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 4 deletions

View file

@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)).
* **code:** keep Python `from __future__` imports before executable code during AST compression and validate compressed Python with `compile(..., "exec")` so compile-time syntax rules are enforced ([#1233](https://github.com/chopratejas/headroom/issues/1233)).
* **proxy:** report real input tokens on the streaming `message_start` event for LiteLLM/Bedrock-backed requests. LiteLLM streaming never surfaces prompt tokens mid-stream, so `message_start.usage.input_tokens` was always `0`; Anthropic clients (e.g. Claude Code) read input-token metrics from that event, underreporting token usage by ~99% in OTel/CloudWatch dashboards. The Bedrock streamer now backfills `input_tokens` with the count Headroom actually sent upstream when the backend leaves it unset, preserving any non-zero value the backend genuinely reports ([#1132](https://github.com/chopratejas/headroom/issues/1132)).
* **proxy:** give buffered Anthropic request paths their own longer read timeout, so long `/v1/messages` turns and Anthropic batch or passthrough reads no longer trip the generic proxy cap while unrelated request timeouts stay unchanged.

View file

@ -19,6 +19,13 @@ from .health import probe_ready
from .models import DeploymentManifest, InstallPreset, RuntimeKind
from .paths import log_path, pid_path, profile_root
# Inside the container the proxy must listen on every interface so the
# host-side published port (127.0.0.1:<port>) can reach it.
CONTAINER_BIND_HOST = "0.0.0.0" # noqa: S104 — container-internal bind, published only on 127.0.0.1
# proxy_args always starts with the host flag/value pair (see planner.py); we
# drop it and substitute CONTAINER_BIND_HOST for the in-container bind.
_PROXY_ARGS_HOST_PAIR_LEN = 2
PASSTHROUGH_ENV_PREFIXES = (
"HEADROOM_",
"ANTHROPIC_",
@ -136,14 +143,16 @@ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
for name in sorted(os.environ):
if name.startswith(PASSTHROUGH_ENV_PREFIXES):
command.extend(["--env", name])
# The image ENTRYPOINT already runs `headroom proxy` (see Dockerfile), so
# the args appended after the image name are only the proxy flags — never
# `headroom proxy` again, or Docker would run `headroom proxy headroom
# proxy ...` and Click aborts on the extra arguments (issue #833).
command.extend(
[
manifest.image,
"headroom",
"proxy",
"--host",
"0.0.0.0",
*manifest.proxy_args[2:],
CONTAINER_BIND_HOST,
*manifest.proxy_args[_PROXY_ARGS_HOST_PAIR_LEN:],
]
)
return command

View file

@ -94,6 +94,46 @@ def test_build_runtime_command_for_docker_matches_wrapper_parity(
assert "OPENAI_API_KEY" in joined
def test_build_runtime_command_for_docker_does_not_duplicate_entrypoint(
monkeypatch, tmp_path: Path
) -> None:
"""The image ENTRYPOINT is already ``["headroom", "proxy"]`` (Dockerfile),
so the args appended after the image name must NOT re-add ``headroom proxy``
or Docker runs ``headroom proxy headroom proxy ...`` and Click aborts with
"Got unexpected extra arguments (headroom proxy)" (issue #833)."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manifest = DeploymentManifest(
profile="default",
preset="persistent-docker",
runtime_kind="docker",
supervisor_kind="none",
scope="user",
provider_mode="manual",
targets=["claude"],
port=8787,
host="127.0.0.1",
backend="anthropic",
image="ghcr.io/chopratejas/headroom:latest",
base_env={"HEADROOM_PORT": "8787"},
proxy_args=["--host", "127.0.0.1", "--port", "8787", "--backend", "anthropic"],
)
command = build_runtime_command(manifest)
# Everything after the image name is what Docker appends to the ENTRYPOINT.
image_idx = command.index(manifest.image)
container_args = command[image_idx + 1 :]
assert "headroom" not in container_args, (
f"container args re-add the ENTRYPOINT — got {container_args}"
)
assert "proxy" not in container_args, (
f"container args re-add the ENTRYPOINT — got {container_args}"
)
# The container must still bind on all interfaces and keep the real flags.
assert container_args[:2] == ["--host", "0.0.0.0"]
assert container_args[2:] == ["--port", "8787", "--backend", "anthropic"]
def test_resolve_headroom_command_prefers_headroom_binary(monkeypatch) -> None:
monkeypatch.setattr(
"shutil.which", lambda name: "/usr/bin/headroom" if name == "headroom" else None