mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
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>
This commit is contained in:
1 parent
63fbb714e6
commit
e6dcc6b095
72 files changed
+9775
-1021
No files matched your search
@@ -67,6 +67,12 @@ describe("agent local JWT", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an internal Muse authorizer without changing the named agent or responsible user", () => {
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "paperclip_runner", "run-1", "responsible-1", undefined, "authorizer-2");
|
||||
expect(verifyLocalAgentJwt(token!)).toMatchObject({ sub: "agent-1", run_id: "run-1", responsible_user_id: "responsible-1", authorizing_user_id: "authorizer-2" });
|
||||
expect(verifyLocalAgentJwt(createLocalAgentJwt("agent-1", "company-1", "codex_local", "run-1")!)?.authorizing_user_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips a skill_test run scope", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createGrokRoutineDispatcher } from "../services/grok-bot/dispatcher.js";
|
||||
import { grokRoutineService } from "../services/grok-bot/routine.js";
|
||||
import { assertNoExternalOverlap } from "../modules/external-agents/admission.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { Readable } from "node:stream";
|
||||
import { createPublicMcpTransfers } from "../services/public-mcp/file-transfers.js";
|
||||
@@ -16,7 +17,7 @@ import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { grokBotRoutines, grokBotDispatches, agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
heartbeatRuns, mcpOauthGrants, issueAttachments, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, mcpEventSubscriptions, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db";
|
||||
heartbeatRuns, mcpOauthGrants, issueAttachments, activityLog, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, mcpEventSubscriptions, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
|
||||
@@ -208,6 +209,127 @@ describe("durable Dot Runner integration", () => {
|
||||
return { app, personal };
|
||||
}
|
||||
|
||||
it.each(["grok_bot_mcp", "codex_app_server"] as const)("fences a running Grok overlap before assignment materialization with %s", async driverKind => {
|
||||
const f = await fixture({ provider: "grok_bot" });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id,
|
||||
status: "running", runtimeMode: "native", nativePhase: "preparing", driverKind,
|
||||
invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
expect(await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id))).toHaveLength(0);
|
||||
await expect(db.transaction(tx => assertNoExternalOverlap(tx, f.company.id, f.agent.id))).rejects.toThrow("External work is live or unresolved");
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id));
|
||||
await expect(db.transaction(tx => assertNoExternalOverlap(tx, f.company.id, f.agent.id))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["openai_dot", "grok_bot"] as const)("requires a matching %s descriptor for a V6 authority fence", async provider => {
|
||||
const f = await fixture({ provider }), { run, assignment } = await offeredWork(f);
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Owned descriptor fence", assigneeAgentId: f.agent.id, checkoutRunId: run.id, executionRunId: run.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: issue!.id }).where(eq(heartbeatRuns.id, run.id));
|
||||
const binding = { companyId: f.company.id, agentId: f.agent.id, runId: run.id };
|
||||
const event = { sourceEventId: randomUUID(), payload: { kind: "authority_revoked" as const, binding: {
|
||||
...f.snapshot, runId: run.id, normalizedSessionId: assignment.normalizedSessionId,
|
||||
turnId: assignment.turnId, assignmentRevision: assignment.revision,
|
||||
} } };
|
||||
const foreign = provider === "openai_dot" ? "grok_bot" as const : "openai_dot" as const;
|
||||
await expect(f.broker.port({ binding, provider: { kind: foreign, binding: f.snapshot } }).dispatch(event)).rejects.toThrow("binding unavailable");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id)))[0]!.status).toBe("offered");
|
||||
await f.broker.port({ binding, provider: { kind: provider, binding: f.snapshot } }).dispatch(event);
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id)))[0]!.status).toBe("fenced");
|
||||
});
|
||||
|
||||
it("admits a native Grok operation while an actual upload waits for its task lock", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" }), { run, assignment } = await offeredWork(f);
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Concurrent Grok publication", assigneeAgentId: f.agent.id,
|
||||
checkoutRunId: run.id, executionRunId: run.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ status: "running", runtimeMode: "native", nativePhase: "running", driverKind: "grok_bot_mcp",
|
||||
nativeIssueId: issue!.id, nativeSessionId: assignment.normalizedSessionId }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(dotRunnerAssignments).set({ status: "accepted", acceptedAt: new Date() }).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
await db.insert(nativeRunFinalizations).values({ runId: run.id, companyId: f.company.id, issueId: issue!.id, phase: "running",
|
||||
controllerGeneration: assignment.controllerGeneration, leaseExpiresAt: new Date(Date.now() + 60_000) });
|
||||
const files = new Map<string, Buffer>();
|
||||
const transfers = createPublicMcpTransfers(db, f.oauth, async () => { throw new Error("Personal API dispatch forbidden"); }, {
|
||||
provider: "local_disk", putFile: async input => {
|
||||
if (!Buffer.isBuffer(input.body)) throw new Error("Expected bounded upload bytes");
|
||||
files.set(input.objectKey!, input.body);
|
||||
return { provider: "local_disk", objectKey: input.objectKey!, contentType: input.contentType, originalFilename: input.originalFilename,
|
||||
byteSize: input.body.length, sha256: createHash("sha256").update(input.body).digest("hex") };
|
||||
}, getObject: async (_company, key) => ({ stream: Readable.from(files.get(key)!) }),
|
||||
headObject: async () => ({ exists: true }), deleteObject: async (_company, key) => { files.delete(key); },
|
||||
}, { pathPrefix: "/mcp/runner/files", agentSession: { authorize: (principal, sessionId, taskId, write, database) => dotRunnerBroker(database ?? db).authorizeFileSession(principal, sessionId, taskId, write) } });
|
||||
const body = Buffer.from("Authenticated concurrent deliverable"), uploadRequestId = randomUUID(), operationId = randomUUID();
|
||||
const upload = await transfers.getUploadUrl(f.principal, { companyId: f.company.id, taskId: issue!.id, requestId: uploadRequestId,
|
||||
filename: "concurrent.txt", contentType: "text/plain", byteSize: body.length, sha256: createHash("sha256").update(body).digest("hex") }, assignment.id);
|
||||
const url = new URL(String(upload.url)), api = express(); api.use(transfers.router);
|
||||
let uploading: Promise<request.Response> | undefined, uploaded: request.Response | undefined, uploadError: unknown;
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, issue!.id)).for("update");
|
||||
const [holder] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
|
||||
uploading = request(api).put(url.pathname + url.search).set("Content-Type", "text/plain").send(body).then(response => response);
|
||||
await vi.waitFor(async () => {
|
||||
const [state] = await db.execute(sql`select exists (
|
||||
select 1 from pg_stat_activity where datname=current_database() and wait_event_type='Lock'
|
||||
and query ilike '%issues%' and ${holder!.pid}=any(pg_blocking_pids(pid))
|
||||
) as waiting`) as unknown as Array<{ waiting: boolean }>;
|
||||
expect(state!.waiting).toBe(true);
|
||||
}, { timeout: 5000, interval: 20 });
|
||||
const operationDb = Object.assign(Object.create(tx), {
|
||||
transaction: async (effect: (database: typeof db) => Promise<unknown>) => effect(tx as unknown as typeof db),
|
||||
}) as typeof db;
|
||||
await expect(dotRunnerBroker(operationDb).operation(f.principal, assignment.id, operationId, "progress", { text: "Concurrent progress" }))
|
||||
.resolves.toMatchObject({ status: "pending", assignmentId: assignment.id, requestId: operationId });
|
||||
});
|
||||
} finally {
|
||||
try { uploaded = await uploading; } catch (error) { uploadError = error; }
|
||||
}
|
||||
expect(uploadError).toBeUndefined(); expect(uploaded?.status).toBe(200);
|
||||
expect((await db.select().from(dotRunnerOperations).where(and(eq(dotRunnerOperations.assignmentId, assignment.id), eq(dotRunnerOperations.requestId, operationId))))).toHaveLength(1);
|
||||
const attachments = await db.select().from(issueAttachments).where(eq(issueAttachments.issueId, issue!.id));
|
||||
expect(attachments).toHaveLength(1);
|
||||
const attachment = await issueService(db).getAttachmentById(attachments[0]!.id);
|
||||
expect(attachment).toMatchObject({ companyId: f.company.id, issueId: issue!.id, assetId: attachments[0]!.assetId,
|
||||
createdByAgentId: f.agent.id, originatingRunId: run.id, createdByUserId: null });
|
||||
const audit = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.company.id),
|
||||
eq(activityLog.action, "issue.attachment_added"), eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issue!.id),
|
||||
sql`${activityLog.details}->>'attachmentId' = ${attachments[0]!.id}`));
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0]).toMatchObject({ actorType: "agent", actorId: f.agent.id, details: { attachmentId: attachments[0]!.id,
|
||||
authorizingPersonId: f.userId, workSessionId: assignment.id, mcpConnectionId: f.principal.grant.id } });
|
||||
expect(uploaded!.body.attachment).toEqual({ id: attachments[0]!.id, issueId: issue!.id, originalFilename: "concurrent.txt",
|
||||
contentType: "text/plain", byteSize: body.length, sha256: createHash("sha256").update(body).digest("hex"),
|
||||
createdAt: attachment!.createdAt.toISOString() });
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id)))[0]!.status).toBe("accepted");
|
||||
}, 30000);
|
||||
|
||||
it.each(["openai_dot", "grok_bot"] as const)("keeps %s assignment authority after a rejected finish receipt until a completed finish", async provider => {
|
||||
const f = await fixture({ provider }), { run, assignment } = await offeredWork(f);
|
||||
const acceptedAt = new Date("2026-10-01T00:00:00.000Z");
|
||||
await db.update(dotRunnerAssignments).set({ status: "accepted", acceptedAt }).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
const port = f.broker.port({ binding: { companyId: f.company.id, agentId: f.agent.id, runId: run.id },
|
||||
provider: { kind: provider, binding: f.snapshot } });
|
||||
const binding = { ...f.snapshot, runId: run.id, normalizedSessionId: assignment.normalizedSessionId,
|
||||
turnId: assignment.turnId, assignmentRevision: assignment.revision };
|
||||
const rejectedId = randomUUID();
|
||||
await db.insert(dotRunnerOperations).values({ companyId: f.company.id, assignmentId: assignment.id,
|
||||
requestId: rejectedId, digest: "rejected-finish", command: { action: "finish" }, status: "admitted" });
|
||||
const rejection = { sourceEventId: randomUUID(), payload: { binding, requestId: rejectedId,
|
||||
outcome: { status: "rejected", message: "Completion evidence was not accepted" } } };
|
||||
await port.settle(rejection);
|
||||
const [rejected] = await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
expect(rejected).toMatchObject({ status: "accepted", acceptedAt, settledAt: null });
|
||||
const [receipt] = await db.select().from(dotRunnerOperations).where(eq(dotRunnerOperations.requestId, rejectedId));
|
||||
expect(receipt!.outcome).toEqual(rejection.payload.outcome);
|
||||
await port.settle(rejection);
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id)))[0]).toEqual(rejected);
|
||||
const completedId = randomUUID();
|
||||
await db.insert(dotRunnerOperations).values({ companyId: f.company.id, assignmentId: assignment.id,
|
||||
requestId: completedId, digest: "completed-finish", command: { action: "finish" }, status: "admitted" });
|
||||
await port.settle({ sourceEventId: randomUUID(), payload: { binding, requestId: completedId,
|
||||
outcome: { status: "completed" } } });
|
||||
const [completed] = await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
expect(completed).toMatchObject({ status: "settled", acceptedAt });
|
||||
expect(completed!.settledAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("reapplies the Dot-only migration after the merged gateway schema", async () => {
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0317_messy_famine.sql", import.meta.url), "utf8");
|
||||
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||
@@ -805,7 +927,10 @@ describe("durable Dot Runner integration", () => {
|
||||
const appCall = vi.fn().mockResolvedValue({ status: "completed", result: { content: [{ type: "text", text: "synthetic gateway result" }] } });
|
||||
const gatewayTools = [{ name: "fixture:read_status", displayName: "Read fixture status", description: "Read a synthetic app", parametersSchema: { type: "object", properties: {}, additionalProperties: false }, risk: "read" }];
|
||||
registerAssignedMcpGateway(db, { listToolsForNamedGateway: vi.fn().mockResolvedValue(gatewayTools), executeTool: appCall } as unknown as ToolGatewayService);
|
||||
if (provider === "openai_dot") await db.update(agents).set({ adapterConfig: { ...f.agent.adapterConfig, dotBindingId: f.snapshot.bindingId, dotWorkspaceAccess: true, dotAttachmentAccess: true } }).where(eq(agents.id, f.agent.id));
|
||||
if (provider === "openai_dot") {
|
||||
const [connectedAgent] = await db.select().from(agents).where(eq(agents.id, f.agent.id));
|
||||
await db.update(agents).set({ adapterConfig: { ...connectedAgent!.adapterConfig, dotBindingId: f.snapshot.bindingId, dotWorkspaceAccess: true, dotAttachmentAccess: true } }).where(eq(agents.id, f.agent.id));
|
||||
}
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-cloud");
|
||||
try {
|
||||
for (const useRunnerd of [false, true]) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ import {
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
externalAgentHolds,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -230,6 +231,23 @@ describeEmbeddedPostgres("heartbeat queued-run claim isolation", () => {
|
||||
expect(await runStatus(healthyRunId)).toMatchObject({ status: "succeeded" });
|
||||
});
|
||||
|
||||
it.each(["worker", "native-effects"])("retains a %s barrier across provider changes and resumes only after reconciliation", async barrier => {
|
||||
const { companyId, agentId } = await insertAgent();
|
||||
const { runId } = await insertClaimableRun(companyId, agentId);
|
||||
const [hold] = await db.insert(externalAgentHolds).values({ companyId, agentId, provider: "muse", assignmentId: randomUUID(), bindingId: randomUUID(),
|
||||
bindingGeneration: 1, runId: randomUUID(), workerUnknown: barrier === "worker", nativeEffectsUnknown: barrier === "native-effects" }).returning();
|
||||
// The agent now uses Codex; old external uncertainty still owns admission.
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
expect(await runStatus(runId)).toMatchObject({ status: "queued" });
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
await db.update(externalAgentHolds).set({ workerUnknown: false, nativeEffectsUnknown: false, releasedAt: new Date() }).where(sql`${externalAgentHolds.id} = ${hold.id}`);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
expect(await runStatus(runId)).toMatchObject({ status: "succeeded" });
|
||||
expect(mockAdapterExecute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps processing other agents' queued runs after one agent's run is rejected", async () => {
|
||||
const first = await insertAgent();
|
||||
const second = await insertAgent();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isPrivateWebhookHttpRequest,
|
||||
isPrivateMuseHttpRequest,
|
||||
isSecretSensitiveHttpRequest,
|
||||
shouldSilenceHttpSuccessLog,
|
||||
} from "../middleware/http-log-policy.js";
|
||||
@@ -188,3 +189,15 @@ describe("shouldSilenceHttpSuccessLog", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Muse HTTP privacy",()=>{
|
||||
it("protects OAuth, worker bodies and rejected namespace variants",()=>{
|
||||
for(const path of ["/api/muse/v1/oauth/authorize?pairing_ticket=private", "/api/muse/v1/commands", "/api/muse/unknown/private", "/API/MUSE/v1/token", "http://host.invalid/api/%6duse/v1/commands", "/api/%256duse/v1/queries", "/api/companies/company/agents/agent/muse-binding", "/api/companies/company/muse-invitations"])
|
||||
for(const method of ["GET","POST","DELETE"]){expect(isPrivateMuseHttpRequest(path)).toBe(true);expect(isSecretSensitiveHttpRequest(method,path)).toBe(true);}
|
||||
expect(isPrivateMuseHttpRequest("/api/muse-extra")).toBe(false);
|
||||
expect(isPrivateMuseHttpRequest("/api/issues/task")).toBe(false);
|
||||
expect(shouldSilenceHttpSuccessLog("GET","/api/muse/v1/signal",200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET","/api/muse/v1/signal",503)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,19 @@ describe("HTTP logger redaction", () => {
|
||||
const output = JSON.stringify({ logs: chunks, responses: responses.map(response => response.body) });
|
||||
for (const canary of canaries) expect(output).not.toContain(canary);
|
||||
});
|
||||
it.each([200,400,500])("keeps Muse OAuth and task payloads out of %i diagnostics",async status=>{
|
||||
const chunks:string[]=[];
|
||||
const stream=new Writable({write(chunk,_encoding,callback){chunks.push(chunk.toString());callback();}});
|
||||
const app=express();app.use(createHttpLogger(pino({redact:[...HTTP_LOG_REDACT_PATHS]},stream)));app.use(express.json());
|
||||
const canary="private-muse-payload-canary";
|
||||
app.use((_req,res)=>{res.setHeader("Location","https://client.example/callback?code="+canary);res.setHeader("x-provider-prose",canary);if(status>=400)(res as any).err=new Error(canary);res.status(status).json({error:status>=400?"invalid_request":undefined});});
|
||||
await request(app).get("/api/muse/v1/oauth/authorize").query({pairing_ticket:canary,state:canary}).set("Referer","https://client.example/?code="+canary);
|
||||
for(const path of ["/api/muse/v1/oauth/token","/api/muse/v1/commands","/api/muse/unknown/"+canary])await request(app).post(path).set("Authorization","Bearer "+canary).send({code:canary,code_verifier:canary,refresh_token:canary,arguments:{description:canary}});
|
||||
const output=chunks.join("");expect(output).not.toContain(canary);
|
||||
const logs=chunks.flatMap(chunk=>chunk.trim().split("\n").filter(Boolean).map(line=>JSON.parse(line)));
|
||||
expect(logs.length).toBe(4);for(const log of logs){expect(log.req.url).toBe("/api/muse/:protocol");if(status>=400)expect(log.reqBody).toBe("[REDACTED]");}
|
||||
});
|
||||
|
||||
it("redacts inbound MCP OAuth codes, PKCE verifiers, refresh tokens and redirect credentials", async () => {
|
||||
const chunks: string[] = [];
|
||||
const stream = new Writable({ write(chunk, _encoding, callback) { chunks.push(chunk.toString()); callback(); } });
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { museQualificationRoutes } from "../routes/muse-qualification.js";
|
||||
import { museBoardRoutes } from "../routes/muse.js";
|
||||
const mocks = vi.hoisted(() => ({ beginQualification: vi.fn(), endQualification: vi.fn(), readQualificationEvidence: vi.fn(), verify: vi.fn(), attestDetectorCleanup: vi.fn(), decide: vi.fn(), getById: vi.fn() }));
|
||||
vi.mock("../services/muse-runner-broker.js", () => ({ museRunnerBroker: () => mocks }));
|
||||
vi.mock("../services/access.js", () => ({ accessService: () => mocks }));
|
||||
vi.mock("../services/authorization.js", () => ({ authorizationService: () => mocks }));
|
||||
vi.mock("../modules/external-agents/index.js", () => ({ museExternalAgents: () => mocks }));
|
||||
vi.mock("../services/agents.js", () => ({ agentService: () => mocks }));
|
||||
const companyId = "10000000-0000-4000-8000-000000000001", agentId = "20000000-0000-4000-8000-000000000001";
|
||||
const bindingId = "30000000-0000-4000-8000-000000000001", qualificationId = "40000000-0000-4000-8000-000000000001";
|
||||
const path = `/companies/${companyId}/agents/${agentId}/muse-binding/qualification`;
|
||||
const body = { bindingId, qualificationId, generation: 1, expectedRevision: 2 };
|
||||
function app(actor: Record<string, unknown> = { type: "board", source: "board_key", userId: "operator", companyIds: [companyId] }) {
|
||||
const result = express(); result.use(express.json());
|
||||
result.use((req, _res, next) => { Object.assign(req, { actor }); next(); });
|
||||
result.use(museQualificationRoutes({} as Db));
|
||||
result.use(museBoardRoutes({} as Db));
|
||||
result.use((error: { status?: number; name?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(error.status ?? (error.name === "ZodError" ? 400 : 500)).json({ error: "request_rejected" });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getById.mockResolvedValue({ id: agentId, companyId });
|
||||
mocks.decide.mockResolvedValue({ allowed: true });
|
||||
mocks.beginQualification.mockResolvedValue({ qualificationId, startedAt: "2099-01-01T00:00:00.000Z", expiresAt: "2099-01-02T00:00:00.000Z", revision: 3 });
|
||||
mocks.endQualification.mockResolvedValue(undefined);
|
||||
mocks.readQualificationEvidence.mockResolvedValue({ assignments: [], contacts: [] });
|
||||
mocks.verify.mockResolvedValue({ status: "pending" });
|
||||
});
|
||||
describe("operator-only Muse qualification routes", () => {
|
||||
it("uses current operator permission and a server-bounded 24h deadline", async () => {
|
||||
const before = Date.now();
|
||||
const response = await request(app()).post(path).send(body);
|
||||
expect(response.status).toBe(201); expect(response.headers["cache-control"]).toBe("no-store");
|
||||
const input = mocks.beginQualification.mock.calls[0]![0];
|
||||
expect(input).toMatchObject({ ...body, companyId, agentId, operatorId: "operator" });
|
||||
expect(input.expiresAt.getTime()).toBeGreaterThanOrEqual(before + 86_400_000);
|
||||
expect(input.expiresAt.getTime()).toBeLessThanOrEqual(Date.now() + 86_400_000);
|
||||
expect(mocks.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent_config:update", scope: { requiresChangeGrant: true } }));
|
||||
});
|
||||
it("rejects agent, human-MCP, implicit and cross-company callers before starting", async () => {
|
||||
for (const actor of [
|
||||
{ type: "agent", source: "agent_key", agentId, companyId },
|
||||
{ type: "board", source: "mcp_oauth", userId: "operator", companyIds: [companyId] },
|
||||
{ type: "board", source: "local_implicit", userId: "operator", companyIds: [companyId] },
|
||||
{ type: "board", source: "board_key", userId: "operator", companyIds: [] },
|
||||
]) expect((await request(app(actor)).post(path).send(body)).status).toBe(403);
|
||||
expect(mocks.beginQualification).not.toHaveBeenCalled();
|
||||
});
|
||||
it("honors current permission denial and does not allow a caller-selected duration", async () => {
|
||||
mocks.decide.mockResolvedValueOnce({ allowed: false, explanation: "Not allowed" });
|
||||
expect((await request(app()).post(path).send(body)).status).toBe(403);
|
||||
expect((await request(app()).post(path).send({ ...body, expiresAt: "2099-12-31T00:00:00Z" })).status).toBe(400);
|
||||
expect(mocks.beginQualification).not.toHaveBeenCalled();
|
||||
});
|
||||
it("requires matching agent/company for evidence and cleanup", async () => {
|
||||
mocks.getById.mockResolvedValue({ id: agentId, companyId: "50000000-0000-4000-8000-000000000001" });
|
||||
expect((await request(app()).get(path).query({ bindingId, qualificationId })).status).toBe(404);
|
||||
expect((await request(app()).delete(path).send({ bindingId, qualificationId })).status).toBe(404);
|
||||
expect(mocks.readQualificationEvidence).not.toHaveBeenCalled(); expect(mocks.endQualification).not.toHaveBeenCalled();
|
||||
});
|
||||
it("stops only the exact selected qualification and exposes non-cacheable evidence", async () => {
|
||||
const read = await request(app()).get(path).query({ bindingId, qualificationId });
|
||||
expect(read.status).toBe(200); expect(read.headers["cache-control"]).toBe("no-store");
|
||||
expect(mocks.readQualificationEvidence).toHaveBeenCalledWith(companyId, agentId, bindingId, qualificationId);
|
||||
expect((await request(app()).delete(path).send({ bindingId, qualificationId })).status).toBe(204);
|
||||
expect(mocks.endQualification).toHaveBeenCalledWith({ companyId, agentId, operatorId: "operator", bindingId, qualificationId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("board Muse verification attribution", () => {
|
||||
const verifyPath = `/companies/${companyId}/agents/${agentId}/muse-binding/verify`;
|
||||
const verifyBody = { bindingId, generation: 1, expectedRevision: 2 };
|
||||
it("passes only the authorized server-derived board caller to verification", async () => {
|
||||
const actor = { type: "board", source: "session", userId: "actual-board-caller", companyIds: [companyId] };
|
||||
expect((await request(app(actor)).post(verifyPath).send(verifyBody)).status).toBe(200);
|
||||
expect(mocks.verify).toHaveBeenCalledExactlyOnceWith(companyId, agentId, verifyBody, "actual-board-caller");
|
||||
expect(mocks.decide).toHaveBeenCalledWith(expect.objectContaining({ actor, action: "agent_config:update", scope: { requiresChangeGrant: true } }));
|
||||
});
|
||||
it("rejects caller-supplied audit identity before verification", async () => {
|
||||
expect((await request(app()).post(verifyPath).send({ ...verifyBody, operatorId: "spoofed-caller" })).status).toBe(400);
|
||||
expect(mocks.verify).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not verify when current board authority or company scope is denied", async () => {
|
||||
mocks.decide.mockResolvedValueOnce({ allowed: false, explanation: "Not allowed" });
|
||||
expect((await request(app()).post(verifyPath).send(verifyBody)).status).toBe(403);
|
||||
expect((await request(app({ type: "board", source: "board_key", userId: "operator", companyIds: [] })).post(verifyPath).send(verifyBody)).status).toBe(403);
|
||||
expect(mocks.verify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("board-only exact Muse detector cleanup", () => {
|
||||
const cleanupPath = `/companies/${companyId}/agents/${agentId}/muse-binding/attest-detector-cleanup`;
|
||||
const input = { bindingId, qualificationId, generation: 1, expectedRevision: 2, hookScript: `~/hooks/scripts/paperclip-muse-${bindingId}.sh`, detectorRemoved: true,
|
||||
observation: { method: "hooks.list", includeDisabled: true, hookId: "test-hook", outcome: "exact_hook_absent", observedAt: "2099-01-02T00:00:01.000Z", sha256: "a".repeat(64) } };
|
||||
it("uses actual Board caller and normal change-grant authorization", async () => {
|
||||
const actor = { type: "board", source: "session", userId: "actual-operator", companyIds: [companyId] };
|
||||
const response = await request(app(actor)).post(cleanupPath).send(input);
|
||||
expect(response.status).toBe(200); expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(mocks.attestDetectorCleanup).toHaveBeenCalledExactlyOnceWith(companyId, agentId, "actual-operator", input, actor);
|
||||
expect(mocks.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent_config:update", scope: { requiresChangeGrant: true } }));
|
||||
});
|
||||
it("rejects denied authority, agent callers, cross-company callers and supplied actor metadata", async () => {
|
||||
mocks.decide.mockResolvedValueOnce({ allowed: false, explanation: "Denied" });
|
||||
expect((await request(app()).post(cleanupPath).send(input)).status).toBe(403);
|
||||
for (const actor of [{ type: "agent", source: "agent_key", agentId, companyId }, { type: "board", source: "board_key", userId: "operator", companyIds: [] }]) expect((await request(app(actor)).post(cleanupPath).send(input)).status).toBe(403);
|
||||
expect((await request(app()).post(cleanupPath).send({ ...input, operatorId: "spoofed" })).status).toBe(400);
|
||||
expect(mocks.attestDetectorCleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MUSE_ASSET_VERSION, MUSE_PUBLIC_ROUTES } from "@paperclipai/shared";
|
||||
import { COMPANY_IMPORT_TRANSFERS_ROUTE_PATH } from "@paperclipai/shared/company-import-transfer";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { buildOpenApiSpec, openApiRoutes } from "../routes/openapi.js";
|
||||
@@ -45,6 +46,8 @@ const apiPrefixes: Record<string, string> = {
|
||||
"costs.ts": "/api",
|
||||
"dashboard.ts": "/api",
|
||||
"dot-runner.ts": "/api",
|
||||
"muse.ts": "/api",
|
||||
"muse-qualification.ts": "/api",
|
||||
"grok-bot.ts": "/api",
|
||||
"decision-queues.ts": "/api",
|
||||
"decisions.ts": "/api",
|
||||
@@ -105,6 +108,10 @@ const HTTP_METHODS = new Set([
|
||||
const explicitOpenApiCoverageExclusions = new Set<string>();
|
||||
|
||||
const explicitOpenApiOperationCoverageExclusions = new Set([
|
||||
// Muse uses its closed, versioned receiver protocol and dedicated credentials,
|
||||
// documented in doc/muse-personal-agent.md and the shared protocol contract.
|
||||
// Board connection/qualification management remains in this OpenAPI document.
|
||||
...MUSE_PUBLIC_ROUTES.map(route => `${route.method} ${route.path}`),
|
||||
// Inspection uses its own versioned Cloud-permit/managed-run protocol,
|
||||
// documented in CUSTOMER-SUCCESS-INSPECTION.md and the shared contract.
|
||||
// Ordinary board sessions and agent API keys cannot invoke these endpoints.
|
||||
@@ -248,16 +255,21 @@ function loadActualRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (file === "dot-runner.ts" || file === "grok-bot.ts") {
|
||||
for (const name of file === "dot-runner.ts" ? ["path", "invitePath"] : ["path"]) {
|
||||
const basePath = new RegExp(`const ${name} = "([^"]+)"`).exec(source)?.[1];
|
||||
if (!basePath) throw new Error(`Dot ${name} route prefix is missing`);
|
||||
const methods = new RegExp(`router\\.(get|post|put|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g");
|
||||
for (const match of source.matchAll(methods)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
}
|
||||
const variablePaths = file === "dot-runner.ts" ? ["path", "invitePath"]
|
||||
: file === "grok-bot.ts" ? ["path"]
|
||||
: file === "muse.ts" ? ["path", "invite"]
|
||||
: file === "muse-qualification.ts" ? ["path"] : [];
|
||||
for (const name of variablePaths) {
|
||||
const basePath = new RegExp(`\\b${name}\\s*=\\s*"([^"]+)"`).exec(source)?.[1];
|
||||
if (!basePath) throw new Error(`${file} ${name} route prefix is missing`);
|
||||
const methods = new RegExp(`router\\.(get|post|put|delete)\\(\\s*${name}(?:\\s*\\+\\s*"([^"]+)")?\\s*,`, "g");
|
||||
for (const match of source.matchAll(methods)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
}
|
||||
}
|
||||
if (file === "muse.ts" && /for\s*\(const route of MUSE_PUBLIC_ROUTES\)/.test(source)) {
|
||||
for (const route of MUSE_PUBLIC_ROUTES) excludedRoutes.add(`${route.method} ${route.path}`);
|
||||
}
|
||||
|
||||
if (
|
||||
file === "companies.ts" &&
|
||||
@@ -928,6 +940,34 @@ describe("openapi routes", () => {
|
||||
expect(path.put.responses["422"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("documents Muse operator management separately from receiver credentials", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const base = "/api/companies/{companyId}/agents/{agentId}/muse-binding";
|
||||
const operations = [
|
||||
["get", "/api/companies/{companyId}/muse-invitations"],
|
||||
["post", "/api/companies/{companyId}/muse-invitations"],
|
||||
["get", base], ["post", base], ["post", `${base}/verify`],
|
||||
["post", `${base}/revoke`], ["post", `${base}/attest-stop`],
|
||||
["post", `${base}/attest-detector-cleanup`],
|
||||
["get", `${base}/qualification`], ["post", `${base}/qualification`], ["delete", `${base}/qualification`],
|
||||
];
|
||||
for (const [method, routePath] of operations) {
|
||||
const operation = spec.paths[routePath][method];
|
||||
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
||||
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
||||
expect(operation.responses["403"]).toBeDefined();
|
||||
}
|
||||
expect(spec.paths[base].post.responses["201"]).toBeDefined();
|
||||
expect(spec.paths[base].post.responses["201"].content["application/json"].schema.properties.assetVersion.enum).toEqual([MUSE_ASSET_VERSION]);
|
||||
expect(spec.paths[`${base}/revoke`].post.responses["204"]).toBeDefined();
|
||||
expect(spec.paths[`${base}/qualification`].delete.responses["204"]).toBeDefined();
|
||||
expect(spec.paths[`${base}/qualification`].get.parameters).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "bindingId", in: "query", required: true }),
|
||||
expect.objectContaining({ name: "qualificationId", in: "query", required: true }),
|
||||
]));
|
||||
for (const route of MUSE_PUBLIC_ROUTES) expect(spec.paths[route.path]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("documents operator Dot invitations and current pairing and event-test fields", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const invitations = spec.paths["/api/companies/{companyId}/dot-invitations"];
|
||||
|
||||
@@ -10,11 +10,11 @@ import { mcpAttachmentUploads, mcpFileTickets, principalPermissionGrants, heartb
|
||||
import { createHmac, createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, mcpEventAdmissions, mcpEventDeliveries, mcpEventSubscriptions, createDb, authUsers, companies, companyLogos, assets, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, DEVICE_GRANT, McpOAuthError } from "../services/public-mcp/oauth.js";
|
||||
import { McpApiError, createMcpApiDispatch, createPublicMcpExecutor, publicMcpCapabilities } from "../services/public-mcp/capabilities.js";
|
||||
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
@@ -948,6 +948,31 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it.each(["unexpected_storage", "oauth_shaped_storage"])("preserves retryable upload storage failures without confusing them with session authority (%s)", async kind => {
|
||||
const f = await expandedFixture();
|
||||
let restoreStorage = () => {};
|
||||
try {
|
||||
const bytes = Buffer.from("synthetic storage failure");
|
||||
const args = { taskId: f.issue.id, requestId: randomUUID(), filename: "data.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const prepared = await f.call("paperclip_get_upload_url", args);
|
||||
const failure = kind === "oauth_shaped_storage" ? new McpOAuthError("access_denied", "SYNTHETIC_STORAGE_FAILURE_CANARY", 409)
|
||||
: new Error("SYNTHETIC_STORAGE_FAILURE_CANARY");
|
||||
const putSpy = vi.spyOn(f.storage, "putFile").mockRejectedValueOnce(failure);
|
||||
restoreStorage = () => { putSpy.mockRestore(); };
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const url = new URL(prepared.url as string);
|
||||
const response = await request(app).put(url.pathname + url.search).set("Content-Type", "text/plain").send(bytes);
|
||||
expect(putSpy).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toEqual({ error: "transfer_unavailable", message: "Transfer could not be confirmed. Retry the same upload identity, or request another download link." });
|
||||
expect(JSON.stringify(response.body)).not.toContain("SYNTHETIC_STORAGE_FAILURE_CANARY");
|
||||
const [upload] = await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, prepared.uploadId as string));
|
||||
expect(upload!.attachmentId).toBeNull();
|
||||
expect((await f.call("paperclip_list_deliverables", { taskId: f.issue.id })).attachments).toHaveLength(0);
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.company.id), eq(activityLog.action, "issue.attachment_added")))).toHaveLength(0);
|
||||
} finally { restoreStorage(); await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("rejects authority lost during storage and safely reclaims the orphaned object", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user