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
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -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
57
agents/breakglass.md
Normal 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.
|
||||
10
app/breakglass/__init__.py
Normal file
10
app/breakglass/__init__.py
Normal 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``.
|
||||
"""
|
||||
145
app/breakglass/agent_session.py
Normal file
145
app/breakglass/agent_session.py
Normal 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
36
app/breakglass/auth.py
Normal 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
36
app/breakglass/config.py
Normal 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
89
app/breakglass/pve.py
Normal 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
96
app/breakglass/server.py
Normal 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")
|
||||
1
app/breakglass/static/assets/index-DKeuidum.css
Normal file
1
app/breakglass/static/assets/index-DKeuidum.css
Normal file
File diff suppressed because one or more lines are too long
17
app/breakglass/static/assets/index-DNECe1Jo.js
Normal file
17
app/breakglass/static/assets/index-DNECe1Jo.js
Normal file
File diff suppressed because one or more lines are too long
15
app/breakglass/static/index.html
Normal file
15
app/breakglass/static/index.html
Normal 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>
|
||||
51
docker-entrypoint-breakglass.sh
Normal file
51
docker-entrypoint-breakglass.sh
Normal 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
14
frontend/index.html
Normal 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
1179
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal 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
272
frontend/src/App.svelte
Normal 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
494
frontend/src/Chat.svelte
Normal 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 & 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>
|
||||
54
frontend/src/ToolChip.svelte
Normal file
54
frontend/src/ToolChip.svelte
Normal 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>
|
||||
562
frontend/src/VmControls.svelte
Normal file
562
frontend/src/VmControls.svelte
Normal 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
126
frontend/src/app.css
Normal 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
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),
|
||||
};
|
||||
}
|
||||
150
frontend/src/lib/sse.js
Normal file
150
frontend/src/lib/sse.js
Normal 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);
|
||||
}
|
||||
152
frontend/src/lib/sse.test.mjs
Normal file
152
frontend/src/lib/sse.test.mjs
Normal 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
9
frontend/src/main.js
Normal 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;
|
||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal 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
174
tests/test_breakglass.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue