From 5201456bfc913b4b069f34bd9361e25f8ed3e75d Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 19:48:44 -0500 Subject: [PATCH] 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 --- doc/openai-dot-runner.md | 29 +++++++- .../2026-10-02-openai-dot-runner-adapter.md | 7 +- .../src/backends/runtime-context.test.ts | 1 + .../src/evals/compatibility.test.ts | 5 +- .../src/live/runnerd-codex-transport.test.ts | 3 +- .../src/protocol/replay-contract.test.ts | 2 +- .../__tests__/connection-instructions.test.ts | 2 +- server/src/__tests__/dot-runner.test.ts | 66 ++++++++++++++++++- server/src/__tests__/openapi-routes.test.ts | 29 ++++++++ server/src/routes/openapi.ts | 25 +++++++ server/src/services/dot-runner-broker.ts | 4 +- server/src/services/public-mcp/events.ts | 19 +++++- server/src/services/public-mcp/oauth.ts | 20 ++++-- ui/src/adapters/codex-local/config-fields.tsx | 2 +- .../components/DotRunnerConnection.test.tsx | 63 ++++++++++++++++++ ui/src/components/DotRunnerConnection.tsx | 12 ++-- 16 files changed, 265 insertions(+), 24 deletions(-) create mode 100644 ui/src/components/DotRunnerConnection.test.tsx 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;