breakglass: in-cluster emergency-recovery UI for the devvm
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:
Viktor Barzin 2026-06-12 21:36:05 +00:00
parent 694530135d
commit 4f361d91eb
28 changed files with 3889 additions and 0 deletions

5
.gitignore vendored
View file

@ -10,3 +10,8 @@ vault
# agent worktrees
.worktrees/
# breakglass frontend build tooling (node deps are never committed; the
# compiled SPA in app/breakglass/static/ IS committed and served by FastAPI)
frontend/node_modules/
frontend/.vite/

View file

@ -85,6 +85,17 @@ COPY agents/beads-task-runner.md /usr/share/agent-seed/beads-task-runner.md
COPY agents/recruiter-triage.md /usr/share/agent-seed/recruiter-triage.md
COPY agents/nextcloud-todos-planner.md /usr/share/agent-seed/nextcloud-todos-planner.md
COPY agents/nextcloud-todos-exec.md /usr/share/agent-seed/nextcloud-todos-exec.md
# The breakglass deployment (separate stack) seeds this one instead of the
# untrusted-input agents; its init container copies whichever it needs.
COPY agents/breakglass.md /usr/share/agent-seed/breakglass.md
# Breakglass entrypoint. The breakglass Deployment overrides the default CMD
# with this (ssh-agent bootstrap + ssh aliases, then uvicorn for the breakglass
# app). It ships in every image but only that deployment runs it. The built
# frontend lives under app/breakglass/static/ (committed — no in-cluster build,
# per ADR-0002), so the `COPY app/` above carries it in.
COPY docker-entrypoint-breakglass.sh /srv/docker-entrypoint-breakglass.sh
RUN chmod 0755 /srv/docker-entrypoint-breakglass.sh
USER agent
WORKDIR /workspace/infra

57
agents/breakglass.md Normal file
View file

@ -0,0 +1,57 @@
---
name: breakglass
description: Emergency-recovery agent for the devvm. SSHes into the devvm (full sudo) to diagnose and repair it, and can power-cycle it via the Proxmox host. Used only by the in-cluster claude-breakglass UI.
model: sonnet
tools: Bash, Read, Grep, Glob
---
You are the **breakglass** agent. Viktor opens the claude-breakglass web UI when
his development VM (the "devvm") is misbehaving and he wants it diagnosed and
fixed. You run **inside the Kubernetes cluster**, not on the devvm — so you stay
alive when the devvm is wedged.
You have NO web tools and you operate on trusted operator input only. Be
concise and act; this is an incident, not a research task.
## What you can reach (already wired — just use these)
- **`ssh devvm <cmd>`** — a shell on the devvm (10.0.10.10) as the `breakglass`
user with **passwordless sudo**. Use `ssh devvm 'sudo …'` for root actions.
This is your primary diagnose-and-repair surface.
- **`ssh pve <verb>`** — the Proxmox host (192.168.1.127). This key is locked to
a forced command: the ONLY things it accepts are the bare verbs
**`status`**, **`forensics`**, **`reset`**, **`stop`**, **`start`**,
**`cycle`** — each acting on VM 102 (the devvm). Anything else is rejected.
Every mutating verb captures forensics on the host first, automatically.
SSH auth is handled by an in-pod ssh-agent; you never need a key path or
password. Hosts are pinned in known_hosts.
## How to work an incident
1. **Diagnose first.** `ssh devvm 'uptime; free -h; df -h; sudo dmesg -T | tail -40'`,
check the failing service (`ssh devvm 'systemctl status <unit>'`,
`journalctl -u <unit> --no-pager -n 50`), check memory/OOM, disk, swap.
2. **Repair in place when you can** — restart a wedged unit, free disk, clear a
stuck process, fix swap. A soft fix beats a reboot.
3. **If the devvm is unreachable over SSH or unrecoverable in place**, fall back
to the PVE verbs:
- `ssh pve status` — is VM 102 running / stopped / paused?
- `ssh pve forensics` — qm status/config/pending + QMP + guest-agent ping.
- **`ssh pve cycle`** — a full stop→start (NOT a warm reset). This spawns a
fresh QEMU process and so applies any staged VM config. **This is the
correct recovery for a QEMU I/O stall** (the kind that froze the devvm on
2026-06-11); a warm `reset` reuses the wedged QEMU and won't fix it.
- Use `reset` only for a normal-looking guest hang where QEMU itself is fine.
4. You are authorised to run the mutating verbs autonomously when your
diagnosis supports it — Viktor chose autonomous recovery. Still: capture and
report what you saw, then act, then confirm the result (`ssh pve status`,
then re-check SSH to the devvm once it boots).
## Reference
The infra repo is checked out in your workspace. Useful reading:
`docs/runbooks/proxmox-host.md`, `docs/runbooks/breakglass-ui.md`, and any
`docs/post-mortems/*devvm*` for prior failure modes and their fixes.
Report tersely: what you found, what you did, the current state.

View file

@ -0,0 +1,10 @@
"""Breakglass: an isolated emergency-recovery surface for the devvm.
This package is a SEPARATE ASGI app from ``app.main``. The breakglass
deployment runs ``uvicorn app.breakglass.server:app`` and mounts the SSH keys;
the ordinary claude-agent-service deployment keeps running ``app.main:app`` and
never sees those keys. Nothing here imports ``app.main`` and vice versa, so the
untrusted-input agents (recruiter-triage, nextcloud-todos) can never share a
process with the root-on-devvm / PVE-reset credentials. See
``docs/adr/0001-breakglass-security-architecture.md``.
"""

View file

@ -0,0 +1,145 @@
"""Drive the breakglass Claude agent and stream its work to the browser.
Each chat turn runs ``claude -p --output-format stream-json`` in the session's
persistent workspace; the first turn opens the session with ``--session-id`` and
later turns ``--resume`` it, so the conversation has memory across turns. The
CLI's JSON events are translated to a small, stable SSE vocabulary the UI
renders (``session`` / ``text`` / ``tool`` / ``result`` / ``error``) we do not
leak the raw event firehose to the client.
Subprocesses use ``asyncio.create_subprocess_exec`` (list argv, no shell): the
prompt and ids are argv elements, never interpreted by a shell.
"""
import asyncio
import json
import os
from subprocess import PIPE
from typing import AsyncIterator
from . import config
# Sessions we've already opened (so the next turn resumes instead of re-creating).
_started: set[str] = set()
def _turn_argv(session_id: str, prompt: str, resume: bool, model: str) -> list[str]:
argv = [
"claude", "-p",
"--agent", config.BREAKGLASS_AGENT,
"--dangerously-skip-permissions",
"--output-format", "stream-json",
"--verbose", # required for stream-json output
"--model", model,
]
# --session-id opens a brand-new session with that id; --resume continues it.
argv += (["--resume", session_id] if resume else ["--session-id", session_id])
argv.append(prompt)
return argv
def translate_event(obj: dict) -> dict | None:
"""Map one raw stream-json event to a UI event, or None to drop it.
Pure function the unit tests pin this contract. Keeps the noisy
hook/thinking-token/system chatter off the wire and exposes only what an
operator watching a recovery needs: which session, assistant prose, which
tools ran, and the final result.
"""
etype = obj.get("type")
if etype == "system":
if obj.get("subtype") == "init":
return {"kind": "session", "session_id": obj.get("session_id", "")}
return None # hook_started/hook_response/thinking_tokens/etc. — noise
if etype == "assistant":
events: list[dict] = []
for block in obj.get("message", {}).get("content", []) or []:
btype = block.get("type")
if btype == "text" and block.get("text"):
events.append({"kind": "text", "text": block["text"]})
elif btype == "tool_use":
events.append({
"kind": "tool",
"name": block.get("name", ""),
"input": block.get("input", {}),
})
if not events:
return None
# The server flattens a "batch" into individual SSE frames.
return events[0] if len(events) == 1 else {"kind": "batch", "events": events}
if etype == "result":
return {
"kind": "result",
"is_error": bool(obj.get("is_error")),
"result": obj.get("result", ""),
"duration_ms": obj.get("duration_ms"),
}
return None
async def run_turn(
session_id: str, prompt: str, model: str | None = None
) -> AsyncIterator[dict]:
"""Run one chat turn, yielding translated UI events as they arrive."""
resume = session_id in _started
model = model or config.DEFAULT_MODEL
workspace = os.path.join(config.SESSIONS_DIR, session_id)
os.makedirs(workspace, exist_ok=True)
argv = _turn_argv(session_id, prompt, resume, model)
proc = await asyncio.create_subprocess_exec(
*argv, cwd=workspace, stdout=PIPE, stderr=PIPE,
)
_started.add(session_id)
assert proc.stdout is not None and proc.stderr is not None
try:
async def _pump() -> AsyncIterator[dict]:
async for raw in proc.stdout:
line = raw.decode(errors="replace").strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
ev = translate_event(obj)
if ev is None:
continue
if ev.get("kind") == "batch":
for sub in ev["events"]:
yield sub
else:
yield ev
async for ev in _with_timeout(_pump(), config.TURN_TIMEOUT_SECONDS):
yield ev
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
yield {"kind": "error", "error": f"turn timed out after {config.TURN_TIMEOUT_SECONDS}s"}
return
await proc.wait()
if proc.returncode not in (0, None):
err = (await proc.stderr.read()).decode(errors="replace")
yield {"kind": "error", "error": err.strip()[:500] or f"exit {proc.returncode}"}
async def _with_timeout(agen: AsyncIterator[dict], timeout: float) -> AsyncIterator[dict]:
"""Yield from an async generator but raise TimeoutError if the WHOLE turn
exceeds ``timeout`` seconds (a wedged agent shouldn't stream forever)."""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
it = agen.__aiter__()
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise asyncio.TimeoutError
try:
yield await asyncio.wait_for(it.__anext__(), timeout=remaining)
except StopAsyncIteration:
return

36
app/breakglass/auth.py Normal file
View file

@ -0,0 +1,36 @@
"""Auth for the breakglass app.
The app sits behind the ingress ``auth = "required"`` resilience proxy
(Authentik SSO normally, HTTP basic-auth fallback when Authentik is down), so a
browser request that reaches us is already edge-authenticated and carries the
proxy-injected ``X-authentik-username`` header. We also accept a bearer token
for machine/CLI callers. Either is sufficient.
When neither a token is configured nor a trusted header is present, we fail
closed.
"""
import hmac
from fastapi import Header, HTTPException
from . import config
def require_auth(
authorization: str | None = Header(default=None),
x_authentik_username: str | None = Header(default=None),
) -> str:
"""FastAPI dependency. Returns the identity (username or 'bearer'); raises
401 otherwise."""
# Edge-authenticated human: the auth-proxy sets this and overwrites any
# client-supplied value, so its presence is trustworthy.
if x_authentik_username:
return x_authentik_username
# Machine caller with the shared bearer token.
if config.API_TOKEN and authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ")
if hmac.compare_digest(token, config.API_TOKEN):
return "bearer"
raise HTTPException(status_code=401, detail="unauthenticated")

36
app/breakglass/config.py Normal file
View file

@ -0,0 +1,36 @@
"""Environment-driven config for the breakglass app.
Targets are hardcoded IPs by default (the breakglass must not depend on cluster
DNS it has to work when things are broken). Everything is overridable via env
for tests and future re-IPing.
"""
import os
# SSH targets. IPs, not names — no DNS dependency in an incident.
DEVVM_HOST = os.environ.get("BREAKGLASS_DEVVM_HOST", "10.0.10.10")
DEVVM_USER = os.environ.get("BREAKGLASS_DEVVM_USER", "breakglass")
PVE_HOST = os.environ.get("BREAKGLASS_PVE_HOST", "192.168.1.127")
PVE_USER = os.environ.get("BREAKGLASS_PVE_USER", "root")
# The Claude agent the breakglass UI drives. Narrow tool surface, no web tools.
BREAKGLASS_AGENT = os.environ.get("BREAKGLASS_AGENT", "breakglass")
DEFAULT_MODEL = os.environ.get("BREAKGLASS_MODEL", "sonnet")
# Where claude session state + per-session scratch live. emptyDir in prod.
SESSIONS_DIR = os.environ.get("BREAKGLASS_SESSIONS_DIR", "/workspace/sessions")
# A single human operator per incident — no need for the job-runner's fan-out.
MAX_CONCURRENT_TURNS = int(os.environ.get("BREAKGLASS_MAX_CONCURRENT_TURNS", "2"))
# A chat turn that runs longer than this is killed (the agent is wedged).
TURN_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_TURN_TIMEOUT_SECONDS", "1800"))
# A single PVE power verb must return fast; a wedged host shouldn't hang the UI.
PVE_VERB_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_PVE_VERB_TIMEOUT_SECONDS", "120"))
# Auth. The app sits behind the ingress `auth = "required"` resilience proxy
# (Authentik SSO, basic-auth fallback when Authentik is down). We additionally
# accept a bearer token for machine/CLI callers. Either gate is sufficient;
# the edge is the primary one for the browser UI.
API_TOKEN = os.environ.get("API_BEARER_TOKEN", "")
# Header the auth-proxy injects for an authenticated human (set by Authentik, or
# by the basic-auth fallback's `$remote_user`). Presence ⇒ edge-authenticated.
TRUSTED_USER_HEADER = "x-authentik-username"

89
app/breakglass/pve.py Normal file
View file

@ -0,0 +1,89 @@
"""PVE power verbs — the LLM-independent recovery path.
The manual UI buttons hit this directly (no ``claude`` in the path), so reset
works even when the Anthropic API is down. The real enforcement is the
forced-command on the PVE host (``/usr/local/bin/breakglass-pve``): whatever we
send as the SSH command is ignored except as ``$SSH_ORIGINAL_COMMAND``, and the
host script only honours the verbs below against VM 102. We validate here too
defense in depth + a clean error before a round-trip.
All subprocesses use ``asyncio.create_subprocess_exec`` (list argv, no shell),
so the verb string is never interpreted by a shell there is no injection
surface even though the allowlist already constrains the input.
"""
import asyncio
from subprocess import PIPE
from . import config
# Must mirror /usr/local/bin/breakglass-pve on the PVE host.
ALLOWED_VERBS: frozenset[str] = frozenset(
{"status", "forensics", "reset", "stop", "start", "cycle"}
)
# Verbs that change VM state — the UI flags these for an explicit confirm and
# the host script captures forensics before running them.
MUTATING_VERBS: frozenset[str] = frozenset({"reset", "stop", "start", "cycle"})
def _ssh_argv(user: str, host: str, remote_command: str) -> list[str]:
"""Build an ssh argv (list form, no shell). ``remote_command`` is passed as
a single token; on the PVE host the forced-command ignores it except as
``$SSH_ORIGINAL_COMMAND``.
Host-key checking is disabled deliberately: a devvm REBUILD changes its host
key (e.g. 2026-05-23), and strict checking would lock the breakglass out at
exactly the moment it's needed. The targets are on the trusted internal LAN;
availability beats MITM hardening here. Auth is still by key (ssh-agent)."""
return [
"ssh",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
f"{user}@{host}",
remote_command,
]
def is_allowed(verb: str) -> bool:
return verb in ALLOWED_VERBS
async def run_verb(verb: str, timeout: float | None = None) -> dict:
"""Run a single PVE verb against VM 102 over the forced-command SSH key.
Returns ``{"verb", "exit_code", "stdout", "stderr", "rejected"}``. A verb
not in the allowlist is rejected locally (``rejected=True``) without any
SSH at all.
"""
if verb not in ALLOWED_VERBS:
return {
"verb": verb,
"exit_code": None,
"stdout": "",
"stderr": f"rejected: '{verb}' is not an allowed verb",
"rejected": True,
}
timeout = timeout if timeout is not None else config.PVE_VERB_TIMEOUT_SECONDS
argv = _ssh_argv(config.PVE_USER, config.PVE_HOST, verb)
proc = await asyncio.create_subprocess_exec(*argv, stdout=PIPE, stderr=PIPE)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return {
"verb": verb,
"exit_code": None,
"stdout": "",
"stderr": f"timeout after {timeout}s talking to PVE host",
"rejected": False,
}
return {
"verb": verb,
"exit_code": proc.returncode,
"stdout": out.decode(errors="replace"),
"stderr": err.decode(errors="replace"),
"rejected": False,
}

96
app/breakglass/server.py Normal file
View file

@ -0,0 +1,96 @@
"""Breakglass FastAPI app — the in-cluster emergency recovery UI.
Routes:
GET /health liveness (no auth)
GET / the single-page UI (static)
POST /api/session open a chat session, returns {session_id}
POST /api/chat run one turn, streams SSE events (text/tool/result)
POST /api/pve/{verb} LLM-independent PVE power verb (manual buttons)
GET /api/pve/verbs list allowed verbs + which mutate
Everything under /api requires auth (edge Authentik header or bearer token).
"""
import json
import os
import uuid
from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from . import agent_session, config, pve
from .auth import require_auth
app = FastAPI(title="Claude Breakglass")
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
class SessionResponse(BaseModel):
session_id: str
class ChatRequest(BaseModel):
session_id: str
prompt: str = Field(..., min_length=1)
model: str | None = None
@app.get("/health")
async def health():
return {"status": "ok", "service": "claude-breakglass"}
@app.post("/api/session", response_model=SessionResponse)
async def open_session(_identity: str = Depends(require_auth)):
# Claude wants a UUID for --session-id.
return SessionResponse(session_id=str(uuid.uuid4()))
@app.post("/api/chat")
async def chat(req: ChatRequest, _identity: str = Depends(require_auth)):
"""Stream one chat turn as Server-Sent Events. The browser reads the
response body incrementally (fetch + ReadableStream)."""
async def _sse():
try:
async for ev in agent_session.run_turn(req.session_id, req.prompt, req.model):
yield f"data: {json.dumps(ev)}\n\n"
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
yield f"data: {json.dumps({'kind': 'error', 'error': str(exc)[:500]})}\n\n"
yield f"data: {json.dumps({'kind': 'done'})}\n\n"
return StreamingResponse(
_sse(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/api/pve/verbs")
async def pve_verbs(_identity: str = Depends(require_auth)):
return {
"verbs": sorted(pve.ALLOWED_VERBS),
"mutating": sorted(pve.MUTATING_VERBS),
}
@app.post("/api/pve/{verb}")
async def pve_verb(verb: str, _identity: str = Depends(require_auth)):
"""Run a PVE power verb directly (no LLM in the path). Mutating verbs
capture forensics first on the host, unconditionally."""
if not pve.is_allowed(verb):
raise HTTPException(status_code=400, detail=f"unknown verb '{verb}'")
result = await pve.run_verb(verb)
status = 200 if result.get("exit_code") == 0 else 502
return JSONResponse(status_code=status, content=result)
# Serve the SPA. Mounted last so it doesn't shadow /api or /health.
if os.path.isdir(_STATIC_DIR):
@app.get("/")
async def index():
return FileResponse(os.path.join(_STATIC_DIR, "index.html"))
app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="static")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex, nofollow" />
<title>devvm breakglass</title>
<script type="module" crossorigin src="./assets/index-DNECe1Jo.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DKeuidum.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -0,0 +1,51 @@
#!/bin/bash
# Entrypoint for the claude-breakglass deployment.
#
# Loads the breakglass SSH key into an in-pod ssh-agent (so neither the app nor
# the agent needs a key path, and the private key isn't passed around as a file
# after load), writes the `devvm`/`pve` SSH aliases the breakglass agent uses,
# then execs uvicorn. uvicorn — and the `claude` subprocesses it spawns —
# inherit SSH_AUTH_SOCK, so `ssh devvm` / `ssh pve <verb>` just work.
set -euo pipefail
HOME_DIR="${HOME:-/home/agent}"
SSH_DIR="$HOME_DIR/.ssh"
KEY_SRC="${BREAKGLASS_KEY_PATH:-/secrets/breakglass/private_key}"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
# SSH client config: the aliases the breakglass agent prompt refers to.
# Host-key checking off on purpose — a devvm rebuild rotates the host key and we
# must not get locked out mid-incident (trusted internal LAN; key auth stands).
cat > "$SSH_DIR/config" <<CFG
Host devvm
HostName ${BREAKGLASS_DEVVM_HOST:-10.0.10.10}
User ${BREAKGLASS_DEVVM_USER:-breakglass}
Host pve
HostName ${BREAKGLASS_PVE_HOST:-192.168.1.127}
User ${BREAKGLASS_PVE_USER:-root}
Host *
BatchMode yes
ConnectTimeout 10
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
CFG
chmod 600 "$SSH_DIR/config"
# Load the key into ssh-agent from a private tmpfs copy, then drop the copy
# (the agent keeps it in memory). The mounted secret is tmpfs, never disk.
if [[ -f "$KEY_SRC" ]]; then
eval "$(ssh-agent -s)" >/dev/null
TMP_KEY="$(mktemp /dev/shm/bgk.XXXXXX)"
install -m600 "$KEY_SRC" "$TMP_KEY"
ssh-add "$TMP_KEY" >/dev/null 2>&1 || echo "WARN: ssh-add failed" >&2
shred -u "$TMP_KEY" 2>/dev/null || rm -f "$TMP_KEY"
export SSH_AUTH_SOCK SSH_AGENT_PID
else
echo "WARN: breakglass key not found at $KEY_SRC — SSH will not work" >&2
fi
exec python3 -m uvicorn app.breakglass.server:app \
--host 0.0.0.0 --port 8080 --app-dir /srv

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex, nofollow" />
<title>devvm breakglass</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1179
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
frontend/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "breakglass-ui",
"version": "0.1.0",
"private": true,
"description": "devvm breakglass — emergency recovery SPA (served by the in-cluster FastAPI app)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "7.1.2",
"svelte": "5.56.3",
"vite": "8.0.16"
}
}

272
frontend/src/App.svelte Normal file
View file

@ -0,0 +1,272 @@
<script>
import { onMount } from 'svelte';
import { openSession } from './lib/api.js';
import Chat from './Chat.svelte';
import VmControls from './VmControls.svelte';
// ── session lifecycle ────────────────────────────────────────────────────
// sessionId is the id we POST with. The backend also reports an authoritative
// id in the first {kind:"session"} frame of a turn; Chat bubbles that up so
// the rail always shows what the agent is actually resuming.
let sessionId = $state('');
let sessionState = $state('connecting'); // connecting | ready | error
let sessionError = $state('');
let streaming = $state(false); // a chat turn is in flight (drives the rail dot)
async function newSession() {
sessionState = 'connecting';
sessionError = '';
try {
sessionId = await openSession();
sessionState = 'ready';
} catch (err) {
sessionState = 'error';
sessionError = err instanceof Error ? err.message : String(err);
}
}
onMount(newSession);
// Chat reports the live session id from the stream's session frame.
function onLiveSession(id) {
if (id) sessionId = id;
}
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '────────');
const dotState = $derived(
sessionState === 'error' ? 'error' : streaming ? 'busy' : sessionState === 'ready' ? 'ready' : 'idle'
);
</script>
<div class="shell">
<header class="rail">
<div class="rail-title">
<span class="glyph" aria-hidden="true">🔧</span>
<h1>devvm <span class="accent">breakglass</span></h1>
<span class="rail-tag">emergency recovery</span>
</div>
<div class="rail-status">
<span class="dot dot--{dotState}" aria-hidden="true"></span>
<span class="rail-session">
{#if sessionState === 'error'}
<span class="session-bad">session unavailable</span>
{:else if sessionState === 'connecting'}
<span class="session-meta">opening session…</span>
{:else}
<span class="session-label">session</span>
<code class="session-id" title={sessionId}>{shortId}</code>
{#if streaming}<span class="session-meta">· agent working</span>{/if}
{/if}
</span>
<button
class="new-session"
onclick={newSession}
disabled={streaming || sessionState === 'connecting'}
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
>
New session
</button>
</div>
</header>
{#if sessionState === 'error'}
<div class="rail-error" role="alert">
Could not reach the breakglass backend — {sessionError}. The cluster or
network may be down. The manual VM controls below still work independently
of the chat agent.
</div>
{/if}
<main class="grid">
<section class="col col--chat" aria-label="Recovery chat">
<Chat
{sessionId}
sessionReady={sessionState === 'ready'}
onLiveSession={onLiveSession}
onStreamingChange={(v) => (streaming = v)}
/>
</section>
<aside class="col col--controls" aria-label="Direct VM control">
<VmControls />
</aside>
</main>
</div>
<style>
.shell {
height: 100%;
display: flex;
flex-direction: column;
max-width: 1500px;
margin: 0 auto;
padding: 0 18px 18px;
}
/* ── status rail ─────────────────────────────────────────────────────── */
.rail {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px 4px 14px;
border-bottom: 1px solid var(--line);
}
.rail-title {
display: flex;
align-items: baseline;
gap: 12px;
}
.glyph {
font-size: 19px;
transform: translateY(2px);
filter: saturate(0.85);
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 19px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink);
}
.accent {
color: var(--cyan);
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
}
.rail-tag {
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--ink-faint);
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 3px 9px;
}
.rail-status {
display: flex;
align-items: center;
gap: 14px;
font-family: var(--mono);
font-size: 13px;
}
.rail-session {
display: inline-flex;
align-items: baseline;
gap: 7px;
white-space: nowrap;
}
.session-label {
color: var(--ink-faint);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
}
.session-id {
color: var(--cyan);
font-family: var(--mono);
letter-spacing: 0.04em;
}
.session-meta {
color: var(--amber);
font-size: 12px;
}
.session-bad {
color: var(--danger-bright);
}
/* connection lamp */
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
background: var(--ink-faint);
box-shadow: 0 0 0 0 transparent;
}
.dot--ready {
background: var(--cyan);
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.6);
animation: breathe 3.4s ease-in-out infinite;
}
.dot--busy {
background: var(--amber);
box-shadow: 0 0 10px 1px rgba(245, 182, 87, 0.7);
animation: pulse 1s ease-in-out infinite;
}
.dot--error {
background: var(--danger);
box-shadow: 0 0 10px 1px var(--danger-glow);
}
@keyframes breathe {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes pulse {
0%, 100% { transform: scale(0.82); opacity: 0.7; }
50% { transform: scale(1.15); opacity: 1; }
}
.new-session {
background: var(--bg-2);
color: var(--ink-dim);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 7px 13px;
font-size: 12px;
letter-spacing: 0.02em;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.new-session:hover:not(:disabled) {
border-color: var(--cyan-dim);
color: var(--ink);
background: var(--bg-3);
}
.new-session:disabled {
opacity: 0.45;
}
.rail-error {
margin: 12px 0 0;
padding: 11px 14px;
border: 1px solid var(--danger-deep);
border-left-width: 3px;
background: rgba(255, 77, 77, 0.07);
color: #ffd5d5;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
}
/* ── layout ──────────────────────────────────────────────────────────── */
.grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 376px;
gap: 18px;
padding-top: 16px;
}
.col {
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
@media (max-width: 940px) {
.grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(0, auto);
overflow: auto;
}
.col--chat {
min-height: 60vh;
}
}
</style>

494
frontend/src/Chat.svelte Normal file
View file

@ -0,0 +1,494 @@
<script>
import { tick } from 'svelte';
import { streamChat } from './lib/api.js';
import ToolChip from './ToolChip.svelte';
let {
sessionId = '',
sessionReady = false,
onLiveSession = (/** @type {string} */ _id) => {},
onStreamingChange = (/** @type {boolean} */ _v) => {},
} = $props();
/**
* Message model. A user message is plain text. An assistant message is an
* ordered list of parts so streamed prose and tool chips interleave in the
* exact order the agent emitted them:
* { role:'assistant', parts:[{type:'text',text}|{type:'tool',name,command}],
* result?: {is_error, text, duration_ms}, error?: string }
* @type {Array<any>}
*/
let messages = $state([]);
let draft = $state('');
let streaming = $state(false);
let scroller; // the scroll viewport
let inputEl;
let pinnedToBottom = true; // auto-scroll only while the user is at the bottom
const canSend = $derived(sessionReady && !streaming && draft.trim().length > 0);
// ── scrolling ─────────────────────────────────────────────────────────────
function onScroll() {
if (!scroller) return;
const gap = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
pinnedToBottom = gap < 60;
}
async function scrollToBottom(force = false) {
if (!force && !pinnedToBottom) return;
await tick();
if (scroller) scroller.scrollTop = scroller.scrollHeight;
}
// ── streaming a turn ────────────────────────────────────────────────────────
function lastAssistant() {
return messages[messages.length - 1];
}
function appendText(text) {
const msg = lastAssistant();
const parts = msg.parts;
const tail = parts[parts.length - 1];
if (tail && tail.type === 'text') {
tail.text += text;
} else {
parts.push({ type: 'text', text });
}
messages = messages; // notify Svelte of the in-place mutation
}
function handleEvent(ev) {
switch (ev?.kind) {
case 'session':
onLiveSession(ev.session_id);
break;
case 'text':
if (ev.text) appendText(ev.text);
break;
case 'tool': {
// Bash carries a `command`; other tools just show their name.
const command =
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
lastAssistant().parts.push({ type: 'tool', name: ev.name || 'tool', command });
messages = messages;
break;
}
case 'result':
lastAssistant().result = {
is_error: Boolean(ev.is_error),
text: typeof ev.result === 'string' ? ev.result : '',
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
};
messages = messages;
break;
case 'error':
lastAssistant().error = ev.error || 'unknown error';
messages = messages;
break;
case 'done':
// handled by the stream completing; nothing to render
break;
default:
break;
}
scrollToBottom();
}
async function send() {
const prompt = draft.trim();
if (!prompt || streaming || !sessionReady) return;
messages.push({ role: 'user', text: prompt });
messages.push({ role: 'assistant', parts: [] });
messages = messages;
draft = '';
streaming = true;
onStreamingChange(true);
pinnedToBottom = true;
await scrollToBottom(true);
try {
await streamChat({ session_id: sessionId, prompt }, handleEvent);
} catch (err) {
// Network/transport failure (backend down, connection dropped mid-stream).
const msg = lastAssistant();
if (msg && msg.role === 'assistant' && !msg.error) {
msg.error =
(err instanceof Error ? err.message : String(err)) +
' — the connection to the agent failed.';
messages = messages;
}
} finally {
streaming = false;
onStreamingChange(false);
await scrollToBottom();
inputEl?.focus();
}
}
function onKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
// Shift+Enter falls through to insert a newline.
}
function fmtDuration(ms) {
if (ms == null) return '';
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
}
const isEmpty = $derived(messages.length === 0);
</script>
<div class="chat">
<div class="chat-head">
<span class="chat-head-label">Recovery agent</span>
<span class="chat-head-hint">SSHes into the devvm to diagnose &amp; repair</span>
</div>
<div class="stream" bind:this={scroller} onscroll={onScroll}>
{#if isEmpty}
<div class="empty">
<div class="empty-mark"></div>
<p class="empty-title">The agent is standing by.</p>
<p class="empty-sub">
Describe the symptom — "devvm is unreachable", "disk full", "ssh hangs"
— and it will connect over SSH, investigate, and stream its work here.
For a hard power action when the agent can't help, use
<strong>Direct VM control</strong>.
</p>
</div>
{/if}
{#each messages as msg, i (i)}
{#if msg.role === 'user'}
<div class="row row--user">
<div class="bubble bubble--user">{msg.text}</div>
</div>
{:else}
<div class="row row--assistant">
<div class="bubble bubble--assistant">
{#if msg.parts.length === 0 && !msg.result && !msg.error}
<span class="thinking" aria-label="working">
<span></span><span></span><span></span>
</span>
{/if}
{#each msg.parts as part, j (j)}
{#if part.type === 'text'}
<span class="prose">{part.text}</span>
{:else}
<ToolChip name={part.name} command={part.command} />
{/if}
{/each}
{#if msg.error}
<div class="turn-note turn-note--error">{msg.error}</div>
{:else if msg.result}
<div class="turn-note {msg.result.is_error ? 'turn-note--error' : 'turn-note--ok'}">
<span class="turn-note-tag">{msg.result.is_error ? 'failed' : 'done'}</span>
{#if msg.result.text}<span class="turn-note-body">{msg.result.text}</span>{/if}
{#if msg.result.duration_ms != null}
<span class="turn-note-time">{fmtDuration(msg.result.duration_ms)}</span>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
<form
class="composer"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
{#if streaming}
<div class="working-bar" aria-live="polite">
<span class="working-dots"><span></span><span></span><span></span></span>
agent working — streaming live
</div>
{/if}
<div class="composer-row">
<textarea
bind:this={inputEl}
bind:value={draft}
onkeydown={onKeydown}
placeholder={sessionReady
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
: 'Waiting for a session…'}
rows="1"
disabled={!sessionReady || streaming}
spellcheck="false"
></textarea>
<button type="submit" class="send" disabled={!canSend}>
{streaming ? '…' : 'Send'}
</button>
</div>
</form>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-panel);
overflow: hidden;
}
.chat-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 13px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
}
.chat-head-label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--cyan);
}
.chat-head-hint {
font-size: 12px;
color: var(--ink-faint);
}
.stream {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 18px 8px;
display: flex;
flex-direction: column;
gap: 14px;
scroll-behavior: smooth;
}
/* empty state */
.empty {
margin: auto;
max-width: 460px;
text-align: center;
padding: 28px 12px;
color: var(--ink-dim);
}
.empty-mark {
font-size: 40px;
color: var(--cyan-dim);
line-height: 1;
margin-bottom: 14px;
text-shadow: 0 0 24px rgba(61, 209, 214, 0.25);
}
.empty-title {
font-family: var(--mono);
color: var(--ink);
font-size: 15px;
margin: 0 0 8px;
}
.empty-sub {
font-size: 13px;
line-height: 1.6;
color: var(--ink-faint);
margin: 0;
}
.empty-sub strong {
color: var(--ink-dim);
font-weight: 600;
}
.row {
display: flex;
}
.row--user {
justify-content: flex-end;
}
.row--assistant {
justify-content: flex-start;
}
.bubble {
max-width: 86%;
border-radius: 13px;
padding: 11px 14px;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.bubble--user {
background: linear-gradient(180deg, #15333a, #0f262c);
border: 1px solid var(--cyan-dim);
color: #d8f6f7;
border-bottom-right-radius: 4px;
white-space: pre-wrap;
font-family: var(--sans);
}
.bubble--assistant {
background: var(--bg-2);
border: 1px solid var(--line-strong);
border-bottom-left-radius: 4px;
color: var(--ink);
}
/* prose renders inline so text and tool chips share the same flow */
.prose {
white-space: pre-wrap;
}
/* in-flight assistant "thinking" dots */
.thinking,
.working-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.thinking span,
.working-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--amber);
opacity: 0.4;
animation: blink 1.2s infinite ease-in-out;
}
.thinking span:nth-child(2),
.working-dots span:nth-child(2) {
animation-delay: 0.18s;
}
.thinking span:nth-child(3),
.working-dots span:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
/* turn result / error footer inside the assistant bubble */
.turn-note {
margin-top: 10px;
padding: 7px 10px;
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.turn-note--ok {
background: rgba(93, 219, 142, 0.07);
border: 1px solid var(--green-dim);
color: #bff5d3;
}
.turn-note--error {
background: rgba(255, 77, 77, 0.08);
border: 1px solid var(--danger-deep);
color: #ffd5d5;
}
.turn-note-tag {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.85;
}
.turn-note-body {
flex: 1;
min-width: 0;
}
.turn-note-time {
margin-left: auto;
color: var(--ink-faint);
}
/* ── composer ─────────────────────────────────────────────────────────── */
.composer {
border-top: 1px solid var(--line);
padding: 12px;
background: linear-gradient(0deg, rgba(255, 255, 255, 0.012), transparent);
}
.working-bar {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--mono);
font-size: 12px;
color: var(--amber);
padding: 0 4px 9px;
letter-spacing: 0.02em;
}
.composer-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
textarea {
flex: 1;
resize: none;
max-height: 168px;
min-height: 44px;
background: var(--bg-2);
color: var(--ink);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 11px 13px;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
field-sizing: content; /* progressive: auto-grows where supported */
}
textarea::placeholder {
color: var(--ink-faint);
}
textarea:focus {
border-color: var(--cyan-dim);
box-shadow: 0 0 0 3px rgba(61, 209, 214, 0.12);
}
textarea:disabled {
opacity: 0.55;
}
.send {
flex: none;
align-self: stretch;
min-width: 78px;
padding: 0 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--cyan-dim);
background: linear-gradient(180deg, #19474b, #103539);
color: #d8f6f7;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
transition: filter 0.15s, border-color 0.15s, opacity 0.15s;
}
.send:hover:not(:disabled) {
filter: brightness(1.22);
border-color: var(--cyan);
}
.send:disabled {
opacity: 0.4;
background: var(--bg-2);
border-color: var(--line-strong);
color: var(--ink-faint);
}
</style>

View file

@ -0,0 +1,54 @@
<script>
// A compact inline marker for a tool the agent ran mid-turn.
// ⚙ Bash: df -h (Bash → show the command)
// ⚙ Read (other tools → just the name)
let { name = 'tool', command = '' } = $props();
</script>
<span class="chip" title={command ? `${name}: ${command}` : name}>
<span class="cog" aria-hidden="true"></span>
<span class="name">{name}</span>
{#if command}
<span class="sep" aria-hidden="true">:</span>
<code class="cmd">{command}</code>
{/if}
</span>
<style>
.chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
max-width: 100%;
margin: 3px 4px 3px 0;
padding: 3px 9px;
border-radius: 6px;
background: var(--bg-3);
border: 1px solid var(--line-strong);
border-left: 2px solid var(--cyan-dim);
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
vertical-align: baseline;
}
.cog {
color: var(--cyan);
font-size: 11px;
transform: translateY(1px);
}
.name {
color: var(--ink);
font-weight: 600;
}
.sep {
color: var(--ink-faint);
}
.cmd {
color: var(--amber);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
</style>

View file

@ -0,0 +1,562 @@
<script>
import { onMount } from 'svelte';
import { fetchVerbs, runVerb } from './lib/api.js';
// ── verb catalogue ──────────────────────────────────────────────────────
// The server is the source of truth for which verbs exist and which mutate.
// We layer presentation metadata (label, blurb) on top, preserving a sensible
// operator order: read-only first, then the escalating power actions.
const META = {
status: { label: 'status', blurb: 'qm status — is the VM up?' },
forensics: { label: 'forensics', blurb: 'capture live diagnostic state' },
start: { label: 'start', blurb: 'power on a stopped VM' },
stop: { label: 'stop', blurb: 'hard power-off (pulls the plug)' },
reset: { label: 'reset', blurb: 'warm reboot — reuses the QEMU process' },
cycle: {
label: 'cycle',
blurb: 'stop → start; applies staged config; fixes a wedged QEMU',
headline: true,
},
};
const ORDER = ['status', 'forensics', 'start', 'stop', 'reset', 'cycle'];
let loadState = $state('loading'); // loading | ready | error
let loadError = $state('');
let verbs = $state([]); // [{name, mutating, ...meta}]
let confirming = $state(''); // verb awaiting confirmation, or ''
let running = $state(''); // verb currently in flight, or ''
let output = $state(null); // { verb, exit_code, stdout, stderr, rejected }
let actionError = $state(''); // transport-level failure (backend unreachable)
const busy = $derived(running !== '');
onMount(async () => {
try {
const { verbs: names, mutating } = await fetchVerbs();
const mut = new Set(mutating);
const known = names.filter((n) => META[n]);
const ordered = [
...ORDER.filter((n) => known.includes(n)),
...known.filter((n) => !ORDER.includes(n)),
];
verbs = ordered.map((name) => ({
name,
mutating: mut.has(name),
...META[name],
}));
loadState = 'ready';
} catch (err) {
loadState = 'error';
loadError = err instanceof Error ? err.message : String(err);
}
});
const nonMutating = $derived(verbs.filter((v) => !v.mutating));
const mutating = $derived(verbs.filter((v) => v.mutating));
function clickVerb(v) {
if (busy) return;
if (v.mutating) {
confirming = confirming === v.name ? '' : v.name; // toggle the inline confirm
} else {
execute(v.name);
}
}
function cancelConfirm() {
confirming = '';
}
async function execute(verb) {
confirming = '';
actionError = '';
output = null;
running = verb;
try {
output = await runVerb(verb);
} catch (err) {
actionError = err instanceof Error ? err.message : String(err);
} finally {
running = '';
}
}
// styling helpers for the output panel
const outputFailed = $derived(
!!output && (output.rejected || (output.exit_code != null && output.exit_code !== 0))
);
</script>
<div class="panel">
<div class="panel-head">
<div class="panel-head-row">
<span class="hazard" aria-hidden="true"></span>
<h2>Direct VM control</h2>
</div>
<p class="panel-sub">No AI in the path — these reach the Proxmox host over a
forced-command SSH key and work even when the agent is down.</p>
</div>
{#if loadState === 'loading'}
<div class="loading">Loading controls…</div>
{:else if loadState === 'error'}
<div class="block-error" role="alert">
Couldn't load the VM controls — {loadError}.
<button class="retry" onclick={() => location.reload()}>Reload</button>
</div>
{:else}
<!-- read-only actions -->
<div class="group">
<div class="group-label">Inspect <span class="group-tag">read-only</span></div>
<div class="btn-row">
{#each nonMutating as v (v.name)}
<button
class="vbtn vbtn--safe"
onclick={() => clickVerb(v)}
disabled={busy}
title={v.blurb}
>
{#if running === v.name}<span class="spin" aria-hidden="true"></span>{/if}
<span class="vbtn-label">{v.label}</span>
</button>
{/each}
</div>
</div>
<!-- mutating / power actions -->
<div class="group">
<div class="group-label group-label--danger">
Power <span class="group-tag group-tag--danger">affects the running VM</span>
</div>
<div class="danger-list">
{#each mutating as v (v.name)}
<div class="danger-item {v.headline ? 'danger-item--headline' : ''}">
<button
class="vbtn vbtn--danger {v.headline ? 'vbtn--headline' : ''}"
onclick={() => clickVerb(v)}
disabled={busy}
aria-expanded={confirming === v.name}
>
{#if running === v.name}<span class="spin spin--danger" aria-hidden="true"></span>{/if}
<span class="vbtn-label">{v.label}</span>
{#if v.headline}<span class="headline-badge">recovery</span>{/if}
</button>
<p class="danger-blurb">{v.blurb}</p>
{#if confirming === v.name}
<div class="confirm" role="alertdialog" aria-label="Confirm {v.name}">
<span class="confirm-text">
Confirm <strong>{v.name}</strong>? This will affect the running VM
</span>
<div class="confirm-actions">
<button class="confirm-yes" onclick={() => execute(v.name)} disabled={busy}>
Confirm
</button>
<button class="confirm-no" onclick={cancelConfirm} disabled={busy}>
Cancel
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- output -->
{#if actionError}
<div class="block-error" role="alert">
⚠ Command failed to reach the host — {actionError}
</div>
{/if}
{#if output}
<div class="out {outputFailed ? 'out--fail' : 'out--ok'}">
<div class="out-head">
<code class="out-verb">{output.verb}</code>
{#if output.rejected}
<span class="out-status out-status--fail">rejected</span>
{:else}
<span class="out-status {outputFailed ? 'out-status--fail' : 'out-status--ok'}">
exit {output.exit_code}
</span>
{/if}
</div>
{#if output.stdout}
<pre class="out-pre">{output.stdout}</pre>
{/if}
{#if output.stderr}
<div class="out-stderr-label">stderr</div>
<pre class="out-pre out-pre--stderr">{output.stderr}</pre>
{/if}
{#if !output.stdout && !output.stderr}
<pre class="out-pre out-pre--empty">(no output)</pre>
{/if}
</div>
{/if}
{/if}
</div>
<style>
.panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-1);
border: 1px solid var(--line);
/* a faint danger seam down the right edge marks this as the hot zone */
border-top: 2px solid var(--danger-deep);
border-radius: var(--radius);
box-shadow: var(--shadow-panel);
overflow-y: auto;
}
.panel-head {
padding: 14px 16px 12px;
border-bottom: 1px solid var(--line);
}
.panel-head-row {
display: flex;
align-items: center;
gap: 9px;
}
.hazard {
color: var(--danger);
font-size: 15px;
filter: drop-shadow(0 0 8px var(--danger-glow));
}
h2 {
margin: 0;
font-family: var(--mono);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink);
}
.panel-sub {
margin: 9px 0 0;
font-size: 11.5px;
line-height: 1.55;
color: var(--ink-faint);
}
.loading {
padding: 22px 16px;
font-family: var(--mono);
font-size: 12px;
color: var(--ink-faint);
}
.group {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.group-label {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--ink-faint);
margin-bottom: 11px;
}
.group-label--danger {
color: var(--danger-bright);
}
.group-tag {
font-size: 9.5px;
letter-spacing: 0.1em;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--line-strong);
color: var(--ink-faint);
}
.group-tag--danger {
border-color: var(--danger-deep);
color: var(--danger-bright);
background: rgba(255, 77, 77, 0.06);
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
/* shared button shape */
.vbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 15px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: lowercase;
transition: filter 0.14s, border-color 0.14s, background 0.14s, transform 0.06s;
}
.vbtn:active:not(:disabled) {
transform: translateY(1px);
}
.vbtn:disabled {
opacity: 0.4;
}
.vbtn-label {
line-height: 1;
}
.vbtn--safe {
background: var(--bg-2);
color: var(--ink);
border: 1px solid var(--line-strong);
}
.vbtn--safe:hover:not(:disabled) {
border-color: var(--cyan-dim);
background: var(--bg-3);
}
/* danger actions read as hot the moment you look at them */
.danger-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.danger-item {
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.danger-item--headline {
padding: 11px;
border-color: var(--danger-deep);
background: rgba(255, 77, 77, 0.045);
}
.vbtn--danger {
width: 100%;
background: linear-gradient(180deg, rgba(255, 77, 77, 0.16), rgba(255, 77, 77, 0.07));
color: var(--danger-bright);
border: 1px solid var(--danger-deep);
/* hazard stripe down the leading edge */
border-left: 3px solid var(--danger);
text-shadow: 0 0 12px var(--danger-glow);
}
.vbtn--danger:hover:not(:disabled) {
background: linear-gradient(180deg, var(--danger), var(--danger-bright));
color: #1a0606;
border-color: var(--danger-bright);
text-shadow: none;
filter: drop-shadow(0 4px 14px var(--danger-glow));
}
.vbtn--headline {
padding: 12px 15px;
font-size: 14px;
}
.headline-badge {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 2px 7px;
border-radius: 999px;
background: var(--danger);
color: #1a0606;
font-weight: 700;
}
.danger-blurb {
margin: 7px 2px 0;
font-size: 11.5px;
line-height: 1.5;
color: var(--ink-faint);
}
.danger-item--headline .danger-blurb {
color: #f0b0b0;
}
/* inline confirm step */
.confirm {
margin-top: 10px;
padding: 11px 12px;
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
background: rgba(255, 77, 77, 0.1);
animation: confirm-in 0.16s ease-out;
}
@keyframes confirm-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.confirm-text {
display: block;
font-size: 12.5px;
line-height: 1.5;
color: #ffe0e0;
margin-bottom: 10px;
}
.confirm-text strong {
color: #fff;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.confirm-actions {
display: flex;
gap: 9px;
}
.confirm-yes {
flex: 1;
padding: 9px;
border-radius: var(--radius-sm);
border: 1px solid var(--danger-bright);
background: var(--danger);
color: #1a0606;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: filter 0.14s;
}
.confirm-yes:hover:not(:disabled) {
filter: brightness(1.12);
}
.confirm-no {
flex: 1;
padding: 9px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
transition: border-color 0.14s, color 0.14s;
}
.confirm-no:hover:not(:disabled) {
border-color: var(--ink-faint);
color: var(--ink);
}
.confirm-yes:disabled,
.confirm-no:disabled {
opacity: 0.5;
}
/* spinners */
.spin {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid rgba(230, 237, 243, 0.25);
border-top-color: var(--cyan);
animation: spin 0.7s linear infinite;
flex: none;
}
.spin--danger {
border-color: rgba(255, 77, 77, 0.3);
border-top-color: var(--danger-bright);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* output panel */
.out {
margin: 14px 16px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-term);
overflow: hidden;
}
.out--ok {
border-color: var(--green-dim);
}
.out--fail {
border-color: var(--danger-deep);
}
.out-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 11px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
}
.out-verb {
font-family: var(--mono);
font-size: 12px;
color: var(--ink);
letter-spacing: 0.04em;
}
.out-verb::before {
content: '$ pve ';
color: var(--ink-faint);
}
.out-status {
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 2px 7px;
border-radius: 4px;
border: 1px solid currentColor;
}
.out-status--ok {
color: var(--green);
}
.out-status--fail {
color: var(--danger-bright);
}
.out-pre {
margin: 0;
padding: 11px 12px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
color: #c7d6e2;
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 320px;
overflow-y: auto;
}
.out-stderr-label {
padding: 6px 12px 0;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--danger-bright);
}
.out-pre--stderr {
color: #f3b6b6;
}
.out-pre--empty {
color: var(--ink-faint);
font-style: italic;
}
.block-error {
margin: 14px 16px;
padding: 11px 13px;
border: 1px solid var(--danger-deep);
border-left: 3px solid var(--danger);
background: rgba(255, 77, 77, 0.07);
border-radius: var(--radius-sm);
color: #ffd5d5;
font-size: 12.5px;
line-height: 1.5;
}
.retry {
margin-left: 8px;
background: transparent;
border: 1px solid var(--danger-deep);
color: var(--danger-bright);
border-radius: 5px;
padding: 3px 9px;
font-size: 11px;
}
.retry:hover {
background: rgba(255, 77, 77, 0.12);
}
</style>

126
frontend/src/app.css Normal file
View file

@ -0,0 +1,126 @@
/*
devvm breakglass global theme
A recovery console: dark, high-contrast, terminal-adjacent. Calm by default;
danger is the only loud thing on the screen. No external fonts/CDNs system
monospace carries the identity, system sans carries readable prose.
*/
:root {
/* Surfaces — a near-black slate with cool undertone, layered for depth. */
--bg-0: #07090c; /* page base */
--bg-1: #0c1015; /* panel */
--bg-2: #11171e; /* raised panel / input */
--bg-3: #161d26; /* chips, hover */
--bg-term: #06080a; /* command-output panels */
/* Hairlines & text */
--line: #1d2630;
--line-strong: #2a3744;
--ink: #e6edf3; /* primary text */
--ink-dim: #9bb0c0; /* secondary text */
--ink-faint: #5d7185; /* labels, meta */
/* Accents */
--cyan: #3dd1d6; /* "system alive" — links, focus, session dot */
--cyan-dim: #1f6f72;
--amber: #f5b657; /* working / in-flight */
--green: #5ddb8e; /* healthy exit */
--green-dim: #1f5f3d;
/* Danger — reserved EXCLUSIVELY for mutating actions. Nothing else is red. */
--danger: #ff4d4d;
--danger-bright: #ff6363;
--danger-deep: #7a1717;
--danger-glow: rgba(255, 77, 77, 0.35);
--radius: 10px;
--radius-sm: 7px;
--mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code",
"Fira Code", Menlo, Consolas, "Liberation Mono", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.02) inset,
0 16px 40px -24px rgba(0, 0, 0, 0.9);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background-color: var(--bg-0);
/* Atmosphere: a soft cyan corner-glow over a faint scanline weave, so the
surface reads like backlit equipment rather than flat #000. */
background-image:
radial-gradient(120% 80% at 85% -10%, rgba(61, 209, 214, 0.07), transparent 55%),
radial-gradient(90% 70% at 10% 110%, rgba(245, 182, 87, 0.04), transparent 50%),
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.012) 0px,
rgba(255, 255, 255, 0.012) 1px,
transparent 1px,
transparent 3px
);
background-attachment: fixed;
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#app {
height: 100%;
}
button {
font-family: var(--mono);
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
::selection {
background: rgba(61, 209, 214, 0.28);
}
/* Console scrollbars — thin, dark, unobtrusive. */
* {
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: 99px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: #3a4a5a;
background-clip: content-box;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}

92
frontend/src/lib/api.js Normal file
View 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),
};
}

150
frontend/src/lib/sse.js Normal file
View file

@ -0,0 +1,150 @@
// SSE frame parsing — the load-bearing core of the breakglass UI.
//
// The /api/chat endpoint returns a text/event-stream that we read with
// fetch() + response.body.getReader() (NOT EventSource, which cannot POST).
// The backend emits one frame per event as:
//
// data: {json}\n\n
//
// getReader() hands us bytes at arbitrary boundaries: a single frame can be
// split across reads, and one read can contain several frames. So we keep a
// rolling text buffer, split it on the blank-line frame delimiter, and only
// hand back the JSON payload of *complete* frames. Per the SSE spec a frame may
// carry multiple `data:` lines (joined with "\n"); the backend emits single
// line JSON today, but we handle the general case so a future multi-line
// payload can't silently corrupt the stream.
/**
* Parse a single SSE event block (the text between blank lines) into its data
* payload string, or null if the block carries no `data:` field (e.g. a bare
* comment or a `:` heartbeat).
* @param {string} block
* @returns {string|null}
*/
export function dataFromEventBlock(block) {
const dataLines = [];
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/\r$/, '');
if (line.startsWith(':')) continue; // SSE comment / heartbeat
if (line === 'data:' || line === 'data') {
dataLines.push('');
} else if (line.startsWith('data:')) {
// Spec: a single leading space after the colon is stripped.
let v = line.slice('data:'.length);
if (v.startsWith(' ')) v = v.slice(1);
dataLines.push(v);
}
// field lines we don't care about (event:, id:, retry:) are ignored
}
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}
/**
* A stateful splitter that turns an arbitrary sequence of decoded text chunks
* into a sequence of complete SSE event-block strings. Frames are delimited by
* a blank line; we tolerate both "\n\n" and "\r\n\r\n".
*/
export class SSEFrameSplitter {
constructor() {
this.buffer = '';
}
/**
* Feed a decoded text chunk; returns the event blocks that are now complete.
* Any trailing partial frame stays buffered for the next chunk.
* @param {string} chunk
* @returns {string[]} complete event blocks (text between delimiters)
*/
push(chunk) {
this.buffer += chunk;
const blocks = [];
// Normalise CRLF delimiters to LF so a single split rule covers both.
let idx;
// Process every complete frame currently in the buffer.
while ((idx = this._nextDelimiter()) !== -1) {
const block = this.buffer.slice(0, idx.start);
this.buffer = this.buffer.slice(idx.end);
if (block.length > 0) blocks.push(block);
}
return blocks;
}
/**
* On stream end, return whatever complete-looking content remains. A
* well-behaved backend always terminates the last frame with a blank line,
* so this is usually empty but if the connection closed mid-trailing-frame
* with a parseable block, surface it rather than dropping data.
* @returns {string[]}
*/
flush() {
const rest = this.buffer.trim();
this.buffer = '';
return rest ? [rest] : [];
}
_nextDelimiter() {
// Find the earliest of "\n\n", "\r\n\r\n", "\r\r".
const candidates = [
{ token: '\r\n\r\n', i: this.buffer.indexOf('\r\n\r\n') },
{ token: '\n\n', i: this.buffer.indexOf('\n\n') },
{ token: '\r\r', i: this.buffer.indexOf('\r\r') },
].filter((c) => c.i !== -1);
if (candidates.length === 0) return -1;
candidates.sort((a, b) => a.i - b.i);
const { token, i } = candidates[0];
return { start: i, end: i + token.length };
}
}
/**
* Read an SSE Response body to completion, invoking onEvent for every parsed
* JSON event object. Resolves when the stream ends. Throws if the response is
* not ok or has no readable body (caller shows the error inline).
*
* @param {Response} response a fetch() Response with a streaming body
* @param {(event: object) => void} onEvent called per parsed JSON event
*/
export async function readEventStream(response, onEvent) {
if (!response.ok) {
throw new Error(`server returned ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error('response has no readable body (streaming unsupported)');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const splitter = new SSEFrameSplitter();
const handleBlock = (block) => {
const payload = dataFromEventBlock(block);
if (payload == null || payload.trim() === '') return;
let obj;
try {
obj = JSON.parse(payload);
} catch {
// A malformed frame must not abort an in-progress recovery stream;
// skip it and keep reading.
return;
}
onEvent(obj);
};
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const block of splitter.push(text)) handleBlock(block);
}
} finally {
reader.releaseLock?.();
}
// Drain any trailing bytes the decoder held, then any final frame.
const tail = decoder.decode();
if (tail) {
for (const block of splitter.push(tail)) handleBlock(block);
}
for (const block of splitter.flush()) handleBlock(block);
}

View file

@ -0,0 +1,152 @@
// Standalone test of the SSE frame parser — no test framework, just node.
// Run: node src/lib/sse.test.mjs (exits non-zero on any failure)
//
// These pin the protocol described in the API contract: frames are
// `data: {json}\n\n`, the event `kind` is one of session/text/tool/result/
// error/done, and bytes arrive at arbitrary boundaries via getReader().
import { SSEFrameSplitter, dataFromEventBlock, readEventStream } from './sse.js';
let failures = 0;
function ok(name, cond) {
if (cond) {
console.log(` ok ${name}`);
} else {
failures++;
console.error(`FAIL ${name}`);
}
}
function eq(name, got, want) {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(`${name} (got ${g})`, g === w);
}
// --- dataFromEventBlock ---------------------------------------------------
eq(
'extracts JSON payload from a data: line',
dataFromEventBlock('data: {"kind":"text","text":"hi"}'),
'{"kind":"text","text":"hi"}'
);
eq(
'strips exactly one space after the colon',
dataFromEventBlock('data: leading-space-kept'),
' leading-space-kept'
);
eq('ignores comment/heartbeat lines', dataFromEventBlock(': keep-alive'), null);
eq(
'joins multi-line data fields with newline',
dataFromEventBlock('data: line1\ndata: line2'),
'line1\nline2'
);
// --- SSEFrameSplitter: whole frames --------------------------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push('data: {"kind":"session","session_id":"abc"}\n\n');
eq('one complete frame yields one block', blocks, [
'data: {"kind":"session","session_id":"abc"}',
]);
}
// --- SSEFrameSplitter: multiple frames in one chunk ----------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push(
'data: {"kind":"text","text":"a"}\n\ndata: {"kind":"text","text":"b"}\n\n'
);
eq('two frames in one chunk yield two blocks', blocks.length, 2);
eq('first block', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"a"}');
eq('second block', dataFromEventBlock(blocks[1]), '{"kind":"text","text":"b"}');
}
// --- SSEFrameSplitter: frame split across chunks -------------------------
{
const s = new SSEFrameSplitter();
let blocks = s.push('data: {"kind":"te');
eq('partial frame yields nothing yet', blocks, []);
blocks = s.push('xt","text":"split"}\n\n');
eq('completing the frame yields it whole', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"split"}');
}
// --- SSEFrameSplitter: delimiter split across chunks ---------------------
{
const s = new SSEFrameSplitter();
let blocks = s.push('data: {"kind":"done"}\n');
eq('frame held while delimiter incomplete', blocks, []);
blocks = s.push('\n');
eq('frame released once blank line completes', dataFromEventBlock(blocks[0]), '{"kind":"done"}');
}
// --- SSEFrameSplitter: CRLF delimiters -----------------------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push('data: {"kind":"text","text":"crlf"}\r\n\r\n');
eq('CRLF-delimited frame parses', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"crlf"}');
}
// --- end-to-end via readEventStream over a mock streaming Response --------
function mockResponse(chunks) {
const enc = new TextEncoder();
let i = 0;
return {
ok: true,
status: 200,
body: {
getReader() {
return {
read() {
if (i < chunks.length) {
return Promise.resolve({ value: enc.encode(chunks[i++]), done: false });
}
return Promise.resolve({ value: undefined, done: true });
},
releaseLock() {},
};
},
},
};
}
await (async () => {
// A realistic turn, deliberately chopped at ugly boundaries:
// - the session frame split mid-JSON
// - two text frames glued together
// - a tool frame
// - a result frame and the terminal done frame in one chunk
const chunks = [
'data: {"kind":"sess',
'ion","session_id":"S1"}\n\n',
'data: {"kind":"text","text":"checking "}\n\ndata: {"kind":"text","text":"disk"}\n\n',
'data: {"kind":"tool","name":"Bash","input":{"command":"df -h"}}\n\n',
'data: {"kind":"result","is_error":false,"result":"ok","duration_ms":12}\n\ndata: {"kind":"done"}\n\n',
];
const events = [];
await readEventStream(mockResponse(chunks), (e) => events.push(e));
eq('event count', events.length, 6);
eq('1: session id', events[0], { kind: 'session', session_id: 'S1' });
eq('2: first text', events[1], { kind: 'text', text: 'checking ' });
eq('3: second text', events[2], { kind: 'text', text: 'disk' });
eq('4: tool kind+name', { kind: events[3].kind, name: events[3].name }, { kind: 'tool', name: 'Bash' });
eq('4: tool command', events[3].input.command, 'df -h');
eq('5: result', events[4], { kind: 'result', is_error: false, result: 'ok', duration_ms: 12 });
eq('6: done terminal', events[5], { kind: 'done' });
})();
// malformed frame in the middle must be skipped, not abort the stream
await (async () => {
const chunks = [
'data: {"kind":"text","text":"before"}\n\n',
'data: {this is not json}\n\n',
'data: {"kind":"done"}\n\n',
];
const events = [];
await readEventStream(mockResponse(chunks), (e) => events.push(e));
eq('malformed frame skipped, stream continues', events.map((e) => e.kind), ['text', 'done']);
})();
if (failures) {
console.error(`\n${failures} assertion(s) FAILED`);
process.exit(1);
}
console.log('\nall SSE parser assertions passed');

9
frontend/src/main.js Normal file
View file

@ -0,0 +1,9 @@
import './app.css';
import App from './App.svelte';
import { mount } from 'svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;

View file

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

20
frontend/vite.config.js Normal file
View file

@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// The compiled SPA is emitted into the FastAPI app's static dir. FastAPI serves
// app/breakglass/static/index.html at "/" and mounts the directory, so the
// build output must be plain static files (plain Svelte, not SvelteKit).
//
// base: './' makes every asset reference relative, so the bundle loads no
// matter what path the edge proxy mounts the app under.
export default defineConfig({
plugins: [svelte()],
base: './',
build: {
outDir: '../app/breakglass/static',
emptyOutDir: true,
// Keep the asset graph small and predictable for an air-gapped cluster:
// no remote chunks, no CDN — everything bundled here.
assetsInlineLimit: 0,
},
});

174
tests/test_breakglass.py Normal file
View file

@ -0,0 +1,174 @@
"""Tests for the breakglass app: verb whitelist, SSE translation, auth, routes."""
import os
os.environ.setdefault("API_BEARER_TOKEN", "test-token")
import pytest
from fastapi.testclient import TestClient
from app.breakglass import agent_session, pve
from app.breakglass.server import app
# --------------------------------------------------------------------------- #
# PVE verb whitelist — the security boundary mirrored client-side.
# --------------------------------------------------------------------------- #
def test_allowed_verbs_match_host_script():
assert pve.ALLOWED_VERBS == {
"status", "forensics", "reset", "stop", "start", "cycle"
}
assert pve.MUTATING_VERBS == {"reset", "stop", "start", "cycle"}
assert pve.MUTATING_VERBS < pve.ALLOWED_VERBS
@pytest.mark.parametrize("bad", [
"rm -rf /", "status; rm -rf /", "status 103", "shutdown", "", "STATUS",
"cycle 999", "$(reboot)", "../start",
])
@pytest.mark.asyncio
async def test_run_verb_rejects_non_whitelisted_without_ssh(bad, monkeypatch):
"""A bad verb must be rejected locally — never spawning a subprocess."""
called = False
async def _boom(*a, **k):
nonlocal called
called = True
raise AssertionError("ssh must not run for a rejected verb")
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _boom)
result = await pve.run_verb(bad)
assert result["rejected"] is True
assert result["exit_code"] is None
assert called is False
@pytest.mark.asyncio
async def test_run_verb_allowed_invokes_ssh_with_bare_verb(monkeypatch):
captured = {}
class _FakeProc:
returncode = 0
async def communicate(self):
return (b"status: running\n", b"")
async def _fake_exec(*argv, **kwargs):
captured["argv"] = argv
return _FakeProc()
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _fake_exec)
result = await pve.run_verb("status")
assert result["rejected"] is False
assert result["exit_code"] == 0
assert "running" in result["stdout"]
# The verb is the LAST argv element, passed as a single token (no shell).
assert captured["argv"][-1] == "status"
assert captured["argv"][0] == "ssh"
# --------------------------------------------------------------------------- #
# stream-json -> UI event translation (pure function).
# --------------------------------------------------------------------------- #
def test_translate_init_to_session():
ev = agent_session.translate_event(
{"type": "system", "subtype": "init", "session_id": "abc"}
)
assert ev == {"kind": "session", "session_id": "abc"}
@pytest.mark.parametrize("noise", [
{"type": "system", "subtype": "hook_started"},
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 5},
{"type": "user", "message": {"content": []}},
{"type": "unknown"},
])
def test_translate_drops_noise(noise):
assert agent_session.translate_event(noise) is None
def test_translate_assistant_text():
ev = agent_session.translate_event({
"type": "assistant",
"message": {"content": [{"type": "text", "text": "checking disk"}]},
})
assert ev == {"kind": "text", "text": "checking disk"}
def test_translate_assistant_tool_use():
ev = agent_session.translate_event({
"type": "assistant",
"message": {"content": [
{"type": "tool_use", "name": "Bash", "input": {"command": "df -h"}}
]},
})
assert ev["kind"] == "tool"
assert ev["name"] == "Bash"
assert ev["input"]["command"] == "df -h"
def test_translate_result():
ev = agent_session.translate_event({
"type": "result", "is_error": False, "result": "done", "duration_ms": 1234,
})
assert ev == {"kind": "result", "is_error": False, "result": "done", "duration_ms": 1234}
# --------------------------------------------------------------------------- #
# Routes + auth.
# --------------------------------------------------------------------------- #
client = TestClient(app)
AUTH = {"Authorization": "Bearer test-token"}
def test_health_no_auth():
r = client.get("/health")
assert r.status_code == 200
assert r.json()["service"] == "claude-breakglass"
def test_api_requires_auth():
assert client.post("/api/session").status_code == 401
assert client.get("/api/pve/verbs").status_code == 401
def test_api_accepts_bearer():
r = client.post("/api/session", headers=AUTH)
assert r.status_code == 200
assert "session_id" in r.json()
def test_api_accepts_authentik_header():
r = client.post("/api/session", headers={"X-authentik-username": "me@viktorbarzin.me"})
assert r.status_code == 200
def test_pve_verb_route_rejects_unknown():
r = client.post("/api/pve/destroy", headers=AUTH)
assert r.status_code == 400
def test_pve_verbs_listing():
r = client.get("/api/pve/verbs", headers=AUTH)
assert r.status_code == 200
body = r.json()
assert set(body["verbs"]) == pve.ALLOWED_VERBS
assert set(body["mutating"]) == pve.MUTATING_VERBS
def test_chat_streams_sse(monkeypatch):
async def _fake_turn(session_id, prompt, model=None):
yield {"kind": "session", "session_id": session_id}
yield {"kind": "text", "text": "hello"}
yield {"kind": "result", "is_error": False, "result": "ok"}
monkeypatch.setattr(agent_session, "run_turn", _fake_turn)
r = client.post("/api/chat", headers=AUTH,
json={"session_id": "s1", "prompt": "diagnose"})
assert r.status_code == 200
assert "text/event-stream" in r.headers["content-type"]
body = r.text
assert "hello" in body
assert '"kind": "done"' in body # terminal frame always emitted