mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Fix Dot reconnect, disconnect, and pairing recovery
Wake the existing assignment after verified reconnection, fence and cancel matching agent work on OAuth revocation and replay, and restore durable pairing references into reopened forms. Synchronize protocol fixtures and board-only OpenAPI coverage with the merged gateway. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
aaddf6b6b0
commit
5201456bfc
16 files changed
+265
-24
No files matched your search
@@ -83,7 +83,15 @@ normal admission with `paperclip_dot_tasks` and `paperclip_dot_request_work`.
|
||||
not durably recorded; recovery does not execute that write again.
|
||||
- Keep the public origin stable. Subscriptions expire and must be renewed;
|
||||
reconnection drains current mailbox references rather than claiming event
|
||||
replay. Disabling new Dot work does not grant old assignments new authority.
|
||||
replay. Disabling new Dot work does not grant old assignments new authority.
|
||||
|
||||
Resubscribing verifies the Dot callback again and publishes a fresh mailbox
|
||||
reference for its existing outstanding assignment. It does not create another
|
||||
assignment or replay tool effects. OAuth revocation and refresh-token replay
|
||||
also revoke the matching binding, fence its assignments, and cancel waiting
|
||||
Paperclip runs. Revoking an old grant cannot revoke a replacement binding.
|
||||
Reopening an agent form restores the binding reference from the server so a
|
||||
previously completed pairing can still be saved in the agent configuration.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
@@ -120,3 +128,22 @@ assertion (497 of 498 expected joined requests). Both failing cases passed in
|
||||
isolation. A subsequent stable database lane passed 126 tests but failed one
|
||||
embedded PostgreSQL startup; that test also passed in isolation. These results
|
||||
do not establish a completely green repository suite or release readiness.
|
||||
|
||||
Gateway integration on 2026-10-06 merged master through `c365a16e3`, including
|
||||
the released browser/device consent and assistant invitations (#14846 and
|
||||
#14933). Initial integration verification passed full workspace typecheck and
|
||||
build, token gates, 125 gateway/consent/admission tests, five Dot integration
|
||||
tests, 20 consent/Connections UI tests, 318 Rust library tests, 123 Runner/Dot
|
||||
recovery tests and 13 PRP schema tests.
|
||||
|
||||
The local root `pnpm test:run` was stopped after 2 hours 47 minutes when review
|
||||
fixes made it stale; it did not complete and is not a passing result. Fresh
|
||||
PR CI verifies the final branch. The review fixes cover reconnect wakeups,
|
||||
OAuth disconnect and refresh replay, binding-reference restoration, board-only
|
||||
OpenAPI coverage and prior protocol-version assumptions. Current results and
|
||||
remaining qualification are tracked in [PR #15402](https://github.com/paperclipai/paperclip/pull/15402).
|
||||
|
||||
After those fixes, full workspace typecheck and token gates passed again.
|
||||
Focused verification passed 89 gateway, Dot, OpenAPI, connection-instruction
|
||||
and pairing UI tests, 40 protocol/runtime compatibility tests, and the real
|
||||
Runner protocol-upgrade/replacement test.
|
||||
@@ -79,8 +79,11 @@ uses the current shared harness selector.
|
||||
|
||||
Targeted gateway, Dot, onboarding and UI verification passed 125 tests before
|
||||
the added migration replay check. Full workspace typecheck/build and token
|
||||
gates passed. The full repository suite and Runner recovery checks are running;
|
||||
their final results are recorded in the adapter runbook.
|
||||
gates passed. Runner recovery verification passed 123 tests and the Rust
|
||||
workspace passed 318 library tests. The local full repository attempt was
|
||||
stopped after 2 hours 47 minutes when review fixes made it stale. Final branch
|
||||
CI and review are tracked in [PR #15402](https://github.com/paperclipai/paperclip/pull/15402);
|
||||
the adapter runbook records verification limits.
|
||||
|
||||
The other gateway chat's subsequent assistant-tool expansion is still separate
|
||||
work and is not part of this integration. Real-account qualification of the
|
||||
|
||||
@@ -31,6 +31,7 @@ function runtimeInput(
|
||||
entryPath: string,
|
||||
): NativeExecutionInput {
|
||||
return {
|
||||
provider: { kind: "codex" },
|
||||
runtimeContext: {
|
||||
prompt: { text: "Paperclip runtime." },
|
||||
instructions: { bundle: { rootPath }, entryPath },
|
||||
|
||||
@@ -80,8 +80,9 @@ describe("Paperclip Evals integration compatibility", () => {
|
||||
requirement.runnerd.nativeExecutionVersion = 2;
|
||||
requirement.runnerd.harnessDriverVersion = 2;
|
||||
requirement.nativeExecutionVersion = 2;
|
||||
requirement.prp = { minimumVersion: 3, maximumVersion: 3 };
|
||||
requirement.runnerd.prp = { name: "paperclip.runner", minimumVersion: 3, maximumVersion: 3 };
|
||||
const unsupportedVersion = PAPERCLIP_RUNNER_BUILD_METADATA.prp.maximumVersion + 1;
|
||||
requirement.prp = { minimumVersion: unsupportedVersion, maximumVersion: unsupportedVersion };
|
||||
requirement.runnerd.prp = { name: "paperclip.runner", minimumVersion: unsupportedVersion, maximumVersion: unsupportedVersion };
|
||||
requirement.catalog = { version: 2, sha256: `sha256:${"0".repeat(64)}` };
|
||||
requirement.driver.contractVersion = 2;
|
||||
requirement.driver.descriptor.capabilities.dynamicTools = false;
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
PrpStructuredRunResult,
|
||||
PrpTerminalState,
|
||||
} from "../protocol/replay-contract.js";
|
||||
import { PRP_PROTOCOL_VERSION } from "../protocol/replay-contract.js";
|
||||
import { executeNativeSession } from "../native-session-runtime.js";
|
||||
import { redactCapabilityEvidenceData } from "./evidence-redaction.js";
|
||||
import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js";
|
||||
@@ -232,7 +233,7 @@ it("replaces an owned v1 runner with fresh v2 authorization before warm attachme
|
||||
async () => {
|
||||
expect(handles).toHaveLength(2);
|
||||
const state = await runnerState();
|
||||
expect(state.lastConnectionProtocolVersion).toBe(2);
|
||||
expect(state.lastConnectionProtocolVersion).toBe(PRP_PROTOCOL_VERSION);
|
||||
expect(state.v2ReplayEvents).toEqual({});
|
||||
expect(state.outbox).toEqual([]);
|
||||
expect(core.activeRunnerConnectionCount()).toBe(1);
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("PRP v1 JSON Schema contract", () => {
|
||||
it("fails closed on unsupported nested required schema versions", async () => {
|
||||
const fixture = await readFixture();
|
||||
const events = fixture.events as Array<Record<string, unknown>>;
|
||||
events[0]!.schemaVersion = 3;
|
||||
events[0]!.schemaVersion = PRP_PROTOCOL_VERSION + 1;
|
||||
expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({
|
||||
ok: false,
|
||||
issues: [
|
||||
|
||||
@@ -147,7 +147,7 @@ async function createRemoteMcpToolFixture(db: ReturnType<typeof createDb>, compa
|
||||
try {
|
||||
await writeFile(join(bundle, "AGENTS.md"), "Agent entry instructions.");
|
||||
parsed.instructions.bundle.rootPath = bundle;
|
||||
expect(nativeSystemInstructions({ runtimeContext: parsed } as NativeExecutionInput)).toContain(paragraph);
|
||||
expect(nativeSystemInstructions({ provider: { kind: "codex" }, runtimeContext: parsed } as NativeExecutionInput)).toContain(paragraph);
|
||||
} finally { await rm(bundle, { recursive: true, force: true }); }
|
||||
expect(() => parseNativeRuntimeContext({ ...runtimeContext, connectionInstructions: { ...snapshot, text: "forged" } })).toThrow();
|
||||
expect(parseNativeRuntimeContext(nativeRuntimeContextFixture()).connectionInstructions).toBeUndefined();
|
||||
|
||||
@@ -73,12 +73,13 @@ describe("durable Dot Runner integration", () => {
|
||||
await expect(personal.authenticate(tokens.access_token)).rejects.toThrow();
|
||||
const secret = "whsec_" + randomBytes(32).toString("base64");
|
||||
const received: Array<Record<string, any>> = [];
|
||||
let verifications = 0;
|
||||
const fetcher: EventFetch = async (_url, init) => {
|
||||
const headers = new Headers(init.headers); const bytes = String(init.body); const body = JSON.parse(bytes);
|
||||
const signature = "v1," + createHmac("sha256", Buffer.from(secret.slice(6), "base64"))
|
||||
.update(`${headers.get("webhook-id")}.${headers.get("webhook-timestamp")}.${bytes}`).digest("base64");
|
||||
expect(headers.get("webhook-signature")).toContain(signature);
|
||||
if (body.type === "verification") return Response.json({ challenge: body.challenge });
|
||||
if (body.type === "verification") { verifications++; return Response.json({ challenge: body.challenge }); }
|
||||
received.push(body); return new Response(null, { status: 204 });
|
||||
};
|
||||
const events = createPublicMcpEvents(db, oauth, async () => { throw new Error("personal API dispatch forbidden"); }, { enableDotRunner: true, fetch: fetcher });
|
||||
@@ -92,7 +93,20 @@ describe("durable Dot Runner integration", () => {
|
||||
await broker.confirmChallenge(principal, String(challenge.references.challenge));
|
||||
const snapshot = await broker.snapshot(company!.id, agent!.id, pairing.bindingId);
|
||||
await db.update(agents).set({ adapterConfig: { provider: "openai_dot", dotBindingId: pairing.bindingId, allowUnmeteredProvider: true, lifecycleMode: "per_turn" } }).where(eq(agents.id, agent!.id));
|
||||
return { company: company!, agent: agent!, userId, broker, principal, oauth, events, subscription, received, snapshot };
|
||||
return { company: company!, agent: agent!, userId, broker, principal, oauth, events, subscription, received, snapshot, tokens, client, verifications: () => verifications };
|
||||
}
|
||||
|
||||
async function offeredWork(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id,
|
||||
status: "queued", invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const [assignment] = await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, bindingId: f.snapshot.bindingId,
|
||||
bindingGeneration: f.snapshot.bindingGeneration, runId: run!.id, agentId: f.agent.id, normalizedSessionId: randomUUID(),
|
||||
turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "test", status: "offered", projection: {},
|
||||
acceptBy: new Date(Date.now() + 10 * 60_000), expiresAt: new Date(Date.now() + 2 * 60 * 60_000) }).returning();
|
||||
await db.insert(dotMailboxItems).values({ companyId: f.company.id, bindingId: f.snapshot.bindingId,
|
||||
bindingGeneration: f.snapshot.bindingGeneration, assignmentId: assignment!.id, kind: "assignment", sourceEventId: randomUUID(),
|
||||
references: { assignmentId: assignment!.id, runId: run!.id, revision: 1 } });
|
||||
return { run: run!, assignment: assignment! };
|
||||
}
|
||||
|
||||
function gateway(oauth: ReturnType<typeof createPublicMcpOAuth>, actor: Request["actor"]) {
|
||||
@@ -114,6 +128,54 @@ describe("durable Dot Runner integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies reconnects and wakes the same outstanding assignment after exhausted delivery", async () => {
|
||||
const f = await fixture();
|
||||
const { assignment } = await offeredWork(f);
|
||||
await f.events.tick();
|
||||
const previousEventId = f.received.at(-1)?.eventId;
|
||||
const before = await f.broker.mailbox(f.principal);
|
||||
const oldItem = before.items.find(item => item.assignmentId === assignment.id)!;
|
||||
await db.update(mcpEventDeliveries).set({ attempts: 6, finishedAt: new Date(), outcome: "delivery_exhausted" })
|
||||
.where(eq(mcpEventDeliveries.mailboxItemId, oldItem.id));
|
||||
const failedReconnect = createPublicMcpEvents(db, f.oauth, async () => { throw new Error("personal dispatch forbidden"); },
|
||||
{ enableDotRunner: true, fetch: async () => Response.json({ challenge: "wrong" }) });
|
||||
await expect(failedReconnect.subscribe(f.principal, f.subscription)).rejects.toThrow();
|
||||
expect((await f.broker.mailbox(f.principal, before.nextCursor)).items).toEqual([]);
|
||||
const verificationCount = f.verifications();
|
||||
await f.events.subscribe(f.principal, f.subscription);
|
||||
expect(f.verifications()).toBe(verificationCount + 1);
|
||||
const after = await f.broker.mailbox(f.principal, before.nextCursor);
|
||||
expect(after.items).toHaveLength(1);
|
||||
expect(after.items[0]).toMatchObject({ kind: "assignment", assignmentId: assignment.id });
|
||||
expect(await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.bindingId, f.snapshot.bindingId))).toHaveLength(1);
|
||||
await f.events.tick();
|
||||
expect(f.received.at(-1)?.data.mailboxItemId).toBe(after.items[0]!.id);
|
||||
expect(f.received.at(-1)?.eventId).not.toBe(previousEventId);
|
||||
}, 30000);
|
||||
|
||||
it.each(["OAuth revocation", "refresh-token replay"])("%s fences Dot and cancels waiting work", async reason => {
|
||||
const f = await fixture();
|
||||
const { run, assignment } = await offeredWork(f);
|
||||
const { app, personal } = gateway(f.oauth, { type: "board", source: "session", userId: f.userId });
|
||||
// A token submitted to the other resource's revocation endpoint has no effect.
|
||||
await personal.revokeToken(f.tokens.access_token, f.client.client_id);
|
||||
expect(await f.broker.bindingForAgent(f.company.id, f.agent.id)).not.toBeNull();
|
||||
if (reason === "OAuth revocation") {
|
||||
expect((await request(app).post("/mcp/runner/oauth/revoke").send({ token: f.tokens.access_token, client_id: f.client.client_id })).status).toBe(200);
|
||||
} else {
|
||||
const refresh = { grant_type: "refresh_token", refresh_token: f.tokens.refresh_token, client_id: f.client.client_id, resource: f.oauth.config.resource };
|
||||
await f.oauth.token(refresh);
|
||||
await expect(f.oauth.token(refresh)).rejects.toThrow();
|
||||
}
|
||||
expect(await f.broker.bindingForAgent(f.company.id, f.agent.id)).toBeNull();
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, assignment.id)))[0]?.status).toBe("fenced");
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]?.status).toBe("cancelled");
|
||||
await expect(f.oauth.authenticate(f.tokens.access_token)).rejects.toThrow();
|
||||
const next = await f.broker.createPairing({ companyId: f.company.id, agentId: f.agent.id, operatorId: f.userId });
|
||||
await f.oauth.revokeToken(f.tokens.access_token, f.client.client_id);
|
||||
expect((await f.broker.bindingForAgent(f.company.id, f.agent.id))?.id).toBe(next.bindingId);
|
||||
}, 30000);
|
||||
|
||||
it("uses merged client metadata and scoped browser consent with a distinct Dot issuer", async () => {
|
||||
const f = await fixture();
|
||||
const clientId = `https://dot-${randomUUID()}.example/oauth.json`;
|
||||
|
||||
@@ -40,6 +40,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"connection-intents.ts": "/api",
|
||||
"costs.ts": "/api",
|
||||
"dashboard.ts": "/api",
|
||||
"dot-runner.ts": "/api",
|
||||
"decision-queues.ts": "/api",
|
||||
"decisions.ts": "/api",
|
||||
"decision-training.ts": "/api",
|
||||
@@ -102,6 +103,12 @@ const explicitOpenApiOperationCoverageExclusions = new Set([
|
||||
"GET /mcp/oauth/authorize",
|
||||
"POST /mcp/oauth/token",
|
||||
"POST /mcp/oauth/revoke",
|
||||
"GET /.well-known/oauth-authorization-server/mcp/runner/oauth",
|
||||
"POST /mcp/runner/oauth/register",
|
||||
"POST /mcp/runner/oauth/device_authorization",
|
||||
"GET /mcp/runner/oauth/authorize",
|
||||
"POST /mcp/runner/oauth/token",
|
||||
"POST /mcp/runner/oauth/revoke",
|
||||
// This endpoint is authenticated by the provider signature rather than by a
|
||||
// Paperclip board/agent credential. It intentionally stays out of the public
|
||||
// board API document, while this exact exclusion keeps route coverage honest.
|
||||
@@ -199,6 +206,28 @@ function loadActualRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
if (file === "public-mcp.ts") {
|
||||
// The shared gateway mounts these protocol paths for each OAuth resource.
|
||||
if (source.includes("router.get(metadataPath,")) {
|
||||
excludedRoutes.add("GET /.well-known/oauth-authorization-server");
|
||||
excludedRoutes.add("GET /.well-known/oauth-authorization-server/mcp/runner/oauth");
|
||||
}
|
||||
for (const match of source.matchAll(/router\.(get|post)\(oauthPath \+ "([^"]+)"/g)) {
|
||||
for (const oauthPath of ["/mcp/oauth", "/mcp/runner/oauth"]) {
|
||||
const operation = `${match[1].toUpperCase()} ${oauthPath}${match[2]}`;
|
||||
if (explicitOpenApiOperationCoverageExclusions.has(operation)) excludedRoutes.add(operation);
|
||||
else routes.add(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (file === "dot-runner.ts") {
|
||||
const basePath = /const path = "([^"]+)"/.exec(source)?.[1];
|
||||
if (!basePath) throw new Error("Dot binding route prefix is missing");
|
||||
for (const match of source.matchAll(/router\.(get|post|delete)\(path(?: \+ "([^"]+)")?/g)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
file === "companies.ts" &&
|
||||
source.includes("router.post(COMPANY_IMPORT_ROUTE_PATH")
|
||||
|
||||
@@ -1687,6 +1687,7 @@ function resolveOperationAuthLevel(
|
||||
const key = operationKey(method, path);
|
||||
if (key === "GET /api/mcp/requests/{id}" || key === "GET /api/mcp/device") return "public";
|
||||
if (path === "/api/mcp/setup" || path === "/api/mcp/device/consent" || path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board";
|
||||
if (/^\/api\/companies\/\{companyId\}\/agents\/\{agentId\}\/dot-binding(?:\/event-test)?$/.test(path)) return "board";
|
||||
if (PUBLIC_OPERATIONS.has(key)) return "public";
|
||||
if (key === "POST /api/companies/{companyId}/agent-commentary") return "agent_heartbeat";
|
||||
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run";
|
||||
@@ -11471,6 +11472,30 @@ registerCurrentRoute({
|
||||
},
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"],
|
||||
summary: "Read the experimental Dot agent connection and event readiness",
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"],
|
||||
summary: "Create a one-use Dot pairing code as a company operator",
|
||||
body: z.object({ dotUrl: z.string().max(2048).optional() }).strict(),
|
||||
responses: { 201: r.ok(z.object({ bindingId: z.string().uuid(), pairingCode: z.string(), expiresAt: z.string().datetime(), instructions: z.string() })),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding/event-test", tags: ["agents"],
|
||||
summary: "Request a harmless Dot readiness challenge as a company operator",
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "delete", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"],
|
||||
summary: "Revoke a Dot connection and fence its active Paperclip assignments",
|
||||
responses: { 204: { description: "Dot connection revoked; external stop is unconfirmed" },
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/mcp/setup", tags: ["tool-gateway"],
|
||||
summary: "Read assistant connection setup using a human browser session",
|
||||
|
||||
@@ -261,9 +261,9 @@ function createBroker(db: Db) {
|
||||
action: "dot.event_test_completed", entityType: "agent", entityId: b.agentId, details: { bindingId: b.id } });
|
||||
return { status: "ready" };
|
||||
},
|
||||
async revoke(companyId: string, agentId: string, operatorId: string) {
|
||||
async revoke(companyId: string, agentId: string, operatorId: string, grantId?: string) {
|
||||
const runs = await db.transaction(async tx => {
|
||||
const [b] = await tx.select().from(bindings).where(and(eq(bindings.companyId, companyId), eq(bindings.agentId, agentId), isNull(bindings.revokedAt))).for("update");
|
||||
const [b] = await tx.select().from(bindings).where(and(eq(bindings.companyId, companyId), eq(bindings.agentId, agentId), isNull(bindings.revokedAt), grantId ? eq(bindings.grantId, grantId) : undefined)).for("update");
|
||||
if (!b) return [];
|
||||
const active = await tx.select({ runId: assignments.runId }).from(assignments).where(and(eq(assignments.bindingId, b.id), inArray(assignments.status, ["offered", "accepted"])));
|
||||
await tx.update(bindings).set({ status: "revoked", generation: b.generation + 1, revokedAt: new Date(), pairingCodeHash: null, challengeHash: null, updatedAt: new Date() }).where(eq(bindings.id, b.id));
|
||||
|
||||
@@ -110,7 +110,9 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
|
||||
const previous = existing ? await decrypt(existing) : null;
|
||||
const [pending] = await tx.select().from(admissions).where(and(eq(admissions.subscriptionId, id), isNull(admissions.finishedAt)));
|
||||
if (pending) throw new McpEventError(-32602, "Subscription verification is in progress. Retry shortly.");
|
||||
const verify = !existing || existing.verifiedAt.getTime() + rotationMs <= now() || previous?.secret !== secret;
|
||||
// Dot refreshes reconnect a worker. Verify its callback before publishing
|
||||
// a fresh mailbox reference for work whose earlier wakeup may be lost.
|
||||
const verify = !!input.arguments.bindingId || !existing || existing.verifiedAt.getTime() + rotationMs <= now() || previous?.secret !== secret;
|
||||
if (!existing) {
|
||||
const [total] = await tx.select({ n: count() }).from(subscriptions);
|
||||
const [company] = await tx.select({ n: count() }).from(subscriptions).where(eq(subscriptions.companyId, principal.grant.companyId));
|
||||
@@ -171,6 +173,21 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
|
||||
arguments: input.arguments, deliveryMaterial: material, expiresAt, stoppedAt: null,
|
||||
verifiedAt: verify ? new Date(now()) : existing!.verifiedAt, startsAt: existing?.startsAt ?? requestedAt, scannedAt: new Date(now()) };
|
||||
await tx.insert(subscriptions).values({ id, ...value }).onConflictDoUpdate({ target: subscriptions.id, set: value });
|
||||
if (input.arguments.bindingId && lease) {
|
||||
const [binding] = await tx.select().from(dotAgentBindings).where(and(
|
||||
eq(dotAgentBindings.id, input.arguments.bindingId), eq(dotAgentBindings.companyId, principal.grant.companyId),
|
||||
eq(dotAgentBindings.grantId, principal.grant.id), isNull(dotAgentBindings.revokedAt))).for("update");
|
||||
if (!binding) throw new McpEventError(-32602, "The Dot binding was revoked. Reconnect the agent.");
|
||||
const outstanding = await tx.select().from(dotRunnerAssignments).where(and(
|
||||
eq(dotRunnerAssignments.bindingId, binding.id), eq(dotRunnerAssignments.bindingGeneration, binding.generation),
|
||||
gt(dotRunnerAssignments.expiresAt, new Date(now())),
|
||||
or(eq(dotRunnerAssignments.status, "accepted"), and(eq(dotRunnerAssignments.status, "offered"), gt(dotRunnerAssignments.acceptBy, new Date(now()))))));
|
||||
for (const assignment of outstanding) await tx.insert(dotMailboxItems).values({
|
||||
companyId: binding.companyId, bindingId: binding.id, bindingGeneration: binding.generation,
|
||||
assignmentId: assignment.id, kind: "assignment", sourceEventId: "reconnect_" + lease.id,
|
||||
references: { assignmentId: assignment.id, runId: assignment.runId, revision: assignment.revision },
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
await logActivity(tx as unknown as Db, { companyId: principal.grant.companyId, actorType: "user", actorId: principal.grant.userId,
|
||||
action: "mcp.event_subscribed", entityType: "mcp_subscription", entityId: id, details: { name: input.name, taskId: input.arguments.taskId, expiresAt: expiresAt.toISOString() } });
|
||||
return { id, refreshBefore: expiresAt.toISOString(), cursor: null, truncated: false };
|
||||
|
||||
@@ -77,6 +77,12 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
}
|
||||
const boardAuth = boardAuthService(db);
|
||||
|
||||
async function fenceAgentGrant(grant: typeof mcpOauthGrants.$inferSelect | null | undefined) {
|
||||
if (grant?.purpose !== "agent" || !grant.agentId) return;
|
||||
const { dotRunnerBroker } = await import("../dot-runner-broker.js");
|
||||
await dotRunnerBroker(db).revoke(grant.companyId, grant.agentId, grant.userId, grant.id);
|
||||
}
|
||||
|
||||
async function actorForGrant(grant: typeof mcpOauthGrants.$inferSelect): Promise<Request["actor"]> {
|
||||
if (grant.revokedAt || grant.resource !== config.resource) throw invalidGrant();
|
||||
const access = await boardAuth.resolveBoardAccess(grant.userId);
|
||||
@@ -419,6 +425,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
if (typeof input.refresh_token !== "string") throw invalidGrant();
|
||||
const tokenHash = hashMcpSecret(input.refresh_token);
|
||||
// A replay revokes the whole grant. Commit that revocation before returning an error.
|
||||
let revokedGrant: typeof mcpOauthGrants.$inferSelect | null = null;
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [token] = await tx.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, tokenHash)).for("update");
|
||||
if (!token || token.kind !== "refresh") return null;
|
||||
@@ -426,6 +433,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
if (!grant || grant.clientId !== clientId || grant.resource !== config.resource || grant.revokedAt) return null;
|
||||
if (token.usedAt) {
|
||||
await tx.update(mcpOauthGrants).set({ revokedAt: new Date() }).where(eq(mcpOauthGrants.id, grant.id));
|
||||
revokedGrant = grant;
|
||||
await tx.insert(activityLog).values({
|
||||
companyId: grant.companyId, actorType: "system", actorId: "mcp_oauth",
|
||||
action: "mcp.connection_revoked", entityType: "mcp_connection", entityId: grant.id,
|
||||
@@ -441,6 +449,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
await tx.update(mcpOauthTokens).set({ usedAt: new Date() }).where(eq(mcpOauthTokens.id, token.id));
|
||||
return issueTokens(tx as unknown as Db, grant);
|
||||
});
|
||||
await fenceAgentGrant(revokedGrant);
|
||||
if (!result) throw invalidGrant();
|
||||
return result;
|
||||
}
|
||||
@@ -466,10 +475,10 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
catch { throw new McpOAuthError("invalid_token", "Paperclip access is no longer available.", 401); }
|
||||
},
|
||||
async revokeToken(token: string, clientId: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
const grant = await db.transaction(async (tx) => {
|
||||
const [row] = await tx.select({ grant: mcpOauthGrants }).from(mcpOauthTokens)
|
||||
.innerJoin(mcpOauthGrants, eq(mcpOauthTokens.grantId, mcpOauthGrants.id))
|
||||
.where(and(eq(mcpOauthTokens.tokenHash, hashMcpSecret(token)), eq(mcpOauthGrants.clientId, clientId)));
|
||||
.where(and(eq(mcpOauthTokens.tokenHash, hashMcpSecret(token)), eq(mcpOauthGrants.clientId, clientId), eq(mcpOauthGrants.resource, config.resource)));
|
||||
if (!row) return;
|
||||
const [revoked] = await tx.update(mcpOauthGrants).set({ revokedAt: new Date() })
|
||||
.where(and(eq(mcpOauthGrants.id, row.grant.id), isNull(mcpOauthGrants.revokedAt))).returning();
|
||||
@@ -478,7 +487,9 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
action: "mcp.connection_revoked", entityType: "mcp_connection", entityId: revoked.id,
|
||||
details: { clientId, reason: "oauth_revocation" },
|
||||
});
|
||||
return row.grant;
|
||||
});
|
||||
await fenceAgentGrant(grant);
|
||||
},
|
||||
async listConnections(userId: string) {
|
||||
const rows = await db.select({ grant: mcpOauthGrants, clientName: mcpOauthClients.name, companyName: companies.name })
|
||||
@@ -493,10 +504,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
async revokeConnection(id: string, userId: string) {
|
||||
const [grant] = await db.update(mcpOauthGrants).set({ revokedAt: new Date() })
|
||||
.where(and(eq(mcpOauthGrants.id, id), eq(mcpOauthGrants.userId, userId), isNull(mcpOauthGrants.revokedAt))).returning();
|
||||
if (grant?.purpose === "agent" && grant.agentId) {
|
||||
const { dotRunnerBroker } = await import("../dot-runner-broker.js");
|
||||
await dotRunnerBroker(db).revoke(grant.companyId, grant.agentId, userId);
|
||||
}
|
||||
await fenceAgentGrant(grant);
|
||||
if (grant) await logActivity(db, {
|
||||
companyId: grant.companyId, actorType: "user", actorId: userId, action: "mcp.connection_revoked",
|
||||
entityType: "mcp_connection", entityId: grant.id, details: { clientId: grant.clientId },
|
||||
|
||||
@@ -262,7 +262,7 @@ export function CodexLocalConfigFields({
|
||||
)}
|
||||
{runnerManaged && runnerProvider === "openai_dot" && <>
|
||||
<Field configSection="adapter" label="Dot connection" hint="A verified event round trip is required before assigning work.">
|
||||
<DotRunnerConnection companyId={companyId} agentId={agentId} onBinding={id => updateRunnerSchemaValue("dotBindingId", id)} />
|
||||
<DotRunnerConnection companyId={companyId} agentId={agentId} bindingId={String(runnerSchemaValue("dotBindingId", ""))} onBinding={id => updateRunnerSchemaValue("dotBindingId", id)} />
|
||||
</Field>
|
||||
<ToggleField label="Allow externally billed provider" hint="Dot does not report token usage or cost. Paperclip cannot enforce a provider spend ceiling; known company and agent budget limits still apply."
|
||||
checked={runnerSchemaValue("allowUnmeteredProvider", false) === true} onChange={value => updateRunnerSchemaValue("allowUnmeteredProvider", value)} />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { DotRunnerConnection } from "./DotRunnerConnection";
|
||||
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), delete: vi.fn() }));
|
||||
vi.mock("../api/client", () => ({ api }));
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const binding = { id: "binding", status: "connected", connected: true, subscriptionVerified: true, hasPendingChallenge: false };
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
const onBinding = vi.fn();
|
||||
function Form() {
|
||||
const [bindingId, setBindingId] = useState("");
|
||||
return <><DotRunnerConnection companyId="company" agentId="agent" bindingId={bindingId}
|
||||
onBinding={id => { onBinding(id); setBindingId(id); }} /><output>{bindingId}</output></>;
|
||||
}
|
||||
async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); }
|
||||
async function render() {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><Form /></QueryClientProvider>));
|
||||
await flush();
|
||||
}
|
||||
beforeEach(() => {
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
api.get.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", binding: null });
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.resetAllMocks(); });
|
||||
|
||||
it("restores the server binding when the form is reopened after pairing without saving", async () => {
|
||||
api.post.mockImplementation(async () => {
|
||||
api.get.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", binding });
|
||||
return { bindingId: binding.id, pairingCode: "one-use-code", expiresAt: "2026-10-07T01:00:00Z" };
|
||||
});
|
||||
await render();
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Pair Dot")!.click());
|
||||
await flush();
|
||||
expect(container.querySelector("output")?.textContent).toBe(binding.id);
|
||||
// Closing the form discards its local adapter edits, while pairing is durable.
|
||||
await act(async () => root.render(null));
|
||||
client.clear(); onBinding.mockClear();
|
||||
await render();
|
||||
expect(container.querySelector("output")?.textContent).toBe(binding.id);
|
||||
expect(onBinding).toHaveBeenCalledTimes(1);
|
||||
expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent === "Pair Dot")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not restore a stale binding while revocation refreshes the connection", async () => {
|
||||
api.get.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", binding });
|
||||
api.delete.mockImplementation(async () => {
|
||||
api.get.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", binding: null });
|
||||
});
|
||||
await render();
|
||||
expect(container.querySelector("output")?.textContent).toBe(binding.id);
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Revoke connection")!.click());
|
||||
await flush();
|
||||
expect(container.querySelector("output")?.textContent).toBe("");
|
||||
expect(onBinding).toHaveBeenLastCalledWith("");
|
||||
expect(Array.from(container.querySelectorAll("button")).some(button => button.textContent === "Pair Dot")).toBe(true);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -11,8 +11,8 @@ interface Connection {
|
||||
}
|
||||
|
||||
/** Pairing codes stay in component memory and expire; never save them in config. */
|
||||
export function DotRunnerConnection({ companyId, agentId, onBinding }: {
|
||||
companyId?: string; agentId?: string; onBinding: (id: string) => void;
|
||||
export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding }: {
|
||||
companyId?: string; agentId?: string; bindingId?: string; onBinding: (id: string) => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const [pairing, setPairing] = useState<{ pairingCode: string; expiresAt: string } | null>(null);
|
||||
@@ -26,7 +26,11 @@ export function DotRunnerConnection({ companyId, agentId, onBinding }: {
|
||||
const test = useMutation({ mutationFn: () => api.post(path + "/event-test", {}),
|
||||
onSuccess: () => { void client.invalidateQueries({ queryKey: key }); } });
|
||||
const revoke = useMutation({ mutationFn: () => api.delete(path),
|
||||
onSuccess: () => { setPairing(null); onBinding(""); void client.invalidateQueries({ queryKey: key }); } });
|
||||
onSuccess: async () => { await client.invalidateQueries({ queryKey: key }); setPairing(null); onBinding(""); } });
|
||||
const existingBindingId = state.data?.binding?.id;
|
||||
useEffect(() => {
|
||||
if (existingBindingId && existingBindingId !== bindingId && !revoke.isPending) onBinding(existingBindingId);
|
||||
}, [existingBindingId, bindingId, onBinding, revoke.isPending]);
|
||||
if (!agentId) return <p className="text-sm text-muted-foreground">Save the agent, then return here to pair your Dot.</p>;
|
||||
const error = state.error ?? pair.error ?? test.error ?? revoke.error;
|
||||
const b = state.data?.binding;
|
||||
|
||||
Reference in new issue
Block a user