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:
2026-10-19 09:28:50 +08:00
parent 9075ff6a5a
commit adaa8e4edd
9 changed files with 1179 additions and 11 deletions
+16
View File
@@ -52,6 +52,22 @@ python3 <skill-dir>/scripts/agent.py run stop --root <project-root> --run <run-i
`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill `run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill
适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。 适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。
tester 的 test-preflight 一次性节点入口使用 JSON stdin/stdout:
```bash
printf '%s\n' '{"binding":"tester.android","operation":"platform_probe","platform":"android","node":"local"}' \
| python3 <skill-dir>/scripts/agent.py run exec --root <project-root>
```
入口只接受 `platform_probe`、`network_probe`、`install`、`start` 四种受控操作,命令
由 adapter 固定生成,不读取配置中的 command/argv/shell/env/token。local binding 可执行
固定的只读平台/网络探针和 test-preflight 已授权的 Android 安装/启动;非 local binding
先按 `environment + host + project + path` 解析 Orca worktree,再通过 Orca 的临时 terminal
执行固定的节点 runner,并从协议标记读取一个 JSON 结果,结束后关闭该 terminal。远程 iOS
的安装/启动要求调用方把产物放在 Mac 节点可读路径,并在 matrix 的 `artifact.remote_path`
中声明;协调机上的本地路径不会被错误地当成 Mac 路径。任何 transport、节点工具链或远端
产物缺失都返回结构化 `environment-fail`,绝不在协调节点执行平台命令或伪造 `ready`。
## 验证 ## 验证
- `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。 - `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。
+705 -7
View File
@@ -8,15 +8,24 @@ shell, and it only creates builder sessions from a fixed make invocation.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import copy import copy
import gzip
import hashlib import hashlib
import json import json
import os import os
import platform as host_platform
import re import re
import shlex
import shutil
import socket
import subprocess import subprocess
import sys import sys
import time import time
import uuid import uuid
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any 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}$") 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_.-]+)*$") MAKEFILE_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$")
FORBIDDEN_KEYS = {"command", "argv", "shell", "env", "environmentVariables", "token", "secret", "password"} 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): 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": if environment and environment != "local":
command.extend(["--environment", environment]) command.extend(["--environment", environment])
command.append("--json") 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() raw = (proc.stdout or "").strip()
try: try:
data = json.loads(raw) 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": if location["environment"] == "local" and expected_host == "local" and str(item.get("hostId")) != "local":
continue continue
candidates.append(item) candidates.append(item)
if len(candidates) != 1: if not candidates:
if not candidates: raise ConfigError(
raise ConfigError( f"找不到精确 worktree: environment={environment}, host={location_host(location)}, "
f"找不到精确 worktree: environment={environment}, host={location_host(location)}, " f"project={location['project']}, path={location['path']};先创建或登记该 worktree"
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)}") raise ConfigError(f"binding 匹配到多个 worktree: {len(candidates)}")
return candidates[0] 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']}" 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: def cmd_run_start(args: argparse.Namespace) -> int:
root = Path(args.root).resolve() root = Path(args.root).resolve()
value = binding(load_config(root), args.binding) value = binding(load_config(root), args.binding)
@@ -474,6 +1170,8 @@ def parser() -> argparse.ArgumentParser:
start.add_argument("--binding", required=True) start.add_argument("--binding", required=True)
start.add_argument("--wait-ms", type=int, default=0) start.add_argument("--wait-ms", type=int, default=0)
start.set_defaults(func=cmd_run_start) 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)): 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 = run_sub.add_parser(name, parents=[root_parent])
item.add_argument("--run", required=True) item.add_argument("--run", required=True)
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import base64
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "agent.py"
SPEC = importlib.util.spec_from_file_location("agent_runtime_runner", SCRIPT)
assert SPEC and SPEC.loader
agent = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(agent)
class RunnerTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
pouch = self.root / ".pouch"
pouch.mkdir()
(pouch / "agent.yaml").write_text(
"""schemaVersion: 1
locations:
local-app:
environment: local
host: local
project: local-project
path: /tmp/writes
remote-mac:
environment: macmini
host: runtime:0123456789abcdef0123
project: remote-project
path: /Users/tester/writes
profiles:
test-agent:
cli: codex
model: null
reasoningEffort: medium
permissionMode: workspace-write
bindings:
tester.android:
adapter: tester
location: local-app
profile: test-agent
tester.ios:
adapter: tester
location: remote-mac
profile: test-agent
""",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp.cleanup()
def test_local_binding_routes_to_fixed_probe(self):
def command(argv, **_kwargs):
if argv == ["adb", "version"]:
return 0, "Android Debug Bridge version 1"
if argv == ["adb", "devices"]:
return 0, "List of devices attached\nemulator-5554\tdevice\n"
self.assertEqual(argv, ["adb", "-s", "emulator-5554", "shell", "getprop", "sys.boot_completed"])
return 0, "1"
with mock.patch.object(agent, "run_fixed_command", side_effect=command) as runner:
result = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "platform_probe", "platform": "android", "node": "local",
})
self.assertEqual(result["status"], "ready")
self.assertEqual(set(result["checks"]), {"adb", "device", "boot"})
self.assertEqual(runner.call_count, 3)
def test_remote_binding_uses_orca_terminal_transport(self):
marker = "POUCH_RUNNER_RESULT_" + ("0" * 31) + "1"
payload = {"status": "ready", "observed": "Darwin/xcrun/simctl probe completed", "checks": {"macos": {"status": "ready"}}}
def fake_orca(argv, _environment=None):
if argv[:2] == ["terminal", "create"]:
return {"terminal": {"handle": "term-1"}}
if argv[:2] == ["terminal", "read"]:
return {"terminal": {"tail": [marker + base64.b64encode(json.dumps(payload).encode()).decode()]}}
return {}
with mock.patch.object(agent, "worktree_for", return_value={"id": "wt-1", "path": "/Users/tester/writes"}) as resolve, \
mock.patch.object(agent, "orca_json", side_effect=fake_orca), \
mock.patch.object(agent.uuid, "uuid4", return_value=mock.Mock(hex="0" * 31 + "1")):
result = agent.execute_runner_request(self.root, {
"binding": "tester.ios", "operation": "platform_probe", "platform": "ios", "node": "macmini",
})
self.assertEqual(result["status"], "ready")
self.assertEqual(result["checks"]["binding"]["status"], "ready")
resolve.assert_called_once()
def test_remote_transport_submits_startup_command_when_first_read_has_no_marker(self):
marker = "POUCH_RUNNER_RESULT_" + ("0" * 31) + "1"
payload = {"status": "ready", "observed": "Darwin/xcrun/simctl probe completed", "checks": {"macos": {"status": "ready"}}}
reads = [
{"terminal": {"tail": ["Welcome to fish"]}},
{"terminal": {"tail": [marker + base64.b64encode(json.dumps(payload).encode()).decode()]}},
]
def fake_orca(argv, _environment=None):
if argv[:2] == ["terminal", "create"]:
return {"terminal": {"handle": "term-1"}}
if argv[:2] == ["terminal", "read"]:
return reads.pop(0)
return {}
with mock.patch.object(agent, "worktree_for", return_value={"id": "wt-1", "path": "/Users/tester/writes"}), \
mock.patch.object(agent, "orca_json", side_effect=fake_orca) as orca, \
mock.patch.object(agent.uuid, "uuid4", return_value=mock.Mock(hex="0" * 31 + "1")):
result = agent.execute_runner_request(self.root, {
"binding": "tester.ios", "operation": "platform_probe", "platform": "ios", "node": "macmini",
})
self.assertEqual(result["status"], "ready")
self.assertTrue(any(call.args[0][:2] == ["terminal", "send"] for call in orca.call_args_list))
def test_unknown_operation_and_command_failure_are_structured(self):
unknown = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "exec", "platform": "android",
})
self.assertEqual(unknown["status"], "blocked")
with mock.patch.object(agent, "run_fixed_command", return_value=(127, "adb missing")):
failed = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "platform_probe", "platform": "android",
})
self.assertEqual(failed["status"], "environment-fail")
def test_json_stdout_is_single_protocol_object(self):
process = subprocess.run(
[sys.executable, str(SCRIPT), "run", "exec", "--root", str(self.root)],
input=json.dumps({"binding": "tester.android", "operation": "unknown", "platform": "android"}),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(process.returncode, 0)
self.assertEqual(json.loads(process.stdout)["status"], "blocked")
self.assertEqual(process.stderr, "")
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -83,6 +83,14 @@ ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「C
5. `prod` 环境:停止本轨道的 Compose 操作,读取 [production-agent.md](references/production-agent.md),在本机 `~/code/app00` 项目创建 fresh Agent terminal 并委派正式部署;当前 Agent 不得直接执行 `sync.py`、`remote.py` 或 SSH。 5. `prod` 环境:停止本轨道的 Compose 操作,读取 [production-agent.md](references/production-agent.md),在本机 `~/code/app00` 项目创建 fresh Agent terminal 并委派正式部署;当前 Agent 不得直接执行 `sync.py`、`remote.py` 或 SSH。
6. 只有明确点名的非正式远程 Compose 环境,才允许 `sync.py <svc>` 后调用 `remote.py <svc> recreate`;只重启调用 `remote.py <svc> restart`,并用 `remote.py <svc> ps` 验证。 6. 只有明确点名的非正式远程 Compose 环境,才允许 `sync.py <svc>` 后调用 `remote.py <svc> recreate`;只重启调用 `remote.py <svc> restart`,并用 `remote.py <svc> ps` 验证。
test-preflight 的一次性部署入口为 `scripts/deploy/prepare.py`。它从 stdin 接收
`{"operation":"prepare","environment":"test","project_root":"/abs/project","host":"...","public_host":"..."}`,
只解析项目 `.pouch/deployer/test`,复用 `lib.service_info` 的 `_config.yaml` 继承和 node
规则,确认 node 是本机后才按 `docker compose config`、`up -d --force-recreate`、`ps`
顺序准备单个 test 服务。输出始终是 `{status, observed, deployment_id?, checks?}` JSON;
prod、缺配置、非本机 node 为 `blocked`,Compose/命令失败为 `environment-fail`。诊断只写
stderr,不输出 `.env` 或命令输出中的凭据。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。 compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。 新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""One-shot JSON adapter for preparing the project's test Compose service.
The adapter owns no deployment policy of its own: service discovery and
``_config.yaml`` inheritance stay in ``lib.service_info``. The command runner
is deliberately injectable so unit tests never need Docker, SSH, or a network.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
import lib # noqa: E402
STATUSES = {"ready", "blocked", "environment-fail"}
LOCAL_NODE_NAMES = {"local", "localhost", "127.0.0.1", "::1"}
CommandRunner = Callable[[list[str], Path], int]
NodeChecker = Callable[[str], bool]
def response(status: str, observed: str, *, deployment_id: str = "", checks: dict[str, Any] | None = None, remediation: str = "") -> dict[str, Any]:
if status not in STATUSES:
raise ValueError(f"invalid status: {status}")
value: dict[str, Any] = {"status": status, "observed": observed}
if deployment_id:
value["deployment_id"] = deployment_id
if checks:
value["checks"] = checks
if remediation:
value["remediation"] = remediation
return value
def _check(status: str, observed: str, expected: str, remediation: str = "") -> dict[str, str]:
value = {"status": status, "observed": observed, "expected": expected}
if remediation:
value["remediation"] = remediation
return value
def run_compose(command: list[str], cwd: Path) -> int:
"""Run adapter-owned Compose argv without a shell or protocol pollution."""
try:
process = subprocess.run(
command,
cwd=str(cwd),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError:
return 127
return process.returncode
def execute_compose(executor: CommandRunner, command: list[str], cwd: Path) -> int:
"""Normalize mock/OS command failures into an environment-fail exit code."""
try:
return int(executor(command, cwd))
except (OSError, TypeError, ValueError):
return 127
def node_is_local(node: str) -> bool:
"""Accept only a local node; ``ssh -G`` is config inspection, never SSH."""
if node in LOCAL_NODE_NAMES:
return True
local_names = {socket.gethostname(), socket.getfqdn(), os.environ.get("HOSTNAME", "")}
if node in {name for name in local_names if name}:
return True
try:
process = subprocess.run(
["ssh", "-G", node],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError:
return False
if process.returncode != 0:
return False
configured_host = ""
for line in (process.stdout or "").splitlines():
key, _, value = line.partition(" ")
if key == "hostname":
configured_host = value.strip()
break
return configured_host in LOCAL_NODE_NAMES or configured_host in {name for name in local_names if name}
def _project_root(value: Any) -> Path | None:
if not isinstance(value, str) or not value.strip():
return None
root = Path(value)
if not root.is_absolute() or ".." in root.parts or os.path.normpath(value) != value:
return None
return root
def prepare_test(request: Any, *, executor: CommandRunner = run_compose, local_node: NodeChecker = node_is_local) -> dict[str, Any]:
if not isinstance(request, dict):
return response("blocked", "prepare request must be a JSON object", remediation="Send one JSON object on stdin")
if request.get("operation") != "prepare":
return response("blocked", "only operation=prepare is supported", remediation="Use the test deployer prepare contract")
environment = request.get("environment")
if environment != "test":
return response("blocked", "only the test environment is permitted", remediation="Use environment: test; production is never accepted by this adapter")
root = _project_root(request.get("project_root"))
if root is None or not root.is_dir():
return response("blocked", "project_root must be an existing absolute project directory", remediation="Provide the project root containing .pouch/deployer/test")
host = request.get("host")
if not isinstance(host, str) or not host.strip():
return response("blocked", "host is required", remediation="Provide the test service host used by preflight")
public_host = request.get("public_host")
if public_host is not None and (not isinstance(public_host, str) or not public_host.strip()):
return response("blocked", "public_host must be non-empty text when provided")
deploy_root = root / ".pouch" / "deployer"
test_dir = deploy_root / "test"
if not deploy_root.is_dir() or not test_dir.is_dir():
return response("blocked", "test deployer directory is missing", remediation="Create .pouch/deployer/test with its Compose files and _config.yaml")
lib.PROJECT_ROOT = deploy_root
try:
info = lib.service_info("test", strict=False)
except (OSError, ValueError, SystemExit):
info = None
if not info:
return response("blocked", "test Compose service is not discoverable", remediation="Ensure test/compose.yaml and inherited _config.yaml resolve a node")
node = str(info.get("node") or "")
if not node or not local_node(node):
return response("blocked", "test Compose node is not confirmed local", remediation=f"Resolve node {node or '<missing>'!r} to this machine; this adapter never falls back to SSH")
compose_file = test_dir / "compose.yaml"
if not compose_file.is_file():
return response("blocked", "test compose.yaml is missing", remediation="Provide .pouch/deployer/test/compose.yaml")
deployment_id = f"test:{info['name']}"
base = ["docker", "compose", "-f", "compose.yaml"]
checks: dict[str, Any] = {}
code = execute_compose(executor, [*base, "config", "--quiet"], test_dir)
if code != 0:
checks["compose_config"] = _check("environment-fail", f"docker compose config exited {code}", "valid test Compose configuration", "Fix the test Compose file or its local environment")
return response("environment-fail", "test Compose configuration failed", deployment_id=deployment_id, checks=checks)
checks["compose_config"] = _check("ready", "validated", "valid test Compose configuration")
code = execute_compose(executor, [*base, "up", "-d", "--force-recreate"], test_dir)
if code != 0:
checks["compose_up"] = _check("environment-fail", f"docker compose up exited {code}", "test services started", "Fix the local Docker/image/network failure and retry")
return response("environment-fail", "test Compose prepare failed", deployment_id=deployment_id, checks=checks)
checks["compose_up"] = _check("ready", "completed", "test services started")
code = execute_compose(executor, [*base, "ps"], test_dir)
if code != 0:
checks["compose_ps"] = _check("environment-fail", f"docker compose ps exited {code}", "test services report status", "Inspect local test service health")
return response("environment-fail", "test Compose status check failed", deployment_id=deployment_id, checks=checks)
checks["compose_ps"] = _check("ready", "completed", "test services report status")
return response("ready", "test Compose service prepared", deployment_id=deployment_id, checks=checks)
def main() -> int:
try:
request = json.load(sys.stdin)
except (json.JSONDecodeError, OSError):
result = response("blocked", "prepare stdin is not one valid JSON object", remediation="Send exactly one JSON object on stdin")
else:
try:
result = prepare_test(request)
except Exception:
print("deployer prepare: unexpected adapter failure", file=sys.stderr)
result = response("environment-fail", "prepare adapter failed before producing evidence", remediation="Inspect the test deployer configuration")
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "prepare.py"
SPEC = importlib.util.spec_from_file_location("deployer_prepare", SCRIPT)
assert SPEC and SPEC.loader
prepare = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(prepare)
class PrepareTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
deployer = self.root / ".pouch" / "deployer" / "test"
deployer.mkdir(parents=True)
(deployer / "compose.yaml").write_text("services:\n app:\n image: example:test\n", encoding="utf-8")
(deployer / "_config.yaml").write_text("node: devbox\nname: writes-test\n", encoding="utf-8")
self.request = {"operation": "prepare", "environment": "test", "project_root": str(self.root), "host": "http://test.local"}
def tearDown(self) -> None:
self.temp.cleanup()
def test_prod_is_rejected_without_inspecting_or_executing_compose(self):
calls = []
result = prepare.prepare_test({**self.request, "environment": "prod"}, executor=lambda command, cwd: calls.append(command) or 0)
self.assertEqual(result["status"], "blocked")
self.assertEqual(calls, [])
def test_missing_config_is_blocked(self):
(self.root / ".pouch" / "deployer" / "test" / "_config.yaml").unlink()
result = prepare.prepare_test(self.request, local_node=lambda node: True)
self.assertEqual(result["status"], "blocked")
self.assertIn("discoverable", result["observed"])
def test_mock_compose_prepare_is_ready(self):
calls = []
def executor(command, cwd):
calls.append((command, cwd))
return 0
result = prepare.prepare_test(self.request, executor=executor, local_node=lambda node: node == "devbox")
self.assertEqual(result["status"], "ready")
self.assertEqual(calls[0][0][-2:], ["config", "--quiet"])
self.assertEqual(calls[1][0][-3:], ["up", "-d", "--force-recreate"])
self.assertEqual(calls[2][0][-1], "ps")
self.assertEqual(calls[0][1], self.root / ".pouch" / "deployer" / "test")
def test_compose_failures_are_environment_fail(self):
for failed_step in range(3):
calls = []
def executor(command, cwd):
del cwd
calls.append(command)
return 1 if len(calls) == failed_step + 1 else 0
result = prepare.prepare_test(self.request, executor=executor, local_node=lambda node: True)
self.assertEqual(result["status"], "environment-fail")
self.assertNotEqual(result["status"], "ready")
def test_json_stdout_has_no_diagnostics(self):
process = subprocess.run(
[sys.executable, str(SCRIPT)],
input=json.dumps({**self.request, "environment": "prod"}),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(process.returncode, 0)
self.assertEqual(json.loads(process.stdout)["status"], "blocked")
self.assertEqual(process.stderr, "")
if __name__ == "__main__":
unittest.main()
+7 -3
View File
@@ -391,7 +391,11 @@ def check_network(evidence: Evidence, config: Mapping[str, Any], row: Mapping[st
statuses.append(status) statuses.append(status)
evidence.add(f"{platform_name}-{index}-network-{probe_index}-{detail['name']}", platform=platform_name, node=node, target=url, observed=detail["observed"], expected=detail["expected"], status=status, remediation=detail.get("remediation", "")) evidence.add(f"{platform_name}-{index}-network-{probe_index}-{detail['name']}", platform=platform_name, node=node, target=url, observed=detail["observed"], expected=detail["expected"], status=status, remediation=detail.get("remediation", ""))
elif isinstance(runner, dict) and runner.get("kind") == "contract": elif isinstance(runner, dict) and runner.get("kind") == "contract":
status, observed, result = runner_call(runner, {"operation": "network_probe", "platform": platform_name, "node": node, "url": url, "probe": probe}) # The runner is selected by the tester binding. Keep the binding
# in the request just like platform_probe, otherwise the shared
# agent-runtime adapter cannot resolve the actual node/worktree.
binding = text(tester.get("binding")) if isinstance(tester, dict) else ""
status, observed, result = runner_call(runner, {"operation": "network_probe", "platform": platform_name, "node": node, "binding": binding, "url": url, "probe": probe})
result = result or {"status": status, "observed": observed} result = result or {"status": status, "observed": observed}
complete = require_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, required={"dns", "tcp", "http"}) complete = require_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, required={"dns", "tcp", "http"})
network_status = add_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, default_expected="DNS/TCP/HTTP reachable from actual tester node") network_status = add_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, default_expected="DNS/TCP/HTTP reachable from actual tester node")
@@ -488,8 +492,8 @@ def run(config: Mapping[str, Any], mode: str) -> Evidence:
evidence.add(f"{platform_name}-{index}-install", platform=platform_name, node=node, target=target_path, observed="not-run", expected="verify and deployment ready", status="blocked", remediation="先修复前置检查;prepare 不伪造安装成功") evidence.add(f"{platform_name}-{index}-install", platform=platform_name, node=node, target=target_path, observed="not-run", expected="verify and deployment ready", status="blocked", remediation="先修复前置检查;prepare 不伪造安装成功")
evidence.add(f"{platform_name}-{index}-start", platform=platform_name, node=node, target=text(identity), observed="not-run", expected="install ready", status="blocked", remediation="安装前置失败,未启动 app") evidence.add(f"{platform_name}-{index}-start", platform=platform_name, node=node, target=text(identity), observed="not-run", expected="install ready", status="blocked", remediation="安装前置失败,未启动 app")
else: else:
install_request = {"operation": "install", "platform": platform_name, "node": node, "artifact": target_path, "identity": identity} install_request = {"operation": "install", "platform": platform_name, "node": node, "binding": binding, "artifact": target_path, "identity": identity}
start_request = {"operation": "start", "platform": platform_name, "node": node, "identity": identity} start_request = {"operation": "start", "platform": platform_name, "node": node, "binding": binding, "identity": identity}
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"): if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
if platform_name == "android": if platform_name == "android":
adb = shutil.which("adb") or "adb" adb = shutil.which("adb") or "adb"
+7 -1
View File
@@ -777,6 +777,10 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
selected: list[dict[str, Any]] = [] selected: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = [] skipped: list[dict[str, str]] = []
missing = set(wanted) missing = set(wanted)
# Resolve authorization gates during selection so an explicit safety or
# mutating request is represented as skipped before any runner/session is
# created. run-script keeps the same check as a defense in depth.
vars_map = resolve_vars(root, meta)
for case in cases: for case in cases:
cid = str(case.get("id") or "") cid = str(case.get("id") or "")
@@ -824,6 +828,9 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
if r_i > max_i: if r_i > max_i:
skipped.append({"id": cid, "reason": f"risk={risk} 超过上限 {max_risk}"}) skipped.append({"id": cid, "reason": f"risk={risk} 超过上限 {max_risk}"})
continue continue
if not authorized(vars_map, meta, case):
skipped.append({"id": cid, "reason": f"风险 {risk} 未授权"})
continue
selected.append(public_case(case)) selected.append(public_case(case))
if wanted and missing: if wanted and missing:
@@ -855,7 +862,6 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
return 2 return 2
# 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。 # 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。
vars_map = resolve_vars(root, meta)
status_probes: dict[str, dict[str, Any]] = {} status_probes: dict[str, dict[str, Any]] = {}
for case in selected: for case in selected:
cat = str(case.get("category") or "") cat = str(case.get("category") or "")
+3
View File
@@ -210,6 +210,9 @@ def android_identity(path: Path, context: RuntimeContext) -> str:
for command in commands: for command in commands:
result = run_command(command, context=context) result = run_command(command, context=context)
if result.code == 0: if result.code == 0:
plain = result.output.strip()
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_.-]*", plain):
return plain
match = re.search(r"(?:name|application-id)[=:]['\"]?([A-Za-z0-9_.]+)", result.output) match = re.search(r"(?:name|application-id)[=:]['\"]?([A-Za-z0-9_.]+)", result.output)
if match: if match:
return match.group(1) return match.group(1)