From 80fae0dda6d65b58533d484d960248c939a5b45c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 01:46:45 +0000 Subject: [PATCH] Hand coding tasks to a local ACP runner. Jonathan can delegate a task to OpenCode, Claude Code, Codex, or Zed in a project folder, stream progress, and list the files that changed. Runner commands and folders go through the approval gate and shell guard. Co-authored-by: Admin --- NOTICE | 16 ++ src/acp/__init__.py | 18 ++ src/acp/protocol.py | 256 +++++++++++++++++++++++++ src/acp/runners.py | 184 ++++++++++++++++++ src/acp/service.py | 171 +++++++++++++++++ src/cli.py | 46 +++++ src/command_system/__init__.py | 2 + src/command_system/builtins.py | 20 ++ src/desktop/runtime.py | 116 +++++++++--- src/desktop/server.py | 8 + src/desktop/web/app.js | 38 ++++ src/desktop/web/index.html | 41 ++++ src/desktop/web/styles.css | 2 + src/security/shell_guard_rules.py | 2 +- src/tool_system/context.py | 6 +- src/tool_system/defaults.py | 2 + src/tool_system/permissions.py | 1 + src/tool_system/tools/__init__.py | 2 + src/tool_system/tools/acp.py | 145 ++++++++++++++ tests/fake_acp_runner.py | 94 +++++++++ tests/test_acp.py | 303 ++++++++++++++++++++++++++++++ 21 files changed, 1443 insertions(+), 30 deletions(-) create mode 100644 src/acp/__init__.py create mode 100644 src/acp/protocol.py create mode 100644 src/acp/runners.py create mode 100644 src/acp/service.py create mode 100644 src/tool_system/tools/acp.py create mode 100644 tests/fake_acp_runner.py create mode 100644 tests/test_acp.py diff --git a/NOTICE b/NOTICE index a528e87..c186441 100644 --- a/NOTICE +++ b/NOTICE @@ -102,3 +102,19 @@ Octop knowledge base https://github.com/TencentCloud/Octop Copyright (c) 2026 Octop Licensed under the MIT License. + +Jonathan Ai can hand a coding task to a local ACP runner. The client follows +Octop's outbound ACP runner: newline JSON-RPC on the runner's stdio, a +session in a chosen project folder, streamed session updates, and a list of +files that changed. Built-in runners are OpenCode, Claude Code, Codex, and +Zed. Each stays disabled until the user enables it and sets a local program +path. Jonathan Ai does not download a runner and does not use npx. The +runner command and the project folder go through the approval gate, and the +shell guard still holds a dangerous command. A permission request from the +runner is cancelled until the user approves it here. The protocol itself +stays on this machine. + +Octop ACP runner +https://github.com/TencentCloud/Octop +Copyright (c) 2026 Octop +Licensed under the MIT License. diff --git a/src/acp/__init__.py b/src/acp/__init__.py new file mode 100644 index 0000000..d2f5512 --- /dev/null +++ b/src/acp/__init__.py @@ -0,0 +1,18 @@ +"""Hand a coding task to a local ACP runner. + +Jonathan Ai speaks the Agent Client Protocol over stdin and stdout. The +runner program stays on this machine. OpenCode, Claude Code, Codex, and Zed +are the built-in runners; each one is off until the user enables it and +sets its path. + +Adapted from Octop's outbound ACP runner. Jonathan Ai does not vendor the +harness package or expose Jonathan as an ACP server. + +Source: https://github.com/TencentCloud/Octop +Copyright (c) 2026 Octop +SPDX-License-Identifier: MIT +""" + +from src.acp.service import format_acp_command + +__all__ = ["format_acp_command"] diff --git a/src/acp/protocol.py b/src/acp/protocol.py new file mode 100644 index 0000000..7154fd7 --- /dev/null +++ b/src/acp/protocol.py @@ -0,0 +1,256 @@ +"""Minimal ACP client over newline-delimited JSON-RPC on stdio. + +The child process is the local runner. Jonathan Ai does not open a network +connection for this protocol. +""" + +from __future__ import annotations + +import json +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +Progress = Callable[[str], None] + + +class AcpError(RuntimeError): + """The local runner failed or stopped speaking ACP.""" + + +@dataclass +class AcpTurn: + text: str = "" + paths: list[str] = field(default_factory=list) + + +class AcpProcess: + """One runner process for one coding task.""" + + def __init__( + self, + argv: list[str], + cwd: Path, + *, + on_progress: Progress | None = None, + timeout_s: float = 120, + ) -> None: + if not argv or not argv[0].strip(): + raise AcpError("runner command is empty") + self.argv = argv + self.cwd = cwd + self.timeout_s = timeout_s + self._on_progress = on_progress + self._proc: subprocess.Popen[str] | None = None + self._write_lock = threading.Lock() + self._cv = threading.Condition() + self._pending: dict[int, dict | None] = {} + self._next_id = 0 + self._reader: threading.Thread | None = None + self._closed = False + self.turn = AcpTurn() + + def run(self, task: str) -> AcpTurn: + self._proc = subprocess.Popen( + self.argv, + cwd=str(self.cwd), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + self._reader = threading.Thread(target=self._read_stdout, name="jonathan-acp", daemon=True) + self._reader.start() + try: + self._request( + "initialize", + { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": {"name": "jonathan-ai", "version": "0.2.8"}, + }, + ) + created = self._request("session/new", {"cwd": str(self.cwd), "mcpServers": []}) + session_id = str(created.get("sessionId") or created.get("session_id") or "") + if not session_id: + raise AcpError("runner did not open a session") + self._request( + "session/prompt", + { + "sessionId": session_id, + "prompt": [{"type": "text", "text": task}], + }, + ) + return self.turn + finally: + self.close() + + def close(self) -> None: + proc = self._proc + if proc is None or self._closed: + return + self._closed = True + if proc.poll() is None: + try: + if proc.stdin is not None: + proc.stdin.close() + except OSError: + pass + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + def _request(self, method: str, params: dict) -> dict: + with self._cv: + self._next_id += 1 + msg_id = self._next_id + self._pending[msg_id] = None + self._write({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params}) + deadline = time.monotonic() + self.timeout_s + with self._cv: + while self._pending.get(msg_id) is None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AcpError(f"runner timed out during {method}") + self._cv.wait(timeout=remaining) + if self._proc is not None and self._proc.poll() is not None and self._pending.get(msg_id) is None: + raise AcpError(self._failure_text(method)) + message = self._pending.pop(msg_id) or {} + if "error" in message: + error = message.get("error") + detail = error.get("message") if isinstance(error, dict) else str(error) + raise AcpError(detail or f"{method} failed") + result = message.get("result") + return result if isinstance(result, dict) else {} + + def _write(self, payload: dict) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise AcpError("runner is not running") + line = json.dumps(payload, separators=(",", ":")) + "\n" + with self._write_lock: + proc.stdin.write(line) + proc.stdin.flush() + + def _read_stdout(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + for raw in proc.stdout: + line = raw.strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + self._progress(line) + continue + if not isinstance(message, dict): + continue + method = message.get("method") + msg_id = message.get("id") + if isinstance(method, str) and msg_id is not None and "result" not in message and "error" not in message: + self._answer_request(method, msg_id, message.get("params")) + continue + if msg_id is not None and method is None: + with self._cv: + if msg_id in self._pending: + self._pending[msg_id] = message + self._cv.notify_all() + continue + if method == "session/update": + self._on_update(message.get("params")) + finally: + with self._cv: + self._cv.notify_all() + + def _answer_request(self, method: str, msg_id: object, params: object) -> None: + del params + if method == "session/request_permission": + result: dict = {"outcome": {"outcome": "cancelled"}} + self._progress("Runner asked to act. Cancelled until you approve it here.") + else: + result = {} + self._progress(f"Ignored runner request {method}.") + try: + self._write({"jsonrpc": "2.0", "id": msg_id, "result": result}) + except AcpError: + return + + def _on_update(self, params: object) -> None: + if not isinstance(params, dict): + return + update = params.get("update") + if not isinstance(update, dict): + return + kind = str(update.get("sessionUpdate") or update.get("session_update") or "") + if kind in {"agent_message_chunk", "agent_thought_chunk"}: + text = _text_of(update.get("content")) + if text: + self.turn.text += text + self._progress(text.strip()) + return + if kind in {"tool_call", "tool_call_update"}: + title = str(update.get("title") or update.get("kind") or "tool") + self._progress(title) + for path in _paths_of(update): + if path not in self.turn.paths: + self.turn.paths.append(path) + + def _progress(self, text: str) -> None: + if self._on_progress is None: + return + clean = text.strip() + if clean: + self._on_progress(clean) + + def _failure_text(self, method: str) -> str: + proc = self._proc + detail = "" + if proc is not None and proc.stderr is not None: + try: + detail = proc.stderr.read().strip() + except Exception: + detail = "" + if detail: + return detail.splitlines()[-1][:400] + code = proc.returncode if proc is not None else "?" + return f"runner exited ({code}) during {method}" + + +def _text_of(content: object) -> str: + if isinstance(content, dict): + if content.get("type") == "text": + return str(content.get("text") or "") + return str(content.get("text") or "") + if isinstance(content, list): + return "".join(_text_of(item) for item in content) + if isinstance(content, str): + return content + return "" + + +def _paths_of(update: dict) -> list[str]: + found: list[str] = [] + for key in ("locations", "content"): + value = update.get(key) + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + path = item.get("path") + if isinstance(path, str) and path.strip(): + found.append(path.strip()) + elif isinstance(value, dict): + path = value.get("path") + if isinstance(path, str) and path.strip(): + found.append(path.strip()) + return found diff --git a/src/acp/runners.py b/src/acp/runners.py new file mode 100644 index 0000000..222a939 --- /dev/null +++ b/src/acp/runners.py @@ -0,0 +1,184 @@ +"""Per-user ACP runner settings. Runners are disabled until enabled here.""" + +from __future__ import annotations + +import json +import os +import shlex +from dataclasses import dataclass +from pathlib import Path + +from src.agent.memory import current_user_id + +RUNNER_IDS = ("opencode", "claude_code", "codex", "zed") + +RUNNER_LABELS = { + "opencode": "OpenCode", + "claude_code": "Claude Code", + "codex": "Codex", + "zed": "Zed", +} + +# Local program names. The settings panel replaces these with a real path. +# Nothing is downloaded, and npx is not the default. +_DEFAULTS: dict[str, tuple[str, tuple[str, ...]]] = { + "opencode": ("opencode", ("acp",)), + "claude_code": ("claude-agent-acp", ()), + "codex": ("codex-acp", ()), + "zed": ("zed", ("acp",)), +} + + +@dataclass +class RunnerConfig: + enabled: bool = False + command: str = "" + args: tuple[str, ...] = () + + def command_line(self) -> str: + return shlex.join([self.command, *self.args]) if self.command else "" + + +def default_runners() -> dict[str, RunnerConfig]: + return { + name: RunnerConfig(enabled=False, command=command, args=args) + for name, (command, args) in _DEFAULTS.items() + } + + +def acp_root(directory: Path | None = None) -> Path: + if directory is not None: + path = directory + else: + raw = os.environ.get("JONATHAN_ACP_DIR", "").strip() + path = Path(raw) if raw else Path.home() / ".clawd" / "acp" + path.mkdir(parents=True, exist_ok=True) + try: + path.chmod(0o700) + except OSError: + pass + return path + + +def settings_path(directory: Path | None = None, *, user_id: str | None = None) -> Path: + user = user_id or current_user_id() + return acp_root(directory) / f"settings-{user}.json" + + +def load_runners(directory: Path | None = None, *, user_id: str | None = None) -> dict[str, RunnerConfig]: + runners = default_runners() + path = settings_path(directory, user_id=user_id) + if not path.is_file(): + return runners + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return runners + stored = raw.get("runners") if isinstance(raw, dict) else None + if not isinstance(stored, dict): + return runners + for name, item in stored.items(): + if name not in runners or not isinstance(item, dict): + continue + command = str(item.get("command") or "").strip() or runners[name].command + args_raw = item.get("args") + if isinstance(args_raw, list): + args = tuple(str(part) for part in args_raw) + else: + args = runners[name].args + runners[name] = RunnerConfig( + enabled=bool(item.get("enabled")), + command=command, + args=args, + ) + return runners + + +def save_runners( + runners: dict[str, RunnerConfig], + directory: Path | None = None, + *, + user_id: str | None = None, +) -> None: + path = settings_path(directory, user_id=user_id) + payload = { + "runners": { + name: { + "enabled": runners[name].enabled, + "command": runners[name].command, + "args": list(runners[name].args), + } + for name in RUNNER_IDS + if name in runners + } + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + return + + +def public_runners(directory: Path | None = None, *, user_id: str | None = None) -> dict[str, object]: + runners = load_runners(directory, user_id=user_id) + return { + "runners": [ + { + "id": name, + "label": RUNNER_LABELS[name], + "enabled": runners[name].enabled, + "command": runners[name].command, + "args": list(runners[name].args), + } + for name in RUNNER_IDS + ] + } + + +def update_runners( + changes: object, + directory: Path | None = None, + *, + user_id: str | None = None, +) -> dict[str, object]: + """Apply a settings-panel update. Unknown runner ids are refused.""" + rows = changes + if isinstance(changes, dict): + rows = changes.get("runners", changes) + if isinstance(rows, dict): + rows = [{"id": key, **value} if isinstance(value, dict) else {"id": key} for key, value in rows.items()] + if not isinstance(rows, list): + raise ValueError("runners must be a list") + runners = load_runners(directory, user_id=user_id) + for item in rows: + if not isinstance(item, dict): + raise ValueError("each runner must be an object") + name = str(item.get("id") or "").strip() + if name not in runners: + raise ValueError(f"unknown ACP runner: {name}") + current = runners[name] + default = default_runners()[name] + enabled = bool(item["enabled"]) if "enabled" in item else current.enabled + if "command" in item: + command_text = str(item.get("command") or "").strip() + else: + command_text = current.command + if not command_text or any(char in command_text for char in "\n\r\x00"): + command_text = default.command + args = default.args + elif "args" in item: + args = _parse_args(item.get("args")) + else: + args = current.args + runners[name] = RunnerConfig(enabled=enabled, command=command_text, args=args) + save_runners(runners, directory, user_id=user_id) + return public_runners(directory, user_id=user_id) + + +def _parse_args(value: object) -> tuple[str, ...]: + if isinstance(value, list): + return tuple(str(part) for part in value if str(part).strip()) + text = str(value or "").strip() + if not text: + return () + return tuple(shlex.split(text)) diff --git a/src/acp/service.py b/src/acp/service.py new file mode 100644 index 0000000..f3c3bb6 --- /dev/null +++ b/src/acp/service.py @@ -0,0 +1,171 @@ +"""Run one coding task in a chosen project folder and report changed files.""" + +from __future__ import annotations + +import os +import shlex +from pathlib import Path +from typing import Callable + +from src.acp.protocol import AcpError, AcpProcess +from src.acp.runners import RUNNER_IDS, RUNNER_LABELS, RunnerConfig, load_runners +from src.secret_mask import mask_secrets +from src.security.shell_guard import enforce_shell_command +from src.tool_system.errors import ToolPermissionError + +_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv"} +_USAGE = ( + "Usage: /acp list | /acp run \n" + "Runners: opencode, claude_code, codex, zed. Enable one in settings first.\n" + "The project folder and the runner command ask for approval." +) + + +def snapshot_files(root: Path, *, limit: int = 8000) -> dict[str, tuple[int, int]]: + """Map relative paths to size and mtime. Skips dependency and VCS trees.""" + found: dict[str, tuple[int, int]] = {} + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [name for name in dirnames if name not in _SKIP_DIRS] + for name in filenames: + path = Path(dirpath) / name + try: + stat = path.stat() + rel = path.relative_to(root).as_posix() + except OSError: + continue + found[rel] = (int(stat.st_size), int(stat.st_mtime_ns)) + if len(found) >= limit: + return found + return found + + +def changed_files(before: dict[str, tuple[int, int]], after: dict[str, tuple[int, int]]) -> list[str]: + names = [name for name, sig in after.items() if before.get(name) != sig] + names.extend(name for name in before if name not in after) + return sorted(set(names)) + + +def resolve_folder(folder: str) -> Path: + text = folder.strip() + if not text: + raise ValueError("project folder is required") + path = Path(text).expanduser().resolve() + if path == Path(path.anchor): + raise ValueError("project folder cannot be the filesystem root") + if not path.is_dir(): + raise ValueError(f"project folder was not found: {path}") + return path + + +def runner_argv(runner: RunnerConfig) -> list[str]: + if not runner.command.strip(): + raise ValueError("runner command is empty") + return [runner.command, *runner.args] + + +def format_result(label: str, text: str, files: list[str]) -> str: + lines = [label] + body = text.strip() + if body: + lines.append(mask_secrets(body)) + lines.append("") + if files: + lines.append("Changed files:") + lines.extend(f"- {name}" for name in files) + else: + lines.append("Changed files: none") + return "\n".join(lines).strip() + + +def delegate_task( + *, + runner_id: str, + folder: str, + task: str, + directory: Path | None = None, + user_id: str | None = None, + on_progress: Callable[[str], None] | None = None, + timeout_s: float = 120, + context: object | None = None, +) -> str: + """Spawn the enabled local runner and return progress plus changed files.""" + name = runner_id.strip() + if name not in RUNNER_IDS: + raise ValueError(f"unknown ACP runner: {name}") + label = RUNNER_LABELS[name] + runner = load_runners(directory, user_id=user_id)[name] + if not runner.enabled: + raise ValueError(f"{label} is not enabled. Turn it on in settings and set its path.") + project = resolve_folder(folder) + prompt = task.strip() + if not prompt: + raise ValueError("task is required") + command_line = shlex.join(runner_argv(runner)) + try: + enforce_shell_command("Acp", command_line, context if context is not None else object()) + except ToolPermissionError as exc: + raise RuntimeError(str(exc)) from exc + before = snapshot_files(project) + + def progress(line: str) -> None: + if on_progress is not None: + on_progress(mask_secrets(line)) + + progress(f"{label} started in {project}") + try: + turn = AcpProcess( + runner_argv(runner), + project, + on_progress=progress, + timeout_s=timeout_s, + ).run(prompt) + except (AcpError, OSError) as exc: + raise RuntimeError(str(exc)) from exc + after = snapshot_files(project) + files = changed_files(before, after) + for reported in turn.paths: + rel = reported.replace("\\", "/").lstrip("./") + if rel and rel not in files: + files.append(rel) + files = sorted(set(files)) + progress(f"{label} finished") + return format_result(label, turn.text, files) + + +def format_acp_command( + args: str, + *, + directory: Path | None = None, + user_id: str | None = None, + on_progress: Callable[[str], None] | None = None, +) -> str: + """Run `/acp list` or `/acp run` and return text.""" + try: + parts = shlex.split(args.strip()) + except ValueError as exc: + return str(exc) + if not parts or parts[0] == "list": + return _format_list(directory, user_id=user_id) + if parts[0] != "run" or len(parts) < 4: + return _USAGE + try: + return delegate_task( + runner_id=parts[1], + folder=parts[2], + task=" ".join(parts[3:]), + directory=directory, + user_id=user_id, + on_progress=on_progress, + ) + except (ValueError, RuntimeError, OSError) as exc: + return str(exc) + + +def _format_list(directory: Path | None, *, user_id: str | None) -> str: + runners = load_runners(directory, user_id=user_id) + lines = ["Local ACP runners:"] + for name in RUNNER_IDS: + runner = runners[name] + state = "enabled" if runner.enabled else "disabled" + lines.append(f"{name} {RUNNER_LABELS[name]} {state} {runner.command_line()}") + return "\n".join(lines) diff --git a/src/cli.py b/src/cli.py index 9c81d2d..99fd884 100644 --- a/src/cli.py +++ b/src/cli.py @@ -37,6 +37,8 @@ def main(): clawd voice settings Show local speech settings clawd kb add Index a local PDF, DOCX, MD, TXT, or HTML file clawd kb search Search local documents and cite the passage + clawd acp list List local ACP coding agents + clawd acp run clawd Start interactive REPL """ ) @@ -122,6 +124,14 @@ def main(): kb_search = kb_sub.add_parser('search', help='Search indexed documents') kb_search.add_argument('query', nargs='+') + acp_parser = subparsers.add_parser('acp', help='List or run a local ACP coding agent') + acp_sub = acp_parser.add_subparsers(dest='acp_command') + acp_sub.add_parser('list', help='List OpenCode, Claude Code, Codex, and Zed') + acp_run = acp_sub.add_parser('run', help='Delegate a coding task in a project folder') + acp_run.add_argument('runner', help='opencode, claude_code, codex, or zed') + acp_run.add_argument('folder', help='Existing project folder') + acp_run.add_argument('task', nargs='+', help='Coding task to delegate') + args = parser.parse_args() # Handle --version @@ -203,6 +213,8 @@ def main(): return handle_voice(args) elif args.command == 'kb': return handle_kb(args) + elif args.command == 'acp': + return handle_acp(args) # Default: start REPL return start_repl(stream=args.stream) @@ -295,6 +307,40 @@ def handle_memory(args) -> int: return 0 +def handle_acp(args) -> int: + """List local ACP runners, or delegate one coding task.""" + from src.acp.service import format_acp_command + from src.tool_system.context import ToolContext + from src.tool_system.permission_handler import PermissionBehavior + from src.tool_system.tools.acp import AcpTool + + action = getattr(args, "acp_command", None) or "list" + if action != "run": + print(format_acp_command("list")) + return 0 + task = " ".join(getattr(args, "task", None) or []) + tool_input = {"runner": args.runner, "folder": args.folder, "task": task} + context = ToolContext(workspace_root=Path(args.folder).expanduser()) + context.gate_destructive_tools = False + + def _print_progress(line: str) -> None: + print(line, flush=True) + + context.progress_handler = _print_progress + decision = AcpTool().check_permissions(tool_input, context) + if decision.behavior != PermissionBehavior.ALLOW: + print(decision.message or "ACP runner was not approved") + return 1 + result = AcpTool().run(decision.updated_input or tool_input, context) + text = "" + if isinstance(result.output, dict): + text = str(result.output.get("text") or result.output.get("error") or "") + else: + text = str(result.output or "") + print(text) + return 1 if result.is_error else 0 + + def handle_cron(args) -> int: """List, run, enable, disable, or delete schedules stored on this machine.""" from src.scheduler.service import format_cron_command diff --git a/src/command_system/__init__.py b/src/command_system/__init__.py index 8d8963b..220297f 100644 --- a/src/command_system/__init__.py +++ b/src/command_system/__init__.py @@ -12,6 +12,7 @@ COST_COMMAND, EXIT_COMMAND, HELP_COMMAND, + ACP_COMMAND, CRON_COMMAND, INIT_COMMAND, KB_COMMAND, @@ -94,6 +95,7 @@ "COST_COMMAND", "CONTEXT_COMMAND", "COMPACT_COMMAND", + "ACP_COMMAND", "CRON_COMMAND", "KB_COMMAND", "MEMORY_COMMAND", diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index 09a3d9a..a7d2db5 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -668,6 +668,22 @@ def kb_command_call(args: str, context: CommandContext) -> LocalCommandResult: return LocalCommandResult(type="text", value=format_kb_command(args)) + +ACP_COMMAND = LocalCommand( + name="acp", + description="Delegate a coding task to a local ACP runner", + argument_hint="list | run ", + supports_non_interactive=True, +) + + +def acp_command_call(args: str, context: CommandContext) -> LocalCommandResult: + """Hand a coding task to OpenCode, Claude Code, Codex, or Zed.""" + del context + from ..acp.service import format_acp_command + + return LocalCommandResult(type="text", value=format_acp_command(args)) + INIT_COMMAND = PromptCommand( name="init", description="Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation", @@ -720,6 +736,8 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t result = voice_command_call(args, context) elif cmd is KB_COMMAND: result = kb_command_call(args, context) + elif cmd is ACP_COMMAND: + result = acp_command_call(args, context) else: return False, None, f"Command not implemented for sync execution: {cmd_name}" @@ -740,6 +758,7 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t CRON_COMMAND.set_call(cron_command_call) VOICE_COMMAND.set_call(voice_command_call) KB_COMMAND.set_call(kb_command_call) +ACP_COMMAND.set_call(acp_command_call) def get_builtin_commands() -> list[Command]: @@ -756,6 +775,7 @@ def get_builtin_commands() -> list[Command]: CRON_COMMAND, VOICE_COMMAND, KB_COMMAND, + ACP_COMMAND, INIT_COMMAND, ] diff --git a/src/desktop/runtime.py b/src/desktop/runtime.py index 3b2484e..7961f6c 100644 --- a/src/desktop/runtime.py +++ b/src/desktop/runtime.py @@ -955,35 +955,44 @@ def on_text_chunk(chunk: str) -> None: if chunk: self._emit(job, {"type": "token", "text": chunk}) + previous_progress = getattr(self.tool_context, "progress_handler", None) + + def on_acp_progress(line: str) -> None: + self._emit(job, {"type": "acp_progress", "text": line}) + + self.tool_context.progress_handler = on_acp_progress try: - result = run_agent_loop( - conversation=self.session.conversation, - provider=self.provider, - tool_registry=self.tool_registry, - tool_context=self.tool_context, - max_turns=20, - stream=self.stream, - verbose=False, - on_event=on_event, - on_text_chunk=on_text_chunk, - ) - except Exception as loop_exc: - from src.tool_system.schema_sanitize import is_input_schema_type_error - - if not is_input_schema_type_error(loop_exc): - raise - result = run_agent_loop( - conversation=self.session.conversation, - provider=self.provider, - tool_registry=self.tool_registry, - tool_context=self.tool_context, - max_turns=20, - stream=self.stream, - verbose=False, - on_event=on_event, - on_text_chunk=on_text_chunk, - omit_tools=True, - ) + try: + result = run_agent_loop( + conversation=self.session.conversation, + provider=self.provider, + tool_registry=self.tool_registry, + tool_context=self.tool_context, + max_turns=20, + stream=self.stream, + verbose=False, + on_event=on_event, + on_text_chunk=on_text_chunk, + ) + except Exception as loop_exc: + from src.tool_system.schema_sanitize import is_input_schema_type_error + + if not is_input_schema_type_error(loop_exc): + raise + result = run_agent_loop( + conversation=self.session.conversation, + provider=self.provider, + tool_registry=self.tool_registry, + tool_context=self.tool_context, + max_turns=20, + stream=self.stream, + verbose=False, + on_event=on_event, + on_text_chunk=on_text_chunk, + omit_tools=True, + ) + finally: + self.tool_context.progress_handler = previous_progress usage = self.session.record_usage(result.usage) if self.session.conversation.messages: self.session.save() @@ -1012,6 +1021,53 @@ def on_text_chunk(chunk: str) -> None: "provider_limit": bool(hint), }) + def _run_acp_slash(self, job: ChatJob, args: str) -> None: + import shlex + + from src.acp.service import _USAGE, format_acp_command + + try: + parts = shlex.split(args.strip()) + except ValueError as exc: + self._finish(job, {"type": "error", "error": str(exc)}) + return + if not parts or parts[0] == "list": + self._finish(job, { + "type": "done", + "text": format_acp_command("list"), + "kind": "command", + }) + return + if parts[0] != "run" or len(parts) < 4: + self._finish(job, {"type": "done", "text": _USAGE, "kind": "command"}) + return + previous_progress = getattr(self.tool_context, "progress_handler", None) + + def on_acp_progress(line: str) -> None: + self._emit(job, {"type": "acp_progress", "text": line}) + + self.tool_context.progress_handler = on_acp_progress + try: + result = self.tool_registry.dispatch( + ToolCall( + name="Acp", + input={ + "runner": parts[1], + "folder": parts[2], + "task": " ".join(parts[3:]), + }, + ), + self.tool_context, + ) + finally: + self.tool_context.progress_handler = previous_progress + output = result.output if isinstance(result.output, dict) else {"text": str(result.output or "")} + text = str(output.get("text") or output.get("error") or "") + if result.is_error: + self._finish(job, {"type": "error", "error": text or "ACP runner failed"}) + return + self._finish(job, {"type": "done", "text": text, "kind": "command"}) + def _handle_slash(self, job: ChatJob, raw: str) -> bool: if raw == "/": self._finish(job, { @@ -1075,6 +1131,9 @@ def _handle_slash(self, job: ChatJob, raw: str) -> bool: text = "\n".join(f"/{s['name']} — {s['description']}" for s in skills) self._finish(job, {"type": "done", "text": text, "kind": "command"}) return True + if cmd == "acp": + self._run_acp_slash(job, args) + return True if cmd == "tool": tool_parts = args.split(maxsplit=1) if not tool_parts: @@ -1175,6 +1234,7 @@ def _format_palette(self) -> str: - Browser tool — local Chrome over CDP. Logins stay in the profile. Submits, purchases, and logins need approval. - `/voice` — local speech and speakers (`/voice settings`, `/voice say hello`) - `/kb` — add, list, remove, or search local documents (`/kb search `) +- `/acp` — list local coding agents, or `/acp run ` Type a message to run the existing agent loop. Destructive and network tools prompt for approval before they run. diff --git a/src/desktop/server.py b/src/desktop/server.py index d79187b..0f1ff37 100644 --- a/src/desktop/server.py +++ b/src/desktop/server.py @@ -251,6 +251,14 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body: from src.knowledge.service import update_knowledge_settings return 200, update_knowledge_settings(body) + if path == "/api/acp" and method == "GET": + from src.acp.runners import public_runners + + return 200, public_runners() + if path == "/api/acp" and method == "POST": + from src.acp.runners import update_runners + + return 200, update_runners(body.get("runners", body)) return 404, {"error": f"unknown route: {method} {path}"} def serve_forever(self) -> None: diff --git a/src/desktop/web/app.js b/src/desktop/web/app.js index 2a3a65e..48258d4 100644 --- a/src/desktop/web/app.js +++ b/src/desktop/web/app.js @@ -14,6 +14,7 @@ const state = { const $ = (id) => document.getElementById(id); const UI_ROUTES = ["chat", "settings", "connectors", "setup", "library"]; +const ACP_RUNNERS = ["opencode", "claude_code", "codex", "zed"]; window.JONATHAN_UI_ROUTES = ["/", "/index.html", ...UI_ROUTES.map((name) => `/#/${name}`)]; function escapeHtml(value) { @@ -603,6 +604,10 @@ function watchJob(jobId) { }); return; } + if (event.type === "acp_progress") { + addBubble("system", event.text || ""); + return; + } if (event.type === "permission_request") { showPermission(event); if (native?.notify) native.notify("Jonathan Ai needs permission", event.message || event.tool_name); @@ -692,6 +697,38 @@ function applyRoute() { if (route === "settings") { loadVoiceSettings().catch(reportError); loadKnowledgeSettings().catch(reportError); + loadAcpSettings().catch(reportError); + } +} + +async function loadAcpSettings() { + const data = await api("/api/acp"); + for (const runner of data.runners || []) { + const enabled = $(`acp-${runner.id}-enabled`); + const command = $(`acp-${runner.id}-command`); + const args = $(`acp-${runner.id}-args`); + if (enabled) enabled.checked = Boolean(runner.enabled); + if (command && document.activeElement !== command) command.value = runner.command || ""; + if (args && document.activeElement !== args) args.value = (runner.args || []).join(" "); + } + const status = $("acp-status"); + if (status && !status.dataset.saved) { + status.textContent = "Local runners. Paths stay on this machine."; + } +} + +async function saveAcpSettings() { + const runners = ACP_RUNNERS.map((id) => ({ + id, + enabled: Boolean($(`acp-${id}-enabled`)?.checked), + command: $(`acp-${id}-command`)?.value || "", + args: $(`acp-${id}-args`)?.value || "", + })); + const data = await api("/api/acp", { method: "POST", body: JSON.stringify({ runners }) }); + const status = $("acp-status"); + if (status) { + status.dataset.saved = "1"; + status.textContent = `Saved ${(data.runners || []).length} coding agents.`; } } @@ -991,6 +1028,7 @@ function bindUi() { $("update-status").textContent = err.message; } }); + $("acp-save")?.addEventListener("click", guard(saveAcpSettings)); $("open-settings").addEventListener("click", () => go("settings")); $("settings-close").addEventListener("click", () => go("chat")); $("kb-save")?.addEventListener("click", guard(async () => { diff --git a/src/desktop/web/index.html b/src/desktop/web/index.html index f14e181..7a1f24f 100644 --- a/src/desktop/web/index.html +++ b/src/desktop/web/index.html @@ -211,6 +211,47 @@

Knowledge base

+

Coding agents

+

Local ACP runners stay off until you enable one and set its program path. The project folder and the command still ask for approval.

+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +

Source and updates: https://github.com/GoDeskio/Clawd-Code. Default local tree: ~/Jonathan/Jonathan-Ai.

diff --git a/src/desktop/web/styles.css b/src/desktop/web/styles.css index 002537d..a40be00 100644 --- a/src/desktop/web/styles.css +++ b/src/desktop/web/styles.css @@ -315,6 +315,8 @@ label.check { display: flex; align-items: center; } .scan-item:hover, .scan-item.active { border-color: var(--accent); } .library-item { display: flex; justify-content: space-between; gap: 8px; align-items: center; } .dialog label { display: block; margin: 8px 0; font-size: 12px; color: var(--muted); } +.dialog input[type="checkbox"] { width: auto; min-height: 0; margin: 0 8px 0 0; } +.acp-runner { margin: 8px 0; } .dialog input, .dialog select { width: 100%; margin-top: 4px; diff --git a/src/security/shell_guard_rules.py b/src/security/shell_guard_rules.py index d5250ff..bdf7811 100644 --- a/src/security/shell_guard_rules.py +++ b/src/security/shell_guard_rules.py @@ -45,7 +45,7 @@ def _rule( ) -> dict[str, Any]: return { "id": rule_id, - "tools": ["bash", "execute", "powershell"], + "tools": ["bash", "execute", "powershell", "acp"], "params": ["command"], "category": category, "severity": severity, diff --git a/src/tool_system/context.py b/src/tool_system/context.py index 4c5a05f..46e39e4 100644 --- a/src/tool_system/context.py +++ b/src/tool_system/context.py @@ -42,6 +42,10 @@ class ToolContext: approved_shell_commands: set[str] = field(default_factory=set) # Local SQLite scheduler directory. Unset uses ~/.clawd/scheduler. scheduler_dir: Path | None = None + # Per-user ACP runner settings. Unset uses ~/.clawd/acp. + acp_dir: Path | None = None + # Called with one progress line while a local ACP runner is working. + progress_handler: Callable[[str], None] | None = None # Permission handler callback: called when a tool needs user consent. # Signature: (tool_name: str, message: str, suggestion: str | None) @@ -89,7 +93,7 @@ def ensure_tool_allowed(self, tool_name: str) -> None: def note_shell_approval(self, tool_name: str, command: str | None) -> None: """Remember one approved shell command. A session tool grant is not enough.""" - if tool_name.lower() not in {"bash", "powershell", "execute"}: + if tool_name.lower() not in {"bash", "powershell", "execute", "acp"}: return if isinstance(command, str) and command: self.approved_shell_commands.add(command) diff --git a/src/tool_system/defaults.py b/src/tool_system/defaults.py index 6b638c9..602cd8e 100644 --- a/src/tool_system/defaults.py +++ b/src/tool_system/defaults.py @@ -5,6 +5,7 @@ from .loader import load_tools_from_dir from .registry import ToolRegistry from .tools import ( + AcpTool, AskUserQuestionTool, BashTool, BriefTool, @@ -109,6 +110,7 @@ def build_default_registry(*, include_user_tools: bool = True) -> ToolRegistry: REPLTool(), TestingPermissionTool(), BrowserTool(), + AcpTool(), ] ) registry.register(AgentTool(registry)) diff --git a/src/tool_system/permissions.py b/src/tool_system/permissions.py index d892df9..d71aa9f 100644 --- a/src/tool_system/permissions.py +++ b/src/tool_system/permissions.py @@ -86,6 +86,7 @@ def ensure_path_allowed(self, path: str | Path) -> Path: "externalagent", "remotetrigger", "browser", + "acp", }) diff --git a/src/tool_system/tools/__init__.py b/src/tool_system/tools/__init__.py index e99973d..b4ccabe 100644 --- a/src/tool_system/tools/__init__.py +++ b/src/tool_system/tools/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .acp import AcpTool from .agent import AgentTool from .ask_user_question import AskUserQuestionTool from .bash import BashTool @@ -40,6 +41,7 @@ from .browser import BrowserTool __all__ = [ + "AcpTool", "AgentTool", "AskUserQuestionTool", "BashTool", diff --git a/src/tool_system/tools/acp.py b/src/tool_system/tools/acp.py new file mode 100644 index 0000000..03bad3a --- /dev/null +++ b/src/tool_system/tools/acp.py @@ -0,0 +1,145 @@ +"""Delegate a coding task to a local ACP runner.""" + +from __future__ import annotations + +import shlex +from typing import Any + +from src.acp.runners import RUNNER_LABELS, load_runners +from src.acp.service import delegate_task, resolve_folder, runner_argv +from src.security.shell_guard import enforce_shell_command, shell_command_permission + +from ..context import ToolContext +from ..errors import ToolInputError, ToolPermissionError +from ..permission_handler import PermissionBehavior, PermissionResult +from ..permissions import maybe_ask_for_gated_tool +from ..protocol import ToolResult +from ..registry import ToolSpec + + +class AcpTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="Acp", + description=( + "Delegate a coding task to a local ACP runner (OpenCode, Claude Code, " + "Codex, or Zed) in a chosen project folder. Streams progress and lists " + "changed files. The runner command and the folder require approval, " + "and the shell guard still applies. Runners are enabled in settings." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "runner": { + "type": "string", + "description": "opencode, claude_code, codex, or zed", + }, + "folder": { + "type": "string", + "description": "Existing project folder for the runner", + }, + "task": {"type": "string", "description": "Coding task to delegate"}, + "command": { + "type": "string", + "description": "Resolved runner command. Filled by the approval check.", + }, + }, + "required": ["runner", "folder", "task"], + }, + is_destructive=True, + max_result_size_chars=50_000, + ) + + def check_permissions(self, tool_input: dict[str, Any], context: ToolContext) -> PermissionResult: + try: + prepared = _prepare(tool_input, context) + except (ToolInputError, ValueError) as exc: + return PermissionResult.deny(str(exc)) + payload = dict(tool_input) + payload["command"] = prepared["command_line"] + guarded = shell_command_permission("Acp", prepared["command_line"], context) + if guarded is not None: + if guarded.behavior == PermissionBehavior.DENY: + return guarded + message = f"Project folder: {prepared['folder']}\n{guarded.message}" + return PermissionResult( + behavior=guarded.behavior, + message=message, + suggestion=guarded.suggestion or "Approve this runner command once", + updated_input=payload, + ) + asked = maybe_ask_for_gated_tool( + context, + "Acp", + ( + f"Delegate to {prepared['label']} in {prepared['folder']}. " + f"Command: {prepared['command_line']}" + ), + "Allow ACP runners for the rest of this session", + ) + if asked.behavior == PermissionBehavior.ASK: + return PermissionResult( + behavior=PermissionBehavior.ASK, + message=asked.message, + suggestion=asked.suggestion, + updated_input=payload, + ) + return PermissionResult.allow(updated_input=payload) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + try: + prepared = _prepare(tool_input, context) + except (ToolInputError, ValueError) as exc: + return ToolResult(name="Acp", output={"error": str(exc)}, is_error=True) + command_line = str(tool_input.get("command") or prepared["command_line"]) + try: + enforce_shell_command("Acp", command_line, context) + except ToolPermissionError as exc: + return ToolResult(name="Acp", output={"error": str(exc)}, is_error=True) + progress = getattr(context, "progress_handler", None) + + def on_progress(line: str) -> None: + if callable(progress): + progress(line) + + try: + text = delegate_task( + runner_id=prepared["runner"], + folder=str(prepared["folder"]), + task=prepared["task"], + directory=getattr(context, "acp_dir", None), + on_progress=on_progress, + context=context, + ) + except (RuntimeError, ValueError, OSError) as exc: + return ToolResult(name="Acp", output={"error": str(exc)}, is_error=True) + return ToolResult(name="Acp", output={"text": text, "folder": str(prepared["folder"])}) + + +def _prepare(tool_input: dict[str, Any], context: ToolContext) -> dict[str, Any]: + runner_id = tool_input.get("runner") + folder = tool_input.get("folder") + task = tool_input.get("task") + if not isinstance(runner_id, str) or not runner_id.strip(): + raise ToolInputError("runner is required") + if not isinstance(folder, str): + raise ToolInputError("folder is required") + if not isinstance(task, str) or not task.strip(): + raise ToolInputError("task is required") + runners = load_runners(getattr(context, "acp_dir", None)) + if runner_id.strip() not in runners: + raise ToolInputError(f"unknown ACP runner: {runner_id.strip()}") + config = runners[runner_id.strip()] + if not config.enabled: + label = RUNNER_LABELS[runner_id.strip()] + raise ValueError(f"{label} is not enabled. Turn it on in settings and set its path.") + project = resolve_folder(folder) + argv = runner_argv(config) + return { + "runner": runner_id.strip(), + "label": RUNNER_LABELS[runner_id.strip()], + "folder": project, + "task": task.strip(), + "command_line": shlex.join(argv), + } diff --git a/tests/fake_acp_runner.py b/tests/fake_acp_runner.py new file mode 100644 index 0000000..75930b8 --- /dev/null +++ b/tests/fake_acp_runner.py @@ -0,0 +1,94 @@ +"""Local stand-in for an ACP coding agent. Speaks newline JSON-RPC on stdio.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def _emit(payload: dict) -> None: + sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def main() -> int: + cwd = Path.cwd() + session_id = "sess-1" + for raw in sys.stdin: + line = raw.strip() + if not line: + continue + message = json.loads(line) + method = message.get("method") + msg_id = message.get("id") + params = message.get("params") if isinstance(message.get("params"), dict) else {} + if method == "initialize": + _emit( + { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "protocolVersion": 1, + "agentInfo": {"name": "fake-acp", "version": "0"}, + }, + } + ) + continue + if method == "session/new": + raw_cwd = params.get("cwd") + if isinstance(raw_cwd, str) and raw_cwd: + cwd = Path(raw_cwd) + _emit({"jsonrpc": "2.0", "id": msg_id, "result": {"sessionId": session_id}}) + continue + if method == "session/prompt": + text = "" + for block in params.get("prompt") or []: + if isinstance(block, dict): + text += str(block.get("text") or "") + (cwd / "delegated.txt").write_text(text + "\n", encoding="utf-8") + _emit( + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "Writing delegated.txt\n" + text}, + }, + }, + } + ) + _emit( + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-1", + "title": "write delegated.txt", + "kind": "edit", + "status": "completed", + "locations": [{"path": "delegated.txt"}], + }, + }, + } + ) + _emit({"jsonrpc": "2.0", "id": msg_id, "result": {"stopReason": "end_turn"}}) + continue + if msg_id is not None: + _emit( + { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": f"unsupported method {method}"}, + } + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_acp.py b/tests/test_acp.py new file mode 100644 index 0000000..23ebfd5 --- /dev/null +++ b/tests/test_acp.py @@ -0,0 +1,303 @@ +"""Local ACP hand-off: approval, shell guard, settings, and a fake runner.""" + +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.acp.runners import load_runners, public_runners, settings_path, update_runners +from src.acp.service import delegate_task, format_acp_command, resolve_folder +from src.command_system.builtins import execute_command_sync +from src.command_system.engine import create_command_context +from src.desktop.runtime import ChatJob, DesktopRuntime, _HELP_TEXT +from src.desktop.server import DesktopServer +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.permission_handler import PermissionBehavior +from src.tool_system.protocol import ToolCall +from src.tool_system.registry import ToolRegistry +from src.tool_system.tools.acp import AcpTool + +ROOT = Path(__file__).resolve().parents[1] +FAKE = Path(__file__).resolve().parent / "fake_acp_runner.py" +SECRET = "sk-ant-" + ("a" * 24) + + +class AcpTestCase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.acp = self.root / "acp" + self.project = self.root / "project" + self.project.mkdir() + self._env = patch.dict( + os.environ, + {"JONATHAN_ACP_DIR": str(self.acp), "JONATHAN_USER": "acp-test"}, + ) + self._env.start() + + def tearDown(self) -> None: + self._env.stop() + self.tmp.cleanup() + + def _enable_fake(self, runner: str = "opencode") -> None: + update_runners( + [{ + "id": runner, + "enabled": True, + "command": sys.executable, + "args": [str(FAKE)], + }], + self.acp, + ) + + def _context(self, *, gate: bool = False) -> ToolContext: + ctx = ToolContext(workspace_root=self.project, acp_dir=self.acp) + ctx.gate_destructive_tools = gate + return ctx + + +class TestRunnerSettings(AcpTestCase): + def test_runners_start_disabled_and_stay_per_user(self) -> None: + listed = format_acp_command("list", directory=self.acp, user_id="alice") + self.assertIn("opencode", listed) + self.assertIn("disabled", listed) + update_runners( + [{"id": "zed", "enabled": True, "command": "/usr/bin/zed", "args": ["acp"]}], + self.acp, + user_id="alice", + ) + alice = load_runners(self.acp, user_id="alice")["zed"] + bob = load_runners(self.acp, user_id="bob")["zed"] + self.assertTrue(alice.enabled) + self.assertEqual(alice.command, "/usr/bin/zed") + self.assertFalse(bob.enabled) + self.assertEqual(bob.command, "zed") + mode = settings_path(self.acp, user_id="alice").stat().st_mode & 0o777 + self.assertEqual(mode, 0o600) + + def test_empty_command_resets_to_the_local_default(self) -> None: + view = update_runners( + [{"id": "opencode", "enabled": True, "command": "", "args": "rm -rf /"}], + self.acp, + ) + row = next(item for item in view["runners"] if item["id"] == "opencode") + self.assertEqual(row["command"], "opencode") + self.assertEqual(row["args"], ["acp"]) + self.assertNotIn("token", row) + self.assertNotIn("api_key", row) + + def test_unknown_runner_is_refused(self) -> None: + with self.assertRaises(ValueError): + update_runners([{"id": "kimi", "enabled": True}], self.acp) + server = DesktopServer(DesktopRuntime(workspace=self.project, permission_timeout_s=1.0)) + status, body = server.handle_api("GET", "/api/acp", {}, {}) + self.assertEqual(status, 200) + self.assertEqual([item["id"] for item in body["runners"]], ["opencode", "claude_code", "codex", "zed"]) + with self.assertRaises(ValueError): + server.handle_api("POST", "/api/acp", {}, {"runners": [{"id": "nope"}]}) + + +class TestApprovalAndGuard(AcpTestCase): + def test_disabled_missing_and_root_never_start(self) -> None: + tool = AcpTool() + ctx = self._context(gate=True) + + def explode(*_args, **_kwargs): + raise AssertionError("runner started") + + with patch("src.acp.protocol.subprocess.Popen", explode): + disabled = tool.check_permissions( + {"runner": "opencode", "folder": str(self.project), "task": "edit"}, + ctx, + ) + self.assertEqual(disabled.behavior, PermissionBehavior.DENY) + self.assertIn("not enabled", (disabled.message or "").lower()) + + self._enable_fake() + missing = tool.check_permissions( + {"runner": "opencode", "folder": str(self.root / "missing"), "task": "edit"}, + ctx, + ) + self.assertEqual(missing.behavior, PermissionBehavior.DENY) + self.assertIn("not found", (missing.message or "").lower()) + + with self.assertRaises(ValueError): + resolve_folder("/") + root = tool.check_permissions( + {"runner": "opencode", "folder": "/", "task": "edit"}, + ctx, + ) + self.assertEqual(root.behavior, PermissionBehavior.DENY) + self.assertIn("filesystem root", (root.message or "").lower()) + + def test_gate_without_a_handler_does_not_start(self) -> None: + self._enable_fake() + ctx = self._context(gate=True) + registry = ToolRegistry(tools=[AcpTool()]) + with patch("src.acp.protocol.subprocess.Popen", side_effect=AssertionError("runner started")): + result = registry.dispatch( + ToolCall( + name="Acp", + input={"runner": "opencode", "folder": str(self.project), "task": "edit"}, + ), + ctx, + ) + self.assertTrue(result.is_error) + self.assertIn(str(self.project), str(result.output)) + self.assertFalse((self.project / "delegated.txt").exists()) + + def test_ask_names_the_folder_and_command(self) -> None: + self._enable_fake() + decision = AcpTool().check_permissions( + {"runner": "opencode", "folder": str(self.project), "task": "edit"}, + self._context(gate=True), + ) + self.assertEqual(decision.behavior, PermissionBehavior.ASK) + message = decision.message or "" + self.assertIn(str(self.project), message) + self.assertIn(sys.executable, message) + self.assertIn(FAKE.name, message) + + def test_dangerous_command_still_asks_with_a_session_grant(self) -> None: + update_runners( + [{ + "id": "codex", + "enabled": True, + "command": "bash", + "args": ["-c", "rm -rf /"], + }], + self.acp, + ) + ctx = self._context(gate=True) + ctx.session_grants.add("*") + decision = AcpTool().check_permissions( + {"runner": "codex", "folder": str(self.project), "task": "edit"}, + ctx, + ) + self.assertEqual(decision.behavior, PermissionBehavior.ASK) + self.assertIn(str(self.project), decision.message or "") + self.assertIn("rm", (decision.message or "").lower()) + ran = AcpTool().run( + {"runner": "codex", "folder": str(self.project), "task": "edit"}, + ctx, + ) + self.assertTrue(ran.is_error) + self.assertFalse((self.project / "delegated.txt").exists()) + + +class TestFakeRunner(AcpTestCase): + def test_streams_progress_and_lists_changed_files(self) -> None: + self._enable_fake() + progress: list[str] = [] + ctx = self._context(gate=False) + ctx.progress_handler = progress.append + tool = AcpTool() + payload = {"runner": "opencode", "folder": str(self.project), "task": SECRET} + decision = tool.check_permissions(payload, ctx) + self.assertEqual(decision.behavior, PermissionBehavior.ALLOW) + result = tool.run(decision.updated_input or payload, ctx) + self.assertFalse(result.is_error) + text = result.output["text"] + self.assertIn("Changed files:", text) + self.assertIn("delegated.txt", text) + self.assertIn("OpenCode", text) + self.assertNotIn(SECRET, text) + self.assertTrue(any("Writing delegated.txt" in line for line in progress)) + self.assertNotIn(SECRET, "\n".join(progress)) + self.assertTrue((self.project / "delegated.txt").is_file()) + + def test_command_list_roundtrip(self) -> None: + context = create_command_context(self.project) + ok, text, error = execute_command_sync("acp", "list", context) + self.assertTrue(ok, error) + self.assertIn("claude_code", text or "") + self.assertIn("disabled", text or "") + blocked = format_acp_command( + f"run opencode {self.project} hello", + directory=self.acp, + ) + self.assertIn("not enabled", blocked.lower()) + + def test_cli_list(self) -> None: + from io import StringIO + + from src.cli import handle_acp + + buf = StringIO() + with patch("sys.stdout", buf): + code = handle_acp(argparse.Namespace(acp_command="list")) + self.assertEqual(code, 0) + self.assertIn("Zed", buf.getvalue()) + + +class TestUiAndRegistry(AcpTestCase): + def test_help_panel_and_skill_index(self) -> None: + html = (ROOT / "src" / "desktop" / "web" / "index.html").read_text(encoding="utf-8") + css = (ROOT / "src" / "desktop" / "web" / "styles.css").read_text(encoding="utf-8") + self.assertIn("/acp", _HELP_TEXT) + for label in ("Coding agents", "OpenCode", "Claude Code", "Codex", "Zed", "acp-save"): + self.assertIn(label, html) + self.assertNotIn("GLM", html) + self.assertIn('input[type="checkbox"]', css) + names = [spec.name for spec in build_default_registry(include_user_tools=False).list_specs()] + self.assertEqual(names[17], "Skill") + self.assertGreater(names.index("Acp"), 17) + + def test_import_does_not_load_a_network_acp_library(self) -> None: + import src.acp.protocol + import src.acp.service + + self.assertNotIn("acp", sys.modules) + self.assertNotIn("agent_client_protocol", sys.modules) + self.assertIn("src.acp.protocol", sys.modules) + self.assertIn("src.acp.service", sys.modules) + + def test_slash_streams_progress_after_approval(self) -> None: + self._enable_fake() + runtime = DesktopRuntime(workspace=self.project, permission_timeout_s=1.0) + runtime.tool_context.acp_dir = self.acp + asked: list[str] = [] + + def allow(tool_name: str, message: str, suggestion: str | None) -> tuple[bool, bool]: + del tool_name, suggestion + asked.append(message) + return True, False + + runtime.tool_context.permission_handler = allow + job = ChatJob(job_id="acp-job") + runtime._run_acp_slash(job, f"run opencode {self.project} write a note") + self.assertTrue(job.done) + kinds = [event["type"] for event in job.events] + self.assertIn("acp_progress", kinds) + self.assertIn("done", kinds) + self.assertTrue(asked) + self.assertIn(str(self.project), asked[0]) + done = next(event for event in job.events if event["type"] == "done") + self.assertIn("delegated.txt", done["text"]) + + +class TestDirectDelegate(AcpTestCase): + def test_delegate_masks_runner_text(self) -> None: + self._enable_fake() + text = delegate_task( + runner_id="opencode", + folder=str(self.project), + task=SECRET, + directory=self.acp, + ) + self.assertIn("delegated.txt", text) + self.assertNotIn(SECRET, text) + view = public_runners(self.acp) + blob = str(view) + self.assertNotIn("SECRET", blob) + self.assertNotIn(SECRET, blob) + + +if __name__ == "__main__": + unittest.main()