alishahryar1--free-claude-code
343e5674bf
## 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 -->
771 行
27 KiB
Python
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
|