项目文件夹

文件
Ali Khokhar aba94d5c3c Disable nonessential Anthropic traffic for FCC Claude sessions (#1083)
## 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 -->
2026-07-12 15:05:54 -07:00

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 == {}