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:
1 parent
305ccdb1c2
commit
8ba45efd04
119 files changed
+13672
-4
No files matched your search
@@ -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"}})
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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">−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">−1</span></span>
|
||||
<a class="open" href="vscode://file/x" title="Open in VS Code">Open ↗</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 < b:</td></tr>
|
||||
<tr class="add"><td class="num"></td><td class="num">2</td><td class="sign">+</td><td class="code">if a <= b:</td></tr>
|
||||
<tr class="add"><td class="num"></td><td class="num">3</td><td class="sign">+</td><td class="code"> return "<b>"</td></tr>
|
||||
</tbody>
|
||||
<tbody><tr class="more"><td colspan="4">… 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"> → </span>docs/new.md</span>
|
||||
<span class="tag">binary</span>
|
||||
<span class="stat"><span class="add">+0</span><span class="del">−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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user