e2e: integration harness (69/69 green) + fix /agent/ws missing bearer auth (BUG-1)

This commit is contained in:
Raphael Westphal
2026-08-18 14:13:15 +02:00
parent 6ba718730e
commit ecb91e941c
17 changed files with 1129 additions and 2 deletions
+78
View File
@@ -0,0 +1,78 @@
// scenarios/agent-lifecycle.mjs — full plugin lifecycle against the real
// daemon: hello→welcome, persisted events, online flag, events after disconnect.
import { agentClient, agentEvent, agentHello, rest, sessionSnapshot, sid, waitUntil } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token, agentUrl } = ctx;
const id = sid("e2e-life");
const snapshot = sessionSnapshot(id);
const agent = await agentClient(agentUrl, token);
const welcome = await agentHello(agent, snapshot);
r.check("hello → welcome received", welcome !== null && welcome.type === "welcome");
r.check("welcome echoes sessionId", welcome?.sessionId === id, JSON.stringify(welcome ?? null));
r.check("welcome lastSeq 0 on fresh session", welcome?.lastSeq === 0, `got ${welcome?.lastSeq}`);
r.check("welcome envelope v=1", welcome?.v === 1);
agentEvent(agent, id, 1, "message_end", {
message: { role: "user", id: "m1", text: "hello from e2e", thinking: null, toolCalls: [], toolCallId: null },
});
agentEvent(agent, id, 2, "tool_execution_start", { toolCallId: "tc1", toolName: "bash", args: { command: "ls" } });
agentEvent(agent, id, 3, "tool_execution_end", { toolCallId: "tc1", toolName: "bash", isError: false, resultPreview: "file1\nfile2" });
agentEvent(agent, id, 4, "agent_end", { usage: { inputTokens: 10, outputTokens: 5, totalCost: 0.25 } });
const listUrl = "/api/sessions";
const online = await waitUntil(async () => {
const res = await rest(base, token, listUrl);
return res.json?.find((s) => s.id === id)?.online === true;
});
r.check("GET /api/sessions shows online=true while agent connected", online);
const listed = (await rest(base, token, listUrl)).json.find((s) => s.id === id);
r.check(
"session snapshot fields exposed",
listed?.cwd === "/work/e2e" &&
listed?.model === "glm-5.3" &&
listed?.provider === "zai-renaud" &&
listed?.agent === false &&
listed?.repo === null &&
typeof listed?.startedAt === "number" &&
typeof listed?.lastEventAt === "number",
JSON.stringify(listed ?? null),
);
agent.close();
const offline = await waitUntil(async () => {
const res = await rest(base, token, listUrl);
return res.json?.find((s) => s.id === id)?.online === false;
});
r.check("online=false after agent disconnect", offline);
const eventsUrl = `/api/sessions/${id}/events?after=0`;
const persisted = await waitUntil(
async () => (await rest(base, token, eventsUrl)).json?.length === 4,
);
r.check("all 4 persisted events served after=0", persisted);
const ev = (await rest(base, token, eventsUrl)).json ?? [];
r.check(
"event seqs ascending [1,2,3,4]",
JSON.stringify(ev.map((e) => e.seq)) === "[1,2,3,4]",
JSON.stringify(ev.map((e) => e.seq)),
);
r.check(
"event types in order",
JSON.stringify(ev.map((e) => e.type)) ===
JSON.stringify(["message_end", "tool_execution_start", "tool_execution_end", "agent_end"]),
JSON.stringify(ev.map((e) => e.type)),
);
r.check(
"persisted frames re-serialized with envelope",
ev[0]?.v === 1 && ev[0]?.sessionId === id && typeof ev[0]?.ts === "number" && ev[0]?.message?.text === "hello from e2e",
JSON.stringify(ev[0] ?? null),
);
const tail = (await rest(base, token, `/api/sessions/${id}/events?after=2`)).json ?? [];
r.check("events?after=2 returns only seq≥3", JSON.stringify(tail.map((e) => e.seq)) === "[3,4]", JSON.stringify(tail.map((e) => e.seq)));
ctx.state.lifecycle = { id, snapshot, lastSeq: 4 };
}
+42
View File
@@ -0,0 +1,42 @@
// scenarios/auth.mjs — bearer auth seams: REST 401s, WS rejection.
// PROTOCOL.md §Auth: "All requests (WS upgrade and REST) carry
// Authorization: Bearer … Daemon rejects with 401 (REST) or closes the WS
// (upgrade) on mismatch."
import { agentHello, rest, sessionSnapshot, sid, WSSock } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token, agentUrl, webUrl } = ctx;
const noToken = await rest(base, null, "/api/sessions");
r.check("REST without token → 401", noToken.status === 401, `got ${noToken.status}`);
const badToken = await rest(base, "definitely-wrong", "/api/sessions");
r.check("REST with bad token → 401", badToken.status === 401, `got ${badToken.status}`);
const goodToken = await rest(base, token, "/api/sessions");
r.check("REST with correct token → 200", goodToken.status === 200, `got ${goodToken.status}`);
// Web WS: token arrives as ?token= query param (browsers cannot set headers).
const badWeb = new WSSock(`${webUrl}?token=wrong-token`);
const webOpened = await badWeb.opened();
r.check("web WS with bad ?token= rejected before upgrade", !webOpened, "connection accepted");
badWeb.close();
// Agent WS with bad bearer must be rejected: PROTOCOL.md §Auth requires
// closing the upgrade on mismatch (fixed: Routes() wraps /agent/ws in
// s.bearerAuth).
const badAgent = new WSSock(agentUrl, { headers: { Authorization: `Bearer wrong-token` } });
const agentOpened = await badAgent.opened();
let welcomed = null;
if (agentOpened) {
agentHello(badAgent, sessionSnapshot(sid("e2e-auth-bad")));
welcomed = await badAgent.waitForFrame((f) => f.type === "welcome");
}
r.check(
"agent WS with bad token rejected (closed before welcome)",
!agentOpened || welcomed === null,
welcomed ? "welcome received despite wrong bearer" : "",
);
badAgent.close();
}
+60
View File
@@ -0,0 +1,60 @@
// scenarios/gitlab.mjs — PAT connect against the fake GitLab (started by the
// driver before daemon boot; its URL is in GITLAB_BASE_URL), project mapping,
// status, and the PAT-leak guarantee.
import { rest } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token, gitlab } = ctx;
const connect = await rest(base, token, "/api/gitlab/connect", {
method: "POST",
body: { token: gitlab.pat },
});
r.check(
"POST /api/gitlab/connect → 200 {username}",
connect.status === 200 && connect.json?.username === "e2e-user",
`got ${connect.status} ${connect.text}`,
);
r.check(
"daemon validated PAT against fake /api/v4/user",
gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === gitlab.pat,
JSON.stringify(gitlab.seen.userAuths),
);
const status = await rest(base, token, "/api/gitlab/status");
r.check(
"status shows connected with username + baseUrl",
status.json?.connected === true && status.json?.username === "e2e-user" && status.json?.baseUrl === gitlab.url,
JSON.stringify(status.json),
);
const repos = await rest(base, token, "/api/gitlab/repos");
r.check(
"repos mapped to protocol shape",
Array.isArray(repos.json) && repos.json.length === 2 &&
repos.json.every((p) => typeof p.path === "string" && typeof p.name === "string" && typeof p.namespace === "string" && typeof p.lastActivityAt === "string" && typeof p.webUrl === "string" && typeof p.defaultBranch === "string"),
JSON.stringify(repos.json ?? null),
);
r.check(
"repos sorted by lastActivityAt desc (daemon-side sort)",
repos.json?.[0]?.path === "lvmh/alpha" && repos.json?.[1]?.path === "lvmh/beta" &&
repos.json?.[0]?.defaultBranch === "trunk",
JSON.stringify((repos.json ?? []).map((p) => p.path)),
);
const leaky = [connect.text, status.text, repos.text].filter((t) => t.includes(gitlab.pat));
r.check("PAT never appears in any daemon response", leaky.length === 0, `leaked in ${leaky.length} response(s)`);
const badConnect = await rest(base, token, "/api/gitlab/connect", {
method: "POST",
body: { token: "glpat-wrong" },
});
r.check("connect with bad PAT → error status", badConnect.status >= 400, `got ${badConnect.status}`);
const statusAfter = await rest(base, token, "/api/gitlab/status");
r.check(
"failed connect does not clobber the stored PAT",
statusAfter.json?.connected === true && statusAfter.json?.username === "e2e-user",
JSON.stringify(statusAfter.json),
);
}
+94
View File
@@ -0,0 +1,94 @@
// scenarios/prompt-routing.mjs — web-side subscribe → live event fan-out
// (including unpersisted message_update), REST prompt routed to the agent WS,
// 409 when the agent is offline.
import { agentClient, agentEvent, agentHello, rest, sessionSnapshot, sid, waitUntil, webClient } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token, agentUrl, webUrl, state } = ctx;
const id = sid("e2e-prompt");
const snapshot = sessionSnapshot(id, { name: "prompt-routing", repo: "e2e/harness" });
const browser = await webClient(webUrl, token);
const initList = await browser.waitForFrame((f) => f.type === "session_list");
r.check("web WS sends initial session_list on connect", initList !== null);
browser.send({ type: "subscribe", sessionId: id });
const agent = await agentClient(agentUrl, token);
const welcome = await agentHello(agent, snapshot);
r.check("agent handshake under active web subscription", welcome?.type === "welcome");
// session change (hello registers the session) must reach the browser.
const listed = await browser.waitForFrame(
(f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id),
);
r.check("session_list frame carries the new session", listed !== null);
const listedRow = listed?.sessions?.find((s) => s.id === id);
r.check(
"session_list row matches hello snapshot",
listedRow?.name === "prompt-routing" && listedRow?.repo === "e2e/harness" && listedRow?.online === true,
JSON.stringify(listedRow ?? null),
);
// Live fan-out: persisted + unpersisted kinds both batched to the subscriber.
agentEvent(agent, id, 1, "message_start", { message: { role: "assistant", id: "m1" } });
agentEvent(agent, id, 2, "message_update", { delta: "live delta one " });
agentEvent(agent, id, 3, "message_update", { delta: "live delta two" });
agentEvent(agent, id, 4, "message_end", {
message: { role: "assistant", id: "m1", text: "live delta one live delta two", thinking: null, toolCalls: [], toolCallId: null },
});
const frame = await browser.waitForFrame((f) => f.type === "events" && f.sessionId === id);
r.check("events frame received for subscribed session", frame !== null);
// Deltas flush in ≤40ms batches; wait until the seq-4 message_end fan-out lands.
const fanoutDone = await browser.waitForFrame(
(f) => f.type === "events" && f.sessionId === id && (f.events ?? []).some((e) => e.seq === 4),
);
r.check("browser received events through message_end (seq 4)", fanoutDone !== null);
const all = browser.frames
.filter((f) => f.type === "events" && f.sessionId === id)
.flatMap((f) => f.events ?? []);
r.check(
"browser saw live message_update deltas (seq 2,3)",
all.some((e) => e.seq === 2 && e.type === "message_update" && e.delta === "live delta one ") &&
all.some((e) => e.seq === 3 && e.delta === "live delta two"),
JSON.stringify(all.map((e) => `${e.seq}:${e.type}`)),
);
const evFrames = browser.frames.filter((f) => f.type === "events" && f.sessionId === id);
r.check(
"events frame after = first seq - 1",
evFrames.every((f) => f.after === (f.events?.[0]?.seq ?? f.after + 1) - 1),
JSON.stringify(evFrames.map((f) => f.after)),
);
// Prompt routing: REST → agent WS.
const prompted = await rest(base, token, `/api/sessions/${id}/prompt`, {
method: "POST",
body: { message: "e2e routed prompt" },
});
r.check("POST prompt while online → 200 {ok:true}", prompted.status === 200 && prompted.json?.ok === true, JSON.stringify(prompted.json));
const promptFrame = await agent.waitForFrame((f) => f.type === "prompt");
r.check("agent received prompt frame", promptFrame !== null);
r.check(
"prompt frame envelope + payload",
promptFrame?.v === 1 && promptFrame?.sessionId === id && promptFrame?.message === "e2e routed prompt" && typeof promptFrame?.promptId === "string",
JSON.stringify(promptFrame ?? null),
);
agent.close();
const goneOffline = await waitUntil(async () => {
const res = await rest(base, token, "/api/sessions");
return res.json?.find((s) => s.id === id)?.online === false;
});
r.check("agent offline after close", goneOffline);
const rejected = await rest(base, token, `/api/sessions/${id}/prompt`, {
method: "POST",
body: { message: "nobody home" },
});
r.check("POST prompt while offline → 409", rejected.status === 409, `got ${rejected.status}`);
browser.close();
state.prompt = { id };
}
+55
View File
@@ -0,0 +1,55 @@
// scenarios/replay.mjs — reconnect handshake: welcome.lastSeq reflects
// persisted events; seq continuation; message_update streamed but never
// persisted.
import { agentClient, agentEvent, agentHello, rest, sid, waitUntil } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token, agentUrl, state } = ctx;
const life = state.lifecycle; // set by agent-lifecycle (seq 1..4 persisted)
const id = life.id;
const agent = await agentClient(agentUrl, token);
const welcome = await agentHello(agent, life.snapshot);
r.check(
`reconnect welcome lastSeq=${life.lastSeq} (persisted high-water)`,
welcome?.lastSeq === life.lastSeq,
`got ${welcome?.lastSeq}, want ${life.lastSeq}`,
);
agentEvent(agent, id, 5, "message_update", { delta: "streaming text that must not persist" });
agentEvent(agent, id, 6, "message_end", {
message: { role: "assistant", id: "m2", text: "assistant final", thinking: null, toolCalls: [], toolCallId: null },
});
agentEvent(agent, id, 7, "agent_settled", {});
const settled = await waitUntil(
async () => (await rest(base, token, `/api/sessions/${id}/events?after=6`)).json?.length === 1,
);
r.check("seq 6..7 persisted (message_update skipped)", settled);
agent.close();
const after1 = (await rest(base, token, `/api/sessions/${id}/events?after=1`)).json ?? [];
r.check(
"events?after=1 returns only seq≥2",
JSON.stringify(after1.map((e) => e.seq)) === "[2,3,4,6,7]",
JSON.stringify(after1.map((e) => e.seq)),
);
r.check(
"message_update (seq 5) NOT persisted",
!after1.some((e) => e.type === "message_update" || e.seq === 5),
JSON.stringify(after1.map((e) => `${e.seq}:${e.type}`)),
);
r.check(
"REST events unaffected by skipped message_update",
after1.some((e) => e.type === "message_end" && e.message?.text === "assistant final"),
);
// Fresh reconnect: lastSeq must now cover everything persisted.
const agent2 = await agentClient(agentUrl, token);
const welcome2 = await agentHello(agent2, life.snapshot);
r.check("welcome lastSeq advanced to 7", welcome2?.lastSeq === 7, `got ${welcome2?.lastSeq}`);
agent2.close();
state.replay = { id };
}
+77
View File
@@ -0,0 +1,77 @@
// scenarios/resilience.mjs — daemon crash (SIGKILL of the go process group)
// and reboot on the SAME sqlite file: every persisted event survives, sessions
// come back listed offline, and seq continues past the persisted high-water.
import { agentClient, agentEvent, agentHello, rest, sessionSnapshot, sid, waitUntil } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token, agentUrl, state } = ctx;
const id = sid("e2e-res");
const marker = `persist-me-${sid("m")}`;
const snapshot = sessionSnapshot(id);
const agent = await agentClient(agentUrl, token);
const w1 = await agentHello(agent, snapshot);
r.check("pre-restart handshake", w1?.type === "welcome");
agentEvent(agent, id, 1, "message_end", {
message: { role: "user", id: "mr1", text: marker, thinking: null, toolCalls: [], toolCallId: null },
});
agentEvent(agent, id, 2, "tool_execution_start", { toolCallId: "tc9", toolName: "bash", args: { command: "true" } });
agentEvent(agent, id, 3, "agent_settled", {});
const flushed = await waitUntil(
async () => (await rest(base, token, `/api/sessions/${id}/events?after=0`)).json?.length === 3,
);
r.check("events flushed to sqlite before crash", flushed);
agent.close();
let restarted = true;
let restartDetail = "";
try {
await ctx.restartDaemon();
} catch (err) {
restarted = false;
restartDetail = String(err?.message ?? err);
}
r.check("daemon restarted on same DB after SIGKILL", restarted, restartDetail);
const sessions = (await rest(base, token, "/api/sessions")).json ?? [];
const row = sessions.find((s) => s.id === id);
r.check(
"persisted session listed with online=false after restart",
row !== undefined && row.online === false,
JSON.stringify(row ?? null),
);
r.check(
"pre-restart sessions from other scenarios also listed offline",
sessions.some((s) => s.id === state.lifecycle?.id && s.online === false) &&
sessions.every((s) => s.online === false),
JSON.stringify(sessions.map((s) => [s.id, s.online])),
);
const events = (await rest(base, token, `/api/sessions/${id}/events?after=0`)).json ?? [];
r.check(
"all 3 events survive the crash",
JSON.stringify(events.map((e) => e.seq)) === "[1,2,3]",
JSON.stringify(events.map((e) => e.seq)),
);
r.check(
"event payload intact (message text marker)",
events[0]?.message?.text === marker,
JSON.stringify(events[0] ?? null),
);
const tail = (await rest(base, token, `/api/sessions/${id}/events?after=2`)).json ?? [];
r.check("after=2 still only seq 3 after restart", JSON.stringify(tail.map((e) => e.seq)) === "[3]");
// Reconnect after restart: the persisted high-water must be honoured.
const agent2 = await agentClient(agentUrl, token);
const w2 = await agentHello(agent2, snapshot);
r.check("post-restart welcome lastSeq=3", w2?.lastSeq === 3, `got ${w2?.lastSeq}`);
agentEvent(agent2, id, 4, "message_end", {
message: { role: "user", id: "mr2", text: "after restart", thinking: null, toolCalls: [], toolCallId: null },
});
const continued = await waitUntil(
async () => (await rest(base, token, `/api/sessions/${id}/events?after=3`)).json?.length === 1,
);
r.check("seq continues past persisted high-water after restart", continued);
agent2.close();
}
+38
View File
@@ -0,0 +1,38 @@
// scenarios/session-list.mjs — every connected web client gets session_list
// broadcasts on session state changes (agent hello / disconnect), including a
// client that subscribed after the session already existed.
import { agentClient, agentHello, sessionSnapshot, sid, webClient } from "../lib.mjs";
export async function run(ctx) {
const { r, token, agentUrl, webUrl } = ctx;
const id = sid("e2e-list");
const first = await webClient(webUrl, token);
const initA = await first.waitForFrame((f) => f.type === "session_list");
r.check("first web client gets initial session_list", initA !== null);
const second = await webClient(webUrl, token);
const initB = await second.waitForFrame((f) => f.type === "session_list");
r.check("second web client gets initial session_list", initB !== null);
const agent = await agentClient(agentUrl, token);
await agentHello(agent, sessionSnapshot(id));
const onA = await first.waitForFrame(
(f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id && s.online === true),
);
r.check("first client receives session_list on new session", onA !== null);
const onB = await second.waitForFrame(
(f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id && s.online === true),
);
r.check("second subscriber also receives the broadcast", onB !== null);
agent.close();
const offlineB = await second.waitForFrame(
(f) => f.type === "session_list" && f.sessions?.some((s) => s.id === id && s.online === false),
);
r.check("session_list broadcast shows online=false after agent disconnect", offlineB !== null);
first.close();
second.close();
}
+34
View File
@@ -0,0 +1,34 @@
// scenarios/spawn-validation.mjs — spawn request validation and status shape.
// Deliberately does NOT POST a valid repo: that path needs Docker, which the
// harness must not require.
import { rest, TEST_TOKEN } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token } = ctx;
const noSlash = await rest(base, token, "/api/spawn", { method: "POST", body: { repo: "nosuchthing" } });
r.check(
"spawn repo without path separator → 400",
noSlash.status === 400 && typeof noSlash.json?.error === "string",
`got ${noSlash.status} ${noSlash.text}`,
);
const badChars = await rest(base, token, "/api/spawn", { method: "POST", body: { repo: "grp/prj!bad" } });
r.check("spawn repo with invalid chars → 400", badChars.status === 400, `got ${badChars.status}`);
const malformed = await fetch(`${base}/api/spawn`, {
method: "POST",
headers: { Authorization: `Bearer ${TEST_TOKEN}`, "Content-Type": "application/json" },
body: "{not json",
});
r.check("spawn with malformed JSON body → 400", malformed.status === 400, `got ${malformed.status}`);
await malformed.text();
const status = await rest(base, token, "/api/spawn/status");
r.check(
"GET /api/spawn/status → 200 array",
status.status === 200 && Array.isArray(status.json),
`got ${status.status} ${status.text}`,
);
}
+45
View File
@@ -0,0 +1,45 @@
// scenarios/web-dist.mjs — the daemon serves the REAL built web UI from
// --webdist: index, hashed assets, manifest, SPA fallback.
import * as fs from "node:fs";
import * as path from "node:path";
import { WEB_DIST } from "../lib.mjs";
export async function run(ctx) {
const { r, base } = ctx;
const index = await fetch(`${base}/`);
const indexText = await index.text();
r.check("GET / → 200 index.html", index.status === 200 && indexText.includes("<!doctype html>"), `got ${index.status}`);
r.check(
"index references built assets",
/src="\/assets\/[^"]+\.js"/.test(indexText) && indexText.includes('id="root"'),
indexText.slice(0, 120),
);
const asset = fs.readdirSync(path.join(WEB_DIST, "assets")).find((f) => f.endsWith(".js"));
r.check("dist contains a hashed JS asset", Boolean(asset));
const assetRes = await fetch(`${base}/assets/${asset}`);
const assetText = await assetRes.text();
r.check(
"GET /assets/<bundle>.js → 200 javascript",
assetRes.status === 200 && (assetRes.headers.get("content-type") ?? "").includes("javascript") && assetText.length > 1000,
`got ${assetRes.status} ${(assetRes.headers.get("content-type") ?? "")}`,
);
const manifest = await fetch(`${base}/manifest.webmanifest`);
const manifestJson = await manifest.json().catch(() => null);
r.check(
"GET /manifest.webmanifest → 200 with name field",
manifest.status === 200 && manifestJson?.name === "lvmh",
`got ${manifest.status}`,
);
const spa = await fetch(`${base}/sessions/anything/deep`);
const spaText = await spa.text();
r.check(
"SPA fallback serves index.html for unknown routes",
spa.status === 200 && spaText.includes("<!doctype html>"),
`got ${spa.status}`,
);
}