breakglass: in-cluster emergency-recovery UI for the devvm
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Viktor wanted a web UI on the claude service to act as his breakglass when the devvm is down: open it, have Claude SSH in to diagnose/repair, and power-cycle the VM via the Proxmox host if needed. This is the app half (the infra stack + host bootstrap live in the infra repo). New, ISOLATED ASGI app under app/breakglass/ (never imports app.main, so the untrusted-input agents — recruiter-triage, nextcloud-todos — can't share a process with the root-on-devvm / PVE-reset SSH key): - pve.py: the LLM-independent power-verb path (status|forensics|reset|stop| start|cycle on VM 102), whitelist-validated client-side, executed over the forced-command SSH key (list argv, no shell). - agent_session.py: multi-turn streamed chat — claude -p --session-id / --resume with --output-format stream-json, translated to a small SSE vocabulary (session/text/tool/result/error/done). - auth.py: edge Authentik header OR bearer; fail-closed. - server.py: FastAPI (session/chat-SSE/pve-verb routes) + serves the Svelte UI. - Svelte SPA (frontend/, built into app/breakglass/static/ and committed — no in-cluster build, per ADR-0002): streamed chat + danger-styled manual VM controls with confirm-on-mutate. - agents/breakglass.md: narrow tools (Bash/Read/Grep/Glob, no web), taught the ssh devvm / ssh pve aliases and cycle-vs-reset. - docker-entrypoint-breakglass.sh: ssh-agent bootstrap from the mounted key + ssh aliases, then uvicorn app.breakglass.server. The breakglass Deployment overrides the image CMD with this; the existing service is untouched. 26 new tests (verb whitelist incl. injection attempts, stream-json→SSE translation, auth gating, route behaviour); full suite 58 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
694530135d
commit
4f361d91eb
28 changed files with 3889 additions and 0 deletions
92
frontend/src/lib/api.js
Normal file
92
frontend/src/lib/api.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Same-origin API client. Auth is handled entirely by the edge proxy
|
||||
// (Authentik / basic-auth / bearer) — this UI never sends or stores a token.
|
||||
import { readEventStream } from './sse.js';
|
||||
|
||||
/** Open a fresh chat session. @returns {Promise<string>} session_id */
|
||||
export async function openSession() {
|
||||
const res = await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`could not open a session (HTTP ${res.status})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (!body || typeof body.session_id !== 'string') {
|
||||
throw new Error('session response missing session_id');
|
||||
}
|
||||
return body.session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one chat turn. Streams events to onEvent until the backend sends
|
||||
* {kind:"done"} and the connection closes. Pass an AbortSignal to cancel.
|
||||
*
|
||||
* @param {{session_id: string, prompt: string, model?: string, signal?: AbortSignal}} opts
|
||||
* @param {(event: object) => void} onEvent
|
||||
*/
|
||||
export async function streamChat({ session_id, prompt, model, signal }, onEvent) {
|
||||
const payload = { session_id, prompt };
|
||||
if (model) payload.model = model;
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
await readEventStream(res, onEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the PVE power verbs and which of them mutate VM state.
|
||||
* @returns {Promise<{verbs: string[], mutating: string[]}>}
|
||||
*/
|
||||
export async function fetchVerbs() {
|
||||
const res = await fetch('/api/pve/verbs');
|
||||
if (!res.ok) {
|
||||
throw new Error(`could not load VM controls (HTTP ${res.status})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return {
|
||||
verbs: Array.isArray(body.verbs) ? body.verbs : [],
|
||||
mutating: Array.isArray(body.mutating) ? body.mutating : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a PVE power verb directly (no AI in the path). The backend returns 200
|
||||
* on success and 502 when the verb's exit code is non-zero, but the JSON body
|
||||
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases — so we
|
||||
* read the body regardless of HTTP status and let the caller style on
|
||||
* exit_code / rejected.
|
||||
*
|
||||
* @param {string} verb
|
||||
* @returns {Promise<{verb: string, exit_code: number|null, stdout: string, stderr: string, rejected: boolean}>}
|
||||
*/
|
||||
export async function runVerb(verb) {
|
||||
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
||||
let body;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
|
||||
}
|
||||
if (res.status === 400) {
|
||||
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
|
||||
}
|
||||
return {
|
||||
verb: body.verb ?? verb,
|
||||
exit_code: body.exit_code ?? null,
|
||||
stdout: body.stdout ?? '',
|
||||
stderr: body.stderr ?? '',
|
||||
rejected: Boolean(body.rejected),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue