From 7e3128057cae574537793cc79301eb4d3d3aa58a Mon Sep 17 00:00:00 2001 From: JD Davis Date: Thu, 13 Aug 2026 21:45:16 -0500 Subject: [PATCH 1/5] ci: allow Dependabot deps commits (#3009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Allow the `deps:` Conventional Commit type emitted by Dependabot. Dependabot PRs currently fail the CI `commitlint` job because `deps` is not included in the repository's configured `type-enum`. Closes # N/A ## 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 - Added `deps` to the allowed commit types in `.commitlintrc.json`. - Existing and future Dependabot commits using `deps: ...` can pass the commit-message policy. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text deps: bump ruff from 0.15.22 to 0.16.2 exit code: 0 bogus: should fail type must be one of [build, chore, ci, docs, deps, feat, fix, parity, perf, refactor, revert, style, test] [type-enum] exit code: 1 git diff --check exit code: 0 ``` ## Real Behavior Proof - Environment: Windows PowerShell; Node.js 22; `@commitlint/cli` and `@commitlint/config-conventional` 19.8.1. - Exact command / steps: Ran commitlint with `.commitlintrc.json` against a real failing Dependabot subject, then against an unapproved `bogus:` type. - Observed result: The `deps:` subject passed; the unapproved type remained rejected by `type-enum`. - Not tested: Python/Rust unit tests and runtime behavior; this change only modifies commit-message validation configuration. ## Runtime Rollout Safety - Rollout-managed feature(s): N/A; CI configuration only. - Minimum rollout channel: N/A. - Stable/default behavior changed: Commitlint now accepts the `deps` type. - Kill switch / disable path: Revert this commit or remove `deps` from `type-enum`. - Unsafe override required: No. - Qualification impact: Dependabot PR commit messages no longer fail solely because their type is `deps`. - Rollback path: Revert this commit. ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A; this change has no user interface. ## Additional Notes - The comment and documentation checklist items are not applicable to this one-line commitlint configuration change. - No automated test file was added; the exact positive and negative commitlint cases were run manually as shown above. - Full application tests were not run because no application code or runtime behavior changed. - Keep this PR unmerged pending maintainer review. --- .commitlintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.commitlintrc.json b/.commitlintrc.json index d42e91489..1391fa7cf 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -12,6 +12,7 @@ "chore", "ci", "docs", + "deps", "feat", "fix", "parity", @@ -23,4 +24,4 @@ ] ] } -} \ No newline at end of file +} From aa811fa91f3a9db3f9d42d7e4f76b5cf114fcc7a Mon Sep 17 00:00:00 2001 From: JD Davis Date: Thu, 13 Aug 2026 22:31:47 -0500 Subject: [PATCH 2/5] ci: allow generated dependency commit bodies (#3012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Disable commitlint's per-line body length limit because Dependabot generates grouped-update commit bodies with dependency/link lines whose length varies with group contents. PR #2964 currently fails only because one generated line is 274 characters long. Closes # N/A ## 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 - Disabled `body-max-line-length` in `.commitlintrc.json`. - Kept Conventional Commit type, subject, and all other configured validation rules enforced. ## Testing - [ ] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text PR #2964 Dependabot commit (49 lines; maximum line length 274) exit code: 0 bogus: should fail type must be one of [build, chore, ci, docs, deps, feat, fix, parity, perf, refactor, revert, style, test] [type-enum] exit code: 1 fix: subject may not be empty [subject-empty] exit code: 1 git diff --check exit code: 0 ``` ## Real Behavior Proof - Environment: Windows PowerShell; Node.js 22; `@commitlint/cli` and `@commitlint/config-conventional` 19.8.1. - Exact command / steps: Fetched the current PR #2964 commit message through the GitHub API and piped the complete message into commitlint using this branch's `.commitlintrc.json`; then ran negative type and subject cases. - Observed result: The exact grouped Dependabot commit passed; an unapproved type and empty subject remained rejected. - Not tested: Python/Rust unit tests and runtime behavior; this change only modifies commit-message validation configuration. ## Runtime Rollout Safety - Rollout-managed feature(s): N/A; CI configuration only. - Minimum rollout channel: N/A. - Stable/default behavior changed: Commit bodies may contain lines of any length; all other commitlint rules remain active. - Kill switch / disable path: Revert this commit or restore a numeric `body-max-line-length` limit. - Unsafe override required: No. - Qualification impact: Generated Dependabot group descriptions no longer fail CI due solely to a long dependency/link line. - Rollback path: Revert this commit. ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A; this change has no user interface. ## Additional Notes - The comment and documentation checklist items are not applicable to this one-line commitlint configuration change. - No automated test file was added; the exact positive and negative commitlint cases were run manually as shown above. - Full application tests were not run because no application code or runtime behavior changed. - Keep this PR unmerged pending maintainer review. --- .commitlintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.commitlintrc.json b/.commitlintrc.json index 1391fa7cf..4bc3e4519 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -1,7 +1,7 @@ { "extends": ["@commitlint/config-conventional"], "rules": { - "body-max-line-length": [2, "always", 200], + "body-max-line-length": [0], "footer-leading-blank": [0], "subject-case": [0], "type-enum": [ From 2d88e31a404e2be6c1c428deb2a387599eb820ba Mon Sep 17 00:00:00 2001 From: JD Davis Date: Thu, 13 Aug 2026 23:01:59 -0500 Subject: [PATCH 3/5] fix(claude): reject conflicting auth before proxy startup (#2993) ## Description Fixes #1443. Claude Code rejects an effective configuration containing both ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN before any request reaches Headroom. The existing wrapper started the proxy and mutated project settings before Claude surfaced its generic Invalid API key message, leaving users to guess which credential came from their shell, global settings, or project settings. Headroom does not own either credential, and both represent legitimate but different auth/billing modes, so automatically deleting one would be destructive. This PR detects the contradiction before any proxy/config mutation and tells the user which source contains each key without exposing credential values. ## Changes Made - Add a pure Claude auth-conflict classifier with explicit settings-layer precedence. - Cover user settings, project .claude/settings.json, project .claude/settings.local.json, and shell environment. - Treat higher-precedence empty values as clearing inherited credentials. - Abort wrap claude before proxy registration/startup when both keys remain effective. - Add a headroom doctor failure with the same source-aware, value-redacted remediation. - Preserve both user credentials and require an explicit choice between API-key billing and token/gateway auth. ## 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) ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text 151 Claude runtime, wrap, doctor, Remote Control, and MCP dependency-contract tests passed ruff check and format checks passed git diff --check passed ``` Branch contains current main, including the MCP v1 cap and the five just-merged blocker PRs. ## Real Behavior Proof - Environment: isolated local worktree on current `main` with Claude wrapper and doctor fixtures. - Exact command / steps: exercised conflicting and non-conflicting shell, user, project, and local-project credential layers through the focused wrap and doctor test suites. - Observed result: conflicting effective credentials fail before proxy startup or settings mutation, report only credential sources, and never expose values. - Not tested: a live Claude Code login with production credentials; credential precedence and side-effect boundaries are covered by fixtures. ## Runtime Rollout Safety - Rollout-managed feature(s): Claude authentication-conflict preflight. - Minimum rollout channel: normal patch release. - Stable/default behavior changed: only configurations with both effective credentials now stop early with actionable diagnostics. - Kill switch / disable path: remove or clear either conflicting credential in its reported source. - Unsafe override required: none; Headroom deliberately does not choose or delete a user credential. - Qualification impact: Claude wrap, doctor, Remote Control, and MCP dependency-contract tests must remain green. - Rollback path: human revert restores the previous late Claude Code rejection; no persisted migration is involved. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Safety No credential value is returned by the classifier, printed by wrap, or emitted in doctor JSON. The preflight runs before _register_proxy_client, proxy startup, MCP registration, or settings writes. --- headroom/cli/doctor.py | 47 ++++++++++++++++- headroom/cli/wrap.py | 32 ++++++++++++ headroom/providers/claude/__init__.py | 6 +++ headroom/providers/claude/runtime.py | 41 +++++++++++++++ .../test_wrap_claude_vertex_proxy_env.py | 51 +++++++++++++++++++ tests/test_cli_doctor.py | 17 +++++++ tests/test_issue_1779_remote_control_gate.py | 30 +++++++++++ 7 files changed, 223 insertions(+), 1 deletion(-) diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index ae8e4cc55..a45a0cbf1 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -30,6 +30,8 @@ from headroom.paths import savings_path from headroom.providers.claude import ( REMOTE_CONTROL_BASE_URL_ENV, REMOTE_CONTROL_SIBLING_GATE_NOTE, + claude_auth_conflict_message, + claude_auth_conflict_sources, detect_claude_code_version, is_custom_anthropic_base_url, remote_control_applies_to_auth, @@ -189,6 +191,39 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult: return _classify_routing_url(name, base_url, port, source=str(settings_path)) +def check_claude_auth_conflict( + settings_path: Path, + project_settings_path: Path, + project_local_settings_path: Path, + environ: Mapping[str, str], +) -> CheckResult | None: + """Report contradictory effective Claude credentials without their values.""" + + def settings_env(path: Path) -> dict[str, object]: + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + env = payload.get("env") if isinstance(payload, dict) else None + return dict(env) if isinstance(env, dict) else {} + + conflict = claude_auth_conflict_sources( + (str(settings_path), settings_env(settings_path)), + (str(project_settings_path), settings_env(project_settings_path)), + (str(project_local_settings_path), settings_env(project_local_settings_path)), + ("shell environment", environ), + ) + if conflict is None: + return None + return CheckResult( + name="claude auth", + status=FAIL, + summary=claude_auth_conflict_message(conflict), + ) + + def check_claude_remote_control_gate( settings_path: Path, environ: Mapping[str, str], @@ -567,16 +602,26 @@ def doctor(port: int, emit_json: bool) -> None: stats = probe_json(f"{base_url}/stats", timeout=5.0) if livez else None installed = get_version() + project_claude_settings = Path.cwd() / ".claude" / "settings.json" + project_local_claude_settings = Path.cwd() / ".claude" / "settings.local.json" checks = [ check_proxy_liveness(livez, base_url), check_version_drift(livez, installed), check_claude_routing(claude_settings_path(), port), - check_wrap_marker_staleness(Path.cwd() / ".claude" / "settings.local.json"), + check_wrap_marker_staleness(project_local_claude_settings), check_codex_routing(codex_config_path(), port), check_shell_env(os.environ, port), check_savings(stats, savings_path()), check_budget(stats), ] + auth_conflict_check = check_claude_auth_conflict( + claude_settings_path(), + project_claude_settings, + project_local_claude_settings, + os.environ, + ) + if auth_conflict_check is not None: + checks.append(auth_conflict_check) # Lazy resolver: `claude --version` is a Node CLI subprocess (seconds of # cold start, 10s worst-case timeout) — only pay for it when the RC gate # is actually plausible (custom base URL + subscription auth). diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index ee91a14a3..09c6642bb 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -77,6 +77,8 @@ from headroom.providers.claude import ( REMOTE_CONTROL_BASE_URL_ENV, TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, + claude_auth_conflict_message, + claude_auth_conflict_sources, claude_user_settings_path, configure_vscode_claude_settings, detect_claude_code_version, @@ -260,6 +262,30 @@ def _read_settings_for_write(path: Path) -> dict[str, Any]: return cast("dict[str, Any]", payload) +def _claude_settings_env(path: Path) -> dict[str, object]: + """Read a Claude settings env block for preflight validation.""" + env = _read_settings_for_write(path).get("env") + return dict(env) if isinstance(env, dict) else {} + + +def _raise_on_claude_auth_conflict( + *, + user_settings_path: Path, + project_settings_path: Path, + project_local_settings_path: Path, + environ: dict[str, str], +) -> None: + """Refuse an auth state Claude Code rejects before mutating wrap state.""" + conflict = claude_auth_conflict_sources( + (str(user_settings_path), _claude_settings_env(user_settings_path)), + (str(project_settings_path), _claude_settings_env(project_settings_path)), + (str(project_local_settings_path), _claude_settings_env(project_local_settings_path)), + ("shell environment", environ), + ) + if conflict is not None: + raise click.ClickException(claude_auth_conflict_message(conflict)) + + def _append_text(path: Path, content: str) -> None: """Append to a text file as UTF-8 without translating line endings.""" fsutil.append_text(path, content) @@ -4745,6 +4771,12 @@ def claude( # early proxy-start failure would make the finally raise UnboundLocalError, # masking the real error and skipping cleanup(). Mirrors the holders above. _wrap_settings_path = Path.cwd() / ".claude" / "settings.local.json" + _raise_on_claude_auth_conflict( + user_settings_path=claude_user_settings_path(), + project_settings_path=Path.cwd() / ".claude" / "settings.json", + project_local_settings_path=_wrap_settings_path, + environ=dict(os.environ), + ) cleanup = _make_cleanup(proxy_holder, port_holder) signal.signal(signal.SIGINT, _ignore_child_sigint) signal.signal(signal.SIGTERM, cleanup) diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py index 9fad1fbfb..e44238ebc 100644 --- a/headroom/providers/claude/__init__.py +++ b/headroom/providers/claude/__init__.py @@ -1,6 +1,7 @@ """Claude-specific provider helpers.""" from .runtime import ( + CLAUDE_AUTH_KEYS, DEFAULT_API_URL, REMOTE_CONTROL_BASE_URL_ENV, REMOTE_CONTROL_GATED_MIN_VERSION, @@ -8,6 +9,8 @@ from .runtime import ( REMOTE_CONTROL_SIBLING_GATE_NOTE, TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, + claude_auth_conflict_message, + claude_auth_conflict_sources, detect_claude_code_version, is_custom_anthropic_base_url, parse_claude_code_version, @@ -25,6 +28,7 @@ from .vscode import ( ) __all__ = [ + "CLAUDE_AUTH_KEYS", "claude_user_settings_path", "configure_vscode_claude_settings", "remove_vscode_claude_settings", @@ -36,6 +40,8 @@ __all__ = [ "REMOTE_CONTROL_SIBLING_GATE_NOTE", "TOOL_SEARCH_DEFAULT", "TOOL_SEARCH_ENV", + "claude_auth_conflict_message", + "claude_auth_conflict_sources", "detect_claude_code_version", "is_custom_anthropic_base_url", "parse_claude_code_version", diff --git a/headroom/providers/claude/runtime.py b/headroom/providers/claude/runtime.py index 3bd6fca91..1c7643606 100644 --- a/headroom/providers/claude/runtime.py +++ b/headroom/providers/claude/runtime.py @@ -18,6 +18,7 @@ TOOL_SEARCH_DEFAULT = "true" TOOL_SEARCH_FOUNDRY_DEFAULT = "false" REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL" REMOTE_CONTROL_FEATURE = "Remote Control" +CLAUDE_AUTH_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN") # GH #1779: Claude Code v2.1.196 added a client-side eligibility check that # DISABLES first-party Remote Control (`/remote-control` / `/rc`, which mirrors a @@ -186,6 +187,46 @@ def remote_control_applies_to_auth(environ: Mapping[str, object]) -> bool: ) +def claude_auth_conflict_sources( + *layers: tuple[str, Mapping[str, object]], +) -> dict[str, str] | None: + """Return source labels when both mutually exclusive Claude auth keys are effective. + + Layers are ordered from lowest to highest precedence. Empty values clear an + inherited value, matching environment overlay semantics. Credential values + are deliberately never returned so callers cannot leak them in diagnostics. + """ + effective: dict[str, str] = {} + sources: dict[str, str] = {} + for source, values in layers: + for key in CLAUDE_AUTH_KEYS: + if key not in values: + continue + value = str(values.get(key) or "").strip() + if value: + effective[key] = value + sources[key] = source + else: + effective.pop(key, None) + sources.pop(key, None) + if all(key in effective for key in CLAUDE_AUTH_KEYS): + return {key: sources[key] for key in CLAUDE_AUTH_KEYS} + return None + + +def claude_auth_conflict_message(sources: Mapping[str, str]) -> str: + """Format a value-free remediation for contradictory Claude credentials.""" + api_source = sources.get("ANTHROPIC_API_KEY", "effective configuration") + token_source = sources.get("ANTHROPIC_AUTH_TOKEN", "effective configuration") + return ( + "Claude Code has both ANTHROPIC_API_KEY " + f"({api_source}) and ANTHROPIC_AUTH_TOKEN ({token_source}) set. " + "Claude rejects this ambiguous auth state before Headroom can proxy a request. " + "Keep ANTHROPIC_API_KEY for API-key billing, or keep ANTHROPIC_AUTH_TOKEN " + "for token/gateway auth; remove the other key from the named source and retry." + ) + + def parse_claude_code_version(text: str | None) -> tuple[int, int, int] | None: """Parse a ``MAJOR.MINOR.PATCH`` version out of ``claude --version`` output. diff --git a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py index e4ec5ea14..62efa7308 100644 --- a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py +++ b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py @@ -148,6 +148,57 @@ def test_wrap_claude_plain_mode_api_key_auth_skips_remote_control_warning( assert "Remote Control" not in output +def test_wrap_claude_rejects_conflicting_auth_before_proxy_mutation( + runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + user_settings = tmp_path / "user-settings.json" + user_settings.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"token-value"}}', encoding="utf-8") + monkeypatch.setattr(wrap_mod, "claude_user_settings_path", lambda: user_settings) + monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/claude") + proxy_calls: list[int] = [] + monkeypatch.setattr(wrap_mod, "_register_proxy_client", lambda port: proxy_calls.append(port)) + + result = runner.invoke( + main, + ["wrap", "claude", "--no-mcp", "--no-tokensave", "--no-serena"], + env={"ANTHROPIC_API_KEY": "api-value"}, + ) + + assert result.exit_code != 0 + assert "both ANTHROPIC_API_KEY" in result.output + assert "shell environment" in result.output + assert str(user_settings) in result.output + assert "api-value" not in result.output + assert "token-value" not in result.output + assert proxy_calls == [] + + +def test_wrap_claude_includes_shared_project_settings_in_auth_precedence( + runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + user_settings = tmp_path / "user-settings.json" + user_settings.write_text("{}", encoding="utf-8") + project_dir = tmp_path / ".claude" + project_dir.mkdir() + shared_settings = project_dir / "settings.json" + shared_settings.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"token-value"}}', encoding="utf-8") + monkeypatch.setattr(wrap_mod, "claude_user_settings_path", lambda: user_settings) + monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/claude") + + result = runner.invoke( + main, + ["wrap", "claude", "--no-mcp", "--no-tokensave", "--no-serena"], + env={"ANTHROPIC_API_KEY": "api-value"}, + ) + + assert result.exit_code != 0 + assert str(shared_settings) in result.output + assert "api-value" not in result.output + assert "token-value" not in result.output + + def test_wrap_claude_sibling_note_accurate_under_1m_and_tool_search_optouts( runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 6b4837418..6fe42581d 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -575,6 +575,23 @@ class TestDoctorCommand: assert result.exit_code == 2 assert "not reachable" in result.output + def test_conflicting_claude_auth_is_a_redacted_failure(self, runner, isolated, monkeypatch): + settings = isolated / "settings.json" + settings.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"token-value"}}', encoding="utf-8") + monkeypatch.setenv("ANTHROPIC_API_KEY", "api-value") + monkeypatch.setattr(doctor_mod, "probe_json", self._probe(None, None)) + + result = runner.invoke(main, ["doctor", "--json"]) + + assert result.exit_code == 2 + payload = json.loads(result.output) + auth = next(check for check in payload["checks"] if check["name"] == "claude auth") + assert auth["status"] == "fail" + assert "shell environment" in auth["summary"] + assert str(settings) in auth["summary"] + assert "api-value" not in result.output + assert "token-value" not in result.output + def test_warnings_only_exits_1(self, runner, isolated, monkeypatch): monkeypatch.setattr(doctor_mod, "probe_json", self._probe(LIVEZ_OK, STATS_OK)) monkeypatch.setattr(doctor_mod, "get_version", lambda: "0.26.0") diff --git a/tests/test_issue_1779_remote_control_gate.py b/tests/test_issue_1779_remote_control_gate.py index 0a7a17e5b..a1ac9ab99 100644 --- a/tests/test_issue_1779_remote_control_gate.py +++ b/tests/test_issue_1779_remote_control_gate.py @@ -19,6 +19,8 @@ import pytest from headroom.providers.claude.runtime import ( REMOTE_CONTROL_GATED_MIN_VERSION, REMOTE_CONTROL_SIBLING_GATE_NOTE, + claude_auth_conflict_message, + claude_auth_conflict_sources, detect_claude_code_version, is_custom_anthropic_base_url, parse_claude_code_version, @@ -34,6 +36,34 @@ _GATED = REMOTE_CONTROL_GATED_MIN_VERSION # (2, 1, 196) _OLD = (2, 1, 195) +def test_claude_auth_conflict_tracks_precedence_without_returning_values() -> None: + conflict = claude_auth_conflict_sources( + ("user settings", {"ANTHROPIC_AUTH_TOKEN": "secret-token"}), + ("project settings", {"ANTHROPIC_API_KEY": "secret-api"}), + ("shell environment", {}), + ) + + assert conflict == { + "ANTHROPIC_API_KEY": "project settings", + "ANTHROPIC_AUTH_TOKEN": "user settings", + } + message = claude_auth_conflict_message(conflict) + assert "secret-token" not in message + assert "secret-api" not in message + assert "project settings" in message + assert "user settings" in message + + +def test_claude_auth_conflict_higher_precedence_empty_value_clears_key() -> None: + assert ( + claude_auth_conflict_sources( + ("settings", {"ANTHROPIC_AUTH_TOKEN": "token", "ANTHROPIC_API_KEY": "key"}), + ("shell", {"ANTHROPIC_API_KEY": ""}), + ) + is None + ) + + # --------------------------------------------------------------------------- # Message accuracy — deterministic wording, not "may" # --------------------------------------------------------------------------- From 888a9f4e147cf1f87244977fac81d5e9613352d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:25:43 -0500 Subject: [PATCH 4/5] deps: bump the cargo-minor-patch group across 1 directory with 4 updates (#2964) Bumps the cargo-minor-patch group with 4 updates in the / directory: [aws-config](https://github.com/smithy-lang/smithy-rs), [rusqlite](https://github.com/rusqlite/rusqlite), [async-trait](https://github.com/dtolnay/async-trait) and [cc](https://github.com/rust-lang/cc-rs). Updates `aws-config` from 1.10.0 to 1.10.1
Commits

Updates `rusqlite` from 0.40.1 to 0.40.2
Release notes

Sourced from rusqlite's releases.

0.40.2

What's Changed

  • Lower MSRV to 1.88.0

Full Changelog: https://github.com/rusqlite/rusqlite/compare/v0.40.1...v0.40.2

Commits

Updates `async-trait` from 0.1.91 to 0.1.92
Release notes

Sourced from async-trait's releases.

0.1.92

  • Resolve double_must_use clippy lint in generated code (#303)
Commits

Updates `cc` from 1.4.1 to 1.4.2
Release notes

Sourced from cc's releases.

cc-v1.4.2

Fixed

  • Infer NEON, not VFPv4, from neon in the target name (#1843)
  • do not emit -mno-omit-leaf-frame-pointer if unsupported (#1845)
Changelog

Sourced from cc's changelog.

1.4.2 - 2026-08-08

Fixed

  • Infer NEON, not VFPv4, from neon in the target name (#1843)
  • do not emit -mno-omit-leaf-frame-pointer if unsupported (#1845)
Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3daec4d06..ccc61141b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,9 +194,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -260,9 +260,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "701418aa459dac33e50a0f8e818e5662a16bc018a6ac7423659b70f3799d67a8" +checksum = "1b180a3c8b55960db3426d8964b8745e652466a1a49fe1a2eda828046d30b5e4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -325,9 +325,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6b50a43f3ccdf331521c6d6c68b7cc9668b6e09d439ebda9569df5722324d76" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -350,9 +350,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.104.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53416d16c278234845392e38d93bd4481d2f09daa0f005a2277f0aa91f59c22" +checksum = "6ffd0fbe7873cb548a7aa60f9573c268fff94155397fd4f14dc9f1ecaaab8516" dependencies = [ "arc-swap", "aws-credential-types", @@ -376,9 +376,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.106.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9b706c3305ed0285d5b1b696c747aa34950f830fb03e3e6c76890f99b9f188" +checksum = "175763eb222a46377df7aa257a3bca980ab3e96703fefc8f4d0b8da6ad2e254c" dependencies = [ "arc-swap", "aws-credential-types", @@ -402,9 +402,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.109.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d214cdfa5bbe17f117e76a7643fadf32a5234fb597322ef8b1fb4b2f17dbbd" +checksum = "dd8b14781dfbff48984017d57167b6ea0b6471c6920ec52b44a2677c7feb3c13" dependencies = [ "arc-swap", "aws-credential-types", @@ -540,9 +540,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" +checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -894,9 +894,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -2552,9 +2552,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3681,9 +3681,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags", "fallible-iterator", From bbe901319d49a3d70caf7b37da2c29f7d7996e07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:50:15 -0500 Subject: [PATCH 5/5] deps: bump tokio-tungstenite from 0.24.0 to 0.30.0 (#2967) Bumps [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) from 0.24.0 to 0.30.0.
Changelog

Sourced from tokio-tungstenite's changelog.

0.30.0

0.29.0

0.28.0

0.27.0

0.26.2

0.26.1

  • Update tungstenite to address an issue that might cause UB in certain cases.

0.26.0

0.25.0

Commits
  • 4994a07 Bump version
  • 753ca72 Document cancel safety of reading from WebSocketStream (#378)
  • 751d7e2 Update version number listed in Readme (#375)
  • 57fc3d0 docs(CHANGELOG.md): fix tungstenite versions (#374)
  • 7930ff2 Bump version
  • 38d0465 Update Readme (#369)
  • 35d110c Implement into_inner to get the underlying stream (#367)
  • f3ae75d Update tungstenite version and fix bugs
  • 25b544e Allow getting a reference to the shared inner stream (#363)
  • e855f9e Fix errors in the examples caused by Utf8Error
  • Additional commits viewable in compare view

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JerrettDavis --- Cargo.lock | 84 +++++++++++++++++-- crates/headroom-proxy/Cargo.toml | 4 +- crates/headroom-proxy/src/websocket.rs | 8 +- crates/headroom-proxy/tests/integration_ws.rs | 4 +- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ccc61141b..2da32bedf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,7 +281,7 @@ dependencies = [ "fastrand", "hex", "http 1.5.0", - "sha1", + "sha1 0.10.6", "time", "tokio", "tracing", @@ -682,10 +682,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower", "tower-layer", "tower-service", @@ -916,6 +916,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1782,6 +1793,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1954,7 +1966,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.30.0", "tokio-util", "tower", "tower-http 0.7.0", @@ -3402,6 +3414,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3440,6 +3463,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -3955,6 +3984,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4396,6 +4436,18 @@ name = "tokio-tungstenite" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -4403,7 +4455,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.30.0", "webpki-roots 0.26.11", ] @@ -4729,13 +4781,29 @@ dependencies = [ "httparse", "log", "rand 0.8.6", - "rustls", - "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.10.2", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", + "thiserror 2.0.20", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/crates/headroom-proxy/Cargo.toml b/crates/headroom-proxy/Cargo.toml index 813b826a7..b1f5088f1 100644 --- a/crates/headroom-proxy/Cargo.toml +++ b/crates/headroom-proxy/Cargo.toml @@ -23,7 +23,7 @@ tower-http = { version = "0.7", features = ["trace", "request-id", "util"] } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["json", "env-filter", "fmt"] } reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "http2"] } -tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } clap = { workspace = true, features = ["derive", "env"] } serde = { workspace = true } serde_json = { workspace = true } @@ -98,7 +98,7 @@ md-5 = "0.10" tower = { workspace = true, features = ["util"] } wiremock = "0.6" reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls", "http2", "json"] } -tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } futures-util = "0.3" tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "net", "io-util", "time", "test-util", "process"] } hyper = { version = "1", features = ["server", "http1", "http2"] } diff --git a/crates/headroom-proxy/src/websocket.rs b/crates/headroom-proxy/src/websocket.rs index c3a6358c6..509cd6bfd 100644 --- a/crates/headroom-proxy/src/websocket.rs +++ b/crates/headroom-proxy/src/websocket.rs @@ -218,10 +218,10 @@ async fn run_ws_pump( fn ax_to_tg(m: AxMsg) -> Option { Some(match m { - AxMsg::Text(t) => TgMsg::Text(t.to_string()), - AxMsg::Binary(b) => TgMsg::Binary(b.to_vec()), - AxMsg::Ping(p) => TgMsg::Ping(p.to_vec()), - AxMsg::Pong(p) => TgMsg::Pong(p.to_vec()), + AxMsg::Text(t) => TgMsg::Text(t.to_string().into()), + AxMsg::Binary(b) => TgMsg::Binary(b.to_vec().into()), + AxMsg::Ping(p) => TgMsg::Ping(p.to_vec().into()), + AxMsg::Pong(p) => TgMsg::Pong(p.to_vec().into()), AxMsg::Close(Some(cf)) => TgMsg::Close(Some(TgCloseFrame { code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(cf.code), reason: cf.reason.to_string().into(), diff --git a/crates/headroom-proxy/tests/integration_ws.rs b/crates/headroom-proxy/tests/integration_ws.rs index 7f9688d87..1c29aaf3a 100644 --- a/crates/headroom-proxy/tests/integration_ws.rs +++ b/crates/headroom-proxy/tests/integration_ws.rs @@ -53,7 +53,7 @@ async fn ws_text_and_binary_round_trip() { for i in 0..5 { let m = format!("hello-{i}"); - ws.send(Message::Text(m.clone())).await.unwrap(); + ws.send(Message::Text(m.clone().into())).await.unwrap(); let echoed = ws.next().await.unwrap().unwrap(); match echoed { Message::Text(t) => assert_eq!(t.as_str(), m), @@ -62,7 +62,7 @@ async fn ws_text_and_binary_round_trip() { } for i in 0..5u8 { let m: Vec = (0..32u8).map(|b| b ^ i).collect(); - ws.send(Message::Binary(m.clone())).await.unwrap(); + ws.send(Message::Binary(m.clone().into())).await.unwrap(); let echoed = ws.next().await.unwrap().unwrap(); match echoed { Message::Binary(b) => assert_eq!(b.to_vec(), m),