Files
.pouch/skills/agent-runtime/scripts/agent.py
T

481 lines
21 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Validate and execute the shared project Agent runtime contract.
The command surface is deliberately small. It never evaluates project text as
shell, and it only creates builder sessions from a fixed make invocation.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError as exc: # pragma: no cover - dependency is checked at runtime
raise SystemExit("需要 PyYAML: python3 -m pip install pyyaml") from exc
CLI_NAMES = {"codex", "cursor-agent", "grok", "omp", "pi"}
ADAPTERS = {"builder", "tester", "ack"}
EFFORTS = {None, "low", "medium", "high", "xhigh"}
PERMISSIONS = {"read-only", "workspace-write"}
TIERS = {"standard", "strong"}
LIFECYCLES = {"persistent", "ephemeral"}
ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
BINDING_RE = re.compile(r"^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$")
TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]{1,96}$")
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$")
MAKEFILE_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$")
FORBIDDEN_KEYS = {"command", "argv", "shell", "env", "environmentVariables", "token", "secret", "password"}
class ConfigError(ValueError):
pass
class UniqueLoader(yaml.SafeLoader):
pass
def _construct_mapping(loader: UniqueLoader, node: yaml.MappingNode, deep: bool = False) -> dict[str, Any]:
mapping: dict[str, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if not isinstance(key, str):
raise ConfigError("YAML mapping key 必须是字符串")
if key in mapping:
raise ConfigError(f"YAML 存在重复键: {key}")
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
def load_yaml(path: Path) -> dict[str, Any]:
try:
value = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueLoader)
except (OSError, yaml.YAMLError) as exc:
raise ConfigError(f"无法读取 {path}: {exc}") from exc
if value is None:
return {}
if not isinstance(value, dict):
raise ConfigError(f"{path} 顶层必须是 mapping")
return value
def deep_merge(base: dict[str, Any], override: dict[str, Any], where: str = "root") -> dict[str, Any]:
result = copy.deepcopy(base)
for key, value in override.items():
if key not in result:
raise ConfigError(f"{where}: local 配置不能增加未声明字段 {key!r}")
if isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value, f"{where}.{key}")
else:
result[key] = copy.deepcopy(value)
return result
def text(value: Any, where: str) -> str:
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise ConfigError(f"{where}: 必须是非空文本")
return value
def safe_id(value: Any, where: str) -> str:
value = text(value, where)
if not ID_RE.fullmatch(value):
raise ConfigError(f"{where}: ID 只能使用小写字母、数字和连字符")
return value
def safe_binding_id(value: Any, where: str) -> str:
value = text(value, where)
if not BINDING_RE.fullmatch(value):
raise ConfigError(f"{where}: binding 必须是 namespace.name 形式")
return value
def safe_path(value: Any, where: str) -> str:
value = text(value, where)
path = Path(value)
if not path.is_absolute() or value == os.path.sep or ".." in path.parts or os.path.normpath(value) != value:
raise ConfigError(f"{where}: 必须是规范绝对路径")
return value
def validate(config: dict[str, Any]) -> None:
if config.get("schemaVersion") != 1:
raise ConfigError("schemaVersion 必须为 1")
for key in ("locations", "profiles", "bindings"):
if not isinstance(config.get(key), dict) or not config[key]:
raise ConfigError(f"{key}: 必须是非空 mapping")
locations = config["locations"]
for location_id, location in locations.items():
safe_id(location_id, f"locations.{location_id}")
if not isinstance(location, dict):
raise ConfigError(f"locations.{location_id}: 必须是 mapping")
for required in ("environment", "project", "path"):
if required not in location:
raise ConfigError(f"locations.{location_id}: 缺少 {required}")
environment = text(location["environment"], f"locations.{location_id}.environment")
if environment != "local" and not ID_RE.fullmatch(environment):
raise ConfigError(f"locations.{location_id}.environment: 必须是 local 或安全环境名")
text(location["project"], f"locations.{location_id}.project")
safe_path(location["path"], f"locations.{location_id}.path")
host = location.get("host")
if host is not None:
host = text(host, f"locations.{location_id}.host")
if host != "local" and not re.fullmatch(r"runtime:[0-9a-f-]{16,}", host):
raise ConfigError(f"locations.{location_id}.host: 必须是 local 或 runtime:<environment-id>")
profiles = config["profiles"]
for profile_id, profile in profiles.items():
safe_id(profile_id, f"profiles.{profile_id}")
if not isinstance(profile, dict):
raise ConfigError(f"profiles.{profile_id}: 必须是 mapping")
for required in ("cli", "model", "reasoningEffort", "permissionMode"):
if required not in profile:
raise ConfigError(f"profiles.{profile_id}: 缺少 {required}")
if profile["cli"] not in CLI_NAMES:
raise ConfigError(f"profiles.{profile_id}.cli: 不支持 {profile['cli']!r}")
if profile["model"] is not None and MODEL_RE.fullmatch(str(profile["model"])) is None:
raise ConfigError(f"profiles.{profile_id}.model: 不是安全模型 ID")
if profile["reasoningEffort"] not in EFFORTS:
raise ConfigError(f"profiles.{profile_id}.reasoningEffort: 不支持该值")
if profile["permissionMode"] not in PERMISSIONS:
raise ConfigError(f"profiles.{profile_id}.permissionMode: 不支持该值")
if profile.get("tier", "standard") not in TIERS:
raise ConfigError(f"profiles.{profile_id}.tier: 必须是 standard/strong")
for binding_id, binding in config["bindings"].items():
safe_binding_id(binding_id, f"bindings.{binding_id}")
if not isinstance(binding, dict):
raise ConfigError(f"bindings.{binding_id}: 必须是 mapping")
for required in ("adapter", "location", "profile"):
if required not in binding:
raise ConfigError(f"bindings.{binding_id}: 缺少 {required}")
if binding["adapter"] not in ADAPTERS:
raise ConfigError(f"bindings.{binding_id}.adapter: 不支持 {binding['adapter']!r}")
location_id = safe_id(binding["location"], f"bindings.{binding_id}.location")
profile_id = safe_id(binding["profile"], f"bindings.{binding_id}.profile")
if location_id not in locations or profile_id not in profiles:
raise ConfigError(f"bindings.{binding_id}: location/profile 引用不存在")
if binding["adapter"] == "builder":
makefile = text(binding.get("makefile", "makefile.builder"), f"bindings.{binding_id}.makefile")
if Path(makefile).is_absolute() or ".." in Path(makefile).parts or MAKEFILE_RE.fullmatch(makefile) is None:
raise ConfigError(f"bindings.{binding_id}.makefile: 必须是项目内相对路径")
target = text(binding.get("target"), f"bindings.{binding_id}.target")
if TARGET_RE.fullmatch(target) is None:
raise ConfigError(f"bindings.{binding_id}.target: 不是安全 make target")
session = binding.get("session", {})
if not isinstance(session, dict) or session.get("lifecycle", "ephemeral") not in LIFECYCLES:
raise ConfigError(f"bindings.{binding_id}.session.lifecycle: 必须是 persistent/ephemeral")
scan_forbidden(config)
def scan_forbidden(value: Any, where: str = "root") -> None:
if isinstance(value, dict):
for key, child in value.items():
if key in FORBIDDEN_KEYS:
raise ConfigError(f"{where}: 禁止配置字段 {key!r}")
scan_forbidden(child, f"{where}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
scan_forbidden(child, f"{where}[{index}]")
def load_config(root: Path) -> dict[str, Any]:
path = root / ".pouch" / "agent.yaml"
if not path.is_file():
raise ConfigError(f"缺少 {path},请先创建项目通用 Agent 配置")
config = load_yaml(path)
local_path = root / ".pouch" / "agent.local.yaml"
if local_path.is_file():
config = deep_merge(config, load_yaml(local_path))
validate(config)
return config
def binding(config: dict[str, Any], binding_id: str) -> dict[str, Any]:
if binding_id not in config["bindings"]:
raise ConfigError(f"不存在 binding: {binding_id}")
value = copy.deepcopy(config["bindings"][binding_id])
value["id"] = binding_id
value["locationId"] = value["location"]
value["location"] = copy.deepcopy(config["locations"][value["locationId"]])
value["profileId"] = value["profile"]
value["profile"] = copy.deepcopy(config["profiles"][value["profileId"]])
return value
def orca_json(args: list[str], environment: str | None = None) -> dict[str, Any]:
command = ["orca", *args]
if environment and environment != "local":
command.extend(["--environment", environment])
command.append("--json")
proc = subprocess.run(command, capture_output=True, text=True, check=False)
raw = (proc.stdout or "").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
detail = (proc.stderr or raw or str(exc)).strip()
raise ConfigError(f"Orca 没有返回 JSON: {' '.join(command)}\n{detail}") from exc
if not data.get("ok"):
error = data.get("error") or {}
raise ConfigError(f"{' '.join(command)}: {error.get('message') or error.get('code') or 'unknown error'}")
result = data.get("result")
return result if isinstance(result, dict) else {}
def environment_id(name: str) -> str:
if name == "local":
return "local"
result = orca_json(["environment", "list"])
for item in result.get("environments", []):
if isinstance(item, dict) and item.get("name") == name:
return str(item.get("id"))
raise ConfigError(f"找不到已保存 Orca environment: {name}")
def location_host(location: dict[str, Any]) -> str:
host = location.get("host")
if host:
return str(host)
if location["environment"] == "local":
return "local"
return f"runtime:{environment_id(str(location['environment']))}"
def worktree_for(binding_value: dict[str, Any]) -> dict[str, Any]:
location = binding_value["location"]
environment = str(location["environment"])
result = orca_json(["worktree", "list"], environment)
candidates = []
for item in result.get("worktrees", []):
if not isinstance(item, dict):
continue
if str(item.get("projectId")) != str(location["project"]):
continue
if str(item.get("path")) != str(location["path"]):
continue
expected_host = location_host(location)
# A routed remote runtime stamps its own worktrees with hostId=local;
# the environment name/ID is the remote-machine discriminator.
if location["environment"] == "local" and expected_host == "local" and str(item.get("hostId")) != "local":
continue
candidates.append(item)
if len(candidates) != 1:
if not candidates:
raise ConfigError(
f"找不到精确 worktree: environment={environment}, host={location_host(location)}, "
f"project={location['project']}, path={location['path']};先创建或登记该 worktree"
)
raise ConfigError(f"binding 匹配到多个 worktree: {len(candidates)}")
return candidates[0]
def project_root_commit(root: Path) -> str | None:
proc = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, check=False)
return proc.stdout.strip() if proc.returncode == 0 else None
def run_dir(root: Path) -> Path:
path = root / ".pouch" / "agent" / "runs"
path.mkdir(parents=True, exist_ok=True)
return path
def run_path(root: Path, run_id: str) -> Path:
if not re.fullmatch(r"run_[0-9a-f]{16,64}", run_id):
raise ConfigError("run id 格式无效")
return run_dir(root) / f"{run_id}.json"
def cmd_validate(args: argparse.Namespace) -> int:
config = load_config(Path(args.root).resolve())
print(json.dumps({"ok": True, "schemaVersion": config["schemaVersion"], "bindings": sorted(config["bindings"])}, ensure_ascii=False, indent=2))
return 0
def cmd_show(args: argparse.Namespace) -> int:
config = load_config(Path(args.root).resolve())
value = binding(config, args.binding)
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
return 0
def cmd_worktree_resolve(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
worktree = worktree_for(value)
print(json.dumps({"binding": args.binding, "worktree": worktree}, ensure_ascii=False, indent=2))
return 0
def cmd_worktree_create(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
location = value["location"]
try:
existing = worktree_for(value)
except ConfigError as exc:
if "找不到精确 worktree" not in str(exc):
raise
existing = None
if existing:
print(json.dumps({"created": False, "binding": args.binding, "worktree": existing}, ensure_ascii=False, indent=2))
return 0
host = location_host(location)
command = ["worktree", "create", "--project", str(location["project"]), "--host", host, "--name", args.name, "--no-parent"]
if args.base:
command.extend(["--base-branch", args.base])
result = orca_json(command, str(location["environment"]))
print(json.dumps({"created": True, "binding": args.binding, "result": result, "next": "将返回 worktree 的 path 写回 location.path 后再运行 resolve"}, ensure_ascii=False, indent=2))
return 0
def make_command(value: dict[str, Any]) -> str:
return f"make -f {value.get('makefile', 'makefile.builder')} {value['target']}"
def cmd_run_start(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
if value["adapter"] != "builder":
raise ConfigError(f"run start 目前只为 builder 提供受控 make session;{value['adapter']} 请走对应 skill adapter")
if value.get("session", {}).get("lifecycle", "ephemeral") != "persistent":
raise ConfigError(f"binding {args.binding} 不是 persistent session")
worktree = worktree_for(value)
environment = str(value["location"]["environment"])
result = orca_json(["terminal", "create", "--worktree", f"id:{worktree['id']}", "--command", make_command(value), "--title", args.binding], environment)
terminal = result.get("terminal") or result
handle = terminal.get("handle") or result.get("handle")
if not handle:
raise ConfigError("Orca terminal create 没有返回 handle")
run_id = "run_" + uuid.uuid4().hex
record = {
"schemaVersion": 1,
"id": run_id,
"binding": args.binding,
"adapter": value["adapter"],
"profileId": value["profileId"],
"profileHash": hashlib.sha256(json.dumps(value["profile"], sort_keys=True).encode()).hexdigest(),
"environment": environment,
"project": value["location"]["project"],
"path": value["location"]["path"],
"worktreeId": worktree["id"],
"terminalHandle": handle,
"command": make_command(value),
"sourceCommit": project_root_commit(root),
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"status": "started",
}
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(record, ensure_ascii=False, indent=2))
if args.wait_ms:
wait = orca_json(["terminal", "wait", "--terminal", handle, "--for", "exit", "--timeout-ms", str(args.wait_ms)], environment)
record["wait"] = wait
record["status"] = "completed" if wait.get("exited") or wait.get("status") == "exited" else "waited"
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(wait, ensure_ascii=False, indent=2))
return 0
def load_run(root: Path, run_id: str) -> dict[str, Any]:
path = run_path(root, run_id)
if not path.is_file():
raise ConfigError(f"找不到 run 记录: {run_id}")
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ConfigError(f"run 记录无效: {run_id}")
return value
def cmd_run_status(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps({"run": record, "terminal": result}, ensure_ascii=False, indent=2))
return 0
def cmd_run_attach(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "read", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_run_resume(args: argparse.Namespace) -> int:
"""Explicitly reattach to a recorded terminal; never search for another one."""
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps({"resumed": True, "run": record, "terminal": result}, ensure_ascii=False, indent=2))
return 0
def cmd_run_stop(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "close", "--terminal", record["terminalHandle"]], record.get("environment"))
record["status"] = "stopped"
record["stoppedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
run_path(root, args.run).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"run": record, "close": result}, ensure_ascii=False, indent=2))
return 0
def parser() -> argparse.ArgumentParser:
root_parent = argparse.ArgumentParser(add_help=False)
root_parent.add_argument("--root", default=".")
parser = argparse.ArgumentParser(description="共享 Agent 配置、worktree 和 session 运行时")
sub = parser.add_subparsers(dest="command", required=True)
validate_parser = sub.add_parser("validate", parents=[root_parent])
validate_parser.set_defaults(func=cmd_validate)
show = sub.add_parser("show", parents=[root_parent])
show.add_argument("--binding", required=True)
show.set_defaults(func=cmd_show)
wt = sub.add_parser("worktree")
wt_sub = wt.add_subparsers(dest="worktree_command", required=True)
resolve = wt_sub.add_parser("resolve", parents=[root_parent])
resolve.add_argument("--binding", required=True)
resolve.set_defaults(func=cmd_worktree_resolve)
create = wt_sub.add_parser("create", parents=[root_parent])
create.add_argument("--binding", required=True)
create.add_argument("--name", required=True)
create.add_argument("--base", default="")
create.set_defaults(func=cmd_worktree_create)
run = sub.add_parser("run")
run_sub = run.add_subparsers(dest="run_command", required=True)
start = run_sub.add_parser("start", parents=[root_parent])
start.add_argument("--binding", required=True)
start.add_argument("--wait-ms", type=int, default=0)
start.set_defaults(func=cmd_run_start)
for name, func in (("status", cmd_run_status), ("attach", cmd_run_attach), ("resume", cmd_run_resume), ("stop", cmd_run_stop)):
item = run_sub.add_parser(name, parents=[root_parent])
item.add_argument("--run", required=True)
item.set_defaults(func=func)
return parser
if __name__ == "__main__":
try:
parsed = parser().parse_args()
raise SystemExit(parsed.func(parsed))
except ConfigError as exc:
print(f"environment-fail: {exc}", file=sys.stderr)
raise SystemExit(2)