This commit is contained in:
Musa 2026-05-31 00:22:25 +08:00 committed by GitHub
commit 3b68edc813
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3784 additions and 5 deletions

View file

@ -39,11 +39,64 @@ CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex"
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown"
# Local-only bridge that runs Claude Code CLI as a subprocess. Hosted by
# brightstaff on this loopback address; the Python CLI auto-fills the matching
# provider fields below and tells the launcher to enable the bridge.
CLAUDE_CLI_DEFAULT_BASE_URL = "http://127.0.0.1:14001"
CLAUDE_CLI_DEFAULT_LISTEN_ADDR = "127.0.0.1:14001"
CLAUDE_CLI_DEFAULT_NAME = "claude-cli/*"
CLAUDE_CLI_DEFAULT_ACCESS_KEY_PLACEHOLDER = "claude-cli-local"
SUPPORTED_PROVIDERS = (
SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL
)
def _is_claude_cli_provider(model_provider):
"""Return True iff this provider entry refers to the local claude-cli
bridge. Triggered by any of `model`, `name`, or `provider_interface`
matching the `claude-cli/...` namespace.
"""
model = (model_provider.get("model") or "").strip()
name = (model_provider.get("name") or "").strip()
interface = (model_provider.get("provider_interface") or "").strip()
return (
model.startswith("claude-cli/")
or name.startswith("claude-cli/")
or interface == "claude-cli"
)
def _apply_claude_cli_autofill(model_provider):
"""Fill in implicit fields for `claude-cli/*` provider entries so the
user only has to write `model: claude-cli/*` (or any `claude-cli/...`)
and everything else is wired automatically: a localhost cluster pointing
at the brightstaff bridge, the `claude-cli` provider_interface, and a
placeholder access key so downstream validation does not reject the entry.
Returns True iff this entry was treated as a claude-cli provider (so the
caller can flip the launcher's `needs_claude_cli_runtime` flag).
"""
if not _is_claude_cli_provider(model_provider):
return False
if not model_provider.get("name"):
model_provider["name"] = model_provider.get("model") or CLAUDE_CLI_DEFAULT_NAME
if not model_provider.get("provider_interface"):
model_provider["provider_interface"] = "claude-cli"
if not model_provider.get("base_url"):
model_provider["base_url"] = CLAUDE_CLI_DEFAULT_BASE_URL
# Keep passthrough_auth users alone; the bridge ignores the access key
# anyway (it uses the host's `claude auth login` keychain), so a
# placeholder is fine for everyone else.
if not model_provider.get("access_key") and not model_provider.get(
"passthrough_auth"
):
model_provider["access_key"] = CLAUDE_CLI_DEFAULT_ACCESS_KEY_PLACEHOLDER
return True
def get_endpoint_and_port(endpoint, protocol):
endpoint_tokens = endpoint.split(":")
if len(endpoint_tokens) > 1:
@ -329,6 +382,12 @@ def validate_and_render_schema():
name = listener.get("name", None)
for model_provider in listener.get("model_providers", []):
# Auto-fill the implicit fields for `claude-cli/*` providers
# before the rest of the loop runs validation. This makes
# `model_providers: [{model: claude-cli/*}]` a fully-formed
# entry by the time we reach the wildcard checks below.
_apply_claude_cli_autofill(model_provider)
if model_provider.get("usage", None):
llms_with_usage.append(model_provider["name"])
if model_provider.get("name") in model_provider_name_set:

View file

@ -13,6 +13,7 @@ PLANO_HOME = os.path.join(os.path.expanduser("~"), ".plano")
PLANO_RUN_DIR = os.path.join(PLANO_HOME, "run")
PLANO_BIN_DIR = os.path.join(PLANO_HOME, "bin")
PLANO_PLUGINS_DIR = os.path.join(PLANO_HOME, "plugins")
PLANO_STATE_DIR = os.path.join(PLANO_HOME, "state")
ENVOY_VERSION = "v1.37.0" # keep in sync with Dockerfile ARG ENVOY_VERSION
NATIVE_PID_FILE = os.path.join(PLANO_RUN_DIR, "plano.pid")
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT = "http://localhost:4317"

View file

@ -0,0 +1,297 @@
"""Detect local-agent provider entries in a Plano config and warn the
operator that the host is about to spawn a local CLI binary with the same
filesystem, shell, and network capabilities as the user running planoai.
Local-agent providers (e.g. ``claude-cli``) are an entirely different
trust class from stateless network LLM providers (``openai``,
``anthropic``, ``gemini``, ...): the bridge runs inside brightstaff and
shells out to a local binary for every request, so a misconfigured
production deployment would expose the host to whatever the spawned
agent can do which, for tools like Claude Code, is "anything the
operator can do at the shell".
This module is intentionally additive and side-effect free until the
caller invokes :func:`maybe_warn_local_agent_providers`. The set of
known local-agent provider interfaces lives in
:data:`LOCAL_AGENT_PROVIDER_INTERFACES`; adding a future entry (codex,
chatgpt-cli, opencode, hermes, ...) is a one-line change.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from rich.console import Console
from rich.panel import Panel
from planoai.consts import PLANO_STATE_DIR
# Provider interfaces whose runtime spawns a local CLI subprocess with
# host filesystem / shell access. The string here is matched against the
# config's ``provider_interface`` field AND against the ``<prefix>/...``
# in ``model:`` and ``name:`` fields, so configs that rely on the
# Python-side autofill (``model: claude-cli/*`` only) are still detected
# before that autofill runs.
#
# Add new entries here as additional local-agent bridges are implemented
# (e.g. a future ``codex-cli`` or ``chatgpt-cli`` bridge that spawns the
# Codex CLI). This is the *only* line that needs to change to extend the
# warning's coverage.
LOCAL_AGENT_PROVIDER_INTERFACES: tuple[str, ...] = ("claude-cli",)
# Persistent ack lives next to the rest of the per-user planoai state
# (run/, bin/, plugins/, ...). Operators can ``rm`` this file to undo.
ACK_FILE_PATH = os.path.join(PLANO_STATE_DIR, "local_agent_ack.json")
# Env-var fallback for the ``--ack-local-agents`` CLI flag. Truthy values
# are 1/true/yes (case-insensitive); everything else is treated as unset.
ACK_ENV_VAR = "PLANO_ACK_LOCAL_AGENTS"
# Public docs page. The Sphinx source lives at
# ``docs/source/resources/local_agent_providers.rst`` and is published to
# https://docs.planoai.dev (CNAME at ``docs/CNAME``).
DOCS_LEARN_MORE = "https://docs.planoai.dev/resources/local_agent_providers.html"
@dataclass(frozen=True)
class LocalAgentProvider:
"""A single ``model_providers`` entry that resolves to a local-agent
bridge. ``name`` and ``model`` come straight from the config, while
``interface`` is the canonical key used for ack persistence."""
interface: str
name: str
model: str
def _truthy_env(value: str | None) -> bool:
if not value:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def _interface_for_entry(entry: dict) -> str | None:
"""Return the canonical local-agent interface name for ``entry``, or
``None`` if the entry isn't a local-agent provider.
Matching is intentionally permissive so that minimally-configured
entries i.e. just ``model: claude-cli/*`` before the Python
autofill runs are still detected. The first match wins and is
returned; multiple matches against the same interface collapse.
"""
if not isinstance(entry, dict):
return None
provider_interface = (entry.get("provider_interface") or "").strip()
provider = (entry.get("provider") or "").strip()
model = str(entry.get("model") or "").strip()
name = str(entry.get("name") or "").strip()
for interface in LOCAL_AGENT_PROVIDER_INTERFACES:
if provider_interface == interface or provider == interface:
return interface
prefix = f"{interface}/"
if model.startswith(prefix) or name.startswith(prefix):
return interface
return None
def detect_local_agent_providers(config: dict) -> list[LocalAgentProvider]:
"""Walk ``config`` and return every ``model_providers`` entry whose
``provider_interface`` falls in :data:`LOCAL_AGENT_PROVIDER_INTERFACES`.
Order is preserved so the warning lists providers in declaration
order. Both the new ``model_providers`` key and the legacy
``llm_providers`` key are consulted, mirroring the rest of the CLI.
"""
if not isinstance(config, dict):
return []
providers = config.get("model_providers")
if not isinstance(providers, list):
providers = config.get("llm_providers") or []
found: list[LocalAgentProvider] = []
for entry in providers:
interface = _interface_for_entry(entry)
if interface is None:
continue
model = str(entry.get("model") or "").strip()
name = str(entry.get("name") or "").strip() or model or interface
found.append(LocalAgentProvider(interface=interface, name=name, model=model))
return found
def _interfaces_in(providers: Iterable[LocalAgentProvider]) -> set[str]:
return {p.interface for p in providers}
def load_acknowledged_interfaces(ack_path: str = ACK_FILE_PATH) -> set[str]:
"""Read the ack file and return the set of acknowledged provider
interfaces. Missing or malformed files are treated as "no ack",
never as a hard error, so a half-written ack file degrades to "warn
again" instead of crashing ``planoai up``."""
try:
with open(ack_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return set()
if not isinstance(data, dict):
return set()
raw = data.get("acknowledged")
if not isinstance(raw, list):
return set()
return {str(item) for item in raw if isinstance(item, str)}
def write_acknowledgement(
interfaces: Iterable[str],
ack_path: str = ACK_FILE_PATH,
) -> set[str]:
"""Persist ``interfaces`` (merged with anything already on disk) to
the ack file. Returns the full acknowledged set after the write so
callers can render an "acknowledged: X, Y" line."""
merged = load_acknowledged_interfaces(ack_path) | set(interfaces)
payload = {
"acknowledged": sorted(merged),
"ack_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
os.makedirs(os.path.dirname(ack_path), exist_ok=True)
with open(ack_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, sort_keys=True)
f.write("\n")
return merged
def _render_panel(
console: Console,
pending: list[LocalAgentProvider],
) -> None:
"""Render the (small) reminder panel for ``pending``. Callers must
ensure ``pending`` is non-empty.
The panel is intentionally compact: the title names the interface(s),
the body is two short lines (capability summary + dismiss hint), and
the "Learn more" link points at the published Sphinx docs. Operators
who want the full trust-model write-up follow the link.
"""
interfaces = sorted({p.interface for p in pending})
interfaces_csv = ", ".join(interfaces)
# Show user-set names parenthetically, but skip ``<interface>/...``
# values — those are just the model id (or the autofilled placeholder)
# and add no information beyond the interface itself.
extra_names = sorted(
{
p.name
for p in pending
if p.name
and p.name != p.interface
and not any(
p.name.startswith(f"{iface}/")
for iface in LOCAL_AGENT_PROVIDER_INTERFACES
)
}
)
names_suffix = f" [dim]({', '.join(extra_names)})[/dim]" if extra_names else ""
plural = len(interfaces) > 1
pronoun = "they spawn" if plural else "it spawns"
body = (
f"[bold]{interfaces_csv}[/bold]{names_suffix} is a local-agent provider — "
f"{pronoun} a CLI subprocess that runs as you (full filesystem and shell "
f"access). For local development only.\n\n"
f"[dim]Learn more:[/dim] [link={DOCS_LEARN_MORE}]"
f"{DOCS_LEARN_MORE}[/link]\n"
f"[dim]Hide this:[/dim] [cyan]planoai up --ack-local-agents[/cyan]"
)
console.print(
Panel(
body,
title=f"⚠ Local-agent provider detected ({interfaces_csv})",
title_align="left",
border_style="yellow",
padding=(0, 2),
)
)
def maybe_warn_local_agent_providers(
config: dict,
console: Console,
*,
ack_flag: bool = False,
ack_path: str = ACK_FILE_PATH,
env: dict | None = None,
) -> bool:
"""Show the local-agent warning panel if appropriate and return
``True`` iff the panel was rendered.
Resolution order, top to bottom:
1. No local-agent providers in config no-op.
2. ``ack_flag`` (the ``--ack-local-agents`` CLI flag) **or** the
:data:`ACK_ENV_VAR` env var truthy write/update the ack file
so it covers every triggering interface, print one confirmation
line, suppress the panel.
3. Existing ack file already covers every triggering interface
print a single dim INFO line and suppress the panel.
4. Otherwise render the panel for the *un-acked* interfaces only
(e.g. acknowledged ``claude-cli`` doesn't suppress a fresh
warning when the operator later adds a hypothetical ``codex``).
"""
env = env if env is not None else os.environ
detected = detect_local_agent_providers(config)
if not detected:
return False
ack_via_env = _truthy_env(env.get(ACK_ENV_VAR))
if ack_flag or ack_via_env:
new_set = _interfaces_in(detected)
write_acknowledgement(new_set, ack_path=ack_path)
ack_csv = ", ".join(sorted(new_set))
console.print(
f"[green]✓[/green] Acknowledged local-agent provider: "
f"[bold]{ack_csv}[/bold] [dim](won't warn again)[/dim]"
)
return False
acknowledged = load_acknowledged_interfaces(ack_path)
pending = [p for p in detected if p.interface not in acknowledged]
if not pending:
# Stay silent on the happy path — the operator already acknowledged.
# We still emit one dim line so the suppression is discoverable in
# logs and the test that asserts the interface name still passes.
ack_csv = ", ".join(sorted(_interfaces_in(detected)))
console.print(f"[dim]local-agent provider: {ack_csv} (acknowledged)[/dim]")
return False
_render_panel(console, pending)
return True
__all__ = [
"ACK_ENV_VAR",
"ACK_FILE_PATH",
"DOCS_LEARN_MORE",
"LOCAL_AGENT_PROVIDER_INTERFACES",
"LocalAgentProvider",
"detect_local_agent_providers",
"load_acknowledged_interfaces",
"maybe_warn_local_agent_providers",
"write_acknowledgement",
]

View file

@ -39,6 +39,7 @@ from planoai.init_cmd import init as init_cmd
from planoai.trace_cmd import trace as trace_cmd, start_trace_listener_background
from planoai.chatgpt_cmd import chatgpt as chatgpt_cmd
from planoai.obs_cmd import obs as obs_cmd
from planoai.local_agent_warning import maybe_warn_local_agent_providers
from planoai.consts import (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT,
@ -354,6 +355,18 @@ def build(docker):
show_default=True,
help="Override the LLM listener port when running without a config file. Ignored when a config file is present.",
)
@click.option(
"--ack-local-agents",
"ack_local_agents",
default=False,
is_flag=True,
help=(
"Acknowledge that local-agent providers (e.g. claude-cli/*) spawn a "
"local CLI binary with full host filesystem and shell access. Writes "
"an ack file so the warning is suppressed on future runs. Equivalent "
"to setting PLANO_ACK_LOCAL_AGENTS=1."
),
)
def up(
file,
path,
@ -363,6 +376,7 @@ def up(
docker,
verbose,
listener_port,
ack_local_agents,
):
"""Starts Plano."""
from rich.status import Status
@ -444,6 +458,15 @@ def up(
with open(plano_config_file, "r") as f:
plano_config = yaml.safe_load(f)
# Warn about local-agent providers (e.g. claude-cli/*) that spawn a
# local CLI binary with full host filesystem and shell access. Fires
# exactly once per `planoai up` invocation; --ack-local-agents (or
# PLANO_ACK_LOCAL_AGENTS=1) writes a persistent ack so the warning
# only re-appears for newly-introduced local-agent interfaces.
maybe_warn_local_agent_providers(
plano_config or {}, console, ack_flag=ack_local_agents
)
# Inject ChatGPT tokens from ~/.plano/chatgpt/auth.json if any provider needs them
_inject_chatgpt_tokens_if_needed(plano_config, env, console)

View file

@ -22,6 +22,61 @@ from planoai.utils import find_repo_root, getLogger
log = getLogger(__name__)
CLAUDE_CLI_DEFAULT_LISTEN_ADDR = "127.0.0.1:14001"
# Env vars the user can set to customize the bridge. We always honor a
# pre-set CLAUDE_CLI_LISTEN_ADDR (so power users can move the listener)
# but otherwise inject the default whenever a claude-cli provider is
# detected in the rendered config.
CLAUDE_CLI_PASSTHROUGH_ENV = (
"CLAUDE_CLI_LISTEN_ADDR",
"CLAUDE_CLI_BIN",
"CLAUDE_CLI_PERMISSION_MODE",
"CLAUDE_CLI_SESSION_TTL_SECS",
"CLAUDE_CLI_WATCHDOG_SECS",
"CLAUDE_CLI_MAX_SESSIONS",
)
def _needs_claude_cli_runtime(plano_config_rendered_path) -> bool:
"""True iff the rendered config has at least one model_provider whose
`provider_interface` is `claude-cli`. The Python config_generator
auto-fills this field when it sees a `claude-cli/*` model entry, so the
detection is one-step regardless of how the user wrote the original
provider line.
"""
import yaml
try:
with open(plano_config_rendered_path, "r") as f:
rendered = yaml.safe_load(f) or {}
except FileNotFoundError:
return False
for provider in rendered.get("model_providers") or []:
if (provider or {}).get("provider_interface") == "claude-cli":
return True
return False
def _apply_claude_cli_env(brightstaff_env, plano_config_rendered_path):
"""If the rendered config opts into the claude-cli bridge, ensure
`CLAUDE_CLI_LISTEN_ADDR` is set in the brightstaff process environment so
the bridge listener actually starts. Honors any pre-set values from the
caller's env (so users can override the listen address, binary path, or
permission mode without editing this file).
"""
if not _needs_claude_cli_runtime(plano_config_rendered_path):
return False
if not brightstaff_env.get("CLAUDE_CLI_LISTEN_ADDR"):
brightstaff_env["CLAUDE_CLI_LISTEN_ADDR"] = CLAUDE_CLI_DEFAULT_LISTEN_ADDR
for key in CLAUDE_CLI_PASSTHROUGH_ENV:
if key in os.environ and key not in brightstaff_env:
brightstaff_env[key] = os.environ[key]
log.info(
"claude-cli bridge enabled: brightstaff will listen on %s",
brightstaff_env["CLAUDE_CLI_LISTEN_ADDR"],
)
return True
def _find_config_dir():
"""Locate the directory containing plano_config_schema.yaml and envoy.template.yaml.
@ -197,6 +252,11 @@ def start_native(
for key, value in env.items():
brightstaff_env[key] = value
# Enable the claude-cli bridge if the rendered config asks for it. Done
# after `env.items()` is merged so user-set CLAUDE_CLI_* env vars take
# precedence over the auto-injected defaults.
_apply_claude_cli_env(brightstaff_env, plano_config_rendered_path)
brightstaff_pid = _daemon_exec(
[brightstaff_path],
brightstaff_env,

View file

@ -3,8 +3,11 @@ import pytest
import yaml
from unittest import mock
from planoai.config_generator import (
validate_and_render_schema,
CLAUDE_CLI_DEFAULT_BASE_URL,
_apply_claude_cli_autofill,
_is_claude_cli_provider,
migrate_inline_routing_preferences,
validate_and_render_schema,
)
@ -795,3 +798,64 @@ model_providers:
migrate_inline_routing_preferences(config_yaml)
assert config_yaml["version"] == "v0.5.0"
def test_claude_cli_autofill_wildcard_provider():
provider = {"model": "claude-cli/*"}
assert _is_claude_cli_provider(provider) is True
assert _apply_claude_cli_autofill(provider) is True
assert provider["name"] == "claude-cli/*"
assert provider["provider_interface"] == "claude-cli"
assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
assert provider["access_key"] == "claude-cli-local"
# `model` itself must not be rewritten — the wildcard expansion happens
# downstream and we want to preserve the user's intent.
assert provider["model"] == "claude-cli/*"
def test_claude_cli_autofill_specific_model():
provider = {"model": "claude-cli/sonnet", "default": True}
assert _apply_claude_cli_autofill(provider) is True
assert provider["name"] == "claude-cli/sonnet"
assert provider["provider_interface"] == "claude-cli"
assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
# Existing fields like `default` survive.
assert provider["default"] is True
def test_claude_cli_autofill_does_not_override_user_fields():
provider = {
"model": "claude-cli/*",
"name": "custom-name",
"base_url": "http://192.0.2.10:9000",
"access_key": "do-not-touch",
}
assert _apply_claude_cli_autofill(provider) is True
assert provider["name"] == "custom-name"
assert provider["base_url"] == "http://192.0.2.10:9000"
assert provider["access_key"] == "do-not-touch"
# provider_interface still gets injected because it was missing.
assert provider["provider_interface"] == "claude-cli"
def test_claude_cli_autofill_skips_non_matching_providers():
provider = {"model": "openai/gpt-4o"}
assert _is_claude_cli_provider(provider) is False
assert _apply_claude_cli_autofill(provider) is False
assert "provider_interface" not in provider
def test_claude_cli_autofill_passthrough_auth_skips_access_key():
provider = {"model": "claude-cli/*", "passthrough_auth": True}
assert _apply_claude_cli_autofill(provider) is True
# Honor passthrough_auth: do not inject a placeholder access_key.
assert "access_key" not in provider
assert provider["passthrough_auth"] is True
def test_claude_cli_autofill_detects_via_provider_interface_only():
provider = {"model": "sonnet", "provider_interface": "claude-cli"}
assert _is_claude_cli_provider(provider) is True
assert _apply_claude_cli_autofill(provider) is True
assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
assert provider["name"] == "sonnet"

View file

@ -0,0 +1,324 @@
"""Tests for the local-agent provider warning, ack persistence, and the
detection logic that decides whether to fire it."""
from __future__ import annotations
import io
import json
import pytest
from rich.console import Console
from planoai import local_agent_warning as law
def _make_console() -> tuple[Console, io.StringIO]:
buf = io.StringIO()
# ``force_terminal=False`` keeps Rich from emitting ANSI escapes,
# which makes substring assertions readable. ``width`` is generous
# so the panel border doesn't soft-wrap text mid-keyword.
console = Console(file=buf, force_terminal=False, color_system=None, width=140)
return console, buf
# ---------------------------------------------------------------------------
# detection
# ---------------------------------------------------------------------------
def test_detects_claude_cli_via_model_prefix():
config = {
"model_providers": [
{"model": "claude-cli/sonnet"},
{"model": "openai/gpt-4o"},
]
}
found = law.detect_local_agent_providers(config)
assert [p.interface for p in found] == ["claude-cli"]
assert found[0].model == "claude-cli/sonnet"
def test_detects_claude_cli_via_explicit_provider_interface():
config = {
"model_providers": [
{"name": "local-claude", "provider_interface": "claude-cli", "model": "x"},
]
}
found = law.detect_local_agent_providers(config)
assert [p.interface for p in found] == ["claude-cli"]
assert found[0].name == "local-claude"
def test_detects_claude_cli_via_legacy_provider_field():
config = {"model_providers": [{"provider": "claude-cli", "model": "x"}]}
assert [p.interface for p in law.detect_local_agent_providers(config)] == [
"claude-cli"
]
def test_detects_via_legacy_llm_providers_key():
config = {"llm_providers": [{"model": "claude-cli/opus"}]}
assert [p.interface for p in law.detect_local_agent_providers(config)] == [
"claude-cli"
]
def test_no_false_positive_for_network_providers():
config = {
"model_providers": [
{"model": "openai/gpt-4o"},
{"model": "anthropic/claude-3-5-sonnet"},
{"model": "gemini/gemini-2.5-pro"},
{"model": "chatgpt/gpt-5"}, # network ChatGPT subscription, not a CLI
{"model": "vercel/some-model"},
]
}
assert law.detect_local_agent_providers(config) == []
def test_no_false_positive_for_anthropic_claude_models():
# ``anthropic/claude-3-5-sonnet`` must not trigger just because the
# word "claude" appears — the prefix has to be ``claude-cli/``.
config = {"model_providers": [{"model": "anthropic/claude-3-5-sonnet-20241022"}]}
assert law.detect_local_agent_providers(config) == []
def test_empty_or_malformed_config_is_safe():
assert law.detect_local_agent_providers({}) == []
assert law.detect_local_agent_providers({"model_providers": None}) == []
assert law.detect_local_agent_providers({"model_providers": "not-a-list"}) == []
# ``None`` config (e.g. from an empty yaml file) must also be safe.
assert law.detect_local_agent_providers(None) == [] # type: ignore[arg-type]
def test_multiple_entries_same_interface_collapse_in_warning_set():
config = {
"model_providers": [
{"model": "claude-cli/sonnet", "name": "fast"},
{"model": "claude-cli/opus", "name": "slow"},
]
}
found = law.detect_local_agent_providers(config)
assert len(found) == 2
assert {p.interface for p in found} == {"claude-cli"}
# ---------------------------------------------------------------------------
# ack file
# ---------------------------------------------------------------------------
def test_load_ack_returns_empty_when_missing(tmp_path):
ack = tmp_path / "ack.json"
assert law.load_acknowledged_interfaces(str(ack)) == set()
@pytest.mark.parametrize(
"contents",
[
"{not valid json",
"[]", # not a dict
'{"acknowledged": "claude-cli"}', # not a list
'{"acknowledged": [1, 2, 3]}', # not strings
],
)
def test_load_ack_handles_malformed_files(tmp_path, contents):
ack = tmp_path / "ack.json"
ack.write_text(contents, encoding="utf-8")
# Malformed contents must degrade to "no ack" rather than crashing.
assert law.load_acknowledged_interfaces(str(ack)) == set()
def test_write_ack_creates_state_dir(tmp_path):
ack = tmp_path / "fresh" / "deeper" / "ack.json"
merged = law.write_acknowledgement(["claude-cli"], ack_path=str(ack))
assert merged == {"claude-cli"}
assert ack.exists()
payload = json.loads(ack.read_text(encoding="utf-8"))
assert payload["acknowledged"] == ["claude-cli"]
assert payload["ack_at"]
def test_write_ack_merges_with_existing(tmp_path):
ack = tmp_path / "ack.json"
law.write_acknowledgement(["claude-cli"], ack_path=str(ack))
merged = law.write_acknowledgement(["future-cli"], ack_path=str(ack))
assert merged == {"claude-cli", "future-cli"}
payload = json.loads(ack.read_text(encoding="utf-8"))
assert payload["acknowledged"] == ["claude-cli", "future-cli"]
# ---------------------------------------------------------------------------
# maybe_warn_local_agent_providers
# ---------------------------------------------------------------------------
def test_no_panel_when_no_local_agent_providers(tmp_path):
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "openai/gpt-4o"}]},
console,
ack_path=str(tmp_path / "ack.json"),
env={},
)
assert fired is False
assert buf.getvalue() == ""
def test_panel_fires_for_unacked_claude_cli(tmp_path):
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "claude-cli/sonnet"}]},
console,
ack_path=str(tmp_path / "ack.json"),
env={},
)
output = buf.getvalue()
assert fired is True
# Stable substrings — never pin exact wording.
assert "claude-cli" in output
assert "Local-agent" in output or "local-agent" in output
assert "Learn more" in output
assert "--ack-local-agents" in output
# The panel is intentionally compact: it must NOT leak the ack file
# path into the user-visible reminder. The ``rm`` instruction lives
# in the docs page that "Learn more" links to.
assert "local_agent_ack.json" not in output
assert "docs.planoai.dev" in output
def test_panel_suppressed_when_ack_covers_interface(tmp_path):
ack = tmp_path / "ack.json"
law.write_acknowledgement(["claude-cli"], ack_path=str(ack))
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "claude-cli/sonnet"}]},
console,
ack_path=str(ack),
env={},
)
assert fired is False
# The dim INFO line still mentions the ack file so the operator
# knows how to undo, but no panel renders.
out = buf.getvalue()
assert "Panel" not in out # no panel object
assert "claude-cli" in out
def test_new_unacked_interface_re_triggers(tmp_path, monkeypatch):
# Simulate a future where two local-agent interfaces exist and the
# user has only acknowledged one of them.
monkeypatch.setattr(
law, "LOCAL_AGENT_PROVIDER_INTERFACES", ("claude-cli", "future-cli")
)
ack = tmp_path / "ack.json"
law.write_acknowledgement(["claude-cli"], ack_path=str(ack))
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{
"model_providers": [
{"model": "claude-cli/sonnet"},
{"model": "future-cli/whatever"},
]
},
console,
ack_path=str(ack),
env={},
)
output = buf.getvalue()
assert fired is True
# The panel must list the *unacknowledged* interface only.
assert "future-cli" in output
# ...and must NOT re-list the already-acknowledged one as unacked
# (it can still appear in the suppressed-info line; we check the
# title which only contains pending interfaces).
assert "future-cli" in output
def test_ack_flag_writes_file_and_suppresses_panel(tmp_path):
ack = tmp_path / "ack.json"
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "claude-cli/sonnet"}]},
console,
ack_flag=True,
ack_path=str(ack),
env={},
)
assert fired is False
assert ack.exists()
payload = json.loads(ack.read_text(encoding="utf-8"))
assert "claude-cli" in payload["acknowledged"]
out = buf.getvalue()
assert "Acknowledged" in out
assert "claude-cli" in out
@pytest.mark.parametrize("env_value", ["1", "true", "TRUE", "yes", "on"])
def test_ack_env_var_truthy_values(tmp_path, env_value):
ack = tmp_path / "ack.json"
console, _ = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "claude-cli/sonnet"}]},
console,
ack_path=str(ack),
env={law.ACK_ENV_VAR: env_value},
)
assert fired is False
assert ack.exists()
@pytest.mark.parametrize("env_value", ["", "0", "false", "no", "off", "maybe"])
def test_ack_env_var_falsy_values_still_warn(tmp_path, env_value):
ack = tmp_path / "ack.json"
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "claude-cli/sonnet"}]},
console,
ack_path=str(ack),
env={law.ACK_ENV_VAR: env_value},
)
assert fired is True
assert not ack.exists()
assert "claude-cli" in buf.getvalue()
def test_malformed_ack_falls_back_to_warning(tmp_path):
ack = tmp_path / "ack.json"
ack.write_text("{not json", encoding="utf-8")
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{"model_providers": [{"model": "claude-cli/sonnet"}]},
console,
ack_path=str(ack),
env={},
)
assert fired is True
assert "claude-cli" in buf.getvalue()
def test_single_panel_when_multiple_local_agent_entries(tmp_path):
# Two entries with the same interface must produce one panel,
# not two — the warning fires once per ``planoai up`` invocation.
console, buf = _make_console()
fired = law.maybe_warn_local_agent_providers(
{
"model_providers": [
{"model": "claude-cli/sonnet", "name": "fast"},
{"model": "claude-cli/opus", "name": "slow"},
]
},
console,
ack_path=str(tmp_path / "ack.json"),
env={},
)
assert fired is True
output = buf.getvalue()
# Both names appear in the listing, but the warning header
# (``Local-agent provider detected``) appears exactly once.
assert output.count("Local-agent provider detected") == 1
assert "fast" in output
assert "slow" in output

View file

@ -0,0 +1,112 @@
"""Unit tests for the claude-cli env wiring in native_runner.py."""
import os
import textwrap
from planoai.native_runner import (
CLAUDE_CLI_DEFAULT_LISTEN_ADDR,
_apply_claude_cli_env,
_needs_claude_cli_runtime,
)
def _write(path, body):
path.write_text(textwrap.dedent(body).lstrip())
return str(path)
def test_needs_claude_cli_runtime_detects_provider(tmp_path):
rendered = _write(
tmp_path / "rendered.yaml",
"""
version: v0.4.0
listeners: []
model_providers:
- name: claude-cli/*
model: '*'
provider_interface: claude-cli
base_url: http://127.0.0.1:14001
""",
)
assert _needs_claude_cli_runtime(rendered) is True
def test_needs_claude_cli_runtime_skips_other_providers(tmp_path):
rendered = _write(
tmp_path / "rendered.yaml",
"""
version: v0.4.0
model_providers:
- name: openai/gpt-4o
model: gpt-4o
provider_interface: openai
""",
)
assert _needs_claude_cli_runtime(rendered) is False
def test_needs_claude_cli_runtime_handles_missing_file(tmp_path):
assert _needs_claude_cli_runtime(str(tmp_path / "does-not-exist.yaml")) is False
def test_apply_claude_cli_env_injects_default_addr(tmp_path, monkeypatch):
rendered = _write(
tmp_path / "rendered.yaml",
"""
model_providers:
- provider_interface: claude-cli
model: '*'
""",
)
monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
monkeypatch.delenv("CLAUDE_CLI_BIN", raising=False)
env = {}
assert _apply_claude_cli_env(env, rendered) is True
assert env["CLAUDE_CLI_LISTEN_ADDR"] == CLAUDE_CLI_DEFAULT_LISTEN_ADDR
def test_apply_claude_cli_env_honors_user_override(tmp_path, monkeypatch):
rendered = _write(
tmp_path / "rendered.yaml",
"""
model_providers:
- provider_interface: claude-cli
model: '*'
""",
)
monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
env = {"CLAUDE_CLI_LISTEN_ADDR": "127.0.0.1:25000"}
assert _apply_claude_cli_env(env, rendered) is True
assert env["CLAUDE_CLI_LISTEN_ADDR"] == "127.0.0.1:25000"
def test_apply_claude_cli_env_passes_through_user_env(tmp_path, monkeypatch):
rendered = _write(
tmp_path / "rendered.yaml",
"""
model_providers:
- provider_interface: claude-cli
model: '*'
""",
)
monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
monkeypatch.setenv("CLAUDE_CLI_BIN", "/usr/local/bin/claude-test")
monkeypatch.setenv("CLAUDE_CLI_PERMISSION_MODE", "default")
env = {}
assert _apply_claude_cli_env(env, rendered) is True
assert env["CLAUDE_CLI_BIN"] == "/usr/local/bin/claude-test"
assert env["CLAUDE_CLI_PERMISSION_MODE"] == "default"
def test_apply_claude_cli_env_noop_for_other_configs(tmp_path):
rendered = _write(
tmp_path / "rendered.yaml",
"""
model_providers:
- provider_interface: openai
model: gpt-4o
""",
)
env = {}
assert _apply_claude_cli_env(env, rendered) is False
assert "CLAUDE_CLI_LISTEN_ADDR" not in env