Requirement 001 phase 6, S2: - Agent popup: a click on an agent opens one fading <dialog> with face, name, earlier names, offices, tasks, claims, subagents, history and the composer; Escape or a click outside closes it. The agent drawer and the Details side panel are gone. - Node popup: a click on a node in a graph view enlarges it and marks it red, and an SVG leader line ties it to the translucent popup; a click elsewhere restores the node. popup.js places the popup right, left, below or above the node, caps its height to the room there and returns the line. - Search: in the 3D views a search field with suggestions replaces the list of every node, one chip per distinct label. - Fit: body.fit now applies at every width; without the side panel the orbit view otherwise grew a page scrollbar at 390 px. - Two page modules had silently shadowed each other's functions of the same name while this was built; test_no_two_page_modules_declare_the_same_function now guards that. Artifact ids carry NUL, which CSS.escape turns into U+FFFD, so the node lookup compares dataset values. - Composer: a message goes to the agent whose popup it was written in. deliver() read the global ui.agent after awaiting the upload, so closing the popup or opening another agent during an upload sent the signed message to null or to the other agent (found by the security skeptic, present before, easier now that a backdrop click closes). Limits: the 3D leader line is placed when the popup opens and does not follow the camera while the layout settles; the search keeps the first node per label, so a second repository's main branch is found in the graph, not through the search. Evidence: make test 667 OK, ruff and markdownlint clean (/tmp/s2-make-test-1.log); e2e 19 OK in Chromium and Firefox at 1920, 1366 and 390 px, none skipped, rerun after the composer fix (/tmp/s2-e2e-4.log); screenshots in /tmp/orchestrator-phase4-shots/; MEDUSA 22 files, 6 findings (a "PLA" pattern in "templates", the UI names openAgent and drawAgent) accepted after an independent skeptic that also found no XSS or selector injection in the popup code. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
8.5 KiB
Python
204 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import concurrent.futures
|
|
import datetime
|
|
import functools
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import secrets
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
HUB = REPO_ROOT / "orchestrator"
|
|
sys.path[:0] = [str(HUB), str(HUB / "client")]
|
|
CLIENT = HUB / "client" / "permission_hook.py"
|
|
RUNNER = HUB / "client" / "runner.py"
|
|
APPROVAL = HUB / "client" / "approval.py"
|
|
RASCI = REPO_ROOT / "skills" / "kvb-org-rasci" / "scripts" / "rasci.py"
|
|
SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_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_orchestrator_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
|
|
from kvb_orchestrator.redirects import Redirects
|
|
|
|
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-orchestrator")
|
|
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": "Morning", "url": "https://radio.example/morning", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True, "via": []},
|
|
{"name": "Night", "url": "http://10.0.0.5:8000/night", "from": "22:00", "to": "02:00", "days": [5], "on": True, "via": []},
|
|
{"name": "Off", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False, "via": []},
|
|
]
|
|
|
|
|
|
def offline(host: str, port: int, timeout: float, public: bool) -> socket.socket:
|
|
raise ConnectionRefusedError(f"no network in tests: {host}:{port}")
|
|
|
|
|
|
def offline_redirects() -> Redirects:
|
|
return Redirects(tls=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), dial=offline, timeout=1.0)
|
|
|
|
|
|
VOICE = HUB / "templates" / "voice.js"
|
|
SPACE = HUB / "templates" / "space.js"
|
|
NAV = HUB / "templates" / "nav.js"
|
|
POPUP = HUB / "templates" / "popup.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
|
|
ZERO_LED_TRIES = 4096
|
|
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:
|
|
return self.registration(self.cose(), origin, rp, flags)
|
|
|
|
def registration(self, cose: dict, 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(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)
|
|
|
|
|
|
@functools.cache
|
|
def zero_led(label: int) -> Key:
|
|
"""Args:
|
|
label: the COSE label of the coordinate, -2 for x or -3 for y.
|
|
|
|
Returns:
|
|
A fresh P-256 key whose coordinate starts with a zero byte, as
|
|
about one key in 256 does.
|
|
"""
|
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
for start in range(0, ZERO_LED_TRIES, 64):
|
|
for signer in pool.map(lambda attempt: Key(-7, f"zero{label}-{attempt}"), range(start, start + 64)):
|
|
if signer.cose()[label][0] == 0:
|
|
return signer
|
|
raise AssertionError(f"no P-256 key with a zero-led coordinate {label} in {ZERO_LED_TRIES} tries")
|