mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:13:19 +08:00
- Rename platform/ to svc/ to avoid shadowing stdlib platform. - Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths. - tests/conftest: prepend repo root to sys.path (no parent-folder package name). - CI: paths and offline_eval script under repo root. - Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup). - Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config. Co-authored-by: Cursor <cursoragent@cursor.com>
282 lines
9.2 KiB
Python
282 lines
9.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from svc.config.paths import PROJECT_ROOT
|
|
|
|
|
|
_PATH_KEYS = {
|
|
"path",
|
|
"file",
|
|
"dir",
|
|
"folder",
|
|
"input_path",
|
|
"output_path",
|
|
"source_path",
|
|
"target_path",
|
|
"workspace_path",
|
|
}
|
|
|
|
_WRITE_HINT_KEYS = {
|
|
"output_path",
|
|
"target_path",
|
|
"dest_path",
|
|
"destination_path",
|
|
"write_path",
|
|
"save_path",
|
|
"out_path",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _RunResult:
|
|
ok: bool
|
|
stdout: str
|
|
stderr: str
|
|
exit_code: int
|
|
duration_ms: int
|
|
|
|
|
|
def _resolve_allowed_roots(skill_dir: Path) -> list[Path]:
|
|
roots = [Path(PROJECT_ROOT).resolve(), skill_dir.resolve()]
|
|
out: list[Path] = []
|
|
seen: set[str] = set()
|
|
for r in roots:
|
|
s = str(r)
|
|
if s in seen:
|
|
continue
|
|
seen.add(s)
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
def _is_under_roots(p: Path, roots: list[Path]) -> bool:
|
|
rp = p.resolve()
|
|
for r in roots:
|
|
try:
|
|
rp.relative_to(r)
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _validate_path_value(v: str, *, skill_dir: Path, roots: list[Path]) -> None:
|
|
raw = str(v or "").strip()
|
|
if not raw:
|
|
return
|
|
# Expand and resolve relative paths against skill_dir
|
|
p = Path(raw)
|
|
if not p.is_absolute():
|
|
p = (skill_dir / p).resolve()
|
|
else:
|
|
p = p.resolve()
|
|
if not _is_under_roots(p, roots):
|
|
raise PermissionError(f"path_outside_allowed_roots:{raw}")
|
|
|
|
|
|
def _walk_and_validate_paths(obj: Any, *, skill_dir: Path, roots: list[Path], key_hint: str | None = None) -> None:
|
|
if isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
kk = str(k or "").strip().lower()
|
|
_walk_and_validate_paths(v, skill_dir=skill_dir, roots=roots, key_hint=kk)
|
|
return
|
|
if isinstance(obj, list):
|
|
for it in obj:
|
|
_walk_and_validate_paths(it, skill_dir=skill_dir, roots=roots, key_hint=key_hint)
|
|
return
|
|
if isinstance(obj, str):
|
|
if (key_hint or "") in _PATH_KEYS:
|
|
_validate_path_value(obj, skill_dir=skill_dir, roots=roots)
|
|
return
|
|
|
|
|
|
def _deny_writes_when_disabled(obj: Any, *, key_hint: str | None = None) -> None:
|
|
if isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
kk = str(k or "").strip().lower()
|
|
_deny_writes_when_disabled(v, key_hint=kk)
|
|
return
|
|
if isinstance(obj, list):
|
|
for it in obj:
|
|
_deny_writes_when_disabled(it, key_hint=key_hint)
|
|
return
|
|
if isinstance(obj, str):
|
|
if (key_hint or "") in _WRITE_HINT_KEYS and str(obj).strip():
|
|
raise PermissionError(f"fs_write_disabled:{key_hint}")
|
|
return
|
|
|
|
|
|
def _resolve_entry(skill_dir: Path, entry: str) -> Path:
|
|
rel = str(entry or "").strip().replace("\\", "/")
|
|
if not rel or rel.startswith("/") or ".." in rel.split("/"):
|
|
raise ValueError("invalid_entry_path")
|
|
p = (skill_dir / rel).resolve()
|
|
try:
|
|
p.relative_to(skill_dir.resolve())
|
|
except Exception:
|
|
raise ValueError("entry_outside_skill_dir")
|
|
# If entry doesn't exist and is .ts, try .js (common in marketplace packages)
|
|
if not p.exists() and p.suffix.lower() == ".ts":
|
|
alt = p.with_suffix(".js")
|
|
if alt.exists():
|
|
return alt
|
|
return p
|
|
|
|
|
|
def _which(cmd: str) -> str | None:
|
|
return shutil.which(cmd)
|
|
|
|
|
|
def _pick_shell() -> list[str] | None:
|
|
# Prefer explicit env override, otherwise bash then sh.
|
|
raw = str(os.getenv("OCLAW_SHELL") or "").strip()
|
|
if raw:
|
|
exe = _which(raw)
|
|
return [exe] if exe else None
|
|
for c in ("bash", "sh"):
|
|
exe = _which(c)
|
|
if exe:
|
|
return [exe]
|
|
return None
|
|
|
|
|
|
def _run(argv: list[str], *, cwd: Path, stdin_json: dict[str, Any], timeout_s: float) -> _RunResult:
|
|
import time
|
|
|
|
start = time.time()
|
|
p = subprocess.run(
|
|
argv,
|
|
input=json.dumps(stdin_json, ensure_ascii=False),
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
capture_output=True,
|
|
cwd=str(cwd),
|
|
timeout=max(1, int(timeout_s)),
|
|
check=False,
|
|
env=_build_env_allowlist(),
|
|
)
|
|
dur_ms = int((time.time() - start) * 1000)
|
|
return _RunResult(
|
|
ok=p.returncode == 0,
|
|
stdout=str(p.stdout or ""),
|
|
stderr=str(p.stderr or ""),
|
|
exit_code=int(p.returncode),
|
|
duration_ms=dur_ms,
|
|
)
|
|
|
|
|
|
def _build_env_allowlist() -> dict[str, str]:
|
|
# Minimal env for subprocesses; keep PATH for finding interpreters.
|
|
allow = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "HOME", "USERPROFILE", "TMP", "TEMP"}
|
|
out: dict[str, str] = {}
|
|
for k in allow:
|
|
v = os.environ.get(k)
|
|
if v:
|
|
out[k] = str(v)
|
|
# Explicitly carry OpenAI/MCP keys? No: skills should not get host secrets by default.
|
|
return out
|
|
|
|
|
|
def run_skill_runtime_entry(
|
|
*,
|
|
skill_name: str,
|
|
skill_dir: str,
|
|
runtime: dict[str, Any],
|
|
args: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Execute one runtime entry (shell/python/node) with restricted filesystem checks."""
|
|
name = str(skill_name or "").strip()
|
|
root = Path(skill_dir).resolve()
|
|
if not root.exists() or not root.is_dir():
|
|
return {"ok": False, "error_code": "skill_dir_missing", "error": f"skill_dir_not_found:{skill_dir}"}
|
|
|
|
tp = str((runtime or {}).get("type") or "").strip().lower()
|
|
entry = str((runtime or {}).get("entry") or "").strip()
|
|
if tp not in {"shell", "python", "node"}:
|
|
return {"ok": False, "error_code": "unsupported_runtime_type", "error": f"unsupported:{tp}"}
|
|
|
|
try:
|
|
entry_path = _resolve_entry(root, entry)
|
|
except Exception as exc:
|
|
return {"ok": False, "error_code": "invalid_entry", "error": str(exc)}
|
|
if not entry_path.exists() or not entry_path.is_file():
|
|
return {"ok": False, "error_code": "entry_missing", "error": f"entry_not_found:{entry}"}
|
|
|
|
roots = _resolve_allowed_roots(root)
|
|
perms = runtime.get("permissions") if isinstance(runtime.get("permissions"), dict) else {}
|
|
source_meta = runtime.get("__source_meta") if isinstance(runtime.get("__source_meta"), dict) else {}
|
|
fs_write = bool(perms.get("fs_write")) if isinstance(perms, dict) and "fs_write" in perms else False
|
|
allow_net = bool(perms.get("net")) if isinstance(perms, dict) and "net" in perms else False
|
|
allow_process = bool(perms.get("process")) if isinstance(perms, dict) and "process" in perms else True
|
|
if not allow_process:
|
|
return {"ok": False, "error_code": "process_disabled", "error": "process_execution_disabled_by_policy"}
|
|
try:
|
|
if not fs_write:
|
|
_deny_writes_when_disabled(args)
|
|
_walk_and_validate_paths(args, skill_dir=root, roots=roots)
|
|
except PermissionError as exc:
|
|
return {"ok": False, "error_code": "path_restricted", "error": str(exc)}
|
|
|
|
timeout_s = float((runtime or {}).get("timeout_s") or 60.0)
|
|
timeout_s = min(max(timeout_s, 1.0), 120.0)
|
|
max_output_bytes = int((runtime or {}).get("max_output_bytes") or 131072)
|
|
max_output_bytes = min(max(max_output_bytes, 1024), 1048576)
|
|
stdin_json = {
|
|
"skill": name,
|
|
"args": args,
|
|
"permissions": {"fs_write": fs_write, "net": allow_net, "process": allow_process},
|
|
"source": dict(source_meta),
|
|
}
|
|
|
|
try:
|
|
if tp == "python":
|
|
argv = [sys.executable, str(entry_path)]
|
|
elif tp == "node":
|
|
node = _which("node")
|
|
if not node:
|
|
return {"ok": False, "error_code": "node_missing", "error": "node_not_found_in_PATH"}
|
|
argv = [node, str(entry_path)]
|
|
else:
|
|
sh = _pick_shell()
|
|
if not sh:
|
|
return {"ok": False, "error_code": "shell_missing", "error": "bash_or_sh_not_found_in_PATH"}
|
|
argv = [*sh, str(entry_path)]
|
|
rr = _run(argv, cwd=root, stdin_json=stdin_json, timeout_s=timeout_s)
|
|
payload: dict[str, Any] = {
|
|
"ok": bool(rr.ok),
|
|
"exit_code": int(rr.exit_code),
|
|
"duration_ms": int(rr.duration_ms),
|
|
"stdout": rr.stdout[:max_output_bytes],
|
|
"stderr": rr.stderr[:max_output_bytes],
|
|
"stdout_truncated": len(rr.stdout) > max_output_bytes,
|
|
"stderr_truncated": len(rr.stderr) > max_output_bytes,
|
|
"source_provider": str(source_meta.get("provider") or ""),
|
|
"source_version": str(source_meta.get("version") or ""),
|
|
"source_kind": str(source_meta.get("kind") or ""),
|
|
}
|
|
# Best-effort JSON decode for programmatic skills.
|
|
out_obj: Any = None
|
|
try:
|
|
out_obj = json.loads(rr.stdout) if rr.stdout.strip().startswith(("{", "[")) else None
|
|
except Exception:
|
|
out_obj = None
|
|
if out_obj is not None:
|
|
payload["result"] = out_obj
|
|
return payload
|
|
except subprocess.TimeoutExpired:
|
|
return {"ok": False, "error_code": "timeout", "error": f"timeout_s_exceeded:{timeout_s}"}
|
|
except Exception as exc:
|
|
return {"ok": False, "error_code": "runtime_error", "error": f"{type(exc).__name__}:{exc}"}
|
|
|
|
|
|
__all__ = ["run_skill_runtime_entry"]
|
|
|