mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
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));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user