mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
feat(dot): expand governed Runner capabilities and finish pairing setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
426e9fac15
commit
ebc99db2f5
45 files changed
+1409
-105
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { publicMcpManagementRoutes, publicMcpIngressRoutes } from "../routes/public-mcp.js";
|
||||
|
||||
const config = { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/runner" };
|
||||
const callback = "https://chatgpt.com/connector_platform_oauth_redirect";
|
||||
describe("Dot onboarding with an operator-issued pairing capability", () => {
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
temp = await startEmbeddedPostgresTestDatabase("paperclip-dot-onboarding-");
|
||||
db = createDb(temp.connectionString);
|
||||
}, 120000);
|
||||
beforeEach(async () => { await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: true }); });
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
async function fixture(authorizationOrigin?: string) {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot onboarding", issuePrefix: "DO" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", status: "active" }).returning();
|
||||
const oauth = createPublicMcpOAuth(db, { ...config, ...(authorizationOrigin ? { authorizationOrigin } : {}) });
|
||||
const client = await oauth.register({ client_name: "Dot", redirect_uris: [callback], grant_types: ["authorization_code", "refresh_token", DEVICE_GRANT] }, randomUUID());
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const input = { client_id: client.client_id, redirect_uri: callback, response_type: "code", resource: config.resource,
|
||||
scope: "paperclip:agent offline_access", code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256" };
|
||||
const begin = async (extra = {}) => (await oauth.authorize({ ...input, ...extra })).split("/").at(-1)!;
|
||||
const pairing = await dotRunnerBroker(db).createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId });
|
||||
const id = await begin();
|
||||
return { userId, company: company!, agent: agent!, oauth, client, verifier, input, pairing, id, begin };
|
||||
}
|
||||
it("pins browser ingress without changing the issuer, resource or token endpoint", async () => {
|
||||
const browserOrigin = "https://paperclip-browser.example:10000";
|
||||
const f = await fixture(browserOrigin);
|
||||
const personal = createPublicMcpOAuth(db, { ...config, authorizationOrigin: browserOrigin, resource: config.origin + "/mcp/paperclip" });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "none" }; next(); });
|
||||
app.use(publicMcpIngressRoutes(f.oauth, vi.fn()));
|
||||
app.use("/api", publicMcpManagementRoutes(personal, f.oauth));
|
||||
const metadata = await request(app).get("/.well-known/oauth-authorization-server/mcp/runner/oauth");
|
||||
expect(metadata.body).toMatchObject({ issuer: config.origin + "/mcp/runner/oauth",
|
||||
authorization_endpoint: browserOrigin + "/mcp/runner/oauth/authorize", token_endpoint: config.origin + "/mcp/runner/oauth/token" });
|
||||
const authorization = await request(app).get("/mcp/runner/oauth/authorize").query(f.input);
|
||||
expect(authorization.status).toBe(303);
|
||||
expect(new URL(authorization.headers.location).origin).toBe(browserOrigin);
|
||||
const path = `/api/mcp/requests/${f.id}/dot-pairing`;
|
||||
expect((await request(app).post(path).set("Origin", "https://other.example").send({ pairingCode: f.pairing.pairingCode })).status).toBe(403);
|
||||
const approval = await request(app).post(path).set("Origin", browserOrigin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(approval.status).toBe(200);
|
||||
expect(new URL(approval.body.redirectUrl).searchParams.get("iss")).toBe(config.origin + "/mcp/runner/oauth");
|
||||
const device = await f.oauth.deviceAuthorize({ client_id: f.client.client_id, resource: config.resource, scope: "paperclip:agent" }, randomUUID());
|
||||
expect(device.verification_uri).toBe(browserOrigin + "/mcp-device");
|
||||
});
|
||||
it("validates a separately configured browser origin", () => {
|
||||
expect(publicMcpConfig({ PAPERCLIP_PUBLIC_URL: config.origin, PAPERCLIP_MCP_AUTHORIZATION_ORIGIN: "https://browser.example:10000" }))
|
||||
.toMatchObject({ origin: config.origin, resource: config.origin + "/mcp/paperclip", authorizationOrigin: "https://browser.example:10000" });
|
||||
for (const invalid of ["http://browser.example", "https://secret@browser.example", "https://browser.example/path", "https://browser.example?next=evil", "https://browser.example#fragment"])
|
||||
expect(() => publicMcpConfig({ PAPERCLIP_PUBLIC_URL: config.origin, PAPERCLIP_MCP_AUTHORIZATION_ORIGIN: invalid })).toThrow();
|
||||
});
|
||||
it("connects without a board session, binds the exact agent, and consumes the capability once", async () => {
|
||||
const f = await fixture();
|
||||
const personal = createPublicMcpOAuth(db, { ...config, resource: config.origin + "/mcp/paperclip" });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "none" }; next(); });
|
||||
app.use("/api", publicMcpManagementRoutes(personal, f.oauth));
|
||||
const path = `/api/mcp/requests/${f.id}/dot-pairing`;
|
||||
expect((await request(app).post(path).send({ pairingCode: f.pairing.pairingCode })).status).toBe(403);
|
||||
const preview = await request(app).post(path + "/preview").set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body).toMatchObject({ company: { id: f.company.id, name: f.company.name }, agent: { id: f.agent.id, name: f.agent.name } });
|
||||
expect(JSON.stringify(preview.body)).not.toContain(f.pairing.pairingCode);
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(0);
|
||||
const approval = await request(app).post(path).set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode });
|
||||
expect(approval.status).toBe(200);
|
||||
const redirect = new URL(approval.body.redirectUrl);
|
||||
expect(redirect.origin + redirect.pathname).toBe(callback);
|
||||
const exchange = { grant_type: "authorization_code", client_id: f.client.client_id, redirect_uri: callback, resource: config.resource,
|
||||
code: redirect.searchParams.get("code"), code_verifier: f.verifier };
|
||||
await expect(f.oauth.token({ ...exchange, code_verifier: "x".repeat(43) })).rejects.toThrow();
|
||||
const tokens = await f.oauth.token(exchange);
|
||||
const principal = await f.oauth.authenticate(tokens.access_token);
|
||||
expect(principal.actor).toMatchObject({ type: "agent", agentId: f.agent.id, companyId: f.company.id });
|
||||
expect(principal.grant.scopes).toEqual(["paperclip:agent", "offline_access"]);
|
||||
await expect(personal.authenticate(tokens.access_token)).rejects.toThrow();
|
||||
expect(await dotRunnerBroker(db).mailbox(principal)).toMatchObject({ bindingId: f.pairing.bindingId });
|
||||
const [binding] = await db.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, f.pairing.bindingId));
|
||||
expect(binding).toMatchObject({ status: "connected", pairingCodeHash: null, pairingExpiresAt: null });
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig.dotBindingId).toBe(f.pairing.bindingId);
|
||||
await expect(f.oauth.consentDotPairing(f.id, f.pairing.pairingCode)).rejects.toThrow();
|
||||
await expect(f.oauth.consentDotPairing(await f.begin(), f.pairing.pairingCode)).rejects.toThrow();
|
||||
});
|
||||
it.each(["expired-code", "expired-request", "revoked", "paused", "viewer", "wrong-company", "runner-disabled"])("rejects %s without creating a grant", async failure => {
|
||||
const f = await fixture();
|
||||
let id = f.id;
|
||||
if (failure === "expired-code") await db.update(dotAgentBindings).set({ pairingExpiresAt: new Date(0) }).where(eq(dotAgentBindings.id, f.pairing.bindingId));
|
||||
if (failure === "expired-request") await db.update(mcpOauthRequests).set({ expiresAt: new Date(0) }).where(eq(mcpOauthRequests.id, id));
|
||||
if (failure === "revoked") await dotRunnerBroker(db).revoke(f.company.id, f.agent.id, f.userId);
|
||||
if (failure === "paused") await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
if (failure === "viewer") await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.userId)));
|
||||
if (failure === "wrong-company") id = await f.begin({ company_id: randomUUID() });
|
||||
if (failure === "runner-disabled") await instanceSettingsService(db).updateExperimental({ enableNativeRunner: false });
|
||||
await expect(f.oauth.describeDotPairing(id, f.pairing.pairingCode)).rejects.toThrow();
|
||||
await expect(f.oauth.consentDotPairing(id, f.pairing.pairingCode)).rejects.toThrow();
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(0);
|
||||
});
|
||||
it("prevents concurrent requests and the personal endpoint from spending the same capability", async () => {
|
||||
const f = await fixture();
|
||||
const personal = createPublicMcpOAuth(db, { ...config, resource: config.origin + "/mcp/paperclip" });
|
||||
await expect(personal.consentDotPairing(f.id, f.pairing.pairingCode)).rejects.toMatchObject({ status: 403 });
|
||||
const other = await f.begin();
|
||||
const results = await Promise.allSettled([f.oauth.consentDotPairing(f.id, f.pairing.pairingCode), f.oauth.consentDotPairing(other, f.pairing.pairingCode)]);
|
||||
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
heartbeatRuns, agentWakeupRequests, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, workspaceOperations } from "@paperclipai/db";
|
||||
heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, workspaceOperations } 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";
|
||||
@@ -23,6 +23,8 @@ import { prepareNativeHeartbeatRun } from "../services/native-runtime/prepare-na
|
||||
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
import { executePaperclipNativeSession } from "../services/native-runtime/native-session-executor.js";
|
||||
import { registerAssignedMcpGateway } from "../services/native-runtime/assigned-mcp-tools.js";
|
||||
import type { ToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { setupRunnerPrpWebSocketServer } from "../realtime/runner-prp-ws.js";
|
||||
import { finalizeNativeRun } from "../services/native-runtime/native-run-finalizer.js";
|
||||
@@ -100,6 +102,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const broker = dotRunnerBroker(db);
|
||||
const pairing = await broker.createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId });
|
||||
await broker.pair(unpaired, pairing.pairingCode);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig.dotBindingId).toBe(pairing.bindingId);
|
||||
await expect(broker.pair(unpaired, pairing.pairingCode)).rejects.toThrow();
|
||||
const principal = await oauth.authenticate(tokens.access_token);
|
||||
expect(principal.actor).toMatchObject({ type: "agent", agentId: agent!.id, companyId: company!.id });
|
||||
@@ -162,6 +165,29 @@ describe("durable Dot Runner integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("admits one durable idle intake without requiring a preassigned task", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.company.id));
|
||||
const holding = await offeredWork(f);
|
||||
await db.update(heartbeatRuns).set({ status: "running", startedAt: new Date() }).where(eq(heartbeatRuns.id, holding.run.id));
|
||||
const requestId = randomUUID();
|
||||
try {
|
||||
const requests = await Promise.all([1, 2, 3].map(() => f.broker.requestTurn(f.principal, "Create hello and assign it to my owner", requestId)));
|
||||
expect(requests[1]).toEqual(requests[0]); expect(requests[2]).toEqual(requests[0]);
|
||||
const intake = await db.select().from(issues).where(eq(issues.id, requests[0]!.issueId));
|
||||
expect(intake).toHaveLength(1);
|
||||
expect(intake[0]).toMatchObject({ assigneeAgentId: f.agent.id, createdByAgentId: f.agent.id, responsibleUserId: f.userId });
|
||||
expect(await f.broker.capabilities(f.principal)).toMatchObject({ agentId: f.agent.id, responsibleUser: { id: f.userId }, idle: { start: "paperclip_dot_request_turn" } });
|
||||
await expect(f.broker.requestTurn(f.principal, "Different request", requestId)).rejects.toThrow("reused");
|
||||
await db.update(companies).set({ budgetMonthlyCents: 1, spentMonthlyCents: 1 }).where(eq(companies.id, f.company.id));
|
||||
await expect(f.broker.requestTurn(f.principal, "Spend more", randomUUID())).rejects.toThrow("budget");
|
||||
await db.update(companies).set({ budgetMonthlyCents: 0, spentMonthlyCents: 0 }).where(eq(companies.id, f.company.id));
|
||||
await f.broker.revoke(f.company.id, f.agent.id, f.userId);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig).not.toHaveProperty("dotBindingId");
|
||||
await expect(f.broker.requestTurn(f.principal, "After revoke", randomUUID())).rejects.toThrow();
|
||||
} finally { await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, holding.run.id)); }
|
||||
});
|
||||
|
||||
it("keeps competing Dot assignments queued and replays durable work requests", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.company.id));
|
||||
@@ -201,6 +227,29 @@ describe("durable Dot Runner integration", () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("delivers deduplicated follow-up references only for the accepted current assignment", async () => {
|
||||
const f = await fixture(), { run, assignment } = await offeredWork(f);
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Follow-up fixture", assigneeAgentId: f.agent.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: task!.id, status: "running" }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(dotRunnerAssignments).set({ status: "accepted" }).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
const [incoming] = await db.insert(issueComments).values({ companyId: f.company.id, issueId: task!.id, authorUserId: f.userId, body: "Synthetic comment body must not be in webhook data" }).returning();
|
||||
await db.insert(issueComments).values({ companyId: f.company.id, issueId: task!.id, authorAgentId: f.agent.id, body: "Dot's own output" });
|
||||
try {
|
||||
await f.events.tick(); await f.events.tick();
|
||||
const rows = await db.select().from(dotMailboxItems).where(and(eq(dotMailboxItems.assignmentId, assignment.id), eq(dotMailboxItems.kind, "follow_up")));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.references).toEqual({ assignmentId: assignment.id, commentId: incoming!.id });
|
||||
const delivered = f.received.filter(event => event.data?.kind === "follow_up");
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(JSON.stringify(delivered)).not.toContain("Synthetic comment body");
|
||||
expect(JSON.stringify(delivered)).not.toContain("commentId");
|
||||
await db.update(dotRunnerAssignments).set({ status: "fenced" }).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
await db.insert(issueComments).values({ companyId: f.company.id, issueId: task!.id, authorUserId: f.userId, body: "Too late" });
|
||||
await f.events.tick();
|
||||
expect(await db.select().from(dotMailboxItems).where(and(eq(dotMailboxItems.assignmentId, assignment.id), eq(dotMailboxItems.kind, "follow_up")))).toHaveLength(1);
|
||||
} finally { await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop(); }
|
||||
});
|
||||
|
||||
it("serializes tool-result writes and cursor reads with the binding mailbox lock", async () => {
|
||||
const f = await fixture();
|
||||
const { run, assignment } = await offeredWork(f);
|
||||
@@ -400,7 +449,11 @@ describe("durable Dot Runner integration", () => {
|
||||
const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing test server");
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: `http://127.0.0.1:${address.port}` });
|
||||
const observed: Array<Record<string, any>> = [];
|
||||
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);
|
||||
const resultPromise = executePaperclipNativeSession({ db, execution, runnerInstanceId: prepared.runnerInstanceId,
|
||||
runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/dot-fixture", PAPERCLIP_NATIVE_MCP_TOKEN: "fixture-gateway-secret" },
|
||||
useRunnerd: true, turnTimeoutMs: 45000, onEvent: async event => { observed.push(event); }, onLog: async () => {} });
|
||||
// Attach a handler immediately: a bootstrap error must never become an unhandled rejection.
|
||||
let startupError: unknown;
|
||||
@@ -420,6 +473,13 @@ describe("durable Dot Runner integration", () => {
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId === "register_deliverable")).toBe(false);
|
||||
expect(await f.broker.operation(f.principal, assignment!.id, randomUUID(), "tool", { name: "write_document", arguments: {} })).toMatchObject({ status: "rejected" });
|
||||
await f.broker.operation(f.principal, assignment!.id, randomUUID(), "accept", {});
|
||||
expect(JSON.stringify(work)).not.toContain("fixture-gateway-secret");
|
||||
const app = (work.tools as Array<{ operationId: string }>).find(tool => tool.operationId.startsWith("app_"))!;
|
||||
expect(app).toBeTruthy();
|
||||
const appRequestId = randomUUID();
|
||||
await f.broker.operation(f.principal, assignment!.id, appRequestId, "tool", { name: app.operationId, arguments: {} });
|
||||
await vi.waitFor(async () => expect(await f.broker.operationStatus(f.principal, assignment!.id, appRequestId)).toMatchObject({ status: "completed", isError: false }), { timeout: 10000 });
|
||||
expect(appCall).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "dot-fixture", sessionToken: "fixture-gateway-secret", tool: "fixture:read_status" }));
|
||||
await db.update(agents).set({ budgetMonthlyCents: 100, spentMonthlyCents: 100 }).where(eq(agents.id, f.agent.id));
|
||||
await expect(f.broker.read(f.principal, assignment!.id)).rejects.toThrow("execution authority");
|
||||
await db.update(agents).set({ budgetMonthlyCents: 0, spentMonthlyCents: 0, status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
|
||||
@@ -33,6 +33,17 @@ describe("public MCP client metadata", () => {
|
||||
expect((await createClientMetadataResolver(async () => response({ ...metadata, grant_types: ["client_credentials"] }))(id)).grant_types).toEqual([]);
|
||||
await expect(createClientMetadataResolver(async () => response({ ...metadata, grant_types: [null] }))(id)).rejects.toThrow();
|
||||
});
|
||||
it("selects public PKCE only when broader client authentication capabilities include none", async () => {
|
||||
const metadata = { ...document, token_endpoint_auth_method: "private_key_jwt",
|
||||
token_endpoint_auth_methods_supported: ["none", "private_key_jwt"] };
|
||||
expect((await createClientMetadataResolver(async () => response(metadata))(id)).token_endpoint_auth_method).toBe("none");
|
||||
for (const unsupported of [
|
||||
{ ...metadata, token_endpoint_auth_methods_supported: ["private_key_jwt"] },
|
||||
{ ...metadata, token_endpoint_auth_methods_supported: undefined },
|
||||
{ ...metadata, token_endpoint_auth_method: "none", token_endpoint_auth_methods_supported: ["private_key_jwt"] },
|
||||
{ ...metadata, token_endpoint_auth_methods_supported: [null] },
|
||||
]) await expect(createClientMetadataResolver(async () => response(unsupported))(id)).rejects.toThrow();
|
||||
});
|
||||
it("recognizes older loopback-only native metadata without widening declared web clients", async () => {
|
||||
expect((await createClientMetadataResolver(async () => response(document))(id)).application_type).toBe("native");
|
||||
expect((await createClientMetadataResolver(async () => response({ ...document, application_type: "web" }))(id)).application_type).toBe("web");
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import express from "express";
|
||||
import { createServer, request } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { staticUiCompression } from "../middleware/static-ui-compression.js";
|
||||
|
||||
const source = "export const message = 'synthetic static UI fixture';\n".repeat(2000);
|
||||
let root: string;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "paperclip-static-compression-"));
|
||||
await writeFile(path.join(root, "app.js"), source);
|
||||
const app = express();
|
||||
app.get("/mcp/runner", (_req, res) => res.status(401).json({ error: "invalid_token" }));
|
||||
const ui = express.Router();
|
||||
ui.use(staticUiCompression());
|
||||
ui.use("/assets", express.static(root, { maxAge: "1y", immutable: true }));
|
||||
app.use(ui);
|
||||
server = createServer(app);
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => server.close(err => err ? reject(err) : resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function get(url: string, headers: Record<string, string> = {}, method = "GET") {
|
||||
return new Promise<{ status: number; headers: import("node:http").IncomingHttpHeaders; body: Buffer }>((resolve, reject) => {
|
||||
const req = request({ host: "127.0.0.1", port, path: url, headers, method }, res => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", chunk => chunks.push(Buffer.from(chunk)));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("built UI compression", () => {
|
||||
it.each(["gzip", "br"])("compresses large JS with %s while preserving content and caching", async encoding => {
|
||||
const res = await get("/assets/app.js", { "Accept-Encoding": encoding });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-encoding"]).toBe(encoding);
|
||||
expect(res.headers.vary).toContain("Accept-Encoding");
|
||||
expect(res.headers["cache-control"]).toContain("immutable");
|
||||
expect(res.headers["content-type"]).toContain("javascript");
|
||||
expect(res.body.length).toBeLessThan(Buffer.byteLength(source) / 4);
|
||||
expect((encoding === "br" ? brotliDecompressSync(res.body) : gunzipSync(res.body)).toString()).toBe(source);
|
||||
});
|
||||
it("keeps an uncompressed response for clients requesting identity", async () => {
|
||||
const res = await get("/assets/app.js", { "Accept-Encoding": "identity" });
|
||||
expect(res.headers["content-encoding"]).toBeUndefined();
|
||||
expect(res.body.toString()).toBe(source);
|
||||
});
|
||||
it("preserves byte ranges and conditional cache revalidation", async () => {
|
||||
const partial = await get("/assets/app.js", { "Accept-Encoding": "br, gzip", Range: "bytes=0-31" });
|
||||
expect(partial.status).toBe(206);
|
||||
expect(partial.headers["content-encoding"]).toBeUndefined();
|
||||
expect(partial.headers["content-range"]).toBe(`bytes 0-31/${Buffer.byteLength(source)}`);
|
||||
expect(partial.body.toString()).toBe(source.slice(0, 32));
|
||||
const cached = await get("/assets/app.js", { "Accept-Encoding": "gzip" });
|
||||
const fresh = await get("/assets/app.js", { "Accept-Encoding": "gzip", "If-None-Match": cached.headers.etag! });
|
||||
expect(fresh.status).toBe(304);
|
||||
expect(fresh.body.length).toBe(0);
|
||||
const head = await get("/assets/app.js", { "Accept-Encoding": "gzip" }, "HEAD");
|
||||
expect(head.status).toBe(200);
|
||||
expect(head.body.length).toBe(0);
|
||||
});
|
||||
it("does not compress MCP responses or turn missing assets into HTML", async () => {
|
||||
const mcp = await get("/mcp/runner", { "Accept-Encoding": "gzip" });
|
||||
expect(mcp.status).toBe(401);
|
||||
expect(mcp.headers["content-encoding"]).toBeUndefined();
|
||||
expect((await get("/assets/missing.js", { "Accept-Encoding": "gzip" })).status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user