feat(hub): kvb-hub review hub with passkey-signed approvals

The kvb-hub is a local review hub in a container bound to 127.0.0.1: one page, JSON APIs and an MCP endpoint for the operator's inbox, asks, decisions, staged-commit reviews, the radio stream, the agent graph, the worktree tree, push-to-talk and read-aloud radio (Piper, whisper.cpp) and an optional LLM command line. The hub is only a relay. The host executors in hub/client (the runner user service, the PermissionRequest hook and the approval CLI the commit skills call) verify a WebAuthn passkey signature against keys pinned outside every sandbox, refuse replays and fail closed. scripts/hub.sh with make hub-install, hub-check, hub-pin, hub-runner and hub-hook installs and checks the hub; hub-hook writes ~/.claude/settings.json after a backup. make watch-check shows a stuck watch hook. The new kvb-git-diff skill and the commit skills route reviews through the hub; the test_hub_* suites replace test_hub.py.

Security review (kvb-think-criticize, three skeptics) fixed four holes: signed rules auto-approved chained shell commands; the client and LLM openers followed redirects, the LLM one with its bearer key; the worktree scan ran git fsmonitor and filter commands from agent-writable repos; compose set no memory, pids or health limits. Documented until core requirement 093, on the operator's word: the hub page is trusted while signing and pinning, local processes can forge the unsigned operator actions, and the container reads the host /tmp. Committed on the operator's word despite the gate: the passkey path is not yet verified live.

Accepted MEDUSA false positives (137 findings on 115 lines, rows in security-accepted.jsonl): EXA002 x17, MEDUSA-MM-SCAN-058 x12, B603 x8, MEDUSA-FTSC-SCAN-089 x7, MEDUSA-PRIV-SCAN-762 x6, AIC-EXFIL-005 x5, EXA006 x5, MEDUSA-FTSCAN-231 x5, WEB-SSRF x5, MEDUSA-INF-SCAN-2057 x3, RH005 x3, TC001 x3, LO002 x2, MEDUSA-AEXP-SCAN-096 x2, MEDUSA-CGS-SCAN-028 x2, MEDUSA-INF-SCAN-1350 x2, MEDUSA-INF-SCAN-2369 x2, MEDUSA-INF-SCAN-2440 x2, MEDUSA-INF-SCAN-2638 x2, MLSC-SERIAL-002 x2, PL002 x2, PQC005 x2, SAST-PATH-001 x2, TC004 x2, TC005 x2, TC006 x2, TC007 x2, AA-146, EXA007, FINE-050, GB-ENCODE-001, GB-ENCODE-003, MEDUSA-CGS-SCAN-029, MEDUSA-CGS-SCAN-103, MEDUSA-DP-SCAN-1034, MEDUSA-DP-SCAN-1916, MEDUSA-DP-SCAN-954, MEDUSA-EXF26-TOOL-004, MEDUSA-FT-TRIGGER-005, MEDUSA-INF-SCAN-2206, MEDUSA-INF-SCAN-2457, MEDUSA-INF-WEB-003, MEDUSA-INFRA-SCAN-030, MEDUSA-MP-SCAN-1135, MEDUSA-RLHF-POISON-002, MEDUSA-RT-SCAN-006, MM-ENCODE-001, MP-124, PQC001, PQC002, PQC006, RP001, TC009, TC010, WEB-FLASK-004.

🎯 Fix: 80 %
💥 Break: 4 %
💸 Cost: 129 %
⏱️ Time: –
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 60
🚦 Gate: ⛔ fix 80 % < 90; cost 129 % > 100: ask the operator before the commit

```
/home/kevinveenbirkenbach/Repositories/github.com/kevinveenbirkenbach/skills/.worktrees/feature/kvb-hub
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 18:46:40 +00:00
1 parent 305ccdb1c2
commit 8ba45efd04
119 files changed
+13672 -4

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
from __future__ import annotations
import http.client
import itertools
import json
import os
import subprocess
import tempfile
import threading
import time
import unittest
from collections.abc import Callable
from pathlib import Path
from unittest import mock
from hub_engines import write_engines
from hub_support import (
GIT_ENV,
HOST_STATE,
HUB,
OPTIONS,
PAGE,
SCORE,
TREE,
FakeScripts,
Key,
key,
server,
signing,
)
class HubCase(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
base = Path(tmp.name)
self.council = base / "council"
self.pages = base / "pages"
self.state = base / "state"
self.outside = base / "outside"
self.projects = base / "projects"
self.repos = base / "repos"
self.worktrees = base / "worktrees"
self.timers = base / "timers"
self.approvers = base / "config" / "approvers.json"
self.replay = base / "replay"
for path in (self.pages, self.state, self.outside, self.projects, self.repos, self.worktrees, self.timers):
path.mkdir()
silence = mock.patch.object(server.Handler, "log_message")
silence.start()
self.addCleanup(silence.stop)
self.ids = itertools.count(1)
self.scripts = FakeScripts()
self.engines = write_engines(base)
httpd = server.HubServer(("127.0.0.1", 0), server.Handler)
self.port = httpd.server_address[1]
httpd.hub = server.open_hub(
council=self.council,
pages=self.pages,
public_port=self.port,
state=self.state,
host_state=HOST_STATE,
transcripts=self.projects,
scripts=self.scripts,
templates=HUB / "templates",
places=server.Places(repos=self.repos, worktrees=self.worktrees, timers=self.timers),
model=server.Model(url="", model="", key=""),
engines=self.engines,
approvers=self.approvers,
)
self.httpd = httpd
self.origin = signing.origin_of(self.port)
thread = threading.Thread(target=httpd.serve_forever, args=(0.05,), daemon=True)
thread.start()
self.addCleanup(httpd.server_close)
self.addCleanup(thread.join, 10)
self.addCleanup(httpd.shutdown)
self.host = f"127.0.0.1:{self.port}"
def call(self, method: str, path: str, body: bytes | None, headers: dict[str, str]) -> tuple[int, dict, bytes]:
connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
try:
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
return response.status, {k.lower(): v for k, v in response.getheaders()}, response.read()
finally:
connection.close()
def get(self, path: str) -> tuple[int, dict, bytes]:
return self.call("GET", path, None, {"Host": self.host})
def post_mcp(self, payload: object, headers: dict[str, str]) -> tuple[int, dict, bytes]:
merged = {"Host": self.host, "Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
return self.call("POST", "/mcp", json.dumps(payload).encode(), {**merged, **headers})
def rpc(self, method: str, params: dict) -> dict:
status, _, body = self.post_mcp({"jsonrpc": "2.0", "id": next(self.ids), "method": method, "params": params}, {})
self.assertEqual(status, 200, body)
return json.loads(body)
def tool(self, name: str, arguments: dict) -> dict:
return self.rpc("tools/call", {"name": name, "arguments": arguments})["result"]
def guard(self, host: str) -> dict[str, str]:
return {"Host": host, "Origin": f"http://{host}", "X-Kvb-Hub": "1"}
def pin(self) -> Key:
signer = key(-7, "main")
signer.pin(self.approvers, self.origin)
return signer
def prepare(self, items: list[dict]) -> dict:
status, _, body = self.post_json("/api/sign", {"items": items}, self.guard(self.host))
self.assertEqual(status, 200, body)
return json.loads(body)
def signed(self, items: list[dict], signer: Key) -> dict:
return signer.proof(self.prepare(items)["payload"], self.origin)
def proven(self, items: list[dict], signer: Key) -> bytes:
return json.dumps({"proof": self.signed(items, signer)}).encode()
def page(self, name: str, text: str) -> str:
path = self.pages / name
path.write_text(text, encoding="utf-8")
return str(path)
def arguments(self, title: str, notes: str, page: str) -> dict:
return {"repo": "/repo/.git", "worktree": "/repo/.worktrees/feature/x", "branch": "feature/x", "tree": TREE, "title": title, "page": page, "notes": notes, "gate": "pass", "score": SCORE}
def events(self, query: str) -> dict:
status, _, body = self.get(f"/api/events?{query}")
self.assertEqual(status, 200, body)
return json.loads(body)
def client(self) -> dict[str, str]:
return {"Host": self.host, "X-Kvb-Hub": "1"}
def settle(self, check: Callable[[], bool]) -> None:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if check():
return
time.sleep(0.05)
self.fail("the condition did not settle")
def approve(self, items: list[dict], proof: dict | None) -> tuple[int, dict, bytes]:
return self.post_json("/api/inbox/approve", {"items": items, "proof": proof}, self.guard(self.host))
def entry(self, kind: str, ident: str, label: str, digest: str) -> dict:
return {"kind": kind, "id": ident, "verb": "approve", "label": label, "sha256": digest}
def decision(self, recommended: str) -> str:
result = self.tool("decision_request", {"question": "Merge the hub?", "options": OPTIONS, "agent": "Skills", "recommended": recommended})
self.assertFalse(result["isError"], result)
return result["structuredContent"]["id"]
def choice(self, decision_id: str) -> str | None:
return self.tool("decision_wait", {"id": decision_id, "timeout_seconds": 0})["structuredContent"]["choice"]
def git(self, cwd: Path, *args: str) -> str:
done = subprocess.run(["git", *args], cwd=cwd, env={**os.environ, **GIT_ENV}, check=True, capture_output=True, text=True)
return done.stdout
def request(self, title: str, notes: str) -> str:
result = self.tool("review_request", self.arguments(title, notes, self.page("bundle.html", PAGE)))
self.assertFalse(result["isError"], result)
return result["structuredContent"]["id"]
def status(self, review_id: str) -> str:
return self.tool("review_status", {"id": review_id})["structuredContent"]["status"]
def index(self) -> str:
status, headers, body = self.get("/")
self.assertEqual(status, 200, body)
self.assertTrue(headers["content-type"].startswith("text/html"))
return body.decode("utf-8")
def snapshot(self) -> dict:
status, headers, body = self.get("/api/state")
self.assertEqual(status, 200, body)
self.assertEqual(headers["content-type"], "application/json")
return json.loads(body)
def write_council(self, files: dict[str, object]) -> None:
for name, content in files.items():
path = self.council / name
path.parent.mkdir(parents=True, exist_ok=True)
text = content if isinstance(content, str) else json.dumps(content)
path.write_text(text, encoding="utf-8")
def jsonl(self, rows: list[dict]) -> str:
return "".join(json.dumps(row) + "\n" for row in rows)
def post_json(self, path: str, payload: object, headers: dict[str, str]) -> tuple[int, dict, bytes]:
return self.call("POST", path, json.dumps(payload).encode(), {**headers, "Content-Type": "application/json"})
def local(self) -> dict[str, str]:
return {"Host": self.host, "X-Kvb-Hub": "1"}
def create_ask(self, payload: dict) -> str:
status, _, body = self.post_json("/api/asks", payload, self.local())
self.assertEqual(status, 201, body)
return json.loads(body)["id"]
def ask_status(self, ask_id: str, wait: str) -> str:
status, _, body = self.get(f"/api/asks/{ask_id}?wait={wait}")
self.assertEqual(status, 200, body)
return json.loads(body)["status"]
def claim(self, session: str, name: str) -> None:
self.write_council({f"work/{session}/claim.json": {"office": f"work/{session}", "session": session, "runtime": "claude", "name": name, "since": "2026-11-05T08:00:00+00:00", "beat": time.time(), "work": "x"}})
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import io
import json
import sys
import wave
from pathlib import Path
from hub_support import server
VOICES = ["de_DE-solo-medium:0", "en_US-test-high:0", "en_US-test-high:1", "en_US-test-high:2", "en_US-test-high:3"]
OPERATOR_VOICE = "en_US-test-high:0"
FAKE_PIPER = """
import argparse, json, os, sys, wave
parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--output-file", required=True)
parser.add_argument("--speaker", type=int)
args = parser.parse_args()
text = sys.stdin.read()
with open(os.path.join(os.environ["HOME"], "piper.jsonl"), "a", encoding="utf-8") as log:
log.write(json.dumps({"argv": sys.argv[1:], "text": text, "tmp": os.environ["TMPDIR"], "cwd": os.getcwd()}) + "\\n")
if "FAIL" in text:
sys.exit(3)
with wave.open(args.output_file, "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(22050)
out.writeframes(bytes(2 * len(text)))
"""
FAKE_WHISPER = """
import json, os, sys
data = sys.stdin.buffer.read()
with open(os.path.join(os.environ["HOME"], "whisper.jsonl"), "a", encoding="utf-8") as log:
log.write(json.dumps({"argv": sys.argv[1:], "sha256": __import__("hashlib").sha256(data).hexdigest()}) + "\\n")
if b"FAIL" in data:
sys.exit(4)
print(" hello from the [BLANK_AUDIO] mic")
print("second line")
"""
def write_engines(base: Path) -> server.Engines:
bin_dir, voices, scratch = base / "bin", base / "voices", base / "scratch"
for folder in (bin_dir, voices, scratch):
folder.mkdir()
for name, body in (("piper", FAKE_PIPER), ("whisper-cli", FAKE_WHISPER)):
path = bin_dir / name
path.write_text(f"#!{sys.executable}\n{body}", encoding="utf-8")
path.chmod(0o755)
for model, config in (("en_US-test-high", {"num_speakers": 4}), ("de_DE-solo-medium", {"num_speakers": 1}), ("zero", {"num_speakers": 0}), ("bad name!", {"num_speakers": 2})):
(voices / f"{model}.onnx").write_bytes(b"onnx")
(voices / f"{model}.onnx.json").write_text(json.dumps(config), encoding="utf-8")
(voices / "broken.onnx").write_bytes(b"onnx")
model = base / "ggml-base.bin"
model.write_bytes(b"ggml")
return server.Engines(piper=bin_dir / "piper", voices=voices, operator_voice=OPERATOR_VOICE, whisper=bin_dir / "whisper-cli", whisper_model=model, scratch=scratch)
def wav_bytes(channels: int, width: int, rate: int, frames: bytes) -> bytes:
buffer = io.BytesIO()
with wave.open(buffer, "wb") as writer:
writer.setnchannels(channels)
writer.setsampwidth(width)
writer.setframerate(rate)
writer.writeframes(frames)
return buffer.getvalue()
def log_rows(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] if path.is_file() else []
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import base64
import datetime
import functools
import hashlib
import importlib.util
import json
import os
import secrets
import subprocess
import sys
import tempfile
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
HUB = REPO_ROOT / "hub"
sys.path[:0] = [str(HUB), str(HUB / "client")]
CLIENT = HUB / "client" / "permission_hook.py"
RUNNER = HUB / "client" / "runner.py"
APPROVAL = HUB / "client" / "approval.py"
SPEC = importlib.util.spec_from_file_location("kvb_hub_server", HUB / "server.py")
server = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = server
SPEC.loader.exec_module(server)
RUNNER_SPEC = importlib.util.spec_from_file_location("kvb_hub_runner", RUNNER)
runner = importlib.util.module_from_spec(RUNNER_SPEC)
RUNNER_SPEC.loader.exec_module(runner)
import hub_api # noqa: F401
import passkey
import signing
import verifier
SCORE = "🎯 95 💥 3"
GIT_ENV = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid",
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1",
}
TREE = "0123456789abcdef0123456789abcdef01234567"
PAGE = "<!doctype html><title>diff</title><script>document.title = 'page script'</script>"
HOST_STATE = Path("/home/op/.local/state/kvb-hub")
ASK = {"tool": "Bash", "command": "make deploy", "cwd": "/repo", "session": "s1", "agent": "", "timeout": 60}
HOOK = {
"session_id": "s1",
"transcript_path": "/home/op/.claude/projects/-repo/s1.jsonl",
"cwd": "/repo",
"permission_mode": "default",
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf node_modules", "description": "Remove node_modules"},
"permission_suggestions": [],
}
OPTIONS = [{"label": "Merge now", "description": "Gate is green"}, {"label": "Wait", "description": ""}]
AMBIENCE = HUB / "templates" / "ambience.js"
STREAMS = [
{"name": "Morgen", "url": "https://radio.example/morgen", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True},
{"name": "Nacht", "url": "http://10.0.0.5:8000/nacht", "from": "22:00", "to": "02:00", "days": [5], "on": True},
{"name": "Aus", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False},
]
VOICE = HUB / "templates" / "voice.js"
SPACE = HUB / "templates" / "space.js"
class FakeScripts:
def __init__(self) -> None:
self.calls: list[tuple[str, list[str]]] = []
self.code = 0
def run(self, script: str, args: list[str]) -> dict:
self.calls.append((script, args))
return {"command": " ".join([Path(script).name, *args]), "code": self.code, "stdout": "done", "stderr": ""}
def iso(ts: float) -> str:
return datetime.datetime.fromtimestamp(ts, datetime.UTC).isoformat().replace("+00:00", "Z")
KEYS = tempfile.TemporaryDirectory()
ORIGIN = "http://localhost:8737"
PRESENT = 0x05
REGISTERED = 0x45
KEY_SPECS = {
-7: ["-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256"],
-8: ["-algorithm", "ED25519"],
-257: ["-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"],
}
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def openssl(argv: list[str], data: bytes) -> bytes:
return subprocess.run(["openssl", *argv], input=data, capture_output=True, timeout=60, check=True).stdout
def cbor_encode(value: object) -> bytes:
def head(major: int, size: int) -> bytes:
if size < 24:
return bytes([major << 5 | size])
width = next(width for width in (1, 2, 4, 8) if size < 1 << (8 * width))
return bytes([major << 5 | {1: 24, 2: 25, 4: 26, 8: 27}[width]]) + size.to_bytes(width, "big")
if isinstance(value, bool) or value is None:
return bytes([0xE0 | {False: 20, True: 21, None: 22}[value]])
if isinstance(value, int):
return head(0, value) if value >= 0 else head(1, -1 - value)
if isinstance(value, bytes):
return head(2, len(value)) + value
if isinstance(value, str):
return head(3, len(value.encode())) + value.encode()
if isinstance(value, list):
return head(4, len(value)) + b"".join(cbor_encode(entry) for entry in value)
return head(5, len(value)) + b"".join(cbor_encode(name) + cbor_encode(entry) for name, entry in value.items())
class Key:
def __init__(self, alg: int, name: str) -> None:
self.alg = alg
self.pem = Path(KEYS.name) / f"{name}{alg}.pem"
openssl(["genpkey", *KEY_SPECS[alg], "-out", str(self.pem)], b"")
self.spki = openssl(["pkey", "-in", str(self.pem), "-pubout", "-outform", "DER"], b"")
self.raw_id = os.urandom(16)
self.id = b64(self.raw_id)
def cose(self) -> dict:
if self.alg == -7:
return {1: 2, 3: -7, -1: 1, -2: self.spki[-64:-32], -3: self.spki[-32:]}
if self.alg == -8:
return {1: 1, 3: -8, -1: 6, -2: self.spki[-32:]}
modulus = openssl(["rsa", "-pubin", "-inform", "DER", "-modulus", "-noout"], self.spki).decode("ascii").strip().split("=", 1)[1]
return {1: 3, 3: -257, -1: bytes.fromhex(modulus), -2: (65537).to_bytes(3, "big")}
def sign(self, data: bytes) -> bytes:
message = Path(KEYS.name) / f"message-{secrets.token_hex(8)}"
message.write_bytes(data)
if self.alg == -8:
return openssl(["pkeyutl", "-sign", "-inkey", str(self.pem), "-rawin", "-in", str(message)], b"")
return openssl(["dgst", "-sha256", "-sign", str(self.pem), str(message)], b"")
def credential(self, origin: str, rp: str, flags: int) -> dict:
data = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + bytes(4) + bytes(16) + len(self.raw_id).to_bytes(2, "big") + self.raw_id + cbor_encode(self.cose())
client = json.dumps({"type": "webauthn.create", "challenge": b64(os.urandom(32)), "origin": origin}).encode()
return {"id": self.id, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": data})), "clientDataJSON": b64(client)}
def blob(self, origin: str) -> str:
return b64(json.dumps(self.credential(origin, "localhost", REGISTERED)).encode())
def assertion(self, payload: dict, origin: str, rp: str, flags: int, count: int) -> dict:
client = json.dumps({"type": "webauthn.get", "challenge": signing.challenge(payload), "origin": origin, "crossOrigin": False}).encode()
data = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + count.to_bytes(4, "big")
signature = self.sign(data + hashlib.sha256(client).digest())
return {"id": self.id, "clientDataJSON": b64(client), "authenticatorData": b64(data), "signature": b64(signature), "userHandle": None}
def proof(self, payload: dict, origin: str) -> dict:
return {"payload": payload, "assertion": self.assertion(payload, origin, "localhost", PRESENT, 0)}
def pin(self, path: Path, origin: str) -> None:
verifier.pin(path, passkey.attested(self.credential(origin, "localhost", REGISTERED), origin), time.time())
@functools.cache
def key(alg: int, name: str) -> Key:
return Key(alg, name)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import json
import subprocess
import unittest
from hub_support import AMBIENCE, STREAMS
SLOTS = [(0, 420, "Morgen"), (0, 539, "Morgen"), (0, 540, None), (1, 419, None), (5, 480, None), (5, 1380, "Nacht"), (6, 90, "Nacht"), (6, 120, None), (4, 1380, None), (6, 1380, None)]
PROBE = """
const A = require(process.argv[1]);
const at = (text) => Date.parse(text);
const pick = (body, keys) => Object.fromEntries(keys.map((key) => [key, body[key]]));
const surfaces = [];
for (const mode of ["light", "dark"]) {
for (const surface of ["bg", "panel"]) {
for (const sky of A.SKY.flatMap(([, top, bottom]) => [top, bottom])) {
const behind = A.mix(A.hex(sky), A.hex(A.THEMES[mode][surface]), A.GLASS);
surfaces.push({mode, surface, sky, text: A.contrast(A.hex(A.THEMES[mode].text), behind), muted: A.contrast(A.hex(A.THEMES[mode].muted), behind)});
}
}
}
const slots = JSON.parse(process.argv[3]).map(([day, minute]) => ({day, minute}));
console.log(JSON.stringify({
sun: pick(A.sun(at("1992-10-13T00:00:00Z"), 0, 0), ["ra", "dec", "eot"]),
solstice: A.sun(at("2024-06-20T20:51:00Z"), 0, 0).dec,
london: A.sun(at("2024-06-21T03:43:00Z"), 51.5074, -0.1278).alt,
moon: pick(A.moon(at("1992-04-12T00:00:00Z"), 0, 0), ["ra", "dec", "lambda", "beta", "distance", "lit", "limb", "waxing"]),
full: A.moon(at("2024-04-23T23:49:00Z"), 52.5, 13.4).lit,
fresh: A.moon(at("2024-04-08T18:21:00Z"), 52.5, 13.4).lit,
quarter: pick(A.moon(at("2024-04-15T19:13:00Z"), 52.5, 13.4), ["lit", "waxing"]),
sidereal: [A.sidereal(at("1987-04-10T00:00:00Z")), A.sidereal(at("1987-04-10T19:21:00Z"))],
stages: [-30, -15, -9, -3, 3, 20].map(A.stage),
modes: [-10, 3.9, 4, 40].map(A.modeAt),
black: A.contrast([0, 0, 0], [255, 255, 255]),
surfaces,
slot: A.slotOf(new Date(2024, 5, 21, 7, 30)),
playing: slots.map((slot) => (A.activeStream(JSON.parse(process.argv[2]), slot) || {name: null}).name),
}));
"""
class TestAmbience(unittest.TestCase):
@classmethod
def setUpClass(cls):
command = ["node", "-e", PROBE, str(AMBIENCE), json.dumps(STREAMS), json.dumps([slot[:2] for slot in SLOTS])]
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
def test_the_sun_matches_meeus_and_noaa_references(self):
sun = self.data["sun"]
self.assertAlmostEqual(sun["ra"], 198.38083, delta=0.01)
self.assertAlmostEqual(sun["dec"], -7.78507, delta=0.01)
self.assertAlmostEqual(sun["eot"], 13.71, delta=0.1)
self.assertAlmostEqual(self.data["solstice"], 23.438, delta=0.01)
self.assertAlmostEqual(self.data["london"], -0.833, delta=0.3)
self.assertAlmostEqual(self.data["sidereal"][0], 197.693195, delta=0.001)
self.assertAlmostEqual(self.data["sidereal"][1], 128.7378734, delta=0.001)
def test_the_moon_matches_meeus_within_the_low_precision_series(self):
moon = self.data["moon"]
for key, expected, delta in (("lambda", 133.162655, 0.5), ("beta", -3.229126, 0.3), ("ra", 134.68847, 0.5), ("dec", 13.768368, 0.3), ("distance", 368409.7, 3000), ("lit", 0.6786, 0.01), ("limb", 285.0, 2.0)):
with self.subTest(key=key):
self.assertAlmostEqual(moon[key], expected, delta=delta)
self.assertTrue(moon["waxing"])
self.assertGreater(self.data["full"], 0.99)
self.assertLess(self.data["fresh"], 0.01)
self.assertAlmostEqual(self.data["quarter"]["lit"], 0.5, delta=0.03)
self.assertTrue(self.data["quarter"]["waxing"])
def test_sky_stages_modes_and_text_contrast_hold_in_every_sky(self):
self.assertEqual(self.data["stages"], ["night", "astronomical", "nautical", "civil", "golden", "day"])
self.assertEqual(self.data["modes"], ["dark", "dark", "light", "light"])
self.assertAlmostEqual(self.data["black"], 21.0, places=6)
self.assertEqual(len(self.data["surfaces"]), 88)
for row in self.data["surfaces"]:
with self.subTest(mode=row["mode"], surface=row["surface"], sky=row["sky"]):
self.assertGreaterEqual(row["text"], 7.0)
self.assertGreaterEqual(row["muted"], 4.5)
def test_streams_play_on_their_weekdays_and_hours(self):
self.assertEqual(self.data["slot"], {"day": 4, "minute": 450})
self.assertEqual(self.data["playing"], [slot[2] for slot in SLOTS])
if __name__ == "__main__":
unittest.main()
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
import hashlib
import http.server
import json
import os
import secrets
import stat
import subprocess
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
from hub_support import (
APPROVAL,
ORIGIN,
PRESENT,
REGISTERED,
b64,
cbor_encode,
hub_api,
key,
passkey,
signing,
verifier,
)
from kvb_hub import language
class Redirect(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location", "http://127.0.0.1:9/elsewhere")
self.end_headers()
def log_message(self, *args):
pass
class TestApproval(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.base = Path(tmp.name)
self.approvers = self.base / "config" / "approvers.json"
self.trust = verifier.Trust(approvers=self.approvers, replay=self.base / "state" / "replay.json", origin=ORIGIN)
self.now = time.time()
self.item = signing.ask_item("0123456789abcdef", "Bash", "make test", "/repo", "s1")
def payload(self, items: list[dict], expires: int) -> dict:
return signing.approval_payload([signing.item_hash(item) for item in items], secrets.token_hex(16), expires)
def refused(self, proof: object, item: dict, gate: str, pattern: str) -> None:
with self.assertRaisesRegex(signing.Refused, pattern):
verifier.check(proof, item, gate, self.trust, self.now)
def test_valid_es256_eddsa_and_rs256_signatures_pass(self):
for alg in (-7, -8, -257):
with self.subTest(alg=alg):
signer = key(alg, "main")
signer.pin(self.approvers, ORIGIN)
verifier.check(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", self.trust, self.now)
self.assertEqual([entry["alg"] for entry in verifier.pinned(self.approvers)], [-7, -8, -257])
def test_every_broken_part_of_a_signature_is_refused(self):
signer = key(-7, "main")
signer.pin(self.approvers, ORIGIN)
payload = self.payload([self.item], int(self.now) + 60)
good = signer.proof(payload, ORIGIN)
other = signing.ask_item("fedcba9876543210", "Bash", "make test", "/repo", "s1")
cases = (
("wrong origin", {"payload": payload, "assertion": signer.assertion(payload, "http://localhost:9999", "localhost", PRESENT, 0)}, self.item, "origin"),
("http 127.0.0.1", {"payload": payload, "assertion": signer.assertion(payload, "http://127.0.0.1:8737", "localhost", PRESENT, 0)}, self.item, "origin"),
("wrong rpId", {"payload": payload, "assertion": signer.assertion(payload, ORIGIN, "evil.example", PRESENT, 0)}, self.item, "rpIdHash"),
("UP flag missing", {"payload": payload, "assertion": signer.assertion(payload, ORIGIN, "localhost", 0x04, 0)}, self.item, "user presence"),
("tampered command", good, {**self.item, "command": "make deploy"}, "does not list this item"),
("tampered cwd", good, {**self.item, "cwd": "/elsewhere"}, "does not list this item"),
("tampered session", good, {**self.item, "session": "s2"}, "does not list this item"),
("item not in the bulk list", signer.proof(self.payload([other, {**other, "id": "0000000000000000"}], int(self.now) + 60), ORIGIN), self.item, "does not list this item"),
("expired", signer.proof(self.payload([self.item], int(self.now) - 1), ORIGIN), self.item, "expired"),
("expiry too far ahead", signer.proof(self.payload([self.item], int(self.now) + signing.LIFETIME + 60), ORIGIN), self.item, "too far"),
("unpinned key", key(-8, "stranger").proof(payload, ORIGIN), self.item, "not pinned"),
("tampered payload", {"payload": {**payload, "expires": payload["expires"] + 1}, "assertion": good["assertion"]}, self.item, "challenge"),
("tampered signature", {"payload": payload, "assertion": {**good["assertion"], "signature": b64(bytes(70))}}, self.item, "does not verify"),
("tampered authenticator data", {"payload": payload, "assertion": {**good["assertion"], "authenticatorData": b64(hashlib.sha256(b"localhost").digest() + bytes([PRESENT]) + bytes([0, 0, 0, 9]))}}, self.item, "does not verify"),
("create instead of get", {"payload": payload, "assertion": {**good["assertion"], "clientDataJSON": b64(json.dumps({"type": "webauthn.create", "challenge": signing.challenge(payload), "origin": ORIGIN}).encode())}}, self.item, "webauthn.get"),
("no signature", None, self.item, "no signature"),
("rule kind smuggled", {"payload": {**payload, "kind": "rule"}, "assertion": good["assertion"]}, self.item, "no rule"),
)
for name, proof, item, pattern in cases:
with self.subTest(name=name):
self.refused(proof, item, "", pattern)
verifier.check(good, self.item, "", self.trust, self.now)
self.refused(good, self.item, "", "already used")
self.approvers.unlink()
self.refused(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", "no key is pinned")
self.approvers.parent.mkdir(exist_ok=True)
self.approvers.write_text('{"credentials": []}', encoding="utf-8")
self.refused(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", "no key is pinned")
def test_one_bulk_signature_serves_each_listed_item_once(self):
signer = key(-8, "main")
signer.pin(self.approvers, ORIGIN)
items = [signing.operator_item(f"operator/task-{number}", f"echo {number}", "/repo") for number in range(3)]
proof = signer.proof(self.payload(items, int(self.now) + 60), ORIGIN)
for item in items:
verifier.check(proof, item, "", self.trust, self.now)
for item in items:
with self.subTest(item=item["id"]):
self.refused(proof, item, "", "already used")
self.refused(proof, signing.operator_item("operator/task-3", "echo 3", "/repo"), "", "does not list")
other = verifier.Trust(approvers=self.approvers, replay=self.base / "state" / "other.json", origin=ORIGIN)
verifier.check(proof, items[0], "", other, self.now)
self.assertEqual(len(json.loads(self.trust.replay.read_text())["used"]), 3)
self.assertEqual(stat.S_IMODE(self.trust.replay.stat().st_mode), 0o600)
verifier.consume(self.trust.replay, "fresh", int(self.now) + 100, signer.id, 0, "n", self.now + 61)
self.assertEqual(list(json.loads(self.trust.replay.read_text())["used"]), ["fresh"])
self.trust.replay.write_text("not json", encoding="utf-8")
self.refused(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", "replay store")
def test_a_signature_counter_must_grow_when_it_is_kept(self):
signer = key(-7, "main")
signer.pin(self.approvers, ORIGIN)
def counted(count: int, items: list[dict]) -> dict:
payload = self.payload(items, int(self.now) + 60)
return {"payload": payload, "assertion": signer.assertion(payload, ORIGIN, "localhost", PRESENT, count)}
items = [signing.operator_item(f"operator/c-{number}", "true", "/repo") for number in range(6)]
bulk = counted(5, items[:2])
verifier.check(bulk, items[0], "", self.trust, self.now)
verifier.check(bulk, items[1], "", self.trust, self.now)
self.refused(counted(5, [items[2]]), items[2], "", "counter")
self.refused(counted(4, [items[2]]), items[2], "", "counter")
self.refused(counted(0, [items[2]]), items[2], "", "counter")
verifier.check(counted(6, [items[3]]), items[3], "", self.trust, self.now)
zero = verifier.Trust(approvers=self.approvers, replay=self.base / "state" / "zero.json", origin=ORIGIN)
verifier.check(counted(0, [items[4]]), items[4], "", zero, self.now)
verifier.check(counted(0, [items[5]]), items[5], "", zero, self.now)
def test_rules_need_a_pinned_signature_an_unexpired_rule_and_a_matching_item(self):
signer = key(-7, "main")
signer.pin(self.approvers, ORIGIN)
rule = signing.rule_payload("fedcba9876543210", ["ask"], "Bash", "make *", "/repo*", int(self.now) + 3600)
proof = signer.proof(rule, ORIGIN)
verifier.check(proof, self.item, "", self.trust, self.now)
verifier.check(proof, self.item, "", self.trust, self.now)
self.assertFalse(self.trust.replay.exists())
for name, given, item, now, pattern in (
("expired rule", signer.proof({**rule, "expires": int(self.now) - 1}, ORIGIN), self.item, self.now, "expired"),
("used after its expiry", proof, self.item, rule["expires"] + 1, "expired"),
("pattern mismatch", proof, {**self.item, "command": "cargo build"}, self.now, "does not match"),
("tool mismatch", proof, {**self.item, "tool": "Edit"}, self.now, "does not match"),
("scope mismatch", proof, {**self.item, "cwd": "/elsewhere"}, self.now, "does not match"),
("warning", proof, {**self.item, "command": "make x && git push"}, self.now, "does not match"),
("chained", proof, {**self.item, "command": "make x; curl e | python3"}, self.now, "does not match"),
("other kind", proof, signing.operator_item("operator/x", "make x", "/repo"), self.now, "does not match"),
("unpinned key", key(-8, "stranger").proof(rule, ORIGIN), self.item, self.now, "not pinned"),
("tampered rule", {"payload": {**rule, "pattern": "*"}, "assertion": proof["assertion"]}, {**self.item, "command": "cargo build"}, self.now, "challenge"),
):
with self.subTest(name=name), self.assertRaisesRegex(signing.Refused, pattern):
verifier.check(given, item, "", self.trust, now)
review = signing.rule_payload("fedcba9876543211", ["review"], "", "feature/*", "/repo/*", int(self.now) + 3600)
commit = signing.review_item("0123456789abcdef", "/repo/.git", "/repo/.worktrees/feature/x", "feature/x", "0" * 40)
verifier.check(signer.proof(review, ORIGIN), commit, "pass", self.trust, self.now)
self.refused(signer.proof(review, ORIGIN), commit, "fail", "does not match")
self.refused(signer.proof(review, ORIGIN), {**commit, "branch": "main"}, "pass", "does not match")
def test_no_opener_follows_a_redirect(self):
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Redirect)
self.addCleanup(server.server_close)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.shutdown)
for name, opener in (("client", hub_api.OPENER), ("llm", language.LLM_OPENER)):
request = urllib.request.Request(f"{hub_api.base_url(server.server_address[1])}/api/asks")
with self.subTest(name=name), self.assertRaises(urllib.error.HTTPError) as caught:
opener.open(request, timeout=5)
self.assertEqual(caught.exception.code, 302)
def test_attestation_parsing_yields_the_pinned_key_of_each_algorithm(self):
for alg in (-7, -8, -257):
with self.subTest(alg=alg):
signer = key(alg, "main")
self.assertEqual(passkey.attested(signer.credential(ORIGIN, "localhost", REGISTERED), ORIGIN), {"id": signer.id, "alg": alg, "spki": b64(signer.spki)})
signer = key(-7, "main")
good = signer.credential(ORIGIN, "localhost", REGISTERED)
attestation, _ = passkey.cbor(passkey.b64decode(good["attestationObject"], "attestationObject"), 0, 0)
data = attestation["authData"]
p384 = {1: 2, 3: -35, -1: 2, -2: bytes(48), -3: bytes(48)}
swapped = data[:55] + key(-8, "stranger").raw_id + data[71:]
for name, credential, pattern in (
("wrong origin", signer.credential("http://localhost:1", "localhost", REGISTERED), "origin"),
("wrong rpId", signer.credential(ORIGIN, "evil.example", REGISTERED), "rpIdHash"),
("no attested credential", signer.credential(ORIGIN, "localhost", PRESENT), "attested"),
("no user presence", signer.credential(ORIGIN, "localhost", 0x44), "user presence"),
("get instead of create", {**good, "clientDataJSON": b64(json.dumps({"type": "webauthn.get", "origin": ORIGIN}).encode())}, "webauthn.create"),
("other id", {**good, "id": key(-8, "stranger").id}, "differs"),
("swapped id", {**good, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": swapped}))}, "differs"),
("truncated", {**good, "attestationObject": good["attestationObject"][:40]}, "CBOR|authData"),
("P-384", {**good, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": data[:71] + cbor_encode(p384)}))}, "none of"),
("extra field", {**good, "type": "public-key"}, "exactly"),
):
with self.subTest(name=name), self.assertRaisesRegex(signing.Refused, pattern):
passkey.attested(credential, ORIGIN)
def test_the_cbor_decoder_reads_what_webauthn_sends_and_nothing_else(self):
value = {"fmt": "none", 1: -7, -2: b"\x00\xff", "list": [0, 23, 24, 255, 256, 65536, 2**32, True, False, None], "text": "ä"}
encoded = cbor_encode(value)
self.assertEqual(passkey.cbor(encoded, 0, 0), (value, len(encoded)))
for name, data in (
("indefinite array", b"\x9f\x01\xff"),
("float", b"\xfa\x00\x00\x00\x00"),
("tag", b"\xc0\x01"),
("truncated bytes", b"\x44\x00"),
("truncated length", b"\x19\x01"),
("repeated key", b"\xa2\x01\x01\x01\x02"),
("bad utf-8", b"\x61\xff"),
("too deep", b"\x81" * 40 + b"\x01"),
("empty", b""),
):
with self.subTest(name=name), self.assertRaises(signing.Refused):
passkey.cbor(data, 0, 0)
def test_both_backends_verify_and_reject_the_same(self):
backends = [passkey.by_openssl] if passkey.serialization is None else [passkey.by_openssl, passkey.by_library]
for alg in (-7, -8, -257):
signer = key(alg, "main")
data = os.urandom(64)
signature = signer.sign(data)
for backend in backends:
with self.subTest(alg=alg, backend=backend.__name__):
self.assertTrue(backend(alg, signer.spki, data, signature))
self.assertFalse(backend(alg, signer.spki, data + b"x", signature))
self.assertFalse(backend(alg, signer.spki, data, bytes(len(signature))))
self.assertFalse(backend({-7: -257, -8: -7, -257: -7}[alg], signer.spki, data, signature))
self.assertFalse(backend(-36, signer.spki, data, signature))
signer = key(-8, "main")
signer.pin(self.approvers, ORIGIN)
with mock.patch.object(passkey, "serialization", None):
verifier.check(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", self.trust, self.now)
def test_the_pin_cli_writes_the_pinned_keys_with_mode_0600(self):
approvers = self.base / "fresh" / "kvb-hub" / "approvers.json"
def pin(blob: str, port: str) -> subprocess.CompletedProcess:
argv = [sys.executable, str(APPROVAL), "pin", "--port", port, "--approvers", str(approvers), "--credential", blob]
return subprocess.run(argv, capture_output=True, text=True, timeout=60, check=False)
done = pin(key(-7, "main").blob(ORIGIN), "8737")
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn(f"pinned {key(-7, 'main').id} (ES256)", done.stdout)
self.assertEqual((stat.S_IMODE(approvers.stat().st_mode), stat.S_IMODE(approvers.parent.stat().st_mode)), (0o600, 0o700))
self.assertEqual(pin(key(-7, "main").blob(ORIGIN), "8737").returncode, 0)
self.assertEqual(pin(key(-8, "main").blob(ORIGIN), "8737").returncode, 0)
self.assertEqual([(entry["id"], entry["alg"]) for entry in verifier.pinned(approvers)], [(key(-7, "main").id, -7), (key(-8, "main").id, -8)])
self.assertEqual(stat.S_IMODE(approvers.stat().st_mode), 0o600)
before = approvers.read_bytes()
for blob, port, reason in ((key(-257, "main").blob(ORIGIN), "9999", "origin"), ("not-a-blob!", "8737", "base64url"), (b64(b"[1]"), "8737", "exactly")):
with self.subTest(reason=reason):
done = pin(blob, port)
self.assertEqual(done.returncode, 1, done.stdout)
self.assertIn(reason, done.stderr)
self.assertEqual(approvers.read_bytes(), before)
self.assertEqual(sorted(path.name for path in approvers.parent.iterdir()), ["approvers.json"])
approvers.chmod(0o664)
with self.assertRaisesRegex(signing.Refused, "writable by others"):
verifier.pinned(approvers)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import json
import socket
import subprocess
import sys
import threading
import time
import unittest
from collections.abc import Callable
from hub_case import HubCase
from hub_support import ASK, CLIENT, HOOK, OPTIONS, Key, key
class TestHubAsks(HubCase):
def first_ask(self) -> dict:
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
asks = self.snapshot()["asks"]
if asks:
return asks[0]
time.sleep(0.05)
self.fail("no ask arrived")
def hook(self, port: int, timeout: str) -> subprocess.Popen:
trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "hook.json")]
command = [sys.executable, str(CLIENT), "--port", str(port), "--timeout", timeout, *trust]
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
process.stdin.write(json.dumps(HOOK))
process.stdin.close()
self.addCleanup(process.stdout.close)
self.addCleanup(process.stderr.close)
return process
def finish(self, process: subprocess.Popen) -> tuple[int, str]:
process.wait(30)
return process.returncode, process.stdout.read()
def test_asks_are_created_waited_on_and_decided(self):
self.claim("s1", "Skills session")
bad_headers = (
{"Host": self.host},
{**self.local(), "X-Kvb-Hub": "0"},
{**self.local(), "Origin": "http://evil.example"},
{**self.local(), "Origin": "null"},
{"Host": "evil.example", "X-Kvb-Hub": "1"},
)
for headers in bad_headers:
with self.subTest(headers=headers):
self.assertEqual(self.post_json("/api/asks", ASK, headers)[0], 403)
for payload in ({**ASK, "extra": 1}, {k: v for k, v in ASK.items() if k != "cwd"}, {**ASK, "tool": "two\nlines"}, {**ASK, "timeout": -1}, [1]):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/asks", payload, self.local())[0], 400)
self.assertEqual(self.post_json("/api/asks", ASK, {**self.local(), "Origin": f"http://{self.host}"})[0], 201)
ask_id = self.create_ask(ASK)
asks = [row for row in self.snapshot()["asks"] if row["id"] == ask_id]
self.assertEqual([(row["tool"], row["command"], row["cwd"], row["agent"], row["session"]) for row in asks], [("Bash", "make deploy", "/repo", "Skills", "s1")])
self.assertEqual(self.ask_status(ask_id, "0.1"), "pending")
results = []
waiter = threading.Thread(target=lambda: results.append(self.ask_status(ask_id, "20")))
waiter.start()
time.sleep(0.3)
for headers in ({"Host": self.host, "X-Kvb-Hub": "1"}, {"Host": self.host, "Origin": f"http://{self.host}"}, {**self.guard(self.host), "Origin": "http://evil.example"}):
with self.subTest(headers=headers):
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", b"", headers)[0], 403)
signer = key(-7, "main")
stranger = self.create_ask({**ASK, "command": "make other"})
for body in (b"", b'{"proof": null}', self.proven([self.entry("ask", stranger, "", "")], signer), b'{"proof": {"payload": {}, "assertion": {}}}'):
with self.subTest(body=body[:60]):
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", body, self.guard(self.host))[0], 400)
self.assertEqual(self.ask_status(ask_id, "0"), "pending")
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", self.proven([self.entry("ask", ask_id, "", "")], signer), self.guard(self.host))[0], 200)
waiter.join(10)
self.assertEqual(results, ["allowed"])
self.assertEqual(self.get(f"/api/asks/{ask_id}?wait=0")[2].count(b'"assertion"'), 1)
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/deny", b"", self.guard(self.host))[0], 409)
other = self.create_ask(ASK)
self.assertEqual(self.call("POST", f"/api/asks/{other}/deny", b"", self.guard(f"localhost:{self.port}"))[0], 200)
self.assertEqual(self.ask_status(other, "0"), "denied")
expired = self.create_ask({**ASK, "timeout": 0})
self.assertEqual(self.ask_status(expired, "5"), "expired")
self.assertEqual(self.call("POST", f"/api/asks/{expired}/allow", b"", self.guard(self.host))[0], 409)
self.assertNotIn(expired, [row["id"] for row in self.snapshot()["asks"]])
self.assertEqual(self.call("POST", "/api/asks/0123456789abcdef/allow", b"", self.guard(self.host))[0], 404)
self.assertEqual(self.get("/api/asks/0123456789abcdef?wait=0")[0], 404)
self.assertEqual(self.get(f"/api/asks/{ask_id}/allow")[0], 405)
self.assertEqual(self.get(f"/api/asks/{ask_id}?wait=soon")[0], 400)
def test_the_hook_allows_only_a_valid_signature_and_denies_unsigned(self):
relayed = []
def asked(decide: Callable[[str], None]) -> tuple[int, str]:
process = self.hook(self.port, "20")
ask = self.first_ask()
self.assertEqual((ask["tool"], ask["command"], ask["cwd"], ask["session"]), ("Bash", "rm -rf node_modules", "/repo", "s1"))
decide(ask["id"])
return self.finish(process)
def deny(ask_id: str) -> None:
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/deny", b"", self.guard(self.host))[0], 200)
def allow_by(signer: Key) -> Callable[[str], None]:
def allow(ask_id: str) -> None:
body = self.proven([self.entry("ask", ask_id, "", "")], signer)
relayed.append(json.loads(body)["proof"])
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", body, self.guard(self.host))[0], 200)
return allow
def relay(proof: dict | None) -> Callable[[str], None]:
def compromised(ask_id: str) -> None:
self.httpd.hub.asks.decide(ask_id, {"status": "allowed", "proof": proof})
return compromised
code, out = asked(deny)
self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "deny", "message": "The operator denied this in the kvb-hub."}}}))
self.assertEqual(asked(allow_by(key(-7, "main"))), (0, ""))
self.pin()
self.assertEqual(asked(allow_by(key(-8, "stranger"))), (0, ""))
self.assertEqual(asked(relay(None)), (0, ""))
code, out = asked(allow_by(key(-7, "main")))
self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}))
self.assertEqual(asked(relay(relayed[-1])), (0, ""))
self.assertEqual(self.finish(self.hook(self.port, "0.3")), (0, ""))
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
closed = probe.getsockname()[1]
self.assertEqual(self.finish(self.hook(closed, "5")), (0, ""))
def test_decisions_are_requested_chosen_and_waited_for(self):
for arguments in (
{"question": "Merge?", "options": [], "agent": "Skills"},
{"question": "Merge?", "options": [OPTIONS[0], OPTIONS[0]], "agent": "Skills"},
{"question": "Merge?", "options": [{"label": "x"}], "agent": "Skills"},
{"question": "Merge?", "options": [{**OPTIONS[0], "extra": 1}], "agent": "Skills"},
{"question": "Merge?", "options": "Merge now", "agent": "Skills"},
{"question": " ", "options": OPTIONS, "agent": "Skills"},
{"question": "Merge?", "options": OPTIONS, "agent": "two\nlines"},
{"question": "Merge?", "options": OPTIONS},
{"question": "Merge?", "options": OPTIONS, "agent": "Skills"},
{"question": "Merge?", "options": OPTIONS, "agent": "Skills", "recommended": "Later"},
{"question": "Merge?", "options": OPTIONS, "agent": "Skills", "recommended": 5},
):
with self.subTest(arguments=arguments):
self.assertTrue(self.tool("decision_request", arguments)["isError"])
result = self.tool("decision_request", {"question": "Merge the hub?", "options": OPTIONS, "agent": "Skills", "recommended": ""})["structuredContent"]
decision_id = result["id"]
self.assertEqual((result["status"], result["url"]), ("pending", f"http://localhost:{self.port}/"))
listed = self.snapshot()["decisions"]
self.assertEqual([(row["id"], row["question"], row["agent"], [o["label"] for o in row["options"]]) for row in listed], [(decision_id, "Merge the hub?", "Skills", ["Merge now", "Wait"])])
self.assertEqual(self.tool("decision_wait", {"id": decision_id, "timeout_seconds": 0})["structuredContent"]["status"], "pending")
results = []
waiter = threading.Thread(target=lambda: results.append(self.tool("decision_wait", {"id": decision_id, "timeout_seconds": 30})))
waiter.start()
time.sleep(0.3)
path = f"/api/decisions/{decision_id}/choose"
self.assertEqual(self.post_json(path, {"label": "Merge now"}, {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403)
self.assertEqual(self.post_json(path, {"label": "Later"}, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json(path, {"label": "Merge now", "x": 1}, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json(path, {"label": "Merge now"}, self.guard(self.host))[0], 200)
waiter.join(10)
decided = results[0]["structuredContent"]
self.assertEqual((decided["status"], decided["choice"]), ("decided", "Merge now"))
self.assertIsNotNone(decided["decided"])
self.assertEqual(self.post_json(path, {"label": "Wait"}, self.guard(self.host))[0], 409)
self.assertEqual(self.snapshot()["decisions"], [])
self.assertTrue(self.tool("decision_wait", {"id": "0123456789abcdef", "timeout_seconds": 0})["isError"])
self.assertEqual(self.post_json("/api/decisions/0123456789abcdef/choose", {"label": "x"}, self.guard(self.host))[0], 404)
if __name__ == "__main__":
unittest.main()
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import dataclasses
import http.server
import json
import threading
import time
import unittest
from unittest import mock
from urllib.parse import quote
from hub_case import HubCase
from hub_support import ASK, HOST_STATE, iso, server
from kvb_hub import handler, messaging, skill_scripts, transcripts
class TestHubBoard(HubCase):
def test_uploads_are_sanitised_and_bounded(self):
headers = {**self.guard(self.host), "Content-Type": "application/octet-stream"}
status, _, body = self.call("POST", f"/api/uploads?name={quote('../../évil name.sh')}", b"data", headers)
self.assertEqual(status, 201, body)
upload = json.loads(body)
self.assertEqual(upload["name"], "_vil_name.sh")
self.assertEqual(upload["path"], str(HOST_STATE / "uploads" / upload["id"] / "_vil_name.sh"))
self.assertEqual((self.state / "uploads" / upload["id"] / "_vil_name.sh").read_bytes(), b"data")
for name, expected in (("..", "upload"), (".bashrc", "bashrc"), ("a\\b/c.txt", "c.txt"), ("x" * 300, "x" * 120)):
with self.subTest(name=name):
status, _, body = self.call("POST", f"/api/uploads?name={quote(name)}", b"1", headers)
self.assertEqual((status, json.loads(body)["name"]), (201, expected))
with mock.patch.object(handler, "UPLOAD_LIMIT", 8):
self.assertEqual(self.call("POST", "/api/uploads?name=big", b"123456789", headers)[0], 413)
self.assertEqual(self.call("POST", "/api/uploads?name=fits", b"12345678", headers)[0], 201)
self.assertEqual(self.call("POST", "/api/uploads?name=x", b"1", {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403)
self.assertEqual(self.get("/api/uploads")[0], 405)
def test_operator_messages_go_by_radio_and_verify_over_mcp(self):
self.claim("s1", "Skills session")
headers = {**self.guard(self.host), "Content-Type": "application/octet-stream"}
upload = json.loads(self.call("POST", "/api/uploads?name=plan.md", b"# plan", headers)[2])
status, _, body = self.post_json("/api/messages", {"to": "Skills", "text": "please\n rebase", "upload": upload["id"]}, self.guard(self.host))
self.assertEqual(status, 201, body)
sent = json.loads(body)
self.assertTrue(sent["ok"])
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Skills session", "--session", "Operator", f"[kvb-hub msg {sent['id']}] please rebase file: {upload['path']}"])])
verified = self.tool("operator_message", {"id": sent["id"]})["structuredContent"]
self.assertEqual((verified["to"], verified["text"], verified["upload"]), ("Skills", "please\n rebase", upload["path"]))
self.assertEqual([(row["id"], row["to"], row["upload"]) for row in self.snapshot()["messages"]], [(sent["id"], "Skills", "plan.md")])
self.assertTrue(self.tool("operator_message", {"id": "0123456789abcdef"})["isError"])
self.assertTrue(self.tool("operator_message", {"id": "../x"})["isError"])
for payload in (
{"to": "Skills", "text": " ", "upload": ""},
{"to": "Skills", "text": "hi", "upload": "0123456789abcdef"},
{"to": "Skills", "text": "hi", "upload": "../../etc"},
{"to": "two\nlines", "text": "hi", "upload": ""},
{"to": "Skills", "text": "hi"},
):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/messages", payload, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json("/api/messages", {"to": "Skills", "text": "hi", "upload": ""}, {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403)
self.assertEqual(len(self.scripts.calls), 1)
def test_board_actions_run_the_skill_scripts(self):
self.claim("s1", "Skills session")
self.claim("s2", "Gatekeeper")
now = time.time()
self.write_council({
"radio-silence.json": {"since": now, "until": now + 60, "by": "Docs", "text": "disk full"},
"night.json": {"mode": "evening", "active": "Operator", "awake": []},
"rasci.json": {"work/a": {"worker": "A", "status": "running", "prio": 2}, "work/b": {"worker": "B session", "status": "queued", "prio": 1}, "work/c": {"worker": "C", "status": "done"}},
})
state = self.snapshot()
self.assertEqual((state["silence"]["by"], state["night"], state["priority"]), ("Docs", "evening", ["B session", "A"]))
def board(action: str, payload: object) -> tuple[int, dict | None]:
status, _, body = self.post_json(f"/api/board/{action}", payload, self.guard(self.host))
return status, json.loads(body) if status == 200 else None
status, result = board("clear-mayday", {})
self.assertEqual((status, result["ok"], result["runs"][0]["stdout"]), (200, True, "done"))
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"]))
status, result = board("broadcast", {"text": "main is green"})
message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"]})["structuredContent"]["id"]
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-hub msg {message_id}] main is green"]))
self.assertEqual(board("order", {"agents": ["A", "B session"]})[0], 200)
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RASCI, ["order", "A,B session"]))
for payload in ({"agents": ["A,B"]}, {"agents": ["A", "A"]}, {"agents": []}, {"agents": ["-x"]}, {"agents": "A"}, {"text": "x"}):
with self.subTest(payload=payload):
self.assertEqual(board("order", payload)[0], 400)
calls = len(self.scripts.calls)
self.assertEqual(board("evening", {})[0], 200)
self.assertEqual(self.scripts.calls[calls:], [
(skill_scripts.NIGHT, ["set", "evening", "--session", "Operator"]),
(skill_scripts.RADIO, ["send", "direct", "--wake", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]),
])
self.assertEqual(board("sunrise", {})[0], 200)
self.assertEqual(self.scripts.calls[-2][1], ["clear", "--session", "Operator"])
self.scripts.code = 1
calls = len(self.scripts.calls)
status, result = board("sunset", {})
self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["set", "sunset", "--session", "Operator"])]))
self.assertEqual(board("reboot", {})[0], 404)
self.assertEqual(board("broadcast", {"text": " "})[0], 400)
self.assertEqual(self.post_json("/api/board/sunset", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403)
self.assertEqual(self.get("/api/board/sunset")[0], 405)
def test_nl_actions_are_validated_against_a_faked_endpoint(self):
status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host))
self.assertEqual(status, 400, body)
self.assertIn(b"KVB_HUB_LLM_URL", body)
seen: list[tuple[str, str | None, dict]] = []
replies: list[str] = []
class Fake(http.server.BaseHTTPRequestHandler):
def do_POST(self) -> None:
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
seen.append((self.path, self.headers.get("Authorization"), data))
answer = json.dumps({"choices": [{"message": {"content": replies[-1]}}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
def log_message(self, *args: object) -> None:
return
fake = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fake)
threading.Thread(target=fake.serve_forever, args=(0.05,), daemon=True).start()
self.addCleanup(fake.server_close)
self.addCleanup(fake.shutdown)
self.httpd.hub = dataclasses.replace(self.httpd.hub, model=server.Model(url=f"http://127.0.0.1:{fake.server_address[1]}/v1", model="m", key="k"))
self.claim("s1", "Skills session")
transcript = self.projects / "-repo" / "s1.jsonl"
transcript.parent.mkdir(parents=True)
transcript.write_text(self.jsonl([{"type": "user", "sessionId": "s1", "timestamp": iso(time.time()), "message": {"role": "user", "content": "SECRET-TRANSCRIPT"}}]), encoding="utf-8")
with mock.patch.object(transcripts, "STREAM_SCAN", 0):
self.assertEqual(len(self.events("kinds=transcript")["entries"]), 1)
review_id = self.request("feat: nl", "")
ask_id = self.create_ask(ASK)
decision_id = self.decision("Merge now")
replies.append(json.dumps({"actions": [
{"name": "approve", "arguments": {"ids": [review_id, decision_id]}},
{"name": "choose", "arguments": {"id": decision_id, "label": "Wait"}},
{"name": "message", "arguments": {"to": "Skills", "text": "rebase"}},
{"name": "view", "arguments": {"name": "crons"}},
{"name": "snooze", "arguments": {"ids": [ask_id], "hours": 2}},
{"name": "approve", "arguments": {"ids": ["ffffffffffffffff"]}},
{"name": "reject", "arguments": {"ids": [decision_id]}},
{"name": "message", "arguments": {"to": "Nobody", "text": "x"}},
{"name": "shutdown", "arguments": {}},
{"name": "snooze", "arguments": {"ids": [ask_id], "hours": 500}},
{"name": "view", "arguments": {"name": "crons", "extra": 1}},
]}))
status, _, body = self.post_json("/api/nl", {"text": "gib alles Grüne frei"}, self.guard(self.host))
self.assertEqual(status, 200, body)
result = json.loads(body)
self.assertEqual([action["name"] for action in result["actions"]], ["approve", "choose", "message", "view", "snooze"])
self.assertEqual(result["actions"][0]["args"]["items"], [self.entry("review", review_id, "", ""), self.entry("decision", decision_id, "Merge now", "")])
self.assertEqual(result["actions"][4]["args"], {"keys": [{"kind": "ask", "id": ask_id}], "hours": 2})
self.assertEqual(len(result["rejected"]), 6)
path, key, sent = seen[-1]
self.assertEqual((path, key, sent["model"]), ("/v1/chat/completions", "Bearer k", "m"))
self.assertNotIn("SECRET-TRANSCRIPT", json.dumps(sent))
self.assertIn(review_id, sent["messages"][1]["content"])
self.assertIn("approve", sent["messages"][0]["content"])
replies.append("not json")
self.assertEqual(self.post_json("/api/nl", {"text": "x"}, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json("/api/nl", {"text": "x"}, self.client())[0], 403)
if __name__ == "__main__":
unittest.main()
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import time
import unittest
from unittest import mock
import hub_support
from hub_case import HubCase
from hub_support import (
APPROVAL,
ASK,
GIT_ENV,
PAGE,
PRESENT,
RUNNER,
TREE,
runner,
signing,
verifier,
)
from kvb_hub import batches, rules, skill_scripts
# An import named key trips F402: test_rules_are_signed_once_and_checked_by_the_executor rebinds it in a comprehension.
key = hub_support.key
class TestHubExecutors(HubCase):
def trust(self, name: str) -> verifier.Trust:
return verifier.Trust(approvers=self.approvers, replay=self.replay / f"{name}.json", origin=self.origin)
def runner_jobs(self) -> list[dict]:
status, _, body = self.call("GET", "/api/runner/jobs", None, self.client())
self.assertEqual(status, 200, body)
return json.loads(body)["jobs"]
def run_runner(self) -> None:
trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")]
command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, "--once"]
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr)
def test_the_runner_runs_only_signed_commands_once(self):
command = "echo hello && echo world >&2"
register = {
"operator/greet": {"title": command, "workdir": str(self.outside), "description": "say hi", "check": "manual", "status": "queued", "roles": {}},
"operator/decide": {"title": "pick a name", "workdir": str(self.outside), "check": "decision", "status": "queued", "roles": {}},
}
self.write_council({"rasci.json": register})
items = {item["id"]: item for item in self.snapshot()["inbox"]}
greet = items["operator/greet"]
digest = hashlib.sha256(command.encode()).hexdigest()
self.assertEqual((greet["runnable"], greet["sha256"], items["operator/decide"]["runnable"]), (True, digest, False))
self.assertEqual(self.approve([self.entry("operator", "operator/greet", "", "0" * 64)], None)[0], 409)
self.assertEqual(self.approve([self.entry("operator", "operator/decide", "", "")], None)[0], 400)
self.assertEqual(self.approve([self.entry("operator", "operator/greet", "", digest)], None)[0], 400)
self.assertEqual(self.call("GET", "/api/runner/jobs", None, {"Host": self.host})[0], 403)
self.assertEqual(self.call("GET", "/api/runner/jobs", None, self.guard(self.host))[0], 403)
signer = self.pin()
greeting = [self.entry("operator", "operator/greet", "", digest)]
with mock.patch.object(batches, "UNDO", 0.05):
self.assertEqual(self.approve(greeting, self.signed(greeting, signer))[0], 202)
self.settle(lambda: self.runner_jobs() != [])
queued = self.runner_jobs()
self.assertEqual([(job["task"], job["command"], job["directory"], job["sha256"]) for job in queued], [("operator/greet", command, str(self.outside), digest)])
proof = queued[0]["proof"]
self.assertIn(signing.item_hash(signing.operator_item("operator/greet", command, str(self.outside))), proof["payload"]["items"])
self.assertNotIn("operator/greet", [item["id"] for item in self.snapshot()["inbox"]])
self.run_runner()
job = self.snapshot()["jobs"][-1]
self.assertEqual((job["status"], job["code"], job["output"]), ("done", 0, "hello\nworld\n"))
self.assertNotIn("proof", job)
self.assertEqual(self.scripts.calls[-1], (skill_scripts.OPERATOR_SCRIPT, ["done", "greet"]))
claim = {**self.client(), "Content-Type": "application/json"}
self.assertEqual(self.call("POST", f"/api/runner/{job['id']}/claim", b"{}", claim)[0], 409)
self.assertEqual(self.call("POST", f"/api/runner/{job['id']}/claim", b"{}", {**self.guard(self.host), "Content-Type": "application/json"})[0], 403)
stranger = key(-8, "stranger").proof(proof["payload"], self.origin)
forged = (
("echo evil", str(self.outside), proof, "does not list this item"),
(command, "/", proof, "does not list this item"),
(command, str(self.outside), proof, "already used"),
(command, str(self.outside), None, "no signature"),
(command, str(self.outside), stranger, "not pinned"),
)
for text, directory, relayed, reason in forged:
with self.subTest(command=text, directory=directory, reason=reason):
fields = {"task": "operator/greet", "command": text, "directory": directory, "sha256": digest, "status": "queued", "proof": relayed}
tampered = self.httpd.hub.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
self.run_runner()
record = self.httpd.hub.jobs.get(tampered["id"])
self.assertEqual(record["status"], "refused")
self.assertTrue(record["output"].startswith(runner.REFUSED), record["output"])
self.assertIn(reason, record["output"])
self.assertEqual(len([call for call in self.scripts.calls if call[0] == skill_scripts.OPERATOR_SCRIPT]), 1)
self.approvers.unlink()
job = {"task": "operator/greet", "command": command, "directory": str(self.outside), "proof": proof}
self.assertIn("no key is pinned", runner.refusal(job, self.trust("other"), time.time()))
reports = []
self.assertEqual(runner.execute({"command": "exit 3", "directory": str(self.outside)}, reports.append), 3)
self.assertEqual(reports[-1], {"output": "", "code": 3, "refused": False})
def test_rules_are_signed_once_and_checked_by_the_executor(self):
now = time.time()
rule = {"kind": "ask", "tool": "Bash", "pattern": "make *", "scope": "/repo*", "expires": now + 3600}
for payload in (
{**rule, "expires": now - 1},
{**rule, "expires": now + 400 * 86400},
{**rule, "tool": ""},
{**rule, "kind": "review"},
{**rule, "pattern": " "},
{**rule, "scope": "two\nlines"},
{key: value for key, value in rule.items() if key != "scope"},
):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/rules/sign", payload, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json("/api/rules/sign", rule, self.client())[0], 403)
signer = self.pin()
def prepared(fields: dict) -> dict:
status, _, body = self.post_json("/api/rules/sign", fields, self.guard(self.host))
self.assertEqual(status, 200, body)
return json.loads(body)
def created(payload: dict, assertion: dict | None) -> tuple[int, dict, bytes]:
return self.post_json("/api/rules", {"rule": payload, "assertion": assertion, "enabled": True}, self.guard(self.host))
ask_rule = prepared(rule)
self.assertEqual((ask_rule["sign"], ask_rule["challenge"], ask_rule["credentials"]), (True, signing.challenge(ask_rule["payload"]), [signer.id]))
signed = signer.assertion(ask_rule["payload"], self.origin, "localhost", PRESENT, 0)
for payload, assertion in (
(ask_rule["payload"], None),
({**ask_rule["payload"], "kinds": ["ask", "review"]}, signed),
({**ask_rule["payload"], "expires": now + 400 * 86400}, signed),
({**ask_rule["payload"], "id": "x"}, signed),
):
with self.subTest(payload=payload):
self.assertEqual(created(payload, assertion)[0], 400)
self.assertEqual(self.post_json("/api/rules", {"rule": ask_rule["payload"], "assertion": signed, "enabled": True}, self.client())[0], 403)
asks = json.loads(created(ask_rule["payload"], signed)[2])
self.assertEqual(created(ask_rule["payload"], signed)[0], 409)
review_rule = prepared({"kind": "review", "tool": "", "pattern": "feature/*", "scope": "/repo/*", "expires": now + 3600})
self.assertEqual(created(review_rule["payload"], signer.assertion(review_rule["payload"], self.origin, "localhost", PRESENT, 0))[0], 201)
decision_rule = prepared({"kind": "decision", "tool": "", "pattern": "*", "scope": "*", "expires": now + 3600})
self.assertFalse(decision_rule["sign"])
self.assertEqual(created(decision_rule["payload"], signed)[0], 400)
self.assertEqual(created(decision_rule["payload"], None)[0], 201)
forged_rule = prepared({**rule, "pattern": "cargo *"})
self.assertEqual(created(forged_rule["payload"], key(-8, "stranger").assertion(forged_rule["payload"], self.origin, "localhost", PRESENT, 0))[0], 201)
self.assertEqual(sorted((entry["kind"], entry["signed"]) for entry in self.snapshot()["rules"]), [("ask", True), ("ask", True), ("decision", False), ("review", True)])
with mock.patch.object(batches, "UNDO", 0.05):
matched = self.create_ask(ASK)
risky = self.create_ask({**ASK, "command": "make deploy && git push"})
other = self.create_ask({**ASK, "tool": "Edit"})
outside = self.create_ask({**ASK, "cwd": "/elsewhere"})
cargo = self.create_ask({**ASK, "command": "cargo build"})
green = self.request("feat: auto", "")
red = self.tool("review_request", {**self.arguments("feat: red", "", self.page("red.html", PAGE)), "gate": "fail"})["structuredContent"]["id"]
chosen = self.decision("Wait")
plain = self.decision("")
self.settle(lambda: self.ask_status(matched, "0") == "allowed")
self.settle(lambda: self.ask_status(cargo, "0") == "allowed")
self.settle(lambda: self.status(green) == "approved")
self.settle(lambda: self.choice(chosen) == "Wait")
time.sleep(0.3)
self.assertEqual([self.ask_status(ask, "0") for ask in (risky, other, outside)], ["pending", "pending", "pending"])
self.assertEqual((self.status(red), self.choice(plain)), ("pending", None))
self.assertEqual(len(self.events("kinds=rule")["events"]), 4)
hook = self.trust("hook")
verifier.check(json.loads(self.get(f"/api/asks/{matched}?wait=0")[2])["proof"], signing.ask_item(matched, "Bash", "make deploy", "/repo", "s1"), "", hook, time.time())
with self.assertRaisesRegex(signing.Refused, "not pinned"):
verifier.check(json.loads(self.get(f"/api/asks/{cargo}?wait=0")[2])["proof"], signing.ask_item(cargo, "Bash", "cargo build", "/repo", "s1"), "", hook, time.time())
relayed = json.loads(self.get(f"/api/reviews/{green}")[2])["proof"]
commit = signing.review_item(green, "/repo/.git", "/repo/.worktrees/feature/x", "feature/x", TREE)
verifier.check(relayed, commit, "pass", self.trust("commit"), time.time())
with self.assertRaisesRegex(signing.Refused, "does not match"):
verifier.check(relayed, commit, "fail", self.trust("commit"), time.time())
self.assertEqual(self.call("POST", f"/api/rules/{asks['id']}/disable", b"", self.guard(self.host))[0], 200)
with mock.patch.object(batches, "UNDO", 0.05):
later = self.create_ask(ASK)
time.sleep(0.3)
self.assertEqual(self.ask_status(later, "0"), "pending")
self.assertEqual(self.call("POST", f"/api/rules/{asks['id']}/delete", b"", self.guard(self.host))[0], 200)
self.assertEqual(self.call("POST", f"/api/rules/{asks['id']}/delete", b"", self.guard(self.host))[0], 404)
stored = {**asks, "enabled": True}
subject = {"item": signing.ask_item(later, "Bash", "make deploy", "/repo", "s1"), "gate": ""}
self.assertTrue(rules.rule_matches(stored, "ask", subject, now))
self.assertFalse(rules.rule_matches({**stored, "expires": now - 1}, "ask", subject, now))
self.assertFalse(rules.rule_matches({**stored, "proof": None}, "ask", subject, now))
self.assertFalse(rules.rule_matches(stored, "ask", subject, stored["proof"]["payload"]["expires"] + 1))
def test_the_commit_check_verifies_the_staged_tree_before_a_commit(self):
repo = self.outside / "repo"
repo.mkdir()
self.git(repo, "init", "--quiet", "--initial-branch", "feature/x")
(repo / "a.txt").write_text("one\n", encoding="utf-8")
self.git(repo, "add", "a.txt")
tree = self.git(repo, "write-tree").strip()
common = self.git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()
top = self.git(repo, "rev-parse", "--show-toplevel").strip()
arguments = {**self.arguments("feat: signed", "", self.page("signed.html", PAGE)), "repo": common, "worktree": top, "tree": tree}
review_id = self.tool("review_request", arguments)["structuredContent"]["id"]
def verify(gate: str) -> subprocess.CompletedProcess:
argv = ["verify", "--port", str(self.port), "--approvers", str(self.approvers), "--replay", str(self.replay / "commit.json"), "--review", review_id, "--gate", gate]
return subprocess.run([sys.executable, str(APPROVAL), *argv], cwd=repo, env={**os.environ, **GIT_ENV}, capture_output=True, text=True, timeout=60, check=False)
done = verify("pass")
self.assertEqual(done.returncode, 1, done.stdout)
self.assertIn("the review is pending", done.stderr)
signer = self.pin()
entries = [self.entry("review", review_id, "", "")]
with mock.patch.object(batches, "UNDO", 0.05):
self.assertEqual(self.approve(entries, self.signed(entries, signer))[0], 202)
self.settle(lambda: self.status(review_id) == "approved")
(repo / "a.txt").write_text("two\n", encoding="utf-8")
self.git(repo, "add", "a.txt")
done = verify("pass")
self.assertEqual(done.returncode, 1, done.stdout)
self.assertIn("does not list this item", done.stderr)
(repo / "a.txt").write_text("one\n", encoding="utf-8")
self.git(repo, "add", "a.txt")
done = verify("pass")
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn(f"tree {tree} on feature/x", done.stdout)
done = verify("pass")
self.assertEqual(done.returncode, 1, done.stdout)
self.assertIn("already used", done.stderr)
if __name__ == "__main__":
unittest.main()
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import json
import math
import os
import time
import unittest
from unittest import mock
from hub_case import HubCase
from hub_support import iso
from kvb_hub import lifetimes, names, orbits
class TestHubGraph(HubCase):
def test_affinity_pulls_talking_agents_together(self):
now = 1_000_000.0
nodes = [{"id": name, "kind": kind, "parent": None} for name, kind in (("Operator", "operator"), ("Maintainer", "chair"), ("Alpha", "session"), ("Beta", "session"), ("Gamma", "session"))]
talk = [{"ts": now - n * 60, "source": "Alpha" if n % 2 else "Beta", "targets": ["Beta" if n % 2 else "Alpha"], "class": "direct", "text": "x"} for n in range(20)]
def spot(layout: dict, name: str) -> tuple[float, float]:
place = layout["orbit"][name]
return place["radius"] * math.cos(place["angle"]), place["radius"] * math.sin(place["angle"])
layout = orbits.affinity(nodes, talk, {}, [], now, str)
self.assertEqual(layout, orbits.affinity(nodes, talk, {}, [], now, str))
self.assertEqual(set(layout["order"]), {"Maintainer", "Alpha", "Beta", "Gamma"})
self.assertLess(math.dist(spot(layout, "Alpha"), spot(layout, "Beta")), math.dist(spot(layout, "Alpha"), spot(layout, "Gamma")))
self.assertLess(math.dist(spot(layout, "Alpha"), spot(layout, "Beta")), math.dist(spot(layout, "Beta"), spot(layout, "Gamma")))
self.assertLess(layout["orbit"]["Alpha"]["radius"], layout["orbit"]["Gamma"]["radius"])
self.assertEqual(layout["orbit"]["Gamma"]["radius"], orbits.OUTER)
old = [{**row, "ts": now - 86400} for row in talk]
fresh = [{"ts": now - 60, "source": "Gamma", "targets": ["Beta"], "class": "direct", "text": "x"}] * 3
later = orbits.affinity(nodes, old + fresh, {}, [], now, str)
self.assertLess(math.dist(spot(later, "Beta"), spot(later, "Gamma")), math.dist(spot(later, "Alpha"), spot(later, "Beta")))
shared = orbits.affinity(nodes, [], {"work/x": {"status": "running", "worker": "Alpha", "roles": {"Gamma": "C"}}}, [], now, str)
self.assertLess(math.dist(spot(shared, "Alpha"), spot(shared, "Gamma")), math.dist(spot(shared, "Alpha"), spot(shared, "Beta")))
self.assertIn("layout", self.snapshot()["graph"])
def test_the_track_samples_the_affinity_over_the_window_on_request(self):
now = time.time()
news = [{"ts": now - 3000 + n * 60, "from": "Beta" if n % 2 else "Alpha", "to": ["Alpha" if n % 2 else "Beta"], "text": "x"} for n in range(30)]
news.append({"ts": now - 100, "from": "Gamma", "to": [], "text": "here"})
self.write_council({"news.jsonl": self.jsonl(news)})
self.assertEqual(self.snapshot()["graph"]["track"], [])
status, _, body = self.get(f"/api/state?from={int(now - 3600)}&track=1")
self.assertEqual(status, 200, body)
state = json.loads(body)
graph, track = state["graph"], state["graph"]["track"]
stamps = [sample["ts"] for sample in track]
self.assertEqual(len(track), orbits.TRACK_STEPS + 1)
self.assertEqual((stamps[0], stamps[-1]), (state["first"], state["until"]))
self.assertEqual(stamps, sorted(stamps))
self.assertEqual(track[-1]["orbit"], graph["layout"]["orbit"])
for sample in track:
self.assertEqual(set(sample["orbit"]), set(graph["layout"]["order"]))
keys = {node["label"]: node["id"] for node in graph["nodes"]}
self.assertEqual([sample["orbit"][keys["Gamma"]]["radius"] for sample in track[:-1]], [orbits.OUTER] * orbits.TRACK_STEPS)
self.assertLess(track[0]["orbit"][keys["Alpha"]]["radius"], orbits.OUTER)
self.assertLess(track[-1]["orbit"][keys["Alpha"]]["radius"], track[-1]["orbit"][keys["Gamma"]]["radius"])
for broken in ("0", "yes", "1&track=1"):
with self.subTest(broken=broken):
self.assertEqual(self.get(f"/api/state?track={broken}")[0], 400)
def test_a_rename_keeps_the_session_and_shows_the_name_of_its_moment(self):
now = time.time()
before = {"ts": now - 100, "from": "Skills", "to": ["Docs"], "text": "before"}
claim = {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills", "since": iso(now - 200), "beat": now, "work": "x"}
self.write_council({"work/s1/claim.json": claim, "news.jsonl": self.jsonl([before])})
self.assertEqual([row["agent"] for row in self.events("kinds=radio")["events"]], ["Skills"])
folder = self.projects / "-repo" / "s1"
folder.mkdir(parents=True)
title = folder / "custom-title.json"
title.write_text(json.dumps({"customTitle": "IAM"}), encoding="utf-8")
os.utime(title, (now - 50, now - 50))
self.write_council({"news.jsonl": self.jsonl([before, {"ts": now - 20, "from": "IAM", "to": ["Docs"], "text": "after"}])})
with mock.patch.object(names, "TITLE_SCAN", 0):
rows = self.events("kinds=radio,rename")["events"]
self.assertEqual([(row["kind"], row["session"], row["agent"], row["now"]) for row in rows], [
("radio", "s1", "Skills", "IAM"),
("rename", "s1", "IAM", "IAM"),
("radio", "s1", "IAM", "IAM"),
])
self.assertEqual(rows[1]["title"], "Skills heißt jetzt IAM")
self.assertAlmostEqual(rows[1]["ts"], now - 50, places=3)
for name in ("Skills", "IAM"):
with self.subTest(name=name):
self.assertEqual([row["text"] for row in self.events(f"kinds=radio&agent={name}")["events"]], ["before", "after"])
snapshot = self.snapshot()
self.assertEqual([row["speech"] for row in snapshot["radio"]], ["Docs, Docs, this is Skills, before, over", "Docs, Docs, this is IAM, after, over"])
self.assertEqual({row["voice"] for row in snapshot["radio"]}, {self.httpd.hub.speech.voice_of("s1")})
live = {node["id"]: node for node in snapshot["graph"]["nodes"]}
self.assertEqual((live["s1"]["label"], live["s1"]["names"]), ("IAM", ["Skills", "IAM"]))
self.assertEqual([entry["name"] for entry in live["s1"]["timeline"]], ["Skills", "IAM"])
for entry, since in zip(live["s1"]["timeline"], (now - 200, now - 50), strict=True):
self.assertAlmostEqual(entry["since"], since, places=3)
self.assertEqual(live["Operator"]["timeline"], [{"name": "Operator", "since": snapshot["until"]}])
status, _, body = self.get(f"/api/state?from={int(now - 300)}&to={int(now - 80)}")
self.assertEqual(status, 200, body)
replay = {node["id"]: node for node in json.loads(body)["graph"]["nodes"]}
self.assertEqual(replay["s1"]["label"], "Skills")
self.assertEqual(replay["s1"]["timeline"], live["s1"]["timeline"])
stored = json.loads((self.state / "names.json").read_text(encoding="utf-8"))["entries"]
self.assertEqual([(entry["session"], entry["name"], entry["source"]) for entry in stored], [("s1", "Skills", "claim"), ("s1", "IAM", "title")])
def test_birth_and_death_dates_follow_the_transcripts(self):
self.assertEqual((lifetimes.lifetime_stamp(1_700_000_000.0), lifetimes.lifetime_stamp(None)), ("2023-11-14T22:13Z", ""))
now = time.time()
self.claim("s1", "Skills")
main = self.projects / "-repo" / "s1.jsonl"
subagents = self.projects / "-repo" / "s1" / "subagents"
subagents.mkdir(parents=True)
def line(ts: float) -> dict:
return {"type": "user", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "user", "content": "x"}}
main.write_text(self.jsonl([line(now - 3 * 86400), line(now - 2 * 86400)]), encoding="utf-8")
for ident, finished in (("abc", True), ("def", False)):
(subagents / f"agent-{ident}.meta.json").write_text(json.dumps({"agentType": "Explore", "description": ident, "spawnDepth": 1, "finished": finished}), encoding="utf-8")
(subagents / f"agent-{ident}.jsonl").write_text(self.jsonl([line(now - 3 * 86400 + 60), line(now - 3 * 86400 + 120)]), encoding="utf-8")
def life() -> dict:
status, _, body = self.get("/api/subagents?session=s1")
self.assertEqual(status, 200, body)
return json.loads(body)
born, ended = lifetimes.lifetime_stamp(now - 3 * 86400), lifetimes.lifetime_stamp(now - 2 * 86400)
sub_born, sub_died = lifetimes.lifetime_stamp(now - 3 * 86400 + 60), lifetimes.lifetime_stamp(now - 3 * 86400 + 120)
data = life()
self.assertEqual((data["born"], data["died"], data["until"]), (born, "", None))
self.assertAlmostEqual(data["since"], now - 3 * 86400, places=3)
self.assertEqual([(row["id"], row["born"], row["died"]) for row in data["subagents"]], [("abc", sub_born, sub_died), ("def", sub_born, "")])
self.assertAlmostEqual(data["subagents"][0]["until"], now - 3 * 86400 + 120, places=3)
self.assertEqual(data["subagents"][1]["until"], None)
for row in data["subagents"]:
self.assertAlmostEqual(row["since"], now - 3 * 86400 + 60, places=3)
os.utime(main, (now - 2 * 86400, now - 2 * 86400))
self.assertEqual(life()["died"], "")
self.write_council({"work/s1/claim.json": {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills", "since": iso(now - 4 * 86400), "beat": now - 2 * 86400, "work": "x"}})
self.assertEqual((life()["born"], life()["died"]), (born, ended))
self.assertAlmostEqual(life()["until"], now - 2 * 86400, places=3)
if __name__ == "__main__":
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import json
import time
import unittest
from unittest import mock
from hub_case import HubCase
from hub_support import ASK, PAGE, TREE, key, signing
from kvb_hub import batches
class TestHubInbox(HubCase):
def test_inbox_bulk_approval_waits_for_the_undo_window(self):
green = self.request("feat: green", "")
red = self.tool("review_request", {**self.arguments("feat: red", "", self.page("red.html", PAGE)), "gate": "fail"})["structuredContent"]["id"]
ask_id = self.create_ask(ASK)
risky = self.create_ask({**ASK, "command": "rm -rf build"})
decision_id = self.decision("Merge now")
inbox = {item["id"]: item for item in self.snapshot()["inbox"]}
self.assertEqual([inbox[key]["green"] for key in (green, red, ask_id, risky, decision_id)], [True, False, True, False, True])
self.assertEqual(inbox[risky]["warnings"], ["löscht rekursiv"])
batch = [self.entry("review", green, "", ""), self.entry("ask", ask_id, "", ""), self.entry("decision", decision_id, "Merge now", "")]
signer = key(-7, "main")
self.assertEqual(self.approve([self.entry("review", green, "", ""), self.entry("review", red, "", "")], None)[0], 400)
self.assertEqual(self.approve([self.entry("decision", decision_id, "Later", "")], None)[0], 400)
self.assertEqual(self.approve([self.entry("review", green, "", ""), self.entry("review", green, "", "")], None)[0], 400)
self.assertEqual(self.approve([self.entry("review", "0123456789abcdef", "", "")], None)[0], 404)
self.assertEqual(self.post_json("/api/inbox/approve", {"items": batch, "proof": None}, self.client())[0], 403)
prepared = self.prepare(batch)
listed = [signing.item_hash(signing.review_item(green, "/repo/.git", "/repo/.worktrees/feature/x", "feature/x", TREE)), signing.item_hash(signing.ask_item(ask_id, "Bash", "make deploy", "/repo", "s1"))]
self.assertEqual((prepared["payload"]["items"], prepared["challenge"]), (sorted(listed), signing.challenge(prepared["payload"])))
unsigned = [self.entry("decision", decision_id, "Merge now", ""), {**self.entry("ask", risky, "", ""), "verb": "reject"}]
self.assertIsNone(self.prepare(unsigned)["payload"])
partial = signer.proof(self.prepare(batch[:1])["payload"], self.origin)
for proof in (None, partial, {"payload": prepared["payload"]}, {"payload": {**prepared["payload"], "expires": int(time.time()) - 1}, "assertion": {}}):
with self.subTest(proof=str(proof)[:80]):
self.assertEqual(self.approve(batch, proof)[0], 400)
self.assertEqual(self.approve(unsigned, partial)[0], 400)
proof = signer.proof(prepared["payload"], self.origin)
with mock.patch.object(batches, "UNDO", 0.5):
status, _, body = self.approve(batch, proof)
self.assertEqual(status, 202, body)
first = json.loads(body)
self.assertNotIn("proof", first)
self.assertEqual(self.approve([batch[0]], None)[0], 409)
self.assertTrue(all(item["held"] for item in self.snapshot()["inbox"] if item["id"] in (green, ask_id, decision_id)))
self.assertEqual(self.call("POST", f"/api/inbox/undo/{first['id']}", b"", self.guard(self.host))[0], 200)
time.sleep(0.8)
self.assertEqual((self.status(green), self.ask_status(ask_id, "0"), self.choice(decision_id)), ("pending", "pending", None))
self.assertIsNone(json.loads(self.get(f"/api/reviews/{green}")[2])["proof"])
second = json.loads(self.approve(batch, proof)[2])
self.assertEqual(self.status(green), "pending")
self.assertIsNone(json.loads(self.get(f"/api/asks/{ask_id}?wait=0")[2])["proof"])
self.settle(lambda: self.status(green) == "approved")
self.settle(lambda: self.choice(decision_id) == "Merge now")
self.assertEqual(self.ask_status(ask_id, "0"), "allowed")
self.assertEqual(json.loads(self.get(f"/api/asks/{ask_id}?wait=0")[2])["proof"], proof)
self.assertEqual(json.loads(self.get(f"/api/reviews/{green}")[2])["proof"], proof)
self.assertNotIn(b'"assertion"', self.get("/api/state")[2])
self.assertEqual(self.call("POST", f"/api/inbox/undo/{second['id']}", b"", self.guard(self.host))[0], 409)
self.assertEqual(self.call("POST", "/api/inbox/undo/0123456789abcdef", b"", self.guard(self.host))[0], 404)
with mock.patch.object(batches, "UNDO", 0.05):
self.assertEqual(self.approve([self.entry("review", red, "", "")], self.signed([self.entry("review", red, "", "")], signer))[0], 202)
self.settle(lambda: self.status(red) == "approved")
until = time.time() + 3600
self.assertEqual(self.post_json("/api/inbox/mark", {"kind": "ask", "id": risky, "until": until, "pinned": True}, self.guard(self.host))[0], 200)
top = self.snapshot()["inbox"][0]
self.assertEqual((top["id"], top["pinned"], round(top["until"])), (risky, True, round(until)))
for payload in (
{"kind": "ask", "id": risky, "until": time.time() - 5, "pinned": False},
{"kind": "ask", "id": risky, "until": time.time() + 9 * 86400, "pinned": False},
{"kind": "ask", "id": risky, "until": 0, "pinned": "yes"},
{"kind": "radio", "id": risky, "until": 0, "pinned": False},
):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/inbox/mark", payload, self.guard(self.host))[0], 400)
titles = [row["title"] for row in self.events("kinds=approval")["events"]]
self.assertIn("Freigabe review: feat: green", titles)
self.assertTrue(any(title.startswith("Rückgängig: ") for title in titles))
def test_the_sign_endpoint_and_the_page_offer_the_pinned_keys_only(self):
ask_id = self.create_ask(ASK)
prepared = self.prepare([self.entry("ask", ask_id, "", "")])
self.assertEqual((prepared["credentials"], self.snapshot()["approvers"]), ([], []))
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.ask_item(ask_id, "Bash", "make deploy", "/repo", "s1"))])
self.assertTrue(time.time() < prepared["payload"]["expires"] <= time.time() + signing.LIFETIME)
self.assertRegex(prepared["payload"]["nonce"], r"^[0-9a-f]{32}$")
self.assertNotEqual(prepared["payload"]["nonce"], self.prepare([self.entry("ask", ask_id, "", "")])["payload"]["nonce"])
self.assertEqual(self.post_json("/api/sign", {"items": [self.entry("ask", ask_id, "", "")]}, self.client())[0], 403)
signer = self.pin()
key(-8, "main").pin(self.approvers, self.origin)
self.assertEqual(self.prepare([self.entry("ask", ask_id, "", "")])["credentials"], [signer.id, key(-8, "main").id])
self.assertEqual(self.snapshot()["approvers"], [{"id": signer.id, "alg": "ES256"}, {"id": key(-8, "main").id, "alg": "EdDSA"}])
self.approvers.chmod(0o666)
self.assertEqual(self.snapshot()["approvers"], [])
html = self.index()
for text in ("Passkey registrieren", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-hub/approvers.json", "nicht signiert"):
with self.subTest(text=text):
self.assertIn(text, html)
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import json
import unittest
from hub_case import HubCase
from hub_support import PAGE, SCORE, TREE
from kvb_hub import mcp
TOOLS = {"review_request", "review_status", "review_wait", "review_list", "decision_request", "decision_wait", "operator_message"}
class TestHubMcp(HubCase):
def test_mcp_round_trip(self):
init = self.rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}})
self.assertEqual(init["result"]["protocolVersion"], "2025-06-18")
self.assertIn("tools", init["result"]["capabilities"])
self.assertEqual(init["result"]["serverInfo"]["name"], "kvb-hub")
self.assertEqual(self.rpc("initialize", {"protocolVersion": "1999-01-01"})["result"]["protocolVersion"], mcp.PROTOCOLS[0])
status, _, body = self.post_mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}, {})
self.assertEqual((status, body), (202, b""))
self.assertEqual(self.rpc("ping", {})["result"], {})
tools = self.rpc("tools/list", {})["result"]["tools"]
self.assertEqual({t["name"] for t in tools}, TOOLS)
for entry in tools:
with self.subTest(tool=entry["name"]):
schema = entry["inputSchema"]
self.assertEqual((schema["type"], schema["additionalProperties"]), ("object", False))
self.assertEqual(sorted(schema["required"]), sorted(schema["properties"]))
review_id = self.request("feat: hub", "table")
self.assertRegex(review_id, r"^[0-9a-f]{16}$")
state = self.tool("review_status", {"id": review_id})
self.assertEqual(json.loads(state["content"][0]["text"]), state["structuredContent"])
self.assertEqual(state["structuredContent"], {"id": review_id, "status": "pending", "tree": TREE, "decided": None})
listed = self.tool("review_list", {})["structuredContent"]["reviews"]
self.assertEqual([(r["id"], r["title"], r["tree"]) for r in listed], [(review_id, "feat: hub", TREE)])
self.assertEqual(self.rpc("resources/list", {})["error"]["code"], -32601)
self.assertEqual(self.rpc("tools/call", {"name": "review_approve", "arguments": {"id": review_id}})["error"]["code"], -32602)
status, _, body = self.call("POST", "/mcp", b"{not json", {"Host": self.host, "Content-Type": "application/json"})
self.assertEqual((status, json.loads(body)["error"]["code"]), (400, -32700))
def test_tool_arguments_are_validated(self):
good = self.arguments("feat: x", "", self.page("bundle.html", PAGE))
for name, arguments in (
("review_request", {k: v for k, v in good.items() if k != "notes"}),
("review_request", {k: v for k, v in good.items() if k != "repo"}),
("review_request", {**good, "repo": "relative/.git"}),
("review_request", {**good, "extra": "1"}),
("review_request", {**good, "tree": "HEAD"}),
("review_request", {**good, "title": " "}),
("review_request", {**good, "title": "two\nlines"}),
("review_request", {**good, "worktree": "relative/path"}),
("review_request", {**good, "branch": "has space"}),
("review_request", {**good, "notes": 5}),
("review_request", {k: v for k, v in good.items() if k != "gate"}),
("review_request", {k: v for k, v in good.items() if k != "score"}),
("review_request", {**good, "gate": "maybe"}),
("review_request", {**good, "score": 5}),
("review_request", {**good, "score": "x" * 501}),
("review_status", {"id": "../../etc"}),
("review_status", {"id": "0123456789abcdef"}),
("review_wait", {"id": "0123456789abcdef", "timeout_seconds": 1}),
("review_list", {"extra": 1}),
):
with self.subTest(name=name, arguments=arguments):
self.assertTrue(self.tool(name, arguments)["isError"])
review_id = self.request("feat: x", "")
for timeout in (-1, True, "5", None, float("nan"), float("inf")):
with self.subTest(timeout=timeout):
self.assertTrue(self.tool("review_wait", {"id": review_id, "timeout_seconds": timeout})["isError"])
bad = self.rpc("tools/call", {"name": "review_status", "arguments": ["x"]})
self.assertEqual(bad["error"]["code"], -32602)
def test_mcp_rejects_foreign_origins_hosts_and_simple_requests(self):
ping = {"jsonrpc": "2.0", "id": 1, "method": "ping"}
self.assertEqual(self.post_mcp(ping, {"Origin": "http://evil.example"})[0], 403)
self.assertEqual(self.post_mcp(ping, {"Host": "evil.example"})[0], 403)
self.assertEqual(self.post_mcp(ping, {"Content-Type": "text/plain"})[0], 415)
self.assertEqual(self.post_mcp(ping, {"Origin": f"http://{self.host}"})[0], 200)
self.assertEqual(self.get("/mcp")[0], 405)
def test_mcp_requires_gate_score_and_recommended(self):
tools = {entry["name"]: entry["inputSchema"] for entry in self.rpc("tools/list", {})["result"]["tools"]}
self.assertEqual(tools["review_request"]["properties"]["gate"]["enum"], ["pass", "fail"])
self.assertTrue({"gate", "score"} <= set(tools["review_request"]["required"]))
self.assertIn("recommended", tools["decision_request"]["required"])
review_id = self.request("feat: gated", "")
decision_id = self.decision("Wait")
inbox = {item["id"]: item for item in self.snapshot()["inbox"]}
self.assertEqual((inbox[review_id]["gate"], inbox[review_id]["score"], inbox[review_id]["green"]), ("pass", SCORE, True))
self.assertEqual((inbox[decision_id]["recommended"], inbox[decision_id]["green"]), ("Wait", True))
plain = self.decision("")
self.assertFalse(next(item for item in self.snapshot()["inbox"] if item["id"] == plain)["green"])
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import json
import re
import subprocess
import time
import unittest
from hub_case import HubCase
from hub_support import AMBIENCE, SPACE, VOICE
from kvb_hub import exchange
class TestHubPage(HubCase):
def test_review_page_is_sandboxed_and_the_index_allows_only_its_own_inline_code(self):
review_id = self.request("feat: csp", "")
status, headers, _ = self.get(f"/reviews/{review_id}/page")
self.assertEqual(status, 200)
self.assertEqual(headers["content-security-policy"], "sandbox allow-scripts")
self.assertEqual(headers["x-content-type-options"], "nosniff")
status, headers, body = self.get("/")
csp = headers["content-security-policy"]
nonce = re.search(r"script-src 'nonce-([^']+)'", csp).group(1)
self.assertIn(f"style-src 'nonce-{nonce}'", csp)
self.assertNotIn("unsafe", csp)
self.assertIn("default-src 'none'", csp)
html = body.decode("utf-8")
tags = re.findall(r"<(script|style)\b([^>]*)>", html)
self.assertEqual(sorted({tag for tag, _ in tags}), ["script", "style"])
self.assertGreaterEqual(len(tags), 3)
for tag, attributes in tags:
with self.subTest(tag=tag, attributes=attributes):
self.assertIn(f'nonce="{nonce}"', attributes)
self.assertNotIn(nonce, self.get("/")[1]["content-security-policy"])
status, headers, _ = self.get(f"/reviews/{review_id}/files")
self.assertEqual((status, headers["content-type"]), (200, "application/json"))
self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP)
def test_markup_from_agents_is_escaped(self):
self.request("<script>alert(1)</script>", "</pre><script>alert(2)</script>")
register = {"work/x": {"title": "<script>alert(3)</script>", "status": "running", "roles": {"<b>One</b>": "RA"}}}
news = {"ts": time.time(), "from": "<img src=x>", "to": [], "text": "<script>alert(4)</script>"}
self.write_council({"rasci.json": register, "news.jsonl": self.jsonl([news])})
html = self.index()
for n in range(1, 5):
with self.subTest(n=n):
self.assertNotIn(f"<script>alert({n})", html)
self.assertNotIn("<img src=x>", html)
self.assertNotIn("<b>One</b>", html)
embedded = re.search(r'<script nonce="[^"]+" id="state" type="application/json">(.*?)</script>', html, re.DOTALL).group(1)
state = json.loads(embedded)
self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>"))
self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>"))
def test_the_index_scripts_parse_and_embed_the_ambience_voice_and_space_modules(self):
html = self.index()
for module in (AMBIENCE, VOICE, SPACE):
with self.subTest(module=module.name):
self.assertIn(module.read_text(encoding="utf-8").strip(), html)
scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL)
self.assertEqual(len(scripts), 4)
page = self.state / "page.js"
page.write_text("\n;\n".join(scripts), encoding="utf-8")
done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr)
for module in (AMBIENCE, VOICE, SPACE):
with self.subTest(module=module.name):
done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr)
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import json
import os
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
from hub_case import HubCase
from hub_support import PAGE, TREE, key
from kvb_hub import ledger, review_page, reviewing
REVIEW_PAGE = """<!DOCTYPE html><html><head><script>const x = "<details class='file'>";</script></head><body>
<section class="group" id="staged" data-files="2">
<h2>Staged <span class="muted">2 files</span><span class="stat"><span class="add">+2</span><span class="del">&minus;1</span></span></h2>
<details class="file" id="f1" data-path="hub/server.py" open>
<summary>
<span class="badge modified" title="modified">M</span>
<span class="path">hub/server.py</span>
<span class="stat"><span class="add">+2</span><span class="del">&minus;1</span></span>
<a class="open" href="vscode://file/x" title="Open in VS Code">Open &#8599;</a>
</summary>
<table class="diff">
<tbody>
<tr class="hunk"><td class="num" colspan="2"></td><td class="code" colspan="2">@@ -1,2 +1,3 @@</td></tr>
<tr class="ctx"><td class="num">1</td><td class="num"><a href="vscode://file/x:1">1</a></td><td class="sign"></td><td class="code">import os</td></tr>
<tr class="del"><td class="num">2</td><td class="num"></td><td class="sign">-</td><td class="code">if a &lt; b:</td></tr>
<tr class="add"><td class="num"></td><td class="num">2</td><td class="sign">+</td><td class="code">if a &lt;= b:</td></tr>
<tr class="add"><td class="num"></td><td class="num">3</td><td class="sign">+</td><td class="code"> return &quot;&lt;b&gt;&quot;</td></tr>
</tbody>
<tbody><tr class="more"><td colspan="4">&hellip; 5 more lines not shown</td></tr></tbody>
</table>
</details>
<details class="file" id="f2" data-path="docs/new.md">
<summary>
<span class="badge renamed" title="renamed">R</span>
<span class="path"><span class="old">docs/old.md</span><span class="arrow"> &rarr; </span>docs/new.md</span>
<span class="tag">binary</span>
<span class="stat"><span class="add">+0</span><span class="del">&minus;0</span></span>
</summary>
<p class="placeholder">No content changes.</p>
</details>
</section>
<section class="group" id="commits">
<details class="commit" id="c1" open><summary><code class="sha">abc1234</code></summary>
<div class="commit-files">
<details class="file" id="f3" data-path="skip.py"><summary><span class="badge added" title="added">A</span><span class="path">skip.py</span></summary></details>
</div>
</details>
</section>
</body></html>
"""
class TestHubReviews(HubCase):
def decide(self, review_id: str, action: str, headers: dict[str, str], body: bytes) -> int:
status, _, _ = self.call("POST", f"/reviews/{review_id}/{action}", body, headers)
return status
def test_review_request_copies_the_page(self):
source = self.page("bundle.html", PAGE)
result = self.tool("review_request", self.arguments("feat: copy", "", source))
review_id = result["structuredContent"]["id"]
self.assertEqual(result["structuredContent"]["url"], f"http://localhost:{self.port}/")
Path(source).write_text("<p>changed after the request</p>", encoding="utf-8")
status, _, body = self.get(f"/reviews/{review_id}/page")
self.assertEqual((status, body.decode("utf-8")), (200, PAGE))
self.assertEqual((self.state / "pages" / f"{review_id}.html").read_text(encoding="utf-8"), PAGE)
def test_review_request_rejects_pages_outside_the_pages_dir(self):
evil = self.outside / "evil.html"
evil.write_text("<p>evil</p>", encoding="utf-8")
(self.pages / "notes.txt").write_text("plain", encoding="utf-8")
(self.pages / "link.html").symlink_to(evil)
(self.pages / "dir.html").mkdir()
os.mkfifo(self.pages / "fifo.html")
for page in (
str(evil),
f"{self.pages}/../outside/evil.html",
str(self.pages / "notes.txt"),
str(self.pages / "link.html"),
str(self.pages / "dir.html"),
str(self.pages / "fifo.html"),
str(self.pages / "missing.html"),
"bundle.html",
"",
):
with self.subTest(page=page):
result = self.tool("review_request", self.arguments("feat: x", "", page))
self.assertTrue(result["isError"], result)
self.assertEqual(self.tool("review_list", {})["structuredContent"]["reviews"], [])
self.assertEqual(list((self.state / "pages").iterdir()), [])
def test_approve_flips_the_status_and_review_wait_returns_it(self):
review_id = self.request("feat: approve me", "")
results = []
waiter = threading.Thread(target=lambda: results.append(self.tool("review_wait", {"id": review_id, "timeout_seconds": 30})))
waiter.start()
time.sleep(0.3)
self.assertTrue(waiter.is_alive())
signer = key(-7, "main")
for body in (b"", b'{"proof": null}', b'{"proof": {"payload": {}, "assertion": {}}}', b'{"proof": 1, "extra": 1}'):
with self.subTest(body=body):
self.assertEqual(self.decide(review_id, "approve", self.guard(self.host), body), 400)
self.assertTrue(waiter.is_alive())
started = time.monotonic()
proven = self.proven([self.entry("review", review_id, "", "")], signer)
self.assertEqual(self.decide(review_id, "approve", self.guard(self.host), proven), 200)
waiter.join(10)
self.assertLess(time.monotonic() - started, 10)
decided = results[0]["structuredContent"]
self.assertEqual((decided["status"], decided["tree"]), ("approved", TREE))
self.assertIsNotNone(decided["decided"])
self.assertEqual(self.status(review_id), "approved")
status, _, body = self.get(f"/api/reviews/{review_id}")
self.assertEqual((status, json.loads(body)["proof"]), (200, json.loads(proven)["proof"]))
self.assertEqual(self.decide(review_id, "reject", self.guard(self.host), b""), 409)
self.assertEqual(self.status(review_id), "approved")
other = self.request("feat: reject me", "")
self.assertEqual(self.decide(other, "reject", self.guard(f"localhost:{self.port}"), b""), 200)
self.assertEqual(self.tool("review_wait", {"id": other, "timeout_seconds": 0})["structuredContent"]["status"], "rejected")
self.assertIsNone(json.loads(self.get(f"/api/reviews/{other}")[2])["proof"])
self.assertEqual(self.tool("review_list", {})["structuredContent"]["reviews"], [])
self.assertEqual(self.decide("0123456789abcdef", "approve", self.guard(self.host), proven), 404)
self.assertEqual(self.get("/api/reviews/0123456789abcdef")[0], 404)
self.assertEqual(ledger.Reviews(self.state).get(review_id)["status"], "approved")
def test_review_wait_times_out_at_the_cap(self):
review_id = self.request("feat: slow", "")
started = time.monotonic()
with mock.patch.object(reviewing, "WAIT_CAP", 0.2):
result = self.tool("review_wait", {"id": review_id, "timeout_seconds": 10**6})
self.assertLess(time.monotonic() - started, 5)
self.assertEqual(result["structuredContent"]["status"], "pending")
def test_cross_site_decisions_are_forbidden(self):
review_id = self.request("feat: guarded", "")
good = self.guard(self.host)
for headers in (
{k: v for k, v in good.items() if k != "X-Kvb-Hub"},
{**good, "X-Kvb-Hub": "0"},
{k: v for k, v in good.items() if k != "Origin"},
{**good, "Origin": "http://evil.example"},
{**good, "Origin": "null"},
{**good, "Origin": f"http://localhost:{self.port}"},
{**good, "Host": f"evil.example:{self.port}", "Origin": f"http://evil.example:{self.port}"},
{**good, "Host": "127.0.0.1:1", "Origin": "http://127.0.0.1:1"},
):
for action in ("approve", "reject"):
with self.subTest(headers=headers, action=action):
self.assertEqual(self.decide(review_id, action, headers, b""), 403)
self.assertEqual(self.status(review_id), "pending")
def test_review_files_come_from_the_page(self):
files = review_page.review_files(REVIEW_PAGE.encode("utf-8"))
self.assertEqual([(f["section"], f["path"], f["old"], f["status"], f["added"], f["removed"]) for f in files], [
("staged", "hub/server.py", "", "modified", 2, 1),
("staged", "docs/new.md", "docs/old.md", "renamed", 0, 0),
])
lines = [(line["kind"], line["old"], line["new"], line["text"]) for line in files[0]["lines"]]
self.assertEqual(lines, [
("hunk", "", "", "@@ -1,2 +1,3 @@"),
("ctx", "1", "1", "import os"),
("del", "2", "", "if a < b:"),
("add", "", "2", "if a <= b:"),
("add", "", "3", ' return "<b>"'),
("more", "", "", "… 5 more lines not shown"),
])
self.assertEqual((files[1]["tags"], files[1]["detail"], files[1]["lines"]), (["binary"], "No content changes.", []))
review_id = self.tool("review_request", self.arguments("feat: tree", "", self.page("tree.html", REVIEW_PAGE)))["structuredContent"]["id"]
status, _, body = self.get(f"/reviews/{review_id}/files")
self.assertEqual((status, json.loads(body)), (200, {"files": files}))
self.assertEqual([(r["id"], r["files"]) for r in self.snapshot()["reviews"]], [(review_id, 2)])
self.assertEqual(self.get(f"/reviews/{review_id}/page")[2].decode("utf-8"), REVIEW_PAGE)
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import unittest
from hub_case import HubCase
from hub_support import STREAMS
from kvb_hub import settings
class TestHubSettings(HubCase):
def settings_payload(self, streams: list[dict]) -> dict:
colors = {"top": "#101820", "bottom": "#405060", "accent": "#ffaa00"}
speech = {"on": True, "classes": ["direct", "pan-pan", "mayday"], "muted": ["name:Docs", "s1"]}
space = {"graph": {"axis": "time", "yaw": -45.5, "pitch": 12.25, "zoom": 1.5, "pan_x": -30.0, "pan_y": 12.0}, "sky": {"azimuth": 225.0, "altitude": 20.0, "fov": 120.0}}
return {
**settings.SETTINGS, "latitude": 52.52, "longitude": 13.405, "theme": "custom", "colors": colors, "music": {"on": True, "volume": 0.3}, "streams": streams, "speech": speech,
"space": space,
}
def media_src(self) -> str:
csp = self.get("/")[1]["content-security-policy"]
return next(part.strip() for part in csp.split(";") if part.strip().startswith("media-src"))
def test_settings_are_guarded_validated_and_persisted(self):
self.assertEqual(self.snapshot()["settings"], settings.SETTINGS)
payload = self.settings_payload(STREAMS)
good = self.guard(self.host)
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
with self.subTest(headers=headers):
self.assertEqual(self.post_json("/api/settings", payload, headers)[0], 403)
self.assertFalse((self.state / "settings.json").exists())
stream = STREAMS[0]
for broken in (
{**payload, "latitude": 91},
{**payload, "theme": "neon"},
{**payload, "extra": 1},
{**payload, "colors": {**payload["colors"], "accent": "red"}},
{**payload, "music": {"on": "yes", "volume": 0.3}},
{**payload, "streams": [{**stream, "url": "javascript:alert(1)"}]},
{**payload, "streams": [{**stream, "url": "https://evil.example;script-src"}]},
{**payload, "streams": [{**stream, "url": "https://a.example 'unsafe-inline'"}]},
{**payload, "streams": [{**stream, "url": "https://user:pw@a.example/"}]},
{**payload, "streams": [{**stream, "days": [7]}]},
{**payload, "streams": [{**stream, "days": [1, 1]}]},
{**payload, "streams": [{**stream, "from": "09:00", "to": "09:00"}]},
{**payload, "streams": [{**stream, "to": "25:00"}]},
*({**payload, "speech": {**payload["speech"], **change}} for change in (
{"on": "yes"},
{"classes": ["direct", "pan-pan"]},
{"classes": ["mayday", "shout"]},
{"classes": ["mayday", "mayday"]},
{"classes": "mayday"},
{"muted": "s1"},
{"muted": ["s1", "s1"]},
{"muted": ["two\nlines"]},
{"muted": [""]},
{"muted": [7]},
{"muted": [f"s{n}" for n in range(settings.MUTED_LIMIT + 1)]},
{"extra": 1},
)),
{**payload, "speech": {"on": True, "classes": ["mayday"]}},
{key: value for key, value in payload.items() if key != "speech"},
*({**payload, "space": change} for change in (
"flat",
{"graph": payload["space"]["graph"]},
{**payload["space"], "extra": 1},
{**payload["space"], "sky": [180, 0, 180]},
)),
*({**payload, "space": {**payload["space"], "graph": {**payload["space"]["graph"], **change}}} for change in (
{"axis": "depth"},
{"axis": 1},
{"yaw": 180.5},
{"pitch": "up"},
{"zoom": True},
{"zoom": 0.2},
{"pan_x": 2001},
{"pan_y": float("nan")},
{"roll": 0},
)),
{**payload, "space": {**payload["space"], "graph": {key: value for key, value in payload["space"]["graph"].items() if key != "pan_y"}}},
*({**payload, "space": {**payload["space"], "sky": {**payload["space"]["sky"], **change}}} for change in (
{"azimuth": -1},
{"azimuth": 360.5},
{"altitude": 86},
{"fov": 10},
{"fov": None},
{"fov": float("inf")},
{"zoom": 1},
)),
{key: value for key, value in payload.items() if key != "space"},
):
with self.subTest(broken=broken):
self.assertEqual(self.post_json("/api/settings", broken, good)[0], 400)
status, _, body = self.post_json("/api/settings", payload, good)
self.assertEqual(status, 200, body)
self.assertEqual(json.loads(body), payload)
self.assertEqual(json.loads((self.state / "settings.json").read_text(encoding="utf-8")), payload)
self.assertEqual(self.snapshot()["settings"], payload)
self.assertEqual(settings.Settings(self.state / "settings.json").get(), payload)
self.assertEqual(self.get("/api/settings")[0], 405)
shuffled = {**payload, "speech": {"on": False, "classes": ["mayday", "broadcast", "security"], "muted": ["s1", "Operator", "name:Docs"]}}
status, _, body = self.post_json("/api/settings", shuffled, good)
self.assertEqual(status, 200, body)
self.assertEqual(json.loads(body)["speech"], {"on": False, "classes": ["broadcast", "security", "mayday"], "muted": ["Operator", "name:Docs", "s1"]})
def test_settings_written_before_the_radio_filter_keep_their_values(self):
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "speech"}
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
loaded = settings.Settings(self.state / "settings.json").get()
self.assertEqual(loaded, {**older, "speech": settings.SETTINGS["speech"]})
flat = {key: value for key, value in older.items() if key != "space"}
(self.state / "settings.json").write_text(json.dumps(flat), encoding="utf-8")
self.assertEqual(settings.Settings(self.state / "settings.json").get(), {**flat, "speech": settings.SETTINGS["speech"], "space": settings.SETTINGS["space"]})
(self.state / "settings.json").write_text(json.dumps({**older, "unknown": 1}), encoding="utf-8")
self.assertEqual(settings.Settings(self.state / "settings.json").get(), settings.SETTINGS)
def test_csp_media_src_lists_exactly_the_stream_origins(self):
self.assertEqual(self.media_src(), "media-src 'self'")
streams = [*STREAMS, {**STREAMS[0], "name": "Gleich", "url": "https://Radio.Example:443/zweiter?x=1"}]
status, _, body = self.post_json("/api/settings", self.settings_payload(streams), self.guard(self.host))
self.assertEqual(status, 200, body)
origins = ["http://10.0.0.5:8000", "https://other.example:8443", "https://radio.example"]
self.assertEqual(self.media_src(), " ".join(["media-src 'self'", *origins]))
csp = self.get("/")[1]["content-security-policy"]
self.assertIn("connect-src 'self';", csp)
self.assertEqual(csp.count("radio.example"), 1)
self.assertEqual(self.snapshot()["media"], origins)
if __name__ == "__main__":
unittest.main()
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import itertools
import json
import math
import subprocess
import unittest
from hub_support import SPACE
from kvb_hub import settings
SPACE_NOW = 1_800_000_000
SPACE_LOW = SPACE_NOW - 86400
SPACE_PROBE = """
const S = require(process.argv[1]);
const now = Number(process.argv[2]);
const low = Number(process.argv[3]);
let seed = 7;
const next = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
};
const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)];
const clock = (run) => {
const began = process.hrtime.bigint();
const value = run();
return [Number(process.hrtime.bigint() - began) / 1e6, value];
};
const sessions = Array.from({length: 30}, (_, index) => `s${index}`);
const keys = ["name:Maintainer", ...sessions];
const nodes = [
{id: "Operator", kind: "operator", parent: null, label: "Operator", timeline: [{name: "Operator", since: now}]},
{id: "name:Maintainer", kind: "chair", parent: null, label: "Maintainer", timeline: [{name: "Maintainer", since: low}]},
...sessions.map((id, index) => ({id, kind: "session", parent: null, label: `Renamed ${index}`, timeline: [{name: `Agent ${index}`, since: low - 10}, {name: `Renamed ${index}`, since: low + 43200}]})),
{id: "subagent x @ s1", kind: "subagent", parent: "s1", label: "subagent x", timeline: [{name: "subagent x", since: now}]},
];
const track = Array.from({length: 13}, (_, step) => ({ts: low + 7200 * step, orbit: Object.fromEntries(keys.map((key, at) => [key, {angle: -Math.PI / 2 + at * 0.2 + step * 0.05, radius: 150 + ((at * 37 + step * 11) % 150)}]))}));
const classes = ["direct", "direct", "direct", "broadcast", "security", "mayday", "pan-pan"];
const radio = Array.from({length: 5000}, (_, index) => {
const cls = classes[Math.floor(next() * classes.length)];
return {ts: low + 17.28 * index, source: keys[Math.floor(next() * keys.length)], targets: cls === "broadcast" ? [] : [keys[Math.floor(next() * keys.length)]], class: cls, text: "x"};
});
const grants = Array.from({length: 50}, (_, index) => ({ts: low + 1700 * index, target: sessions[index % sessions.length]}));
const subagents = {s1: [
{id: "root", depth: 1, since: low + 3600, until: low + 7200, started: low + 3600, type: "Explore", description: "scan", finished: true},
{id: "leaf", depth: 2, since: low + 4000, until: null, started: low + 4000, type: "Plan", description: "plan", finished: false},
{id: "late", depth: 1, since: null, until: null, started: low + 80000, type: "", description: "late", finished: false},
]};
const spots = Object.fromEntries(keys.map((key, at) => [key, {x: 200 * Math.cos(at), y: 200 * Math.sin(at)}]));
const input = {axis: "time", range: [low, now], span: [low, now], cursor: now, nodes, track, now: spots, lives: {s2: [low + 600, low + 1200]}, subagents, radio, grants, chair: "name:Maintainer", cap: S.ARC_CAP};
const built = S.scene(input);
const spawnOf = (scene, sub) => scene.arcs.find((arc) => arc.kind === "spawn" && arc.sub === sub);
const flat = (view) => S.camera({yaw: 0, pitch: 0, zoom: 1, pan_x: 0, pan_y: 0, ...view}, 860, 860);
const spawn = S.scene({...input, axis: "spawn"});
const calls = {count: 0};
const ctx = Object.fromEntries(["clearRect", "beginPath", "moveTo", "lineTo", "quadraticCurveTo", "closePath", "stroke", "fill", "arc", "strokeText", "fillText"].map((name) => [name, () => {
calls.count += 1;
}]));
const colors = Object.fromEntries(["line", "accent", "muted", "violet", "direct", "broadcast", "alarm", "security", "grant", "panel", "text"].map((role) => [role, "#123456"]));
const style = {colors, font: "12px sans", strong: "bold 12px sans", level: "Tiefe", cursor: "Stand", selected: "s3", format: String};
const builds = Array.from({length: 15}, () => clock(() => S.scene(input))[0]);
let stats = null;
const paints = Array.from({length: 30}, (_, frame) => {
const [spent, result] = clock(() => S.paint(ctx, built, S.camera({...S.DEFAULTS.graph, yaw: frame * 12}, 1280, 760), style));
stats = result;
return spent;
});
const mini = {...input, nodes: nodes.slice(0, 4), track: [], subagents: {}, lives: {}, grants: [], radio: [{ts: low + 43200, source: "s0", targets: ["s1"], class: "direct", text: "x"}], now: {s0: {x: 200, y: 0}, s1: {x: -200, y: 0}, "name:Maintainer": {x: 0, y: -200}}};
const small = S.scene(mini);
const cam = S.camera(S.DEFAULTS.graph, 860, 860);
const wire = small.arcs[0];
const [a, b, c] = [wire.from, wire.via, wire.to].map((entry) => S.project(cam, entry));
const tail = S.project(cam, small.lines.find((entry) => entry.node === "s0").points[0]);
const hit = (x, y) => {
const found = S.pick(small, cam, x, y, 10);
return found ? `${found.kind}:${found.kind === "line" ? found.line.node : found.arc.kind}` : null;
};
const sight = S.lens(S.DEFAULTS.sky, 1600, 900);
const synthetic = Array.from({length: 5000}, (_, index) => ({tone: ["direct", "broadcast", "alarm", "security", "grant", "spawn"][index % 6], ts: index}));
const capped = S.capArcs(synthetic, 2000);
console.log(JSON.stringify({
defaults: S.DEFAULTS, limits: S.LIMITS, axes: S.AXES, cap: S.ARC_CAP,
matrices: [[0, 0], [30, 20], [-135, 60], [90, -90], [179.5, 45], [12.3, -77.7]].map(([yaw, pitch]) => S.rotation(yaw, pitch)),
projected: [
[flat({}), {x: 0, y: 0, z: 0}], [flat({}), {x: 100, y: 0, z: 0}], [flat({}), {x: 100, y: 50, z: 400}], [flat({yaw: 90}), {x: 100, y: 0, z: 0}],
[flat({pitch: 90}), {x: 0, y: 100, z: 0}], [S.camera({yaw: 0, pitch: 0, zoom: 2, pan_x: 10, pan_y: -20}, 1720, 860), {x: 100, y: 0, z: 0}], [flat({}), {x: 0, y: 0, z: -1500}],
].map(([view, point]) => S.project(view, point)),
sorted: S.depthSort([{id: "a", depth: 5}, {id: "b", depth: 50}, {id: "c", depth: -20}, {id: "d", depth: 50}]).map((item) => item.id),
axis: {
time: [low, (low + now) / 2, now, low - 100, now + 100].map((ts) => S.depthOf("time", {ts, level: 0}, [low, now])),
still: S.depthOf("time", {ts: now, level: 0}, [now, now]),
spawn: [0, 1, 2, 4, 9].map((level) => S.depthOf("spawn", {ts: now, level}, [low, now])),
none: [S.depthOf("none", {ts: low, level: 3}, [low, now]), S.depthOf("none", {ts: now, level: 0}, [low, now])],
},
same: [S.scene(input), S.scene(JSON.parse(JSON.stringify(input)))].every((other) => JSON.stringify(other) === JSON.stringify(built)),
lines: Object.fromEntries(built.lines.map((entry) => [entry.sub || entry.node, {kind: entry.kind, level: entry.level, alive: entry.alive, stamps: entry.points.map((point) => point.ts), first: entry.points[0], name: entry.timeline[0].name}])),
labels: built.labels.filter((label) => built.lines[label.line].node === "s5").map((label) => [label.at.ts, label.text]),
spawns: Object.fromEntries(["root", "leaf"].map((sub) => [sub, spawnOf(built, sub)])),
plane: built.plane, guides: built.guides, total: built.total, dropped: built.dropped, arcs: built.arcs.length,
alarms: built.arcs.filter((arc) => arc.tone === "alarm").length,
spawned: {
points: spawn.lines.map((entry) => entry.points.length), leaf: spawn.lines.find((entry) => entry.sub === "leaf").points[0].z, session: spawn.lines.find((entry) => entry.node === "s1").points[0].z,
arc: spawnOf(spawn, "leaf"), plane: spawn.plane, messages: spawn.arcs.filter((arc) => arc.kind !== "spawn").length,
},
capped: {kept: capped.kept.map((arc) => arc.ts), dropped: capped.dropped, small: S.capArcs(synthetic.slice(0, 100), 1500).kept.length},
frame: {build: median(builds), paint: median(paints), stats, calls: calls.count / 30},
picks: [hit(tail.x, tail.y), hit(0.25 * a.x + 0.5 * b.x + 0.25 * c.x, 0.25 * a.y + 0.5 * b.y + 0.25 * c.y), hit(3, 3)],
names: [5, 10, 15, 20, 99].map((ts) => S.nameAt([{name: "A", since: 10}, {name: "B", since: 20}], ts)),
tree: S.tree([{id: "x", depth: 1, since: 10, until: 50, started: 10}, {id: "y", depth: 1, since: 20, until: 30, started: 20}, {id: "z", depth: 2, since: 40, until: null, started: 40}, {id: "w", depth: 2, since: 25, until: null, started: 25}]).parents,
clamped: [S.clampView("graph", {axis: "time", yaw: 190, pitch: 100, zoom: 9, pan_x: -5000, pan_y: 1}), S.clampView("sky", {azimuth: -10, altitude: 99, fov: 5})],
tweened: S.tween("graph", {axis: "time", yaw: 170, pitch: 0, zoom: 1, pan_x: 0, pan_y: 0}, {axis: "spawn", yaw: -170, pitch: 10, zoom: 2, pan_x: 0, pan_y: 0}, 0.5),
tidy: S.tidy({axis: "time", yaw: 12.3456, zoom: 0.999}),
sky: {
south: [[30, 135], [0, 90], [0, 270], [45, 180]].map(([alt, az]) => S.place(sight, alt, az)),
horizon: S.horizon(sight, 8).map((point) => point.y),
east: S.place(S.lens({azimuth: 90, altitude: 0, fov: 180}, 1600, 900), 0, 90),
up: S.place(S.lens({azimuth: 180, altitude: 30, fov: 180}, 1600, 900), 30, 180),
narrow: S.place(S.lens({azimuth: 180, altitude: 0, fov: 90}, 1600, 900), 0, 225),
},
compass: [0, 22.4, 22.6, 180, 359, -90, 404].map(S.compass),
}));
"""
class TestSpaceModule(unittest.TestCase):
@classmethod
def setUpClass(cls):
command = ["node", "-e", SPACE_PROBE, str(SPACE), str(SPACE_NOW), str(SPACE_LOW)]
done = subprocess.run(command, capture_output=True, text=True, timeout=120, check=True)
cls.data = json.loads(done.stdout)
def assert_close(self, actual: dict, expected: dict) -> None:
for key, value in expected.items():
with self.subTest(key=key):
self.assertAlmostEqual(actual[key], value, places=6)
def test_the_defaults_limits_and_axes_match_the_server_validation(self):
self.assertEqual(self.data["defaults"], settings.SETTINGS["space"])
self.assertEqual(self.data["limits"], {view: {key: list(pair) for key, pair in limits.items()} for view, limits in settings.SPACE_LIMITS.items()})
self.assertEqual(tuple(self.data["axes"]), settings.SPACE_AXES)
def test_rotation_matrices_are_orthonormal(self):
self.assertEqual(len(self.data["matrices"]), 6)
for matrix in self.data["matrices"]:
rows = [matrix[0:3], matrix[3:6], matrix[6:9]]
with self.subTest(matrix=matrix):
for first, second in itertools.product(range(3), repeat=2):
self.assertAlmostEqual(sum(rows[first][k] * rows[second][k] for k in range(3)), float(first == second), places=12)
self.assertAlmostEqual(sum(rows[k][first] * rows[k][second] for k in range(3)), float(first == second), places=12)
(a, b, c), (d, e, f), (g, h, i) = rows
self.assertAlmostEqual(a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g), 1.0, places=12)
def test_known_points_project_to_the_expected_screen_coordinates(self):
expected = [
{"x": 430, "y": 430, "depth": 0, "size": 1},
{"x": 530, "y": 430, "depth": 0, "size": 1},
{"x": 510, "y": 470, "depth": 400, "size": 0.8},
{"x": 430, "y": 430, "depth": -100, "size": 1600 / 1500},
{"x": 430, "y": 430, "depth": 100, "size": 1600 / 1700},
{"x": 1080, "y": 390, "depth": 0, "size": 2},
]
projected = self.data["projected"]
for actual, wanted in zip(projected[:-1], expected, strict=True):
self.assert_close(actual, wanted)
self.assertIsNone(projected[-1])
def test_depth_sort_draws_far_before_near_and_keeps_ties_in_order(self):
self.assertEqual(self.data["sorted"], ["b", "d", "a", "c"])
def test_the_axis_maps_time_spawn_depth_or_nothing(self):
axis = self.data["axis"]
self.assertEqual(axis["time"], [300, 0, -300, 300, -300])
self.assertEqual(axis["still"], -300)
self.assertEqual(axis["spawn"], [-300, -150, 0, 300, 300])
self.assertEqual(axis["none"], [0, 0])
def test_world_lines_are_deterministic_and_follow_the_layout_of_their_time(self):
self.assertTrue(self.data["same"])
lines = self.data["lines"]
self.assertLessEqual({"Operator", "name:Maintainer", "subagent x @ s1", *(f"s{n}" for n in range(30))}, set(lines))
root, leaf, late = lines["root"], lines["leaf"], lines["late"]
self.assertEqual((root["kind"], root["level"], root["alive"], root["stamps"]), ("twig", 1, False, [SPACE_LOW + 3600, SPACE_LOW + 7200]))
self.assertEqual((leaf["level"], leaf["alive"], leaf["stamps"][0], leaf["stamps"][-1]), (2, True, SPACE_LOW + 4000, SPACE_NOW))
self.assertEqual((late["stamps"], late["name"]), ([SPACE_LOW + 80000, SPACE_NOW], "Subagent"))
self.assertEqual((lines["s2"]["stamps"], lines["s2"]["alive"]), ([SPACE_LOW + 600, SPACE_LOW + 1200], False))
self.assertEqual(lines["s5"]["stamps"], [SPACE_LOW + 7200 * step for step in range(13)])
self.assertEqual((lines["subagent x @ s1"]["level"], lines["subagent x @ s1"]["kind"]), (1, "subagent"))
angle, radius = -math.pi / 2 + 0.425, 229.5
born = self.data["spawns"]["root"]
self.assert_close(born["from"], {"x": radius * math.cos(angle), "y": radius * math.sin(angle), "z": 275, "ts": SPACE_LOW + 3600})
for sub in ("root", "leaf"):
with self.subTest(sub=sub):
self.assertEqual(self.data["spawns"][sub]["to"], lines[sub]["first"])
self.assertEqual(self.data["spawns"][sub]["from"]["z"], lines[sub]["first"]["z"])
self.assertEqual(self.data["labels"], [[SPACE_NOW, "Renamed 5"], [SPACE_LOW + 43200, "Renamed 5"], [SPACE_LOW, "Agent 5"]])
self.assertEqual(self.data["plane"], {"z": -300, "ts": SPACE_NOW})
self.assertEqual([(guide["z"], guide["ts"]) for guide in self.data["guides"]], [(300, SPACE_LOW), (-300, SPACE_NOW)])
spawned = self.data["spawned"]
self.assertEqual(set(spawned["points"]), {1})
self.assertEqual((spawned["session"], spawned["leaf"], spawned["plane"]), (-300, 0, None))
self.assertEqual((spawned["arc"]["from"]["z"], spawned["arc"]["to"]["z"]), (-150, 0))
self.assertLessEqual(spawned["messages"], self.data["cap"])
def test_arcs_are_capped_by_urgency_then_recency(self):
self.assertEqual((self.data["arcs"], self.data["dropped"]), (self.data["cap"], self.data["total"] - self.data["cap"]))
self.assertGreater(self.data["total"], self.data["cap"])
capped = self.data["capped"]
kept = capped["kept"]
self.assertEqual((len(kept), capped["dropped"], capped["small"]), (2000, 3000, 100))
self.assertEqual(kept, sorted(kept))
tones = ["direct", "broadcast", "alarm", "security", "grant", "spawn"]
by_tone = {tone: [ts for ts in kept if tones[ts % 6] == tone] for tone in tones}
self.assertEqual({tone: len(found) for tone, found in by_tone.items()}, {"direct": 0, "broadcast": 0, "alarm": 833, "security": 334, "grant": 0, "spawn": 833})
self.assertEqual(by_tone["security"], [ts for ts in range(5000) if ts % 6 == 3][-334:])
def test_a_frame_of_5000_events_draws_capped_batched_arcs_inside_the_frame_budget(self):
frame = self.data["frame"]
self.assertLessEqual(frame["stats"]["arcs"], self.data["cap"])
self.assertLess(frame["stats"]["batches"], 400)
self.assertLess(frame["paint"], 1000 / 60)
def test_picking_finds_the_world_line_the_arc_or_nothing(self):
self.assertEqual(self.data["picks"], ["line:s0", "arc:message", None])
def test_names_trees_and_views_follow_their_rules(self):
self.assertEqual(self.data["names"], ["A", "A", "A", "B", "B"])
self.assertEqual(self.data["tree"], {"x": "", "y": "", "w": "y", "z": "x"})
self.assertEqual(self.data["clamped"], [{"axis": "time", "yaw": -170, "pitch": 90, "zoom": 4, "pan_x": -2000, "pan_y": 1}, {"azimuth": 350, "altitude": 85, "fov": 20}])
self.assert_close(self.data["tweened"], {"pitch": 5, "zoom": 1.5, "pan_x": 0, "pan_y": 0})
self.assertAlmostEqual(abs(self.data["tweened"]["yaw"]), 180, places=6)
self.assertEqual((self.data["tweened"]["axis"], self.data["tidy"]), ("spawn", {"axis": "time", "yaw": 12.35, "zoom": 1}))
def test_the_sky_faces_south_by_default_and_turns_with_the_view(self):
sky = self.data["sky"]
width, height = 1600, 900
horizon = 0.86 * height
today = [{"x": width / 2 + ((az - 180) / 180) * width, "y": horizon - (alt / 90) * horizon * 0.94} for alt, az in ((30, 135), (0, 90), (0, 270), (45, 180))]
for actual, wanted in zip(sky["south"], today, strict=True):
self.assert_close(actual, wanted)
for y in sky["horizon"]:
self.assertAlmostEqual(y, horizon, places=6)
for name, wanted in (("east", {"x": 800, "y": horizon}), ("up", {"x": 800, "y": horizon}), ("narrow", {"x": width, "y": horizon})):
with self.subTest(name=name):
self.assert_close(sky[name], wanted)
self.assertEqual(self.data["compass"], [0, 0, 1, 4, 0, 6, 1])
if __name__ == "__main__":
unittest.main()
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import dataclasses
import hashlib
import io
import json
import os
import subprocess
import sys
import unittest
import wave
from unittest import mock
from hub_case import HubCase
from hub_engines import OPERATOR_VOICE, VOICES, log_rows, wav_bytes
from hub_support import HOST_STATE, HUB, server
from kvb_hub import council, engines, exchange, handler, phrasing
class TestHubSpeech(HubCase):
def test_voices_come_from_the_models_and_stick_to_their_session(self):
speech = self.httpd.hub.speech
self.assertEqual(list(speech.voices), VOICES)
self.assertEqual((speech.voices["de_DE-solo-medium:0"], speech.voices["en_US-test-high:3"]), (("de_DE-solo-medium", None), ("en_US-test-high", 3)))
pool = [voice for voice in VOICES if voice != OPERATOR_VOICE]
self.assertEqual((speech.pool, speech.operator), (pool, OPERATOR_VOICE))
keys = [f"session-{n}" for n in range(40)] + ["s1", "name:Docs"]
picked = [engines.voice_of(key, pool, OPERATOR_VOICE) for key in keys]
self.assertEqual(picked, [pool[int(hashlib.sha256(key.encode()).hexdigest(), 16) % len(pool)] for key in keys])
self.assertEqual(picked, [speech.voice_of(key) for key in keys])
self.assertEqual(picked, [engines.voice_of(key, list(pool), OPERATOR_VOICE) for key in keys])
self.assertEqual(set(picked), set(pool))
self.assertEqual(speech.voice_of("Operator"), OPERATOR_VOICE)
self.assertEqual(engines.voice_of("s1", [], OPERATOR_VOICE), OPERATOR_VOICE)
self.assertEqual(engines.voice_of("s1", [], ""), "")
with self.assertRaises(server.ToolError):
engines.Speech(dataclasses.replace(self.engines, operator_voice="en_US-test-high:4"), self.state / "other")
silent = engines.Speech(dataclasses.replace(self.engines, voices=self.outside, operator_voice="anything"), self.state / "silent")
self.assertEqual((silent.speaks(), silent.voice_of("s1"), silent.describe()["operator"], silent.describe()["tts"]), (False, "", "", False))
deaf = engines.Speech(dataclasses.replace(self.engines, whisper=self.outside / "missing"), self.state / "deaf")
self.assertFalse(deaf.hears())
def test_speech_output_is_guarded_whitelisted_capped_and_cached(self):
good = self.guard(self.host)
calls = self.engines.scratch / "piper.jsonl"
cache = self.state / "tts"
request = {"text": "all stations, this is Docs, board, out", "voice": "en_US-test-high:2"}
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
with self.subTest(headers=headers):
self.assertEqual(self.post_json("/api/tts", request, headers)[0], 403)
for broken in (
{**request, "voice": "en_US-test-high:4"},
{**request, "voice": "zero:0"},
{**request, "voice": "bad name!:0"},
{**request, "voice": "broken:0"},
{**request, "voice": ["en_US-test-high:2"]},
{**request, "text": "x" * (phrasing.SPEECH_LIMIT + 1)},
{**request, "text": " \n\t "},
{**request, "text": "bell \x07 here"},
{**request, "text": 7},
{**request, "extra": 1},
{"text": request["text"]},
):
with self.subTest(broken=broken):
self.assertEqual(self.post_json("/api/tts", broken, good)[0], 400)
self.assertEqual(log_rows(calls), [])
status, headers, body = self.post_json("/api/tts", request, good)
self.assertEqual((status, headers["content-type"], headers["content-security-policy"]), (200, "audio/wav", exchange.PLAIN_CSP), body)
with wave.open(io.BytesIO(body)) as reader:
self.assertEqual((reader.getnchannels(), reader.getframerate(), reader.getnframes()), (1, 22050, len(request["text"]) + 1))
key = hashlib.sha256(f"{request['voice']}\n{request['text']}".encode()).hexdigest()
self.assertEqual(sorted(path.name for path in cache.iterdir()), [f"{key}.wav"])
self.assertEqual(self.post_json("/api/tts", {**request, "text": " all stations,\nthis is Docs, board, out "}, good)[2], body)
self.assertEqual(self.post_json("/api/tts", {**request, "text": "x" * phrasing.SPEECH_LIMIT}, good)[0], 200)
rows = log_rows(calls)
model = self.engines.voices / "en_US-test-high.onnx"
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["argv"][:5], ["--model", str(model), "--config", f"{model}.json", "--output-file"])
self.assertEqual(rows[0]["argv"][6:], ["--speaker", "2"])
self.assertTrue(rows[0]["argv"][5].startswith(f"{cache}/.{key}-"))
self.assertEqual((rows[0]["text"], rows[0]["tmp"], rows[0]["cwd"]), (f"{request['text']}\n", str(self.engines.scratch), str(self.engines.scratch)))
self.assertEqual(self.post_json("/api/tts", {"text": "solo", "voice": "de_DE-solo-medium:0"}, good)[0], 200)
self.assertNotIn("--speaker", log_rows(calls)[-1]["argv"])
status, _, body = self.post_json("/api/tts", {**request, "text": "FAIL now"}, good)
self.assertEqual(status, 502, body)
self.assertIn(b"exit code 3", body)
self.assertEqual([path.name for path in cache.iterdir() if path.name.startswith(".")], [])
with mock.patch.object(engines, "TTS_KEEP", 2):
for number in range(3):
self.assertEqual(self.post_json("/api/tts", {**request, "text": f"line {number}"}, good)[0], 200)
kept = {hashlib.sha256(f"{request['voice']}\nline {number}".encode()).hexdigest() + ".wav" for number in (1, 2)}
self.assertEqual({path.name for path in cache.iterdir()}, kept)
with mock.patch.object(self.httpd.hub.speech, "voices", {}):
self.assertEqual(self.post_json("/api/tts", request, good)[0], 503)
self.assertEqual(self.scripts.calls, [])
def test_speech_input_checks_the_guard_type_size_and_format(self):
good = self.guard(self.host)
calls = self.engines.scratch / "whisper.jsonl"
clip = wav_bytes(1, 2, engines.STT_RATE, b"\x01\x00" * engines.STT_RATE)
def post(body: bytes, headers: dict[str, str]) -> tuple[int, dict, bytes]:
return self.call("POST", "/api/stt", body, {**headers, "Content-Type": "audio/wav"})
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
with self.subTest(headers=headers):
self.assertEqual(post(clip, headers)[0], 403)
for content_type in ("application/octet-stream", "audio/x-wav", "text/plain", "application/json"):
with self.subTest(content_type=content_type):
self.assertEqual(self.call("POST", "/api/stt", clip, {**good, "Content-Type": content_type})[0], 415)
self.assertEqual(self.call("POST", "/api/stt", clip, good)[0], 415)
self.assertEqual(self.call("POST", "/api/stt", b"RIFF", {**good, "Content-Type": "audio/wav", "Content-Length": str(engines.STT_LIMIT + 1)})[0], 413)
for broken in (
b"not a wav",
wav_bytes(1, 2, 44100, b"\x00\x00" * 100),
wav_bytes(2, 2, engines.STT_RATE, b"\x00\x00" * 200),
wav_bytes(1, 1, engines.STT_RATE, b"\x00" * 100),
wav_bytes(1, 2, engines.STT_RATE, b""),
):
with self.subTest(broken=broken[:40]):
self.assertEqual(post(broken, good)[0], 400)
with mock.patch.object(engines, "STT_SECONDS", 1):
self.assertEqual(post(wav_bytes(1, 2, engines.STT_RATE, b"\x00\x00" * (engines.STT_RATE + 1)), good)[0], 400)
self.assertEqual(log_rows(calls), [])
status, headers, body = post(clip, good)
self.assertEqual((status, headers["content-type"]), (200, "application/json"), body)
self.assertEqual(json.loads(body), {"text": "hello from the mic second line"})
argv = ["--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"]
self.assertEqual(log_rows(calls), [{"argv": argv, "sha256": hashlib.sha256(clip).hexdigest()}])
long = wav_bytes(1, 2, engines.STT_RATE, b"\x00\x00" * engines.STT_RATE * 40)
self.assertGreater(len(long), handler.BODY_LIMIT)
self.assertEqual(post(long, good)[0], 200)
status, _, body = post(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100), good)
self.assertEqual(status, 502, body)
with mock.patch.object(self.httpd.hub.speech, "hears", return_value=False):
self.assertEqual(post(clip, good)[0], 503)
self.assertEqual(self.scripts.calls, [])
self.assertEqual(self.get("/api/stt")[0], 405)
def test_the_server_requires_every_engine_argument(self):
args = {
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"), "--repos": str(self.repos),
"--worktrees": str(self.worktrees), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
}
def run(given: dict[str, str]) -> subprocess.CompletedProcess:
argv = [part for pair in given.items() for part in pair]
return subprocess.run([sys.executable, str(HUB / "server.py"), *argv], capture_output=True, text=True, timeout=60, check=False)
for name in ("--piper", "--voices", "--operator-voice", "--whisper", "--whisper-model", "--scratch", "--approvers"):
with self.subTest(name=name):
done = run({key: value for key, value in args.items() if key != name})
self.assertEqual(done.returncode, 2, done.stderr)
self.assertIn(f"required: {name}", done.stderr)
done = run(args)
self.assertEqual(done.returncode, 2, done.stderr)
self.assertIn("the operator voice 'en_US-test-high:9' is not installed", done.stderr)
class TestPhraseology(unittest.TestCase):
def test_every_class_speaks_in_maritime_phraseology(self):
cases = (
(("mayday", "Docs", [], "MAYDAY disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"),
(("mayday", "Docs", ["Skills"], "mayday, mayday: disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"),
(("pan-pan", "Docs", ["Skills"], "PAN-PAN: slow"), "PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over"),
(("security", "Maintainer", ["Skills", "Docs"], "SECURITE rotate"), "SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over"),
(("direct", "Skills", ["Maintainer"], "ready"), "Maintainer, Maintainer, this is Skills, ready, over"),
(("direct", "Skills", ["Alpha", "Beta"], "ready"), "Alpha and Beta, Alpha and Beta, this is Skills, ready, over"),
(("direct", "Docs", ["Ops"], "MAYDAY is only a word here"), "Ops, Ops, this is Docs, MAYDAY is only a word here, over"),
(("direct", "Skills", [], "lost target"), "all stations, this is Skills, lost target, out"),
(("broadcast", "Maintainer", [], "board"), "all stations, this is Maintainer, board, out"),
(("broadcast", "Operator", [], "[kvb-hub msg 0123456789abcdef] deploy now file: /home/op/x.txt"), "all stations, this is Operator, deploy now, out"),
(("broadcast", "Docs", [], " two\n lines\t here "), "all stations, this is Docs, two lines here, out"),
(("broadcast", "", [], "-"), "all stations, this is unknown station, out"),
)
for arguments, expected in cases:
with self.subTest(arguments=arguments):
self.assertEqual(phrasing.phrase(*arguments), expected)
def test_a_phrase_fits_the_speech_limit_on_one_line(self):
for cls in council.CLASSES:
with self.subTest(cls=cls):
spoken = phrasing.phrase(cls, "n" * 900, ["a\nb " * 300] * 9, "word\n" * 9000)
self.assertLessEqual(len(spoken), phrasing.SPEECH_LIMIT)
self.assertTrue(phrasing.SPOKEN_LINE.fullmatch(spoken))
self.assertTrue(phrasing.phrase("broadcast", "Docs", [], "word " * 300).endswith("word, out"))
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import time
import unittest
from unittest import mock
from hub_case import HubCase
from hub_engines import OPERATOR_VOICE, VOICES
from hub_support import ASK
from kvb_hub import council, engines, exchange, phrasing, view
class TestHubState(HubCase):
def test_state_follows_the_time_window(self):
now = time.time()
self.write_council({"news.jsonl": self.jsonl([
{"ts": now - 10800, "from": "Old", "to": [], "text": "three hours"},
{"ts": now - 3600, "from": "Skills", "to": ["Docs"], "text": "one hour"},
{"ts": now - 60, "from": "Docs", "to": [], "text": "a minute"},
])})
self.request("feat: fresh", "")
self.create_ask(ASK)
default = self.snapshot()
self.assertEqual([row["text"] for row in default["radio"]], ["three hours", "one hour", "a minute"])
self.assertAlmostEqual(default["first"], now - 10800, delta=1)
status, _, body = self.get(f"/api/state?from={now - 7200}&to={now - 1800}")
self.assertEqual(status, 200, body)
past = json.loads(body)
self.assertEqual([row["text"] for row in past["radio"]], ["one hour"])
self.assertEqual((past["since"], past["until"]), (now - 7200, now - 1800))
self.assertEqual((past["reviews"], past["asks"]), ([], []))
self.assertEqual({(edge["source"], edge["target"]) for edge in past["graph"]["edges"]}, {("name:Skills", "name:Docs")})
future = json.loads(self.get(f"/api/state?from=0&to={now + 86400}")[2])
self.assertLessEqual(future["until"], time.time())
self.assertEqual(len(future["radio"]), 3)
for query in (f"from={now}&to={now - 60}", "from=soon", "to=-1", "from=1&from=2", "from=nan"):
with self.subTest(query=query):
self.assertEqual(self.get(f"/api/state?{query}")[0], 400)
def test_get_never_changes_state(self):
review_id = self.request("feat: read only", "")
before = (self.state / "reviews.json").read_bytes()
for path, expected in (
("/", 200),
("/?approve=1", 200),
("/api/state", 200),
(f"/reviews/{review_id}/page", 200),
(f"/reviews/{review_id}/files", 200),
("/reviews/0123456789abcdef/files", 404),
(f"/reviews/{review_id}/approve", 405),
(f"/reviews/{review_id}/reject", 405),
(f"/api/reviews/{review_id}", 200),
("/api/reviews/0123456789abcdef", 404),
("/api/sign", 405),
("/api/rules/sign", 405),
("/mcp", 405),
("/api/tts", 405),
("/api/stt", 405),
("/reviews/0123456789abcdef/page", 404),
("/nope", 404),
):
with self.subTest(path=path):
status, _, _ = self.call("GET", path, None, self.guard(self.host))
self.assertEqual(status, expected)
self.assertEqual(self.call("GET", "/", None, {"Host": "evil.example"})[0], 403)
self.assertEqual((self.state / "reviews.json").read_bytes(), before)
self.assertEqual(self.status(review_id), "pending")
def test_state_is_empty_without_council_files(self):
empty = {"reviews": [], "operator": [], "register": [], "radio": [], "claims": [], "grants": []}
state = self.snapshot()
self.assertEqual({key: state[key] for key in empty}, empty)
self.assertEqual([(node["id"], node["kind"]) for node in state["graph"]["nodes"]], [("Operator", "operator"), ("name:Maintainer", "chair")])
self.assertEqual(state["graph"]["edges"], [])
self.index()
self.write_council({"rasci.json": "{not json", "queue-history.jsonl": "[" * 100000 + "\n", "work/x/claim.json": "[1]"})
(self.council / "news.jsonl").write_bytes(b"\xff\xfe" + b"[" * 100000 + b"\n")
state = self.snapshot()
self.assertEqual({key: state[key] for key in empty}, empty)
self.write_council({"rasci.json": "[1, 2]", "news.jsonl": "[" * 100000 + "\n"})
self.assertEqual(self.snapshot()["radio"], [])
self.index()
def test_state_filters_the_register_and_bounds_the_radio(self):
register = {
"operator/push-skills": {"title": "git-sign-push", "workdir": "/repo", "description": "needs the key", "check": "pushed", "status": "queued", "roles": {"Operator": "R", "One": "A"}},
"operator/old": {"title": "old command", "status": "done", "roles": {}},
"work/hub": {"title": "Build the hub", "status": "running", "worker": "One", "objective": "Reviews in the browser", "roles": {"One": "RA", "Two": "C"}, "key_results": [{"text": "a", "met": True}, {"text": "b", "met": False}]},
"work/finished": {"title": "Finished work", "status": "done", "roles": {}},
"work/stopped": {"title": "Stopped work", "status": "stopped", "roles": {}},
"work/bare": {"roles": {"Two": "R"}},
"broken": "not a row",
}
now = time.time()
rows = [{"ts": now - 100 + n, "from": "One", "to": [], "text": f"item {n:02d}"} for n in range(34)]
rows.append({"ts": now, "from": "Two", "to": [], "text": "disk full", "class": "mayday"})
lines = [json.dumps(row) for row in rows[:20]] + ["not json", json.dumps({"text": "no ts"})] + [json.dumps(row) for row in rows[20:]]
self.write_council({"rasci.json": register, "news.jsonl": "\n".join(lines) + "\n"})
with mock.patch.object(council, "RADIO_LIMIT", 30):
state = self.snapshot()
operator = state["operator"]
self.assertEqual([(row["task"], row["command"], row["why"], row["status"]) for row in operator], [("operator/push-skills", "git-sign-push", "needs the key", "queued")])
self.assertEqual([row["task"] for row in state["register"]], ["work/bare", "work/hub"])
hub = state["register"][1]
self.assertEqual((hub["title"], hub["responsible"], hub["accountable"], hub["met"], hub["total"]), ("Build the hub", "One", "One", 1, 2))
self.assertEqual(state["register"][0]["status"], "queued")
texts = [row["text"] for row in state["radio"]]
self.assertEqual(len(texts), 30)
self.assertEqual((texts[0], texts[-2], texts[-1]), ("item 05", "item 33", "disk full"))
self.assertEqual(state["radio"][-1]["class"], "mayday")
def test_state_collects_radio_claims_grants_and_the_graph(self):
now = time.time()
register = {
"operator/push": {"title": "git-sign-push", "workdir": "/repo", "status": "blocked", "roles": {"Operator": "R", "Skills session": "A"}},
"work/hub": {"title": "Build the hub", "status": "running", "worker": "Skills session", "workdir": "/repo/.worktrees/hub", "roles": {"Skills session": "R", "Maintainer": "A"}},
"work/sub": {"title": "Helper", "status": "running", "worker": "subagent general-purpose (default)", "roles": {"Skills": "A"}},
"work/old": {"title": "Old", "status": "done", "worker": "Ghost session", "roles": {}},
}
news = [
{"ts": now - 50, "from": "Skills session", "to": ["Maintainer"], "text": "ready"},
{"ts": now - 40, "from": "Skills", "to": ["Maintainer"], "text": "again", "class": "direct"},
{"ts": now - 30, "from": "Maintainer", "to": [], "text": "board"},
{"ts": now - 20, "from": "Docs", "to": [], "text": "MAYDAY disk full"},
{"ts": now - 10, "from": "Docs", "to": ["Skills"], "text": "PAN-PAN slow"},
{"ts": now - 5, "from": "Maintainer", "to": ["Skills", "Docs"], "text": "rotate", "class": "security"},
{"ts": now - 2 * 86400, "from": "Old", "to": [], "text": "too old"},
]
claim = {"office": "work/hub", "session": "s1", "runtime": "claude", "name": "Skills", "since": "2026-11-05T10:00:00+00:00", "beat": now, "work": "build the hub"}
gate = {"office": "cluster/gate", "session": "s2", "runtime": "codex", "name": "Gatekeeper", "since": "2026-11-05T09:00:00+00:00", "beat": now, "work": "gate"}
history = [
{"id": "q1", "kind": "gate", "signature": "make test", "workdir": "/repo/.worktrees/hub/sub", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2},
{"id": "q2", "kind": "deploy", "signature": "make deploy", "workdir": "/elsewhere", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2},
{"id": "q3", "kind": "gate", "signature": "make test", "workdir": "/repo/.worktrees/hub", "start": now - 2 * 86400, "end": now - 2 * 86400 + 60, "exit": 0, "minutes": 1},
]
self.write_council({
"rasci.json": register,
"news.jsonl": self.jsonl(news),
"work/hub/claim.json": claim,
"cluster/gate/claim.json": gate,
"work/broken/claim.json": "not json",
"queue-history.jsonl": self.jsonl(history),
})
state = self.snapshot()
self.assertEqual(
set(state),
{
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
"register", "priority", "radio", "claims", "grants", "night", "silence", "graph",
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
},
)
self.assertEqual([row["speech"] for row in state["radio"]], [
"Maintainer, Maintainer, this is Skills, ready, over",
"Maintainer, Maintainer, this is Skills, again, over",
"all stations, this is Maintainer, board, out",
"MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over",
"PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over",
"SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over",
])
speech = self.httpd.hub.speech
self.assertEqual([row["voice"] for row in state["radio"]], [speech.voice_of(row["source"]) for row in state["radio"]])
self.assertTrue(all(row["voice"] in VOICES and row["voice"] != OPERATOR_VOICE for row in state["radio"]))
self.assertEqual(state["speech"], {
"tts": True, "stt": True, "voices": len(VOICES), "operator": OPERATOR_VOICE, "sample": "all stations, this is Operator, radio check, out",
"limit": phrasing.SPEECH_LIMIT, "rate": engines.STT_RATE, "seconds": engines.STT_SECONDS,
})
self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW)
self.assertEqual((state["night"], state["silence"], state["priority"]), ("day", None, ["Skills session", "subagent general-purpose (default)"]))
radio = [(row["from"], row["to"], row["class"], row["text"]) for row in state["radio"]]
self.assertEqual(radio, [
("Skills", ["Maintainer"], "direct", "ready"),
("Skills", ["Maintainer"], "direct", "again"),
("Maintainer", [], "broadcast", "board"),
("Docs", [], "mayday", "MAYDAY disk full"),
("Docs", ["Skills"], "pan-pan", "PAN-PAN slow"),
("Maintainer", ["Skills", "Docs"], "security", "rotate"),
])
self.assertEqual([(row["office"], row["agent"], row["runtime"]) for row in state["claims"]], [("cluster/gate", "Gatekeeper", "codex"), ("work/hub", "Skills", "claude")])
self.assertEqual([(row["to"], row["kind"], row["id"]) for row in state["grants"]], [("Skills", "gate", "q1")])
self.assertEqual([row["task"] for row in state["operator"]], ["operator/push"])
nodes = {node["id"]: node for node in state["graph"]["nodes"]}
moon = "subagent general-purpose (default) @ s1"
self.assertEqual(list(nodes), ["Operator", "name:Maintainer", "name:Docs", "s2", "s1", moon])
self.assertEqual([nodes[key]["kind"] for key in nodes], ["operator", "chair", "session", "session", "session", "subagent"])
self.assertEqual([nodes[key]["label"] for key in nodes][:5], ["Operator", "Maintainer", "Docs", "Gatekeeper", "Skills"])
self.assertEqual((nodes[moon]["parent"], nodes[moon]["label"]), ("s1", "subagent general-purpose (default)"))
skills = [(task["task"], task["worker"], task["letters"]) for task in nodes["s1"]["tasks"]]
self.assertEqual(skills, [("operator/push", False, "A"), ("work/hub", True, "R"), ("work/sub", False, "A")])
self.assertEqual([(task["task"], task["letters"]) for task in nodes["Operator"]["tasks"]], [("operator/push", "R")])
self.assertEqual([task["task"] for task in nodes[moon]["tasks"]], ["work/sub"])
self.assertEqual([claim["office"] for claim in nodes["s1"]["claims"]], ["work/hub"])
self.assertEqual((state["people"]["s1"]["now"], state["radio"][0]["source"], state["grants"][0]["target"]), ("Skills", "s1", "s1"))
edges = {(edge["source"], edge["target"], edge["class"]): edge["count"] for edge in state["graph"]["edges"]}
self.assertEqual(edges, {
("name:Docs", "*", "mayday"): 1,
("name:Docs", "s1", "pan-pan"): 1,
("name:Maintainer", "*", "broadcast"): 1,
("name:Maintainer", "name:Docs", "security"): 1,
("name:Maintainer", "s1", "grant"): 1,
("name:Maintainer", "s1", "security"): 1,
("s1", "name:Maintainer", "direct"): 2,
})
def test_state_needs_the_host(self):
for host in ("evil.example", f"evil.example:{self.port}", "127.0.0.1:1"):
with self.subTest(host=host):
self.assertEqual(self.call("GET", "/api/state", None, {"Host": host})[0], 403)
status, headers, _ = self.get("/api/state")
self.assertEqual(status, 200)
self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP)
self.assertEqual(self.call("POST", "/api/state", b"", self.guard(self.host))[0], 404)
if __name__ == "__main__":
unittest.main()
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import json
import os
import time
import unittest
from unittest import mock
from hub_case import HubCase
from hub_support import ASK, iso
from kvb_hub import events, transcripts
class TestHubStream(HubCase):
def test_stream_reads_transcripts_with_filters_and_agent_names(self):
now = time.time()
self.claim("s1", "Skills session")
main = self.projects / "-repo" / "s1.jsonl"
sub = self.projects / "-repo" / "s1" / "subagents" / "agent-abc.jsonl"
other = self.projects / "-other" / "s2.jsonl"
old = self.projects / "-other" / "s3.jsonl"
for path in (main.parent, sub.parent, other.parent):
path.mkdir(parents=True, exist_ok=True)
main.write_text(self.jsonl([
{"type": "user", "sessionId": "s1", "timestamp": iso(now - 50), "message": {"role": "user", "content": "hello agent"}},
{"type": "assistant", "sessionId": "s1", "timestamp": iso(now - 40), "message": {"role": "assistant", "content": [
{"type": "thinking", "thinking": "hidden"}, {"type": "text", "text": "on it"}, {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}}]}},
{"type": "user", "sessionId": "s1", "timestamp": iso(now - 30), "message": {"role": "user", "content": [{"type": "tool_result", "content": "x" * 2500}]}},
{"type": "system", "sessionId": "s1", "timestamp": iso(now - 20), "content": "compacted"},
{"type": "attachment", "sessionId": "s1", "timestamp": iso(now - 20), "attachment": {}},
]) + "not json\n", encoding="utf-8")
sub.write_text(self.jsonl([{"type": "user", "sessionId": "s1", "agentId": "abc", "timestamp": iso(now - 10), "message": {"role": "user", "content": "SUB task"}}]), encoding="utf-8")
other.write_text(self.jsonl([{"type": "user", "sessionId": "s2", "timestamp": iso(now - 5), "message": {"role": "user", "content": "other"}}]), encoding="utf-8")
old.write_text(self.jsonl([{"type": "user", "sessionId": "s3", "timestamp": iso(now - 9 * 86400), "message": {"role": "user", "content": "ancient"}}]), encoding="utf-8")
os.utime(old, (now - 9 * 86400, now - 9 * 86400))
def entries(query: str) -> list[dict]:
status, _, body = self.get(f"/api/stream?{query}")
self.assertEqual(status, 200, body)
return json.loads(body)["entries"]
with mock.patch.object(transcripts, "STREAM_SCAN", 0):
rows = entries("")
self.assertEqual([(row["kind"], row["agent"], row["sub"], row["text"][:11]) for row in rows], [
("user", "Skills", "", "hello agent"),
("assistant", "Skills", "", "on it"),
("tool_call", "Skills", "", 'Bash {"comm'),
("tool_result", "Skills", "", "xxxxxxxxxxx"),
("system", "Skills", "", "compacted"),
("user", "Skills", "abc", "SUB task"),
("user", "", "", "other"),
])
result = rows[3]
self.assertEqual((len(result["text"]), result["truncated"]), (2000, True))
status, _, body = self.get(f"/api/stream/{result['id']}")
self.assertEqual((status, len(json.loads(body)["text"])), (200, 2500))
self.assertEqual(self.get("/api/stream/999999")[0], 404)
self.assertEqual(len(entries("agent=Skills")), 6)
self.assertEqual([row["text"] for row in entries("session=s2")], ["other"])
self.assertEqual([row["kind"] for row in entries("kind=tool_call,system")], ["tool_call", "system"])
self.assertEqual([row["text"] for row in entries("q=sub")], ["SUB task"])
self.assertEqual([row["text"] for row in entries(f"from={now - 45}&to={now - 15}")][1:], ["Bash {\"command\": \"ls\"}", "x" * 2000, "compacted"])
self.assertEqual(len(entries("limit=2")), 2)
for query in ("kind=bogus", "from=soon", "after=-1", "limit=x"):
with self.subTest(query=query):
self.assertEqual(self.get(f"/api/stream?{query}")[0], 400)
status, _, body = self.get("/api/stream")
last = json.loads(body)["last"]
with main.open("a", encoding="utf-8") as handle:
handle.write(json.dumps({"type": "user", "sessionId": "s1", "timestamp": iso(now), "message": {"role": "user", "content": "fresh"}}) + "\n")
handle.write('{"type": "user", "partial')
self.assertEqual([row["text"] for row in entries(f"after={last}")], ["fresh"])
self.assertNotIn("ancient", json.dumps(entries("")))
def test_saved_views_filter_the_event_stream(self):
now = time.time()
self.claim("s1", "Skills session")
register = {
"operator/push": {"title": "git-sign-push", "workdir": "/repo", "status": "queued", "check": "manual", "roles": {}},
"work/a": {"title": "A", "status": "queued", "roles": {}},
}
news = [{"ts": now - 30, "from": "Docs", "to": ["Skills"], "text": "hello"}, {"ts": now - 20, "from": "Docs", "to": [], "text": "MAYDAY disk full"}]
self.write_council({"rasci.json": register, "news.jsonl": self.jsonl(news)})
self.request("feat: view", "")
self.create_ask(ASK)
self.decision("")
def kinds(query: str) -> list[str]:
return [row["kind"] for row in self.events(query)["events"]]
self.assertEqual(sorted(set(kinds("view=fuer-dich"))), ["ask", "decision", "operator", "radio", "review"])
self.assertEqual([row["text"] for row in self.events("view=fuer-dich")["events"] if row["kind"] == "radio"], ["MAYDAY disk full"])
self.assertEqual(kinds("view=reviews"), ["review"])
self.assertEqual(kinds("view=radio"), ["radio", "radio"])
self.assertEqual(kinds("view=operator"), ["operator"])
self.assertEqual(kinds("view=entscheidungen"), ["decision"])
self.assertEqual(kinds("kinds=radio&q=mayday"), ["radio"])
self.assertEqual(kinds("kinds=ask,decision&waiting=1"), ["ask", "decision"])
self.assertEqual(kinds("kinds=radio&agent=Docs"), ["radio", "radio"])
self.assertEqual(kinds("view=register"), [])
self.write_council({"rasci.json": {**register, "work/a": {**register["work/a"], "status": "running"}}})
self.assertEqual([(row["title"], row["text"]) for row in self.events("view=register")["events"]], [("work/a", "status: queued → running")])
for query in ("view=bogus", "kinds=bogus", "parts=bogus", "after=x"):
with self.subTest(query=query):
self.assertEqual(self.get(f"/api/events?{query}")[0], 400)
sample = events.event({"id": "x", "ts": now, "kind": "review", "title": "feat: x", "waiting": True})
filters = events.event_filters({"view": ["reviews"]})
self.assertTrue(events.wanted(sample, filters))
self.assertFalse(events.wanted({**sample, "kind": "radio"}, filters))
self.assertFalse(events.wanted({**sample, "waiting": False}, events.event_filters({"view": ["fuer-dich"]})))
def test_crons_come_from_transcripts_timers_and_the_queue(self):
now = time.time()
self.claim("s1", "Skills session")
main = self.projects / "-repo" / "s1.jsonl"
main.parent.mkdir(parents=True)
def use(ts: float, ident: str, name: str, data: dict) -> dict:
return {"type": "assistant", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "assistant", "content": [{"type": "tool_use", "id": ident, "name": name, "input": data}]}}
def result(ts: float, ident: str, text: str) -> dict:
return {"type": "user", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": ident, "content": text}]}}
main.write_text(self.jsonl([
use(now - 60, "t1", "CronCreate", {"cron": "*/5 * * * *", "prompt": "check deploy", "recurring": True}),
result(now - 59, "t1", "Scheduled recurring job c0ac4c97 (Every 5 minutes). Session-only."),
use(now - 50, "t2", "CronCreate", {"cron": "7 * * * *", "prompt": "iterate", "recurring": True}),
result(now - 49, "t2", "Scheduled recurring job 0a929f2d (Every hour at :07)."),
use(now - 40, "t3", "CronDelete", {"id": "c0ac4c97"}),
use(now - 35, "t4", "ScheduleWakeup", {"delaySeconds": 60, "reason": "first", "prompt": "/loop"}),
use(now - 30, "t5", "ScheduleWakeup", {"delaySeconds": 1200, "reason": "fallback", "prompt": "/loop"}),
use(now - 20, "t6", "Bash", {"command": "make test", "run_in_background": True, "description": "gate"}),
use(now - 10, "t7", "Bash", {"command": "ls"}),
]), encoding="utf-8")
(self.timers / "kvb-dream-notify.timer").write_text("[Unit]\nDescription=Daily dream\n[Timer]\nOnCalendar=*-*-* 08:00:00\n", encoding="utf-8")
(self.timers / "timers.target.wants").mkdir()
(self.timers / "timers.target.wants" / "kvb-dream-notify.timer").write_text("", encoding="utf-8")
(self.timers / "old.timer").write_text("[Timer]\nOnUnitActiveSec=1h\n", encoding="utf-8")
entry = {"id": "gate-1", "kind": "gate", "task": "work/hub", "status": "active", "enqueued": now - 5, "cmd": "make test", "name": "Skills session", "session": "s1"}
self.write_council({"queue.json": {"seq": 1, "entries": [entry, "broken"]}})
def crons() -> dict:
with mock.patch.object(transcripts, "STREAM_SCAN", 0):
rows = self.events("view=crons")["events"]
return {row["title"]: (row["ref"]["active"], row["agent"], row["text"]) for row in rows}
self.assertEqual(crons(), {
"Cron: */5 * * * *": (False, "Skills", "check deploy"),
"Cron: 7 * * * *": (True, "Skills", "iterate"),
"Wakeup: in 60 s": (False, "Skills", "/loop\nfirst"),
"Wakeup: in 1200 s": (True, "Skills", "/loop\nfallback"),
"Hintergrund: gate": (True, "Skills", "make test"),
"Timer: *-*-* 08:00:00": (True, "", "Daily dream\nkvb-dream-notify.timer"),
"Timer: 1h": (False, "", "old.timer"),
"Queue: gate · läuft": (True, "Skills", "make test\nwork/hub"),
})
self.write_council({"work/s1/claim.json": {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills session", "since": "2026-11-05T08:00:00+00:00", "beat": now - 3600, "work": "x"}})
self.assertEqual(crons()["Cron: 7 * * * *"][0], False)
subagents = self.projects / "-repo" / "s1" / "subagents"
subagents.mkdir(parents=True)
(subagents / "agent-abc.meta.json").write_text(json.dumps({"agentType": "general-purpose", "description": "review", "spawnDepth": 1, "finished": True, "toolUseId": "t8"}), encoding="utf-8")
(subagents / "agent-def.meta.json").write_text(json.dumps({"agentType": "Explore", "description": "find", "spawnDepth": 2, "finished": False, "toolUseId": "t9"}), encoding="utf-8")
status, _, body = self.get("/api/subagents?session=s1")
self.assertEqual(status, 200, body)
self.assertEqual([(row["id"], row["type"], row["depth"], row["finished"]) for row in json.loads(body)["subagents"]], [("abc", "general-purpose", 1, True), ("def", "Explore", 2, False)])
self.assertEqual(self.get("/api/subagents?session=../x")[0], 400)
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import json
import unittest
from hub_case import HubCase
from hub_support import PAGE
class TestHubTree(HubCase):
def test_tree_lists_repos_branches_and_worktrees(self):
origin = self.outside / "origin.git"
self.git(self.outside, "init", "--quiet", "--bare", "--initial-branch=main", str(origin))
repo = self.repos / "github.com" / "me" / "demo"
repo.mkdir(parents=True)
self.git(repo, "init", "--quiet", "--initial-branch=main")
(repo / "a.txt").write_text("a", encoding="utf-8")
self.git(repo, "add", "a.txt")
self.git(repo, "commit", "--quiet", "-m", "a")
self.git(repo, "remote", "add", "origin", str(origin))
self.git(repo, "push", "--quiet", "-u", "origin", "main")
(repo / "b.txt").write_text("b", encoding="utf-8")
self.git(repo, "add", "b.txt")
self.git(repo, "commit", "--quiet", "-m", "b")
feature = self.worktrees / "github.com" / "me" / "demo" / "feature"
self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(feature))
(feature / "dirty.txt").write_text("x", encoding="utf-8")
other = self.outside / "elsewhere"
other.mkdir()
self.git(other, "init", "--quiet", "--initial-branch=main")
(other / "c.txt").write_text("c", encoding="utf-8")
self.git(other, "add", "c.txt")
self.git(other, "commit", "--quiet", "-m", "c")
lone = self.worktrees / "x" / "y" / "lone"
self.git(other, "worktree", "add", "--quiet", "-b", "lone", str(lone))
feature, repo, other, lone = (path.resolve() for path in (feature, repo, other, lone))
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "worker": "Skills session", "workdir": str(feature)}}})
self.tool("review_request", {**self.arguments("feat: tree", "", self.page("tree.html", PAGE)), "worktree": str(feature), "branch": "feature"})
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
data = json.loads(body)
repos = {entry["name"]: entry for entry in data["repos"]}
self.assertEqual(set(repos), {"github.com/me/demo", str(other)})
demo = {entry["name"]: entry for entry in repos["github.com/me/demo"]["branches"]}
self.assertEqual((demo["main"]["upstream"], demo["main"]["ahead"], demo["main"]["behind"]), ("origin/main", 1, 0))
trees = [(tree["path"], tree["dirty"], tree["workers"], tree["review"]) for tree in demo["main"]["worktrees"] + demo["feature"]["worktrees"]]
self.assertEqual(trees, [(str(repo), False, [], None), (str(feature), True, ["Skills"], {"status": "pending", "gate": "pass", "title": "feat: tree"})])
self.assertEqual([tree["path"] for tree in {entry["name"]: entry for entry in repos[str(other)]["branches"]}["lone"]["worktrees"]], [str(lone)])
self.assertEqual(json.loads(self.get("/api/tree")[2])["scanned"], data["scanned"])
def test_tree_runs_no_command_a_repo_config_names(self):
marker = self.outside / "ran"
trap, filtered = (self.repos / "github.com" / "me" / name for name in ("trap", "filtered"))
for repo in (trap, filtered):
repo.mkdir(parents=True)
self.git(repo, "init", "--quiet", "--initial-branch=main")
(repo / "a.txt").write_text("a", encoding="utf-8")
self.git(repo, "add", "a.txt")
self.git(repo, "commit", "--quiet", "-m", "a")
self.git(trap, "config", "core.fsmonitor", f"touch {marker}")
self.git(filtered, "config", "filter.x.clean", f"touch {marker}")
(filtered / ".gitattributes").write_text("* filter=x\n", encoding="utf-8")
(filtered / "a.txt").write_text("b", encoding="utf-8")
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
dirty = {entry["name"]: entry["branches"][0]["worktrees"][0]["dirty"] for entry in json.loads(body)["repos"]}
self.assertEqual(dirty, {"github.com/me/trap": False, "github.com/me/filtered": None})
self.assertFalse(marker.exists())
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import base64
import io
import json
import struct
import subprocess
import unittest
import wave
from hub_support import VOICE
from kvb_hub import engines, settings
VOICE_PROBE = """
const V = require(process.argv[1]);
const settings = JSON.parse(process.argv[2]);
const rows = [
{class: "mayday", to: [], source: "s1", speech: "m", voice: "v"},
{class: "pan-pan", to: ["Operator"], source: "s1", speech: "p", voice: "v"},
{class: "security", to: [], source: "s2", speech: "s", voice: "v"},
{class: "direct", to: ["Operator"], source: "s2", speech: "d", voice: "v"},
{class: "direct", to: ["Docs"], source: "s3", speech: "o", voice: "v"},
{class: "broadcast", to: [], source: "s3", speech: "b", voice: "v"},
{class: "broadcast", to: [], source: "s3", speech: "", voice: "v"},
];
const filters = [
{on: false, classes: ["broadcast", "direct", "security", "pan-pan", "mayday"], muted: []},
{on: true, classes: ["mayday"], muted: ["s1"]},
{on: true, classes: ["direct", "mayday"], muted: ["s2"]},
{on: true, classes: ["broadcast", "direct", "security", "pan-pan", "mayday"], muted: []},
];
let pending = [];
for (const cls of ["broadcast", "direct", "security", "mayday", "broadcast", "pan-pan", "mayday", "direct"]) {
pending = V.enqueue(pending, {cls, text: `${cls}-${pending.length}`}, 6);
}
console.log(JSON.stringify({
tones: rows.map((row) => V.toneOf(row, "Operator")),
spoken: filters.map((filter) => rows.map((row) => V.speakable(row, filter))),
queue: pending.map((item) => item.cls),
loud: settings.map((one) => Object.fromEntries(["mayday", "review", "direct", "radio"].map((tone) => [tone, V.loudness(tone, one)]))),
frames: [V.frames(1.5, 16000, 120), V.frames(130, 16000, 120), V.frames(0, 16000, 120)],
wav: Buffer.from(V.wav(Float32Array.from([0, 0.5, -0.5, 1, -1, 2, -2]), 16000)).toString("base64"),
}));
"""
class TestVoiceModule(unittest.TestCase):
@classmethod
def setUpClass(cls):
quiet = {name: {"on": False, "volume": 0.1} for name in settings.TONES}
cls.settings = [
settings.SETTINGS,
{**settings.SETTINGS, "music": {"on": True, "volume": 1.0}, "sounds": quiet},
{**settings.SETTINGS, "sounds": {**settings.SETTINGS["sounds"], "mayday": {"on": True, "volume": 0.0}, "radio": {"on": True, "volume": 0.8}}},
]
command = ["node", "-e", VOICE_PROBE, str(VOICE), json.dumps(cls.settings)]
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
def test_urgency_picks_the_tone_and_mayday_is_never_quieter(self):
self.assertEqual(self.data["tones"], ["mayday", "mayday", "direct", "direct", "radio", "radio", "radio"])
self.assertEqual(len(self.data["loud"]), len(self.settings))
for loud in self.data["loud"]:
with self.subTest(loud=loud):
self.assertEqual(loud["mayday"], max(loud.values()))
self.assertEqual([loud["mayday"] for loud in self.data["loud"]], [0.9, 1.0, 0.8])
def test_the_radio_filter_picks_classes_and_stations(self):
self.assertEqual(self.data["spoken"], [
[False] * 7,
[True, False, False, False, False, False, False],
[True, False, False, False, True, False, False],
[True, True, True, True, True, True, False],
])
def test_the_queue_puts_alarms_first_and_drops_routine_traffic(self):
self.assertEqual(self.data["queue"], ["mayday", "mayday", "pan-pan", "security", "broadcast", "direct"])
def test_the_recording_is_encoded_as_16_khz_mono_pcm(self):
body = base64.b64decode(self.data["wav"])
self.assertEqual(engines.wav_seconds(body), 7 / engines.STT_RATE)
with wave.open(io.BytesIO(body)) as reader:
self.assertEqual((reader.getnchannels(), reader.getsampwidth(), reader.getframerate(), reader.getnframes()), (1, 2, 16000, 7))
self.assertEqual(struct.unpack("<7h", reader.readframes(7)), (0, 16383, -16384, 32767, -32768, 32767, -32768))
self.assertEqual(self.data["frames"], [24000, 16000 * 120, 0])
if __name__ == "__main__":
unittest.main()