mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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.
297 lines
10 KiB
Python
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"
|