Files
skills/orchestrator/templates/page-passkey.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 6f940b4415 feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface
Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked.

Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once.

MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46).

Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 101 %
⏱️ Time: –
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 87
🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:20:06 +00:00

88 lines
5.3 KiB
JavaScript

const RP_ID = "localhost";
const ALGORITHMS = [-7, -8, -257];
const onLocalhost = () => window.location.hostname === RP_ID;
const localUrl = () => `http://${RP_ID}:${window.location.port}${window.location.pathname}${window.location.hash}`;
const b64url = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")), (char) => char.charCodeAt(0));
const localLink = () => h("a", {href: localUrl()}, localUrl());
const KEEPASSXC_LOCALHOST = "KeePassXC lehnt Passkeys auf http://localhost ab, bis in KeePassXC unter Werkzeuge → Einstellungen → Browser-Integration → Fortgeschritten „Nutzung von localhost mit Passkeys zulassen“ (Allow using localhost with passkeys) angehakt ist; die Datenbank muss entsperrt sein. Sonst meldet KeePassXC-Browser „Passkeys request canceled.“ und Chrome öffnet seinen eigenen Passkey-Dialog.";
async function assertion(prepared, label) {
if (!onLocalhost()) {
toast(`${label}: nicht signiert`, [`Der Passkey signiert nur unter ${localUrl()}`], false);
return null;
}
if (!prepared.credentials.length) {
toast(`${label}: nicht signiert`, ["Kein Passkey gepinnt: Einstellungen → Passkey registrieren"], false);
return null;
}
try {
const credential = await navigator.credentials.get({publicKey: {
challenge: unb64url(prepared.challenge),
rpId: RP_ID,
allowCredentials: prepared.credentials.map((id) => ({type: "public-key", id: unb64url(id)})),
userVerification: "preferred",
timeout: 120000,
}});
const answer = credential.response;
return {id: b64url(credential.rawId), clientDataJSON: b64url(answer.clientDataJSON), authenticatorData: b64url(answer.authenticatorData), signature: b64url(answer.signature)};
} catch (error) {
toast(`${label}: nicht signiert`, [`${(error && error.name) || error}: KeePassXC gesperrt oder abgebrochen; nichts wurde gesendet.`], false);
return null;
}
}
async function proofFor(entries, label) {
const result = await sendJson("/api/sign", {items: entries});
if (!result.ok || !result.data) {
toast(`${label}: nicht möglich`, fail(result), false);
return null;
}
if (result.data.payload === null) {
return {proof: null};
}
const signed = await assertion(result.data, label);
return signed ? {proof: {payload: result.data.payload, assertion: signed}} : null;
}
async function registerPasskey(output) {
if (!onLocalhost()) {
output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink()));
return;
}
try {
const credential = await navigator.credentials.create({publicKey: {
rp: {id: RP_ID, name: "Agent Orchestrator"},
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "Agent Orchestrator Operator"},
challenge: crypto.getRandomValues(new Uint8Array(32)),
pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})),
attestation: "none",
authenticatorSelection: {residentKey: "preferred", userVerification: "preferred"},
timeout: 120000,
}});
const answer = credential.response;
const blob = b64url(new TextEncoder().encode(JSON.stringify({id: b64url(credential.rawId), attestationObject: b64url(answer.attestationObject), clientDataJSON: b64url(answer.clientDataJSON)})));
const command = `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-orchestrator/approvers.json --credential '${blob}'`;
output.replaceChildren(
h("p", {class: "muted"}, "Diesen Befehl im Terminal des Hosts ausführen, außerhalb jeder Sandbox; erst dann gilt der Passkey. Der Hub pinnt nie selbst."),
h("div", {class: "command"}, h("code", {}, command), copyButton(command)),
h("label", {}, "Credential", h("textarea", {class: "field", readonly: true, rows: 3}, blob)));
} catch (error) {
output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
}
}
function passkeySection() {
const output = h("div", {class: "mini", "aria-live": "polite"});
const keys = state.approvers.length
? h("ul", {class: "mini"}, state.approvers.map((key) => h("li", {}, pill(key.alg, "ok"), " ", h("code", {title: key.id}, `${key.id.slice(0, 16)}…`))))
: h("p", {class: "muted"}, "Kein Passkey gepinnt: Runner, Hook und Commit-Skills verweigern jede Freigabe.");
return h("section", {}, h("h3", {}, "Passkey"),
h("p", {class: "muted"}, "Freigaben von Asks, Reviews und Operator-Befehlen signiert ein Passkey in KeePassXC; Runner, Hook und Commit-Skills prüfen die Signatur selbst gegen die gepinnten Schlüssel."),
keys,
h("p", {class: "muted"}, KEEPASSXC_LOCALHOST),
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()),
h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))),
output);
}