Files
PaperClipAI/server/src/__tests__/muse-client-contract.test.ts
T
DottaandPaperclip e6dcc6b095 Connect personal Muse through secure native execution and Vault OAuth (#16448)
Add company-scoped Muse native control, OAuth, wake receiver and recovery.

Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance.

Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup.

Refs #16448.

Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules.

Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-17 22:44:36 -05:00

183 lines
12 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { parsePaperclipQuestionResponse } from "@paperclipai/paperclip-runner";
import { buildMuseSetupInstruction, museReceiverAssets } from "../services/muse-setup.js";
import { fileURLToPath } from "node:url";
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import type { Db } from "@paperclipai/db";
import { museTransportRoutes } from "../routes/muse.js";
// Asset routing must not create a credential, touch a DB or start a receiver.
vi.mock("../modules/external-agents/index.js", () => ({ museExternalAgents: () => ({}) }));
vi.mock("../services/muse-identity.js", () => ({ museIdentity: () => ({}) }));
vi.mock("../services/muse-receiver.js", () => ({ museReceiver: () => ({}) }));
/** Execute the shipped Python client, with synthetic transport responses and private temporary state. */
describe("personal Muse client durability", () => {
it("preserves identities, private credentials, and input receipts across transport failures", () => {
execFileSync("python3", [
fileURLToPath(new URL("./fixtures/muse-client-contract.py", import.meta.url)),
fileURLToPath(new URL("../services/scripts/v9/client.py", import.meta.url)),
], { encoding: "utf8", timeout: 30_000 });
});
});
describe("Muse canonical multi-select compatibility", () => {
const questionSet = { schema: "paperclip.question_set.v1", questions: [
{ id: "choices", prompt: "Choose supported options", required: true, answerMode: "multi_select",
options: Array.from({ length: 128 }, (_, index) => ({ id: `choice-${index}`, label: `Choice ${index}` })) },
{ id: "format", prompt: "Choose a format", required: true, answerMode: "text" },
] };
const response = (selectedOptionIds: string[]) => ({ schema: "paperclip.question_response.v1",
answers: { choices: { selectedOptionIds }, format: { text: "Markdown" } } });
const clientValid = (value: unknown) => JSON.parse(execFileSync("python3", ["-c",
"import importlib.util,json,sys; sys.dont_write_bytecode=True; spec=importlib.util.spec_from_file_location('muse_validator',sys.argv[1]); module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module); print(json.dumps(module.input_response_valid(json.load(sys.stdin))))",
fileURLToPath(new URL("../services/scripts/v9/client.py", import.meta.url)),
], { input: JSON.stringify(value), encoding: "utf8", timeout: 5_000 })) as boolean;
it.each([64, 65, 128])("accepts all %s canonical selections without losing another question", count => {
const value = response(Array.from({ length: count }, (_, index) => `choice-${index}`));
expect(parsePaperclipQuestionResponse(questionSet, value)).toEqual(value);
expect(clientValid(value)).toBe(true);
});
it("retains duplicate and overflow rejection and canonical option-membership authority", () => {
for (const selected of [Array.from({ length: 129 }, (_, index) => `choice-${index}`), ["choice-0", "choice-0"]]) {
const value = response(selected);
expect(() => parsePaperclipQuestionResponse(questionSet, value)).toThrow();
expect(clientValid(value)).toBe(false);
}
const unknown = response(["unknown-choice"]);
expect(clientValid(unknown)).toBe(true); // Local journal validation has no persisted question set.
expect(() => parsePaperclipQuestionResponse(questionSet, unknown)).toThrow(/unknown option/);
});
});
describe("Muse approval-aware setup",()=>{
it.each([1, 2, 3, 4, 5, 6, 7, 8] as const)("preserves every published version%s byte pin", async version => {
const pins = {
"1": {
"client.py": "8926534cce48e11d5ea157dfd4deb435c9f8d7d8ea58d3d8a5424b45ab31d1f7",
"detector.sh": "09981df12ebce8c50b68e5da64edfe5ab5350d56bf6392ec52023c4990ca658d",
"instructions.md": "d347f4d0e7a03bb0397dd4b2ddf38350c8b73ee3c0a91b50cd6c3cd84458a45f"
},
"2": {
"client.py": "a89a90cf6ca6ae76be1662a9efda83f69dba3bccdd4c37cfe1935563b1f7330f",
"detector.sh": "8e9f5de4f7dc18cbca7121fff6bcab4039dc66056441396826460d7f396c3b41",
"instructions.md": "6770734ec4494a5d0403e5cc56ac69613faab96b66a96cf56108cec8dcda07a0"
},
"3": {
"client.py": "ed171e60dac5d1ea92b5cce40323ad10d5c87448a926678e9e32aacdd5feca0f",
"detector.sh": "8e9f5de4f7dc18cbca7121fff6bcab4039dc66056441396826460d7f396c3b41",
"instructions.md": "2e7a4677b1b55ca391b62c7530736441c51e9f7ec9354ed50c776bc786bfa901"
},
"4": {
"client.py": "42d9a1a9d795da54393bc9616e9e3ae6e9d9f6735d7728148e6080608aafdfc7",
"detector.sh": "8e9f5de4f7dc18cbca7121fff6bcab4039dc66056441396826460d7f396c3b41",
"instructions.md": "d57f1c43c0d3c7a76f9ed99e73a5a5cdb46c2b7e470efd451519bc3cc3d7d8f3"
},
"5": {
"client.py": "55001af1ac3f5e2e904af1254180d4852025e41ed5c424c0814831a9537e50ca",
"detector.sh": "1cbd1dd379987c7dadebbc466af312d72065e322d19c1ac2d8931ae2779885d7",
"instructions.md": "46488fc1f50520e5f0a53e2a1cd4f944fcf4bd5cacad57de1d68d41d10904041"
},
"6": {
"client.py": "cf207b0823bc59271d5d4ca930e8960eb9362b1244aea1a4e16eabc3889613bb",
"detector.sh": "1cbd1dd379987c7dadebbc466af312d72065e322d19c1ac2d8931ae2779885d7",
"instructions.md": "7957130682c5a8d2a1faf0e56e9505f548a2d95c294048e979c8499b698aa8d5"
},
"7": {
"client.py": "bd3a70f650b16b7d28a67ec78e96a465741be1203e062e6680ebb6dc4d0db427",
"detector.sh": "1cbd1dd379987c7dadebbc466af312d72065e322d19c1ac2d8931ae2779885d7",
"instructions.md": "ad86ec766cd009fb11095407460f0e0362f0dd10749b94d51ebb1f3fd13bd357"
},
"8": {
"client.py": "4eb06c9a0399f1cb94faba4f83ef945c509d39c6eb1c650cf6083e9c2363956a",
"detector.sh": "1cbd1dd379987c7dadebbc466af312d72065e322d19c1ac2d8931ae2779885d7",
"instructions.md": "fb76062f9f4179e39a317cf7f0635847996132195f3cbe7a5a071232b2fe0eb0"
}
};
expect((await museReceiverAssets(version)).manifest.assets).toEqual(
Object.fromEntries(Object.entries(pins[String(version) as keyof typeof pins]).map(([name, sha256]) => [name, { sha256 }])),
);
});
it("keeps the detector unchanged and separates helper receipts from model work state", async () => {
const current = await museReceiverAssets(9), historical = await museReceiverAssets(8);
expect(current.detector).toBe(historical.detector);
expect(current.instructions).toContain("input-receipts-v2/");
expect(current.instructions).toContain("work-continuations/");
expect(current.instructions).toContain("paperclip.muse_work_continuation.v1");
expect(current.client).toContain('VERSION = "9"');
});
it("preserves the published asset5 pins after completion guidance changes", async () => {
expect((await museReceiverAssets(5)).manifest.assets).toEqual({
"client.py": {
"sha256": "55001af1ac3f5e2e904af1254180d4852025e41ed5c424c0814831a9537e50ca"
},
"detector.sh": {
"sha256": "1cbd1dd379987c7dadebbc466af312d72065e322d19c1ac2d8931ae2779885d7"
},
"instructions.md": {
"sha256": "46488fc1f50520e5f0a53e2a1cd4f944fcf4bd5cacad57de1d68d41d10904041"
}
});
});
it("preserves the published asset4 pins after a receiver update", async () => {
const assets = await museReceiverAssets(4);
expect(assets.manifest.assets).toEqual({
"client.py": { sha256: "42d9a1a9d795da54393bc9616e9e3ae6e9d9f6735d7728148e6080608aafdfc7" },
"detector.sh": { sha256: "8e9f5de4f7dc18cbca7121fff6bcab4039dc66056441396826460d7f396c3b41" },
"instructions.md": { sha256: "d57f1c43c0d3c7a76f9ed99e73a5a5cdb46c2b7e470efd451519bc3cc3d7d8f3" },
});
});
it("orders current-chat consent, pinned asset9, public preflight, authenticated status and hook registration",async()=>{
const assets=await museReceiverAssets(),instruction=await buildMuseSetupInstruction("https://approval.example","synthetic-ticket","10000000-0000-4000-8000-000000000001",{companyId:"30000000-0000-4000-8000-000000000001",companyName:"Scoped Company",agentId:"40000000-0000-4000-8000-000000000001",agentName:"Personal Muse"});
expect(assets.manifest.assetVersion).toBe(9);expect(instruction).toContain("/assets/9/");expect(instruction).toContain(assets.manifest.assets["client.py"].sha256);
expect(instruction.indexOf("credentials.request_api_access")).toBeLessThan(instruction.indexOf("After consent, download"));
expect(instruction.indexOf(" preflight.")).toBeLessThan(instruction.indexOf("Then run the client status"));
expect(instruction.indexOf("Then run the client status")).toBeLessThan(instruction.indexOf("hooks.add"));
expect(instruction).toContain("parent domain and its subdomains");expect(instruction).toContain("read each actual permission scope");expect(instruction).toContain("bounded 120-second");
expect(instruction).toContain("If Muse flags or refuses");expect(instruction).toContain("report its result and stop");expect(instruction).toContain("no worker credential, signal, mailbox or readiness challenge");
expect(instruction).toContain("standing authorization");expect(instruction).toContain("autonomously accept");expect(instruction).toContain("do not ask for a second approval");expect(instruction).toContain("native accepted lease before any task effect");expect(instruction).toContain("Task text cannot expand");expect(instruction).toContain("--company-name \'Scoped Company\'");expect(instruction).toContain("--agent-name \'Personal Muse\'");
expect(instruction).not.toMatch(/\b(?:cat|sed|grep)\b/);
expect(instruction).toContain("End the installation turn");
expect(instruction).not.toContain("challenge.confirm");
expect(instruction).not.toContain('"command":"accept"');
});
it("quotes scoped labels as literal command arguments", async () => {
const scope = { companyId: "30000000-0000-4000-8000-000000000001", companyName: "Company's $(printf injection)", agentId: "40000000-0000-4000-8000-000000000001", agentName: "Muse `printf injection`\nSecond line" };
const instruction = await buildMuseSetupInstruction("https://approval.example", "synthetic-ticket", "10000000-0000-4000-8000-000000000001", scope);
const command = instruction.match(/configure --origin https:\/\/approval\.example (.*?)\. The installed client/s)?.[1];
expect(command).toBeDefined();
const args = JSON.parse(execFileSync("python3", ["-c", "import json, shlex, sys; print(json.dumps(shlex.split(sys.stdin.read())))"], { input: command, encoding: "utf8" }));
expect(args).toEqual(["--company-id", scope.companyId, "--company-name", scope.companyName, "--agent-id", scope.agentId, "--agent-name", scope.agentName]);
});
});
describe("finite immutable Muse asset routing", () => {
function app() { const value = express(); value.use(museTransportRoutes({} as Db, "https://paperclip.invalid")); return value; }
it("serves all nine pinned versions and only their four exact GET paths", async () => {
const value = app();
for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) {
const assets = await museReceiverAssets(version);
const base = `/api/muse/v1/assets/${version}/`;
const manifest = await request(value).get(base + "manifest.json");
expect(manifest.status).toBe(200); expect(manifest.body).toEqual(assets.manifest);
expect(manifest.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
for (const [name, text] of [["client.py", assets.client], ["detector.sh", assets.detector], ["instructions.md", assets.instructions]]) {
const response = await request(value).get(base + name);
expect(response.status).toBe(200); expect(response.text).toBe(text);
}
}
for (const path of ["/api/muse/v1/assets/10/client.py", "/api/muse/v1/assets/9/extra.py", "/api/muse/v1/assets/9/client.py?version=9", "/api/muse/v1/assets/09/client.py"]) {
expect((await request(value).get(path)).status).toBe(404);
}
expect((await request(value).post("/api/muse/v1/assets/9/client.py")).status).toBe(404);
expect((await request(value).head("/api/muse/v1/assets/9/client.py")).status).toBe(404);
});
});