mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip manages agents and governs their work. > - The Runner owns execution and task authority. > - Dot executes at OpenAI and uses Paperclip through MCP. > - Dot previously required a self-hosted controller. > - Cloud must admit Dot work in a managed sandbox and keep protocol traffic in the tenant. > - This pull request adds managed Dot execution and dedicated pairing routes. > - The same Runner path can later host sandboxed workspace tools. ## Linked Issues or Issue Description **Agent or provider** OpenAI Dot, through the existing dedicated MCP adapter. Related changes: #15402, #15414, and #15581. **Why this adapter is useful** Cloud users need to assign work to Dot and let Dot request work while idle. Neither path should execute agent commands on the control-plane host or require an idle sandbox. **How the agent is invoked** The tenant serves OAuth, signed mailbox events, and scoped tools. Normal admission creates a managed sandbox for each assignment. A verified Rust Runner owns that assignment and relays its tools and results. ## What Changed - Enable the external-agent invitation in Cloud behind the standalone Dot flag. - Launch Dot through the existing managed process launcher and authenticated PRP transport. - Verify the artifact digest and Dot capability before dispatch. Read provider checkpoints from the execution target for recovery. - Reject local execution in Cloud and reject managed workspace access for this release. - Add dedicated public Dot pairing aliases. Preserve the personal assistant consent paths. - Keep Dot events in the tenant when a personal Cloud broker is configured. - Use the app-configured heartbeat for Dot-initiated work. Report terminal admission failures and fence stranded offers before new admission. - Offer normal comments through the active assignment's mailbox. Keep their wakes deferred until a successful history receipt contains the exact IDs; preserve unread comments through finish, batching and recovery without replaying consumed input. - Require a sandbox process fingerprint before dispatch and allow a bounded five-minute controller reconnect gap. Preserve remote process birth without inspecting a colliding controller PID. - Recognize authenticated remote shutdown receipts even without an OS exit code. - Document dedicated pairing aliases and teach route coverage to inspect literal path arrays. - Add opt-in private Daytona qualification and focused contract tests. ## Verification - Workspace typecheck, production build, and UI token gates passed. - Focused onboarding, OAuth, managed environment, artifact metadata, and terminal-fence tests passed. One environment-test timeout passed on an isolated repeat. - A private Daytona fixture passed with the real Rust Runner, signed readiness, assigned app tools, document writes, duplicate receipts, authority fences, completion, and sandbox deletion. - A real OpenAI Dot completed both an operator-assigned task and a Dot-initiated task in managed Daytona sandboxes. Both leases were released. - A real accepted Dot assignment survived a controller gap longer than one minute with the same sandbox process, Runner, session, and assignment. Dot consumed the normal task-comment follow-up and completed exactly once; the lease was released. - A real cancellation fenced an accepted assignment, released its lease, and received Dot's acknowledgement without claiming a global provider stop. - Final local checks: 74 Dot execution, OAuth onboarding and API contract tests after integrating the current agent-lifecycle changes, 9 real Rust driver tests, 21 remote-runtime tests plus the 3 detached-process cleanup tests, and 17 lifecycle tests passed. The final live Daytona fixture passed and deleted its sandbox. - A final real Dot follow-up stayed pending until its exact comment appeared in a successful history receipt. Dot completed the original managed assignment once; its lease was released. - The broad local suite crossed a rebase and was stopped; its failures are not a qualification of this final source. Focused repeats passed. The final head passed all CI gates: workspace types, build, general and serialized test suites, Rust and Runner verification, all browser shards, and the canary dry run. Greptile is 5/5 with both findings resolved. ## Risks - Deploy the tenant app with its current Linux Runner artifact and the corresponding front-door protocol routes together. - This branch has not been deployed to a Cloud tenant. A deployed tenant still needs an OAuth and assignment smoke test. - Dot provider usage and cost are unmetered. Revocation fences Paperclip tools and does not prove a global OpenAI stop. - Missing or changed recovery identity fails closed. No replacement Dot thread is invented. - No schema migration is added. Managed Dot workspace access remains unavailable. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, code execution, and browser testing. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
252 lines
20 KiB
TypeScript
252 lines
20 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, dotMailboxItems, mcpEventSubscriptions, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, 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: false }); });
|
|
afterAll(async () => { await temp?.cleanup(); });
|
|
async function fixture(authorizationOrigin?: string, lifecycleState = "ready") {
|
|
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", adapterConfig: { provider: "openai_dot" }, lifecycleState, status: lifecycleState === "ready" ? "active" : "paused" }).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 };
|
|
}
|
|
async function connect(f: Awaited<ReturnType<typeof fixture>>) {
|
|
const consent = await f.oauth.consentDotPairing(f.id, f.pairing.pairingCode);
|
|
return f.oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id,
|
|
redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: f.verifier });
|
|
}
|
|
|
|
it("invalidates setup when a broker pairing is applied or revoked", async () => {
|
|
const f = await fixture(undefined, "preparing");
|
|
const consent = await f.oauth.consent(f.id, { type: "board", source: "session", userId: f.userId },
|
|
{ decision: "approve", companyId: f.company.id, allowWrites: false });
|
|
const tokens = await f.oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id,
|
|
redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: f.verifier });
|
|
const principal = await f.oauth.authenticate(tokens.access_token);
|
|
const broker = dotRunnerBroker(db);
|
|
await broker.pair(principal, f.pairing.pairingCode);
|
|
const [paired] = await db.select().from(agents).where(eq(agents.id, f.agent.id));
|
|
expect(paired.adapterConfig.dotBindingId).toBe(f.pairing.bindingId);
|
|
expect(paired.lifecycleVersion).toBe(f.agent.lifecycleVersion + 1);
|
|
await expect(broker.pair(principal, f.pairing.pairingCode)).rejects.toThrow("Pairing code expired or was consumed");
|
|
|
|
await broker.revoke(f.company.id, f.agent.id, f.userId);
|
|
const [revoked] = await db.select().from(agents).where(eq(agents.id, f.agent.id));
|
|
expect(revoked.adapterConfig).not.toHaveProperty("dotBindingId");
|
|
expect(revoked.lifecycleVersion).toBe(paired.lifecycleVersion + 1);
|
|
await expect(f.oauth.authenticate(tokens.access_token)).rejects.toThrow();
|
|
});
|
|
it.each(["preparing", "verifying"])("allows Dot setup during %s without allowing task access", async (state) => {
|
|
const f = await fixture(undefined, state);
|
|
const tokens = await connect(f);
|
|
const principal = await f.oauth.authenticate(tokens.access_token);
|
|
const broker = dotRunnerBroker(db);
|
|
await db.insert(mcpEventSubscriptions).values({ id: randomUUID(), companyId: f.company.id,
|
|
grantId: principal.grant.id, name: "paperclip.dot.mailbox_updated", bindingId: f.pairing.bindingId,
|
|
arguments: {}, deliveryMaterial: {}, verifiedAt: new Date(), expiresAt: new Date(Date.now() + 60_000) });
|
|
await db.insert(dotMailboxItems).values({ companyId: f.company.id, bindingId: f.pairing.bindingId,
|
|
bindingGeneration: 1, kind: "assignment", sourceEventId: randomUUID(), references: { task: "hidden" } });
|
|
await broker.challenge(f.company.id, f.agent.id);
|
|
const inbox = await broker.mailbox(principal);
|
|
expect(inbox.items.map(item => item.kind)).toEqual(["readiness_challenge"]);
|
|
expect(inbox.nextCursor).toBe(0);
|
|
await expect(broker.confirmChallenge(principal, String(inbox.items[0]!.references.challenge))).resolves.toEqual({ status: "ready" });
|
|
expect(await broker.capabilities(principal)).toMatchObject({ ready: false });
|
|
await expect(broker.tasks(principal)).rejects.toThrow("authority");
|
|
await expect(broker.snapshot(f.company.id, f.agent.id, f.pairing.bindingId)).rejects.toThrow("authority");
|
|
await db.update(agents).set({ lifecycleState: "paused" }).where(eq(agents.id, f.agent.id));
|
|
expect((await broker.mailbox(principal)).items).toEqual([]);
|
|
await expect(broker.confirmChallenge(principal, "already-used")).rejects.toThrow("authority");
|
|
});
|
|
it("refreshes a Dot connection after years of inactivity, still rotates and revokes on replay", async () => {
|
|
const f = await fixture();
|
|
const tokens = await connect(f);
|
|
const [stored] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(tokens.refresh_token!)));
|
|
expect(stored!.expiresAt).toBeNull();
|
|
const refresh = { grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: tokens.refresh_token };
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
try {
|
|
vi.setSystemTime(new Date(Date.now() + 2 * 365 * 24 * 60 * 60_000));
|
|
await expect(f.oauth.authenticate(tokens.access_token)).rejects.toThrow();
|
|
const next = await f.oauth.token(refresh);
|
|
expect(next.refresh_token).not.toBe(tokens.refresh_token);
|
|
expect(next.expires_in).toBe(900);
|
|
expect((await f.oauth.authenticate(next.access_token)).actor).toMatchObject({ type: "agent", agentId: f.agent.id });
|
|
const [rotated] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(next.refresh_token!)));
|
|
expect(rotated!.expiresAt).toBeNull();
|
|
await expect(f.oauth.token(refresh)).rejects.toThrow();
|
|
await expect(f.oauth.authenticate(next.access_token)).rejects.toThrow();
|
|
expect(await dotRunnerBroker(db).bindingForAgent(f.company.id, f.agent.id)).toBeNull();
|
|
} finally { vi.useRealTimers(); }
|
|
});
|
|
it("upgrades valid Dot refresh tokens without reviving expired, used or revoked credentials", async () => {
|
|
const f = await fixture();
|
|
const tokens = await connect(f);
|
|
const grant = (await f.oauth.authenticate(tokens.access_token)).grant;
|
|
const validExpiry = new Date(Date.now() + 30 * 24 * 60 * 60_000);
|
|
await db.update(mcpOauthTokens).set({ expiresAt: validExpiry }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(tokens.refresh_token!)));
|
|
const stale = await fixture();
|
|
const staleTokens = await connect(stale);
|
|
const expired = new Date(0);
|
|
await db.update(mcpOauthTokens).set({ expiresAt: expired }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(staleTokens.refresh_token!)));
|
|
const personalId = randomUUID();
|
|
const revokedId = randomUUID();
|
|
const usedHash = randomUUID();
|
|
await db.insert(mcpOauthGrants).values([
|
|
{ ...grant, id: personalId, purpose: "personal", agentId: null, resource: config.origin + "/mcp/paperclip" },
|
|
{ ...grant, id: revokedId, revokedAt: new Date() },
|
|
]);
|
|
await db.insert(mcpOauthTokens).values([
|
|
{ grantId: personalId, tokenHash: randomUUID(), kind: "refresh", expiresAt: validExpiry },
|
|
{ grantId: revokedId, tokenHash: randomUUID(), kind: "refresh", expiresAt: validExpiry },
|
|
{ grantId: grant.id, tokenHash: usedHash, kind: "refresh", expiresAt: validExpiry, usedAt: new Date() },
|
|
]);
|
|
const migration = await readFile(new URL("../../../packages/db/src/migrations/0319_heavy_captain_midlands.sql", import.meta.url), "utf8");
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement));
|
|
}
|
|
const [personal] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.grantId, personalId));
|
|
const [revoked] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.grantId, revokedId));
|
|
const [used] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, usedHash));
|
|
const [staleToken] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(staleTokens.refresh_token!)));
|
|
expect(personal!.expiresAt).toEqual(validExpiry);
|
|
expect(revoked!.expiresAt).toEqual(validExpiry);
|
|
expect(used!.expiresAt).toEqual(validExpiry);
|
|
expect(staleToken!.expiresAt).toEqual(expired);
|
|
await expect(stale.oauth.token({ grant_type: "refresh_token", client_id: stale.client.client_id, resource: config.resource, refresh_token: staleTokens.refresh_token })).rejects.toThrow();
|
|
const next = await f.oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: tokens.refresh_token });
|
|
expect((await f.oauth.authenticate(next.access_token)).actor).toMatchObject({ type: "agent", agentId: f.agent.id });
|
|
await f.oauth.revokeToken(next.access_token, f.client.client_id);
|
|
await expect(f.oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: next.refresh_token })).rejects.toThrow();
|
|
});
|
|
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);
|
|
expect(new URL(authorization.headers.location).pathname).toMatch(/^\/dot-connect\/pcmcp_request_[A-Za-z0-9_-]{43}$/);
|
|
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.each(["mcp", "dot-mcp"])("connects via %s without a board session, binds the exact agent, and consumes the capability once", async prefix => {
|
|
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/${prefix}/requests/${f.id}/dot-pairing`;
|
|
expect((await request(app).get(`/api/${prefix}/requests/${f.id}`)).body.agentConnection).toBe(true);
|
|
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.accessDuration).toBe("Ongoing until revoked. This connection does not expire from inactivity.");
|
|
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("does not expose personal assistant requests through the public Dot pairing lane", async () => {
|
|
const f = await fixture();
|
|
const personal = createPublicMcpOAuth(db, { ...config, resource: config.origin + "/mcp/paperclip" });
|
|
const client = await personal.register({ client_name: "Personal", redirect_uris: [callback], grant_types: ["authorization_code"] }, randomUUID());
|
|
const id = (await personal.authorize({ ...f.input, client_id: client.client_id, resource: config.origin + "/mcp/paperclip", scope: "paperclip:read" })).split("/").at(-1)!;
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "none" }; next(); });
|
|
app.use("/api", publicMcpManagementRoutes(personal, f.oauth));
|
|
expect((await request(app).get(`/api/dot-mcp/requests/${id}`)).status).toBe(404);
|
|
for (const suffix of ["", "/preview"]) {
|
|
expect((await request(app).post(`/api/dot-mcp/requests/${id}/dot-pairing${suffix}`).set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode })).status).toBe(404);
|
|
}
|
|
expect((await request(app).get(`/api/mcp/requests/${id}`)).status).toBe(200);
|
|
});
|
|
it.each(["expired-code", "expired-request", "revoked", "paused", "viewer", "wrong-company", "dot-disabled", "mcp-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 === "dot-disabled") await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: false });
|
|
if (failure === "mcp-disabled") await instanceSettingsService(db).updateExperimental({ enablePublicMcp: 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);
|
|
});
|
|
});
|