2026-04-11 13:47:05 -05:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
|
|
|
import click
|
2026-06-11 17:42:43 -07:00
|
|
|
import pytest
|
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
import headroom.cli.wrap as wrap_cli
|
2026-04-11 13:47:05 -05:00
|
|
|
|
|
|
|
|
|
2026-06-11 17:42:43 -07:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _no_attached_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""Default: no other wrap clients attached, so restart paths are hermetic.
|
|
|
|
|
|
|
|
|
|
The ephemeral restart guards consult ``_live_proxy_clients``; without this, a
|
|
|
|
|
real ``headroom wrap`` session on the dev's machine could make these tests
|
|
|
|
|
flaky. Individual tests override this to simulate attached wrappers.
|
|
|
|
|
"""
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [])
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 13:47:05 -05:00
|
|
|
class _Manifest:
|
|
|
|
|
profile = "default"
|
|
|
|
|
preset = "persistent-service"
|
|
|
|
|
supervisor_kind = "service"
|
|
|
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
2026-04-11 13:47:05 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.supervisors.start_supervisor",
|
|
|
|
|
lambda manifest: calls.append(f"start:{manifest.profile}"),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
2026-04-22 11:28:30 +00:00
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
2026-04-11 13:47:05 -05:00
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
2026-04-11 13:47:05 -05:00
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == ["start:default"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
2026-04-11 13:47:05 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.supervisors.start_supervisor",
|
|
|
|
|
lambda manifest: calls.append(f"start:{manifest.profile}"),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
2026-04-11 13:47:05 -05:00
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == ["start:default"]
|
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None:
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
|
|
|
|
|
|
|
|
try:
|
2026-04-22 11:28:30 +00:00
|
|
|
wrap_cli._ensure_proxy(8787, False)
|
fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.
Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.
Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 17:34:40 -05:00
|
|
|
except click.ClickException as exc:
|
|
|
|
|
assert "is not healthy" in str(exc)
|
|
|
|
|
else:
|
|
|
|
|
raise AssertionError("expected unhealthy persistent deployment to raise")
|
2026-04-11 18:24:15 -05:00
|
|
|
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == ["start"]
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 07:19:00 +06:00
|
|
|
def test_ensure_proxy_reports_unbindable_port_before_starting_subprocess(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_port_bind_error",
|
|
|
|
|
lambda port: PermissionError(10013, "access denied by OS port reservation"),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
wrap_cli._ensure_proxy(8787, False, agent_type="cursor")
|
|
|
|
|
except click.ClickException as exc:
|
|
|
|
|
message = str(exc)
|
|
|
|
|
else:
|
|
|
|
|
raise AssertionError("expected unbindable port to raise before starting proxy")
|
|
|
|
|
|
|
|
|
|
assert "Port 8787 is unavailable" in message
|
|
|
|
|
assert "Windows" in message
|
|
|
|
|
assert "headroom wrap cursor --port 8788" in message
|
|
|
|
|
assert calls == []
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 15:58:27 -07:00
|
|
|
def test_ensure_proxy_restarts_idle_stale_persistent_deployment(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(f"restart:{manifest.profile}:{port}") or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == ["restart:default:8787"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_leaves_active_stale_persistent_deployment_running(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 1, "active_relay_tasks": 2}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("active deployment should not restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 17:42:43 -07:00
|
|
|
def test_ensure_proxy_defers_persistent_restart_when_http_wrapper_attached(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A stale persistent proxy is left running while marker-tracked HTTP
|
|
|
|
|
wrappers are attached, even when WebSocket session count is zero."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [999])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("attached persistent proxy should not restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 18:24:15 -05:00
|
|
|
def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
|
|
|
|
|
class DefaultManifest:
|
|
|
|
|
profile = "default"
|
|
|
|
|
port = 8787
|
|
|
|
|
|
|
|
|
|
class OtherManifest:
|
|
|
|
|
profile = "custom"
|
|
|
|
|
port = 8787
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.state.list_manifests",
|
|
|
|
|
lambda: [OtherManifest(), DefaultManifest()],
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
manifest = wrap_cli._find_persistent_manifest(8787)
|
2026-04-11 18:24:15 -05:00
|
|
|
|
|
|
|
|
assert manifest.profile == "default"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recover_persistent_proxy_reuses_healthy_deployment(monkeypatch) -> None:
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
2026-04-11 18:24:15 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
assert wrap_cli._recover_persistent_proxy(8787) is True
|
2026-04-11 18:24:15 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None:
|
|
|
|
|
class TaskManifest(_Manifest):
|
|
|
|
|
supervisor_kind = "task"
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: TaskManifest())
|
2026-04-11 18:24:15 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
assert wrap_cli._recover_persistent_proxy(8787) is False
|
2026-05-09 15:58:27 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-05-09 15:58:27 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 21:24:47 -07:00
|
|
|
def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": "12345",
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": "https://api.githubcopilot.com",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-06-02 21:24:47 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.individual.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][2]["openai_api_url"] == "https://api.individual.githubcopilot.com"
|
|
|
|
|
|
|
|
|
|
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
def test_ensure_proxy_reuses_agent_proxy_without_savings_profile(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.delenv("HEADROOM_SAVINGS_PROFILE", raising=False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("default agent proxy should not restart for savings profile")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_for_explicit_agent_savings_profile(monkeypatch) -> None:
|
2026-06-12 02:58:06 +03:00
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
monkeypatch.setenv("HEADROOM_SAVINGS_PROFILE", "agent-90")
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_reuses_agent_proxy_with_savings_profile(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": "12345",
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"savings_profile": "agent-90",
|
|
|
|
|
"target_ratio": 0.10,
|
|
|
|
|
"compress_user_messages": True,
|
|
|
|
|
"compress_system_messages": True,
|
|
|
|
|
"protect_recent": 2,
|
|
|
|
|
"protect_analysis_context": True,
|
|
|
|
|
"min_tokens_to_crush": 120,
|
|
|
|
|
"max_items_after_crush": 8,
|
|
|
|
|
"smart_crusher_with_compaction": False,
|
|
|
|
|
"accuracy_guard": "strict",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
monkeypatch.setenv("HEADROOM_SAVINGS_PROFILE", "agent-90")
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("configured proxy should not restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False, agent_type="cursor")
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 15:58:27 -07:00
|
|
|
def test_ensure_proxy_leaves_active_stale_ephemeral_proxy_running(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 2, "active_relay_tasks": 2}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("active proxy should not be killed")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
2026-06-11 17:42:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_defers_version_restart_when_http_wrapper_attached(monkeypatch) -> None:
|
|
|
|
|
"""A stale-version proxy is NOT restarted while a marker-tracked HTTP
|
|
|
|
|
wrapper is attached, even though the WebSocket session count is zero."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1", # stale → version restart wanted
|
|
|
|
|
# No WebSocket relay sessions — the gap that let the old code kill it.
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
# Another HTTP wrapper (PID 999) is attached per the marker registry.
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [999])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("attached proxy must not be killed for a version restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy must not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_defers_flag_restart_when_other_wrapper_attached(monkeypatch) -> None:
|
|
|
|
|
"""Requesting --memory must not restart the proxy out from under another
|
|
|
|
|
attached wrapper; reuse the running proxy as-is instead."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION, # same version → no version restart
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
# Running proxy lacks `memory`; this session asks for it.
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [999])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("attached proxy must not be killed to add flags")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy must not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False, memory=True)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_for_flags_when_no_other_wrapper(monkeypatch) -> None:
|
|
|
|
|
"""Control: with no other wrapper attached, a missing-flag restart still
|
|
|
|
|
happens — the guard must not block the single-client upgrade path."""
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [])
|
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary
This PR implements transparent `headroom wrap opencode` support without
asking users to edit OpenCode provider URLs, choose an extra CLI flag,
or maintain a static provider list.
The wrapper now lives at the runtime transport boundary: OpenCode keeps
its user/provider config, while Headroom intercepts outbound provider
traffic in-process and routes it through the local Headroom proxy.
## What changed
### Transparent OpenCode wrapping
- `headroom wrap opencode` injects the `headroom-opencode` plugin
through `OPENCODE_CONFIG_CONTENT`.
- Existing OpenCode provider URLs are preserved. We do not rewrite user
config URLs to point at Headroom.
- Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are
preserved.
- Local OpenCode traffic, localhost traffic, and Headroom proxy traffic
bypass the shim to avoid loops.
### Runtime transport interception
- Added an OpenCode plugin transport shim that wraps:
- `globalThis.fetch`
- `http.request` / `http.get`
- `https.request` / `https.get`
- External provider calls are routed to the local Headroom proxy.
- The original upstream origin is passed through `x-headroom-base-url`,
so the proxy can forward to the real provider without changing OpenCode
config.
- External `http2.connect` is blocked loudly instead of allowing direct
provider traffic to leak outside Headroom.
### Live provider additions
Provider coverage is no longer based on a static config scan. Because
routing happens at outbound request time, providers added mid-session
are routed through Headroom automatically as long as they use the
covered Node transport paths.
### Subagent and child-process coverage
- The parent OpenCode plugin sets a packaged Node preload shim through
`NODE_OPTIONS=--import=.../hook-shim/handler.js`.
- The transport shim patches `child_process.spawn`, `exec`, `execFile`,
and `fork` so child Node processes receive the Headroom preload even
when OpenCode passes a custom `env`.
- The child-process shim fails closed if it loads without
`HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`.
- This closes the subagent leak path where a child Node process could
otherwise start without Headroom transport interception.
## Why this goes beyond PR #1089
PR #1089 improves OpenCode provider registration, but it still focuses
on provider config shape. This PR moves the enforcement boundary to
runtime transport interception.
This PR goes further because:
- No provider URL rewriting is required.
- New providers added mid-session are covered automatically.
- Subagents and child Node processes inherit the Headroom transport
shim.
- Direct external HTTP/2 paths fail loudly instead of leaking.
- The wrap remains transparent to the user's OpenCode provider config.
- The wrapper is fail-closed for unsupported child-process preload
state.
## Additional robustness fixes
While validating the change in Docker, the full Python suite exposed
unrelated Linux/container robustness issues. These are fixed in this PR
so the suite is green:
- Binary cache handling now treats cache paths under a non-writable
existing parent as unavailable, including when tests run as root in
Docker.
- `release_version.py` honors `MANUAL_VER` before git calls so direct
script execution works outside a `.git` checkout.
- Test logger isolation now resets relevant Headroom child loggers so
proxy logging setup cannot poison later `caplog` tests.
- The scanner missing-path test now uses a guaranteed missing `tmp_path`
child instead of relying on `/nonexistent/path`.
## Validation
All implementation validation was run inside Docker.
- Full Python suite from a fresh Docker copy: `6605 passed, 523
skipped`.
- Ruff on changed Python/OpenCode paths: passed.
- OpenCode plugin typecheck: passed.
- OpenCode plugin tests: `9 passed`.
- OpenCode plugin build: passed.
- Hook shim preload smoke test: passed.
## Notes
This PR intentionally does not add a CLI option. `headroom wrap
opencode` means full wrap. Either Headroom wraps OpenCode transparently,
or the path fails loudly instead of silently leaking provider traffic.
---------
Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-06-11 17:42:43 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False, memory=True)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
2026-06-29 00:26:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch(monkeypatch) -> None:
|
|
|
|
|
"""Persistent deployment should restart when requested features differ from running config."""
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
# Persistent proxy is running, so _check_proxy returns True
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Request openai_api_url that differs from running config (None)
|
|
|
|
|
result = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
# Proxy should be killed and restarted due to openai_api_url mismatch
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][2]["openai_api_url"] == "https://api.githubcopilot.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch(monkeypatch) -> None:
|
|
|
|
|
"""Persistent deployment should restart when memory is requested but not enabled."""
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
# Persistent proxy is running, so _check_proxy returns True
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Request memory that differs from running config (False)
|
|
|
|
|
result = wrap_cli._ensure_proxy(8787, False, memory=True)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
# Proxy should be killed and restarted due to memory mismatch
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_recovered_persistent_for_openai_api_url_mismatch(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(("restart", manifest.profile, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.business.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == [("restart", "default", 8787)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_recovered_persistent_when_config_unavailable(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: {"version": "x"})
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_config", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(("restart", manifest.profile, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.business.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == [("restart", "default", 8787)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_reuses_persistent_deployment_when_features_match(monkeypatch) -> None:
|
|
|
|
|
"""Persistent deployment should be reused when all requested features match."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": True,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": "https://api.githubcopilot.com",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("should not restart when features match")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("should not start ephemeral proxy when features match")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Request same features as running config
|
|
|
|
|
result = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
memory=True,
|
|
|
|
|
openai_api_url="https://api.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_recovered_persistent_deployment_checks_feature_mismatch(monkeypatch) -> None:
|
|
|
|
|
"""Recovered persistent deployments must still restart on feature mismatch.
|
|
|
|
|
|
|
|
|
|
Regression guard for the recover path: when wrap requests a different
|
|
|
|
|
openai_api_url (Copilot subscription), do not early-return right after
|
|
|
|
|
recover; run the shared mismatch checks and restart if needed.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": "12345",
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(("restart", manifest.profile, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
assert calls == [("restart", "default", 8787)]
|