headroom/tests/test_install/test_planner.py
Abhay Singh 14c4c9d5b7
fix(install): stop baking the host memory DB path into a container deployment (#2845)
## Description

`headroom deploy --memory` on the `persistent-docker` preset can never
become ready. The planner resolves the memory DB path against the
**host** home and appends it verbatim to `proxy_args`:

```python
# headroom/install/planner.py
proxy_args.extend(["--memory", "--memory-db-path", str(_paths.memory_db_path())])
# -> --memory-db-path /home/<user>/.headroom/memory.db
```

The docker runtime passes everything after the leading `--host` pair
through unchanged, and the container's `HOME` is `/tmp/headroom-home`
with the host's `~/.headroom` bind-mounted at
`/tmp/headroom-home/.headroom`. The host path
`/home/<user>/.headroom/memory.db` does not exist inside the container,
so SQLite cannot open the DB:

```text
Memory: backend initialization failed (startup continues): unable to open database file
```

`/health` then reports `memory.ready = false`, `/readyz` stays 503 for
the full `wait_ready` window, and `_start_deployment` times out and
rolls back, so the failure presents as "did not become ready" rather
than a path bug. The same applies on macOS with `/Users/<user>/...`.

The fix omits `--memory-db-path` for a container (docker) runtime. When
the flag is absent the proxy resolves the DB under its own cwd
(`.headroom/memory.db`), and the container's workdir is
`/tmp/headroom-home` (the bind mount), so the DB lands in exactly the
same host file the explicit path intended. The host (python) runtime
still passes the resolved host path, which is correct there.

Fixes #2803

## 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/planner.py` (`build_manifest`): append `--memory`
always, but add `--memory-db-path <host path>` only when `runtime_kind
!= RuntimeKind.DOCKER.value`. Imported `RuntimeKind` from `.models`.
- `tests/test_install/test_planner.py`: extended
`test_build_manifest_for_persistent_docker_sets_expected_defaults` to
assert `--memory-db-path` is absent for the docker runtime, and added
`test_build_manifest_python_runtime_keeps_explicit_memory_db_path`
asserting it is still present for the python runtime.

## Testing

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

### Test Output

```text
# Fail-before (source fix stashed, updated tests kept):
tests/test_install/test_planner.py::test_build_manifest_for_persistent_docker_sets_expected_defaults FAILED
  assert "--memory-db-path" not in manifest.proxy_args
  AssertionError: assert '--memory-db-path' not in ['--host', '127.0.0.1', ...]

# Pass-after (fix applied):
tests/test_install/test_planner.py  19 passed

# Broader install suites:
tests/test_install/  141 passed, 1 skipped, 2 unrelated pre-existing/flaky failures
#   - test_native_installers.py::test_powershell_native_installer_supports_persistent_docker_lifecycle
#     runs scripts/install.ps1 and fails identically on clean main (environment-specific).
#   - test_runtime.py::test_runtime_status_survives_winerror87_systemerror passes in isolation
#     and in its own file; it only failed under cross-file ordering in the broad run, and is
#     untouched by this diff (planner.py only).

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/install/planner.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: traced the path from `planner.py`
(`--memory-db-path str(_paths.memory_db_path())`, host home) through
`runtime.py` (`build_runtime_command` passes
`proxy_args[_PROXY_ARGS_HOST_PAIR_LEN:]` through, container HOME
`/tmp/headroom-home`, `~/.headroom` bind-mounted) and confirmed via
`server.py` that an empty `memory_db_path` resolves to
`Path.cwd()/.headroom/memory.db` (the container workdir, hence the
mount). Fail-before with `git stash push headroom/install/planner.py`
and `python -m pytest tests/test_install/test_planner.py -k
persistent_docker` (host path present in proxy_args), pass-after with
`git stash pop` and rerunning (19 passed).
- Observed result: for the docker runtime, `manifest.proxy_args` now
carries `--memory` without `--memory-db-path`, so the container resolves
the DB to `/tmp/headroom-home/.headroom/memory.db` (the bind mount to
host `~/.headroom/memory.db`) and can open it, instead of receiving a
nonexistent host path. The python runtime still carries the explicit
host path.
- Not tested: a live `headroom deploy --memory` against a running Docker
daemon (no container runtime in this environment). The manifest
construction is verified directly, and the container-side resolution it
relies on is existing server behavior (`empty memory_db_path ->
cwd/.headroom/memory.db`) confirmed by reading `server.py`.

## 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
- [ ] 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)

## Additional Notes

The DB persistence location is unchanged: both the old host path and the
new container-cwd resolution point at the host's `~/.headroom/memory.db`
(directly on the host, or through the bind mount inside the container),
so existing memory DBs are picked up either way. This is the memory-path
half of the persistent-docker issues; the separate rootless-Podman
`--user` bind-mount problem (#2804) is left for its own fix.
2026-08-08 01:15:41 -05:00

297 lines
10 KiB
Python

from __future__ import annotations
import click
import pytest
from headroom.install.models import ConfigScope, InstallPreset, ProviderSelectionMode, ToolTarget
from headroom.install.planner import PROVIDER_SCOPE_TARGETS, build_manifest, resolve_targets
def test_resolve_targets_auto_falls_back_when_detection_empty(monkeypatch) -> None:
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
targets = resolve_targets(ProviderSelectionMode.AUTO.value, [])
assert targets == [
ToolTarget.CLAUDE.value,
ToolTarget.CODEX.value,
ToolTarget.COPILOT.value,
]
def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None:
manifest = build_manifest(
profile="default",
preset=InstallPreset.PERSISTENT_DOCKER.value,
runtime_kind="docker",
scope="user",
provider_mode="manual",
targets=["claude", "copilot"],
port=8787,
backend="anthropic",
anyllm_provider=None,
region=None,
proxy_mode="token",
memory_enabled=True,
telemetry_enabled=False,
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
assert manifest.supervisor_kind == "none"
assert manifest.runtime_kind == "docker"
assert manifest.health_url == "http://127.0.0.1:8787/readyz"
assert manifest.base_env["HEADROOM_PORT"] == "8787"
assert manifest.base_env["HEADROOM_TELEMETRY"] == "off"
assert "--no-telemetry" in manifest.proxy_args
assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert manifest.tool_envs["copilot"]["COPILOT_PROVIDER_TYPE"] == "anthropic"
assert "--memory" in manifest.proxy_args
# A container runtime must NOT carry the host memory DB path: it does not
# exist inside the container and would keep /readyz at 503 (#2803). The proxy
# resolves the DB under its own cwd, which is the bind-mounted ~/.headroom.
assert "--memory-db-path" not in manifest.proxy_args
def test_build_manifest_python_runtime_keeps_explicit_memory_db_path() -> None:
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=True,
telemetry_enabled=False,
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
# On the host the resolved path is correct, so it is still passed explicitly.
assert "--memory" in manifest.proxy_args
assert "--memory-db-path" in manifest.proxy_args
def test_build_manifest_uses_provider_slice_env_builders_for_all_supported_targets() -> None:
manifest = build_manifest(
profile="default",
preset=InstallPreset.PERSISTENT_SERVICE.value,
runtime_kind="python",
scope="user",
provider_mode="manual",
targets=["claude", "copilot", "codex", "aider", "cursor"],
port=9999,
backend="anyllm",
anyllm_provider="groq",
region=None,
proxy_mode="token",
memory_enabled=False,
telemetry_enabled=True,
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
# telemetry_enabled=True must write the explicit opt-in value + flag.
assert manifest.base_env["HEADROOM_TELEMETRY"] == "on"
assert "--telemetry" in manifest.proxy_args
assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9999"
assert manifest.tool_envs["codex"]["OPENAI_BASE_URL"] == "http://127.0.0.1:9999/v1"
assert manifest.tool_envs["aider"] == {
"OPENAI_API_BASE": "http://127.0.0.1:9999/v1",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9999",
}
assert manifest.tool_envs["cursor"] == {
"OPENAI_BASE_URL": "http://127.0.0.1:9999/v1",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9999",
}
assert manifest.tool_envs["copilot"] == {
"COPILOT_PROVIDER_TYPE": "openai",
"COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9999/v1",
"COPILOT_PROVIDER_WIRE_API": "completions",
}
def test_resolve_targets_provider_scope_auto_excludes_copilot(monkeypatch) -> None:
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
targets = resolve_targets(
ProviderSelectionMode.AUTO.value,
[],
scope=ConfigScope.PROVIDER.value,
)
assert targets == [ToolTarget.CLAUDE.value, ToolTarget.CODEX.value]
def test_resolve_targets_manual_dedupes_and_filters_invalid() -> None:
targets = resolve_targets(
ProviderSelectionMode.MANUAL.value,
["claude", "copilot", "claude", "invalid"],
)
assert targets == [ToolTarget.CLAUDE.value, ToolTarget.COPILOT.value]
def test_build_manifest_omits_no_http2_by_default() -> None:
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=True,
image="ghcr.io/headroomlabs-ai/headroom:latest",
)
assert "--no-http2" not in manifest.proxy_args
def test_build_manifest_persists_no_http2_override() -> None:
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=True,
image="ghcr.io/headroomlabs-ai/headroom:latest",
no_http2=True,
)
assert manifest.proxy_args.count("--no-http2") == 1
assert "HEADROOM_HTTP2" not in manifest.base_env
def test_resolve_targets_provider_scope_all_ignores_unsupported_requested() -> None:
"""`all` mode never consults the requested list, so an unsupported entry
like `cursor` must not make it raise — it should return the full provider
target set (regression: this used to raise a ClickException)."""
targets = resolve_targets(
ProviderSelectionMode.ALL.value,
["cursor"],
scope=ConfigScope.PROVIDER.value,
)
assert targets == [t.value for t in PROVIDER_SCOPE_TARGETS]
def test_resolve_targets_provider_scope_auto_ignores_unsupported_requested(monkeypatch) -> None:
"""`auto` mode also ignores the requested list, so an unsupported entry
must not raise."""
monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
targets = resolve_targets(
ProviderSelectionMode.AUTO.value,
["cursor"],
scope=ConfigScope.PROVIDER.value,
)
assert targets == [ToolTarget.CLAUDE.value, ToolTarget.CODEX.value]
def test_resolve_targets_provider_scope_manual_rejects_unsupported() -> None:
"""The manual path DOES consult the requested list, so an unsupported
target under provider scope must still be rejected."""
with pytest.raises(click.ClickException, match="cursor"):
resolve_targets(
ProviderSelectionMode.MANUAL.value,
["cursor"],
scope=ConfigScope.PROVIDER.value,
)
def _base_manifest_kwargs(**overrides):
kwargs = {
"profile": "default",
"preset": InstallPreset.PERSISTENT_SERVICE.value,
"runtime_kind": "python",
"scope": "user",
"provider_mode": "manual",
"targets": ["claude"],
"port": 8787,
"backend": "bedrock",
"anyllm_provider": None,
"region": "eu-west-1",
"proxy_mode": "token",
"memory_enabled": False,
"telemetry_enabled": False,
"image": "ghcr.io/chopratejas/headroom:latest",
}
kwargs.update(overrides)
return kwargs
def test_build_manifest_omits_new_bedrock_flags_by_default() -> None:
manifest = build_manifest(**_base_manifest_kwargs())
assert "--code-aware" not in manifest.proxy_args
assert "--no-code-aware" not in manifest.proxy_args
assert "--intercept-tool-results" not in manifest.proxy_args
assert "--protect-tool-results" not in manifest.proxy_args
assert "--bedrock-profile" not in manifest.proxy_args
def test_build_manifest_persists_code_aware_true() -> None:
manifest = build_manifest(**_base_manifest_kwargs(code_aware=True))
assert "--code-aware" in manifest.proxy_args
assert "--no-code-aware" not in manifest.proxy_args
def test_build_manifest_persists_code_aware_false() -> None:
manifest = build_manifest(**_base_manifest_kwargs(code_aware=False))
assert "--no-code-aware" in manifest.proxy_args
assert "--code-aware" not in manifest.proxy_args
def test_build_manifest_persists_intercept_tool_results() -> None:
manifest = build_manifest(**_base_manifest_kwargs(intercept_tool_results=True))
assert "--intercept-tool-results" in manifest.proxy_args
def test_build_manifest_persists_protect_tool_results() -> None:
manifest = build_manifest(**_base_manifest_kwargs(protect_tool_results="Bash,WebFetch"))
idx = manifest.proxy_args.index("--protect-tool-results")
assert manifest.proxy_args[idx + 1] == "Bash,WebFetch"
def test_build_manifest_persists_bedrock_profile() -> None:
manifest = build_manifest(**_base_manifest_kwargs(bedrock_profile="sso-bedrock"))
idx = manifest.proxy_args.index("--bedrock-profile")
assert manifest.proxy_args[idx + 1] == "sso-bedrock"
def test_build_manifest_merges_extra_env_into_base_env() -> None:
manifest = build_manifest(
**_base_manifest_kwargs(extra_env={"HEADROOM_WORKSPACE_DIR": "/custom/workspace"})
)
assert manifest.base_env["HEADROOM_WORKSPACE_DIR"] == "/custom/workspace"
def test_build_manifest_extra_env_overrides_derived_defaults() -> None:
manifest = build_manifest(**_base_manifest_kwargs(extra_env={"HEADROOM_TELEMETRY": "on"}))
# telemetry_enabled=False in _base_manifest_kwargs would normally set "off";
# an explicit --env must win.
assert manifest.base_env["HEADROOM_TELEMETRY"] == "on"