Merge stable saved rotation baselines

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/agent-connection-api-parity:
  Keep the rotation baseline stable across saved handoff reloads
This commit is contained in:
DottaandPaperclip committed 2026-10-26 15:33:38 -05:00
commit cbf3dc6fc6
5 files changed
+17 -7

No files matched your search

@@ -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));
+5 -3
View File
@@ -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),
};
}
+1
View File
@@ -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({
@@ -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 {
+4 -4
View File
@@ -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<ConnectionIntentSetupOptions> {
@@ -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);