feat(orchestrator): operator composer with signed text and voice

Stream -> Radio gets one line per station with state, play and stop,
autoplay and CSP refusals as row state and toast, and the speaking agent
pulsing there and in the Orbit and Chord views. The composer sends on all
seven channels; push-to-talk records with MediaRecorder, whisper.cpp
transcribes off the request thread under per-minute and per-hour caps,
and the recording travels only when "Aufnahme anhängen" is ticked.

Every operator message, the mayday clear, the day and night board
actions and a settings save that adds a stream origin take the passkey;
the hub verifies each signature once against the pinned keys before it
relays or acts. The container gets TMPDIR=/scratch so that verification
no longer fails on the read-only root.

A denial now signs its recipient: the inbox and the signing step resolve
it through one function, a claim set naming several agents leaves a
direct denial without recipient, and a recipient that changed after the
inbox showed it is refused with 409. operator_message returns only the
signed reason for a denial, never the agent's own words the radio row
quotes, and the permission hook hashes the signed recipient too.

🎯 Fix: 95 %
💥 Break: 3 %
💸 Cost: 99 %
⏱️ Time: 68 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 19:59:54 +00:00
1 parent dbd9ec3311
commit 089f8dbb9f
49 files changed
+1971 -241

No files matched your search

+6 -2
View File
@@ -4,7 +4,8 @@ import {createServer} from "node:net";
import {tmpdir} from "node:os";
import {join} from "node:path";
// Args: <firefox binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot} like cdp.mjs.
// Args: <firefox binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot, gesture} and an optional
// init like cdp.mjs.
// Prints {results, errors}: one probe value per step and every JavaScript error the page logged.
const [binary, planFile] = process.argv.slice(2);
const plan = JSON.parse(readFileSync(planFile, "utf8"));
@@ -67,6 +68,9 @@ const results = [];
try {
await send("session.new", {capabilities: {}});
await send("session.subscribe", {events: ["log.entryAdded"]});
if (plan.init) {
await send("script.addPreloadScript", {functionDeclaration: plan.init});
}
const {contexts} = await send("browsingContext.getTree", {});
const context = contexts[0].context;
for (const step of plan.steps) {
@@ -77,7 +81,7 @@ try {
await sleep(step.wait);
let value = null;
if (step.probe) {
const answer = await send("script.evaluate", {expression: `(async () => JSON.stringify(await ${step.probe}))()`, target: {context}, awaitPromise: true, resultOwnership: "none"});
const answer = await send("script.evaluate", {expression: `(async () => JSON.stringify(await ${step.probe}))()`, target: {context}, awaitPromise: true, resultOwnership: "none", userActivation: Boolean(step.gesture)});
value = answer.type === "success" ? JSON.parse(answer.result.value) : {error: answer.exceptionDetails.text};
}
if (step.shot) {
+10 -5
View File
@@ -3,16 +3,17 @@ import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, keys, probe, shot, media}, keys
// being real key presses (Enter, Tab) on the focused element before the probe, and an optional
// authenticator {credentialId, privateKey, rpId}: base64 id and PKCS#8 key of a passkey the page may sign with.
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, keys, probe, shot, media, gesture},
// keys being real key presses (Enter, Tab) on the focused element before the probe and gesture running the probe with a
// user activation like a click, an optional init, the text of a function every new document runs before its own scripts,
// and an optional authenticator {credentialId, privateKey, rpId}: base64 id and PKCS#8 key of a passkey the page may sign with.
// Prints {results, errors}: one probe value per step and every uncaught page exception.
const [binary, planFile] = process.argv.slice(2);
const plan = JSON.parse(readFileSync(planFile, "utf8"));
const profile = mkdtempSync(join(tmpdir(), "cdp-"));
// --no-sandbox: the agent sandbox already forbids the user namespaces Chromium's own sandbox needs.
// --enable-unsafe-swiftshader: without a GPU, headless Chromium gives WebGL only through SwiftShader, which it no longer enables by itself.
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const KEYS = {Enter: [13, "\r"], Tab: [9, ""]};
@@ -69,6 +70,10 @@ try {
const {sessionId} = await send("Target.attachToTarget", {targetId, flatten: true});
const page = (method, params) => send(method, params, sessionId);
await page("Runtime.enable", {});
if (plan.init) {
await page("Page.enable", {});
await page("Page.addScriptToEvaluateOnNewDocument", {source: `(${plan.init})();`});
}
if (plan.authenticator) {
await page("WebAuthn.enable", {enableUI: false});
const {authenticatorId} = await page("WebAuthn.addVirtualAuthenticator", {options: {protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true}});
@@ -89,7 +94,7 @@ try {
}
let value = null;
if (step.probe) {
const {result, exceptionDetails} = await page("Runtime.evaluate", {expression: step.probe, awaitPromise: true, returnByValue: true});
const {result, exceptionDetails} = await page("Runtime.evaluate", {expression: step.probe, awaitPromise: true, returnByValue: true, userGesture: Boolean(step.gesture)});
value = exceptionDetails ? {error: exceptionDetails.exception ? exceptionDetails.exception.description : exceptionDetails.text} : result.value;
}
if (step.shot) {
+424 -11
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import array
import base64
import contextlib
import http.server
import json
import math
import os
import shutil
import subprocess
@@ -12,12 +16,15 @@ import time
import unittest
from collections.abc import Callable
from pathlib import Path
from unittest import mock
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
from hub_case import HubCase
from hub_support import ASK, RUNNER, iso, verifier
from hub_engines import wav_bytes
from hub_support import ASK, HOST_STATE, RUNNER, iso, verifier
from kvb_orchestrator import skill_scripts
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
from test_hub_offices import COUNCIL_SCRIPT
from test_hub_offices import host as office_host
@@ -25,7 +32,8 @@ from test_hub_offices import host as office_host
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy"]
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy"]
SUBS = ["Inbox", "Regeln", "Radio"]
HELPERS = """
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const until = async (check, ms) => {
@@ -209,6 +217,246 @@ AFTER = """(async () => {@HELPERS@
const past = await until(() => all("#main details.repo").find((details) => details.textContent.includes("Zuletzt gelöscht")), 15000);
return {past: past ? [past.querySelector("[data-result]").dataset.result, name(past.querySelector("span.state"))] : null, branches: all("#main details.repo").filter((details) => details !== past).flatMap((details) => all(".branch-name strong", details)).map(text)};
})()""".replace("@HELPERS@", HELPERS)
OVERLAP = """
const shown = (node) => node.getClientRects().length > 0 && node.getBoundingClientRect().width > 0;
const words = (label) => [...label.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent).join("").trim();
const called = (node) => node.getAttribute("aria-label") || (node.closest("label") ? words(node.closest("label")) : "") || text(node) || node.type;
const clashes = (root) => {
const boxes = all("input, select, textarea, button", root).filter(shown).map((node) => ({node, box: node.getBoundingClientRect()}));
const found = [];
boxes.forEach((a, index) => boxes.slice(index + 1).forEach((b) => {
const x = Math.min(a.box.right, b.box.right) - Math.max(a.box.left, b.box.left);
const y = Math.min(a.box.bottom, b.box.bottom) - Math.max(a.box.top, b.box.top);
if (x > 0.5 && y > 0.5) {
found.push(`${called(a.node)} × ${called(b.node)}`);
}
}));
const outside = boxes.filter(({box}) => box.right > root.getBoundingClientRect().right + 0.5).map(({node}) => called(node));
return {found, outside};
};
"""
RULES_OVERLAP = """(async () => {@HELPERS@@CLASHES@
const open = document.querySelector(".sheet-head button");
if (open) {
open.click();
}
const rules = await until(() => document.querySelector("#main .rule-form"), 8000);
const kind = rules.querySelector("select");
const found = {};
for (const value of ["decision", "review", "ask"]) {
kind.value = value;
kind.dispatchEvent(new Event("change", {bubbles: true}));
await sleep(150);
found[value] = {...clashes(rules), labels: all("label", rules).filter(shown).map(words)};
}
return found;
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
SETTINGS_OVERLAP = """(async () => {@HELPERS@@CLASHES@
document.getElementById("settings-open").click();
const settings = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
all("button", settings).find((button) => text(button) === "+ Sender").click();
const theme = all("select", settings).find((select) => [...select.options].some((option) => option.value === "custom"));
theme.value = "custom";
theme.dispatchEvent(new Event("change", {bubbles: true}));
await sleep(250);
const place = all("section", settings).find((section) => text(section.querySelector("h3")) === "Ort");
place.scrollIntoView({block: "start"});
return clashes(settings);
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
MEDIA_LOG = """() => {
const keep = (entry) => {
try {
const list = JSON.parse(sessionStorage.getItem("e2e-media") || "[]");
list.push({...entry, page: Math.round(performance.timeOrigin)});
sessionStorage.setItem("e2e-media", JSON.stringify(list));
} catch (error) {
return;
}
};
const play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
window.e2eAudio = this;
const src = this.getAttribute("src") || "";
const result = play.call(this);
result.then(() => keep({play: "ok", src}), (error) => keep({play: error.name, src}));
return result;
};
document.addEventListener("securitypolicyviolation", (event) => keep({csp: event.effectiveDirective, blocked: event.blockedURI}));
}"""
MEDIA = """
const log = () => JSON.parse(sessionStorage.getItem("e2e-media") || "[]");
const sample = () => (window.e2eAudio ? {paused: window.e2eAudio.paused, time: window.e2eAudio.currentTime, error: window.e2eAudio.error ? window.e2eAudio.error.code : null, src: window.e2eAudio.getAttribute("src")} : null);
const advancing = async () => {
const first = sample();
await sleep(1500);
const second = sample();
return {first, second, playing: Boolean(first && second && !second.paused && second.time - first.time > 0.5)};
};
"""
ADD_STATION = """(async () => {@HELPERS@
const clock = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
document.getElementById("settings-open").click();
const form = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
all("button", form).find((button) => text(button) === "+ Sender").click();
await sleep(200);
const row = all(".stream-row", form).pop();
const [label, address, start, end] = all("input.field", row);
const set = (input, value) => {
input.value = value;
input.dispatchEvent(new Event("input", {bubbles: true}));
};
set(label, "@NAME@");
set(address, "@STATION@");
set(start, clock(new Date(Date.now() - 3600000)));
set(end, clock(new Date(Date.now() + 3600000)));
all('.days input[type="checkbox"]', row).forEach((box) => {
box.checked = true;
});
all("button", form).find((button) => button.type === "submit").click();
await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Neuer Sender")), 8000);
return {toasts: all("#toasts .toast").map(text)};
})()""".replace("@HELPERS@", HELPERS)
STATION_NAME = "E2E Ton mit einem sehr langen Sendernamen, der in keine Zeile passt"
RADIO_STATE = """(async () => {@HELPERS@@MEDIA@
await until(() => log().some((entry) => entry.play), 8000);
return {log: log(), media: await advancing(), toasts: all("#toasts .toast").map(text)};
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
RADIO_VIEW = """(async () => {@HELPERS@@MEDIA@
location.hash = "#stream/radio";
const row = await until(() => document.querySelector('#main [data-station="0"]'), 8000);
if (!row) {
const initial = JSON.parse(document.getElementById("state").textContent);
return {error: "no station row", log: log(), wrapped: !String(HTMLMediaElement.prototype.play).includes("[native code]"), streams: initial.settings.streams, media: initial.media, now: new Date().toString()};
}
const label = row.querySelector(".station-name");
const style = getComputedStyle(label);
return {
state: row.dataset.state,
note: text(row.querySelector(".station-note")),
title: label.title,
line: [style.whiteSpace, style.textOverflow, label.scrollWidth > label.clientWidth, label.getBoundingClientRect().height < 2 * parseFloat(style.lineHeight)],
media: await advancing(),
log: log(),
toasts: all("#toasts .toast").map(text),
};
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
RADIO_CLICK = """(async () => {@HELPERS@@MEDIA@
const row = document.querySelector('#main [data-station="0"]');
if (!row) {
return {error: "no station row", log: log(), media: await advancing()};
}
const play = row.querySelector('button[data-action="play"]');
const clicked = Boolean(sample() && sample().paused && play);
if (clicked) {
play.click();
}
await until(() => sample() && !sample().paused, 6000);
const media = await advancing();
const now = document.querySelector('#main [data-station="0"]');
return {clicked, state: now.dataset.state, note: text(now.querySelector(".station-note")), media, log: log()};
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
SPEAKING = """
const caption = () => {
const node = document.querySelector("#main .radio-caption");
return node && !node.hidden ? node : null;
};
const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null);
const cue = async (word) => {
await until(() => document.querySelector("#main .speakers .speaker"), 8000);
await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"});
const found = await until(caption, 25000);
const chip = document.querySelector("#main .speaker.speaking");
return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: chip ? chip.dataset.key : null, ring: ring(chip)} : {error: "no caption", toasts: all("#toasts .toast").map(text)};
};
"""
SPEAK = """(async () => {@HELPERS@@SPEAKING@
return cue("@CUE@");
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@
location.hash = "#agents/orbit";
const node = await until(() => document.querySelector("#main svg .node.speaking"), 6000);
return {node: node ? node.getAttribute("aria-label") : null, ring: node ? getComputedStyle(node.querySelector("circle")).animationName : null};
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000));
location.hash = "#stream/radio";
await until(() => document.querySelector("#main .radio-caption"), 6000);
await sleep(300);
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length};
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
COMPOSE = """(async () => {@HELPERS@
const form = await until(() => document.querySelector("#main form.composer"), 8000);
if (!form) {
return {error: "no composer"};
}
const [channel, recipient] = all("select", form);
await until(() => [...recipient.options].some((option) => option.value === "@TO@"), 8000);
channel.value = "call";
channel.dispatchEvent(new Event("change", {bubbles: true}));
recipient.value = "@TO@";
const record = all("button", form).find((button) => button.hasAttribute("aria-pressed"));
record.click();
await sleep(1500);
const recording = record.getAttribute("aria-pressed");
record.click();
const field = form.querySelector("textarea");
await until(() => field.value && form.querySelector('.voice-note input[type="checkbox"]'), 60000);
const replay = form.querySelector(".voice-note audio");
const attach = form.querySelector('.voice-note input[type="checkbox"]');
return {
channels: all("option", channel).map((option) => option.value),
recipients: all("option", recipient).map((option) => option.value),
recording,
text: field.value,
replay: replay ? replay.getAttribute("src") : null,
attach: attach ? [text(attach.closest("label")), attach.checked] : null,
status: text(form.querySelector(".composer-status")),
toasts: all("#toasts .toast").map(text),
clashes: (() => {@CLASHES@
return clashes(form);
})(),
};
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
COMPOSE_SEND = """(async () => {@HELPERS@
const form = document.querySelector("#main form.composer");
const field = form.querySelector("textarea");
field.value = `${field.value} bitte`;
field.dispatchEvent(new Event("input", {bubbles: true}));
form.querySelector('.voice-note input[type="checkbox"]').checked = @ATTACH@;
const before = all("#toasts .toast").length;
form.querySelector('button[type="submit"]').click();
const sent = await until(() => all("#toasts .toast").slice(before).map(text).find((toast) => toast.startsWith("Call an")), 20000);
return {sent, left: field.value, voice: Boolean(form.querySelector(".voice-note audio"))};
})()""".replace("@HELPERS@", HELPERS)
CLEAR_MAYDAY = """(async () => {@HELPERS@
window.confirm = () => true;
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Mayday aufheben" && !node.disabled), 8000);
if (!button) {
return {error: "no enabled Mayday aufheben button"};
}
button.click();
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Mayday aufheben")), 15000);
return {done, toasts: all("#toasts .toast").map(text)};
})()""".replace("@HELPERS@", HELPERS)
def tone(seconds: int, rate: int) -> bytes:
frames = array.array("h", (int(6000 * math.sin(2 * math.pi * 440 * n / rate)) for n in range(seconds * rate)))
return wav_bytes(1, 2, rate, frames.tobytes())
class Station(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
body = self.server.body
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
with contextlib.suppress(OSError):
self.wfile.write(body)
def log_message(self, *args: object) -> None:
return
LABELS = """
@@ -338,9 +586,9 @@ class TestOrchestratorE2E(HubCase):
(folder / f"agent-{ident}.meta.json").write_text(json.dumps({"agentType": "Explore", "description": f"look {ident}", "spawnDepth": 1, "finished": finished}), encoding="utf-8")
(folder / f"agent-{ident}.jsonl").write_text(self.jsonl([line(now - 1800), line(now - 60)]), encoding="utf-8")
def drive(self, steps: list[dict], authenticator: dict | None, driver: str = "cdp.mjs", binary: str = os.environ.get("CHROMIUM", "chromium")) -> dict:
def drive(self, steps: list[dict], authenticator: dict | None, driver: str = "cdp.mjs", binary: str = os.environ.get("CHROMIUM", "chromium"), init: str | None = None) -> dict:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump({"steps": steps, "authenticator": authenticator}, handle)
json.dump({"steps": steps, "authenticator": authenticator, "init": init}, handle)
self.addCleanup(os.unlink, handle.name)
done = subprocess.run(["node", str(HERE / driver), binary, handle.name], capture_output=True, text=True, timeout=900, check=False)
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
@@ -361,6 +609,112 @@ class TestOrchestratorE2E(HubCase):
def shot(self, name: str) -> str:
return str(OUT / f"chromium-{name}.png")
def authenticator(self) -> dict:
signer = self.pin()
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
return {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
def speaks(self, cues: tuple[str, ...]) -> None:
hub = self.httpd.hub
hub.settings.put({**hub.settings.get(), "speech": {"on": True, "classes": ["mayday", "pan-pan", "security"], "muted": []}})
spoken = tone(6, 8000)
def synthesize(body: bytes) -> bytes:
word = json.loads(body)["text"]
if word in cues:
row = {"ts": time.time(), "from": "Stage", "to": [], "text": f"Platte fast voll ({word})\nzweite Zeile", "class": "security", "v": 2}
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
handle.write(self.jsonl([row]))
return spoken
patch = mock.patch.object(hub.speech, "synthesize", synthesize)
patch.start()
self.addCleanup(patch.stop)
def station(self) -> str:
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Station)
httpd.body = tone(30, 22050)
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)
return f"http://127.0.0.1:{httpd.server_address[1]}/tone.wav"
def check_overlaps(self, browser: str, driver: str, binary: str) -> None:
steps = []
for width, height in WIDTHS:
steps.append({"width": width, "height": height, "url": f"{self.base}#stream/rules", "wait": 900, "probe": RULES_OVERLAP, "shot": str(OUT / f"{browser}-{width}-rules-form.png")})
steps.append({"width": width, "height": height, "url": None, "wait": 0, "probe": SETTINGS_OVERLAP, "shot": str(OUT / f"{browser}-{width}-settings-form.png")})
data = self.drive(steps, None, driver, binary)
(OUT / f"{browser}-overlap.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
hidden = {"decision": ("Werkzeug (Glob)", "Verzeichnis (Glob)"), "review": ("Werkzeug (Glob)",), "ask": ()}
for index, (width, _) in enumerate(WIDTHS):
rules, settings = data["results"][2 * index : 2 * index + 2]
with self.subTest(browser=browser, width=width):
self.assertNotIn("error", rules)
self.assertNotIn("error", settings)
for kind, found in rules.items():
self.assertEqual((found["found"], found["outside"]), ([], []), kind)
self.assertEqual([label for label in hidden[kind] if label in found["labels"]], [], kind)
self.assertIn("Verzeichnis (Glob)", rules["review"]["labels"])
self.assertEqual(settings, {"found": [], "outside": []})
def check_radio(self, browser: str, driver: str, binary: str) -> dict:
"""Args:
driver: cdp.mjs saves the station through the settings form and
signs its new origin with a virtual passkey; bidi.mjs has no
virtual authenticator, so the station is saved before the page
loads.
"""
station = self.station()
signs = driver == "cdp.mjs"
if signs:
first = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#stream", "wait": 900, "probe": ADD_STATION.replace("@STATION@", station).replace("@NAME@", STATION_NAME), "shot": None, "gesture": True}
else:
hub = self.httpd.hub
hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True}]})
first = {"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": None, "shot": None}
steps = [
first,
{"width": 390, "height": 844, "url": None, "wait": 3000, "probe": RADIO_VIEW, "shot": str(OUT / f"{browser}-390-radio-station.png")},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": RADIO_CLICK, "shot": str(OUT / f"{browser}-1366-radio-playing.png"), "gesture": True},
]
data = self.drive(steps, self.authenticator() if signs else None, driver, binary, MEDIA_LOG)
(OUT / f"{browser}-radio.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
saved, view, click = data["results"]
if signs:
self.assertTrue(any(toast.startswith("Neuer Sender") for toast in saved["toasts"]), saved)
self.assertNotIn("error", view)
self.assertEqual(view["title"], STATION_NAME)
self.assertEqual(view["line"], ["nowrap", "ellipsis", True, True])
blocked = [entry for entry in view["log"] if entry.get("play") == "NotAllowedError"]
if not view["media"]["playing"]:
self.assertTrue(blocked, view)
self.assertEqual(view["state"], "blocked", view)
self.assertIn("Klick", view["note"])
self.assertTrue(any("blockiert" in toast for toast in view["toasts"]), view["toasts"])
self.assertTrue(click["clicked"], click)
self.assertNotIn("error", click)
self.assertTrue(click["media"]["playing"], click)
self.assertEqual(click["state"], "playing", click)
self.assertEqual([entry for entry in click["log"] if entry.get("csp") == "media-src"], [])
return data
def test_chromium_form_controls_never_overlap(self):
self.check_overlaps("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_form_controls_never_overlap(self):
self.check_overlaps("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def test_chromium_plays_a_saved_station(self):
self.check_radio("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_plays_a_saved_station(self):
self.check_radio("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def test_chromium_walks_every_screen_at_three_widths(self):
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": self.shot(f"{width}-{screen.replace('/', '-')}")} for width, height in WIDTHS for screen in SCREENS]
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
@@ -377,8 +731,8 @@ class TestOrchestratorE2E(HubCase):
self.assertNotIn("error", probe)
self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator"))
self.assertEqual(probe["header"], ["palette-open", "settings-open"])
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], ["Inbox", "Regeln"]))
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-agents": "1", "tab-artifacts": None})
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], SUBS))
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None})
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full")
self.assertFalse(probe["rail"])
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
@@ -446,13 +800,74 @@ class TestOrchestratorE2E(HubCase):
handle.write(self.jsonl(rows))
self.beside(answer)
data = self.drive([{"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": CALL, "shot": self.shot("1366-call-receipt")}], None)
local = f"http://localhost:{self.port}/"
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#stream", "wait": 900, "probe": CALL, "shot": self.shot("1366-call-receipt")}], self.authenticator())
self.assertEqual(data["errors"], [])
result = data["results"][0]
self.assertNotIn("error", result)
self.assertIsNotNone(result["receipt"], result)
self.assertIn("Gelesen", result["receipt"])
def test_chromium_shows_who_speaks_while_a_message_is_read(self):
self.speaks(("e2e-cue-1", "e2e-cue-2"))
steps = [
{"width": 1366, "height": 768, "url": f"{self.base}#stream/radio", "wait": 900, "probe": SPEAK.replace("@CUE@", "e2e-cue-1"), "shot": self.shot("1366-radio-speaking"), "gesture": True},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": SPEAK_GRAPH, "shot": self.shot("1366-agents-speaking")},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": SPEAK_END, "shot": None},
{"width": 390, "height": 844, "url": None, "wait": 0, "probe": SPEAK.replace("@CUE@", "e2e-cue-2"), "shot": self.shot("390-radio-speaking-reduced-motion"), "gesture": True, "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]},
]
data = self.drive(steps, None)
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
speaking, graph, ended, calm = data["results"]
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-1)", "s2", "speaking"), speaking)
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
self.assertEqual(graph["ring"], "speaking-node")
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0})
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-2)", "none"), calm)
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
local = f"http://localhost:{self.port}/"
steps = [
{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": self.shot("1366-radio-composer"), "gesture": True},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "false"), "shot": None},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": None, "gesture": True},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "true"), "shot": self.shot("1366-radio-composer-sent")},
]
data = self.drive(steps, self.authenticator())
(OUT / "chromium-composer.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
composed, plain, again, sent = data["results"]
self.assertNotIn("error", composed)
self.assertEqual(composed["channels"], ["post", "call", "newsletter", "broadcast", "pan-pan", "security", "mayday"])
self.assertIn("Stage", composed["recipients"])
self.assertEqual(composed["recording"], "true")
self.assertEqual(composed["text"], "hello from the mic second line", composed)
self.assertRegex(composed["replay"] or "", r"^/api/voice/[0-9a-f]{16}\.wav$")
self.assertEqual(composed["attach"], ["Aufnahme anhängen", False])
self.assertEqual(composed["clashes"], {"found": [], "outside": []})
self.assertEqual(again["attach"], ["Aufnahme anhängen", False])
note = again["replay"].removeprefix("/api/voice/").removesuffix(".wav")
for result in (plain, sent):
self.assertEqual((result["left"], result["voice"]), ("", False), result)
self.assertTrue(result["sent"].startswith("Call an Stage: gesendet"), result)
calls = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
self.assertEqual(len(calls), 2, self.scripts.calls)
self.assertEqual([call[:4] for call in calls], [["send", "call", "--to", "Stage"]] * 2)
self.assertTrue(calls[0][-1].endswith("] hello from the mic second line bitte"), calls[0])
self.assertTrue(calls[1][-1].endswith(f"] hello from the mic second line bitte voice: {HOST_STATE}/voice/{note}.wav"), calls[1])
status, _, body = self.get(f"/api/voice/{note}.wav")
self.assertEqual((status, body[:4]), (200, b"RIFF"))
def test_chromium_clears_the_mayday_only_after_the_passkey_signs(self):
local = f"http://localhost:{self.port}/"
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#agents/orbit", "wait": 1200, "probe": CLEAR_MAYDAY, "shot": self.shot("1366-mayday-cleared")}], self.authenticator())
self.assertEqual(data["errors"], [])
result = data["results"][0]
self.assertNotIn("error", result)
self.assertTrue((result["done"] or "").startswith("Mayday aufheben: erledigt"), result)
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
tree = json.loads(self.tree_file.read_text(encoding="utf-8"))
tree["delete_ready"], tree["delete_reason"] = True, ""
@@ -468,13 +883,11 @@ class TestOrchestratorE2E(HubCase):
following = json.loads(json.dumps(tree))
following["repos"][0]["items"] = [item for item in following["repos"][0]["items"] if item["branch"] != "feature/done"]
(fake.parent / "next-tree.json").write_text(json.dumps(following), encoding="utf-8")
signer = self.pin()
authenticator = self.authenticator()
trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "cleanup.json", origin=self.origin)
runner = host.Cleanup(trust, self.tree_file, fake)
jobs, results = self.cleanup / "jobs", self.cleanup / "results"
self.beside(lambda: any(jobs.iterdir()) and host.cycle(jobs, results, runner))
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
local = f"http://localhost:{self.port}/"
steps = [
{"width": 1366, "height": 768, "url": f"{local}#artifacts/tree", "wait": 1200, "probe": OPEN_DELETE, "shot": self.shot("1366-delete-dry-run")},
@@ -658,7 +1071,7 @@ class TestOrchestratorE2E(HubCase):
with self.subTest(width=width, screen=screen):
self.assertNotIn("error", probe)
self.assertEqual(probe["hash"], f"#{screen}")
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], ["Inbox", "Regeln"]))
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], SUBS))
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
self.assertFalse(probe["wide"], "the page scrolls sideways")
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
+18
View File
@@ -122,6 +122,24 @@ class HubCase(unittest.TestCase):
def proven(self, items: list[dict], signer: Key) -> bytes:
return json.dumps({"proof": self.signed(items, signer)}).encode()
def message_proof(self, payload: dict, signer: Key) -> dict:
status, _, body = self.post_json("/api/messages/sign", payload, self.guard(self.host))
self.assertEqual(status, 200, body)
return signer.proof(json.loads(body)["payload"], self.origin)
def board_proof(self, action: str, signer: Key) -> dict:
status, _, body = self.post_json("/api/board/sign", {"action": action}, self.guard(self.host))
self.assertEqual(status, 200, body)
return signer.proof(json.loads(body)["payload"], self.origin)
def say(self, payload: dict, signer: Key) -> tuple[int, dict, bytes]:
proof = self.message_proof({"upload": "", "voice": "", **payload}, signer)
return self.post_json("/api/messages", {"upload": "", "voice": "", **payload, "proof": proof}, self.guard(self.host))
def tell_all(self, payload: dict, signer: Key) -> tuple[int, dict, bytes]:
proof = self.message_proof({"to": "*", "upload": "", "voice": "", **payload}, signer)
return self.post_json("/api/board/broadcast", {"voice": "", **payload, "proof": proof}, self.guard(self.host))
def page(self, name: str, text: str) -> str:
path = self.pages / name
path.write_text(text, encoding="utf-8")
+15
View File
@@ -4,6 +4,7 @@ import hashlib
import http.server
import json
import os
import re
import secrets
import stat
import subprocess
@@ -297,6 +298,20 @@ class TestApproval(unittest.TestCase):
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_openssl_without_a_temporary_directory_refuses_cleanly(self):
signer = key(-7, "main")
data = os.urandom(64)
signature = signer.sign(data)
with mock.patch.object(tempfile, "tempdir", str(self.base / "no-such-tmp")), self.assertRaisesRegex(signing.Refused, "no writable temporary directory"):
passkey.by_openssl(-7, signer.spki, data, signature)
self.assertTrue(passkey.by_openssl(-7, signer.spki, data, signature))
def test_the_container_points_tmpdir_at_a_tmpfs(self):
compose = (HUB / "compose.yml").read_text(encoding="utf-8")
tmpdir = re.search(r"(?m)^ {6}TMPDIR: (/\S+)$", compose)
self.assertIsNotNone(tmpdir, "compose.yml sets no TMPDIR for the hub")
self.assertRegex(compose, rf"(?m)^ {{6}}- {re.escape(tmpdir.group(1))}:size=")
def test_the_pin_cli_writes_the_pinned_keys_with_mode_0600(self):
approvers = self.base / "fresh" / "kvb-orchestrator" / "approvers.json"
+51 -20
View File
@@ -10,7 +10,7 @@ from unittest import mock
from urllib.parse import quote
from hub_case import HubCase
from hub_support import ASK, HOST_STATE, iso, server
from hub_support import ASK, HOST_STATE, iso, key, server
from kvb_orchestrator import handler, messaging, skill_scripts, transcripts
@@ -35,28 +35,30 @@ class TestHubBoard(HubCase):
def test_operator_messages_go_by_radio_and_verify_over_mcp(self):
self.claim("s1", "Skills session")
signer = self.pin()
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"], "class": "post"}, self.guard(self.host))
status, _, body = self.say({"to": "Skills", "text": "please\n rebase", "upload": upload["id"], "class": "post"}, signer)
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-orchestrator 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"])
verified = self.tool("operator_message", {"id": sent["id"], "text": f"please rebase file: {upload['path']}", "to": "Skills session"})["structuredContent"]
self.assertEqual((verified["match"], verified["to"], verified["text"], verified["upload"], verified["voice"]), (True, "Skills", "please\n rebase", upload["path"], None))
self.assertEqual([(row["id"], row["to"], row["upload"], row["signed"]) for row in self.snapshot()["messages"]], [(sent["id"], "Skills", "plan.md", True)])
self.assertTrue(self.tool("operator_message", {"id": "0123456789abcdef", "text": "please rebase", "to": "Skills"})["isError"])
self.assertTrue(self.tool("operator_message", {"id": "../x", "text": "please rebase", "to": "Skills"})["isError"])
for payload in (
{"to": "Skills", "text": " ", "upload": "", "class": "post"},
{"to": "Skills", "text": "hi", "upload": "0123456789abcdef", "class": "post"},
{"to": "Skills", "text": "hi", "upload": "../../etc", "class": "post"},
{"to": "two\nlines", "text": "hi", "upload": "", "class": "post"},
{"to": "Skills", "text": "hi", "class": "post"},
{"to": "Skills", "text": " ", "upload": "", "class": "post", "voice": "", "proof": None},
{"to": "Skills", "text": "hi", "upload": "0123456789abcdef", "class": "post", "voice": "", "proof": None},
{"to": "Skills", "text": "hi", "upload": "../../etc", "class": "post", "voice": "", "proof": None},
{"to": "two\nlines", "text": "hi", "upload": "", "class": "post", "voice": "", "proof": None},
{"to": "Skills", "text": "hi", "class": "post", "voice": "", "proof": None},
{"to": "Skills", "text": "hi", "upload": "", "class": "post", "voice": "", "proof": None},
):
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": "", "class": "post"}, {"Host": self.host, "X-Kvb-Orchestrator": "1"})[0], 403)
self.assertEqual(self.post_json("/api/messages", {"to": "Skills", "text": "hi", "upload": "", "class": "post", "voice": "", "proof": None}, {"Host": self.host, "X-Kvb-Orchestrator": "1"})[0], 403)
self.assertEqual(len(self.scripts.calls), 1)
def test_board_actions_run_the_skill_scripts(self):
@@ -75,15 +77,20 @@ class TestHubBoard(HubCase):
self.assertEqual(self.snapshot()["agent_order"], ["Gatekeeper", "A", "B session", "Skills session"])
register = (self.council / "rasci.json").read_text(encoding="utf-8")
signer = self.pin()
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", {})
def signed(action: str) -> dict:
return {"proof": self.board_proof(action, signer)}
status, result = board("clear-mayday", signed("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", "class": "newsletter"})
message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"]})["structuredContent"]["id"]
self.assertEqual(self.tell_all({"text": "main is green", "class": "newsletter"}, signer)[0], 200)
message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"], "text": "main is green", "to": "*"})["structuredContent"]["id"]
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-orchestrator msg {message_id}] main is green"]))
self.assertEqual(board("order", {"agents": ["Gatekeeper", "A", "B session"]})[0], 200)
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RASCI, ["agents", "order", "Gatekeeper,A,B session"]))
@@ -94,22 +101,46 @@ class TestHubBoard(HubCase):
self.assertEqual(len(self.scripts.calls), calls)
self.assertEqual((self.council / "rasci.json").read_text(encoding="utf-8"), register)
calls = len(self.scripts.calls)
self.assertEqual(board("evening", {})[0], 200)
self.assertEqual(board("evening", signed("evening"))[0], 200)
self.assertEqual(self.scripts.calls[calls:], [
(skill_scripts.NIGHT, ["set", "evening", "--session", "Operator"]),
(skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]),
])
self.assertEqual(board("sunrise", {})[0], 200)
self.assertEqual(board("sunrise", signed("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", {})
status, result = board("sunset", signed("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": " ", "class": "newsletter"})[0], 400)
self.assertEqual(board("broadcast", {"text": " ", "class": "newsletter", "voice": "", "proof": None})[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_board_actions_that_speak_for_the_operator_need_the_passkey(self):
self.claim("s1", "Skills session")
signer = self.pin()
good = self.guard(self.host)
for action, other in (("clear-mayday", "sunrise"), ("evening", "sunset"), ("sunset", "evening"), ("sunrise", "clear-mayday")):
with self.subTest(action=action):
self.assertEqual(self.post_json(f"/api/board/{action}", {}, good)[0], 400)
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"nicht signiert: no signature\n"))
stranger = key(-7, "stranger").proof(self.board_proof(action, signer)["payload"], self.origin)
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": stranger}, good)[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
proof = self.board_proof(action, signer)
self.assertEqual(self.post_json(f"/api/board/{other}", {"proof": proof}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
self.assertEqual(self.scripts.calls, [])
proof = self.board_proof("clear-mayday", signer)
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[0], 200)
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": 1}, {}, {"action": "evening", "extra": 1}):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/board/sign", payload, good)[0], 400)
self.assertEqual(self.post_json("/api/board/sign", {"action": "evening"}, self.client())[0], 403)
self.assertEqual(self.get("/api/board/sign")[0], 405)
self.assertEqual(len(self.scripts.calls), 1)
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)
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
import hashlib
import json
import secrets
import tempfile
import threading
import time
import unittest
from unittest import mock
from hub_case import HubCase
from hub_engines import log_rows, wav_bytes
from hub_support import HOST_STATE, key, signing
from kvb_orchestrator import engines, exchange, signed, skill_scripts, voicenotes
class TestHubComposer(HubCase):
def clip(self, sample: bytes) -> bytes:
return wav_bytes(1, 2, engines.STT_RATE, sample * engines.STT_RATE)
def record(self, body: bytes, headers: dict[str, str]) -> tuple[int, dict, bytes]:
return self.call("POST", "/api/voice", body, {"Content-Type": "audio/wav", **headers})
def note(self, body: bytes) -> str:
status, _, answer = self.record(body, self.guard(self.host))
self.assertEqual(status, 202, answer)
note = json.loads(answer)
self.settle(lambda: self.heard(note["id"])["status"] != "pending")
return note["id"]
def heard(self, note_id: str) -> dict:
status, _, body = self.get(f"/api/voice/{note_id}")
self.assertEqual(status, 200, body)
return json.loads(body)
def submit(self, body: bytes) -> tuple[int, bytes]:
status, _, answer = self.record(body, self.guard(self.host))
if status == 202:
note = json.loads(answer)["id"]
self.settle(lambda: self.heard(note)["status"] != "pending")
return status, answer
def hear(self, body: bytes) -> tuple[int, bytes]:
status, _, answer = self.call("POST", "/api/stt", body, {**self.guard(self.host), "Content-Type": "audio/wav"})
return status, answer
def check(self, message_id: str, text: str, to: str) -> dict:
return self.tool("operator_message", {"id": message_id, "text": text, "to": to})
def test_a_recording_is_transcribed_off_the_request_and_kept_for_replay(self):
good = self.guard(self.host)
clip = self.clip(b"\x01\x00")
release = threading.Event()
whisper = self.httpd.hub.speech.whisper
def slow(body: bytes) -> dict:
release.wait(10)
return whisper(body)
with mock.patch.object(self.httpd.hub.speech, "whisper", side_effect=slow):
status, _, body = self.record(clip, good)
self.assertEqual(status, 202, body)
note = json.loads(body)
self.assertEqual((note["status"], note["text"], note["seconds"]), ("pending", "", 1.0))
self.assertEqual(self.heard(note["id"])["status"], "pending")
release.set()
self.settle(lambda: self.heard(note["id"])["status"] == "done")
self.assertEqual(self.heard(note["id"])["text"], "hello from the mic second line")
status, headers, body = self.get(f"/api/voice/{note['id']}.wav")
self.assertEqual((status, headers["content-type"], headers["content-security-policy"], body), (200, "audio/wav", exchange.PLAIN_CSP, clip))
self.assertEqual((self.state / "voice" / f"{note['id']}.wav").read_bytes(), clip)
failed = self.note(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100))
self.assertEqual(self.heard(failed)["status"], "failed")
self.assertIn("exit code 4", self.heard(failed)["error"])
self.assertEqual(self.get(f"/api/voice/{failed}.wav")[0], 200)
for path in ("/api/voice/0123456789abcdef", "/api/voice/0123456789abcdef.wav", "/api/voice/../tts", "/api/voice/x.wav"):
with self.subTest(path=path):
self.assertEqual(self.get(path)[0], 404)
self.assertEqual(self.get("/api/voice")[0], 405)
self.assertEqual(len(log_rows(self.engines.scratch / "whisper.jsonl")), 2)
self.assertEqual(self.scripts.calls, [])
def test_a_recording_is_checked_for_guard_type_size_length_and_load(self):
good = self.guard(self.host)
clip = self.clip(b"\x01\x00")
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
with self.subTest(headers=headers):
self.assertEqual(self.record(clip, headers)[0], 403)
self.assertEqual(self.call("POST", "/api/voice", clip, {**good, "Content-Type": "audio/webm"})[0], 415)
self.assertEqual(self.record(b"RIFF", {**good, "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(1, 2, engines.STT_RATE, b"")):
with self.subTest(broken=broken[:20]):
self.assertEqual(self.record(broken, good)[0], 400)
with mock.patch.object(engines, "STT_SECONDS", 1):
self.assertEqual(self.record(wav_bytes(1, 2, engines.STT_RATE, b"\x00\x00" * (engines.STT_RATE + 1)), good)[0], 400)
with mock.patch.object(self.httpd.hub.speech, "input_gap", return_value="the whisper.cpp model m is not a file"):
self.assertEqual(self.record(clip, good)[:3:2], (503, b"speech input is off: the whisper.cpp model m is not a file\n"))
release = threading.Event()
self.addCleanup(release.set)
with mock.patch.object(self.httpd.hub.speech, "whisper", side_effect=lambda body: release.wait(10) and {"text": ""}):
for _ in range(voicenotes.HEARING_LIMIT):
self.assertEqual(self.record(clip, good)[0], 202)
self.assertEqual(self.record(clip, good)[0], 503)
release.set()
self.assertEqual(list((self.state / "voice").glob(".*")), [])
def test_only_the_newest_recordings_are_kept(self):
with mock.patch.object(voicenotes, "VOICE_KEEP", 2):
first, second, third = (self.note(self.clip(bytes([n, 0]))) for n in (1, 2, 3))
self.assertEqual(self.get(f"/api/voice/{first}.wav")[0], 404)
self.assertEqual([self.get(f"/api/voice/{note}.wav")[0] for note in (second, third)], [200, 200])
self.assertEqual(sorted(path.stem for path in (self.state / "voice").iterdir()), sorted([second, third]))
def test_an_operator_message_needs_the_passkey_and_carries_its_recording(self):
self.claim("s1", "Skills session")
voice = self.note(self.clip(b"\x01\x00"))
payload = {"to": "Skills", "text": " bitte rebasen ", "upload": "", "class": "call", "voice": voice}
status, _, body = self.post_json("/api/messages/sign", payload, self.guard(self.host))
self.assertEqual(status, 200, body)
self.assertEqual(json.loads(body)["credentials"], [])
unpinned = key(-7, "stranger").proof(json.loads(body)["payload"], self.origin)
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": unpinned}, self.guard(self.host))[:3:2], (400, b"nicht signiert: no key is pinned\n"))
signer = self.pin()
proof = self.message_proof(payload, signer)
for changed, reason in (
({"proof": None}, b"nicht signiert: no signature\n"),
({"proof": key(-7, "stranger").proof(proof["payload"], self.origin)}, b"nicht signiert: the signing key is not pinned\n"),
({"proof": signer.proof(proof["payload"], "http://127.0.0.1:1")}, None),
({"text": "bitte mergen", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
({"class": "post", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
({"voice": "", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
):
with self.subTest(changed=list(changed)):
status, _, answer = self.post_json("/api/messages", {**payload, **changed}, self.guard(self.host))
self.assertEqual(status, 400, answer)
if reason is not None:
self.assertEqual(answer, reason)
self.assertEqual(self.scripts.calls, [])
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
self.assertEqual(status, 201, body)
sent = json.loads(body)
recording = f"{HOST_STATE}/voice/{voice}.wav"
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] bitte rebasen voice: {recording}"])])
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
verified = self.check(sent["id"], "bitte rebasen", "Skills session")["structuredContent"]
self.assertEqual((verified["match"], verified["text"], verified["class"], verified["voice"]), (True, "bitte rebasen", "call", recording))
row = self.snapshot()["messages"][-1]
self.assertEqual((row["id"], row["class"], row["voice"], row["signed"]), (sent["id"], "call", voice, True))
self.assertEqual(len(self.scripts.calls), 1)
def test_a_signed_message_to_all_names_everyone_and_keeps_its_class(self):
signer = self.pin()
voice = self.note(self.clip(b"\x01\x00"))
status, _, body = self.tell_all({"text": "disk full", "class": "mayday", "voice": voice}, signer)
self.assertEqual(status, 200, body)
script, args = self.scripts.calls[-1]
self.assertEqual((script, args[:4]), (skill_scripts.RADIO, ["send", "mayday", "--session", "Operator"]))
self.assertTrue(args[-1].endswith(f"] disk full voice: {HOST_STATE}/voice/{voice}.wav"), args[-1])
self.assertEqual(self.snapshot()["messages"][-1]["to"], "*")
for payload in (
{"to": "Skills", "text": "x", "upload": "", "class": "mayday", "voice": ""},
{"to": "*", "text": "x", "upload": "0123456789abcdef", "class": "newsletter", "voice": ""},
{"to": "*", "text": "x", "upload": "", "class": "receipt", "voice": ""},
{"to": "*", "text": " ", "upload": "", "class": "broadcast", "voice": ""},
{"to": "*", "text": "x", "upload": "", "class": "broadcast", "voice": "0123456789abcdef"},
{"to": "*", "text": "x", "upload": "", "class": "broadcast", "voice": 7},
{"to": "*", "text": "x", "upload": "", "class": "broadcast"},
):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/messages/sign", payload, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json("/api/messages/sign", {"to": "*", "text": "x", "upload": "", "class": "broadcast", "voice": ""}, self.client())[0], 403)
self.assertEqual(self.get("/api/messages/sign")[0], 405)
def test_operator_message_matches_only_the_signed_text_and_recipient(self):
self.claim("s1", "Skills session")
signer = self.pin()
voice = self.note(self.clip(b"\x01\x00"))
status, _, body = self.say({"to": "Skills", "text": "bitte\n rebasen", "class": "call", "voice": voice}, signer)
self.assertEqual(status, 201, body)
sent = json.loads(body)["id"]
row = self.scripts.calls[-1][1][-1].split("] ", 1)[1]
for text, to in (("bitte rebasen", "Skills"), ("bitte rebasen", "Skills session"), (row, "Skills")):
with self.subTest(text=text, to=to):
result = self.check(sent, text, to)
self.assertFalse(result["isError"], result)
found = result["structuredContent"]
self.assertEqual((found["match"], found["text"], found["to"], found["class"]), (True, "bitte\n rebasen", "Skills", "call"))
self.assertEqual(found["voice"], f"{HOST_STATE}/voice/{voice}.wav")
for text, to in (("bitte mergen", "Skills"), ("bitte rebasen", "Stage"), ("bitte rebasen", "*"), ("bitte", "Skills")):
with self.subTest(text=text, to=to):
result = self.check(sent, text, to)
self.assertTrue(result["isError"], result)
self.assertNotIn("structuredContent", result)
self.assertEqual(self.tell_all({"text": "main is green", "class": "newsletter"}, signer)[0], 200)
everyone = next(message["id"] for message in self.snapshot()["messages"] if message["to"] == "*")
self.assertTrue(self.check(everyone, "main is green", "*")["structuredContent"]["match"])
self.assertTrue(self.check(everyone, "main is green", "Skills")["isError"])
legacy = self.httpd.hub.messages.add({"to": "Skills", "address": "Skills session", "text": "deploy", "upload": None, "created_ts": time.time()})
self.assertTrue(self.check(legacy["id"], "deploy", "Skills")["isError"])
self.httpd.hub.messages.change(sent, {"text": "rm -rf"})
self.assertTrue(self.check(sent, "rm -rf", "Skills")["isError"])
self.assertTrue(self.tool("operator_message", {"id": everyone})["isError"])
def test_a_post_or_a_call_never_goes_to_everyone(self):
signer = self.pin()
for cls in ("post", "call"):
with self.subTest(cls=cls):
payload = {"to": " * ", "text": "x", "upload": "", "class": cls, "voice": ""}
self.assertEqual(self.post_json("/api/messages/sign", payload, self.guard(self.host))[:3:2], (400, b"a post or a call names one agent, never *\n"))
item = signing.message_item("*", cls, "x", "", "", "")
request = signing.approval_payload([signing.item_hash(item)], secrets.token_hex(16), int(time.time()) + 60)
proof = signer.proof(request, self.origin)
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[0], 400)
self.assertEqual(self.scripts.calls, [])
def test_the_hub_transcribes_a_bounded_number_of_recordings_per_minute(self):
answers = [self.submit(self.clip(b"\x01\x00")) for _ in range(12)]
statuses = [status for status, _ in answers]
self.assertIn(503, statuses)
self.assertEqual(statuses, [202] * engines.HEARING_RATE + [503] * (12 - engines.HEARING_RATE))
self.assertIn(f"at most {engines.HEARING_RATE} recordings per {engines.HEARING_WINDOW} s".encode(), answers[-1][1])
self.assertEqual(self.hear(self.clip(b"\x01\x00"))[0], 503)
self.assertEqual(len(log_rows(self.engines.scratch / "whisper.jsonl")), engines.HEARING_RATE)
def test_the_hub_transcribes_at_most_its_hourly_audio_budget(self):
full = wav_bytes(1, 2, engines.STT_RATE, b"\x01\x00" * engines.STT_RATE * engines.STT_SECONDS)
statuses = [self.submit(full)[0] for _ in range(5)]
status, answer = self.submit(self.clip(b"\x01\x00"))
self.assertEqual((statuses, status), ([202] * 5, 503), answer)
self.assertEqual(engines.HEARING_BUDGET, 5 * engines.STT_SECONDS)
self.assertIn(f"{engines.HEARING_BUDGET} s of audio per hour".encode(), answer)
self.assertEqual(self.hear(self.clip(b"\x01\x00"))[0], 503)
def test_a_recording_a_stored_message_refers_to_is_never_trimmed(self):
self.claim("s1", "Skills session")
signer = self.pin()
with mock.patch.object(voicenotes, "VOICE_KEEP", 1):
kept = self.note(self.clip(b"\x01\x00"))
self.assertEqual(self.say({"to": "Skills", "text": "hear this", "class": "post", "voice": kept}, signer)[0], 201)
newer = [self.note(self.clip(bytes([n, 0]))) for n in (2, 3)]
self.assertEqual(self.get(f"/api/voice/{kept}.wav")[0], 200)
self.assertEqual([self.get(f"/api/voice/{note}.wav")[0] for note in newer], [404, 200])
sending = self.note(self.clip(b"\x04\x00"))
with self.httpd.hub.voice_notes.held(sending):
self.note(self.clip(b"\x05\x00"))
self.assertEqual(self.get(f"/api/voice/{sending}.wav")[0], 200)
self.assertEqual(self.get(f"/api/voice/{kept}.wav")[0], 200)
def test_the_signature_binds_the_bytes_of_the_recording(self):
self.claim("s1", "Skills session")
signer = self.pin()
voice = self.note(self.clip(b"\x01\x00"))
wav = self.state / "voice" / f"{voice}.wav"
original = wav.read_bytes()
payload = {"to": "Skills", "text": "hear this", "upload": "", "class": "call", "voice": voice}
proof = self.message_proof(payload, signer)
wav.write_bytes(self.clip(b"\x02\x00"))
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
self.assertEqual(self.scripts.calls, [])
wav.write_bytes(original)
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
self.assertEqual(status, 201, body)
found = self.check(json.loads(body)["id"], "hear this", "Skills")["structuredContent"]
self.assertEqual(found["voice_sha256"], hashlib.sha256(original).hexdigest())
def test_a_signature_check_without_a_temporary_directory_answers_400(self):
self.claim("s1", "Skills session")
signer = self.pin()
payload = {"to": "Skills", "text": "x", "upload": "", "class": "post", "voice": ""}
proof = self.message_proof(payload, signer)
missing = self.state / "no-such-tmp"
with mock.patch.object(signed.passkey, "serialization", None), mock.patch.object(tempfile, "tempdir", str(missing)):
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
self.assertEqual(status, 400, body)
self.assertTrue(body.startswith(b"nicht signiert: "), body)
self.assertEqual(self.scripts.calls, [])
if __name__ == "__main__":
unittest.main()
+63 -7
View File
@@ -14,6 +14,7 @@ from hub_support import ASK, CLIENT, Key, key, signing, verifier
from kvb_orchestrator import batches, skill_scripts
PUSH = "git push"
PLANTED = {"work/a0/claim.json": {"office": "work/a0", "session": "s1", "runtime": "claude", "name": "Villain session", "since": "2026-11-05T08:00:00+00:00", "beat": 0, "work": "x"}}
HOOK_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_permission_hook", CLIENT)
permission_hook = importlib.util.module_from_spec(HOOK_SPEC)
HOOK_SPEC.loader.exec_module(permission_hook)
@@ -21,7 +22,8 @@ HOOK_SPEC.loader.exec_module(permission_hook)
class TestHubDeny(HubCase):
def denial(self, kind: str, ident: str, digest: str, channel: str, reason: str) -> dict:
return {**self.entry(kind, ident, "", digest), "verb": "reject", "channel": channel, "reason": reason}
shown = next(item["to"] for item in self.snapshot()["inbox"] if item["id"] == ident)
return {**self.entry(kind, ident, "", digest), "verb": "reject", "channel": channel, "reason": reason, "to": shown}
def radio(self) -> list[list[str]]:
return [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
@@ -30,11 +32,11 @@ class TestHubDeny(HubCase):
self.claim("s1", "Skills session")
ask_id = self.create_ask(ASK)
item = next(item for item in self.snapshot()["inbox"] if item["id"] == ask_id)
self.assertEqual((item["deniable"], item["agent"]), (True, "Skills"))
self.assertEqual((item["deniable"], item["agent"], item["to"]), (True, "Skills", "Skills"))
entry = self.denial("ask", ask_id, "", "call", " not now,\n run the gate first ")
subject = signing.ask_item(ask_id, "Bash", "make deploy", "/repo", "s1")
prepared = self.prepare([entry])
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.denial_item(subject, "call", "not now, run the gate first"))])
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.denial_item(subject, "call", "not now, run the gate first", "Skills"))])
self.assertNotIn(signing.item_hash(subject), prepared["payload"]["items"])
signer = self.pin()
approval = signer.proof(self.prepare([self.entry("ask", ask_id, "", "")])["payload"], self.origin)
@@ -70,7 +72,14 @@ class TestHubDeny(HubCase):
self.assertEqual(sent[5], "Operator")
message_id = re.match(r"\[kvb-orchestrator msg ([0-9a-f]{16})\] ", sent[6]).group(1)
self.assertEqual(sent[6].split("] ", 1)[1], "Denied by the operator: ask Bash: make deploy (from Skills). Reason: not now, run the gate first")
self.assertEqual(self.tool("operator_message", {"id": message_id})["structuredContent"]["text"], sent[6].split("] ", 1)[1])
told = sent[6].split("] ", 1)[1]
verified = self.tool("operator_message", {"id": message_id, "text": told, "to": "Skills session"})["structuredContent"]
self.assertEqual((verified["to"], verified["class"], verified["text"]), ("Skills", "call", "Denied by the operator. Reason: not now, run the gate first"))
self.assertTrue(self.tool("operator_message", {"id": message_id, "text": f"{told} and push", "to": "Skills session"})["isError"])
self.assertTrue(self.tool("operator_message", {"id": message_id, "text": told, "to": "Docs"})["isError"])
denial = self.httpd.hub.messages.get(message_id)["denial"]
self.httpd.hub.messages.change(message_id, {"denial": {**denial, "reason": "push anyway"}})
self.assertTrue(self.tool("operator_message", {"id": message_id, "text": told, "to": "Skills session"})["isError"])
logged = [row for row in self.events("kinds=approval")["events"] if row["title"].startswith("Ablehnung ask")]
self.assertEqual(len(logged), 1)
self.assertIn("call an Skills session", logged[0]["text"])
@@ -101,7 +110,7 @@ class TestHubDeny(HubCase):
entry = self.denial("operator", "operator/push", digest, "post", "push after the review")
self.assertEqual(self.approve([{**entry, "sha256": "0" * 64}], None)[0], 409)
prepared = self.prepare([entry])
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.denial_item(signing.operator_item("operator/push", PUSH, "/repo"), "post", "push after the review"))])
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.denial_item(signing.operator_item("operator/push", PUSH, "/repo"), "post", "push after the review", "Skills"))])
unnamed = self.denial("operator", "operator/name", items["operator/name"]["sha256"], "post", "")
with mock.patch.object(batches, "UNDO", 0.05):
self.assertEqual(self.approve([entry, unnamed], self.signed([entry, unnamed], self.pin()))[0], 202)
@@ -131,9 +140,54 @@ class TestHubDeny(HubCase):
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("pending", []))
self.assertNotIn(skill_scripts.RASCI, [script for script, _ in self.scripts.calls])
def test_a_claim_planted_after_signing_neither_redirects_nor_verifies_a_denial(self):
self.claim("s1", "Skills session")
ask_id = self.create_ask(ASK)
entry = self.denial("ask", ask_id, "", "call", "not now")
proof = self.signed([entry], self.pin())
with mock.patch.object(batches, "UNDO", 0.3):
self.assertEqual(self.approve([entry], proof)[0], 202)
self.write_council(PLANTED)
self.settle(lambda: self.radio() != [])
sent = self.radio()[-1]
self.assertEqual(sent[:4], ["send", "call", "--to", "Skills session"])
message_id = re.match(r"\[kvb-orchestrator msg ([0-9a-f]{16})\] ", sent[6]).group(1)
told = sent[6].split("] ", 1)[1]
self.assertIn("make deploy", told)
self.assertTrue(self.tool("operator_message", {"id": message_id, "text": told, "to": "Villain session"})["isError"])
verified = self.tool("operator_message", {"id": message_id, "text": told, "to": "Skills"})["structuredContent"]
self.assertEqual((verified["to"], verified["text"]), ("Skills", "Denied by the operator. Reason: not now"))
def test_a_recipient_changed_after_the_inbox_showed_it_is_refused(self):
self.claim("s1", "Skills session")
ask_id = self.create_ask(ASK)
entry = self.denial("ask", ask_id, "", "call", "not now")
self.assertEqual(entry["to"], "Skills")
self.claim("s1", "Villain session")
for path, body in (("/api/sign", {"items": [entry]}), ("/api/inbox/approve", {"items": [entry], "proof": None})):
with self.subTest(path=path):
status, _, answer = self.post_json(path, body, self.guard(self.host))
self.assertEqual(status, 409, answer)
self.assertIn("recipient changed", answer.decode())
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("pending", []))
def test_a_claim_planted_before_signing_leaves_a_direct_denial_without_recipient(self):
self.claim("s1", "Skills session")
self.write_council(PLANTED)
ask_id = self.create_ask(ASK)
item = next(item for item in self.snapshot()["inbox"] if item["id"] == ask_id)
self.assertEqual(item["to"], "")
entry = self.denial("ask", ask_id, "", "call", "not now")
subject = signing.ask_item(ask_id, "Bash", "make deploy", "/repo", "s1")
self.assertEqual(self.prepare([entry])["payload"]["items"], [signing.item_hash(signing.denial_item(subject, "call", "not now", ""))])
with mock.patch.object(batches, "UNDO", 0.05):
self.assertEqual(self.approve([entry], self.signed([entry], self.pin()))[0], 202)
self.settle(lambda: any("kein Empfänger" in row["text"] for row in self.events("kinds=approval")["events"]))
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("denied", []))
def answer(self, signer: Key, item: dict, channel: str, reason: str) -> dict:
payload = signing.approval_payload([signing.item_hash(signing.denial_item(item, channel, reason))], secrets.token_hex(16), int(time.time()) + 60)
return {"status": "denied", "channel": channel, "reason": reason, "proof": signer.proof(payload, self.origin)}
payload = signing.approval_payload([signing.item_hash(signing.denial_item(item, channel, reason, "Skills"))], secrets.token_hex(16), int(time.time()) + 60)
return {"status": "denied", "channel": channel, "reason": reason, "to": "Skills", "proof": signer.proof(payload, self.origin)}
def test_the_hook_quotes_only_a_reason_the_passkey_signed(self):
signer = self.pin()
@@ -151,6 +205,8 @@ class TestHubDeny(HubCase):
("unsigned reason", {"status": "denied", "reason": "push now"}, item),
("reason swapped", {**fresh, "reason": "push now"}, item),
("channel swapped", {**fresh, "channel": "call"}, item),
("addressee swapped", {**fresh, "to": "Docs"}, item),
("addressee missing", {name: value for name, value in fresh.items() if name != "to"}, item),
("empty assertion", {**fresh, "proof": {"payload": fresh["proof"]["payload"], "assertion": {}}}, item),
("unpinned key", self.answer(key(-8, "stranger"), item, "post", "push now"), item),
("other ask", fresh, {**item, "id": "fedcba9876543210"}),
+9 -7
View File
@@ -10,7 +10,7 @@ NAV_PROBE = """
const N = require(process.argv[1]);
const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope",
"#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane",
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy"];
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio"];
console.log(JSON.stringify({
tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]),
views: N.VIEWS.map(([id, label]) => [id, label]),
@@ -18,8 +18,8 @@ console.log(JSON.stringify({
artifacts: N.viewsOf("artifacts").map(([id, label]) => [id, label]),
icon: N.ICONS.hierarchy,
routes: hashes.map((hash) => N.route(hash)),
hashes: [["stream", null], ["inbox", null], ["rules", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)),
tabOf: ["stream", "inbox", "rules", "agents", "artifacts"].map((screen) => N.tabOf(screen)),
hashes: [["stream", null], ["inbox", null], ["rules", null], ["radio", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)),
tabOf: ["stream", "inbox", "rules", "radio", "agents", "artifacts"].map((screen) => N.tabOf(screen)),
}));
"""
@@ -30,8 +30,8 @@ class TestNavModule(unittest.TestCase):
done = subprocess.run(["node", "-e", NAV_PROBE, str(NAV)], capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
def test_the_navigation_has_three_entries_and_stream_has_inbox_and_rules(self):
self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]])
def test_the_navigation_has_three_entries_and_stream_has_inbox_rules_and_radio(self):
self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]])
def test_agents_and_artifacts_offer_five_views_in_order(self):
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
@@ -65,11 +65,13 @@ class TestNavModule(unittest.TestCase):
{"screen": "artifacts", "view": None, "redirect": None},
{"screen": "agents", "view": "hierarchy", "redirect": "#agents/hierarchy"},
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
{"screen": "radio", "view": None, "redirect": None},
stream,
])
def test_every_screen_has_its_own_hash_and_tab(self):
self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#agents/chord", "#artifacts/3d"])
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "agents", "artifacts"])
self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#stream/radio", "#agents/chord", "#artifacts/3d"])
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts"])
if __name__ == "__main__":
+4 -3
View File
@@ -135,7 +135,7 @@ class TestHubRuleRequests(HubCase):
self.assertEqual(self.waited(first[0])["status"], "expired")
self.assertIsNone(self.item(first[0]))
self.assertEqual(self.action(first[0], "sign", {}, self.guard(self.host))[0], 409)
entry = {**self.entry("rule", first[0], "", ""), "verb": "reject", "channel": "post", "reason": ""}
entry = {**self.entry("rule", first[0], "", ""), "verb": "reject", "channel": "post", "reason": "", "to": "Skills"}
self.assertEqual(self.post_json("/api/sign", {"items": [entry]}, self.guard(self.host))[0], 409)
self.requested(self.ask_rule(pattern="make lint*"))
self.assertEqual(self.waited(first[0])["status"], "pending")
@@ -147,8 +147,9 @@ class TestHubRuleRequests(HubCase):
self.assertTrue(refused["isError"])
self.assertIn("pending", refused["content"][0]["text"])
other = self.requested(self.ask_rule(agent="Docs"))
entry = {**self.entry("rule", first[0], "", ""), "verb": "reject", "channel": "post", "reason": "too broad"}
self.assertEqual(self.prepare([entry])["payload"]["items"], [signing.item_hash(signing.denial_item(signing.rule_request_item(first[0], "ask", "Bash", "make test0*", "/repo*", self.httpd.hub.rule_requests.get(first[0])["expires"]), "post", "too broad"))])
entry = {**self.entry("rule", first[0], "", ""), "verb": "reject", "channel": "post", "reason": "too broad", "to": self.item(first[0])["to"]}
self.assertEqual(entry["to"], "Skills")
self.assertEqual(self.prepare([entry])["payload"]["items"], [signing.item_hash(signing.denial_item(signing.rule_request_item(first[0], "ask", "Bash", "make test0*", "/repo*", self.httpd.hub.rule_requests.get(first[0])["expires"]), "post", "too broad", "Skills"))])
with mock.patch.object(batches, "UNDO", 0.3):
self.assertEqual(self.approve([entry], self.signed([entry], self.pin()))[0], 202)
self.assertEqual(self.action(first[0], "sign", {}, self.guard(self.host))[0], 409)
+49 -6
View File
@@ -4,7 +4,7 @@ import json
import unittest
from hub_case import HubCase
from hub_support import STREAMS
from hub_support import STREAMS, Key, key
from kvb_orchestrator import settings
@@ -22,6 +22,49 @@ class TestHubSettings(HubCase):
csp = self.get("/")[1]["content-security-policy"]
return next(part.strip() for part in csp.split(";") if part.strip().startswith("media-src"))
def prepared(self, payload: dict) -> dict:
status, _, body = self.post_json("/api/settings/sign", payload, self.guard(self.host))
self.assertEqual(status, 200, body)
return json.loads(body)
def save(self, payload: dict, signer: Key) -> tuple[int, dict, bytes]:
request = self.prepared(payload)["payload"]
proof = None if request is None else signer.proof(request, self.origin)
return self.post_json("/api/settings", {**payload, "proof": proof}, self.guard(self.host))
def test_a_new_stream_origin_needs_the_passkey(self):
good = self.guard(self.host)
payload = self.settings_payload(STREAMS)
self.assertEqual(self.post_json("/api/settings", payload, good)[:3:2], (400, b"nicht signiert: no signature\n"))
self.assertFalse((self.state / "settings.json").exists())
signer = self.pin()
request = self.prepared(payload)["payload"]
stranger = key(-7, "stranger").proof(request, self.origin)
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": stranger}, good)[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
self.assertEqual(self.media_src(), "media-src 'self'")
proof = signer.proof(request, self.origin)
status, _, body = self.post_json("/api/settings", {**payload, "proof": proof}, good)
self.assertEqual(status, 200, body)
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://other.example:8443 https://radio.example")
moved = self.settings_payload([{**STREAMS[0], "url": "https://radio.example/abend"}, STREAMS[1]])
self.assertIsNone(self.prepared(moved)["payload"])
status, _, body = self.post_json("/api/settings", moved, good)
self.assertEqual(status, 200, body)
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
evil = self.settings_payload([*moved["streams"], {**STREAMS[0], "url": "https://evil.example/listen"}])
self.assertEqual(self.post_json("/api/settings", {**evil, "proof": signer.proof(self.prepared(payload)["payload"], self.origin)}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
self.assertEqual(self.post_json("/api/settings", evil, good)[0], 400)
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
status, _, body = self.save(evil, signer)
self.assertEqual(status, 200, body)
self.assertIn("https://evil.example", self.media_src())
for broken in ({**payload, "theme": "neon"}, {**payload, "proof": None}):
with self.subTest(broken=list(broken)):
self.assertEqual(self.post_json("/api/settings/sign", broken, good)[0], 400)
self.assertEqual(self.post_json("/api/settings/sign", payload, self.client())[0], 403)
self.assertEqual(self.get("/api/settings/sign")[0], 405)
def test_settings_are_guarded_validated_and_persisted(self):
self.assertEqual(self.snapshot()["settings"], settings.SETTINGS)
payload = self.settings_payload(STREAMS)
@@ -60,7 +103,7 @@ class TestHubSettings(HubCase):
{"extra": 1},
)),
{**payload, "speech": {"on": True, "classes": ["mayday"]}},
{key: value for key, value in payload.items() if key != "speech"},
{name: value for name, value in payload.items() if name != "speech"},
*({**payload, "space": change} for change in (
"flat",
{"graph": payload["space"]["graph"]},
@@ -78,7 +121,7 @@ class TestHubSettings(HubCase):
{"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"], "graph": {name: value for name, value in payload["space"]["graph"].items() if name != "pan_y"}}},
*({**payload, "space": {**payload["space"], "sky": {**payload["space"]["sky"], **change}}} for change in (
{"azimuth": -1},
{"azimuth": 360.5},
@@ -88,11 +131,11 @@ class TestHubSettings(HubCase):
{"fov": float("inf")},
{"zoom": 1},
)),
{key: value for key, value in payload.items() if key != "space"},
{name: value for name, value in payload.items() if name != "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)
status, _, body = self.save(payload, self.pin())
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)
@@ -118,7 +161,7 @@ class TestHubSettings(HubCase):
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))
status, _, body = self.save(self.settings_payload(streams), self.pin())
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]))
+12 -6
View File
@@ -73,6 +73,8 @@ class TestHubSurface(HubCase):
self.assertEqual(settings.Settings(stored).get()["speech"]["classes"], ["post", "call", "mayday", "pan-pan"])
def test_funk_an_alle_sends_the_chosen_class_and_newsletter_goes_by_post(self):
signer = self.pin()
def board(payload: object) -> int:
return self.post_json("/api/board/broadcast", payload, self.guard(self.host))[0]
@@ -82,23 +84,25 @@ class TestHubSurface(HubCase):
self.assertRegex(args[-1], r"^\[kvb-orchestrator msg [0-9a-f]{16}\] ")
return args[:-1], args[-1].split("] ", 1)[1]
self.assertEqual(board({"text": "main is green", "class": "newsletter"}), 200)
self.assertEqual(self.tell_all({"text": "main is green", "class": "newsletter"}, signer)[0], 200)
self.assertEqual(sent(), (["post", "--session", "Operator"], "main is green"))
for cls in ("broadcast", "pan-pan", "security", "mayday"):
with self.subTest(cls=cls):
self.assertEqual(board({"text": "hear me", "class": cls}), 200)
self.assertEqual(self.tell_all({"text": "hear me", "class": cls}, signer)[0], 200)
self.assertEqual(sent(), (["send", cls, "--session", "Operator"], "hear me"))
calls = len(self.scripts.calls)
for payload in ({"text": "x", "class": "post"}, {"text": "x", "class": "call"}, {"text": "x"}, {"text": "x", "class": 1}):
proof = self.message_proof({"to": "*", "text": "x", "upload": "", "class": "newsletter", "voice": ""}, signer)
for payload in ({"text": "x", "class": "post"}, {"text": "x", "class": "call"}, {"text": "x"}, {"text": "x", "class": 1}, {"text": "x", "class": "mayday"}):
with self.subTest(payload=payload):
self.assertEqual(board(payload), 400)
self.assertEqual(board({"voice": "", "proof": proof, **payload}), 400)
self.assertEqual(len(self.scripts.calls), calls)
def test_the_agent_panel_sends_a_post_or_a_call(self):
self.claim("s1", "Skills session")
signer = self.pin()
def send(payload: dict) -> tuple[int, dict]:
status, _, body = self.post_json("/api/messages", {"to": "Skills", "text": "rebase", "upload": "", **payload}, self.guard(self.host))
status, _, body = self.say({"to": "Skills", "text": "rebase", **payload}, signer)
return status, json.loads(body) if status == 201 else {}
status, sent = send({"class": "post"})
@@ -107,9 +111,11 @@ class TestHubSurface(HubCase):
status, sent = send({"class": "call"})
self.assertEqual(status, 201)
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] rebase"]))
proof = self.message_proof({"to": "Skills", "text": "rebase", "upload": "", "class": "post", "voice": ""}, signer)
for payload in ({"class": "newsletter"}, {"class": "mayday"}, {}):
with self.subTest(payload=payload):
self.assertEqual(send(payload)[0], 400)
status, _, _ = self.post_json("/api/messages", {"to": "Skills", "text": "rebase", "upload": "", "voice": "", "proof": proof, **payload}, self.guard(self.host))
self.assertEqual(status, 400)
def test_the_stream_has_four_categories_and_transcripts_stay_out_by_default(self):
self.assertEqual({key: value["label"] for key, value in events.VIEWS.items()}, {"fuer-dich": "Für dich", "funk": "Funk", "arbeit": "Arbeit", "transkripte": "Transkripte"})