项目文件夹

文件
Ali Khokhar 343e5674bf Restore Claude model discovery without enabling telemetry (#1118)
## Problem

Claude Code's aggregate nonessential-traffic flag overrides gateway
discovery, so `fcc-claude` never requests `/v1/models` and `/model`
omits FCC models. Deleting the flag alone also re-enables updater,
feedback, error-reporting, and telemetry traffic. Fixes #1112.

## Changes

| Before | After |
| --- | --- |
| FCC forced the aggregate traffic flag, which suppressed gateway model
discovery. | The shared Claude environment removes inherited aggregate
flags and enables gateway discovery. |
| Removing the aggregate flag would restore every optional traffic
category. | Four granular opt-outs preserve the existing updater,
feedback, error-reporting, and telemetry policy. |
| VS Code and JetBrains examples documented the discovery-blocking flag.
| Both IDE examples use the same granular environment policy as
`fcc-claude`. |
| The retry smoke counted locally short-circuited auxiliary requests as
provider retries. | The retry smoke counts terminal failures and still
requires exactly one upstream provider attempt. |
| Package metadata reported version 4.5.1. | Package metadata and the
lockfile report version 4.5.2. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR restores Claude gateway model discovery while keeping most
optional Claude traffic disabled. The main changes are:

- Replaces the aggregate Claude traffic flag with granular opt-out
environment variables.
- Updates VS Code and JetBrains setup examples to match the new
environment policy.
- Adds smoke coverage for `/v1/models` discovery through the local
gateway.
- Adjusts the provider-error smoke retry count.
- Bumps package metadata and the lockfile to 4.5.2.
</details>

<h3>Confidence Score: 4/5</h3>

The Claude environment policy has one contained traffic-control
regression.

Gateway discovery is enabled through the changed launch environment, and
package version sources are consistent.

src/free_claude_code/cli/claude_env.py and README.md need the granular
opt-out set checked because it does not cover feedback-survey traffic
that the removed aggregate flag previously blocked.

<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**
- Reproduced the Feedback Survey Traffic Reenabled scenario by running a
focused Python repro harness that imports build\_claude\_proxy\_env with
CLAUDE\_CODE\_DISABLE\_NONESSENTIAL\_TRAFFIC in the base environment.
- Used the repro harness to verify that
CLAUDE\_CODE\_ENABLE\_GATEWAY\_MODEL\_DISCOVERY is set to '1' and the
four granular opt-outs are '1', while
CLAUDE\_CODE\_DISABLE\_NONESSENTIAL\_TRAFFIC is removed and
CLAUDE\_CODE\_DISABLE\_FEEDBACK\_SURVEY is absent.
- Observed in the repro command output that the aggregate traffic flag
was stripped and the feedback-survey opt-out was absent, confirming the
expected environment state.
- Performed broader contract validation: the env policy harness shows
the expected proxy/managed env state with aggregate flag absent and
gateway model discovery enabled, the targeted CLI tests total 96 with
exit code 0, and the CLAUDE CLI is unavailable in this environment (exit
code 127).

<a
href="https://app.greptile.com/trex/runs/14470129/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/claude_env.py | Builds the Claude child
environment with gateway discovery and granular traffic opt-outs, but
misses the feedback-survey opt-out. |
| README.md | Updates IDE examples to use the same granular Claude
environment policy as the runtime helper. |
| smoke/prereq/test_cli_prereq_live.py | Adds a live assertion that
Claude discovers models through the local gateway. |
| smoke/product/test_client_product_live.py | Counts terminal failed
downstream message requests while preserving the provider-attempt
assertion. |
| pyproject.toml | Bumps the project version to 4.5.2. |
| uv.lock | Updates the locked editable package version to 4.5.2. |

</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22ali%2Frestore-claude-model-discovery%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Frestore-claude-model-discovery%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Asrc%2Ffree_claude_code%2Fcli%2Fclaude_env.py%3A30-33%0A**Feedback%20Survey%20Traffic%20Reenabled**%0A%0AWhen%20%60fcc-claude%60%20or%20a%20managed%20Claude%20task%20is%20launched%20through%20this%20helper%2C%20the%20old%20aggregate%20opt-out%20no%20longer%20suppresses%20Claude's%20session%20feedback%20survey%20path.%20The%20replacement%20only%20disables%20updater%2C%20feedback%20command%2C%20error%20reporting%2C%20and%20telemetry%2C%20so%20restricted-network%20or%20privacy-controlled%20runs%20can%20still%20show%20or%20send%20nonessential%20feedback-survey%20traffic%20that%20was%20previously%20blocked.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1118&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (1): Last reviewed commit: ["Restore Claude gateway model
discovery"](https://github.com/alishahryar1/free-claude-code/commit/03e915adfb6f49d96e492e4659c3e00a3f7b12dc)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=44306473)</sub>

> Greptile also left **1 inline comment** on this PR.

**Context used:**

- Context used - CLAUDE.md
([source](https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/custom-context?memory=d2fd24d8-0dec-4faf-8ee4-e085e215a2f8))

<!-- /greptile_comment -->
2026-07-14 16:12:16 -07:00

771 行
27 KiB
Python

"""Tests for cli/ module."""
import asyncio
import os
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from free_claude_code.messaging.event_parser import parse_cli_event
# --- Existing Parser Tests ---
class TestCLIParser:
"""Test CLI event parsing."""
def test_parse_text_content(self):
"""Test parsing text content from assistant message."""
event = {
"type": "assistant",
"message": {"content": [{"type": "text", "text": "Hello, world!"}]},
}
result = parse_cli_event(event)
assert len(result) == 1
assert result[0]["type"] == "text_chunk"
assert result[0]["text"] == "Hello, world!"
def test_parse_thinking_content(self):
"""Test parsing thinking content."""
event = {
"type": "assistant",
"message": {
"content": [{"type": "thinking", "thinking": "Let me think..."}]
},
}
result = parse_cli_event(event)
assert len(result) == 1
assert result[0]["type"] == "thinking_chunk"
assert (
result[0]["text"] == "Let me think...\n"
or result[0]["text"] == "Let me think..."
)
def test_parse_multiple_content(self):
"""Test parsing mixed content (thinking + tools)."""
event = {
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Thinking..."},
{"type": "tool_use", "name": "ls", "input": {}},
]
},
}
result = parse_cli_event(event)
assert len(result) == 2
assert result[0]["type"] == "thinking_chunk"
assert result[0]["text"] == "Thinking..."
assert result[1]["type"] == "tool_use"
def test_parse_tool_use(self):
"""Test parsing tool use content."""
event = {
"type": "assistant",
"message": {
"content": [
{
"type": "tool_use",
"name": "read_file",
"input": {"path": "/test"},
}
]
},
}
result = parse_cli_event(event)
assert len(result) == 1
assert result[0]["type"] == "tool_use"
assert result[0]["name"] == "read_file"
assert result[0]["input"] == {"path": "/test"}
def test_parse_text_delta(self):
"""Test parsing streaming text delta."""
event = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "streaming text"},
}
result = parse_cli_event(event)
assert len(result) == 1
assert result[0]["type"] == "text_delta"
assert result[0]["text"] == "streaming text"
def test_parse_thinking_delta(self):
"""Test parsing streaming thinking delta."""
event = {
"type": "content_block_delta",
"index": 1,
"delta": {"type": "thinking_delta", "thinking": "thinking..."},
}
result = parse_cli_event(event)
assert len(result) == 1
assert result[0]["type"] == "thinking_delta"
assert result[0]["text"] == "thinking..."
def test_parse_error(self):
"""Test parsing error event."""
event = {"type": "error", "error": {"message": "Something went wrong"}}
result = parse_cli_event(event)
assert result[0]["type"] == "error"
assert result[0]["message"] == "Something went wrong"
def test_parse_exit_success(self):
"""Test parsing exit event with success."""
event = {"type": "exit", "code": 0}
result = parse_cli_event(event)
assert result[0]["type"] == "complete"
assert result[0]["status"] == "success"
def test_parse_exit_failure(self):
"""Test parsing exit event with failure returns an error only."""
event = {"type": "exit", "code": 1}
result = parse_cli_event(event)
assert len(result) == 1
assert result[0] == {
"type": "error",
"message": "Process exited with code 1",
"source": "exit",
"exit_code": 1,
}
assert (
"exit" in result[0]["message"].lower()
or "code" in result[0]["message"].lower()
)
def test_parse_invalid_event(self):
"""Test parsing returns empty list for unrecognized event."""
result = parse_cli_event({"type": "unknown"})
assert result == []
def test_parse_non_dict(self):
"""Test parsing returns empty list for non-dict input."""
result = parse_cli_event("not a dict")
assert result == []
# --- CLI Session Tests ---
class TestManagedClaudeSession:
"""Test ManagedClaudeSession."""
def test_session_init(self):
"""Test ManagedClaudeSession initialization."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession(
workspace_path="/tmp/test",
proxy_root_url="http://localhost:8082",
allowed_dirs=["/home/user/projects"],
)
assert session.workspace == os.path.normpath(os.path.abspath("/tmp/test"))
assert session.proxy_root_url == "http://localhost:8082"
assert not session.is_busy
def test_session_extract_session_id(self):
"""Test session ID extraction from various event formats."""
from free_claude_code.cli.managed.claude import (
extract_managed_claude_session_id,
)
# Direct session_id field
assert extract_managed_claude_session_id({"session_id": "abc123"}) == "abc123"
assert extract_managed_claude_session_id({"sessionId": "abc123"}) == "abc123"
# Nested in init
assert (
extract_managed_claude_session_id({"init": {"session_id": "nested123"}})
== "nested123"
)
# Nested in result
assert (
extract_managed_claude_session_id({"result": {"session_id": "res123"}})
== "res123"
)
# Conversation id
assert (
extract_managed_claude_session_id({"conversation": {"id": "conv123"}})
== "conv123"
)
# No session ID
assert extract_managed_claude_session_id({"type": "message"}) is None
assert extract_managed_claude_session_id("not a dict") is None
@pytest.mark.asyncio
async def test_start_task_basic_flow(self):
"""Test start_task running a basic command flow."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
# Mock subprocess
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [
b'{"type": "message", "content": "Hello"}\n',
b'{"session_id": "sess_1"}\n',
b"", # EOF
]
mock_process.stderr.read.return_value = b"" # No error
mock_process.wait.return_value = 0
mock_process.returncode = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("Hello")]
# Verify command construction
# Arg 1 is subprocess command
args = mock_exec.call_args[0]
assert args[0] == "claude"
assert "-p" in args
assert "Hello" in args
# Verify events
assert (
len(events) == 4
) # message, session_id, session_info (synthesized), exit
assert events[0] == {"type": "message", "content": "Hello"}
assert events[1] == {"type": "session_info", "session_id": "sess_1"}
# The session_info event is yielded by _handle_line_gen right after extracting ID
assert events[2] == {"session_id": "sess_1"} # The original event
assert events[3] == {"type": "exit", "code": 0, "stderr": None}
assert session.current_session_id == "sess_1"
@pytest.mark.asyncio
async def test_start_task_with_session_resume(self):
"""Test resuming an existing session."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [
b"",
] # Immediate EOF
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
async for _ in session.start_task("Hello", session_id="sess_abc"):
pass
args = mock_exec.call_args[0]
assert "--resume" in args
assert "sess_abc" in args
assert "--fork-session" not in args
@pytest.mark.asyncio
async def test_start_task_with_session_resume_and_fork(self):
"""Test resuming an existing session and forking."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""] # Immediate EOF
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
async for _ in session.start_task(
"Hello", session_id="sess_abc", fork_session=True
):
pass
args = mock_exec.call_args[0]
assert "--resume" in args
assert "sess_abc" in args
assert "--fork-session" in args
@pytest.mark.asyncio
async def test_start_task_process_failure_with_stderr(self):
"""Test process exit with error code and stderr output."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""] # No stdout
mock_process.stderr.read.side_effect = [b"Fatal error", b""]
mock_process.wait.return_value = 1
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("Hello")]
# Should have error event from stderr, then exit event
assert len(events) == 2
assert events[0]["type"] == "error"
assert events[0]["error"]["message"] == "Fatal error"
assert events[1]["type"] == "exit"
assert events[1]["code"] == 1
assert events[1]["stderr"] == "Fatal error"
@pytest.mark.asyncio
async def test_start_task_stderr_while_stdout_streams(self):
"""Stderr is drained concurrently so stdout streaming is not blocked."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [
b'{"type": "message", "content": "Hi"}\n',
b"",
]
mock_process.stderr.read.side_effect = [b"warning on stderr\n", b""]
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("Hello")]
assert mock_process.stderr.read.await_count >= 2
err_events = [e for e in events if e.get("type") == "error"]
assert len(err_events) == 1
assert "warning on stderr" in err_events[0]["error"]["message"]
assert events[-1]["type"] == "exit"
assert events[-1]["code"] == 0
@pytest.mark.asyncio
async def test_start_task_ignores_benign_claude_connectors_stderr(self):
"""Known Claude diagnostics on stderr are not surfaced as task failures."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [
b'{"type": "message", "content": "Hi"}\n',
b"",
]
mock_process.stderr.read.side_effect = [
b"claude.ai connectors are disabled in this environment\n",
b"",
]
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("Hello")]
assert [e for e in events if e.get("type") == "error"] == []
assert events[-1] == {"type": "exit", "code": 0, "stderr": None}
@pytest.mark.asyncio
async def test_start_task_mixed_stderr_reports_only_fatal_lines(self):
"""Benign stderr diagnostics are filtered without hiding real failures."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""]
mock_process.stderr.read.side_effect = [
(b"claude.ai connectors are disabled in this environment\nFatal error\n"),
b"",
]
mock_process.wait.return_value = 1
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("Hello")]
assert len(events) == 2
assert events[0] == {"type": "error", "error": {"message": "Fatal error"}}
assert events[1] == {"type": "exit", "code": 1, "stderr": "Fatal error"}
@pytest.mark.asyncio
async def test_start_task_nonzero_with_only_benign_stderr_has_no_stderr_error(
self,
):
"""A benign stderr line is not duplicated as the process failure reason."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""]
mock_process.stderr.read.side_effect = [
b"claude.ai connectors are disabled in this environment\n",
b"",
]
mock_process.wait.return_value = 1
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("Hello")]
assert events == [{"type": "exit", "code": 1, "stderr": None}]
@pytest.mark.asyncio
async def test_drain_stderr_bounded_retains_cap_but_drains_to_eof(self):
"""Oversized stderr is fully drained so the pipe cannot deadlock; capture is bounded."""
from free_claude_code.cli.managed.session import (
_MAX_STDERR_CAPTURE_BYTES,
ManagedClaudeSession,
)
total_len = _MAX_STDERR_CAPTURE_BYTES + 100_000
remaining: dict[str, int] = {"n": total_len}
class _FakeStderr:
async def read(self, n: int = 65536) -> bytes:
left = remaining["n"]
if left <= 0:
return b""
take = min(n, left)
remaining["n"] = left - take
return b"y" * take
class _FakeProcess:
stderr = _FakeStderr()
out = await ManagedClaudeSession._drain_stderr_bounded(
cast(asyncio.subprocess.Process, _FakeProcess())
)
assert len(out) == _MAX_STDERR_CAPTURE_BYTES
assert out == b"y" * _MAX_STDERR_CAPTURE_BYTES
assert remaining["n"] == 0
@pytest.mark.asyncio
async def test_stop_session(self):
"""Test stopping the session process."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = MagicMock()
mock_process.returncode = None # Running
# Mock wait to simulate async finish
mock_process.wait = AsyncMock(return_value=0)
session.process = mock_process
with patch(
"free_claude_code.cli.managed.session.kill_pid_tree_best_effort"
) as kill_tree:
stopped = await session.stop()
assert stopped is True
kill_tree.assert_called_once_with(mock_process.pid)
mock_process.wait.assert_called()
@pytest.mark.asyncio
async def test_stop_session_timeout_force_kill(self):
"""Test force kill if terminate times out."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = MagicMock()
mock_process.returncode = None
# First wait times out
async def wait_side_effect():
if not mock_process.kill.called:
await asyncio.sleep(6) # Should be > 5.0 timeout
return 0
# We can simulate timeout by raising TimeoutError directly on first call
mock_process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
session.process = mock_process
with patch(
"free_claude_code.cli.managed.session.kill_pid_tree_best_effort"
) as kill_tree:
stopped = await session.stop()
assert stopped is True
kill_tree.assert_called_once_with(mock_process.pid)
mock_process.kill.assert_called()
@pytest.mark.asyncio
async def test_start_task_split_buffer(self):
"""Test handling of JSON split across chunks."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
# Split json: {"type": "mess... age"}
mock_process.stdout.read.side_effect = [
b'{"type": "mess',
b'age", "content": "Split"}\n',
b"",
]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [
e async for e in session.start_task("test") if e["type"] == "message"
]
assert len(events) == 1
assert events[0]["content"] == "Split"
@pytest.mark.asyncio
async def test_start_task_remnant_buffer(self):
"""Test handling of buffer remnant at EOF (no newline at end)."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [
b'{"type": "message", "content": "Remnant"}', # No newline
b"",
]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [
e async for e in session.start_task("test") if e["type"] == "message"
]
assert len(events) == 1
assert events[0]["content"] == "Remnant"
@pytest.mark.asyncio
async def test_start_task_targets_proxy_root(self):
"""Test start_task passes the configured proxy root to Claude Code."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
async for _ in session.start_task("test"):
pass
# Check env var
kwargs = mock_exec.call_args[1]
env = kwargs["env"]
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:8082"
@pytest.mark.asyncio
async def test_start_task_sets_proxy_auth_token(self):
"""Test start_task forwards configured proxy auth to Claude Code."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession(
"/tmp", "http://localhost:8082", auth_token="proxy-token"
)
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with (
patch.dict(os.environ, {"ANTHROPIC_API_KEY": "official-key"}, clear=False),
patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec,
):
mock_exec.return_value = mock_process
async for _ in session.start_task("test"):
pass
env = mock_exec.call_args.kwargs["env"]
assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000"
assert env["DISABLE_TELEMETRY"] == "1"
assert "ANTHROPIC_API_KEY" not in env
assert "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" not in env
@pytest.mark.asyncio
async def test_start_task_uses_sentinel_when_proxy_auth_blank(self):
"""Test start_task does not leak inherited Claude auth into proxy calls."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082", auth_token="")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with (
patch.dict(os.environ, {"ANTHROPIC_AUTH_TOKEN": "stale"}, clear=False),
patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec,
):
mock_exec.return_value = mock_process
async for _ in session.start_task("test"):
pass
env = mock_exec.call_args.kwargs["env"]
assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth"
@pytest.mark.asyncio
async def test_start_task_allowed_dirs(self):
"""Test start_task includes allowed dirs in command."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession(
"/tmp", "http://localhost:8082", allowed_dirs=["/dir1", "/dir2"]
)
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b""]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
async for _ in session.start_task("test"):
pass
cmd = mock_exec.call_args[0]
assert "--add-dir" in cmd
assert os.path.normpath("/dir1") in cmd
assert os.path.normpath("/dir2") in cmd
@pytest.mark.asyncio
async def test_start_task_json_error(self):
"""Test handling of non-JSON output from free_claude_code.cli."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = AsyncMock()
mock_process.stdout.read.side_effect = [b"Not valid json\n", b""]
mock_process.stderr.read.return_value = b""
mock_process.wait.return_value = 0
with patch(
"asyncio.create_subprocess_exec", new_callable=AsyncMock
) as mock_exec:
mock_exec.return_value = mock_process
events = [e async for e in session.start_task("test") if e["type"] == "raw"]
assert len(events) == 1
assert events[0]["content"] == "Not valid json"
@pytest.mark.asyncio
async def test_stop_exception(self):
"""Test exception handling during stop."""
from free_claude_code.cli.managed.session import ManagedClaudeSession
session = ManagedClaudeSession("/tmp", "http://localhost:8082")
mock_process = MagicMock()
mock_process.returncode = None
session.process = mock_process
with patch(
"free_claude_code.cli.managed.session.kill_pid_tree_best_effort",
side_effect=RuntimeError("Permission denied"),
):
stopped = await session.stop()
assert stopped is False
class TestManagedClaudeSessionManager:
"""Test ManagedClaudeSessionManager."""
@pytest.mark.asyncio
async def test_manager_create_session(self):
"""Test creating a new session."""
from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager
manager = ManagedClaudeSessionManager(
workspace_path="/tmp/test",
proxy_root_url="http://localhost:8082",
)
session, sid, is_new = await manager.get_or_create_session()
assert session is not None
assert sid.startswith("pending_")
assert is_new is True
@pytest.mark.asyncio
async def test_manager_reuse_session(self):
"""Test reusing an existing session."""
from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager
manager = ManagedClaudeSessionManager(
workspace_path="/tmp/test",
proxy_root_url="http://localhost:8082",
)
# Create first session
s1, sid1, _is_new1 = await manager.get_or_create_session()
# Request same session
s2, _sid2, is_new2 = await manager.get_or_create_session(session_id=sid1)
assert s1 is s2
assert is_new2 is False
@pytest.mark.asyncio
async def test_manager_stats(self):
"""Test manager stats."""
from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager
manager = ManagedClaudeSessionManager(
workspace_path="/tmp/test",
proxy_root_url="http://localhost:8082",
)
stats = manager.get_stats()
assert stats["active_sessions"] == 0
assert stats["pending_sessions"] == 0