diff --git a/doc/openai-dot-runner.md b/doc/openai-dot-runner.md index 30125996db0..bec12d44674 100644 --- a/doc/openai-dot-runner.md +++ b/doc/openai-dot-runner.md @@ -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. diff --git a/doc/plans/2026-10-02-openai-dot-runner-adapter.md b/doc/plans/2026-10-02-openai-dot-runner-adapter.md index bc8e8c365e0..4f3296892cf 100644 --- a/doc/plans/2026-10-02-openai-dot-runner-adapter.md +++ b/doc/plans/2026-10-02-openai-dot-runner-adapter.md @@ -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 diff --git a/packages/paperclip-runner/src/backends/runtime-context.test.ts b/packages/paperclip-runner/src/backends/runtime-context.test.ts index 2fc3ac47260..d77dda3ff68 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.test.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.test.ts @@ -31,6 +31,7 @@ function runtimeInput( entryPath: string, ): NativeExecutionInput { return { + provider: { kind: "codex" }, runtimeContext: { prompt: { text: "Paperclip runtime." }, instructions: { bundle: { rootPath }, entryPath }, diff --git a/packages/paperclip-runner/src/evals/compatibility.test.ts b/packages/paperclip-runner/src/evals/compatibility.test.ts index 759d4525ba6..be5fe552f27 100644 --- a/packages/paperclip-runner/src/evals/compatibility.test.ts +++ b/packages/paperclip-runner/src/evals/compatibility.test.ts @@ -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; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 24f8f98cc86..612991674ac 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/protocol/replay-contract.test.ts b/packages/paperclip-runner/src/protocol/replay-contract.test.ts index d2147e751c5..3f17ecf2b62 100644 --- a/packages/paperclip-runner/src/protocol/replay-contract.test.ts +++ b/packages/paperclip-runner/src/protocol/replay-contract.test.ts @@ -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>; - events[0]!.schemaVersion = 3; + events[0]!.schemaVersion = PRP_PROTOCOL_VERSION + 1; expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({ ok: false, issues: [ diff --git a/server/src/__tests__/connection-instructions.test.ts b/server/src/__tests__/connection-instructions.test.ts index 9b1fcabe0db..97da913aa76 100644 --- a/server/src/__tests__/connection-instructions.test.ts +++ b/server/src/__tests__/connection-instructions.test.ts @@ -147,7 +147,7 @@ async function createRemoteMcpToolFixture(db: ReturnType, 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(); diff --git a/server/src/__tests__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index 73e8ae7f363..653a6fc89f1 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -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> = []; + 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>) { + 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, 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`; diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 212e26eb5a2..4cdf04cd51a 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -40,6 +40,7 @@ const apiPrefixes: Record = { "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") diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a5bd3f0ad7d..b865a35d861 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/dot-runner-broker.ts b/server/src/services/dot-runner-broker.ts index 9a2cfac005a..50fb8d5320e 100644 --- a/server/src/services/dot-runner-broker.ts +++ b/server/src/services/dot-runner-broker.ts @@ -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)); diff --git a/server/src/services/public-mcp/events.ts b/server/src/services/public-mcp/events.ts index ceddb5e1c52..ab684d1d849 100644 --- a/server/src/services/public-mcp/events.ts +++ b/server/src/services/public-mcp/events.ts @@ -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 }; diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index 0b6410654c4..3411fc45c3f 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -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 { 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 }, diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 6212fff1ee5..b27fd563079 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -262,7 +262,7 @@ export function CodexLocalConfigFields({ )} {runnerManaged && runnerProvider === "openai_dot" && <> - updateRunnerSchemaValue("dotBindingId", id)} /> + updateRunnerSchemaValue("dotBindingId", id)} /> updateRunnerSchemaValue("allowUnmeteredProvider", value)} /> diff --git a/ui/src/components/DotRunnerConnection.test.tsx b/ui/src/components/DotRunnerConnection.test.tsx new file mode 100644 index 00000000000..6218320679e --- /dev/null +++ b/ui/src/components/DotRunnerConnection.test.tsx @@ -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 <> { onBinding(id); setBindingId(id); }} />{bindingId}; +} +async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); } +async function render() { + await act(async () => root.render(
)); + 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); +}); diff --git a/ui/src/components/DotRunnerConnection.tsx b/ui/src/components/DotRunnerConnection.tsx index 55d71cb7c22..b3c2f3fcc37 100644 --- a/ui/src/components/DotRunnerConnection.tsx +++ b/ui/src/components/DotRunnerConnection.tsx @@ -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

Save the agent, then return here to pair your Dot.

; const error = state.error ?? pair.error ?? test.error ?? revoke.error; const b = state.data?.binding;