mirror of
https://github.com/katanemo/plano.git
synced 2026-06-08 14:55:14 +02:00
Merge aaa9546890 into 554a3d1f6a
This commit is contained in:
commit
3b68edc813
36 changed files with 3784 additions and 5 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
297
cli/planoai/local_agent_warning.py
Normal file
297
cli/planoai/local_agent_warning.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
324
cli/test/test_local_agent_warning.py
Normal file
324
cli/test/test_local_agent_warning.py
Normal 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
|
||||
112
cli/test/test_native_runner_claude_cli.py
Normal file
112
cli/test/test_native_runner_claude_cli.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue