alishahryar1--free-claude-code
258ed3e7ed
## Problem FCC-local health and model-catalog requests inherited machine proxy settings, so an outbound proxy could return HTTP 502 even while `fcc-server` was healthy. Spawned agents inherited the same missing loopback bypass. Fixes #1199. ## Changes | Before | After | | --- | --- | | Launcher probes used the process-wide urllib proxy policy. | FCC-local probes use one proxy-disabled transport owner. | | Codex catalog loading could leave through an outbound proxy. | Codex catalog loading uses the same direct local transport. | | Claude, Codex, Pi, and managed messaging inherited incomplete bypass lists. | Spawned clients preserve outbound proxies while merging FCC and loopback hosts into `NO_PROXY` and `no_proxy`. | | Local and upstream proxy responsibilities overlapped. | CLI-local transport and provider-specific upstream proxies have explicit separate owners. | | Tests mocked launcher reachability without exercising proxy interception. | A real fake-proxy regression proves loopback traffic never reaches the proxy, and an ownership contract prevents raw local transports from returning. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR keeps FCC-local traffic separate from configured outbound proxies. The main changes are: - Adds one direct HTTP transport for FCC health and model-catalog requests. - Adds FCC and loopback hosts to spawned clients' proxy-bypass environments. - Updates Claude, Codex, and Pi launcher integration. - Adds proxy-interception and transport-ownership tests. - Documents the boundary and bumps the package version. </details> <h3>Confidence Score: 5/5</h3> This looks safe to merge. No blocking issues found in the changed code. Updated callers supply the new Codex environment argument. The direct opener changes proxy handling while retaining urllib's standard request handlers. Tests cover real proxy interception and preservation of existing bypass policy. No changed files need additional attention. <details><summary><h3><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="20" align="absmiddle"></a> T-Rex Logs</h3></summary> **What T-Rex did** - T-Rex validated the contract by comparing preflight and forward-proxy state before and after HEAD, noting the preflight result moved from HTTP 502 and the forward-proxy hits to a clean health-check state. - T-Rex confirmed all three tests in tests/cli/test\_local\_http.py passed. <a href="https://app.greptile.com/trex/runs/15066178/artifacts"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img alt="View all artifacts" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a> <sub><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="14" align="absmiddle"></a> Ran code and verified through T-Rex</sub> </details> <details open><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | src/free_claude_code/cli/local_http.py | Adds the shared direct opener and proxy-bypass environment policy. | | src/free_claude_code/cli/launchers/common.py | Routes FCC health checks through the direct local transport. | | src/free_claude_code/cli/launchers/codex.py | Routes catalog requests directly and adds local bypass entries to the Codex environment. | | src/free_claude_code/cli/claude_env.py | Adds FCC and loopback proxy bypasses to Claude environments. | | src/free_claude_code/cli/launchers/pi.py | Adds FCC and loopback proxy bypasses to Pi environments. | | tests/cli/test_local_http.py | Tests direct local access, proxy interception, environment merging, and deduplication. | | tests/contracts/test_import_boundaries.py | Enforces one owner for direct local transports and proxy-bypass variables. | </details> <details open><summary><h3>Flowchart</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart LR L[Launcher] --> R[FCC health or catalog request] R --> D[Direct local opener] D --> F[FCC server] L --> E[Build child environment] E --> N[Merge FCC and loopback hosts into NO_PROXY] N --> C[Claude, Codex, or Pi] C -->|FCC-local traffic| F C -->|Other outbound traffic| P[Configured outbound proxy] ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart LR L[Launcher] --> R[FCC health or catalog request] R --> D[Direct local opener] D --> F[FCC server] L --> E[Build child environment] E --> N[Merge FCC and loopback hosts into NO_PROXY] N --> C[Claude, Codex, or Pi] C -->|FCC-local traffic| F C -->|Other outbound traffic| P[Configured outbound proxy] ``` </a> </details> <sub>Reviews (1): Last reviewed commit: ["Keep FCC-local traffic off outbound prox..."](https://github.com/alishahryar1/free-claude-code/commit/072cc4b7474c1b33bfc368d148da5c1aa24914b6) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=45589926)</sub> <!-- /greptile_comment -->
225 行
7.6 KiB
Python
225 行
7.6 KiB
Python
import os
|
|
|
|
from free_claude_code.cli.claude_env import build_claude_proxy_env
|
|
from free_claude_code.cli.managed.claude import (
|
|
MANAGED_CLAUDE_MODEL_TIER,
|
|
ManagedClaudeConfig,
|
|
ManagedClaudeParseState,
|
|
ManagedClaudeTaskRequest,
|
|
build_managed_claude_env,
|
|
build_managed_claude_invocation,
|
|
extract_managed_claude_session_id,
|
|
parse_managed_claude_stdout_line,
|
|
)
|
|
from free_claude_code.cli.managed.diagnostics import classify_managed_claude_stderr
|
|
|
|
|
|
def _config(**overrides: object) -> ManagedClaudeConfig:
|
|
workspace_path = overrides.get("workspace_path", os.path.normpath("/tmp/workspace"))
|
|
proxy_root_url = overrides.get("proxy_root_url", "http://localhost:8082")
|
|
raw_allowed_dirs = overrides.get("allowed_dirs")
|
|
allowed_dirs: list[str] = []
|
|
if raw_allowed_dirs is not None:
|
|
assert isinstance(raw_allowed_dirs, list)
|
|
for directory in raw_allowed_dirs:
|
|
assert isinstance(directory, str)
|
|
allowed_dirs.append(directory)
|
|
claude_bin = overrides.get("claude_bin", "claude")
|
|
auth_token = overrides.get("auth_token", "proxy-token")
|
|
|
|
assert isinstance(workspace_path, str)
|
|
assert isinstance(proxy_root_url, str)
|
|
assert isinstance(claude_bin, str)
|
|
assert isinstance(auth_token, str)
|
|
return ManagedClaudeConfig(
|
|
workspace_path=workspace_path,
|
|
proxy_root_url=proxy_root_url,
|
|
allowed_dirs=allowed_dirs,
|
|
claude_bin=claude_bin,
|
|
auth_token=auth_token,
|
|
)
|
|
|
|
|
|
def test_managed_claude_defaults_to_fable() -> None:
|
|
assert MANAGED_CLAUDE_MODEL_TIER == "fable"
|
|
|
|
|
|
def test_managed_claude_builds_new_task_command_and_env() -> None:
|
|
invocation = build_managed_claude_invocation(
|
|
config=_config(allowed_dirs=[os.path.normpath("/tmp/extra")]),
|
|
request=ManagedClaudeTaskRequest(prompt="hello"),
|
|
base_env={"PATH": "keep", "ANTHROPIC_API_KEY": "official"},
|
|
)
|
|
|
|
assert invocation.argv[:4] == (
|
|
"claude",
|
|
"--model",
|
|
MANAGED_CLAUDE_MODEL_TIER,
|
|
"-p",
|
|
)
|
|
assert "hello" in invocation.argv
|
|
assert "--output-format" in invocation.argv
|
|
assert "stream-json" in invocation.argv
|
|
assert "--add-dir" in invocation.argv
|
|
assert os.path.normpath("/tmp/extra") in invocation.argv
|
|
assert "--settings" not in invocation.argv
|
|
assert invocation.env["PATH"] == "keep"
|
|
assert invocation.env["ANTHROPIC_BASE_URL"] == "http://localhost:8082"
|
|
assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
|
|
assert invocation.env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
|
assert invocation.env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000"
|
|
assert invocation.env["DISABLE_AUTOUPDATER"] == "1"
|
|
assert invocation.env["DISABLE_FEEDBACK_COMMAND"] == "1"
|
|
assert invocation.env["DISABLE_ERROR_REPORTING"] == "1"
|
|
assert invocation.env["DISABLE_TELEMETRY"] == "1"
|
|
assert invocation.env["NO_PROXY"] == "127.0.0.1,localhost,::1"
|
|
assert invocation.env["no_proxy"] == invocation.env["NO_PROXY"]
|
|
assert "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" not in invocation.env
|
|
assert "ANTHROPIC_API_URL" not in invocation.env
|
|
assert "ANTHROPIC_API_KEY" not in invocation.env
|
|
assert invocation.trace_metadata["client_cli_id"] == "claude"
|
|
assert invocation.trace_metadata["claude_binary"] == "claude"
|
|
assert invocation.trace_metadata["managed_model_tier"] == MANAGED_CLAUDE_MODEL_TIER
|
|
|
|
|
|
def test_managed_claude_builds_resume_and_fork_commands() -> None:
|
|
resume = build_managed_claude_invocation(
|
|
config=_config(),
|
|
request=ManagedClaudeTaskRequest(prompt="again", session_id="sess_1"),
|
|
base_env={},
|
|
)
|
|
fork = build_managed_claude_invocation(
|
|
config=_config(),
|
|
request=ManagedClaudeTaskRequest(
|
|
prompt="branch", session_id="sess_1", fork_session=True
|
|
),
|
|
base_env={},
|
|
)
|
|
|
|
assert resume.argv[:6] == (
|
|
"claude",
|
|
"--resume",
|
|
"sess_1",
|
|
"--model",
|
|
MANAGED_CLAUDE_MODEL_TIER,
|
|
"-p",
|
|
)
|
|
assert "--fork-session" not in resume.argv
|
|
assert fork.argv[:7] == (
|
|
"claude",
|
|
"--resume",
|
|
"sess_1",
|
|
"--fork-session",
|
|
"--model",
|
|
MANAGED_CLAUDE_MODEL_TIER,
|
|
"-p",
|
|
)
|
|
assert "--fork-session" in fork.argv
|
|
|
|
|
|
def test_managed_claude_uses_native_plan_storage() -> None:
|
|
invocation = build_managed_claude_invocation(
|
|
config=_config(),
|
|
request=ManagedClaudeTaskRequest(prompt="hello"),
|
|
base_env={},
|
|
)
|
|
|
|
assert "--settings" not in invocation.argv
|
|
|
|
|
|
def test_managed_claude_env_uses_sentinel_when_proxy_auth_blank() -> None:
|
|
env = build_managed_claude_env(
|
|
proxy_root_url="http://localhost:8082",
|
|
auth_token="",
|
|
base_env={"ANTHROPIC_AUTH_TOKEN": "stale"},
|
|
)
|
|
|
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth"
|
|
|
|
|
|
def test_managed_claude_env_only_adds_noninteractive_process_settings() -> None:
|
|
base_env = {
|
|
"PATH": "keep",
|
|
"ANTHROPIC_API_URL": "https://api.anthropic.com/v1",
|
|
"ANTHROPIC_API_KEY": "official-key",
|
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
"DISABLE_TELEMETRY": "0",
|
|
}
|
|
proxy_env = build_claude_proxy_env(
|
|
proxy_root_url="http://localhost:8082",
|
|
auth_token="proxy-token",
|
|
base_env=base_env,
|
|
)
|
|
|
|
managed_env = build_managed_claude_env(
|
|
proxy_root_url="http://localhost:8082",
|
|
auth_token="proxy-token",
|
|
base_env=base_env,
|
|
)
|
|
|
|
assert managed_env == {
|
|
**proxy_env,
|
|
"TERM": "dumb",
|
|
"PYTHONIOENCODING": "utf-8",
|
|
}
|
|
|
|
|
|
def test_managed_claude_stderr_classifier_filters_known_benign_notice() -> None:
|
|
diagnostics = classify_managed_claude_stderr(
|
|
"claude.ai connectors are disabled in this environment"
|
|
)
|
|
|
|
assert diagnostics.has_benign
|
|
assert diagnostics.benign_lines == (
|
|
"claude.ai connectors are disabled in this environment",
|
|
)
|
|
assert diagnostics.fatal_text is None
|
|
|
|
|
|
def test_managed_claude_stderr_classifier_preserves_unknown_lines() -> None:
|
|
diagnostics = classify_managed_claude_stderr(
|
|
"claude.ai connectors are disabled in this environment\nFatal error"
|
|
)
|
|
|
|
assert diagnostics.has_benign
|
|
assert diagnostics.fatal_text == "Fatal error"
|
|
|
|
|
|
def test_managed_claude_extracts_session_ids() -> None:
|
|
assert extract_managed_claude_session_id({"session_id": "direct"}) == "direct"
|
|
assert extract_managed_claude_session_id({"sessionId": "camel"}) == "camel"
|
|
assert (
|
|
extract_managed_claude_session_id({"init": {"session_id": "nested"}})
|
|
== "nested"
|
|
)
|
|
assert (
|
|
extract_managed_claude_session_id({"result": {"sessionId": "result"}})
|
|
== "result"
|
|
)
|
|
assert extract_managed_claude_session_id({"conversation": {"id": "conv"}}) == "conv"
|
|
assert extract_managed_claude_session_id({"type": "message"}) is None
|
|
assert extract_managed_claude_session_id("not a dict") is None
|
|
|
|
|
|
def test_managed_claude_parser_emits_session_info_once() -> None:
|
|
state = ManagedClaudeParseState()
|
|
|
|
first = list(parse_managed_claude_stdout_line('{"session_id": "sess_1"}', state))
|
|
second = list(parse_managed_claude_stdout_line('{"session_id": "sess_2"}', state))
|
|
|
|
assert first == [
|
|
{"type": "session_info", "session_id": "sess_1"},
|
|
{"session_id": "sess_1"},
|
|
]
|
|
assert second == [{"session_id": "sess_2"}]
|
|
|
|
|
|
def test_managed_claude_parser_returns_raw_for_non_json() -> None:
|
|
events = list(
|
|
parse_managed_claude_stdout_line(
|
|
"not json", ManagedClaudeParseState(log_raw_cli_diagnostics=False)
|
|
)
|
|
)
|
|
|
|
assert events == [{"type": "raw", "content": "not json"}]
|