alishahryar1--free-claude-code
aba94d5c3c
## Problem Claude proxy environment policy was duplicated between `fcc-claude`, managed messaging, and live smoke drivers. Managed messaging preserved a legacy endpoint variable, inherited more Anthropic state, and did not disable nonessential traffic, so FCC-launched Claude sessions could drift apart. ## Changes | Before | After | | --- | --- | | `fcc-claude` and managed messaging assembled proxy environments independently. | One shared owner strips inherited Anthropic variables and configures proxy URL, auth, discovery, compaction, and nonessential-traffic policy. | | Managed messaging carried a `/v1` API URL and converted it back to a proxy root while also setting a legacy endpoint variable. | Managed messaging receives the loopback-safe proxy root and uses the supported `ANTHROPIC_BASE_URL` contract directly. | | Managed and interactive policy could diverge while smoke drivers duplicated both shapes. | `fcc-claude`, messaging, and Claude smoke drivers use the same canonical environment builder. | | Managed execution concerns were mixed with shared proxy policy. | Messaging adds only noninteractive process settings and keeps `--model opus` plus stream-JSON flags in command construction. | | IDE examples left nonessential Anthropic traffic enabled. | VS Code and JetBrains examples disable nonessential Anthropic traffic. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR centralizes Claude Code proxy environment setup for FCC-launched sessions. The main changes are: - Adds one shared builder for Claude proxy environment variables. - Routes managed messaging sessions through the same proxy policy as `fcc-claude`. - Strips inherited `ANTHROPIC_*` state before launching Claude. - Sets the nonessential-traffic disable flag for managed and interactive Claude launches. - Updates smoke tests, docs, and version metadata for the new proxy-root URL shape. </details> <h3>Confidence Score: 5/5</h3> This looks safe to merge. No blocking issues were found in the changed code. Managed Claude launches now use the shared environment builder, and the managed path now sets the nonessential-traffic disable flag. No files need 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** - The executable harness claude\_env\_policy\_harness.py was generated to enable direct module-level runtime proof without requiring a real Claude binary or live Anthropic credentials. - A focused pytest run was executed, and it completed with 14 tests passing and exit code 0, validating the harness workflow. <a href="https://app.greptile.com/trex/runs/14173386/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 | Adds the shared Claude proxy environment builder and canonical traffic-disable policy. | | src/free_claude_code/cli/managed/claude.py | Delegates managed Claude environment construction to the shared proxy builder. | | src/free_claude_code/runtime/application.py | Passes the loopback-safe proxy root into the managed Claude session manager. | | src/free_claude_code/cli/managed/session.py | Renames managed session URL state to use the proxy-root contract. | | src/free_claude_code/cli/managed/manager.py | Carries the proxy-root URL through manager-created managed sessions. | </details> <sub>Reviews (2): Last reviewed commit: ["Keep README focused on client setup"](https://github.com/alishahryar1/free-claude-code/commit/83739a2f8953771ec9e447fc83a2057b08708891) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=43684511)</sub> <!-- /greptile_comment -->
472 行
16 KiB
Python
472 行
16 KiB
Python
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager
|
|
from free_claude_code.cli.managed.session import ManagedClaudeSession
|
|
|
|
|
|
def _manager() -> ManagedClaudeSessionManager:
|
|
return ManagedClaudeSessionManager(
|
|
workspace_path="/tmp",
|
|
proxy_root_url="http://127.0.0.1:8082",
|
|
)
|
|
|
|
|
|
def _mock_session(*stop_results: object) -> MagicMock:
|
|
session = MagicMock(spec=ManagedClaudeSession)
|
|
session.is_busy = False
|
|
session.stop = AsyncMock(side_effect=list(stop_results))
|
|
return session
|
|
|
|
|
|
def _completed_process(pid: int) -> MagicMock:
|
|
process = MagicMock()
|
|
process.pid = pid
|
|
process.returncode = 0
|
|
process.stdout.read = AsyncMock(return_value=b"")
|
|
process.stderr = None
|
|
process.wait = AsyncMock(return_value=0)
|
|
return process
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_is_idempotent_without_a_live_process() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
|
|
assert await session.stop() is True
|
|
|
|
process = MagicMock()
|
|
process.pid = 101
|
|
process.returncode = 0
|
|
process.wait = AsyncMock()
|
|
session.process = process
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.kill_pid_tree_best_effort"
|
|
) as kill_tree,
|
|
patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
|
|
):
|
|
assert await session.stop() is True
|
|
|
|
kill_tree.assert_not_called()
|
|
process.wait.assert_not_awaited()
|
|
unregister.assert_called_once_with(101)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stopped_session_reference_cannot_launch_a_new_process() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
|
|
assert await session.stop() is True
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=_completed_process(100)),
|
|
) as create_process,
|
|
patch("free_claude_code.cli.managed.session.trace_event") as trace,
|
|
pytest.raises(RuntimeError, match="closed"),
|
|
):
|
|
async for _ in session.start_task("must not launch"):
|
|
pass
|
|
|
|
create_process.assert_not_awaited()
|
|
trace.assert_not_called()
|
|
assert session.is_busy is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_publication_wins_before_concurrent_stop() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
launch_entered = asyncio.Event()
|
|
release_launch = asyncio.Event()
|
|
release_stream = asyncio.Event()
|
|
process = MagicMock()
|
|
process.pid = 102
|
|
process.returncode = None
|
|
process.stderr = None
|
|
|
|
async def create_process(*_args: object, **_kwargs: object) -> MagicMock:
|
|
launch_entered.set()
|
|
await release_launch.wait()
|
|
return process
|
|
|
|
async def read_stdout(*_args: object, **_kwargs: object) -> bytes:
|
|
await release_stream.wait()
|
|
return b""
|
|
|
|
async def wait_process() -> int:
|
|
process.returncode = 0
|
|
return 0
|
|
|
|
process.stdout.read = AsyncMock(side_effect=read_stdout)
|
|
process.wait = AsyncMock(side_effect=wait_process)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
|
|
side_effect=create_process,
|
|
),
|
|
patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
|
|
patch("free_claude_code.cli.managed.session.register_pid"),
|
|
patch("free_claude_code.cli.managed.session.unregister_pid"),
|
|
):
|
|
stream_task = asyncio.create_task(
|
|
_collect_session_events(session, "launch wins")
|
|
)
|
|
await launch_entered.wait()
|
|
stop_task = asyncio.create_task(session.stop())
|
|
await asyncio.sleep(0)
|
|
stopped_before_publication = stop_task.done()
|
|
|
|
release_launch.set()
|
|
assert await asyncio.wait_for(stop_task, timeout=1) is True
|
|
release_stream.set()
|
|
events = await asyncio.wait_for(stream_task, timeout=1)
|
|
|
|
with pytest.raises(RuntimeError, match="closed"):
|
|
async for _ in session.start_task("cannot relaunch"):
|
|
pass
|
|
|
|
assert events == [{"type": "exit", "code": 0, "stderr": None}]
|
|
assert stopped_before_publication is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_stop_wins_before_launch_publication() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
stop_entered = asyncio.Event()
|
|
release_stop = asyncio.Event()
|
|
process = MagicMock()
|
|
process.pid = 103
|
|
process.returncode = None
|
|
|
|
async def wait_process() -> int:
|
|
stop_entered.set()
|
|
await release_stop.wait()
|
|
process.returncode = 0
|
|
return 0
|
|
|
|
process.wait = AsyncMock(side_effect=wait_process)
|
|
session.process = process
|
|
unexpected_process = _completed_process(106)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=unexpected_process),
|
|
) as create_process,
|
|
patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
|
|
patch("free_claude_code.cli.managed.session.unregister_pid"),
|
|
patch("free_claude_code.cli.managed.session.trace_event") as trace,
|
|
):
|
|
stop_task = asyncio.create_task(session.stop())
|
|
await stop_entered.wait()
|
|
stream_task = asyncio.create_task(_collect_session_events(session, "stop wins"))
|
|
await asyncio.sleep(0)
|
|
|
|
release_stop.set()
|
|
assert await asyncio.wait_for(stop_task, timeout=1) is True
|
|
with pytest.raises(RuntimeError, match="closed"):
|
|
await asyncio.wait_for(stream_task, timeout=1)
|
|
|
|
create_process.assert_not_awaited()
|
|
trace.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normal_sequential_starts_remain_allowed_before_stop() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
processes = [_completed_process(104), _completed_process(105)]
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
|
|
new=AsyncMock(side_effect=processes),
|
|
) as create_process,
|
|
patch("free_claude_code.cli.managed.session.register_pid"),
|
|
patch("free_claude_code.cli.managed.session.unregister_pid"),
|
|
):
|
|
first = await _collect_session_events(session, "first")
|
|
second = await _collect_session_events(session, "second")
|
|
|
|
assert first == [{"type": "exit", "code": 0, "stderr": None}]
|
|
assert second == [{"type": "exit", "code": 0, "stderr": None}]
|
|
assert create_process.await_count == 2
|
|
|
|
|
|
async def _collect_session_events(
|
|
session: ManagedClaudeSession,
|
|
prompt: str,
|
|
) -> list[dict]:
|
|
return [event async for event in session.start_task(prompt)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_stop_keeps_pid_registered_until_retry_confirms_exit() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
process = MagicMock()
|
|
process.pid = 202
|
|
process.returncode = None
|
|
process.wait = AsyncMock(side_effect=[RuntimeError("wait failed"), 0])
|
|
session.process = process
|
|
|
|
with (
|
|
patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
|
|
patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
|
|
):
|
|
assert await session.stop() is False
|
|
unregister.assert_not_called()
|
|
|
|
assert await session.stop() is True
|
|
|
|
unregister.assert_called_once_with(202)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_stop_keeps_pid_registered_and_can_be_retried() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
process = MagicMock()
|
|
process.pid = 303
|
|
process.returncode = None
|
|
wait_entered = asyncio.Event()
|
|
|
|
async def wait_until_cancelled() -> int:
|
|
wait_entered.set()
|
|
await asyncio.Event().wait()
|
|
return 0
|
|
|
|
process.wait = AsyncMock(side_effect=wait_until_cancelled)
|
|
session.process = process
|
|
|
|
with (
|
|
patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
|
|
patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
|
|
):
|
|
stopping = asyncio.create_task(session.stop())
|
|
await wait_entered.wait()
|
|
stopping.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await stopping
|
|
unregister.assert_not_called()
|
|
|
|
process.wait = AsyncMock(return_value=0)
|
|
assert await session.stop() is True
|
|
|
|
unregister.assert_called_once_with(303)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_failure_keeps_unconfirmed_process_pid_registered() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
process = MagicMock()
|
|
process.pid = 404
|
|
process.returncode = None
|
|
process.stdout.read = AsyncMock(side_effect=RuntimeError("read failed"))
|
|
process.stderr = None
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=process),
|
|
),
|
|
patch("free_claude_code.cli.managed.session.register_pid") as register,
|
|
patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
|
|
pytest.raises(RuntimeError, match="read failed"),
|
|
):
|
|
async for _ in session.start_task("hello"):
|
|
pass
|
|
|
|
register.assert_called_once_with(404)
|
|
unregister.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completed_task_wait_unregisters_confirmed_process_pid() -> None:
|
|
session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
|
|
process = MagicMock()
|
|
process.pid = 405
|
|
process.returncode = None
|
|
process.stdout.read = AsyncMock(return_value=b"")
|
|
process.stderr = None
|
|
process.wait = AsyncMock(return_value=0)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=process),
|
|
),
|
|
patch("free_claude_code.cli.managed.session.register_pid") as register,
|
|
patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
|
|
):
|
|
events = [event async for event in session.start_task("hello")]
|
|
|
|
assert events == [{"type": "exit", "code": 0, "stderr": None}]
|
|
register.assert_called_once_with(405)
|
|
unregister.assert_called_once_with(405)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("first_stop", "raised"),
|
|
[
|
|
(False, None),
|
|
(RuntimeError("stop failed"), RuntimeError),
|
|
(asyncio.CancelledError(), asyncio.CancelledError),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_remove_failure_retains_exact_aliases_and_closes_session_for_reuse(
|
|
first_stop: object,
|
|
raised: type[BaseException] | None,
|
|
) -> None:
|
|
manager = _manager()
|
|
session = _mock_session(first_stop, True)
|
|
manager._sessions["real_1"] = session
|
|
manager._temp_to_real["temp_1"] = "real_1"
|
|
manager._real_to_temp["real_1"] = "temp_1"
|
|
expected_sessions = dict(manager._sessions)
|
|
expected_temp_to_real = dict(manager._temp_to_real)
|
|
expected_real_to_temp = dict(manager._real_to_temp)
|
|
|
|
if raised is None:
|
|
assert await manager.remove_session("real_1") is False
|
|
else:
|
|
with pytest.raises(raised):
|
|
await manager.remove_session("real_1")
|
|
|
|
assert manager._sessions == expected_sessions
|
|
assert manager._temp_to_real == expected_temp_to_real
|
|
assert manager._real_to_temp == expected_real_to_temp
|
|
with pytest.raises(RuntimeError, match="closing"):
|
|
await manager.get_or_create_session("real_1")
|
|
with pytest.raises(RuntimeError, match="closing"):
|
|
await manager.get_or_create_session("temp_1")
|
|
|
|
assert await manager.remove_session("temp_1") is True
|
|
assert manager._sessions == {}
|
|
assert manager._temp_to_real == {}
|
|
assert manager._real_to_temp == {}
|
|
assert session.stop.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_rejects_a_pending_session_after_stop_failure() -> None:
|
|
manager = _manager()
|
|
session = _mock_session(False)
|
|
manager._pending_sessions["temp_1"] = session
|
|
|
|
assert await manager.remove_session("temp_1") is False
|
|
assert await manager.register_real_session_id("temp_1", "real_1") is False
|
|
assert manager._pending_sessions == {"temp_1": session}
|
|
assert manager._sessions == {}
|
|
assert manager._temp_to_real == {}
|
|
assert manager._real_to_temp == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_rejects_real_id_owned_by_a_different_session() -> None:
|
|
manager = _manager()
|
|
existing = _mock_session(False)
|
|
pending = _mock_session(True)
|
|
manager._sessions["real_1"] = existing
|
|
manager._pending_sessions["temp_2"] = pending
|
|
|
|
assert await manager.register_real_session_id("temp_2", "real_1") is False
|
|
|
|
assert manager._sessions == {"real_1": existing}
|
|
assert manager._pending_sessions == {"temp_2": pending}
|
|
assert manager._temp_to_real == {}
|
|
assert manager._real_to_temp == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_all_never_loses_a_closing_owner_outside_identity_maps() -> None:
|
|
manager = _manager()
|
|
orphaned_owner = _mock_session(False)
|
|
mapped_owner = _mock_session(True)
|
|
manager._closing_sessions.add(orphaned_owner)
|
|
manager._sessions["real_1"] = mapped_owner
|
|
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match=r"^Managed Claude session shutdown failures: 1\.$",
|
|
):
|
|
await manager.stop_all()
|
|
|
|
orphaned_owner.stop.assert_awaited_once()
|
|
mapped_owner.stop.assert_awaited_once()
|
|
assert manager._sessions == {}
|
|
assert manager._closing_sessions == {orphaned_owner}
|
|
|
|
orphaned_owner.stop = AsyncMock(return_value=True)
|
|
await manager.stop_all()
|
|
|
|
orphaned_owner.stop.assert_awaited_once()
|
|
assert manager._closing_sessions == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_all_attempts_every_session_and_retains_only_failures() -> None:
|
|
manager = _manager()
|
|
returned_false = _mock_session(False)
|
|
raised_error = _mock_session(RuntimeError("secret failure detail"))
|
|
stopped = _mock_session(True)
|
|
manager._sessions.update(
|
|
{
|
|
"real_error": raised_error,
|
|
"real_false": returned_false,
|
|
"real_stopped": stopped,
|
|
}
|
|
)
|
|
manager._temp_to_real.update(
|
|
{
|
|
"temp_error": "real_error",
|
|
"temp_false": "real_false",
|
|
"temp_stopped": "real_stopped",
|
|
}
|
|
)
|
|
manager._real_to_temp.update(
|
|
{
|
|
"real_error": "temp_error",
|
|
"real_false": "temp_false",
|
|
"real_stopped": "temp_stopped",
|
|
}
|
|
)
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await manager.stop_all()
|
|
|
|
assert str(exc_info.value) == "Managed Claude session shutdown failures: 2."
|
|
returned_false.stop.assert_awaited_once()
|
|
raised_error.stop.assert_awaited_once()
|
|
stopped.stop.assert_awaited_once()
|
|
assert manager._sessions == {
|
|
"real_error": raised_error,
|
|
"real_false": returned_false,
|
|
}
|
|
assert manager._pending_sessions == {}
|
|
assert manager._temp_to_real == {
|
|
"temp_error": "real_error",
|
|
"temp_false": "real_false",
|
|
}
|
|
assert manager._real_to_temp == {
|
|
"real_error": "temp_error",
|
|
"real_false": "temp_false",
|
|
}
|
|
with pytest.raises(RuntimeError, match="closing"):
|
|
await manager.get_or_create_session("temp_false")
|
|
with pytest.raises(RuntimeError, match="closing"):
|
|
await manager.get_or_create_session("temp_error")
|
|
|
|
returned_false.stop = AsyncMock(return_value=True)
|
|
raised_error.stop = AsyncMock(return_value=True)
|
|
await manager.stop_all()
|
|
|
|
assert manager._sessions == {}
|
|
assert manager._pending_sessions == {}
|
|
assert manager._temp_to_real == {}
|
|
assert manager._real_to_temp == {}
|