Files
omnigent-ai--omnigent/tests/runner/test_app_claude_native_launch_args.py
2026-07-13 13:12:00 +08:00

282 lines
12 KiB
Python

"""Tests for the runner's claude-native base-args assembly.
``_build_claude_native_base_args`` is the pure seam that turns a
session's persisted launch config (reasoning_effort, model_override,
terminal_launch_args) into the base ``claude`` CLI args a
daemon/server-spawned runner launches with — before
``augment_claude_args`` layers on the bridge/MCP/hook/AP wiring. The
invariants under test (order, model precedence, ignore-unknown-effort)
are what make a host-spawned launch match what the CLI would have
passed. See designs/NATIVE_RUNNER_SERVER_LAUNCH.md.
"""
from __future__ import annotations
import pytest
from omnigent.claude_native import (
ClaudeNativeUcodeConfig,
build_native_claude_terminal_env,
)
from omnigent.runner.app import _build_claude_native_base_args, _claude_terminal_env_unset
@pytest.mark.parametrize(
("reasoning_effort", "model_override", "terminal_launch_args", "expected"),
[
# Effort only → "--effort <value>"; nothing else contributed.
("high", None, None, ("--effort", "high")),
# Pass-through flags are included verbatim; model_override is
# appended as a default --model because the user gave no --model.
(
None,
"claude-opus-4-7",
["--dangerously-skip-permissions"],
("--dangerously-skip-permissions", "--model", "claude-opus-4-7"),
),
# Explicit --model in pass-through args WINS over model_override
# (space form): the override default must not be appended.
(None, "claude-opus-4-7", ["--model", "sonnet"], ("--model", "sonnet")),
# Explicit --model in pass-through args WINS (joined form): the
# ``--model=X`` spelling must also suppress the override default.
(None, "claude-opus-4-7", ["--model=sonnet"], ("--model=sonnet",)),
# Full ordering: effort prefix, then pass-through, then the
# model default last. A different order would mean the assembly
# logic changed and the launch command no longer matches the CLI.
(
"high",
"claude-opus-4-7",
["--verbose"],
("--effort", "high", "--verbose", "--model", "claude-opus-4-7"),
),
# Nothing persisted → no args (Claude uses its settings.json
# defaults). A non-empty result here would mean we injected a
# phantom flag.
(None, None, None, ()),
# An empty pass-through list behaves like None — contributes
# nothing, but the model default still applies.
(None, "claude-opus-4-7", [], ("--model", "claude-opus-4-7")),
# An unrecognised effort is dropped (not a Claude effort), so it
# never reaches the CLI as a bogus ``--effort`` value.
("bogus-effort", None, None, ()),
],
ids=[
"effort-only",
"model-default-appended",
"explicit-model-space-wins",
"explicit-model-joined-wins",
"full-ordering",
"all-none",
"empty-passthrough-still-adds-model",
"unknown-effort-dropped",
],
)
def test_build_claude_native_base_args(
reasoning_effort: str | None,
model_override: str | None,
terminal_launch_args: list[str] | None,
expected: tuple[str, ...],
) -> None:
"""
Assemble base args from persisted launch config.
Each case pins one invariant; the expected tuple is the exact arg
vector the runner must hand to ``augment_claude_args``. A mismatch
means a daemon/server-spawned claude launch would diverge from the
CLI's command (wrong order, missing pass-through flag, or the model
override clobbering an explicit user ``--model``).
"""
assert (
_build_claude_native_base_args(
reasoning_effort=reasoning_effort,
model_override=model_override,
terminal_launch_args=terminal_launch_args,
)
== expected
)
@pytest.mark.parametrize(
("reasoning_effort", "model_override", "terminal_launch_args", "resume", "expected"),
[
# Resume alone → just the --resume prefix.
(None, None, None, "sid-123", ("--resume", "sid-123")),
# --resume comes FIRST, before effort / pass-through / model —
# mirroring the CLI's (*cold_resume_args, *claude_args) order.
(
"high",
"claude-opus-4-7",
["--verbose"],
"sid-123",
("--resume", "sid-123", "--effort", "high", "--verbose", "--model", "claude-opus-4-7"),
),
# No resume id → no --resume (fresh launch, or no local
# transcript could be synthesized).
(None, None, ["--verbose"], None, ("--verbose",)),
],
ids=["resume-only", "resume-first-ordering", "no-resume"],
)
def test_build_claude_native_base_args_resume_prefix(
reasoning_effort: str | None,
model_override: str | None,
terminal_launch_args: list[str] | None,
resume: str | None,
expected: tuple[str, ...],
) -> None:
"""
A cold-resume session id is prepended as ``--resume <sid>`` ahead of
every other arg.
The ordering matters: Claude applies ``--resume`` to pick the
transcript, and the runner-side launch must match the CLI's
long-standing ``--resume``-first arg vector. A wrong position (or a
missing prefix when an id is supplied) would mean a daemon/web-UI
resume silently starts a fresh Claude session instead of reopening
the prior transcript.
"""
assert (
_build_claude_native_base_args(
reasoning_effort=reasoning_effort,
model_override=model_override,
terminal_launch_args=terminal_launch_args,
resume_external_session_id=resume,
)
== expected
)
def test_claude_terminal_env_unset_masks_key_with_api_key_helper() -> None:
"""An apiKeyHelper launch strips the raw key + nested-session marker.
When the credential reaches Claude Code via an ``apiKeyHelper``, a raw
``ANTHROPIC_API_KEY`` in the child env makes Claude open its custom-API-
key confirmation menu, and the first web message is typed into that menu
instead of the chat composer. The key must not leak into the terminal
child; ``CLAUDECODE`` is stripped alongside it to avoid a nested-session
error. ``DATABRICKS_CONFIG_PROFILE`` is always dropped.
"""
config = ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
api_key_helper="printf %s sk-gateway",
model="gateway-served-claude",
)
env_unset = _claude_terminal_env_unset(config)
assert "ANTHROPIC_API_KEY" in env_unset
assert "CLAUDECODE" in env_unset
assert "DATABRICKS_CONFIG_PROFILE" in env_unset
def test_claude_terminal_env_unset_without_helper_keeps_key() -> None:
"""No apiKeyHelper preserves the raw key but strips nested-session state.
Claude's own-login path (``None`` config) and a Bedrock-style config have
no ``apiKeyHelper``, so this helper does not strip ``ANTHROPIC_API_KEY``.
``CLAUDECODE`` must still be absent because Claude Code rejects nested
launches in every auth mode.
"""
expected = ["DATABRICKS_CONFIG_PROFILE", "CLAUDECODE"]
own_login_env_unset = _claude_terminal_env_unset(None)
assert own_login_env_unset == expected
assert "ANTHROPIC_API_KEY" not in own_login_env_unset
bedrock_like = ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock.example"},
api_key_helper=None,
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
)
bedrock_env_unset = _claude_terminal_env_unset(bedrock_like)
assert bedrock_env_unset == expected
assert "ANTHROPIC_API_KEY" not in bedrock_env_unset
def test_native_launch_passes_synthesized_model_as_flag() -> None:
"""A synthesized gateway model reaches the launch as ``--model``.
``_auto_create_claude_terminal`` feeds ``claude_config.model`` (populated
by ambient Anthropic synthesis from ``ANTHROPIC_MODEL``) into the base
args as the model default. This pins the end-to-end contract: a gateway
model resolves to ``--model <id>`` so Claude Code doesn't launch with its
own default that the gateway rejects.
"""
config = ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
api_key_helper="printf %s sk-gateway",
model="gateway-served-claude",
)
# Mirrors the runner's precedence: session override wins, else the
# provider/ucode gateway model becomes the --model default.
args = _build_claude_native_base_args(
reasoning_effort=None,
model_override=None or config.model,
terminal_launch_args=None,
)
assert args == ("--model", "gateway-served-claude")
def test_build_native_claude_terminal_env_rejects_raw_key_on_helper_path() -> None:
"""The env-build seam fails loud if a raw key rides the apiKeyHelper path.
``_claude_terminal_env_unset`` strips the raw key from the terminal child
only because ``build_native_claude_terminal_env`` never emits one on the
helper path. Pin that invariant mechanically: if a future config injects a
raw ``ANTHROPIC_API_KEY`` into the terminal env while an ``apiKeyHelper`` is
configured, the build must raise rather than silently reintroduce Claude
Code's custom-API-key menu hang.
"""
leaking = ClaudeNativeUcodeConfig(
env={
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
"ANTHROPIC_API_KEY": "sk-leaked",
},
api_key_helper="printf %s sk-gateway",
model="gateway-served-claude",
)
with pytest.raises(RuntimeError, match="ANTHROPIC_API_KEY"):
build_native_claude_terminal_env(leaking)
def test_claude_terminal_env_databricks_gateway_helper_path() -> None:
"""The Databricks ucode/profile gateway session, end to end through the env seams.
A Databricks-gateway session has an ``apiKeyHelper`` (the Databricks auth
command), ``ANTHROPIC_BASE_URL`` at the Databricks endpoint, a ucode model,
and NO raw ``ANTHROPIC_API_KEY``; the runner also carries an ambient
``DATABRICKS_CONFIG_PROFILE``. This pins the real-user shape (not just a
generic gateway): the terminal child must drop the Databricks profile and
the raw key / nested-session marker, while ``apiKeyHelper`` +
``ANTHROPIC_BASE_URL`` + the model override survive so Claude Code still
authenticates against Databricks via the helper.
"""
config = ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://dbc-example.cloud.databricks.com/anthropic"},
api_key_helper="databricks auth token --host https://dbc-example.cloud.databricks.com",
model="databricks-claude-opus-4-8",
)
# The terminal child strips the ambient Databricks profile plus the raw
# key / nested-session marker on the helper path.
env_unset = _claude_terminal_env_unset(config)
assert "DATABRICKS_CONFIG_PROFILE" in env_unset
assert "ANTHROPIC_API_KEY" in env_unset
assert "CLAUDECODE" in env_unset
# The built terminal env preserves the gateway endpoint and never emits a
# raw key (routing is via ANTHROPIC_BASE_URL + apiKeyHelper); the Databricks
# profile is dropped via env_unset, not the built env.
terminal_env = build_native_claude_terminal_env(config)
assert terminal_env["ANTHROPIC_BASE_URL"] == (
"https://dbc-example.cloud.databricks.com/anthropic"
)
assert "ANTHROPIC_API_KEY" not in terminal_env
assert "DATABRICKS_CONFIG_PROFILE" not in terminal_env
# The gateway model reaches the launch as ``--model`` so Claude Code
# doesn't start on an Anthropic-direct default the gateway rejects; the
# apiKeyHelper survives on the config for augment_claude_args to register.
args = _build_claude_native_base_args(
reasoning_effort=None,
model_override=config.model,
terminal_launch_args=None,
)
assert args == ("--model", "databricks-claude-opus-4-8")
assert config.api_key_helper