mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## Description
On Windows, `Path.read_text()` and `open()` default to the system locale
encoding (cp1252, GBK, etc.) instead of UTF-8. This causes
`UnicodeDecodeError` when reading or writing instruction files that
contain multi-byte UTF-8 characters such as smart quotes or em dashes.
The RTK instructions block itself contains an em dash (U+2014, `—`), so
`_inject_rtk_instructions` crashes on any non-UTF-8 Windows locale when
writing to `.copilot_instructions`, `.clinerules`, `AGENTS.md`, or
similar hint files.
Closes #1126
## 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
- Add `encoding="utf-8"` to all bare `read_text()`, `write_text()`, and
`open()` calls in `headroom/cli/wrap.py` that handle instruction or
config files (18 call sites)
- Update test assertions in `test_wrap_hintfile_agents.py`,
`test_wrap_copilot.py`, and `test_wrap_bridge.py` to read with
`encoding="utf-8"`
- Add `test_inject_rtk_handles_utf8_content` verifying that existing
hint files with smart quotes and em dashes survive RTK injection without
crashing
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_cli/test_wrap_hintfile_agents.py tests/test_cli/test_wrap_copilot.py tests/test_cli/test_wrap_bridge.py -v
47 passed in 1.28s
```
## Real Behavior Proof
- Environment: Windows 11 China (GBK locale), Python 3.11, headroom main
(f03e77b)
- Exact command / steps: python -m pytest
tests/test_cli/test_wrap_hintfile_agents.py -v (on GBK Windows)
- Observed result: Before fix,
test_prepare_only_injects_rtk_into_hintfile fails with
UnicodeDecodeError 'gbk' at position 283 (the em dash in RTK block).
After fix, all 12 hintfile tests pass including new UTF-8 round-trip
test.
- Not tested: no manual `headroom wrap copilot` run against a real
Copilot installation
## 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
- [ ] 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
## Additional Notes
This is the same class of bug reported in #733 (GBK config.toml
corruption). This PR fixes the `wrap.py` call sites; other modules
(`learn/analyzer.py`, `install/providers.py`) have the same pattern and
could benefit from the same treatment in a follow-up.
---------
Signed-off-by: Yiming Zeng <yzeng424@gmail.com>
Signed-off-by: RTCartist <wangshengb@buaa.edu.cn>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
8.3 KiB
Python
243 lines
8.3 KiB
Python
"""Tests for Docker-bridge wrap preparation flows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from headroom.cli.main import main
|
|
from headroom.cli.wrap import _setup_lean_ctx_agent
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _default_context_tool(monkeypatch) -> None:
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
monkeypatch.delenv("LEAN_CTX_AGENT", raising=False)
|
|
monkeypatch.delenv("LEAN_CTX_DATA_DIR", raising=False)
|
|
|
|
|
|
def _set_test_home(monkeypatch, tmp_path: Path) -> None:
|
|
home = str(tmp_path)
|
|
monkeypatch.setenv("HOME", home)
|
|
monkeypatch.setenv("USERPROFILE", home)
|
|
|
|
|
|
def test_wrap_claude_prepare_only_skips_host_binary_lookup() -> None:
|
|
runner = CliRunner()
|
|
|
|
with patch("headroom.cli.wrap._prepare_wrap_rtk") as prepare_rtk:
|
|
with patch("headroom.cli.wrap.shutil.which") as which_mock:
|
|
result = runner.invoke(main, ["wrap", "claude", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
prepare_rtk.assert_called_once()
|
|
which_mock.assert_not_called()
|
|
|
|
|
|
def test_wrap_claude_prepare_only_uses_lean_ctx_when_configured(monkeypatch) -> None:
|
|
runner = CliRunner()
|
|
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "lean-ctx")
|
|
|
|
with patch("headroom.cli.wrap._prepare_wrap_rtk") as prepare_rtk:
|
|
with patch(
|
|
"headroom.cli.wrap._setup_lean_ctx_agent",
|
|
return_value=Path("lean-ctx"),
|
|
) as setup:
|
|
result = runner.invoke(main, ["wrap", "claude", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
prepare_rtk.assert_not_called()
|
|
setup.assert_called_once_with("claude", verbose=False)
|
|
|
|
|
|
def test_setup_lean_ctx_agent_runs_outside_project_root(monkeypatch, tmp_path: Path) -> None:
|
|
project_root = tmp_path / "project"
|
|
project_root.mkdir()
|
|
(project_root / ".git").mkdir()
|
|
lean_ctx = tmp_path / "lean-ctx"
|
|
lean_ctx.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
calls: list[dict] = []
|
|
|
|
def fake_run(*args, **kwargs):
|
|
calls.append({"args": args, "kwargs": kwargs})
|
|
return subprocess.CompletedProcess(args[0], 0, stdout="", stderr="")
|
|
|
|
monkeypatch.chdir(project_root)
|
|
monkeypatch.setattr("headroom.lean_ctx.get_lean_ctx_path", lambda: lean_ctx)
|
|
monkeypatch.setattr("headroom.cli.wrap.subprocess.run", fake_run)
|
|
|
|
assert _setup_lean_ctx_agent("codex") == lean_ctx
|
|
|
|
assert calls
|
|
cwd = Path(calls[0]["kwargs"]["cwd"])
|
|
assert cwd != project_root
|
|
assert project_root not in cwd.parents
|
|
|
|
|
|
def test_wrap_codex_prepare_only_updates_config(monkeypatch, tmp_path: Path) -> None:
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
runner = CliRunner()
|
|
|
|
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
|
|
result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
config_file = tmp_path / ".codex" / "config.toml"
|
|
assert config_file.exists()
|
|
content = config_file.read_text(encoding="utf-8")
|
|
assert 'model_provider = "headroom"' in content
|
|
assert 'base_url = "http://127.0.0.1:8787/v1"' in content
|
|
|
|
|
|
def test_wrap_codex_prepare_only_uses_lean_ctx_when_configured(monkeypatch, tmp_path: Path) -> None:
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "lean-ctx")
|
|
runner = CliRunner()
|
|
|
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
|
with patch("headroom.cli.wrap._ensure_rtk_binary") as ensure_rtk:
|
|
with patch(
|
|
"headroom.cli.wrap._setup_lean_ctx_agent",
|
|
return_value=Path("lean-ctx"),
|
|
) as setup:
|
|
result = runner.invoke(
|
|
main,
|
|
["wrap", "codex", "--prepare-only", "--no-mcp", "--no-serena"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
ensure_rtk.assert_not_called()
|
|
setup.assert_called_once_with("codex", verbose=False)
|
|
assert not Path("AGENTS.md").exists()
|
|
|
|
|
|
def test_wrap_codex_prepare_only_accepts_no_context_tool_alias(monkeypatch, tmp_path: Path) -> None:
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "lean-ctx")
|
|
runner = CliRunner()
|
|
|
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
|
with patch("headroom.cli.wrap._ensure_rtk_binary") as ensure_rtk:
|
|
with patch("headroom.cli.wrap._setup_lean_ctx_agent") as setup:
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"wrap",
|
|
"codex",
|
|
"--prepare-only",
|
|
"--no-context-tool",
|
|
"--no-mcp",
|
|
"--no-serena",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
ensure_rtk.assert_not_called()
|
|
setup.assert_not_called()
|
|
|
|
|
|
def test_wrap_aider_prepare_only_injects_conventions(monkeypatch, tmp_path: Path) -> None:
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
runner = CliRunner()
|
|
|
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
|
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=Path("rtk")):
|
|
result = runner.invoke(main, ["wrap", "aider", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
conventions = Path("CONVENTIONS.md")
|
|
assert conventions.exists()
|
|
assert "headroom:rtk-instructions" in conventions.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_wrap_cursor_prepare_only_injects_cursorrules(monkeypatch, tmp_path: Path) -> None:
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
runner = CliRunner()
|
|
|
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
|
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=Path("rtk")):
|
|
result = runner.invoke(main, ["wrap", "cursor", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
cursorrules = Path(".cursorrules")
|
|
assert cursorrules.exists()
|
|
assert "headroom:rtk-instructions" in cursorrules.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_wrap_cursor_prepare_only_uses_lean_ctx_when_configured(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "lean-ctx")
|
|
runner = CliRunner()
|
|
|
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
|
with patch("headroom.cli.wrap._ensure_rtk_binary") as ensure_rtk:
|
|
with patch(
|
|
"headroom.cli.wrap._setup_lean_ctx_agent",
|
|
return_value=Path("lean-ctx"),
|
|
) as setup:
|
|
result = runner.invoke(main, ["wrap", "cursor", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
ensure_rtk.assert_not_called()
|
|
setup.assert_called_once_with("cursor", verbose=False)
|
|
assert not Path(".cursorrules").exists()
|
|
|
|
|
|
def test_wrap_openclaw_prepare_only_emits_config_without_python_default() -> None:
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"wrap",
|
|
"openclaw",
|
|
"--prepare-only",
|
|
"--gateway-provider-id",
|
|
"codex",
|
|
"--gateway-provider-id",
|
|
"anthropic",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload["enabled"] is True
|
|
assert payload["config"]["proxyPort"] == 8787
|
|
assert payload["config"]["gatewayProviderIds"] == ["codex", "anthropic"]
|
|
assert "pythonPath" not in payload["config"]
|
|
|
|
|
|
def test_unwrap_openclaw_prepare_only_preserves_unmanaged_config() -> None:
|
|
runner = CliRunner()
|
|
existing_entry = json.dumps(
|
|
{
|
|
"enabled": True,
|
|
"config": {
|
|
"pythonPath": "C:\\Python312\\python.exe",
|
|
"proxyPort": 8787,
|
|
"customFlag": True,
|
|
},
|
|
}
|
|
)
|
|
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"unwrap",
|
|
"openclaw",
|
|
"--prepare-only",
|
|
"--existing-entry-json",
|
|
existing_entry,
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
payload = json.loads(result.output)
|
|
assert payload == {"enabled": False, "config": {"customFlag": True}}
|