1#!/usr/bin/env python3
2import argparse
3import fcntl
4import json
5import os
6from pathlib import Path
7import pty
8import re
9import select
10import shlex
11import sqlite3
12import subprocess
13import tempfile
14import termios
15import time
16import urllib.error
17import urllib.request
18import uuid
19
20
21def 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
174if __name__ == "__main__":
175 main()