From e54b9b11d67d8eb75a7684b932b3894332c31c79 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 15:32:52 -0500 Subject: [PATCH] Keep the rotation baseline stable across saved handoff reloads Expose only an opaque rotation identifier and bind setup links to the generation they requested. Preserve the baseline on run retries and validate the projection through real authentication. Co-Authored-By: Paperclip --- .../__tests__/connection-management.integration.test.ts | 4 ++++ server/src/routes/chat-channels.ts | 8 +++++--- server/src/routes/openapi.ts | 1 + server/src/services/chat-channels/endpoints.ts | 3 +++ server/src/services/connection-intents.ts | 8 ++++---- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index d5f31236b5e..d6333d362e5 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -717,6 +717,10 @@ describe("connection management with production default agents", () => { expect(await humanRotation.json()).toEqual({ webhookSecret: expect.stringMatching(/^[a-f0-9]{64}$/) }); const [humanRotated] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); expect(humanRotated.credentialSecretRefs).not.toEqual(beforeRotation.credentialSecretRefs); + const rotationState = await f.request("GET", `/chat-endpoints/${endpoint.id}`); + expect(rotationState.body.setup).toMatchObject({ credentialRotationId: expect.any(String), webhookVerifiedAt: null }); + const nextRotation = await f.request("POST", `/chat-endpoints/${endpoint.id}/setup-secret`, {}); + expect(nextRotation.body.requiredUserAction.setupPath).toContain(`afterRotationId=${rotationState.body.setup.credentialRotationId}`); expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull(); // Provider-fixture states separate restored health from new webhook verification. const [rotatedEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 6d246cabcc1..4e7f19f6ee0 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,5 +1,5 @@ import { connectionManagementService } from "../services/connection-management.js"; -import { connectionIntentService, savedChatSetupPath } from "../services/connection-intents.js"; +import { chatCredentialRotationId, connectionIntentService, savedChatSetupPath } from "../services/connection-intents.js"; import { companies } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { requiredConnectionUserAction, filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js"; @@ -109,13 +109,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { const endpoint = await service.get(endpointId(req)); const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, endpoint.companyId)); const action = options.requireCredentialRotation ? "rotate_credentials" as const : kind === "enter_credentials" || kind === "confirm_identity" ? kind : undefined; - const interactionId = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId + const handoff = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId ? await connectionIntentService(db).requestChatHandoff({ sub: req.actor.agentId!, company_id: endpoint.companyId, run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id, { action }) : null; + const interactionId = handoff?.interactionId ?? null; + const previousRotationId = handoff ? handoff.previousRotationId : chatCredentialRotationId(endpoint.setup); return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId, ...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId }, - savedChatSetupPath(company!.prefix, endpoint, action), reason, kind), + savedChatSetupPath(company!.prefix, endpoint, action, previousRotationId), reason, kind), }; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e79e40a0cad..2edb199b59d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -835,6 +835,7 @@ const chatEndpointSetupResponseSchema = z webhookUrl: z.string().nullable().optional(), messagingEndpoint: z.string().nullable().optional(), webhookVerifiedAt: z.string().datetime().nullable().optional(), + credentialRotationId: z.string().uuid().nullable().optional(), webhookSecretConfigured: z.boolean().optional(), callbackSurfaces: z .object({ diff --git a/server/src/services/chat-channels/endpoints.ts b/server/src/services/chat-channels/endpoints.ts index b391f44646d..4d09b7b5d85 100644 --- a/server/src/services/chat-channels/endpoints.ts +++ b/server/src/services/chat-channels/endpoints.ts @@ -38,6 +38,7 @@ import type { } from "@paperclipai/shared"; import { isAgentStatusInvokable, + isUuidLike, } from "@paperclipai/shared"; import { @@ -85,6 +86,7 @@ function providerSetupState( const path = endpoint.provider === "speko" ? `/api/voice-webhooks/${endpoint.publicId}/tools` : `/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`; const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null; const step = endpoint.status === "active" ? "complete" : endpoint.setup.step; + const rotationId: unknown = Reflect.get(endpoint.setup, "credentialRotationId"); switch (endpoint.provider) { case "speko": return { step, providerUrl: "https://platform.speko.ai/", webhookUrl } as const; @@ -103,6 +105,7 @@ function providerSetupState( providerUrl: "https://github.com/settings/installations", webhookUrl, webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, + credentialRotationId: typeof rotationId === "string" && isUuidLike(rotationId) ? rotationId : null, } as const; case "discord": return { diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index ce680726cb7..3fdabb0d06f 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -72,11 +72,11 @@ export function chatCredentialRotationId(setup: object): string | null { return typeof value === "string" && isUuidLike(value) ? value : null; } -export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"]) { +export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"], previousRotationId?: string | null) { const query = new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id }); if (action === "enter_credentials" || action === "rotate_credentials") query.set("stage", "credentials"); if (action === "confirm_identity") query.set("stage", "identity"); - if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); } + if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); query.set("afterRotationId", previousRotationId ?? "none"); } return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`; } @@ -911,7 +911,7 @@ export function connectionIntentService(db: Db) { addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" ? `:${context.run.id}` : ""}`, }); if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome."); - return interaction.id; + return { interactionId: interaction.id, previousRotationId: connectionIntentPayloadSchema.parse(interaction.payload).savedSetup?.credentialRotation?.previousId ?? null }; } async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise { @@ -929,7 +929,7 @@ export function connectionIntentService(db: Db) { return { version: 1, interaction: loaded.interaction, requestedAgentId: payload.requestingAgentId, existingConnections: [], service: { service: payload.serviceSlug, name: payload.serviceName, description: null, logoUrl: null, methods: [], state: "needs_user_action", connectionId: endpoint.connectionId }, chatSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId, - path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action) }, + path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action, payload.savedSetup.credentialRotation?.previousId) }, }; } const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId, payload.purpose);