mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
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>
183 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|