f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
319 lines
11 KiB
Python
319 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from claude_tap import parse_args
|
|
from claude_tap.cli import CLIENT_CONFIGS, ClientConfig, run_client
|
|
|
|
|
|
class _DummyProc:
|
|
def __init__(self) -> None:
|
|
self.pid = 12345
|
|
self.returncode: int | None = None
|
|
|
|
async def wait(self) -> int:
|
|
self.returncode = 0
|
|
return 0
|
|
|
|
def terminate(self) -> None:
|
|
self.returncode = 0
|
|
|
|
def kill(self) -> None:
|
|
self.returncode = -9
|
|
|
|
|
|
def test_client_config_default_proxy_mode_defaults_to_reverse() -> None:
|
|
cfg = ClientConfig(
|
|
cmd="x",
|
|
label="X",
|
|
install_url="https://example.com",
|
|
base_url_env="X_BASE_URL",
|
|
base_url_suffix="",
|
|
default_target="https://example.com",
|
|
)
|
|
assert cfg.default_proxy_mode == "reverse"
|
|
|
|
|
|
def test_hermes_registered_in_client_configs() -> None:
|
|
cfg = CLIENT_CONFIGS["hermes"]
|
|
assert cfg.cmd == "hermes"
|
|
assert cfg.label == "Hermes Agent"
|
|
assert cfg.default_target == "https://api.openai.com"
|
|
assert cfg.base_url_env == "OPENAI_BASE_URL"
|
|
assert cfg.base_url_suffix == "/v1"
|
|
assert cfg.default_proxy_mode == "forward"
|
|
|
|
|
|
def test_claude_default_proxy_mode_unchanged() -> None:
|
|
assert CLIENT_CONFIGS["claude"].default_proxy_mode == "reverse"
|
|
|
|
|
|
def test_codex_default_proxy_mode_unchanged() -> None:
|
|
assert CLIENT_CONFIGS["codex"].default_proxy_mode == "reverse"
|
|
|
|
|
|
def test_parse_args_hermes_defaults_to_forward_mode() -> None:
|
|
args = parse_args(["--tap-client", "hermes"])
|
|
assert args.client == "hermes"
|
|
assert args.proxy_mode == "forward"
|
|
|
|
|
|
def test_parse_args_hermes_explicit_reverse_overrides_default() -> None:
|
|
args = parse_args(["--tap-client", "hermes", "--tap-proxy-mode", "reverse"])
|
|
assert args.client == "hermes"
|
|
assert args.proxy_mode == "reverse"
|
|
|
|
|
|
def test_parse_args_claude_default_unchanged() -> None:
|
|
args = parse_args([])
|
|
assert args.client == "claude"
|
|
assert args.proxy_mode == "reverse"
|
|
|
|
|
|
def test_parse_args_codex_default_unchanged() -> None:
|
|
args = parse_args(["--tap-client", "codex"])
|
|
assert args.client == "codex"
|
|
assert args.proxy_mode == "reverse"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_forward_sets_python_ca_env(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
ca_path = Path("/tmp/test-ca.pem")
|
|
|
|
async def fake_create_subprocess_exec(*cmd, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return _DummyProc()
|
|
|
|
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/hermes")
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
|
|
|
code = await run_client(43123, ["chat"], client="hermes", proxy_mode="forward", ca_cert_path=ca_path)
|
|
|
|
assert code == 0
|
|
env = captured["env"]
|
|
assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123"
|
|
# hermes uses httpx/requests; both honor SSL_CERT_FILE; requests also reads REQUESTS_CA_BUNDLE
|
|
assert env["SSL_CERT_FILE"] == str(ca_path)
|
|
assert env["REQUESTS_CA_BUNDLE"] == str(ca_path)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_codex_forward_still_sets_existing_ca_env(monkeypatch) -> None:
|
|
"""Regression: codex still gets SSL_CERT_FILE and CODEX_CA_CERTIFICATE."""
|
|
captured: dict[str, object] = {}
|
|
ca_path = Path("/tmp/test-ca.pem")
|
|
|
|
async def fake_create_subprocess_exec(*cmd, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return _DummyProc()
|
|
|
|
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex")
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
|
|
|
code = await run_client(43123, ["exec", "hi"], client="codex", proxy_mode="forward", ca_cert_path=ca_path)
|
|
|
|
assert code == 0
|
|
env = captured["env"]
|
|
assert env["SSL_CERT_FILE"] == str(ca_path)
|
|
assert env["CODEX_CA_CERTIFICATE"] == str(ca_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# argv rewrite: hermes recent versions delegate `gateway start` to launchd /
|
|
# systemd, which spawns the gateway in a fresh env that does NOT inherit
|
|
# HTTPS_PROXY / CA. We rewrite to `gateway run` (foreground) so the spawned
|
|
# process is our child and inherits the injected env.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _capture_cmd(monkeypatch, which: str = "/tmp/hermes") -> dict[str, object]:
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_create_subprocess_exec(*cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return _DummyProc()
|
|
|
|
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: which)
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
|
return captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrites_gateway_start_to_gateway_run(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(43123, ["gateway", "start"], client="hermes", proxy_mode="forward")
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "gateway", "run")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrite_preserves_trailing_flags(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(
|
|
43123,
|
|
["gateway", "start", "--profile", "coder", "--replace"],
|
|
client="hermes",
|
|
proxy_mode="forward",
|
|
)
|
|
assert code == 0
|
|
assert captured["cmd"] == (
|
|
"/tmp/hermes",
|
|
"gateway",
|
|
"run",
|
|
"--profile",
|
|
"coder",
|
|
"--replace",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrite_after_long_global_option(monkeypatch) -> None:
|
|
# `hermes --profile work gateway start` is the documented shape;
|
|
# the rewrite must skip the leading global option before matching.
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(
|
|
43123,
|
|
["--profile", "work", "gateway", "start"],
|
|
client="hermes",
|
|
proxy_mode="forward",
|
|
)
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "--profile", "work", "gateway", "run")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrite_after_short_profile_option(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(
|
|
43123,
|
|
["-p", "work", "gateway", "start"],
|
|
client="hermes",
|
|
proxy_mode="forward",
|
|
)
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "-p", "work", "gateway", "run")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrite_after_profile_equals_form(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(
|
|
43123,
|
|
["--profile=work", "gateway", "start"],
|
|
client="hermes",
|
|
proxy_mode="forward",
|
|
)
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "--profile=work", "gateway", "run")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrite_after_boolean_global_flag(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(
|
|
43123,
|
|
["--ignore-user-config", "gateway", "start"],
|
|
client="hermes",
|
|
proxy_mode="forward",
|
|
)
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "--ignore-user-config", "gateway", "run")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_rewrite_with_global_option_and_trailing_flags(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(
|
|
43123,
|
|
["--profile", "work", "gateway", "start", "--replace"],
|
|
client="hermes",
|
|
proxy_mode="forward",
|
|
)
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "--profile", "work", "gateway", "run", "--replace")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_gateway_run_passthrough_unchanged(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(43123, ["gateway", "run"], client="hermes", proxy_mode="forward")
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "gateway", "run")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_other_subcommands_unchanged(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch)
|
|
code = await run_client(43123, ["chat"], client="hermes", proxy_mode="forward")
|
|
assert code == 0
|
|
assert captured["cmd"] == ("/tmp/hermes", "chat")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_codex_not_affected_by_hermes_rewrite(monkeypatch) -> None:
|
|
captured = await _capture_cmd(monkeypatch, which="/tmp/codex")
|
|
code = await run_client(43123, ["gateway", "start"], client="codex", proxy_mode="forward")
|
|
assert code == 0
|
|
# The hermes rewrite must not fire for codex
|
|
assert captured["cmd"] == ("/tmp/codex", "gateway", "start")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_reverse_sets_openai_base_url(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_create_subprocess_exec(*cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["env"] = kwargs["env"]
|
|
return _DummyProc()
|
|
|
|
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/hermes")
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
|
monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
|
|
|
|
code = await run_client(43123, ["chat"], client="hermes", proxy_mode="reverse")
|
|
|
|
assert code == 0
|
|
env = captured["env"]
|
|
assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1"
|
|
assert "ANTHROPIC_BASE_URL" not in env
|
|
assert "OPENROUTER_BASE_URL" not in env
|
|
assert "CUSTOM_BASE_URL" not in env
|
|
# Reverse mode for hermes must not inject the codex-only -c flag
|
|
assert captured["cmd"] == ("/tmp/hermes", "chat")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_client_hermes_capture_only_reverse_sets_multi_provider_urls(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_create_subprocess_exec(*cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["env"] = kwargs["env"]
|
|
return _DummyProc()
|
|
|
|
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/hermes")
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
|
|
|
code = await run_client(43123, ["chat"], client="hermes", proxy_mode="reverse", capture_only=True)
|
|
|
|
assert code == 0
|
|
env = captured["env"]
|
|
assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1"
|
|
assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:43123"
|
|
assert env["OPENROUTER_BASE_URL"] == "http://127.0.0.1:43123/v1"
|
|
assert env["CUSTOM_BASE_URL"] == "http://127.0.0.1:43123/v1"
|
|
# Reverse mode for hermes must not inject the codex-only -c flag
|
|
assert captured["cmd"] == ("/tmp/hermes", "chat")
|