#!/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 base64 import copy import gzip import hashlib import json import os import platform as host_platform import re import shlex import shutil import socket import subprocess import sys import time import uuid import urllib.error import urllib.parse import urllib.request 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"} RUNNER_OPERATIONS = {"platform_probe", "network_probe", "install", "start", "maestro", "maestro_batch", "artifact_download"} RUNNER_PLATFORMS = {"android", "ios"} LOCAL_NODE_NAMES = {"local", "localhost", "127.0.0.1", "::1"} 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:") 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") # A remote terminal read is a poll, not a blocking operation. Some Orca # providers keep the CLI open when a PTY has no new output, so bound the # read and let the caller poll again. Other commands may legitimately # take longer (for example terminal creation), hence the larger default. timeout = 10 if args[:2] == ["terminal", "read"] else 120 try: proc = subprocess.run(command, capture_output=True, text=True, check=False, timeout=timeout) except subprocess.TimeoutExpired as exc: if args[:2] == ["terminal", "read"]: return {} raise ConfigError(f"Orca command timed out after {timeout}s: {' '.join(command)}") from exc 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"]) project_ids = {str(location["project"])} projects = orca_json(["project", "list"], environment).get("projects", []) for project in projects: if not isinstance(project, dict): continue if str(project.get("id")) == str(location["project"]) or str(project.get("displayName")) == str(location["project"]): project_ids.update(str(repo_id) for repo_id in project.get("sourceRepoIds", []) if repo_id) result = orca_json(["worktree", "list"], environment) candidates = [] for item in result.get("worktrees", []): if not isinstance(item, dict): continue item_project_ids = {str(item.get("projectId")), str(item.get("repoId"))} if not project_ids.intersection(item_project_ids): 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 not candidates: raise ConfigError( f"找不到精确 worktree: environment={environment}, host={location_host(location)}, " f"project={location['project']}, path={location['path']};先创建或登记该 worktree" ) if len(candidates) > 1: # Orca can retain duplicate records for the same physical worktree # after an environment is paired again. They are safe to collapse only # when every execution-relevant field is identical; divergent records # must still fail closed instead of guessing a node or checkout. identity = {(str(item.get("path")), str(item.get("projectId")), str(item.get("head")), str(item.get("hostId"))) for item in candidates} if len(identity) == 1: return candidates[0] 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: # Replace the shell so terminal wait observes the make exit code instead # of waiting forever on the interactive shell after make returns. return f"exec make -f {value.get('makefile', 'makefile.builder')} {value['target']}" def runner_response(status: str, observed: Any = "", *, checks: dict[str, Any] | None = None, remediation: str = "", **extra: Any) -> dict[str, Any]: """Build the JSON response shared with test-preflight's runner contract.""" response: dict[str, Any] = {"status": status, "observed": observed} if checks: response["checks"] = checks if remediation: response["remediation"] = remediation response.update(extra) return response def run_fixed_command(command: list[str], *, cwd: str | None = None) -> tuple[int, str]: """Run an adapter-owned argv; never invoke a shell or print command output.""" try: process = subprocess.run( command, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) except OSError as exc: return 127, str(exc) return process.returncode, (process.stdout or "").strip() def _runner_check(status: str, observed: Any, expected: str, remediation: str = "") -> dict[str, Any]: value: dict[str, Any] = {"status": status, "observed": observed, "expected": expected} if remediation: value["remediation"] = remediation return value def _local_platform_probe(platform_name: str) -> dict[str, Any]: if platform_name == "android": adb_code, adb_output = run_fixed_command(["adb", "version"]) if adb_code != 0: failure = "adb unavailable on the local tester node" return runner_response("environment-fail", failure, checks={ "adb": _runner_check("environment-fail", failure, "adb responds", "Install or expose adb on the tester node"), "device": _runner_check("environment-fail", "not checked", "authorized Android device", "Make adb available first"), "boot": _runner_check("environment-fail", "not checked", "Android device boot complete", "Make adb available first"), }) devices_code, devices_output = run_fixed_command(["adb", "devices"]) devices: list[str] = [] if devices_code == 0: for line in devices_output.splitlines(): if "\tdevice" in line: devices.append(line.split("\t", 1)[0].strip()) if devices_code != 0 or not devices: observed = "adb responded but no authorized device is available" return runner_response("environment-fail", observed, checks={ "adb": _runner_check("ready" if devices_code == 0 else "environment-fail", "adb responded" if devices_code == 0 else "adb devices failed", "adb responds"), "device": _runner_check("environment-fail", observed, "authorized Android device", "Connect or start an authorized tester device"), "boot": _runner_check("environment-fail", "not checked", "Android device boot complete", "Provide a booted authorized device"), }) serial = devices[0] boot_code, boot_output = run_fixed_command(["adb", "-s", serial, "shell", "getprop", "sys.boot_completed"]) booted = boot_code == 0 and boot_output.strip() == "1" return runner_response("ready" if booted else "environment-fail", "adb/device probe completed", checks={ "adb": _runner_check("ready", "adb responded", "adb responds"), "device": _runner_check("ready", "authorized", "authorized Android device"), "boot": _runner_check("ready" if booted else "environment-fail", "boot complete" if booted else "device is not booted", "Android device boot complete", "Boot the Android tester device" if not booted else ""), }) if platform_name != "ios": return runner_response("blocked", "unsupported platform", remediation="Use platform android or ios") if host_platform.system() != "Darwin": observed = "iOS runner requires a macOS tester node; xcrun was not invoked" return runner_response("environment-fail", observed, checks={ "macos": _runner_check("environment-fail", observed, "Darwin tester node", "Route iOS tests to a Mac tester binding"), "xcrun": _runner_check("environment-fail", "not invoked", "xcrun available", "Route iOS tests to a Mac tester binding"), "simctl": _runner_check("environment-fail", "not invoked", "simctl available", "Route iOS tests to a Mac tester binding"), "boot": _runner_check("environment-fail", "not checked", "booted iOS simulator", "Route iOS tests to a Mac tester binding"), }) xcrun_code, _ = run_fixed_command(["xcrun", "--version"]) simctl_code, simctl_output = run_fixed_command(["xcrun", "simctl", "list", "devices", "--json"]) booted = False if simctl_code == 0: try: data = json.loads(simctl_output) booted = any( isinstance(device, dict) and device.get("state") == "Booted" for devices in (data.get("devices") or {}).values() if isinstance(devices, list) for device in devices ) except (TypeError, json.JSONDecodeError): booted = False status = "ready" if xcrun_code == 0 and simctl_code == 0 and booted else "environment-fail" return runner_response(status, "macOS/simctl probe completed", checks={ "macos": _runner_check("ready", "Darwin", "Darwin tester node"), "xcrun": _runner_check("ready" if xcrun_code == 0 else "environment-fail", "available" if xcrun_code == 0 else "unavailable", "xcrun available"), "simctl": _runner_check("ready" if simctl_code == 0 else "environment-fail", "available" if simctl_code == 0 else "unavailable", "simctl available"), "boot": _runner_check("ready" if booted else "environment-fail", "booted simulator" if booted else "no booted simulator", "booted iOS simulator", "Boot an iOS simulator" if not booted else ""), }) def _network_probe(request: dict[str, Any]) -> dict[str, Any]: raw_url = request.get("url") if not isinstance(raw_url, str) or not raw_url.strip(): return runner_response("blocked", "network_probe requires url", remediation="Provide an http(s) URL") parsed = urllib.parse.urlsplit(raw_url) if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: return runner_response("blocked", "URL must be http(s) without embedded credentials", remediation="Provide a credential-free probe URL") try: port = parsed.port or (443 if parsed.scheme == "https" else 80) except ValueError: return runner_response("blocked", "URL contains an invalid port", remediation="Provide a valid http(s) probe URL") try: addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) except (OSError, ValueError) as exc: return runner_response("environment-fail", "DNS probe failed", checks={ "dns": _runner_check("environment-fail", str(exc), "resolvable hostname", "Check tester-node DNS/VPN") }) checks: dict[str, Any] = {"dns": _runner_check("ready", sorted({item[4][0] for item in addresses}), "resolvable hostname")} try: import socket as _socket with _socket.create_connection((parsed.hostname, port), timeout=5): pass checks["tcp"] = _runner_check("ready", f"{parsed.hostname}:{port}", "TCP connection") except OSError as exc: checks["tcp"] = _runner_check("environment-fail", str(exc), f"TCP {parsed.hostname}:{port}", "Check the tester-node route and firewall") return runner_response("environment-fail", "TCP probe failed", checks=checks) try: with urllib.request.urlopen(urllib.request.Request(raw_url, method="GET"), timeout=5) as response: code = response.status except urllib.error.HTTPError as exc: code = exc.code except (OSError, urllib.error.URLError) as exc: checks["http"] = _runner_check("environment-fail", str(exc), "HTTP response", "Check service listener, proxy and TLS") return runner_response("environment-fail", "HTTP probe failed", checks=checks) expected = request.get("probe", {}).get("expected_status") if isinstance(request.get("probe"), dict) else None matches = not isinstance(expected, list) or code in expected checks["http"] = _runner_check("ready" if 200 <= code < 500 and matches else "environment-fail", code, expected or "HTTP 2xx-4xx", "Check service health or expected_status" if not matches or code >= 500 else "") return runner_response("ready" if checks["http"]["status"] == "ready" else "environment-fail", "DNS/TCP/HTTP probe completed", checks=checks) def _request_artifact(request: dict[str, Any]) -> tuple[Path | None, str | None]: value = request.get("artifact") if not isinstance(value, str) or not value.strip(): return None, "install requires an artifact path" path = Path(value) if not path.is_absolute() or ".." in path.parts or path == Path("/"): return None, "artifact must be a safe absolute path" return path, None def _request_flow(request: dict[str, Any]) -> tuple[str | None, str | None]: flow = request.get("flow") if not isinstance(flow, str) or not flow.strip(): return None, "maestro requires a non-empty flow" if len(flow.encode("utf-8")) > 120_000: return None, "maestro flow is too large" env = request.get("env", {}) if env is not None: if not isinstance(env, dict) or any( not isinstance(key, str) or not re.fullmatch(r"[A-Z][A-Z0-9_]{0,63}", key) or not isinstance(value, str) or len(value) > 4096 for key, value in env.items() ): return None, "maestro env must contain safe uppercase names and text values" return flow, None def _maestro_env(request: dict[str, Any]) -> dict[str, str]: values = request.get("env") if isinstance(request.get("env"), dict) else {} return {str(key): str(value) for key, value in values.items()} def _local_maestro(platform_name: str, request: dict[str, Any]) -> dict[str, Any]: flow, error = _request_flow(request) if error: return runner_response("blocked", error, remediation="Generate a bounded Maestro flow from the catalog case") if platform_name != "android": return runner_response("blocked", "local Maestro runner only supports Android", remediation="Route iOS Maestro to tester.ios") maestro = Path("/home/ace/.maestro/bin/maestro") if not maestro.is_file(): maestro_path = shutil.which("maestro") maestro = Path(maestro_path) if maestro_path else maestro if not maestro.is_file(): return runner_response("environment-fail", "maestro is not installed on the local tester node", remediation="Install Maestro and rerun test-preflight") device_code, device_output = run_fixed_command(["adb", "devices"]) serial = next((line.split("\t", 1)[0].strip() for line in device_output.splitlines() if "\tdevice" in line), "") if device_code == 0 else "" if not serial: return runner_response("environment-fail", "no authorized Android device for Maestro") flow_path = Path("/tmp") / f"pouch-maestro-{uuid.uuid4().hex}.yaml" flow_path.write_text(flow, encoding="utf-8") try: command = [str(maestro), "test", "--device", serial, "--no-color", str(flow_path)] for key, value in _maestro_env(request).items(): command.extend(["-e", f"{key}={value}"]) env = os.environ.copy() env["MAESTRO_CLI_NO_ANALYTICS"] = "true" process = subprocess.run(command, capture_output=True, text=True, check=False, timeout=float(request.get("timeout") or 180), env=env) output = ((process.stdout or "") + ("\n" + process.stderr if process.stderr else "")).strip()[-6000:] return runner_response("ready", "maestro completed", checks={"maestro": _runner_check("ready", output, "Maestro flow executed")}, test_result="pass" if process.returncode == 0 else "fail", exit=process.returncode, output=output) except subprocess.TimeoutExpired as exc: return runner_response("ready", "maestro timed out", checks={"maestro": _runner_check("ready", "timeout", "Maestro flow executed")}, test_result="environment-fail", exit=124, output=str(exc)) finally: flow_path.unlink(missing_ok=True) def _local_mutation(platform_name: str, operation: str, request: dict[str, Any]) -> dict[str, Any]: if platform_name == "ios": return runner_response("blocked", "local iOS mutation requires an explicit Mac transport", remediation="Use a non-local tester binding with a supported node transport") if platform_name != "android": return runner_response("blocked", "unsupported platform", remediation="Use platform android or ios") path, error = _request_artifact(request) if operation == "install" else (None, None) identity = request.get("identity") if operation == "install" and error: return runner_response("blocked", error, remediation="Provide the caller-supplied APK path") if not isinstance(identity, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,192}", identity): return runner_response("blocked", "identity must be a safe Android package id", remediation="Provide the declared artifact package") if operation == "install" and not path.is_file(): return runner_response("blocked", "artifact does not exist", remediation="Provide an existing caller-supplied APK") probe = _local_platform_probe("android") if probe.get("status") != "ready": return probe device_code, device_output = run_fixed_command(["adb", "devices"]) serial = next((line.split("\t", 1)[0].strip() for line in device_output.splitlines() if "\tdevice" in line), "") if device_code == 0 else "" if not serial: return runner_response("environment-fail", "no authorized Android device") command = ["adb", "-s", serial, "install", "-r", str(path)] if operation == "install" else ["adb", "-s", serial, "shell", "monkey", "-p", identity, "1"] code, _ = run_fixed_command(command) if code != 0: return runner_response("environment-fail", f"{operation} command failed", remediation=f"Inspect the tester-node {operation} evidence") return runner_response("ready", f"{operation} completed", checks={operation: _runner_check("ready", "completed", "operation succeeded")}) REMOTE_RUNNER_SOURCE = r''' import base64, gzip, json, os, re, shutil, socket, subprocess, sys, urllib.error, urllib.parse, urllib.request, uuid, zipfile from pathlib import Path request_bytes = base64.b64decode(sys.stdin.read()) try: request = json.loads(request_bytes.decode("utf-8")) except UnicodeDecodeError: request = json.loads(gzip.decompress(request_bytes).decode("utf-8")) marker = request.pop("_marker") def check(status, observed, expected, remediation=""): item = {"status": status, "observed": observed, "expected": expected} if remediation: item["remediation"] = remediation return item def run(argv, env=None, timeout=180): try: proc = subprocess.run(argv, env=env, timeout=timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False) except subprocess.TimeoutExpired as exc: return 124, (str(exc) or "command timed out").strip() except OSError as exc: return 127, str(exc).strip() return proc.returncode, (proc.stdout or "").strip() def response(status, observed, checks=None, remediation=""): item = {"status": status, "observed": observed} if checks: item["checks"] = checks if remediation: item["remediation"] = remediation return item def ios_platform(): uname_code, uname_output = run(["uname", "-s"]) darwin = uname_code == 0 and uname_output.splitlines()[-1:] == ["Darwin"] checks = {"macos": check("ready" if darwin else "environment-fail", uname_output or "unavailable", "Darwin tester node", "Route iOS tests to the Mac tester node" if not darwin else "")} if not darwin: checks.update({ "xcrun": check("environment-fail", "not invoked", "xcrun available", "Use a macOS tester node"), "simctl": check("environment-fail", "not invoked", "simctl available", "Use a macOS tester node"), "boot": check("environment-fail", "not checked", "booted iOS simulator", "Use a macOS tester node"), }) return response("environment-fail", "remote node is not macOS", checks) xcrun_code, xcrun_output = run(["xcrun", "--version"]) simctl_code, simctl_output = run(["xcrun", "simctl", "list", "devices", "--json"]) booted = False if simctl_code == 0: try: data = json.loads(simctl_output) booted = any( isinstance(device, dict) and device.get("state") == "Booted" for devices in (data.get("devices") or {}).values() if isinstance(devices, list) for device in devices ) except (TypeError, json.JSONDecodeError): booted = False checks.update({ "xcrun": check("ready" if xcrun_code == 0 else "environment-fail", xcrun_output or "unavailable", "xcrun available", "Install/select Xcode command-line tools" if xcrun_code else ""), "simctl": check("ready" if simctl_code == 0 else "environment-fail", "available" if simctl_code == 0 else simctl_output, "simctl available", "Check Xcode simulator runtime" if simctl_code else ""), "boot": check("ready" if booted else "environment-fail", "booted simulator" if booted else "no booted simulator", "booted iOS simulator", "Boot an iOS simulator" if not booted else ""), }) status = "ready" if all(item["status"] == "ready" for item in checks.values()) else "environment-fail" return response(status, "macOS/xcrun/simctl probe completed", checks) def network_probe(): raw_url = request.get("url") parsed = urllib.parse.urlsplit(raw_url or "") if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: return response("blocked", "URL must be http(s) without embedded credentials", remediation="Provide a credential-free probe URL") port = parsed.port or (443 if parsed.scheme == "https" else 80) try: addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) except (OSError, ValueError) as exc: return response("environment-fail", "DNS probe failed", {"dns": check("environment-fail", str(exc), "resolvable hostname", "Check tester-node DNS/VPN")}) checks = {"dns": check("ready", sorted({item[4][0] for item in addresses}), "resolvable hostname")} try: with socket.create_connection((parsed.hostname, port), timeout=5): pass checks["tcp"] = check("ready", f"{parsed.hostname}:{port}", "TCP connection") except OSError as exc: checks["tcp"] = check("environment-fail", str(exc), f"TCP {parsed.hostname}:{port}", "Check tester-node route and firewall") return response("environment-fail", "TCP probe failed", checks) try: with urllib.request.urlopen(urllib.request.Request(raw_url, method="GET"), timeout=5) as result: code = result.status except urllib.error.HTTPError as exc: code = exc.code except (OSError, urllib.error.URLError) as exc: checks["http"] = check("environment-fail", str(exc), "HTTP response", "Check service listener, proxy and TLS") return response("environment-fail", "HTTP probe failed", checks) expected = request.get("probe", {}).get("expected_status") if isinstance(request.get("probe"), dict) else None matches = not isinstance(expected, list) or code in expected checks["http"] = check("ready" if 200 <= code < 500 and matches else "environment-fail", code, expected or "HTTP 2xx-4xx", "Check service health or expected_status" if not matches or code >= 500 else "") return response("ready" if checks["http"]["status"] == "ready" else "environment-fail", "DNS/TCP/HTTP probe completed", checks) operation = request.get("operation") if operation == "platform_probe" and request.get("platform") == "ios": result = ios_platform() elif operation == "network_probe": result = network_probe() elif operation == "maestro" and request.get("platform") == "ios": flow = request.get("flow") values = request.get("env") if isinstance(request.get("env"), dict) else {} if not isinstance(flow, str) or not flow.strip() or len(flow.encode("utf-8")) > 120000: result = response("blocked", "maestro requires a bounded non-empty flow") elif any(not isinstance(key, str) or not key.isidentifier() or not key.isupper() or not isinstance(value, str) or len(value) > 4096 for key, value in values.items()): result = response("blocked", "maestro env must contain uppercase text values") else: flow_path = Path("/tmp") / ("pouch-maestro-" + uuid.uuid4().hex + ".yaml") try: flow_path.write_text(flow, encoding="utf-8") env = os.environ.copy() env["PATH"] = "/Users/laily/bin:/Users/laily/.maestro/bin:/opt/homebrew/bin:" + env.get("PATH", "") env["JAVA_HOME"] = "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" env["MAESTRO_CLI_NO_ANALYTICS"] = "true" device = "booted" sim_code, sim_output = run(["xcrun", "simctl", "list", "devices", "--json"], env=env, timeout=30) if sim_code == 0: try: sim_data = json.loads(sim_output) booted = [ item.get("udid") for devices in (sim_data.get("devices") or {}).values() if isinstance(devices, list) for item in devices if isinstance(item, dict) and item.get("state") == "Booted" and item.get("udid") ] if booted: device = str(booted[0]) # Maestro's iOS driver uses IDB for simulator I/O. The # companion is deliberately connected through the user # local path; Homebrew cannot create /usr/local/bin here. run(["idb", "connect", device, "--json"], env=env, timeout=20) except (TypeError, json.JSONDecodeError): pass command = ["/Users/laily/.maestro/bin/maestro", "test", "--device", device, "--no-color", str(flow_path)] for key, value in values.items(): command.extend(["-e", key + "=" + value]) code, output = run(command, env=env, timeout=float(request.get("timeout") or 180)) result = response("ready" if code not in {124, 127} else "environment-fail", "maestro completed" if code == 0 else ("maestro command unavailable" if code == 127 else "maestro timed out"), {"maestro": check("ready" if code == 0 else ("environment-fail" if code in {124, 127} else "ready"), output[-6000:], "Maestro flow executed")}) result.update({"test_result": "pass" if code == 0 else ("environment-fail" if code in {124, 127} else "fail"), "exit": code, "output": output[-6000:]}) finally: try: flow_path.unlink() except OSError: pass elif operation == "maestro_batch" and request.get("platform") == "ios": cases = request.get("cases") if not isinstance(cases, list) or not cases or len(cases) > 64: result = response("blocked", "maestro_batch requires 1-64 cases") else: values = request.get("env", {}) if not isinstance(values, dict) or any(not isinstance(key, str) or not key.isidentifier() or not key.isupper() or not isinstance(value, str) or len(value) > 4096 for key, value in values.items()): result = response("blocked", "maestro env must contain uppercase text values") else: env = os.environ.copy() env["PATH"] = "/Users/laily/bin:/Users/laily/.maestro/bin:/opt/homebrew/bin:" + env.get("PATH", "") env["JAVA_HOME"] = "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" env["MAESTRO_CLI_NO_ANALYTICS"] = "true" device = "booted" sim_code, sim_output = run(["xcrun", "simctl", "list", "devices", "--json"], env=env, timeout=30) if sim_code == 0: try: sim_data = json.loads(sim_output) booted = [ item.get("udid") for devices in (sim_data.get("devices") or {}).values() if isinstance(devices, list) for item in devices if isinstance(item, dict) and item.get("state") == "Booted" and item.get("udid") ] if booted: device = str(booted[0]) run(["idb", "connect", device, "--json"], env=env, timeout=20) except (TypeError, json.JSONDecodeError): pass results = [] for item in cases: case_id = item.get("id") if isinstance(item, dict) else None flow = item.get("flow") if isinstance(item, dict) else None case_env = item.get("env", values) if isinstance(item, dict) else values if not isinstance(case_id, str) or not case_id or not isinstance(flow, str) or not flow.strip() or len(flow.encode("utf-8")) > 120000: results.append({"id": str(case_id or ""), "test_result": "environment-fail", "exit": 2, "output": "invalid maestro batch case"}) continue if not isinstance(case_env, dict) or any(not isinstance(key, str) or not key.isidentifier() or not key.isupper() or not isinstance(value, str) or len(value) > 4096 for key, value in case_env.items()): results.append({"id": case_id, "test_result": "environment-fail", "exit": 2, "output": "invalid maestro batch env"}) continue flow_path = Path("/tmp") / ("pouch-maestro-batch-" + uuid.uuid4().hex + ".yaml") try: flow_path.write_text(flow, encoding="utf-8") command = ["/Users/laily/.maestro/bin/maestro", "test", "--device", device, "--no-color", str(flow_path)] for key, value in case_env.items(): command.extend(["-e", key + "=" + value]) code, output = run(command, env=env, timeout=float(request.get("case_timeout") or 180)) test_result = "pass" if code == 0 else ("environment-fail" if code in {124, 127} else "fail") results.append({"id": case_id, "test_result": test_result, "exit": code, "output": output[-6000:]}) except Exception as exc: results.append({"id": case_id, "test_result": "environment-fail", "exit": 124, "output": str(exc)}) finally: try: flow_path.unlink() except OSError: pass has_env_failure = any(item.get("test_result") == "environment-fail" for item in results) result = response("environment-fail" if has_env_failure else "ready", "maestro batch completed", {"maestro": check("environment-fail" if has_env_failure else "ready", f"{len(results)} cases", "Maestro batch executed")}) result.update({"results": results}) elif operation == "artifact_download": url = request.get("url") destination = request.get("destination") if not isinstance(url, str) or not url.startswith("https://git.duguying.net/"): result = response("blocked", "artifact_download only accepts the configured Gitea host") elif not isinstance(destination, str) or not destination.startswith("/Users/laily/orca/workspaces/app/"): result = response("blocked", "artifact destination must stay under the Mac tester artifact root") else: dest = Path(destination) archive = dest.with_suffix(".artifact.zip") try: config_paths = [ Path.home() / ".config" / "tea" / "config.yml", Path("/Users/duguying/.config/tea/config.yml"), ] config_path = next((item for item in config_paths if item.is_file()), None) if config_path is None: raise RuntimeError("Mac mini tea login for git.duguying.net is missing") config_text = config_path.read_text(encoding="utf-8") token_match = re.search(r"name:\s*git\.duguying\.net[\s\S]*?token:\s*([^\s]+)", config_text) token = token_match.group(1) if token_match else "" if not token: raise RuntimeError(f"Mac mini tea login for git.duguying.net is missing in {config_path}") request_obj = urllib.request.Request(url, headers={"Authorization": f"token {token}", "User-Agent": "pouch-tester/1"}) dest.parent.mkdir(parents=True, exist_ok=True) with urllib.request.urlopen(request_obj, timeout=120) as source, archive.open("wb") as target: shutil.copyfileobj(source, target) with zipfile.ZipFile(archive) as artifact_archive: nested = next((name for name in artifact_archive.namelist() if name.endswith("ios-simulator.zip")), None) if not nested: raise RuntimeError("Gitea artifact does not contain ios-simulator.zip") nested_bytes = artifact_archive.read(nested) nested_archive = dest.with_suffix(".ios-simulator.zip") nested_archive.write_bytes(nested_bytes) with zipfile.ZipFile(nested_archive) as app_archive: app_archive.extractall(dest.parent) app = dest.parent / "Writes.app" if not app.is_dir() or not (app / "Info.plist").is_file(): raise RuntimeError("downloaded iOS artifact does not contain Writes.app/Info.plist") result = response("ready", "iOS simulator artifact downloaded", {"artifact": check("ready", str(app), "remote artifact present")}) except Exception as exc: result = response("environment-fail", "iOS simulator artifact download failed", remediation=str(exc)) elif operation in {"install", "start"} and request.get("platform") == "ios": identity = request.get("identity") artifact = request.get("artifact") if operation == "install": if not isinstance(artifact, str) or not (Path(artifact).is_file() or Path(artifact).is_dir()): result = response("environment-fail", "remote iOS artifact is not present", remediation="Provide artifact.remote_path on the Mac tester node") else: sim_code, sim_output = run(["xcrun", "simctl", "list", "devices", "--json"]) device = "booted" if sim_code == 0: try: sim_data = json.loads(sim_output) booted = [ item.get("udid") for devices in (sim_data.get("devices") or {}).values() if isinstance(devices, list) for item in devices if isinstance(item, dict) and item.get("state") == "Booted" and item.get("udid") ] if booted: device = str(booted[0]) except (TypeError, json.JSONDecodeError): pass code, output = run(["xcrun", "simctl", "install", device, artifact]) result = response("ready" if code == 0 else "environment-fail", "iOS app installed" if code == 0 else output, {"install": check("ready" if code == 0 else "environment-fail", output or "installed", "simctl install succeeds", "Check IPA and simulator" if code else "")}) elif not isinstance(identity, str) or not identity: result = response("blocked", "start requires bundle id") else: code, output = run(["xcrun", "simctl", "launch", "booted", identity]) result = response("ready" if code == 0 else "environment-fail", "iOS app started" if code == 0 else output, {"start": check("ready" if code == 0 else "environment-fail", output or "started", "simctl launch succeeds", "Check bundle id and installed app" if code else "")}) else: result = response("blocked", "unsupported remote runner operation", remediation="Use iOS platform_probe, network_probe, install, or start") print(marker + base64.b64encode(json.dumps(result, separators=(",", ":")).encode("utf-8")).decode("ascii")) ''' def _terminal_output(result: dict[str, Any]) -> str: terminal = result.get("terminal") if isinstance(result, dict) else None if not isinstance(terminal, dict): terminal = result tail = terminal.get("tail") if isinstance(terminal, dict) else None if isinstance(tail, list): return "\n".join(str(item) for item in tail) if isinstance(tail, str): return tail output = terminal.get("output") if isinstance(terminal, dict) else None return output if isinstance(output, str) else "" def remote_runner_transport(binding_value: dict[str, Any], request: dict[str, Any], worktree: dict[str, Any]) -> dict[str, Any]: environment = str(binding_value["location"]["environment"]) marker = "POUCH_RUNNER_RESULT_" + uuid.uuid4().hex source = REMOTE_RUNNER_SOURCE source_b64 = base64.b64encode(gzip.compress(source.encode("utf-8"), mtime=0)).decode("ascii") payload = dict(request) payload["_marker"] = marker payload_b64 = base64.b64encode(gzip.compress(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), mtime=0)).decode("ascii") handle = "" try: def send_text(text: str) -> None: # Keep each PTY write below the provider's line-editor limit, then # submit it explicitly. Long base64 lines are accepted as input # but may remain unsubmitted in fish. orca_json(["terminal", "send", "--terminal", handle, "--text", text], environment) orca_json(["terminal", "send", "--terminal", handle, "--text", "\r"], environment) # The remote PTY acknowledges accepted bytes before fish has # drained its input buffer. Leave room between chunks so no # base64 data is dropped on slower Mac sessions. time.sleep(0.2) created = orca_json(["terminal", "create", "--worktree", f"id:{worktree['id']}", "--title", "tester-node-runner"], environment) terminal = created.get("terminal") if isinstance(created, dict) else None handle = str(terminal.get("handle")) if isinstance(terminal, dict) and terminal.get("handle") else "" if not handle: return runner_response("environment-fail", "remote runner terminal was not created", remediation="Check the Orca macmini terminal service") remote_base = f"/tmp/pouch-remote-runner-{uuid.uuid4().hex}" source_file = remote_base + ".b64" payload_file = remote_base + ".payload.b64" send_text(f": > {source_file}") for offset in range(0, len(source_b64), 400): send_text(f"printf '%s' '{source_b64[offset:offset + 400]}' >> {source_file}") send_text( "python3 -c 'import base64,gzip;open(\"%s.py\",\"wb\").write(gzip.decompress(base64.b64decode(open(\"%s\",\"rb\").read())))'" % (remote_base, source_file) ) send_text(f": > {payload_file}") for offset in range(0, len(payload_b64), 400): send_text(f"printf '%s' '{payload_b64[offset:offset + 400]}' >> {payload_file}") # The embedded runner accepts base64(gzip(JSON)) on stdin directly. send_text(f"python3 {remote_base}.py < {payload_file}") runner_wait_ms = min( 900000, max(15000, int(float(request.get("timeout") or request.get("case_timeout") or 180) * 1000) + 15000), ) output = "" deadline = time.monotonic() + runner_wait_ms / 1000 while time.monotonic() < deadline: output = _terminal_output(orca_json(["terminal", "read", "--terminal", handle, "--limit", "20000"], environment)) for line in reversed(output.splitlines()): if line.startswith(marker): try: result = json.loads(base64.b64decode(line[len(marker):]).decode("utf-8")) except (ValueError, json.JSONDecodeError): break if isinstance(result, dict): result.setdefault("checks", {}) result["checks"].setdefault("binding", _runner_check("ready", binding_value["id"], "tester binding resolved")) result["checks"].setdefault("worktree", _runner_check("ready", worktree.get("path", "resolved"), "exact Orca worktree")) return result time.sleep(1) return runner_response("environment-fail", "remote runner did not return a protocol result", checks={ "binding": _runner_check("ready", binding_value["id"], "tester binding resolved"), "worktree": _runner_check("ready", worktree.get("path", "resolved"), "exact Orca worktree"), "transport": _runner_check("environment-fail", output[-1000:], "node runner protocol result", "Inspect the macmini terminal output"), }) except (ConfigError, OSError) as exc: return runner_response("environment-fail", "remote runner transport failed", remediation=str(exc)) finally: if handle: try: orca_json(["terminal", "close", "--terminal", handle], environment) except ConfigError: pass def execute_runner_request(root: Path, request: Any) -> dict[str, Any]: if not isinstance(request, dict): return runner_response("blocked", "runner request must be a JSON object", remediation="Send one JSON object on stdin") binding_id = request.get("binding") operation = request.get("operation") platform_name = request.get("platform") if not isinstance(binding_id, str) or not BINDING_RE.fullmatch(binding_id): return runner_response("blocked", "binding must be a safe namespace.name", remediation="Use the resolved tester. binding") if not isinstance(operation, str) or operation not in RUNNER_OPERATIONS: return runner_response("blocked", "unknown runner operation", remediation="Use platform_probe, network_probe, install, or start") if not isinstance(platform_name, str) or platform_name not in RUNNER_PLATFORMS: return runner_response("blocked", "platform must be android or ios", remediation="Use the matrix platform") try: value = binding(load_config(root), binding_id) except (ConfigError, OSError) as exc: return runner_response("blocked", "tester binding could not be resolved", remediation=str(exc)) if value.get("adapter") != "tester": return runner_response("blocked", "binding is not a tester binding", remediation="Use a binding with adapter: tester") location = value["location"] environment = str(location.get("environment")) requested_node = request.get("node") if requested_node is not None and (not isinstance(requested_node, str) or not requested_node.strip()): return runner_response("blocked", "node must be non-empty text when provided") if environment == "local": if requested_node and requested_node not in LOCAL_NODE_NAMES: return runner_response("blocked", "local binding does not match requested tester node", remediation="Use node: local or a local alias") if operation == "platform_probe": return _local_platform_probe(platform_name) if operation == "network_probe": return _network_probe(request) if operation == "maestro": return _local_maestro(platform_name, request) return _local_mutation(platform_name, operation, request) try: worktree = worktree_for(value) except ConfigError as exc: return runner_response("blocked", "remote tester worktree could not be resolved", checks={ "binding": _runner_check("ready", binding_id, "tester binding resolved"), "worktree": _runner_check("blocked", "unresolved", "exact Orca worktree", str(exc)), }, remediation="Register one exact environment/host/project/path worktree before using the remote tester") return remote_runner_transport(value, request, worktree) def cmd_run_exec(args: argparse.Namespace) -> int: root = Path(args.root).resolve() try: request = json.load(sys.stdin) except (json.JSONDecodeError, OSError): response = runner_response("blocked", "runner stdin is not one valid JSON object", remediation="Send exactly one JSON object on stdin") else: try: response = execute_runner_request(root, request) except Exception: print("agent-runtime runner: unexpected adapter failure", file=sys.stderr) response = runner_response("environment-fail", "runner adapter failed before producing evidence", remediation="Inspect the tester binding and adapter environment") print(json.dumps(response, ensure_ascii=False, separators=(",", ":"))) return 0 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) execute = run_sub.add_parser("exec", parents=[root_parent]) execute.set_defaults(func=cmd_run_exec) 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)