Files
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00

103 lines
4.2 KiB
TypeScript

import { describe, expect, test, mock } from "bun:test";
import { createExternalAnnotationHandler } from "./external-annotations";
describe("external annotations SSE", () => {
test("disables idle timeout for stream requests", async () => {
const handler = createExternalAnnotationHandler("plan");
const disableIdleTimeout = mock(() => {});
const res = await handler.handle(
new Request("http://localhost/api/external-annotations/stream"),
new URL("http://localhost/api/external-annotations/stream"),
{ disableIdleTimeout },
);
expect(disableIdleTimeout).toHaveBeenCalledTimes(1);
expect(res?.headers.get("content-type")).toBe("text/event-stream");
});
});
describe("PATCH /api/external-annotations", () => {
test("cannot clear or change the source marker (skill-injection guard, reproduced end-to-end)", async () => {
const handler = createExternalAnnotationHandler("review");
const added = handler.addAnnotations({
source: "rogue-agent",
scope: "general",
text: "apply $some-human-only-skill",
});
if ("error" in added) throw new Error(added.error);
const [id] = added.ids;
const patch = async (body: unknown) => {
const url = `http://localhost/api/external-annotations?id=${encodeURIComponent(id)}`;
const res = await handler.handle(
new Request(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
new URL(url),
);
expect(res?.status).toBe(200);
return (await res!.json()) as { annotation: { source?: string; text?: string } };
};
// The reproduced bypass: PATCH {"source": ""} cleared the field and
// re-armed verbatim SKILL.md injection for a tool-submitted comment.
const cleared = await patch({ source: "" });
expect(cleared.annotation.source).toBe("rogue-agent");
const swapped = await patch({ source: "innocent" });
expect(swapped.annotation.source).toBe("rogue-agent");
const nulled = await patch({ source: null });
expect(nulled.annotation.source).toBe("rogue-agent");
// Legitimate field patches still work, with source intact.
const edited = await patch({ text: "edited text" });
expect(edited.annotation.text).toBe("edited text");
expect(edited.annotation.source).toBe("rogue-agent");
});
// PATCH merges arbitrary fields, so it was the one way to create an
// inReplyTo self-reference or cycle (which the export used to drop while
// still counting). The invalid state is refused at ingest.
test("refuses an inReplyTo that is self, missing, or would close a cycle; accepts a valid reply", async () => {
const handler = createExternalAnnotationHandler("plan");
const added = handler.addAnnotations({
annotations: [
{ source: "tool", text: "first" },
{ source: "tool", text: "second" },
],
});
if ("error" in added) throw new Error(added.error);
const [first, second] = added.ids;
const patch = async (id: string, body: unknown) => {
const url = `http://localhost/api/external-annotations?id=${encodeURIComponent(id)}`;
const res = await handler.handle(
new Request(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
new URL(url),
);
return { status: res!.status, body: (await res!.json()) as { error?: string; annotation?: { inReplyTo?: string } } };
};
expect((await patch(first, { inReplyTo: first })).status).toBe(400);
expect((await patch(first, { inReplyTo: "nope" })).status).toBe(400);
expect((await patch(first, { inReplyTo: 7 })).status).toBe(400);
const ok = await patch(second, { inReplyTo: first });
expect(ok.status).toBe(200);
expect(ok.body.annotation?.inReplyTo).toBe(first);
// second -> first is in place; first -> second would close the loop.
const cycle = await patch(first, { inReplyTo: second });
expect(cycle.status).toBe(400);
expect(cycle.body.error).toContain("cycle");
// Clearing stays allowed, and an unrelated patch does not touch the field.
expect((await patch(second, { inReplyTo: null })).status).toBe(200);
expect((await patch(second, { text: "still fine" })).status).toBe(200);
});
});