225 lines
8.3 KiB
Python
225 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
|
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
MAX_BYTES = 1024 * 1024
|
|
MAX_CHANGED_FILES = 256
|
|
MAX_FILE_BYTES = 4 * 1024 * 1024
|
|
SENSITIVE_NAMES = frozenset({
|
|
".env", ".env.local", ".env.production", "id_rsa", "id_ed25519",
|
|
"credentials", "credentials.json",
|
|
})
|
|
SENSITIVE_SUFFIXES = (".key", ".p12", ".pfx")
|
|
SECRET_PATTERNS = (
|
|
re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
re.compile(rb"\bAKIA[0-9A-Z]{16}\b"),
|
|
re.compile(rb"\bghp_[A-Za-z0-9]{30,}\b"),
|
|
re.compile(rb"\bsk-[A-Za-z0-9_-]{24,}\b"),
|
|
)
|
|
|
|
|
|
class RuntimeErrorV2(RuntimeError):
|
|
def __init__(self, code: str, *, details: Mapping[str, Any] | None = None) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.details = dict(details or {})
|
|
|
|
|
|
def safe_relative_path(value: str) -> str:
|
|
if (
|
|
not value
|
|
or value.startswith(("/", "~"))
|
|
or "\\" in value
|
|
or "\x00" in value
|
|
or any(part in {"", ".", ".."} for part in value.split("/"))
|
|
):
|
|
raise RuntimeErrorV2("CHANGED_PATH_INVALID", details={"path": value[:160]})
|
|
lowered = value.split("/")[-1].lower()
|
|
if value.startswith(".git/") or value in {".git", ".gitmodules"}:
|
|
raise RuntimeErrorV2("GIT_METADATA_CHANGE_REJECTED", details={"path": value})
|
|
if lowered in SENSITIVE_NAMES or lowered.endswith(SENSITIVE_SUFFIXES):
|
|
raise RuntimeErrorV2("SENSITIVE_PATH_REJECTED", details={"path": value})
|
|
return value
|
|
|
|
|
|
def git(worktree: Path, args: Sequence[str], timeout: int = 60) -> bytes:
|
|
completed = subprocess.run(
|
|
["/usr/bin/git", "-C", str(worktree), *args],
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=timeout,
|
|
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
|
)
|
|
if completed.returncode != 0:
|
|
raise RuntimeErrorV2("WORKTREE_GIT_FAILED", details={"operation": args[0] if args else ""})
|
|
return completed.stdout
|
|
|
|
|
|
def _zpaths(raw: bytes) -> list[str]:
|
|
result: list[str] = []
|
|
for item in raw.split(b"\0"):
|
|
if not item:
|
|
continue
|
|
try:
|
|
result.append(item.decode("utf-8"))
|
|
except UnicodeDecodeError as exc:
|
|
raise RuntimeErrorV2("WORKTREE_PATH_ENCODING_INVALID") from exc
|
|
return result
|
|
|
|
|
|
def collect_changes(worktree: Path, base_oid: str) -> list[str]:
|
|
if not OID_RE.fullmatch(base_oid):
|
|
raise RuntimeErrorV2("BASE_OID_INVALID")
|
|
head = git(worktree, ["rev-parse", "HEAD"]).decode("ascii", "strict").strip()
|
|
if head != base_oid:
|
|
raise RuntimeErrorV2("WORKTREE_HEAD_MUTATED", details={"expected": base_oid, "actual": head})
|
|
tracked = _zpaths(git(worktree, ["diff", "--name-only", "--no-renames", "-z", base_oid, "--"]))
|
|
untracked = _zpaths(git(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]))
|
|
changed = sorted({safe_relative_path(item) for item in tracked + untracked})
|
|
if not changed:
|
|
raise RuntimeErrorV2("AGENT_PRODUCED_NO_CHANGES")
|
|
if len(changed) > MAX_CHANGED_FILES:
|
|
raise RuntimeErrorV2("CHANGED_FILE_LIMIT_EXCEEDED")
|
|
root = worktree.resolve()
|
|
for relative in changed:
|
|
path = (root / relative).resolve(strict=False)
|
|
if root not in path.parents or not path.is_file() or path.is_symlink():
|
|
raise RuntimeErrorV2("CHANGED_FILE_UNSUPPORTED", details={"path": relative})
|
|
if path.stat().st_size > MAX_FILE_BYTES:
|
|
raise RuntimeErrorV2("CHANGED_FILE_TOO_LARGE", details={"path": relative})
|
|
return changed
|
|
|
|
|
|
def scan_secrets(worktree: Path, changed: Sequence[str]) -> None:
|
|
root = worktree.resolve()
|
|
for relative in changed:
|
|
path = (root / safe_relative_path(relative)).resolve()
|
|
raw = path.read_bytes()
|
|
for pattern in SECRET_PATTERNS:
|
|
if pattern.search(raw):
|
|
raise RuntimeErrorV2("SECRET_MATERIAL_REJECTED", details={"path": relative})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeResult:
|
|
status: str
|
|
summary: str
|
|
verification: tuple[dict[str, Any], ...]
|
|
evidence: dict[str, Any]
|
|
|
|
@classmethod
|
|
def parse(cls, value: Mapping[str, Any]) -> "RuntimeResult":
|
|
if value.get("schema") != "aios.online-agent.runtime-result.v1":
|
|
raise RuntimeErrorV2("RUNTIME_RESULT_SCHEMA_INVALID")
|
|
status = str(value.get("status") or "")
|
|
if status not in {"COMPLETED", "BLOCKED", "FAILED"}:
|
|
raise RuntimeErrorV2("RUNTIME_RESULT_STATUS_INVALID")
|
|
summary = str(value.get("summary") or "").strip()
|
|
if not summary or len(summary) > 8192:
|
|
raise RuntimeErrorV2("RUNTIME_RESULT_SUMMARY_INVALID")
|
|
verification = value.get("verification") or []
|
|
evidence = value.get("evidence") or {}
|
|
if not isinstance(verification, list) or len(verification) > 64:
|
|
raise RuntimeErrorV2("RUNTIME_VERIFICATION_INVALID")
|
|
if not isinstance(evidence, Mapping):
|
|
raise RuntimeErrorV2("RUNTIME_EVIDENCE_INVALID")
|
|
return cls(status, summary, tuple(dict(item) for item in verification if isinstance(item, Mapping)), dict(evidence))
|
|
|
|
|
|
def request_payload(
|
|
*,
|
|
adapter: str,
|
|
task_id: str,
|
|
goal: str,
|
|
instructions: str,
|
|
success_criteria: Sequence[str],
|
|
repository: str,
|
|
base_oid: str,
|
|
target_branch: str,
|
|
worktree: Path,
|
|
) -> dict[str, Any]:
|
|
if adapter not in {"hermes", "codex", "openclaw"}:
|
|
raise RuntimeErrorV2("ADAPTER_INVALID")
|
|
if not OID_RE.fullmatch(base_oid):
|
|
raise RuntimeErrorV2("BASE_OID_INVALID")
|
|
return {
|
|
"schema": "aios.online-agent.runtime-request.v1",
|
|
"adapter": adapter,
|
|
"task": {
|
|
"task_id": task_id,
|
|
"goal": goal,
|
|
"instructions": instructions,
|
|
"success_criteria": list(success_criteria),
|
|
},
|
|
"execution_context": {
|
|
"repository": repository,
|
|
"base_oid": base_oid,
|
|
"target_branch": target_branch,
|
|
"worktree": str(worktree),
|
|
},
|
|
"policy": {
|
|
"authoritative_repository_write": False,
|
|
"direct_git_ref_mutation": False,
|
|
"direct_git_push": False,
|
|
"direct_git_commit": False,
|
|
"main_write": False,
|
|
"force_push": False,
|
|
"production_write": False,
|
|
"tag_write": False,
|
|
"baseline_write": False,
|
|
"task_api_credential_visible": False,
|
|
"writer_lease_visible": False,
|
|
},
|
|
}
|
|
|
|
|
|
def invoke(socket_path: Path, payload: Mapping[str, Any], timeout_seconds: int) -> RuntimeResult:
|
|
if not socket_path.is_absolute():
|
|
raise RuntimeErrorV2("RUNTIME_SOCKET_NOT_ABSOLUTE")
|
|
if not 1 <= timeout_seconds <= 7200:
|
|
raise RuntimeErrorV2("RUNTIME_TIMEOUT_INVALID")
|
|
raw = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
if len(raw) > MAX_BYTES:
|
|
raise RuntimeErrorV2("RUNTIME_REQUEST_TOO_LARGE")
|
|
|
|
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
client.settimeout(timeout_seconds)
|
|
try:
|
|
client.connect(str(socket_path))
|
|
client.sendall(raw)
|
|
client.shutdown(socket.SHUT_WR)
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
while True:
|
|
chunk = client.recv(65536)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > MAX_BYTES:
|
|
raise RuntimeErrorV2("RUNTIME_RESPONSE_TOO_LARGE")
|
|
chunks.append(chunk)
|
|
except (OSError, socket.timeout) as exc:
|
|
raise RuntimeErrorV2("RUNTIME_UNAVAILABLE", details={"socket": str(socket_path)}) from exc
|
|
finally:
|
|
client.close()
|
|
try:
|
|
value = json.loads(b"".join(chunks).decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise RuntimeErrorV2("RUNTIME_RESPONSE_INVALID") from exc
|
|
if not isinstance(value, Mapping):
|
|
raise RuntimeErrorV2("RUNTIME_RESPONSE_INVALID")
|
|
result = RuntimeResult.parse(value)
|
|
if result.status == "BLOCKED":
|
|
raise RuntimeErrorV2("RUNTIME_BLOCKED", details={"summary": result.summary})
|
|
if result.status == "FAILED":
|
|
raise RuntimeErrorV2("RUNTIME_FAILED", details={"summary": result.summary})
|
|
return result
|