mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c2950e709f
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.
103 lines
4.2 KiB
TypeScript
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);
|
|
});
|
|
});
|