diff --git a/docs/LOCAL_PUBLIC_TOOLS.md b/docs/LOCAL_PUBLIC_TOOLS.md new file mode 100644 index 00000000..d6e8cb5d --- /dev/null +++ b/docs/LOCAL_PUBLIC_TOOLS.md @@ -0,0 +1,27 @@ +# Local Public Tools + +This project exposes local atomic capabilities as shared `public` tools for all agents. + +## Included P0 tools + +- `local_run_command` +- `local_read_file` +- `local_write_file` +- `local_edit_file` + +## Loading path + +- Files live under `runtime/tools/public/`. +- They are auto-discovered by `runtime/tools/public_registry.py` (`*_tool` factory naming). +- Final exposure is controlled in `runtime/tools/catalog.py`. + +## Risk gating + +- `local_read_file` is `risk_level=low` and visible by default. +- `local_run_command`, `local_write_file`, `local_edit_file` are `risk_level=high`. +- High-risk public tools are hidden unless `AIA_PUBLIC_TOOLS_ALLOW_HIGH=1`. + +## Local backend adapter + +- Adapter path: `runtime/tools/local_sdk/adapter.py`. +- Uses a self-implemented local backend (cross-platform) with a stable tool contract. diff --git a/runtime/gateway.py b/runtime/gateway.py index 6e21270a..9a8d4c7d 100644 --- a/runtime/gateway.py +++ b/runtime/gateway.py @@ -5,6 +5,7 @@ import os import time import uuid import threading +import logging from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Optional @@ -35,6 +36,9 @@ from oclaw.runtime.worker import ensure_worker_started from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id from oclaw.runtime.chat.tool_runtime import compact_turn_tool_messages_for_storage from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload +from oclaw.runtime.tools.local_sdk import local_adapter_startup_self_check + +logger = logging.getLogger(__name__) _OC_STAGE_BY_EVENT: dict[str, str] = { "gateway_received": "ingress", @@ -98,6 +102,16 @@ class GatewayDispatchPlan: class OclawGateway: def __init__(self, *, store: Any): self.store = store + try: + check = local_adapter_startup_self_check() + if not bool(check.get("ok")): + logger.warning( + "Local adapter startup self-check failed: %s (%s)", + str(check.get("error_code") or ""), + str(check.get("error") or ""), + ) + except Exception: + pass @staticmethod def _looks_like_manager_instruction(reply: str, instruction: str) -> bool: diff --git a/runtime/skills/_workspace/SkillScan/scripts/scanner.py b/runtime/skills/_workspace/SkillScan/scripts/scanner.py index 5579be50..02eb7cec 100644 --- a/runtime/skills/_workspace/SkillScan/scripts/scanner.py +++ b/runtime/skills/_workspace/SkillScan/scripts/scanner.py @@ -87,7 +87,7 @@ def skill_install_paths(): home / ".vibe/skills", home / ".mux/skills", home / ".config/opencode/skills", - home / ".openhands/skills", + home / ".oh/skills", home / ".pi/agent/skills", home / ".qoder/skills", home / ".qwen/skills", diff --git a/runtime/skills/skillscan/scripts/scanner.py b/runtime/skills/skillscan/scripts/scanner.py index 5579be50..02eb7cec 100644 --- a/runtime/skills/skillscan/scripts/scanner.py +++ b/runtime/skills/skillscan/scripts/scanner.py @@ -87,7 +87,7 @@ def skill_install_paths(): home / ".vibe/skills", home / ".mux/skills", home / ".config/opencode/skills", - home / ".openhands/skills", + home / ".oh/skills", home / ".pi/agent/skills", home / ".qoder/skills", home / ".qwen/skills", diff --git a/runtime/tools/local_sdk/__init__.py b/runtime/tools/local_sdk/__init__.py new file mode 100644 index 00000000..0431cb54 --- /dev/null +++ b/runtime/tools/local_sdk/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .adapter import LocalAdapter, LocalAdapterError, get_local_adapter, local_adapter_startup_self_check + +__all__ = ["LocalAdapter", "LocalAdapterError", "get_local_adapter", "local_adapter_startup_self_check"] diff --git a/runtime/tools/local_sdk/adapter.py b/runtime/tools/local_sdk/adapter.py new file mode 100644 index 00000000..fa32da16 --- /dev/null +++ b/runtime/tools/local_sdk/adapter.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from typing import Any + +from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path, truncate_text + + +@dataclass(frozen=True) +class LocalAdapterError(Exception): + error_code: str + message: str + + def as_result(self) -> dict[str, Any]: + return {"ok": False, "error_code": self.error_code, "error": self.message} + + +class LocalAdapter: + """Self-implemented local backend (cross-platform).""" + + def __init__(self) -> None: + self._init_error: LocalAdapterError | None = None + + def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 30) -> dict[str, Any]: + cmd = str(command or "").strip() + if not cmd: + return {"ok": False, "error_code": "command_required", "error": "command_required"} + try: + timeout_s = max(1, min(int(timeout or 30), 600)) + workdir = str(resolve_workspace_path(cwd or ".")) + run_kwargs: dict[str, Any] = { + "cwd": workdir, + "shell": True, + "capture_output": True, + "text": True, + "timeout": float(timeout_s), + } + if os.name == "nt": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = 0 + run_kwargs["startupinfo"] = startupinfo + run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + cp = subprocess.run(cmd, **run_kwargs) + stdout = str(cp.stdout or "") + stderr = str(cp.stderr or "") + combined = stdout + (("\n" + stderr) if stderr else "") + return { + "ok": int(cp.returncode) == 0, + "stdout": stdout, + "stderr": stderr, + "exit_code": int(cp.returncode), + "output": truncate_text(combined, limit=20000), + "cwd": workdir, + } + except subprocess.TimeoutExpired as exc: + partial = (str(exc.stdout or "") + ("\n" + str(exc.stderr or "") if exc.stderr else "")).strip() + return { + "ok": False, + "error_code": "command_timeout", + "error": "command_timeout", + "stdout": str(exc.stdout or ""), + "stderr": str(exc.stderr or ""), + "output": truncate_text(partial, limit=20000), + } + except Exception as exc: + return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"} + + def read_file( + self, + *, + path: str, + start_line: int | None = None, + end_line: int | None = None, + ) -> dict[str, Any]: + target = str(path or "").strip() + if not target: + return {"ok": False, "error_code": "path_required", "error": "path_required"} + try: + p = resolve_workspace_path(target) + if not p.exists() or not p.is_file(): + return {"ok": False, "error_code": "file_not_found", "error": "file_not_found", "path": str(p)} + lines = p.read_text(encoding="utf-8", errors="replace").splitlines() + s = max(1, int(start_line or 1)) + e = int(end_line or len(lines)) + e = max(s, min(e, len(lines))) + content = "\n".join(lines[s - 1 : e]) + return { + "ok": True, + "path": str(p), + "start_line": s, + "end_line": e, + "total_lines": len(lines), + "content": content, + } + except Exception as exc: + return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"} + + def write_file(self, *, path: str, content: str, mode: str = "overwrite") -> dict[str, Any]: + target = str(path or "").strip() + if not target: + return {"ok": False, "error_code": "path_required", "error": "path_required"} + write_mode = str(mode or "overwrite").strip().lower() + if write_mode not in {"overwrite", "append"}: + return {"ok": False, "error_code": "invalid_mode", "error": "invalid_mode"} + try: + p = resolve_workspace_path(target) + p.parent.mkdir(parents=True, exist_ok=True) + if write_mode == "append" and p.exists(): + merged = p.read_text(encoding="utf-8", errors="replace") + str(content or "") + p.write_text(merged, encoding="utf-8") + else: + p.write_text(str(content or ""), encoding="utf-8") + return {"ok": True, "path": str(p), "bytes": int(p.stat().st_size), "mode": write_mode} + except Exception as exc: + return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"} + + def edit_file( + self, + *, + path: str, + search: str | None = None, + replace: str | None = None, + start_line: int | None = None, + end_line: int | None = None, + replacement: str | None = None, + ) -> dict[str, Any]: + target = str(path or "").strip() + if not target: + return {"ok": False, "error_code": "path_required", "error": "path_required"} + try: + p = resolve_workspace_path(target) + if not p.exists() or not p.is_file(): + return {"ok": False, "error_code": "file_not_found", "error": "file_not_found", "path": str(p)} + text = p.read_text(encoding="utf-8", errors="replace") + if search is not None: + needle = str(search) + if needle not in text: + return {"ok": False, "error_code": "search_not_found", "error": "search_not_found"} + new_text = text.replace(needle, str(replace or ""), 1) + p.write_text(new_text, encoding="utf-8") + return {"ok": True, "path": str(p), "mode": "search_replace"} + + if start_line is not None and end_line is not None: + lines = text.splitlines() + s = max(1, int(start_line)) + e = max(s, int(end_line)) + if s > len(lines): + return {"ok": False, "error_code": "line_out_of_range", "error": "line_out_of_range"} + e = min(e, len(lines)) + repl_lines = str(replacement or "").splitlines() + new_lines = lines[: s - 1] + repl_lines + lines[e:] + suffix = "\n" if text.endswith("\n") else "" + p.write_text("\n".join(new_lines) + suffix, encoding="utf-8") + return {"ok": True, "path": str(p), "mode": "line_replace", "start_line": s, "end_line": e} + return {"ok": False, "error_code": "invalid_edit_arguments", "error": "line_range_required"} + except Exception as exc: + return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"} + + +_ADAPTER_SINGLETON: LocalAdapter | None = None + + +def get_local_adapter() -> LocalAdapter: + global _ADAPTER_SINGLETON + if _ADAPTER_SINGLETON is None: + _ADAPTER_SINGLETON = LocalAdapter() + return _ADAPTER_SINGLETON + + +def local_adapter_startup_self_check() -> dict[str, Any]: + """Best-effort startup probe for local backend availability.""" + enabled = str(os.getenv("AIA_LOCAL_ADAPTER_STARTUP_SELF_CHECK") or "1").strip().lower() in { + "1", + "true", + "yes", + "on", + } + if not enabled: + return {"ok": True, "enabled": False} + try: + adapter = LocalAdapter() + if adapter._init_error is not None: + return {"ok": False, "enabled": True, "error_code": adapter._init_error.error_code, "error": adapter._init_error.message} + return {"ok": True, "enabled": True} + except Exception as exc: + return { + "ok": False, + "enabled": True, + "error_code": "local_adapter_startup_self_check_failed", + "error": f"{type(exc).__name__}: {exc}", + } + + +__all__ = ["LocalAdapter", "LocalAdapterError", "get_local_adapter", "local_adapter_startup_self_check"] diff --git a/runtime/tools/public/local_edit_file_tool.py b/runtime/tools/public/local_edit_file_tool.py new file mode 100644 index 00000000..48f641ba --- /dev/null +++ b/runtime/tools/public/local_edit_file_tool.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any + +from oclaw.runtime.tools.base import ToolSpec +from oclaw.runtime.tools.local_sdk import get_local_adapter + + +def local_edit_file_tool() -> ToolSpec: + def _handler(args: dict[str, Any]) -> dict[str, Any]: + path = str(args.get("path") or "").strip() + if not path: + return {"ok": False, "error_code": "path_required", "error": "path_required"} + + search = args.get("search") + replace = args.get("replace") + start_line = args.get("start_line") + end_line = args.get("end_line") + replacement = args.get("replacement") + + has_search = search is not None + has_range = start_line is not None or end_line is not None or replacement is not None + if has_search and has_range: + return { + "ok": False, + "error_code": "invalid_edit_arguments", + "error": "choose search/replace or line-range replacement, not both", + } + if not has_search and not has_range: + return { + "ok": False, + "error_code": "invalid_edit_arguments", + "error": "missing edit arguments", + } + + return get_local_adapter().edit_file( + path=path, + search=str(search) if has_search else None, + replace=str(replace) if replace is not None else None, + start_line=int(start_line) if start_line is not None else None, + end_line=int(end_line) if end_line is not None else None, + replacement=str(replacement) if replacement is not None else None, + ) + + return ToolSpec( + name="local_edit_file", + description="Edit partial file content via local backend.", + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Target file path."}, + "search": {"type": "string", "description": "Search text for single replace mode."}, + "replace": {"type": "string", "description": "Replacement text for search mode."}, + "start_line": {"type": "integer", "description": "Start line for line-range replace mode."}, + "end_line": {"type": "integer", "description": "End line for line-range replace mode."}, + "replacement": {"type": "string", "description": "Replacement content for line-range mode."}, + }, + "required": ["path"], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"public", "local", "workspace", "write", "edit"}), + risk_level="high", + timeout_s=30.0, + read_only=False, + ) + + +__all__ = ["local_edit_file_tool"] diff --git a/runtime/tools/public/local_read_file_tool.py b/runtime/tools/public/local_read_file_tool.py new file mode 100644 index 00000000..a460124c --- /dev/null +++ b/runtime/tools/public/local_read_file_tool.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any + +from oclaw.runtime.tools.base import ToolSpec +from oclaw.runtime.tools.local_sdk import get_local_adapter + + +def local_read_file_tool() -> ToolSpec: + def _handler(args: dict[str, Any]) -> dict[str, Any]: + path = str(args.get("path") or "").strip() + if not path: + return {"ok": False, "error_code": "path_required", "error": "path_required"} + start_line = args.get("start_line") + end_line = args.get("end_line") + return get_local_adapter().read_file( + path=path, + start_line=int(start_line) if start_line is not None else None, + end_line=int(end_line) if end_line is not None else None, + ) + + return ToolSpec( + name="local_read_file", + description="Read file content via local backend.", + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Target file path."}, + "start_line": {"type": "integer", "description": "Optional start line (1-indexed)."}, + "end_line": {"type": "integer", "description": "Optional end line (1-indexed)."}, + }, + "required": ["path"], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"public", "local", "workspace", "read"}), + risk_level="low", + timeout_s=20.0, + read_only=True, + ) + + +__all__ = ["local_read_file_tool"] diff --git a/runtime/tools/public/local_run_command_tool.py b/runtime/tools/public/local_run_command_tool.py new file mode 100644 index 00000000..5c05219a --- /dev/null +++ b/runtime/tools/public/local_run_command_tool.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from oclaw.runtime.tools.base import ToolSpec +from oclaw.runtime.tools.local_sdk import get_local_adapter + + +def local_run_command_tool() -> ToolSpec: + def _handler(args: dict[str, Any]) -> dict[str, Any]: + command = str(args.get("command") or "").strip() + if not command: + return {"ok": False, "error_code": "command_required", "error": "command_required"} + cwd = str(args.get("cwd") or "").strip() or None + timeout = int(args.get("timeout") or 30) + return get_local_adapter().run_command(command=command, cwd=cwd, timeout=timeout) + + return ToolSpec( + name="local_run_command", + description="Run a shell command via local backend.", + parameters={ + "type": "object", + "properties": { + "command": {"type": "string", "description": "Shell command to execute."}, + "cwd": {"type": "string", "description": "Optional working directory."}, + "timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 30}, + }, + "required": ["command"], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"public", "local", "exec", "workspace"}), + risk_level="high", + timeout_s=90.0, + read_only=False, + ) + + +__all__ = ["local_run_command_tool"] diff --git a/runtime/tools/public/local_write_file_tool.py b/runtime/tools/public/local_write_file_tool.py new file mode 100644 index 00000000..e7d5aef3 --- /dev/null +++ b/runtime/tools/public/local_write_file_tool.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from oclaw.runtime.tools.base import ToolSpec +from oclaw.runtime.tools.local_sdk import get_local_adapter + + +def local_write_file_tool() -> ToolSpec: + def _handler(args: dict[str, Any]) -> dict[str, Any]: + path = str(args.get("path") or "").strip() + if not path: + return {"ok": False, "error_code": "path_required", "error": "path_required"} + content = str(args.get("content") or "") + mode = str(args.get("mode") or "overwrite").strip().lower() + return get_local_adapter().write_file(path=path, content=content, mode=mode) + + return ToolSpec( + name="local_write_file", + description="Write or append file content via local backend.", + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Target file path."}, + "content": {"type": "string", "description": "Content to write."}, + "mode": {"type": "string", "enum": ["overwrite", "append"], "default": "overwrite"}, + }, + "required": ["path", "content"], + "additionalProperties": False, + }, + handler=_handler, + tags=frozenset({"public", "local", "workspace", "write"}), + risk_level="high", + timeout_s=30.0, + read_only=False, + ) + + +__all__ = ["local_write_file_tool"] diff --git a/tests/test_local_public_tools.py b/tests/test_local_public_tools.py new file mode 100644 index 00000000..eac7356a --- /dev/null +++ b/tests/test_local_public_tools.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +from oclaw.runtime.tools.catalog import default_registry +from oclaw.runtime.tools.local_sdk.adapter import LocalAdapter +from oclaw.runtime.tools.public.local_edit_file_tool import local_edit_file_tool +from oclaw.runtime.tools.public.local_read_file_tool import local_read_file_tool +from oclaw.runtime.tools.public.local_run_command_tool import local_run_command_tool +from oclaw.runtime.tools.public.local_write_file_tool import local_write_file_tool +from oclaw.runtime.tools.public_registry import clear_public_tool_cache + + +def test_local_public_read_tool_visible_by_default() -> None: + clear_public_tool_cache() + names = [t.name for t in default_registry(expert="network_ops+memory", specialist="ops").list()] + assert "local_read_file" in names + assert "local_run_command" not in names + assert "local_write_file" not in names + assert "local_edit_file" not in names + + +def test_local_public_high_risk_tools_visible_when_enabled(monkeypatch) -> None: + clear_public_tool_cache() + monkeypatch.setenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", "1") + names = [t.name for t in default_registry(expert="network_ops+memory", specialist="ops").list()] + assert "local_read_file" in names + assert "local_run_command" in names + assert "local_write_file" in names + assert "local_edit_file" in names + + +def test_local_adapter_backend_roundtrip(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path)) + adapter = LocalAdapter() + out_w = adapter.write_file(path="a.txt", content="hello\nworld\n") + assert out_w.get("ok") is True + out = adapter.edit_file(path="a.txt", search="hello", replace="hi") + assert out.get("ok") is True + out2 = adapter.run_command(command="echo hi", timeout=10) + assert out2.get("ok") is True + assert "hi" in str(out2.get("stdout") or "").lower() + + +def test_local_run_command_tool_handler(monkeypatch) -> None: + class _Adapter: + def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 30): + return {"ok": True, "command": command, "cwd": cwd, "timeout": timeout} + + monkeypatch.setattr("oclaw.runtime.tools.public.local_run_command_tool.get_local_adapter", lambda: _Adapter()) + spec = local_run_command_tool() + out = spec.handler({"command": "echo hi", "cwd": "repo", "timeout": 9}) + assert out.get("ok") is True + assert out.get("command") == "echo hi" + assert out.get("cwd") == "repo" + assert out.get("timeout") == 9 + + +def test_local_read_file_tool_handler(monkeypatch) -> None: + class _Adapter: + def read_file(self, *, path: str, start_line: int | None = None, end_line: int | None = None): + return {"ok": True, "path": path, "start_line": start_line, "end_line": end_line} + + monkeypatch.setattr("oclaw.runtime.tools.public.local_read_file_tool.get_local_adapter", lambda: _Adapter()) + spec = local_read_file_tool() + out = spec.handler({"path": "a.py", "start_line": 2, "end_line": 5}) + assert out.get("ok") is True + assert out.get("path") == "a.py" + assert out.get("start_line") == 2 + assert out.get("end_line") == 5 + + +def test_local_write_file_tool_handler(monkeypatch) -> None: + class _Adapter: + def write_file(self, *, path: str, content: str, mode: str = "overwrite"): + return {"ok": True, "path": path, "content": content, "mode": mode} + + monkeypatch.setattr("oclaw.runtime.tools.public.local_write_file_tool.get_local_adapter", lambda: _Adapter()) + spec = local_write_file_tool() + out = spec.handler({"path": "a.py", "content": "x=1", "mode": "append"}) + assert out.get("ok") is True + assert out.get("path") == "a.py" + assert out.get("content") == "x=1" + assert out.get("mode") == "append" + + +def test_local_edit_file_tool_handler(monkeypatch) -> None: + class _Adapter: + def edit_file( + self, + *, + path: str, + search: str | None = None, + replace: str | None = None, + start_line: int | None = None, + end_line: int | None = None, + replacement: str | None = None, + ): + return { + "ok": True, + "path": path, + "search": search, + "replace": replace, + "start_line": start_line, + "end_line": end_line, + "replacement": replacement, + } + + monkeypatch.setattr("oclaw.runtime.tools.public.local_edit_file_tool.get_local_adapter", lambda: _Adapter()) + spec = local_edit_file_tool() + out = spec.handler({"path": "a.py", "search": "foo", "replace": "bar"}) + assert out.get("ok") is True + assert out.get("path") == "a.py" + assert out.get("search") == "foo" + assert out.get("replace") == "bar" + + +def test_local_tool_integration_roundtrip(monkeypatch) -> None: + run_spec = local_run_command_tool() + read_spec = local_read_file_tool() + write_spec = local_write_file_tool() + edit_spec = local_edit_file_tool() + + tmpdir = Path(tempfile.mkdtemp(prefix="local_it_")) + monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmpdir)) + target = tmpdir / "it_sample.txt" + + out_write = write_spec.handler({"path": str(target), "content": "line1\nline2\n", "mode": "overwrite"}) + assert out_write.get("ok") is True, out_write + + out_read_before = read_spec.handler({"path": str(target), "start_line": 1, "end_line": 10}) + assert out_read_before.get("ok") is True, out_read_before + assert "line2" in str(out_read_before.get("content") or "") + + out_edit = edit_spec.handler({"path": str(target), "search": "line2", "replace": "line2_edited"}) + assert out_edit.get("ok") is True, out_edit + + out_read_after = read_spec.handler({"path": str(target), "start_line": 1, "end_line": 10}) + assert out_read_after.get("ok") is True, out_read_after + assert "line2_edited" in str(out_read_after.get("content") or "") + + out_run = run_spec.handler({"command": "python -c \"print(12345)\"", "cwd": str(tmpdir), "timeout": 20}) + assert out_run.get("ok") is True, out_run + stdout = str(out_run.get("stdout") or "") + assert "12345" in stdout +