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>
152 lines
5.4 KiB
JavaScript
152 lines
5.4 KiB
JavaScript
// 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');
|