AtlatestRepositoryapiary
1
#!/usr/bin/env python32
"""Drive apiary (an MCP-stdio server) as a scriptable client.4
Apiary has no CLI; running the binary speaks MCP JSON-RPC on5
stdin/stdout. This module spawns one apiary process per bot, performs6
the MCP handshake, and lets a test script call tools and observe7
inbound `notifications/claude/channel` events -- which is exactly the8
signal that says "this bot's trusted-set filter delivered that9
message".11
Everything is line-delimited JSON on stdout; stderr carries apiary's12
own structured log.14
Configuration comes from the environment so a single experiment script15
can be pointed at two different binaries for a matched pair:17
APIARY_BIN path to the apiary release bundle to drive (required)18
HARNESS_RUN state/log directory (default /tmp/apiary-harness)19
"""21
import json22
import os23
import queue24
import subprocess25
import sys26
import threading27
import time29
RUN_DIR = os.environ.get("HARNESS_RUN", "/tmp/apiary-harness")31
# Identifiers used by env-up.sh's seeded database. Generic on purpose:32
# this is a public repo and these must not encode any particular33
# deployment's nicks, hosts or channels.34
OWNER = os.environ.get("HARNESS_OWNER", "owner-1")35
LEADER_NICK = os.environ.get("HARNESS_LEADER", "leader-1")36
CHANNEL = os.environ.get("HARNESS_CHANNEL", "#coord")37
WORKER_GROUP = os.environ.get("HARNESS_GROUP", "test-workers")40
def apiary_bin():41
b = os.environ.get("APIARY_BIN")42
if not b:43
raise SystemExit(44
"APIARY_BIN is not set. Point it at the apiary release "45
"bundle to drive, e.g.\n"46
" APIARY_BIN=$PWD/build/release/bin/apiary python3 "47
"test/harness/exp_silent_loss.py\n"48
"Refusing to guess: a matched pair is worthless if both "49
"arms silently run the same binary.")50
if not os.access(b, os.X_OK):51
raise SystemExit(f"APIARY_BIN is not executable: {b}")52
return b55
def load_state(run_dir=None):56
"""Read env-up.sh's state.env."""57
run_dir = run_dir or RUN_DIR58
path = os.path.join(run_dir, "state.env")59
if not os.path.exists(path):60
raise SystemExit(61
f"no {path} -- run test/harness/env-up.sh first")62
state = {}63
with open(path) as f:64
for line in f:65
k, _, v = line.strip().partition("=")66
if k:67
state[k] = v68
return state71
class Bot:72
def __init__(self, label, env, bin=None, log_dir=None):73
self.label = label74
self.env = dict(os.environ)75
# Scrub any inherited APIARY_* so a stray value from the shell76
# cannot silently change what we think we are testing.77
for k in list(self.env):78
if k.startswith("APIARY_"):79
del self.env[k]80
self.env.update({k: str(v) for k, v in env.items()})81
log_dir = log_dir or RUN_DIR82
os.makedirs(log_dir, exist_ok=True)83
self.stderr_path = os.path.join(log_dir, f"{label}.stderr")84
self._stderr_f = open(self.stderr_path, "w")85
self.bin = bin or apiary_bin()86
self.proc = subprocess.Popen(87
[self.bin],88
stdin=subprocess.PIPE,89
stdout=subprocess.PIPE,90
stderr=self._stderr_f,91
env=self.env,92
text=True,93
bufsize=1,94
)95
self._id = 096
self._responses = {}97
self._resp_lock = threading.Condition()98
self.notifications = []99
self._notif_lock = threading.Condition()100
self.raw = []101
self._reader = threading.Thread(target=self._read_loop, daemon=True)102
self._reader.start()104
# ---- plumbing -------------------------------------------------106
def _read_loop(self):107
for line in self.proc.stdout:108
line = line.strip()109
if not line:110
continue111
self.raw.append(line)112
try:113
msg = json.loads(line)114
except json.JSONDecodeError:115
continue116
if "id" in msg and ("result" in msg or "error" in msg):117
with self._resp_lock:118
self._responses[msg["id"]] = msg119
self._resp_lock.notify_all()120
elif msg.get("method") == "notifications/claude/channel":121
with self._notif_lock:122
self.notifications.append(msg["params"])123
self._notif_lock.notify_all()125
def _send(self, obj):126
self.proc.stdin.write(json.dumps(obj) + "\n")127
self.proc.stdin.flush()129
def _request(self, method, params=None, timeout=60):130
self._id += 1131
rid = self._id132
self._send({"jsonrpc": "2.0", "id": rid, "method": method,133
"params": params or {}})134
deadline = time.time() + timeout135
with self._resp_lock:136
while rid not in self._responses:137
remaining = deadline - time.time()138
if remaining <= 0:139
raise TimeoutError(f"{self.label}: {method} timed out")140
self._resp_lock.wait(remaining)141
return self._responses.pop(rid)143
# ---- MCP ------------------------------------------------------145
def initialize(self, timeout=60):146
r = self._request("initialize", {147
"protocolVersion": "2024-11-05",148
"capabilities": {},149
"clientInfo": {"name": "apiary-harness", "version": "0.0"},150
}, timeout=timeout)151
self._send({"jsonrpc": "2.0", "method": "notifications/initialized",152
"params": {}})153
return r155
def tools(self):156
r = self._request("tools/list")157
return sorted(t["name"] for t in r["result"]["tools"])159
def call(self, tool, timeout=60, **args):160
r = self._request("tools/call", {"name": tool, "arguments": args},161
timeout=timeout)162
if "error" in r:163
return f"<JSONRPC-ERROR {json.dumps(r['error'])}>"164
parts = [c.get("text", "") for c in r["result"].get("content", [])]165
return "\n".join(parts)167
def wait_connected(self, timeout=25.0):168
"""Block until the bridge logs a successful connect."""169
deadline = time.time() + timeout170
while time.time() < deadline:171
if self.grep_stderr("Apiary bridge connected"):172
return True173
time.sleep(0.1)174
return False176
# ---- observation ---------------------------------------------178
def wait_notification(self, match, timeout=20):179
"""Wait for an inbound channel notification whose content180
contains `match`. Returns the params dict, or None on timeout."""181
deadline = time.time() + timeout182
seen = 0183
with self._notif_lock:184
while True:185
while seen < len(self.notifications):186
n = self.notifications[seen]187
seen += 1188
if match in n.get("content", ""):189
return n190
remaining = deadline - time.time()191
if remaining <= 0:192
return None193
self._notif_lock.wait(remaining)195
def contents(self):196
return [n.get("content", "") for n in self.notifications]198
def alive(self):199
return self.proc.poll() is None201
def stderr_text(self):202
self._stderr_f.flush()203
with open(self.stderr_path) as f:204
return f.read()206
def grep_stderr(self, pat):207
return [l for l in self.stderr_text().splitlines() if pat in l]209
def close(self):210
try:211
self.proc.terminate()212
self.proc.wait(timeout=10)213
except Exception:214
try:215
self.proc.kill()216
except Exception:217
pass218
try:219
self._stderr_f.close()220
except Exception:221
pass224
def leader_env(port, token, nick=None, host="127.0.0.1",225
channel=None, owner=None, **extra):226
e = {227
"APIARY_ENCLAVE_HOST": host,228
"APIARY_ENCLAVE_PORT": port,229
"APIARY_ENCLAVE_TLS": "no",230
"APIARY_USER": nick or LEADER_NICK,231
"APIARY_TOKEN": token,232
"APIARY_CHANNEL": channel or CHANNEL,233
"APIARY_MODE": "leader",234
"APIARY_OWNER_NICK": owner or OWNER,235
"APIARY_REPORTS_TO": owner or OWNER,236
"APIARY_SERVICES_TIMEOUT": "8.0",237
"APIARY_WORKER_GROUP": WORKER_GROUP,238
}239
e.update(extra)240
return e243
def worker_env(port, token, nick, reports_to=None,244
host="127.0.0.1", channel=None, owner=None, **extra):245
e = {246
"APIARY_ENCLAVE_HOST": host,247
"APIARY_ENCLAVE_PORT": port,248
"APIARY_ENCLAVE_TLS": "no",249
"APIARY_USER": nick,250
"APIARY_TOKEN": token,251
"APIARY_CHANNEL": channel or CHANNEL,252
"APIARY_MODE": "worker",253
"APIARY_OWNER_NICK": owner or OWNER,254
"APIARY_REPORTS_TO": reports_to or LEADER_NICK,255
"APIARY_SERVICES_TIMEOUT": "8.0",256
}257
e.update(extra)258
return e261
def unique_nick(prefix):262
"""A nick that has not been used on this server before.264
Revoking a bot DISABLES the row rather than deleting it, so the265
nick stays burned: re-running an experiment with a fixed nick fails266
with `name-taken`. That failure is loud, but in a matched pair it267
lands on the SECOND arm, which is the arm you are testing -- so a268
careless run reports the pre-change result and nothing else.269
"""270
return f"{prefix}{int(time.time()) % 100000}"273
def say(*a):274
print(*a, flush=True)277
def check(label, ok, detail=""):278
say(f" [{'PASS' if ok else 'FAIL'}] {label}"279
+ (f" -- {detail}" if detail else ""))280
return bool(ok)