diff --git a/doc/RFC-provider-registry.md b/doc/RFC-provider-registry.md index 69d88367..1c18f32b 100644 --- a/doc/RFC-provider-registry.md +++ b/doc/RFC-provider-registry.md @@ -1,8 +1,13 @@ # RFC — Provider registry: subscription-auth harness backends for Strict -- Status: accepted (grilling session 2026-08-14); M1 — registry, api - parity, tool bridge, cursor transport — implemented on this branch; - claude-code (M2) and codex (M3) declared but unshipped +- Status: accepted (grilling session 2026-08-14); M1 (registry, api + parity, tool bridge, cursor transport) merged in PR #81 and live-smoked; + M2 claude-code and M3 codex implemented on this branch — claude-code + live-smoked on subscription auth, codex offline-tested only (no ChatGPT + login on the dev machine; readiness reports the login gap). The bridge + additionally serves the on-demand `repo_map`; skill/memory search tools + remain in-process only (cross-process candidate writes deliberately not + opened). - Owner: LLM/backend layer (`llm.py`, `config.py`), agent runtime (`engine/agent_runtime/runner.py`), new `src/infermatrix_copilot/providers/` - Prior art studied: Hermes Agent `api_mode` transports diff --git a/src/infermatrix_copilot/cli/doctor.py b/src/infermatrix_copilot/cli/doctor.py index aa8e2661..27f15fab 100644 --- a/src/infermatrix_copilot/cli/doctor.py +++ b/src/infermatrix_copilot/cli/doctor.py @@ -64,6 +64,9 @@ def _check_strict_backend(settings) -> tuple[bool, str]: return False, (f"backend {backend} selected but its CLI is missing — " "fix: install it or set STRICT_BACKEND_CLI=/path/to/" "cli in ~/.infermatrix-copilot/.env") + gap = transport.auth_gap() + if gap: + return False, f"backend {backend}: {gap}" return True, f"backend {backend} via {cli}" @@ -137,7 +140,7 @@ def _check_playbooks(settings) -> tuple[bool, str]: } -def _tier_targets(settings) -> list[tuple[str, "object"]]: +def _tier_targets(settings) -> list[tuple[str, object]]: """(label, ResolvedTarget) per configured tier; performance omitted (not an error) when deferred/unconfigured.""" from ..config import TierNotConfiguredError diff --git a/src/infermatrix_copilot/mcp_server.py b/src/infermatrix_copilot/mcp_server.py index d6edb949..9ee07752 100644 --- a/src/infermatrix_copilot/mcp_server.py +++ b/src/infermatrix_copilot/mcp_server.py @@ -31,8 +31,9 @@ import sys import threading import uuid +from collections.abc import Callable from pathlib import Path -from typing import Any, Callable, Literal, Optional +from typing import Any, Literal from . import run_status as rs from .config import Settings @@ -50,7 +51,7 @@ class CopilotMCP: ownership-aware reconciliation. Framework-agnostic (no `mcp` import) so it is unit-testable without a live protocol connection.""" - def __init__(self, settings: Optional[Settings] = None): + def __init__(self, settings: Settings | None = None): """Wire settings + a `Copilot` (for `reserve_run`/`execute_reserved` path helpers), register this server's liveness token, reconcile any runs orphaned by a previous server, and start the single worker thread.""" @@ -64,7 +65,7 @@ def __init__(self, settings: Optional[Settings] = None): self.pid = os.getpid() rs.register_server(self.run_root, self.server_id, self.pid) rs.startup_reconcile(self.run_root) - self._q: "queue.Queue[tuple[str, bool]]" = queue.Queue() + self._q: queue.Queue[tuple[str, bool]] = queue.Queue() self._worker = threading.Thread(target=self._worker_loop, daemon=True, name="omni-mcp-worker") self._worker.start() @@ -202,6 +203,10 @@ def strict_readiness(self, repo: str) -> list[str]: f"STRICT_BACKEND={backend} selected but its CLI is " "not installed; install it or set STRICT_BACKEND_CLI " "in ~/.infermatrix-copilot/.env") + else: + gap = transport.auth_gap() + if gap: + missing.append(gap) repo_path = self.copilot._resolve_repo_path(repo) if not repo_path or not Path(repo_path).is_dir(): missing.append( @@ -312,7 +317,7 @@ def _guard(fn: Callable[[], dict]) -> dict: return {"error": str(exc)} -def build_mcp(settings: Optional[Settings] = None): +def build_mcp(settings: Settings | None = None): """Build the FastMCP server with the V1 read-only tools bound to a `CopilotMCP`. Importing FastMCP here keeps the `mcp` dependency out of the core import path (it lives behind the `[mcp]` extra).""" diff --git a/src/infermatrix_copilot/providers/base.py b/src/infermatrix_copilot/providers/base.py index 45396ad2..e5ee8b76 100644 --- a/src/infermatrix_copilot/providers/base.py +++ b/src/infermatrix_copilot/providers/base.py @@ -19,6 +19,7 @@ from __future__ import annotations +import os import shutil from dataclasses import dataclass, field from pathlib import Path @@ -26,6 +27,20 @@ from ..scopes import ToolScope +# Env a harness CLI subprocess keeps. Everything else — API keys, base URLs, +# gh tokens, host markers like CLAUDECODE — is dropped: subscription auth +# lives in HOME state, and an inherited ANTHROPIC_BASE_URL (a gateway on +# this class of machine) would silently reroute a vendor CLI's traffic. +_ENV_KEEP = {"PATH", "HOME", "TERM", "COLORTERM", "LANG", "USER", "LOGNAME", + "SHELL", "TMPDIR"} +_ENV_KEEP_PREFIXES = ("LC_", "XDG_") + + +def sanitized_env() -> dict[str, str]: + """The allowlisted environment for spawning a harness CLI.""" + return {k: v for k, v in os.environ.items() + if k in _ENV_KEEP or k.startswith(_ENV_KEEP_PREFIXES)} + @dataclass(frozen=True) class ProviderSpec: @@ -109,6 +124,13 @@ def require_cli(self) -> str: "STRICT_BACKEND_CLI=/path/to/cli in ~/.infermatrix-copilot/.env") return cli + def auth_gap(self) -> str | None: + """A one-line auth problem with its fix, or None when unknown/fine. + Cheap enough for `strict_readiness` (one fast CLI status call at + most); transports without a cheap check return None and let the run + surface auth errors loudly.""" + return None + # -- contract ------------------------------------------------------------ def run_session(self, req: AgentSessionRequest): """Run one delegated agent step; returns `agent_loop.AgentOutcome`.""" diff --git a/src/infermatrix_copilot/providers/claude_code.py b/src/infermatrix_copilot/providers/claude_code.py new file mode 100644 index 00000000..547edce7 --- /dev/null +++ b/src/infermatrix_copilot/providers/claude_code.py @@ -0,0 +1,205 @@ +"""Claude Code harness transport — Strict on a Claude subscription (M2). + +The cleanest harness citizen (probed live 2026-08-14 on claude 2.1.232): +headless ``claude -p --output-format json`` returns ONE JSON object with +``result``, ``num_turns``, ``stop_reason``, ``total_cost_usd``, ``usage`` +and per-model ``modelUsage``; ``--max-turns`` maps our iteration budget +natively; ``--system-prompt`` (+ ``--exclude-dynamic-system-prompt- +sections``) replaces the vendor system prompt with our step contract. + +Tool governance is fully PREVENTIVE here, unlike cursor: built-in tools are +denied wholesale via ``--disallowedTools``, and only the MCP tool bridge is +allowed (``--mcp-config`` + ``--strict-mcp-config`` + ``--allowedTools +mcp__infermatrix-tools``), so every tool call flows through +``tools.dispatch`` — no native-tool audit needed. Session tool counts come +from the bridge trace delta. + +Env is the shared allowlist (`base.sanitized_env`): the CLI must use its +own subscription auth from HOME, never an inherited ANTHROPIC_API_KEY / +ANTHROPIC_BASE_URL, and never see the host's CLAUDECODE marker. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from ..agent_loop import AgentOutcome +from ..llm import Block, Reply +from .base import ( + AgentSessionRequest, + HarnessTransport, + SessionUsage, + flatten_messages, + sanitized_env, +) +from .registry import PROVIDERS + +# Built-ins denied for every session: the bridge is the only tool surface. +_BUILTIN_DENY = ("Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch," + "NotebookEdit,Task,TodoWrite") +_BRIDGE_SERVER = "infermatrix-tools" + + +class ClaudeCodeTransport(HarnessTransport): + """claude CLI (headless) as a Strict backend.""" + + spec = PROVIDERS["claude-code"] + + # -- process plumbing ---------------------------------------------------- + def _run(self, prompt_text: str, *, system: str, cwd: str, + timeout_s: float, max_turns: int, model: str = "", + mcp_config: Path | None = None) -> tuple[dict, bool]: + """One CLI invocation → (parsed result object, timed_out). Prompt on + stdin (argv has a 128KiB per-arg limit; evidence packs exceed it).""" + cmd = [self.require_cli(), "-p", "--output-format", "json", + "--max-turns", str(max_turns), + "--disallowedTools", _BUILTIN_DENY] + if system: + cmd += ["--system-prompt", system, + "--exclude-dynamic-system-prompt-sections"] + selected = model or self.settings.strict_backend_model + if selected: + cmd += ["--model", selected] + if mcp_config is not None: + cmd += ["--mcp-config", str(mcp_config), "--strict-mcp-config", + "--allowedTools", f"mcp__{_BRIDGE_SERVER}"] + try: + proc = subprocess.run( + cmd, input=prompt_text, cwd=cwd, env=sanitized_env(), + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=timeout_s, check=False) + stdout = proc.stdout or "" + except subprocess.TimeoutExpired as exc: + raw = exc.stdout or b"" + stdout = raw.decode("utf-8", "replace") if isinstance(raw, bytes) \ + else str(raw) + return self._parse(stdout), True + return self._parse(stdout), False + + @staticmethod + def _parse(stdout: str) -> dict: + """The single JSON object from -p json output; tolerant of stray + warning lines before it.""" + start = stdout.find("{") + if start < 0: + return {} + try: + data = json.loads(stdout[start:]) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + return {} + + @staticmethod + def _usage(data: dict) -> SessionUsage: + raw = data.get("usage") or {} + usage = SessionUsage( + input_tokens=int(raw.get("input_tokens") or 0), + output_tokens=int(raw.get("output_tokens") or 0), + cost_usd=(float(data["total_cost_usd"]) + if data.get("total_cost_usd") is not None else None)) + # served model = the modelUsage entry that did the main work (max + # cost); helper models (haiku sidecars) lose that comparison + models = data.get("modelUsage") or {} + if isinstance(models, dict) and models: + usage.served_model = max( + models, key=lambda m: float( + (models[m] or {}).get("costUSD") or 0)) + return usage + + def _write_mcp_config(self, spec_path: Path) -> Path: + """The --mcp-config file, next to the bridge spec (never in the + worktree — claude takes the config by flag, so nothing litters the + session tree).""" + package_root = Path(__file__).resolve().parents[2] + config = spec_path.with_suffix(".mcp.json") + config.write_text(json.dumps({"mcpServers": {_BRIDGE_SERVER: { + "command": sys.executable, + "args": ["-m", "infermatrix_copilot.tool_bridge", + "--spec", str(spec_path)], + "env": {"PYTHONPATH": str(package_root)}, + }}}, indent=2), encoding="utf-8") + return config + + @staticmethod + def _bridge_activity(run_dir: Path, since_line: int) -> tuple[int, list[str]]: + """(tool_calls, tools_used) from the bridge trace delta — with + built-ins denied, bridged calls ARE the session's tool activity.""" + trace = run_dir / "bridge_trace.jsonl" + if not trace.exists(): + return 0, [] + tools: list[str] = [] + for line in trace.read_text(encoding="utf-8").splitlines()[since_line:]: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("kind") == "tool_call": + tools.append(str(event.get("tool") or "?")) + return len(tools), tools + + @staticmethod + def _trace_lines(run_dir: Path) -> int: + trace = run_dir / "bridge_trace.jsonl" + return len(trace.read_text(encoding="utf-8").splitlines()) \ + if trace.exists() else 0 + + # -- transport contract -------------------------------------------------- + def run_session(self, req: AgentSessionRequest) -> AgentOutcome: + cwd = str(req.scope.root or req.run_dir) + mcp_config = (self._write_mcp_config(req.bridge_spec_path) + if req.bridge_spec_path is not None else None) + before = self._trace_lines(req.run_dir) + data, timed_out = self._run( + req.prompt, system=req.system, cwd=cwd, timeout_s=req.timeout_s, + max_turns=req.max_iters, model=req.model, mcp_config=mcp_config) + usage = self._usage(data) + tool_calls, tools_used = self._bridge_activity(req.run_dir, before) + is_error = bool(data.get("is_error")) + if req.trace is not None: + req.trace.record( + "harness_session", provider=self.spec.id, step=req.step_name, + num_turns=data.get("num_turns"), is_error=is_error, + timed_out=timed_out, cost_usd=usage.cost_usd, + bridge_tool_calls=tool_calls, + served_model=usage.served_model) + return AgentOutcome( + text=str(data.get("result") or ""), + iterations=int(data.get("num_turns") or 0), + tool_calls=tool_calls, + truncated=timed_out or data.get("stop_reason") == "max_turns", + refusals=[], + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + tools_used=tools_used[:40]) + + def complete(self, *, system: str, messages: list[dict], + model: str = "", max_tokens: int | None = None, + role: str = "") -> Reply: + """Tool-less one-shot: built-ins denied, no MCP config, two turns + (one to think, the cap as a backstop). Runs in the run-less scratch + of the process cwd — with every tool denied there is nothing to + contain.""" + import tempfile + + with tempfile.TemporaryDirectory(prefix="imc-claude-oneshot-") as td: + data, timed_out = self._run( + flatten_messages("", messages), system=system, cwd=td, + timeout_s=self.settings.strict_backend_timeout_s, + max_turns=2, model=model) + usage = self._usage(data) + text = str(data.get("result") or "") + return Reply( + blocks=[Block(type="text", text=text)] if text else [], + stop_reason="max_tokens" if timed_out else "end_turn", + usage={"input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_read_input_tokens": int( + (data.get("usage") or {}).get( + "cache_read_input_tokens") or 0), + "cache_creation_input_tokens": int( + (data.get("usage") or {}).get( + "cache_creation_input_tokens") or 0)}, + model=usage.served_model) diff --git a/src/infermatrix_copilot/providers/codex.py b/src/infermatrix_copilot/providers/codex.py new file mode 100644 index 00000000..c968f798 --- /dev/null +++ b/src/infermatrix_copilot/providers/codex.py @@ -0,0 +1,197 @@ +"""Codex CLI harness transport — Strict on a ChatGPT subscription (M3). + +``codex exec --json`` emits JSONL events; the final agent message is the +session's answer and ``turn.completed`` events carry token usage. Probed on +codex-cli 0.145.0 (auth was absent on the dev machine, so unlike cursor/ +claude-code this transport is exercised offline against recorded shapes — +the readiness path reports the login gap before any run starts). + +Governance posture (disclosed, per doc/RFC-provider-registry.md): codex +cannot disable its native shell, but ``--sandbox read-only`` is an OS-level +PREVENTIVE guarantee against writes and network egress; the MCP tool +bridge is offered alongside via ``-c mcp_servers...`` overrides so scoped +reads flow through ``tools.dispatch``. Broad *reads* inside the sandbox +remain possible and are a documented limitation of this backend class. + +Env is the shared allowlist (`base.sanitized_env`); codex keeps its own +auth under HOME (~/.codex).""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from ..agent_loop import AgentOutcome +from ..llm import Block, Reply +from .base import ( + AgentSessionRequest, + HarnessTransport, + SessionUsage, + flatten_messages, + sanitized_env, +) +from .registry import PROVIDERS + +_BRIDGE_SERVER = "infermatrix-tools" + + +class CodexTransport(HarnessTransport): + """codex CLI (exec mode) as a Strict backend.""" + + spec = PROVIDERS["codex"] + + def auth_gap(self) -> str | None: + cli = self.cli_path() + if not cli: + return None # the CLI-missing gap is reported separately + try: + out = subprocess.run([cli, "login", "status"], capture_output=True, + text=True, encoding="utf-8", errors="replace", + timeout=15, check=False) + except (OSError, subprocess.SubprocessError): + return None # status probe itself broken — let the run surface it + blob = f"{out.stdout}\n{out.stderr}" + if out.returncode != 0 or "Not logged in" in blob: + return ("codex CLI is not logged in — run: codex login " + "(ChatGPT subscription auth)") + return None + + # -- process plumbing ---------------------------------------------------- + def _mcp_overrides(self, spec_path: Path) -> list[str]: + """``-c`` config overrides wiring the tool bridge as an MCP server — + config-only, so nothing is written into the session tree.""" + package_root = Path(__file__).resolve().parents[2] + args = json.dumps(["-m", "infermatrix_copilot.tool_bridge", + "--spec", str(spec_path)]) + return [ + "-c", f'mcp_servers.{_BRIDGE_SERVER}.command="{sys.executable}"', + "-c", f"mcp_servers.{_BRIDGE_SERVER}.args={args}", + "-c", (f"mcp_servers.{_BRIDGE_SERVER}.env=" + f'{{PYTHONPATH = "{package_root}"}}'), + ] + + def _run(self, text: str, *, cwd: str, timeout_s: float, model: str = "", + mcp_spec: Path | None = None) -> tuple[list[dict], bool]: + """One CLI invocation → (parsed events, timed_out). Prompt on stdin + (the ``-`` positional; argv has a 128KiB per-arg limit).""" + cmd = [self.require_cli(), "exec", "--json", "-s", "read-only", + "--skip-git-repo-check", "-C", cwd] + selected = model or self.settings.strict_backend_model + if selected: + cmd += ["-m", selected] + if mcp_spec is not None: + cmd += self._mcp_overrides(mcp_spec) + cmd += ["-"] + timed_out = False + try: + proc = subprocess.run( + cmd, input=text, cwd=cwd, env=sanitized_env(), + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=timeout_s, check=False) + stdout = proc.stdout or "" + except subprocess.TimeoutExpired as exc: + timed_out = True + raw = exc.stdout or b"" + stdout = raw.decode("utf-8", "replace") if isinstance(raw, bytes) \ + else str(raw) + events: list[dict] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return events, timed_out + + @staticmethod + def _final_text(events: list[dict]) -> str: + """The last agent message. Shape is version-dependent — accept both + the item.completed/agent_message form and any event carrying an + agent message text field.""" + last = "" + for event in events: + item = event.get("item") + if isinstance(item, dict): + kind = item.get("item_type") or item.get("type") or "" + if "agent_message" in str(kind) and item.get("text"): + last = str(item["text"]) + return last.strip() + + @staticmethod + def _usage(events: list[dict]) -> SessionUsage: + usage = SessionUsage() + for event in events: + raw = event.get("usage") + if isinstance(raw, dict): + usage.input_tokens += int(raw.get("input_tokens") or 0) + usage.output_tokens += int(raw.get("output_tokens") or 0) + model = event.get("model") + if isinstance(model, str) and model: + usage.served_model = model + return usage + + @staticmethod + def _tool_activity(events: list[dict]) -> list[str]: + """Names of non-message items the session completed (commands, MCP + tool calls…) — codex's specific item types vary by version, so this + is a best-effort activity log, not an audit (the sandbox is the + enforcement layer).""" + used: list[str] = [] + for event in events: + item = event.get("item") + if isinstance(item, dict): + kind = str(item.get("item_type") or item.get("type") or "") + if kind and "agent_message" not in kind \ + and "reasoning" not in kind: + used.append(kind) + return used + + # -- transport contract -------------------------------------------------- + def run_session(self, req: AgentSessionRequest) -> AgentOutcome: + cwd = str(req.scope.root or req.run_dir) + events, timed_out = self._run( + f"{req.system}\n\n{req.prompt}", cwd=cwd, + timeout_s=req.timeout_s, model=req.model, + mcp_spec=req.bridge_spec_path) + usage = self._usage(events) + used = self._tool_activity(events) + if req.trace is not None: + req.trace.record( + "harness_session", provider=self.spec.id, step=req.step_name, + timed_out=timed_out, item_count=len(events), + tool_items=len(used), served_model=usage.served_model) + return AgentOutcome( + text=self._final_text(events), + iterations=0, # codex does not expose a turn budget/counter + tool_calls=len(used), + truncated=timed_out, + refusals=[], + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + tools_used=used[:40]) + + def complete(self, *, system: str, messages: list[dict], + model: str = "", max_tokens: int | None = None, + role: str = "") -> Reply: + """Tool-less one-shot in an empty scratch cwd (read-only sandbox + + nothing to read = contained).""" + import tempfile + + with tempfile.TemporaryDirectory(prefix="imc-codex-oneshot-") as td: + events, timed_out = self._run( + flatten_messages(system, messages), cwd=td, + timeout_s=self.settings.strict_backend_timeout_s, model=model) + usage = self._usage(events) + text = self._final_text(events) + return Reply( + blocks=[Block(type="text", text=text)] if text else [], + stop_reason="max_tokens" if timed_out else "end_turn", + usage={"input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0}, + model=usage.served_model) diff --git a/src/infermatrix_copilot/providers/cursor.py b/src/infermatrix_copilot/providers/cursor.py index a22ee4f1..4a37f8e4 100644 --- a/src/infermatrix_copilot/providers/cursor.py +++ b/src/infermatrix_copilot/providers/cursor.py @@ -18,7 +18,6 @@ from __future__ import annotations import json -import os import shutil import subprocess import tempfile @@ -26,26 +25,36 @@ from ..agent_loop import AgentOutcome from ..llm import Block, Reply -from .base import AgentSessionRequest, HarnessTransport, SessionUsage, flatten_messages +from .base import ( + AgentSessionRequest, + HarnessTransport, + SessionUsage, + flatten_messages, + sanitized_env, +) from .registry import PROVIDERS -# Env the CLI subprocess keeps. Everything else — API keys, base URLs, gh -# tokens, host markers — is dropped; subscription auth lives in HOME state. -_ENV_KEEP = {"PATH", "HOME", "TERM", "COLORTERM", "LANG", "USER", "LOGNAME", - "SHELL", "TMPDIR"} -_ENV_KEEP_PREFIXES = ("LC_", "XDG_") - class CursorTransport(HarnessTransport): """cursor-agent CLI as a Strict backend.""" spec = PROVIDERS["cursor"] - # -- process plumbing ---------------------------------------------------- - def _env(self) -> dict[str, str]: - return {k: v for k, v in os.environ.items() - if k in _ENV_KEEP or k.startswith(_ENV_KEEP_PREFIXES)} + def auth_gap(self) -> str | None: + cli = self.cli_path() + if not cli: + return None # the CLI-missing gap is reported separately + try: + out = subprocess.run([cli, "status"], capture_output=True, + text=True, encoding="utf-8", errors="replace", + timeout=15, check=False) + except (OSError, subprocess.SubprocessError): + return None + if "Logged in" not in f"{out.stdout}\n{out.stderr}": + return "cursor-agent is not logged in — run: cursor-agent login" + return None + # -- process plumbing ---------------------------------------------------- def _run(self, text: str, *, cwd: str, timeout_s: float, model: str = "") -> tuple[list[dict], bool]: """One CLI invocation → (parsed events, timed_out). A timeout kills @@ -62,7 +71,7 @@ def _run(self, text: str, *, cwd: str, timeout_s: float, timed_out = False try: proc = subprocess.run( - cmd, input=text, cwd=cwd, env=self._env(), + cmd, input=text, cwd=cwd, env=sanitized_env(), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, check=False) stdout = proc.stdout or "" diff --git a/src/infermatrix_copilot/providers/registry.py b/src/infermatrix_copilot/providers/registry.py index fa4b8485..e26b7242 100644 --- a/src/infermatrix_copilot/providers/registry.py +++ b/src/infermatrix_copilot/providers/registry.py @@ -1,10 +1,10 @@ """Provider registry — the one table of ways to reach a model. -All four ids are declared so the config surface is stable from day one, but -only implemented transports resolve: `transport_for` raises a milestone -pointer for claude-code/codex (M2/M3, doc/RFC-provider-registry.md) so -`strict_readiness`/doctor report "not yet shipped" instead of a run failing -mid-flight. +All four ids resolve to shipped transports (M1 cursor, M2 claude-code, +M3 codex — doc/RFC-provider-registry.md). `_UNSHIPPED` remains the +mechanism for declaring a future backend before its transport lands: +`transport_for` raises its milestone pointer so `strict_readiness`/doctor +report "not yet shipped" instead of a run failing mid-flight. """ from __future__ import annotations @@ -23,22 +23,23 @@ capabilities=frozenset({"mcp_tools", "usage_reporting"})), ProviderSpec( id="claude-code", kind="harness", - display="Claude subscription via claude CLI (M2)", + display="Claude subscription via claude CLI", cli_names=("claude",), capabilities=frozenset({ "mcp_tools", "builtin_tools_off", "max_turns", "system_prompt", "usage_reporting", "cost_reporting"})), ProviderSpec( id="codex", kind="harness", - display="ChatGPT subscription via codex CLI (M3)", + display="ChatGPT subscription via codex CLI", cli_names=("codex",), - capabilities=frozenset({"mcp_tools", "usage_reporting"})), + capabilities=frozenset({ + "mcp_tools", "sandbox_read_only", "usage_reporting"})), ) } -# Registered but shipping in a later milestone — transport_for names the +# Declared-but-unshipped backends (currently none) — transport_for names the # milestone instead of returning a transport that cannot work. -_UNSHIPPED: dict[str, str] = {"claude-code": "M2", "codex": "M3"} +_UNSHIPPED: dict[str, str] = {} def resolve_provider(settings) -> ProviderSpec: @@ -66,7 +67,15 @@ def transport_for(settings) -> HarnessTransport: if milestone: raise NotImplementedError( f"backend {spec.id!r} is declared but ships in {milestone} " - "(doc/RFC-provider-registry.md) — use cursor or api for now") - from .cursor import CursorTransport + "(doc/RFC-provider-registry.md)") + if spec.id == "cursor": + from .cursor import CursorTransport + + return CursorTransport(settings) + if spec.id == "claude-code": + from .claude_code import ClaudeCodeTransport + + return ClaudeCodeTransport(settings) + from .codex import CodexTransport - return CursorTransport(settings) + return CodexTransport(settings) diff --git a/src/infermatrix_copilot/tool_bridge.py b/src/infermatrix_copilot/tool_bridge.py index fd94fbfa..c4c14a0b 100644 --- a/src/infermatrix_copilot/tool_bridge.py +++ b/src/infermatrix_copilot/tool_bridge.py @@ -157,22 +157,50 @@ def edit_file(path: str, old: str, new: str) -> str: def run_shell(cmd: str, cwd: str = "") -> str: return _call("run_shell", {"cmd": cmd, "cwd": cwd or None}) - _register_doc_tools(mcp, spec, trace) + _register_knowledge_tools(mcp, spec, scope, trace) return mcp -def _register_doc_tools(mcp, spec: dict, trace: RunTrace) -> None: - """Knowledge doc search/read — same read-only surface the thin MCP - exposes, scoped to general/ + this repo's slice. Absent knowledge root - (source checkout moved) degrades to not registering, never to a crash.""" +def _bridge_ctx(spec: dict, scope: ToolScope, trace: RunTrace): + """A minimal StepContext view for the agent-runtime knowledge factories: + they consume only settings / state / run_dir / trace, all of which the + bridge spec can reconstruct.""" + from types import SimpleNamespace + from .config import Settings - from .knowledge_docs import KnowledgeDocs + return SimpleNamespace( + settings=Settings(), + state={"task_spec": {"repo": spec.get("repo", "")}, + "repo_path": scope.root}, + run_dir=Path(spec["run_dir"]), + trace=trace) + + +def _register_knowledge_tools(mcp, spec: dict, scope: ToolScope, + trace: RunTrace) -> None: + """Knowledge doc search/read + the on-demand repo_map — the same + read-only extra tools the in-process runtime hands agent steps, rebuilt + from the spec. Any piece that cannot be reconstructed degrades to not + registering (capability_gap traced), never to a crash. Still absent vs + in-process: skill_search / memory_search / candidate proposals (a + cross-process write surface deliberately not opened here).""" try: - settings = Settings() - docs = KnowledgeDocs(settings.knowledge_dir, - repo_subdir=f"repos/{spec.get('repo', '')}" - if spec.get("repo") else None) + from .engine.agent_runtime.knowledge import ( + _repo_map_tool, + _resolve_adapter, + ) + from .knowledge_docs import KnowledgeDocs + + ctx = _bridge_ctx(spec, scope, trace) + adapter = _resolve_adapter(ctx) + repo_subdir = None + if adapter is not None: + repo_subdir = (adapter.manifest.get("knowledge") + or {}).get("repo_subdir") + if not repo_subdir and spec.get("repo"): + repo_subdir = f"repos/{spec['repo']}" + docs = KnowledgeDocs(ctx.settings.knowledge_dir, repo_subdir) except Exception as exc: # noqa: BLE001 — degrade, never crash the bridge trace.record("capability_gap", capability="bridge.knowledge_docs", effect=f"doc tools unavailable: {type(exc).__name__}: {exc}") @@ -185,7 +213,7 @@ def doc_search(query: str, limit: int = 20) -> str: out_of_scope=False, path=None) hits = docs.search(query, limit=limit) return "\n".join( - f"{h.get('path')}:{h.get('line')} — {str(h.get('snippet') or '').strip()}" + f"{h.get('path')}:{h.get('line')}:{str(h.get('text') or '').strip()}" for h in hits) or "(no matches)" @mcp.tool(description="Read a knowledge doc returned by doc_search " @@ -198,6 +226,25 @@ def doc_read(path: str, offset: int = 0) -> str: nxt = page.get("next_offset") return text + (f"\n\n[continues — doc_read offset={nxt}]" if nxt else "") + try: + map_tools = _repo_map_tool(ctx, adapter) + except Exception as exc: # noqa: BLE001 — optional; degrade loudly + trace.record("capability_gap", capability="bridge.repo_map", + effect=f"repo_map unavailable: {type(exc).__name__}: {exc}") + return + if "repo_map" in map_tools: + tool = map_tools["repo_map"] + + @mcp.tool(description=tool.description) + def repo_map(query: str) -> str: + # dispatch with extra= mirrors the in-process extra-tool path + # (traced, bypasses the builtin allowlist by design) + out = dispatch("repo_map", {"query": query}, scope=scope, + trace=trace, extra=map_tools) + if not out["ok"]: + raise RuntimeError(str(out.get("error") or "tool error")) + return str(out["result"]) + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( diff --git a/test/test_mcp.py b/test/test_mcp.py index a6018623..d977f509 100644 --- a/test/test_mcp.py +++ b/test/test_mcp.py @@ -248,9 +248,11 @@ def test_strict_readiness_requires_explicit_backend(settings, monkeypatch): assert not any("model credential" in item for item in missing) # declared-but-unshipped backends point at their milestone + from infermatrix_copilot.providers import registry + monkeypatch.setitem(registry._UNSHIPPED, "codex", "M9") settings.strict_backend = "codex" missing = _core(settings).strict_readiness("vllm-omni") - assert any("M3" in item for item in missing) + assert any("M9" in item for item in missing) def test_strict_readiness_accepts_packaged_runtime(settings, tmp_path): diff --git a/test/test_provider_claude_code.py b/test/test_provider_claude_code.py new file mode 100644 index 00000000..4c178a45 --- /dev/null +++ b/test/test_provider_claude_code.py @@ -0,0 +1,153 @@ +"""Claude Code transport against a fake claude CLI — fully offline. + +The fake reproduces the -p --output-format json contract observed live on +claude 2.1.232 (single JSON object: result / num_turns / stop_reason / +total_cost_usd / usage / modelUsage) and captures its invocation.""" + +import json +import stat +from pathlib import Path + +from infermatrix_copilot.config import Settings +from infermatrix_copilot.providers.base import AgentSessionRequest +from infermatrix_copilot.providers.claude_code import ClaudeCodeTransport +from infermatrix_copilot.scopes import read_only_scope +from infermatrix_copilot.tool_bridge import write_bridge_spec + +_FAKE_CLI = """#!/usr/bin/env python3 +import json, os, sys, time +here = os.path.dirname(os.path.abspath(__file__)) +text = sys.stdin.read() +with open(os.path.join(here, "capture.json"), "w") as f: + json.dump({"argv": sys.argv[1:], "stdin": text, "cwd": os.getcwd(), + "env_key": os.environ.get("ANTHROPIC_API_KEY", ""), + "env_claudecode": os.environ.get("CLAUDECODE", "")}, f) +if os.path.exists(os.path.join(here, "sleep")): + time.sleep(10) +print("some stray warning line") +print(json.dumps({ + "type": "result", "subtype": "success", "is_error": False, + "result": "REVIEW", "num_turns": 3, "stop_reason": "end_turn", + "total_cost_usd": 0.21, + "usage": {"input_tokens": 100, "output_tokens": 20, + "cache_read_input_tokens": 5, + "cache_creation_input_tokens": 7}, + "modelUsage": {"claude-haiku-4-5": {"costUSD": 0.001}, + "claude-fable-5": {"costUSD": 0.19}}})) +""" + + +class FakeTrace: + def __init__(self): + self.events = [] + + def record(self, kind, **fields): + self.events.append({"kind": kind, **fields}) + + +def _transport(tmp_path: Path) -> ClaudeCodeTransport: + cli = tmp_path / "bin" / "claude" + cli.parent.mkdir(exist_ok=True) + cli.write_text(_FAKE_CLI, encoding="utf-8") + cli.chmod(cli.stat().st_mode | stat.S_IXUSR) + return ClaudeCodeTransport(Settings( + _env_file=None, strict_backend="claude-code", + strict_backend_cli=str(cli))) + + +def _request(tmp_path: Path, with_bridge: bool = True) -> AgentSessionRequest: + worktree = tmp_path / "worktree" + worktree.mkdir() + run_dir = tmp_path / "run" + run_dir.mkdir() + scope = read_only_scope() + scope = type(scope)(name=scope.name, allowed_tools=scope.allowed_tools, + read_only=True, root=str(worktree)) + bridge = write_bridge_spec(run_dir=run_dir, step_name="agent.review_diff", + scope=scope, repo="vllm-omni") \ + if with_bridge else None + return AgentSessionRequest( + system="SYS", prompt="PROMPT", scope=scope, model="", + max_iters=8, timeout_s=30.0, run_dir=run_dir, + step_name="agent.review_diff", bridge_spec_path=bridge, + trace=FakeTrace()) + + +def test_run_session_flags_parse_and_governance(tmp_path, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "should-not-leak") + monkeypatch.setenv("CLAUDECODE", "1") + transport = _transport(tmp_path) + req = _request(tmp_path) + + outcome = transport.run_session(req) + + assert outcome.text == "REVIEW" and outcome.iterations == 3 + assert (outcome.input_tokens, outcome.output_tokens) == (100, 20) + assert outcome.truncated is False + + capture = json.loads( + (tmp_path / "bin" / "capture.json").read_text(encoding="utf-8")) + argv = capture["argv"] + # governance flags: built-ins denied, bridge-only via strict mcp config + assert "--disallowedTools" in argv and "--strict-mcp-config" in argv + assert argv[argv.index("--allowedTools") + 1] == "mcp__infermatrix-tools" + assert argv[argv.index("--max-turns") + 1] == "8" + assert argv[argv.index("--system-prompt") + 1] == "SYS" + assert "--exclude-dynamic-system-prompt-sections" in argv + # prompt rides stdin; the system prompt does NOT (it has its own channel) + assert capture["stdin"] == "PROMPT" + # sanitized env: no API key (subscription auth) and no host marker + assert capture["env_key"] == "" and capture["env_claudecode"] == "" + + # mcp config written NEXT TO the spec, never into the worktree + config = Path(argv[argv.index("--mcp-config") + 1]) + assert config.parent == req.bridge_spec_path.parent + body = json.loads(config.read_text(encoding="utf-8")) + assert "tool_bridge" in " ".join( + body["mcpServers"]["infermatrix-tools"]["args"]) + + session = [e for e in req.trace.events if e["kind"] == "harness_session"] + assert session[0]["cost_usd"] == 0.21 + assert session[0]["served_model"] == "claude-fable-5" # max-cost entry + + +def test_bridge_activity_counts_only_new_trace_lines(tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + trace = run_dir / "bridge_trace.jsonl" + trace.write_text(json.dumps({"kind": "tool_call", "tool": "grep"}) + "\n", + encoding="utf-8") + before = ClaudeCodeTransport._trace_lines(run_dir) + with trace.open("a", encoding="utf-8") as f: + f.write(json.dumps({"kind": "tool_call", "tool": "read_file"}) + "\n") + f.write(json.dumps({"kind": "tool_refused", "tool": "read_file"}) + "\n") + calls, used = ClaudeCodeTransport._bridge_activity(run_dir, before) + assert (calls, used) == (1, ["read_file"]) + + +def test_run_session_timeout_is_truncated(tmp_path): + transport = _transport(tmp_path) + req = _request(tmp_path, with_bridge=False) + req.timeout_s = 0.8 + (tmp_path / "bin" / "sleep").write_text("", encoding="utf-8") + + outcome = transport.run_session(req) + + assert outcome.truncated is True and outcome.text == "" + + +def test_complete_is_toolless_and_scratch(tmp_path): + transport = _transport(tmp_path) + + reply = transport.complete( + system="CLASSIFY", messages=[{"role": "user", "content": "hi"}]) + + assert reply.text == "REVIEW" + assert reply.usage["cache_read_input_tokens"] == 5 + capture = json.loads( + (tmp_path / "bin" / "capture.json").read_text(encoding="utf-8")) + assert "imc-claude-oneshot-" in capture["cwd"] + argv = capture["argv"] + assert "--mcp-config" not in argv # no tools at all on one-shots + assert argv[argv.index("--system-prompt") + 1] == "CLASSIFY" + assert "[USER]\nhi" in capture["stdin"] diff --git a/test/test_provider_codex.py b/test/test_provider_codex.py new file mode 100644 index 00000000..565b43fc --- /dev/null +++ b/test/test_provider_codex.py @@ -0,0 +1,134 @@ +"""Codex transport against a fake codex CLI — fully offline (the dev +machine has no ChatGPT login, so the JSONL contract is recorded here from +codex-cli 0.145.0 event shapes and the auth-gap path is what a live +readiness check exercises).""" + +import json +import stat +from pathlib import Path + +from infermatrix_copilot.config import Settings +from infermatrix_copilot.providers.base import AgentSessionRequest +from infermatrix_copilot.providers.codex import CodexTransport +from infermatrix_copilot.scopes import read_only_scope +from infermatrix_copilot.tool_bridge import write_bridge_spec + +_FAKE_CLI = """#!/usr/bin/env python3 +import json, os, sys, time +here = os.path.dirname(os.path.abspath(__file__)) +if sys.argv[1:3] == ["login", "status"]: + if os.path.exists(os.path.join(here, "logged-in")): + print("Logged in using ChatGPT"); sys.exit(0) + print("Not logged in"); sys.exit(1) +text = sys.stdin.read() +with open(os.path.join(here, "capture.json"), "w") as f: + json.dump({"argv": sys.argv[1:], "stdin": text, "cwd": os.getcwd(), + "env_key": os.environ.get("OPENAI_API_KEY", "")}, f) +if os.path.exists(os.path.join(here, "sleep")): + time.sleep(10) +print(json.dumps({"type": "thread.started", "thread_id": "t1"})) +print(json.dumps({"type": "item.completed", "item": { + "item_type": "command_execution", "command": "ls"}})) +print(json.dumps({"type": "item.completed", "item": { + "item_type": "reasoning", "text": "thinking..."}})) +print(json.dumps({"type": "item.completed", "item": { + "item_type": "agent_message", "text": "REVIEW"}})) +print(json.dumps({"type": "turn.completed", "usage": { + "input_tokens": 50, "cached_input_tokens": 10, "output_tokens": 9}})) +""" + + +class FakeTrace: + def __init__(self): + self.events = [] + + def record(self, kind, **fields): + self.events.append({"kind": kind, **fields}) + + +def _transport(tmp_path: Path) -> CodexTransport: + cli = tmp_path / "bin" / "codex" + cli.parent.mkdir(exist_ok=True) + cli.write_text(_FAKE_CLI, encoding="utf-8") + cli.chmod(cli.stat().st_mode | stat.S_IXUSR) + return CodexTransport(Settings( + _env_file=None, strict_backend="codex", + strict_backend_cli=str(cli))) + + +def _request(tmp_path: Path, with_bridge: bool = True) -> AgentSessionRequest: + worktree = tmp_path / "worktree" + worktree.mkdir() + run_dir = tmp_path / "run" + run_dir.mkdir() + scope = read_only_scope() + scope = type(scope)(name=scope.name, allowed_tools=scope.allowed_tools, + read_only=True, root=str(worktree)) + bridge = write_bridge_spec(run_dir=run_dir, step_name="agent.review_diff", + scope=scope, repo="vllm-omni") \ + if with_bridge else None + return AgentSessionRequest( + system="SYS", prompt="PROMPT", scope=scope, model="", + max_iters=8, timeout_s=30.0, run_dir=run_dir, + step_name="agent.review_diff", bridge_spec_path=bridge, + trace=FakeTrace()) + + +def test_run_session_sandbox_mcp_and_parse(tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "should-not-leak") + transport = _transport(tmp_path) + req = _request(tmp_path) + + outcome = transport.run_session(req) + + assert outcome.text == "REVIEW" + assert (outcome.input_tokens, outcome.output_tokens) == (50, 9) + assert outcome.tools_used == ["command_execution"] # reasoning excluded + assert outcome.tool_calls == 1 and outcome.truncated is False + + capture = json.loads( + (tmp_path / "bin" / "capture.json").read_text(encoding="utf-8")) + argv = capture["argv"] + assert argv[0] == "exec" and "--json" in argv + assert argv[argv.index("-s") + 1] == "read-only" + assert "--skip-git-repo-check" in argv and argv[-1] == "-" + # bridge wired purely via -c config overrides — nothing in the worktree + overrides = [argv[i + 1] for i, a in enumerate(argv) if a == "-c"] + assert any("mcp_servers.infermatrix-tools.command=" in o + for o in overrides) + assert any("tool_bridge" in o for o in overrides) + assert capture["stdin"] == "SYS\n\nPROMPT" + assert capture["env_key"] == "" # sanitized env + + +def test_auth_gap_reports_login_fix(tmp_path): + transport = _transport(tmp_path) + gap = transport.auth_gap() + assert gap and "codex login" in gap + + (tmp_path / "bin" / "logged-in").write_text("", encoding="utf-8") + assert transport.auth_gap() is None + + +def test_run_session_timeout_is_truncated(tmp_path): + transport = _transport(tmp_path) + req = _request(tmp_path, with_bridge=False) + req.timeout_s = 0.8 + (tmp_path / "bin" / "sleep").write_text("", encoding="utf-8") + + outcome = transport.run_session(req) + + assert outcome.truncated is True and outcome.text == "" + + +def test_complete_runs_in_scratch(tmp_path): + transport = _transport(tmp_path) + + reply = transport.complete( + system="CLASSIFY", messages=[{"role": "user", "content": "hi"}]) + + assert reply.text == "REVIEW" + capture = json.loads( + (tmp_path / "bin" / "capture.json").read_text(encoding="utf-8")) + assert "imc-codex-oneshot-" in capture["cwd"] + assert "CLASSIFY" in capture["stdin"] and "[USER]\nhi" in capture["stdin"] diff --git a/test/test_providers.py b/test/test_providers.py index 1167543f..7454621c 100644 --- a/test/test_providers.py +++ b/test/test_providers.py @@ -52,10 +52,18 @@ def test_unknown_backend_rejected_at_startup(): _settings(strict_backend="not-a-backend") -def test_unshipped_backends_name_their_milestone(): - with pytest.raises(NotImplementedError, match="M2"): - transport_for(_settings(strict_backend="claude-code")) - with pytest.raises(NotImplementedError, match="M3"): +def test_every_harness_id_resolves_a_transport(): + for backend in ("cursor", "claude-code", "codex"): + transport = transport_for(_settings(strict_backend=backend)) + assert transport.spec.id == backend + assert transport.spec.kind == "harness" + + +def test_unshipped_mechanism_names_the_milestone(monkeypatch): + from infermatrix_copilot.providers import registry + + monkeypatch.setitem(registry._UNSHIPPED, "codex", "M9") + with pytest.raises(NotImplementedError, match="M9"): transport_for(_settings(strict_backend="codex"))