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:
Focused Instability 2026-06-18 18:19:43 +02:00 committed by GitHub
parent fe4f9ee478
commit 9f712ccbd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 59 additions and 4 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"