mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
fix(wrap): percent-encode non-ASCII cwd names in X-Headroom-Project header (#1071)
## Description Non-ASCII directory names (Chinese, Japanese, Korean, Cyrillic) caused an immediate API error when using `headroom wrap claude`: ``` API Error: Header 'X-Headroom-Project' has invalid value: '第二大脑共享' ``` RFC 7230 requires HTTP header values to be visible ASCII only. The raw cwd basename was being sent directly, breaking the entire session before the first token. Closes #1069 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/cli/wrap.py` — `_project_name_from_cwd()`: percent-encode non-ASCII chars via `urllib.parse.quote(name, safe="-_.() ")` so the header value is always ASCII-safe - `headroom/proxy/savings_tracker.py` — `sanitize_project_name()`: `urllib.parse.unquote()` before cleanup so the stored/displayed project name is the original Unicode directory name ASCII-only project names are unaffected (quote/unquote is a no-op for them). ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_name_is_percent_encoded PASSED tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe PASSED tests/test_proxy_project_savings.py::test_sanitize_project_name_decodes_percent_encoded_non_ascii PASSED ======================== 15 passed, 1 warning in 0.42s ========================= ``` ## Real Behavior Proof - Environment: macOS 15, Python 3.11.9, headroom dev install from source - Exact command / steps: `mkdir /tmp/test-中文-项目 && cd /tmp/test-中文-项目`, then run `.venv/bin/pytest tests/test_cli/test_wrap_helpers.py::TestApplyProjectHeaderEnv::test_non_ascii_cwd_header_is_ascii_safe -v` — header_value.encode("ascii") passes without UnicodeEncodeError - Observed result: `X-Headroom-Project` header contains percent-encoded ASCII (`test-%E4%B8%AD%E6%96%87-%E9%A1%B9%E7%9B%AE`); proxy decodes back to `test-中文-项目` for storage - Not tested: live end-to-end wrap session with a real Claude API key ## 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 added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe4f9ee478
commit
9f712ccbd7
4 changed files with 59 additions and 4 deletions
|
|
@ -26,6 +26,7 @@ import subprocess
|
|||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
|
@ -1108,12 +1109,14 @@ _PROJECT_HEADER_NAME = "X-Headroom-Project"
|
|||
def _project_name_from_cwd() -> str | None:
|
||||
"""Project label for X-Headroom-Project: basename of the launch directory.
|
||||
|
||||
The proxy sanitizes and caps the value server-side
|
||||
(headroom.proxy.savings_tracker.sanitize_project_name), so the raw
|
||||
directory name is safe to send as-is.
|
||||
Non-ASCII characters are percent-encoded (RFC 3986) so the header value
|
||||
stays within the visible-ASCII range required by RFC 7230. The proxy
|
||||
decodes the value in sanitize_project_name before storing it.
|
||||
"""
|
||||
name = Path.cwd().name.strip()
|
||||
return name or None
|
||||
if not name:
|
||||
return None
|
||||
return urllib.parse.quote(name, safe="-_.() ")
|
||||
|
||||
|
||||
def _apply_project_header_env(env: dict[str, str]) -> None:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import logging
|
|||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.parse
|
||||
from csv import DictWriter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from io import StringIO
|
||||
|
|
@ -313,9 +314,12 @@ def sanitize_project_name(value: Any) -> str | None:
|
|||
|
||||
Strips control characters, trims whitespace, and caps length so a
|
||||
misbehaving client cannot bloat the persisted state or the dashboard.
|
||||
Percent-encoded values (from non-ASCII cwd names) are decoded first so
|
||||
the stored project name matches the original directory name.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = urllib.parse.unquote(value)
|
||||
cleaned = "".join(ch for ch in value if ch.isprintable()).strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -522,6 +522,38 @@ class TestApplyProjectHeaderEnv:
|
|||
|
||||
assert wrap_mod._project_name_from_cwd() == "vibe-headroom"
|
||||
|
||||
def test_non_ascii_cwd_name_is_percent_encoded(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Non-ASCII directory names must be percent-encoded for HTTP headers."""
|
||||
project_dir = tmp_path / "第二大脑共享"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
result = wrap_mod._project_name_from_cwd()
|
||||
assert result is not None
|
||||
# Must be pure ASCII so it's safe in an HTTP header value.
|
||||
result.encode("ascii")
|
||||
# Must round-trip back to the original name via unquote.
|
||||
import urllib.parse
|
||||
|
||||
assert urllib.parse.unquote(result) == "第二大脑共享"
|
||||
|
||||
def test_non_ascii_cwd_header_is_ascii_safe(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""X-Headroom-Project header value must be ASCII when cwd has non-ASCII chars."""
|
||||
project_dir = tmp_path / "test-中文-项目"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
env: dict[str, str] = {}
|
||||
wrap_mod._apply_project_header_env(env)
|
||||
|
||||
header_value = env["ANTHROPIC_CUSTOM_HEADERS"]
|
||||
assert header_value.startswith("X-Headroom-Project: ")
|
||||
header_value.encode("ascii") # raises UnicodeEncodeError if non-ASCII
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy-client reference counting
|
||||
|
|
|
|||
|
|
@ -38,6 +38,22 @@ def test_sanitize_project_name_normalizes_and_caps():
|
|||
assert sanitize_project_name(42) is None
|
||||
|
||||
|
||||
def test_sanitize_project_name_decodes_percent_encoded_non_ascii():
|
||||
"""Percent-encoded non-ASCII cwd names (issue #1069) must decode to Unicode."""
|
||||
import urllib.parse
|
||||
|
||||
chinese = "第二大脑共享"
|
||||
encoded = urllib.parse.quote(chinese, safe="-_.() ")
|
||||
assert sanitize_project_name(encoded) == chinese
|
||||
|
||||
mixed = "test-中文-项目"
|
||||
encoded_mixed = urllib.parse.quote(mixed, safe="-_.() ")
|
||||
assert sanitize_project_name(encoded_mixed) == mixed
|
||||
|
||||
# Plain ASCII names must still pass through unchanged.
|
||||
assert sanitize_project_name("my-project") == "my-project"
|
||||
|
||||
|
||||
def test_classify_project_reads_header_case_insensitively():
|
||||
assert classify_project({"x-headroom-project": "frontend"}) == "frontend"
|
||||
assert classify_project({"X-Headroom-Project": " frontend "}) == "frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue