| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | import fcntl |
| 4 | import json |
| 5 | import os |
| 6 | from pathlib import Path |
| 7 | import pty |
| 8 | import re |
| 9 | import select |
| 10 | import shlex |
| 11 | import sqlite3 |
| 12 | import subprocess |
| 13 | import tempfile |
| 14 | import termios |
| 15 | import time |
| 16 | import urllib.error |
| 17 | import urllib.request |
| 18 | import uuid |
| 19 | |
| 20 | |
| 21 | def main(): |
| 22 | parser = argparse.ArgumentParser() |
| 23 | parser.add_argument("--url", required=True) |
| 24 | parser.add_argument("--output", type=Path) |
| 25 | parser.add_argument("--home", type=Path) |
| 26 | args = parser.parse_args() |
| 27 | origin = args.url.rstrip("/") |
| 28 | if os.uname().sysname == "Darwin": |
| 29 | existing = subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/net.paperclover.agent-relay"], capture_output=True) |
| 30 | assert existing.returncode != 0, "stop the installed agent before running this disposable fixture" |
| 31 | |
| 32 | def http(path, body=None, token=None, status=200): |
| 33 | request = urllib.request.Request(origin + path, data=json.dumps(body).encode() if body is not None else None, |
| 34 | headers={"Content-Type": "application/json", "Origin": origin, **({"Authorization": "Bearer " + token} if token else {})}) |
| 35 | try: |
| 36 | response = urllib.request.urlopen(request, timeout=15) |
| 37 | except urllib.error.HTTPError as error: |
| 38 | response = error |
| 39 | with response: |
| 40 | raw = response.read().decode() |
| 41 | assert response.status == status, (path, response.status, raw[:200]) |
| 42 | try: |
| 43 | return json.loads(raw) if raw else None |
| 44 | except ValueError: |
| 45 | return raw |
| 46 | |
| 47 | with tempfile.TemporaryDirectory(prefix="agent-relay-native-") as temporary: |
| 48 | root = args.home.resolve() if args.home else Path(temporary).resolve() / "home with spaces $dollar %percent" |
| 49 | root.mkdir(exist_ok=True) |
| 50 | assert not (root / ".config/agent-relay/agent.json").exists(), "use a disposable home without an agent pairing" |
| 51 | projects = root / "projects" |
| 52 | projects.mkdir() |
| 53 | codex = root / "codex" |
| 54 | codex.mkdir() |
| 55 | thread = str(uuid.uuid4()) |
| 56 | db = sqlite3.connect(codex / "state_5.sqlite") |
| 57 | db.execute("CREATE TABLE threads (id TEXT,title TEXT,cwd TEXT,updated_at INTEGER,rollout_path TEXT,archived INTEGER)") |
| 58 | db.execute("INSERT INTO threads VALUES (?,?,?,?,?,0)", (thread, "Installer fixture", str(projects), int(time.time()), str(root / "fixture.jsonl"))) |
| 59 | db.commit() |
| 60 | db.close() |
| 61 | env = {**os.environ, "HOME": str(root), "XDG_CONFIG_HOME": str(root / ".config"), "XDG_DATA_HOME": str(root / ".local/share"), "CODEX_HOME": str(codex), "CLAUDE_CONFIG_DIR": str(root / "claude")} |
| 62 | base = root / "Library/Application Support/AgentRelay" if os.uname().sysname == "Darwin" else root / ".local/share/agent-relay" |
| 63 | data = root / ".config/agent-relay" |
| 64 | machine = None |
| 65 | child = None |
| 66 | master = None |
| 67 | transcript = "" |
| 68 | |
| 69 | def install(fresh): |
| 70 | nonlocal child, master, transcript, machine |
| 71 | master, slave = pty.openpty() |
| 72 | |
| 73 | def terminal(): |
| 74 | os.setsid() |
| 75 | fcntl.ioctl(0, termios.TIOCSCTTY, 0) |
| 76 | |
| 77 | child = subprocess.Popen(["sh", "-c", f"curl -fsSL {shlex.quote(origin + '/agent/install.sh')} | sh"], stdin=slave, stdout=slave, stderr=slave, env=env, preexec_fn=terminal) |
| 78 | os.close(slave) |
| 79 | transcript = "" |
| 80 | cursor = 0 |
| 81 | |
| 82 | def expect(pattern, timeout=120): |
| 83 | nonlocal transcript, cursor |
| 84 | deadline = time.monotonic() + timeout |
| 85 | while True: |
| 86 | match = re.search(pattern, transcript[cursor:]) |
| 87 | if match: |
| 88 | cursor += match.end() |
| 89 | return match |
| 90 | assert time.monotonic() < deadline, transcript[-1800:] |
| 91 | if select.select([master], [], [], .2)[0]: |
| 92 | try: |
| 93 | chunk = os.read(master, 65536) |
| 94 | except OSError: |
| 95 | chunk = b"" |
| 96 | assert chunk, transcript[-1800:] |
| 97 | transcript += chunk.decode(errors="replace") |
| 98 | |
| 99 | if fresh: |
| 100 | expect(r"Machine name \[.*?\]: ") |
| 101 | os.write(master, b"Installer fixture\n") |
| 102 | expect(r"Project folder: ") |
| 103 | os.write(master, (str(projects) + "\n" if fresh else "\n").encode()) |
| 104 | if fresh: |
| 105 | expect(r"Project folder: ") |
| 106 | os.write(master, b"\n") |
| 107 | expect(r"Enable desktop control\?.*?: ") |
| 108 | os.write(master, b"n\n") |
| 109 | if fresh: |
| 110 | code = expect(r"link this machine with code ([A-Z0-9]+-[A-Z0-9]+)").group(1) |
| 111 | machine = http("/api/mcp/relay/pair", {"code": code}) |
| 112 | expect(r"Installed\. Agent Relay starts at login\.") |
| 113 | deadline = time.monotonic() + 20 |
| 114 | while child.poll() is None and time.monotonic() < deadline: |
| 115 | if select.select([master], [], [], .2)[0]: |
| 116 | try: |
| 117 | transcript += os.read(master, 65536).decode(errors="replace") |
| 118 | except OSError: |
| 119 | break |
| 120 | assert child.poll() is not None, transcript[-1800:] |
| 121 | assert child.returncode == 0, transcript[-1800:] |
| 122 | os.close(master) |
| 123 | master = None |
| 124 | |
| 125 | def online(expected): |
| 126 | deadline = time.monotonic() + 20 |
| 127 | while time.monotonic() < deadline: |
| 128 | machines = http("/api/mcp")["machines"] |
| 129 | found = next((item for item in machines if item["id"] == machine["id"]), None) |
| 130 | if found and found["online"] == expected: |
| 131 | return |
| 132 | time.sleep(.2) |
| 133 | raise AssertionError("agent connection did not change") |
| 134 | |
| 135 | try: |
| 136 | install(True) |
| 137 | online(True) |
| 138 | config = json.loads((data / "agent.json").read_text()) |
| 139 | assert config["roots"] == [str(projects)] and not config["desktopWrite"] |
| 140 | assert (data / "agent.json").stat().st_mode & 0o777 == 0o600 |
| 141 | key = http("/api/mcp/relay/keys", {"name": "Installer fixture", "resources": [machine["id"]], "write": True})["key"] |
| 142 | result = http(f"/api/v1/machines/{machine['id']}/commands", {"method": "list_threads", "params": {"provider": "codex"}}, key) |
| 143 | assert any(item["id"] == thread for item in result["result"]["threads"]), result |
| 144 | result = http(f"/api/v1/machines/{machine['id']}/commands", {"method": "start_thread", "params": {"provider": "codex", "cwd": str(root), "message": "fixture"}}, key, status=400) |
| 145 | assert "outside the roots" in str(result) |
| 146 | install(False) |
| 147 | online(True) |
| 148 | assert json.loads((data / "agent.json").read_text()) == config |
| 149 | manage = [str(base / "node"), str(base / "setup.mjs")] |
| 150 | subprocess.run([*manage, "stop"], env=env, check=True, capture_output=True) |
| 151 | online(False) |
| 152 | subprocess.run([*manage, "start"], env=env, check=True, capture_output=True) |
| 153 | online(True) |
| 154 | subprocess.run([*manage, "uninstall"], env=env, check=True, capture_output=True) |
| 155 | online(False) |
| 156 | assert (data / "agent.json").exists() |
| 157 | report = {"platform": os.uname().sysname, "curl_pipe_prompts": "passed", "pairing": "passed", "native_login_startup": "passed", "private_credentials": "passed", "captured_cli_environment": "passed", "allowed_folders": "passed", "reinstall_keeps_identity": "passed", "stop_start_uninstall": "passed", "spaces_dollars_percent_in_paths": "passed"} |
| 158 | if args.output: |
| 159 | args.output.write_text(json.dumps(report, indent=2) + "\n") |
| 160 | print(json.dumps(report)) |
| 161 | finally: |
| 162 | if (base / "setup.mjs").exists(): |
| 163 | subprocess.run([str(base / "node"), str(base / "setup.mjs"), "uninstall"], env=env, capture_output=True) |
| 164 | if child and child.poll() is None: |
| 165 | os.killpg(child.pid, 9) |
| 166 | child.wait(timeout=10) |
| 167 | if master is not None: |
| 168 | os.close(master) |
| 169 | if machine: |
| 170 | request = urllib.request.Request(origin + "/api/mcp/relay/machines/" + machine["id"], method="DELETE", headers={"Origin": origin}) |
| 171 | urllib.request.urlopen(request, timeout=10).close() |
| 172 | |
| 173 | |
| 174 | if __name__ == "__main__": |
| 175 | main() |