mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Fixes #2492, #2028, and #2827. Claude daemon workers consume project settings rather than reliably inheriting wrapper environment state, while the Claude VS Code webview cannot render deferred-tool response blocks. Separately, recent Copilot Chat versions use the whole CAPI override for generation; the legacy proxy override alone only sends model discovery through Headroom. This PR carries both integrations through to the actual consumers instead of only changing their launch-time surface configuration. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Build / CI ## Changes Made - Persist the resolved Claude ENABLE_TOOL_SEARCH value into project settings for daemon workers and restore it transactionally after wrap exits. - Use compatibility-safe Foundry and Claude VS Code defaults while preserving explicit user choices. - Configure both Copilot overrideProxyUrl and overrideCapiUrl in the reversible managed VS Code settings block. - Route Copilot unprefixed POST /chat/completions and HTTP /responses requests through the real compression handlers. - Keep /responses out of the Codex WebSocket aliases because Copilot and Codex use different WebSocket wire protocols. - Extend wrap E2E assertions for both the Claude webview mode and Copilot CAPI routing. ## Testing - [x] 127 combined Claude, Copilot, route-integration, and MCP dependency-contract tests pass. - [x] Ruff check passes on all changed Python files. - [x] Ruff format check passes. - [x] Python compilation and git diff --check pass. ## Runtime Safety Standalone Claude CLI defaults remain unchanged. Explicit Claude tool-search values retain precedence, and project settings are restored through the existing cleanup path. Copilot model/session helper endpoints continue through generic passthrough, while only validated HTTP generation paths receive explicit compression routes. Existing Codex WebSocket behavior is unchanged. ## Review Readiness - [x] Current main and MCP v1 compatibility retained - [x] Worker-facing Claude persistence covered - [x] Reversible Copilot and Claude settings behavior covered - [x] Copilot generation routes covered at registration and proxy integration layers - [x] Ready for review
344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""Tests for _write_claude_wrap_base_url / _restore_claude_wrap_base_url (issue #951)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import click
|
|
import pytest
|
|
|
|
from headroom.cli import wrap as wrap_cli
|
|
|
|
|
|
def _settings(tmp_path: Path) -> Path:
|
|
return tmp_path / ".claude" / "settings.json"
|
|
|
|
|
|
def test_write_creates_env_key_in_fresh_file(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
assert prev is None
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
|
|
|
|
def test_write_preserves_other_env_keys(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(json.dumps({"env": {"KEEP": "1", "ANOTHER": "2"}}), encoding="utf-8")
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["KEEP"] == "1"
|
|
assert payload["env"]["ANOTHER"] == "2"
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
|
|
|
|
def test_tool_search_write_and_restore_reaches_daemon_worker_settings(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ENABLE_TOOL_SEARCH": "true", "KEEP": "1"}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
previous = wrap_cli._write_claude_wrap_tool_search("false", settings_path=path)
|
|
|
|
assert previous == "true"
|
|
assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
|
|
"ENABLE_TOOL_SEARCH": "false",
|
|
"KEEP": "1",
|
|
}
|
|
|
|
wrap_cli._restore_claude_wrap_tool_search(previous, settings_path=path)
|
|
assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
|
|
"ENABLE_TOOL_SEARCH": "true",
|
|
"KEEP": "1",
|
|
}
|
|
|
|
|
|
def test_write_returns_none_when_key_absent(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
assert prev is None
|
|
|
|
|
|
def test_write_returns_previous_value_when_key_present(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://old.proxy:9000"}}),
|
|
encoding="utf-8",
|
|
)
|
|
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
assert prev == "http://old.proxy:9000"
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
|
|
|
|
def test_write_foundry_mode_sets_foundry_key(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url(
|
|
"http://127.0.0.1:8787", foundry_mode=True, settings_path=path
|
|
)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_FOUNDRY_BASE_URL"] == "http://127.0.0.1:8787"
|
|
assert "ANTHROPIC_BASE_URL" not in payload["env"]
|
|
|
|
|
|
def test_restore_removes_key_when_previous_none(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
|
|
encoding="utf-8",
|
|
)
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path)
|
|
# file is deleted when payload becomes empty — key is gone
|
|
assert not path.exists()
|
|
|
|
|
|
def test_restore_removes_env_dict_when_empty(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
|
|
encoding="utf-8",
|
|
)
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path)
|
|
# entire payload was {"env": {...only our key...}} — file deleted rather than left as {}
|
|
assert not path.exists()
|
|
|
|
|
|
def test_restore_preserves_sibling_env_keys(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787", "KEEP": "1"}}),
|
|
encoding="utf-8",
|
|
)
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert "ANTHROPIC_BASE_URL" not in payload["env"]
|
|
assert payload["env"]["KEEP"] == "1"
|
|
|
|
|
|
def test_restore_sets_key_back_to_previous_value(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
|
|
encoding="utf-8",
|
|
)
|
|
wrap_cli._restore_claude_wrap_base_url("http://old.proxy:9000", settings_path=path)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://old.proxy:9000"
|
|
|
|
|
|
def test_restore_foundry_mode_removes_foundry_key(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_FOUNDRY_BASE_URL": "http://127.0.0.1:8787"}}),
|
|
encoding="utf-8",
|
|
)
|
|
wrap_cli._restore_claude_wrap_base_url(None, foundry_mode=True, settings_path=path)
|
|
# file deleted when payload empties
|
|
assert not path.exists()
|
|
|
|
|
|
def test_restore_noop_when_file_absent(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise
|
|
|
|
|
|
def test_restore_noop_when_key_not_present(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(json.dumps({"env": {"OTHER": "1"}}), encoding="utf-8")
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # key absent — no-op
|
|
assert json.loads(path.read_text())["env"]["OTHER"] == "1"
|
|
|
|
|
|
def test_restore_noop_when_env_not_dict(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(json.dumps({"env": "not-a-dict"}), encoding="utf-8")
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise
|
|
|
|
|
|
def test_restore_noop_when_payload_not_dict(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text("[1, 2, 3]", encoding="utf-8") # valid JSON but not a dict
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise
|
|
|
|
|
|
def test_restore_noop_when_file_corrupt(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text("not valid json {{{{", encoding="utf-8")
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise
|
|
|
|
|
|
def test_write_refuses_to_clobber_a_corrupt_file(tmp_path: Path) -> None:
|
|
"""A file that will not parse is DATA, not a blank slate — never overwrite it.
|
|
|
|
This previously "recovered" by resetting the payload to ``{}`` and writing
|
|
that back, so a single hand-edited typo (or a transient read error) silently
|
|
destroyed the user's whole settings file — permissions, env and hooks — on
|
|
every ``headroom wrap claude``. Refusing leaves the file for the user to fix.
|
|
"""
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
original = '{"permissions": {"allow": ["Bash"]}, oops'
|
|
path.write_text(original, encoding="utf-8")
|
|
|
|
with pytest.raises(click.ClickException, match="not valid JSON"):
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
|
|
assert path.read_text(encoding="utf-8") == original # untouched
|
|
|
|
|
|
def test_write_refuses_non_dict_payload(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
original = "[1, 2, 3]" # valid JSON but not a settings object
|
|
path.write_text(original, encoding="utf-8")
|
|
|
|
with pytest.raises(click.ClickException, match="does not contain a JSON object"):
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
|
|
assert path.read_text(encoding="utf-8") == original # untouched
|
|
|
|
|
|
def test_write_recovers_from_an_empty_file(tmp_path: Path) -> None:
|
|
"""An empty file has no settings to lose, so recover rather than strand the user.
|
|
|
|
A zero-byte settings.json is the classic residue of an interrupted
|
|
non-atomic write, so this is the one case where treating the file as fresh
|
|
is both safe and the helpful thing to do.
|
|
"""
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(" \n", encoding="utf-8")
|
|
|
|
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
|
|
assert prev is None
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
|
|
|
|
def test_write_restore_roundtrip(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(json.dumps({"model": "opus", "env": {"OTHER": "x"}}), encoding="utf-8")
|
|
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
assert prev is None
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
assert payload["model"] == "opus"
|
|
|
|
wrap_cli._restore_claude_wrap_base_url(prev, settings_path=path)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert "ANTHROPIC_BASE_URL" not in payload.get("env", {})
|
|
assert payload["env"]["OTHER"] == "x"
|
|
assert payload["model"] == "opus"
|
|
|
|
|
|
# --- stale wrap marker (issue #1768) --------------------------------------
|
|
|
|
|
|
def _marker(tmp_path: Path) -> Path:
|
|
return wrap_cli._wrap_marker_path(_settings(tmp_path))
|
|
|
|
|
|
def test_write_with_port_creates_marker(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
|
|
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
|
|
assert marker["port"] == 8787
|
|
assert marker["key"] == "ANTHROPIC_BASE_URL"
|
|
assert marker["previous"] is None
|
|
assert marker["pid"] > 0
|
|
|
|
|
|
def test_write_without_port_skips_marker(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
|
|
assert not _marker(tmp_path).exists()
|
|
|
|
|
|
def test_restore_clears_marker_for_matching_key(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
|
|
assert _marker(tmp_path).exists()
|
|
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path)
|
|
assert not _marker(tmp_path).exists()
|
|
|
|
|
|
def test_wrap_marker_is_stale_when_pid_missing() -> None:
|
|
assert wrap_cli._wrap_marker_is_stale({}) is True
|
|
|
|
|
|
def test_wrap_marker_is_stale_when_pid_dead(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
|
|
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
|
|
marker["pid"] = 999_999_999 # astronomically unlikely to be a live pid
|
|
assert wrap_cli._wrap_marker_is_stale(marker) is True
|
|
|
|
|
|
def test_wrap_marker_is_not_stale_for_live_pid(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
|
|
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
|
|
assert wrap_cli._wrap_marker_is_stale(marker) is False
|
|
|
|
|
|
def test_wrap_marker_is_stale_when_pid_reused(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Inject a deterministic PID identity: _proc_identity returns None on
|
|
# macOS without psutil, where reuse detection is deliberately best-effort
|
|
# and this scenario would be undetectable.
|
|
monkeypatch.setattr(wrap_cli, "_proc_identity", lambda pid: ("test", 50_000.0))
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
|
|
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
|
|
marker["start_time"] = marker["start_time"] - 10_000 # fabricate a mismatched identity
|
|
assert wrap_cli._wrap_marker_is_stale(marker) is True
|
|
|
|
|
|
def test_check_and_clear_stale_wrap_marker_restores_previous(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://old.proxy:9000"}}), encoding="utf-8"
|
|
)
|
|
wrap_cli._write_wrap_marker(
|
|
path, port=8787, key="ANTHROPIC_BASE_URL", previous="http://old.proxy:9000"
|
|
)
|
|
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
|
|
marker["pid"] = 999_999_999
|
|
_marker(tmp_path).write_text(json.dumps(marker), encoding="utf-8")
|
|
|
|
restored = wrap_cli._check_and_clear_stale_wrap_marker(path, key="ANTHROPIC_BASE_URL")
|
|
assert restored == "http://old.proxy:9000"
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://old.proxy:9000"
|
|
assert not _marker(tmp_path).exists()
|
|
|
|
|
|
def test_check_and_clear_stale_wrap_marker_leaves_live_marker(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
|
|
restored = wrap_cli._check_and_clear_stale_wrap_marker(path, key="ANTHROPIC_BASE_URL")
|
|
assert restored is None
|
|
assert _marker(tmp_path).exists()
|
|
|
|
|
|
def test_check_and_clear_stale_wrap_marker_noop_when_no_marker(tmp_path: Path) -> None:
|
|
path = _settings(tmp_path)
|
|
assert wrap_cli._check_and_clear_stale_wrap_marker(path, key="ANTHROPIC_BASE_URL") is None
|