Files
2026-07-01 17:56:31 +08:00

512 lines
17 KiB
Python

"""Preflight checks for OpenOPC external-agent integration."""
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from opc.core.config import OPCConfig
from opc.core.models import Task
from opc.layer3_agent.adapters.base import ExternalAgentAdapter
from opc.layer3_agent.adapters.registry import ADAPTER_CLASSES
from opc.layer3_agent.skill_installer import (
install_collab_surface,
opc_collab_executable,
)
_HELP_COMMANDS: dict[str, tuple[str, ...]] = {
"codex": ("exec", "--help"),
"claude_code": ("--help",),
"cursor": ("--help",),
"opencode": ("run", "--help"),
}
_VERSION_COMMANDS: dict[str, tuple[str, ...]] = {
"codex": ("--version",),
"claude_code": ("--version",),
"cursor": ("--version",),
"opencode": ("--version",),
}
@dataclass
class PathProbeResult:
name: str
path: str
ok: bool
error: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"path": self.path,
"ok": self.ok,
"error": self.error,
}
@dataclass
class ExternalAgentPreflightResult:
agent: str
enabled: bool
command: str
available: bool
binary: str = ""
version: str = ""
launch_command: str = ""
stdin_policy: str = ""
collaboration_rpc_transport: str = ""
isolated_home: str = ""
collab_cli: str = ""
issues: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
write_checks: list[PathProbeResult] = field(default_factory=list)
@property
def ok(self) -> bool:
return bool(self.enabled and self.available and not self.issues)
def as_dict(self) -> dict[str, Any]:
return {
"agent": self.agent,
"enabled": self.enabled,
"command": self.command,
"available": self.available,
"binary": self.binary,
"version": self.version,
"launch_command": self.launch_command,
"stdin_policy": self.stdin_policy,
"collaboration_rpc_transport": self.collaboration_rpc_transport,
"isolated_home": self.isolated_home,
"collab_cli": self.collab_cli,
"issues": list(self.issues),
"warnings": list(self.warnings),
"write_checks": [check.as_dict() for check in self.write_checks],
"ok": self.ok,
}
class ExternalAgentPreflightError(RuntimeError):
"""Raised when the workspace permission contract is not writable."""
def __init__(self, checks: list[PathProbeResult]) -> None:
self.checks = checks
failures = [check for check in checks if not check.ok]
detail = "; ".join(f"{item.name}={item.path}: {item.error}" for item in failures)
super().__init__(f"External agent workspace permission preflight failed: {detail}")
def ensure_external_agent_surfaces(
config: OPCConfig,
*,
opc_home: Path | None = None,
) -> dict[str, dict[str, str]]:
"""Provision isolated agent homes and the shared opc-collab shim.
This is intentionally safe to run from ``opc init`` and ``opc status``:
surfaces are idempotent and user config is mirrored into OpenOPC-owned
isolated homes instead of mutating the user's real agent configuration.
"""
base_home = Path(opc_home) if opc_home else _get_opc_home()
surfaces: dict[str, dict[str, str]] = {}
for agent_name, adapter_cls in ADAPTER_CLASSES.items():
agent_config = config.agents.agents.get(agent_name)
adapter = adapter_cls(config=agent_config)
if agent_config and not agent_config.enabled:
continue
slug = adapter.agent_isolation_home_slug()
if not slug:
continue
home, bin_dir = install_collab_surface(slug, opc_home=base_home)
adapter.post_install_agent_home(str(home))
surfaces[agent_name] = {
"home": str(home),
"bin_dir": str(bin_dir),
"collab_cli": str(opc_collab_executable(bin_dir)),
}
return surfaces
def probe_external_agent_write_contract(
*,
workspace_path: str | Path,
opc_home: Path | None = None,
task: Task | None = None,
project_db_path: str | Path | None = None,
) -> list[PathProbeResult]:
"""Check every path external agents must be able to write before launch."""
base_home = Path(opc_home) if opc_home else _get_opc_home()
metadata = dict(getattr(task, "metadata", {}) or {})
probes: list[tuple[str, Path, bool]] = [
("workspace", Path(workspace_path).expanduser(), False),
("opc_home", base_home.expanduser(), False),
("opc_memory", (base_home / "memory").expanduser(), False),
]
for name, key in (
("collab_workspace", "comms_workspace_root"),
("collab_root", "comms_root"),
("output_root", "output_root"),
("target_output_dir", "target_output_dir"),
):
raw = str(metadata.get(key) or "").strip()
if raw:
probes.append((name, Path(raw).expanduser(), False))
if project_db_path:
probes.append(("project_db", Path(project_db_path).expanduser(), True))
results: list[PathProbeResult] = []
seen: set[tuple[str, str]] = set()
for name, path, is_file in probes:
key = (name, str(path))
if key in seen:
continue
seen.add(key)
results.append(_probe_writable_path(name, path, is_file=is_file))
return results
def assert_external_agent_write_contract(
*,
workspace_path: str | Path,
opc_home: Path | None = None,
task: Task | None = None,
project_db_path: str | Path | None = None,
) -> list[PathProbeResult]:
checks = probe_external_agent_write_contract(
workspace_path=workspace_path,
opc_home=opc_home,
task=task,
project_db_path=project_db_path,
)
if any(not check.ok for check in checks):
raise ExternalAgentPreflightError(checks)
return checks
def run_external_agent_preflight(
config: OPCConfig,
*,
project_id: str = "default",
workspace_path: str | Path | None = None,
opc_home: Path | None = None,
probe_commands: bool = True,
prepare_surfaces: bool = True,
) -> list[ExternalAgentPreflightResult]:
base_home = Path(opc_home) if opc_home else _get_opc_home()
if workspace_path is None:
workspace = _get_project_workplace(project_id)
else:
workspace = Path(workspace_path)
workspace.mkdir(parents=True, exist_ok=True)
surfaces = (
ensure_external_agent_surfaces(config, opc_home=base_home)
if prepare_surfaces
else {}
)
sample_task = _sample_preflight_task(project_id, workspace)
project_db = base_home / "projects" / project_id / "tasks.db"
write_checks = probe_external_agent_write_contract(
workspace_path=workspace,
opc_home=base_home,
task=sample_task,
project_db_path=project_db,
)
rpc_transport, rpc_issue = _describe_collaboration_rpc_transport()
results: list[ExternalAgentPreflightResult] = []
for agent_name, adapter_cls in ADAPTER_CLASSES.items():
agent_config = config.agents.agents.get(agent_name)
adapter = adapter_cls(config=agent_config)
command = adapter.configured_command()
enabled = bool(adapter.config.enabled)
binary = adapter.resolve_binary() if enabled else None
result = ExternalAgentPreflightResult(
agent=agent_name,
enabled=enabled,
command=command,
available=bool(binary),
binary=str(binary or ""),
write_checks=list(write_checks),
collaboration_rpc_transport=rpc_transport,
)
surface = surfaces.get(agent_name, {})
result.isolated_home = surface.get("home", "")
result.collab_cli = surface.get("collab_cli", "")
if not enabled:
result.warnings.append("disabled in config")
results.append(result)
continue
cmd: list[str] = []
try:
cmd, metadata = _build_sample_invocation(adapter, sample_task, str(workspace))
result.launch_command = str(
metadata.get("display_command") or metadata.get("command") or shlex.join(cmd)
)
result.stdin_policy = _describe_stdin_policy(adapter, cmd, metadata)
except Exception as exc:
result.warnings.append(f"sample invocation unavailable: {exc}")
if not binary:
result.issues.append(_missing_agent_issue(agent_name, command))
if rpc_issue:
result.issues.append(rpc_issue)
results.append(result)
continue
_add_windows_wrapper_warnings(command, str(binary), result.warnings)
if rpc_issue:
result.issues.append(rpc_issue)
if probe_commands:
result.version = _probe_version(agent_name, str(binary), result.warnings)
if cmd:
_probe_help_flags(agent_name, str(binary), cmd, result)
_probe_isolated_home(agent_name, result)
for check in write_checks:
if not check.ok:
result.issues.append(f"{check.name} is not writable: {check.path} ({check.error})")
results.append(result)
return results
def _describe_collaboration_rpc_transport() -> tuple[str, str]:
from opc.layer4_tools.collaboration_rpc import (
OPC_COLLAB_RPC_TRANSPORT,
default_collaboration_rpc_transport,
resolve_collaboration_rpc_transport,
)
requested = str(os.environ.get(OPC_COLLAB_RPC_TRANSPORT, "")).strip().lower()
try:
resolved = resolve_collaboration_rpc_transport(requested or "auto")
except Exception as exc:
return (f"{requested or 'auto'}(unavailable)", str(exc))
if resolved == "tcp":
return ("tcp(loopback)", "")
if requested and requested != resolved:
return (f"{resolved}({requested})", "")
return (resolved, "")
def _describe_stdin_policy(
adapter: ExternalAgentAdapter,
cmd: list[str],
metadata: dict[str, Any],
) -> str:
try:
return adapter.stdin_policy_for_process(cmd, metadata)
except Exception:
explicit_policy = str(metadata.get("stdin_policy") or "").strip()
return explicit_policy or "devnull"
def _missing_agent_issue(agent_name: str, command: str) -> str:
if agent_name == "cursor":
editor = shutil.which("cursor")
agent = shutil.which("cursor-agent")
if editor and not agent:
return "Cursor editor found, cursor-agent missing; install Cursor Agent CLI for headless execution"
return f"command not found on PATH: {command}"
def _add_windows_wrapper_warnings(command: str, binary: str, warnings: list[str]) -> None:
if os.name != "nt":
return
path = Path(binary)
if path.suffix.lower() == ".ps1":
warnings.append(
"PowerShell execution policy may block this .ps1 wrapper; use the matching .cmd command manually"
)
return
if path.suffix.lower() == ".cmd":
ps1 = path.with_suffix(".ps1")
if ps1.exists() and Path(command).suffix == "":
warnings.append(
f"PowerShell may prefer {ps1.name}; use `{path.name}` manually if script execution is blocked"
)
def _sample_preflight_task(project_id: str, workspace: Path) -> Task:
metadata = {
"workspace_root": str(workspace),
"comms_workspace_root": str(workspace),
"comms_root": str(workspace / ".opc-comms"),
"target_output_dir": str(workspace),
}
return Task(
title="OpenOPC external agent preflight",
description="Reply with OK. This task is only used to build a launch command.",
assigned_to="owner",
project_id=project_id,
metadata=metadata,
)
def _get_opc_home() -> Path:
from opc.core.config import get_opc_home
return get_opc_home()
def _get_project_workplace(project_id: str) -> Path:
from opc.core.config import get_project_workplace
return get_project_workplace(project_id)
def _build_sample_invocation(
adapter: ExternalAgentAdapter,
task: Task,
workspace: str,
) -> tuple[list[str], dict[str, Any]]:
if adapter.config.run_mode == "interactive" and adapter.supports_interactive():
return adapter.build_interactive_invocation(task, workspace_path=workspace)
return adapter.build_invocation(task, workspace_path=workspace)
def _probe_writable_path(name: str, path: Path, *, is_file: bool) -> PathProbeResult:
try:
if is_file:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
with path.open("ab"):
pass
else:
_write_delete_probe(path.parent)
else:
path.mkdir(parents=True, exist_ok=True)
_write_delete_probe(path)
resolved = path.resolve(strict=False)
return PathProbeResult(name=name, path=str(resolved), ok=True)
except Exception as exc:
return PathProbeResult(name=name, path=str(path), ok=False, error=str(exc))
def _write_delete_probe(directory: Path) -> None:
probe_path = directory / f".opc-write-probe-{uuid.uuid4().hex}.tmp"
probe_path.write_text("ok", encoding="utf-8")
probe_path.unlink()
def _probe_version(agent_name: str, binary: str, warnings: list[str]) -> str:
args = _VERSION_COMMANDS.get(agent_name)
if not args:
return ""
try:
proc = subprocess.run(
[binary, *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=2,
check=False,
)
except Exception as exc:
warnings.append(f"version probe failed: {exc}")
return ""
text = (proc.stdout or proc.stderr or "").strip().splitlines()
if not text and proc.returncode != 0:
warnings.append(f"version probe exited {proc.returncode}")
return text[0][:200] if text else ""
def _probe_help_flags(
agent_name: str,
binary: str,
launch_cmd: list[str],
result: ExternalAgentPreflightResult,
) -> None:
args = _HELP_COMMANDS.get(agent_name, ("--help",))
try:
proc = subprocess.run(
[binary, *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3,
check=False,
)
except Exception as exc:
result.warnings.append(f"help probe failed: {exc}")
return
help_text = f"{proc.stdout}\n{proc.stderr}"
if proc.returncode not in {0, 1, 2} and not help_text.strip():
result.warnings.append(f"help probe exited {proc.returncode}")
return
for flag in _launch_flags(launch_cmd):
if flag in {"--", "-"}:
continue
if flag not in help_text:
result.warnings.append(f"launch flag not advertised by help output: {flag}")
def _launch_flags(cmd: list[str]) -> list[str]:
flags: list[str] = []
for item in cmd[1:]:
value = str(item or "").strip()
if not value.startswith("-"):
continue
flag = value.split("=", 1)[0]
flags.append(flag)
return list(dict.fromkeys(flags))
def _probe_isolated_home(agent_name: str, result: ExternalAgentPreflightResult) -> None:
if not result.isolated_home:
return
home = Path(result.isolated_home)
if not home.exists():
result.issues.append(f"isolated agent home was not created: {home}")
skill = home / "skills" / "opc-collab" / "SKILL.md"
if not skill.exists():
result.issues.append(f"opc-collab skill missing from isolated home: {skill}")
if result.collab_cli and not Path(result.collab_cli).exists():
result.issues.append(f"opc-collab executable missing: {result.collab_cli}")
for source, target in _known_user_config_mirrors(agent_name, home):
if source.exists() and not target.exists():
result.warnings.append(f"user config exists but was not mirrored: {source}")
def _known_user_config_mirrors(agent_name: str, home: Path) -> list[tuple[Path, Path]]:
if agent_name == "codex":
user_home = Path.home() / ".codex"
return [
(user_home / "auth.json", home / "auth.json"),
(user_home / "config.toml", home / "config.toml"),
]
if agent_name == "claude_code":
user_home = Path.home() / ".claude"
return [
(user_home / ".credentials.json", home / ".credentials.json"),
(user_home / "settings.json", home / "settings.json"),
(user_home / "settings.local.json", home / "settings.local.json"),
(user_home / "CLAUDE.md", home / "CLAUDE.md"),
]
if agent_name == "opencode":
candidates: list[tuple[Path, Path]] = []
raw_env = str(os.environ.get("OPENCODE_CONFIG_DIR") or "").strip()
if raw_env:
source_home = Path(raw_env).expanduser()
else:
xdg = str(os.environ.get("XDG_CONFIG_HOME") or "").strip()
source_home = Path(xdg).expanduser() / "opencode" if xdg else Path.home() / ".config" / "opencode"
for name in ("opencode.json", "opencode.jsonc"):
candidates.append((source_home / name, home / name))
return candidates
return []