feat: add test-preflight runner and deployer prepare adapters
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.
This commit is contained in:
@@ -8,15 +8,24 @@ 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
|
||||
|
||||
@@ -38,6 +47,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):
|
||||
@@ -223,7 +235,17 @@ def orca_json(args: list[str], environment: str | None = None) -> dict[str, Any]
|
||||
if environment and environment != "local":
|
||||
command.extend(["--environment", environment])
|
||||
command.append("--json")
|
||||
proc = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
# 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)
|
||||
@@ -282,12 +304,19 @@ def worktree_for(binding_value: dict[str, Any]) -> dict[str, Any]:
|
||||
if location["environment"] == "local" and expected_host == "local" and str(item.get("hostId")) != "local":
|
||||
continue
|
||||
candidates.append(item)
|
||||
if len(candidates) != 1:
|
||||
if not candidates:
|
||||
raise ConfigError(
|
||||
f"找不到精确 worktree: environment={environment}, host={location_host(location)}, "
|
||||
f"project={location['project']}, path={location['path']};先创建或登记该 worktree"
|
||||
)
|
||||
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]
|
||||
|
||||
@@ -358,6 +387,673 @@ def make_command(value: dict[str, Any]) -> str:
|
||||
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)
|
||||
@@ -474,6 +1170,8 @@ def parser() -> argparse.ArgumentParser:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user