mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Add immutable Muse client assets and the grant-linked private contract. Keep the grant migration safe for replay and partial schema recovery, preserving exact preview receipts and existing data. Keep public wake signals separate from private provider authority. Refs #16447. Co-Authored-By: Paperclip <noreply@paperclip.ing>
149 lines
7.4 KiB
TypeScript
149 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { MUSE_ASSET_VERSION, MUSE_CLIENT_VERSION, MUSE_PUBLIC_ROUTES, museCommandSchema, musePairSchema, museQuerySchema } from "./muse-protocol.js";
|
|
import { paperclipQuestionSetPayloadSchema } from "./validators/issue.js";
|
|
|
|
const assignmentId = "00000000-0000-4000-8000-000000000001";
|
|
const requestId = "00000000-0000-4000-8000-000000000002";
|
|
const operation = { version: 1, assignmentId, requestId };
|
|
const questionSet = {
|
|
schema: "paperclip.question_set.v1",
|
|
questions: [{ id: "answer", prompt: "What should happen next?", required: true, answerMode: "text" }],
|
|
};
|
|
const nativeRequestEnvelopes = [
|
|
{
|
|
name: "request_user_input",
|
|
validate: (nativeRequestId: string) => museCommandSchema.safeParse({
|
|
...operation, command: "request_user_input", nativeRequestId, questionSet,
|
|
}),
|
|
},
|
|
{
|
|
name: "consume_input",
|
|
validate: (nativeRequestId: string) => museCommandSchema.safeParse({
|
|
...operation, command: "consume_input", nativeRequestId,
|
|
inputDigest: `sha256:${"a".repeat(64)}`,
|
|
continuationReceiptId: "00000000-0000-4000-8000-000000000003",
|
|
continuationPersisted: true,
|
|
}),
|
|
},
|
|
{
|
|
name: "input.pending",
|
|
validate: (nativeRequestId: string) => museQuerySchema.safeParse({
|
|
version: 1, query: "input.pending", assignmentId, nativeRequestId,
|
|
}),
|
|
},
|
|
];
|
|
|
|
describe("Muse native request identity bounds", () => {
|
|
it.each(nativeRequestEnvelopes)("accepts the 160-byte ASCII boundary for $name", ({ validate }) => {
|
|
expect(validate("Ab09._:-".repeat(20)).success).toBe(true);
|
|
});
|
|
|
|
it.each(nativeRequestEnvelopes)("rejects identities Runner cannot accept for $name", ({ validate }) => {
|
|
for (const value of ["", "a".repeat(161), "request id", "request/1", "é", "回答", "request\n", "request\u0000", ".question", "_question", ":question", "-question"]) {
|
|
expect(validate(value).success, JSON.stringify(value)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Muse progress UTF-8 bounds", () => {
|
|
const validate = (text: string) => museCommandSchema.safeParse({ ...operation, command: "progress", text });
|
|
|
|
it("accepts exactly 12000 ASCII bytes and rejects the next byte", () => {
|
|
expect(validate("a".repeat(12000)).success).toBe(true);
|
|
expect(validate("a".repeat(12001)).success).toBe(false);
|
|
});
|
|
|
|
it.each(["é", "答", "😀"])("bounds %s by UTF-8 bytes rather than JavaScript length", (character) => {
|
|
const bytes = new TextEncoder().encode(character).byteLength;
|
|
const boundary = character.repeat(12000 / bytes);
|
|
expect(validate(boundary).success).toBe(true);
|
|
expect(validate(`${boundary}a`).success).toBe(false);
|
|
});
|
|
|
|
it("retains trimmed text semantics at the byte boundary and rejects blank text", () => {
|
|
const text = "é".repeat(6000);
|
|
const parsed = validate(` \n${text}\t `);
|
|
expect(parsed.success).toBe(true);
|
|
if (parsed.success && parsed.data.command === "progress") expect(parsed.data.text).toBe(text);
|
|
expect(validate(" \n\t ").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
|
|
it("keeps installed-version declarations finite and backward compatible", () => {
|
|
expect(MUSE_ASSET_VERSION).toBe(9);
|
|
expect(MUSE_CLIENT_VERSION).toBe("9");
|
|
expect(museQuerySchema.parse({ version: 1, query: "identify" })).toEqual({ version: 1, query: "identify" });
|
|
for (const clientVersion of ["2", "3", "4", "5", "6", "7", "8", "9"]) {
|
|
expect(museQuerySchema.parse({ version: 1, query: "identify", clientVersion })).toMatchObject({ clientVersion });
|
|
expect(musePairSchema.parse({ version: 1, ticket: "synthetic-unused-ticket", clientVersion })).toMatchObject({ clientVersion });
|
|
}
|
|
for (const clientVersion of ["1", "10"]) {
|
|
expect(() => museQuerySchema.parse({ version: 1, query: "identify", clientVersion })).toThrow();
|
|
expect(() => musePairSchema.parse({ version: 1, ticket: "synthetic-unused-ticket", clientVersion })).toThrow();
|
|
}
|
|
});
|
|
|
|
it("exposes only the exact immutable asset paths for all nine versions", () => {
|
|
expect(MUSE_PUBLIC_ROUTES).toHaveLength(48);
|
|
expect(new Set(MUSE_PUBLIC_ROUTES.map(({ method, path }) => `${method} ${path}`)).size).toBe(48);
|
|
expect(MUSE_PUBLIC_ROUTES.filter(({ path }) => path.includes("/assets/"))).toEqual(
|
|
[1, 2, 3, 4, 5, 6, 7, 8, 9].flatMap((version) => ["manifest.json", "client.py", "detector.sh", "instructions.md"].map((name) => ({
|
|
method: "GET", path: `/api/muse/v1/assets/${version}/${name}`,
|
|
}))),
|
|
);
|
|
});
|
|
|
|
describe("Muse native question-set durable byte bound", () => {
|
|
const encoder = new TextEncoder();
|
|
const validate = (questionSet: unknown) => museCommandSchema.safeParse({
|
|
...operation, command: "request_user_input", nativeRequestId: "scope-01", questionSet,
|
|
});
|
|
const body = (initialText: string) => ({ ...questionSet, title: "t".repeat(1000),
|
|
description: "d".repeat(100000), questions: [{ ...questionSet.questions[0], initialText }] });
|
|
|
|
it("accepts the exact serialized 196 KiB boundary and rejects one more byte", () => {
|
|
const length = 196 * 1024 - encoder.encode(JSON.stringify(body(""))).byteLength;
|
|
const boundary = body("a".repeat(length));
|
|
expect(encoder.encode(JSON.stringify(boundary))).toHaveLength(196 * 1024);
|
|
expect(validate(boundary).success).toBe(true);
|
|
expect(validate(body("a".repeat(length + 1))).success).toBe(false);
|
|
});
|
|
|
|
it.each(["é", "答", "😀", "\n", '"'])("counts UTF-8 and JSON escaping for %s", character => {
|
|
const budget = 196 * 1024 - encoder.encode(JSON.stringify(body(""))).byteLength;
|
|
const unit = encoder.encode(JSON.stringify(character)).byteLength - 2;
|
|
const count = Math.floor(budget / unit), padding = "a".repeat(budget % unit);
|
|
expect(validate(body(character.repeat(count) + padding)).success).toBe(true);
|
|
expect(validate(body(character.repeat(count) + padding + "a")).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Muse bridge-supported question admission", () => {
|
|
const validate = (questionSet: unknown) => museCommandSchema.safeParse({
|
|
...operation, command: "request_user_input", nativeRequestId: "1question._:-", questionSet,
|
|
});
|
|
|
|
it.each(["^[0-9]+$", "", "(a+)+$"])("rejects provider pattern %j while the generic canonical form remains valid", pattern => {
|
|
const payload = { ...questionSet, questions: [{ ...questionSet.questions[0], textValidation: { pattern } }] };
|
|
expect(paperclipQuestionSetPayloadSchema.safeParse(payload).success).toBe(true);
|
|
expect(validate(payload).success).toBe(false);
|
|
});
|
|
|
|
it.each([{ minLength: 2, maxLength: 1 }, { minimum: 2, maximum: 1 }])("rejects conflicting canonical bounds %j", textValidation => {
|
|
expect(validate({ ...questionSet, questions: [{ ...questionSet.questions[0], textValidation }] }).success).toBe(false);
|
|
});
|
|
|
|
it("preserves independent question and option identities plus supported text and choice forms", () => {
|
|
const payload = { ...questionSet, questions: [
|
|
{ id: ".question", prompt: "Amount?", required: true, answerMode: "text", initialText: "2",
|
|
textValidation: { inputType: "integer", minLength: 0, maxLength: 3, minimum: 1, maximum: 10 } },
|
|
{ id: "é choice", prompt: "Choose", required: false, answerMode: "single_select",
|
|
options: [{ id: "_option", label: "Test" }, { id: "option space", label: "Production" }], customAnswer: { enabled: true } },
|
|
] };
|
|
const result = validate(payload);
|
|
expect(result.success).toBe(true);
|
|
if (result.success && result.data.command === "request_user_input") expect(result.data.questionSet).toEqual(payload);
|
|
});
|
|
});
|