adaa8e4edd
agent-runtime executes fixed tester node operations over JSON stdin. deployer prepare brings up the local test Compose service the same way. test-preflight now passes the tester binding; tester select skips unauthorized cases before creating a runner.
1189 lines
64 KiB
Python
Executable File
1189 lines
64 KiB
Python
Executable File
#!/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:<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")
|
||
# 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.<name> 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)
|