fix: require committed credential changes before resuming setup

Keep verification-key handoffs pending across old health, unchanged credentials, and retries. Commit opaque field generations and an active-state fence with vault refs, then require renewed signing verification and current authority before continuation. Reauthorize credential writes before persistence and inside the transaction. Freeze only Date in rate assertions to prevent minute-boundary flakes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 18:40:07 -05:00
1 parent c8cc70ae30
commit 805d165bc3
9 files changed
+133 -16

No files matched your search

+1 -1
View File
@@ -1452,7 +1452,7 @@ export type ConnectionIntentPhase = "requested" | "authorizing" | "needs_retry";
*/
export interface ConnectionIntentPayload {
/** Existing chat setup; the server derives the destination from these IDs. */
savedSetup?: { endpointId: string; connectionId: string; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null } };
savedSetup?: { endpointId: string; connectionId: string; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null }; credentialEntry?: { previousGenerations: Record<string, string | null>; requireAll: boolean } };
version: 1;
/** Server-authored consent to a fixed set of tools on an existing connection. */
accessRequest?: {
+4 -1
View File
@@ -1170,7 +1170,10 @@ const connectionIntentBrandAssetSchema = z
export const connectionIntentPayloadSchema = z
.object({
savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid(), action: z.enum(["enter_credentials", "rotate_credentials", "confirm_identity"]).optional(), credentialRotation: z.object({ previousId: z.string().guid().nullable() }).strict().optional() }).strict()
savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid(), action: z.enum(["enter_credentials", "rotate_credentials", "confirm_identity"]).optional(), credentialRotation: z.object({ previousId: z.string().guid().nullable() }).strict().optional(), credentialEntry: z.object({
previousGenerations: z.record(z.string().refine(field => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(field)), z.string().uuid().nullable()).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 5),
requireAll: z.boolean(),
}).strict().optional() }).strict()
.refine(value => value.action !== "rotate_credentials" || !!value.credentialRotation, "Rotation requests need a completion boundary").optional(),
accessRequest: z.object({
connectionId: z.string().guid(),
@@ -262,6 +262,8 @@ describe("connection management with production default agents", () => {
const address = remote.address();
if (!address || typeof address === "string") throw new Error("Fixture did not listen");
const mcpUrl = `http://127.0.0.1:${address.port}/mcp`;
const freezeRateClock = ["other-agent-caller-rate", "other-agent-shared-rate", "rate-limit", "agent-rate-approval"].includes(revocation);
if (freezeRateClock) vi.useFakeTimers({ toFake: ["Date"] });
try {
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Governed test", transport: "mcp_remote", authKind: "none", transportConfig: { url: mcpUrl } });
await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, created.body.id));
@@ -386,7 +388,7 @@ describe("connection management with production default agents", () => {
else expect(settled, JSON.stringify(settled)).toMatchObject({ status: allowed ? "succeeded" : "failed" });
expect(calls).toBe(allowed ? 1 : 0);
if (allowed) { expect((await approve()).status).toBe(200); expect(calls).toBe(1); }
} finally { remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
} finally { if (freezeRateClock) vi.useRealTimers(); remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
});
it("completes concurrent run-authenticated profile finishing and policy writes without lock-order failures", async () => {
@@ -643,8 +645,12 @@ describe("connection management with production default agents", () => {
const botId = `U${f.agentId.replaceAll("-", "").toUpperCase()}`;
const scopes = [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES];
let sent = 0;
let revokeDuringAuth = false;
provider = async (url, init) => {
if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
if (url.endsWith("auth.test")) {
if (revokeDuringAuth) await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
}
if (url.includes("conversations.list")) return Response.json({ ok: true, channels: [], response_metadata: { next_cursor: "" } });
if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: new URLSearchParams(String(init?.body)).get("user") ?? "UHUMAN", team_id: "TFIXTURE", name: "Fixture user", is_bot: false, deleted: false } });
if (url.endsWith("chat.postMessage")) { sent++; return Response.json({ ok: true, channel: "DTEST", ts: "1.000001" }); }
@@ -660,17 +666,25 @@ describe("connection management with production default agents", () => {
const configured = { status: configuredResponse.status, body: await configuredResponse.json() };
expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 });
expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token");
const [configuredEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
await server.db.update(chatEndpoints).set({ status: "active", setup: { ...configuredEndpoint.setup, step: "complete", webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!;
const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref);
expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt))
.toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } });
let credentialIntentId: string | undefined;
for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) {
const chosen = "agent-chosen-webhook-key";
const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token);
expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id,
requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } });
expect(JSON.stringify(handoff.body)).not.toContain(chosen);
expect(handoff.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1");
if (token === f.jwt) {
if (credentialIntentId) expect(handoff.body.interactionId).toBe(credentialIntentId);
credentialIntentId = handoff.body.interactionId;
}
const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs);
const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
@@ -678,8 +692,46 @@ describe("connection management with production default agents", () => {
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
})).toBe("fixture-signing-secret");
}
const [credentialIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!));
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
const entryProof = (credentialIntent.payload as { savedSetup: { credentialEntry: { previousGenerations: Record<string, string>; requireAll: boolean } } }).savedSetup.credentialEntry;
expect(entryProof).toEqual({ previousGenerations: { signingSecret: expect.any(String) }, requireAll: true });
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
await satisfiedConnectionIntentService(server.db).sweepPending();
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("pending");
// Ordinary write-only bot-token repair remains delegated under current authority.
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } });
const healthy = async () => {
await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, created.body.id));
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
};
await healthy();
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
const humanReconnect = async (signingSecret: string) => {
const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
body: JSON.stringify({ action: "reconnect", credentials: { signingSecret } }) });
expect({ status: response.status, body: await response.json() }).toMatchObject({ status: 200 });
};
await humanReconnect("fixture-signing-secret");
await healthy();
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
await humanReconnect("human-replacement-signing-secret");
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
const resumedRequest = await f.request("POST", setupPath, { action: "reconnect", credentials: { signingSecret: "agent-chosen-webhook-key" } }, f.jwt);
expect(resumedRequest.body.interactionId).toBe(credentialIntentId);
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].payload)
.toMatchObject({ savedSetup: { credentialEntry: entryProof } });
await healthy();
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
const [repaired] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
await server.db.update(chatEndpoints).set({ setup: { ...repaired.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
expect((await findSatisfiedToolConnection(server.db, task, credentialIntent))?.id).toBe(created.body.connectionId);
expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, credentialIntent)).toBeNull();
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active");
await satisfiedConnectionIntentService(server.db).sweepPending();
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("expired");
// Identity is a provider fixture; production HTTP admission and policy remain real.
const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning();
await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() });
@@ -690,6 +742,7 @@ describe("connection management with production default agents", () => {
expect(denied, JSON.stringify(denied)).toMatchObject({ status: 403 });
expect(sent).toBe(0);
await server.db.update(toolPolicies).set({ policyType: "allow" }).where(eq(toolPolicies.id, deny.id));
vi.useFakeTimers({ toFake: ["Date"] });
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One Slack setup send", policyType: "rate_limit", selectors: { connectionId: created.body.connectionId }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
const requestId = randomUUID();
const tested = await f.request("POST", testPath, { requestId });
@@ -698,7 +751,13 @@ describe("connection management with production default agents", () => {
expect(await f.request("POST", testPath, { requestId: randomUUID() })).toMatchObject({ status: 403, body: { details: { reasonCode: "rate_limited" } } });
expect(await f.request("POST", testPath, { requestId: randomUUID(), slackUserId: "UOTHER" })).toMatchObject({ status: 403 });
expect(sent).toBe(1);
} finally { provider = null; }
vi.useRealTimers();
const [beforeRevocation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
revokeDuringAuth = true;
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 403 });
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs)
.toEqual(beforeRevocation.credentialSecretRefs);
} finally { vi.useRealTimers(); provider = null; }
});
it("imports a GitHub App and discovers repositories against provider fixtures", async () => {
+5 -3
View File
@@ -105,13 +105,13 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
const access = accessService(db);
const github = githubChatManagementService(db, options.fetch);
async function setupHandoff(req: ExpressRequest, reason: string, kind: ConnectionRequiredUserAction["kind"] = "authenticate", options: { requireCredentialRotation?: boolean } = {}) {
async function setupHandoff(req: ExpressRequest, reason: string, kind: ConnectionRequiredUserAction["kind"] = "authenticate", options: { requireCredentialRotation?: boolean; credentialFields?: string[] } = {}) {
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 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 })
run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id, { action, credentialFields: options.credentialFields })
: null;
const interactionId = handoff?.interactionId ?? null;
const previousRotationId = handoff ? handoff.previousRotationId : chatCredentialRotationId(endpoint.setup);
@@ -440,7 +440,9 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403
|| !error.details || typeof error.details !== "object" || !("code" in error.details)
|| error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error;
res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials"));
const credentialFields = "credentialFields" in error.details && Array.isArray(error.details.credentialFields)
? error.details.credentialFields.filter((field): field is string => typeof field === "string") : undefined;
res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials", { credentialFields }));
}
},
);
@@ -1,4 +1,5 @@
import type { ConnectionManagementContext } from "../connection-management.js";
import { CHAT_HUMAN_CREDENTIAL_FIELDS } from "./credentials.js";
import { preparePhotonConfiguration, createPhotonConfiguration } from "./imessage-photon/setup.js";
import { slackRuntimeConfig } from "./slack/setup.js";
import { createTelegramSetup } from "./telegram/setup.js";
@@ -147,11 +148,11 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
}
// These keys establish provider authentication or inbound trust. They
// must come from the human setup step or a verified provider handoff.
if (managementContext?.actor.type === "agent" && suppliedCredentialKeys.some(
key => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(key),
)) {
const credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(key => suppliedCredentialKeys.includes(key));
if (managementContext?.actor.type === "agent" && credentialFields.length) {
throw forbidden("The responsible user must enter provider verification credentials in the saved setup", {
code: "chat_endpoint_credential_entry_requires_human",
credentialFields,
});
}
}
@@ -285,6 +286,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
prepared.credentials,
credentialLease,
actorUserId,
managementContext ? async () => managementContext.reauthorize() : undefined,
);
}
credentials = prepared.credentials;
@@ -676,6 +678,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
`${PROVIDER_LABELS[endpoint.provider]} credentials are required`,
);
});
await managementContext?.reauthorize();
if (endpoint.provider === "imessage-photon") {
credentials = preparePhotonConfiguration(endpoint, input, credentials);
}
@@ -769,6 +772,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
credentials,
credentialLease,
actorUserId,
managementContext ? async () => managementContext.reauthorize() : undefined,
);
if (prepared.inventory)
await reconcileProviderResourceRows(
@@ -16,7 +16,8 @@ import { type CredentialMutationLeaseGuard } from "../chat-credential-mutation-l
import { photonSharedScope } from "../photon/cloud.js";
import { photonChannelConfigurationSchema } from "@paperclipai/shared";
import { isUuidLike, photonChannelConfigurationSchema } from "@paperclipai/shared";
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
import {
randomBytes,
@@ -27,6 +28,7 @@ import {
eq,
inArray,
ne,
sql,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -58,6 +60,17 @@ import {
import { secretService } from "../secrets.js";
export const CHAT_HUMAN_CREDENTIAL_FIELDS = ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"] as const;
/** Opaque generations identify actual protected-value changes, never secret bytes. */
export function chatCredentialEntryGenerations(setup: object): Record<string, string | null> {
const generations: unknown = Reflect.get(setup, "credentialEntryGenerations");
return Object.fromEntries(CHAT_HUMAN_CREDENTIAL_FIELDS.map(field => {
const value: unknown = generations && typeof generations === "object" ? Reflect.get(generations, field) : null;
return [field, typeof value === "string" && isUuidLike(value) ? value : null];
}));
}
type ChatCredentialsOptions = {
secrets: ReturnType<typeof secretService>;
PROVIDER_LABELS: Record<ChatProvider, string>;
@@ -266,6 +279,12 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
),
)
.then((rows) => rows[0]?.refs ?? []);
const protectedFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => credentials[field] !== undefined);
const previousValues = protectedFields.length ? await resolveCredentialRefs(endpoint, previousRefs.filter(
ref => protectedFields.some(field => ref.configPath === `credentials.${field}`),
)) : {};
const changedFields = protectedFields.filter(field => credentials[field] !== previousValues[field]);
const generations = Object.fromEntries(changedFields.map(field => [field, randomUUID()]));
const refs: ToolCredentialSecretRef[] = [];
try {
for (const [key, value] of Object.entries(credentials)) {
@@ -345,6 +364,16 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
.update(toolConnections)
.set({ credentialSecretRefs: refs, updatedAt: new Date() })
.where(eq(toolConnections.id, endpoint.connectionId));
if (changedFields.length) {
await notifyChatEndpointWork(tx, endpoint.connectionId);
const verification = changedFields.some(field => field === "signingSecret" || field === "webhookSecret")
? { webhookVerifiedAt: null } : {};
await tx.update(chatEndpoints).set({
// Fence old healthy state in the same transaction as the new refs.
status: sql`case when ${chatEndpoints.status} = 'active' then 'verifying' else ${chatEndpoints.status} end`,
setup: sql`jsonb_set(${chatEndpoints.setup}, '{credentialEntryGenerations}', coalesce(${chatEndpoints.setup}->'credentialEntryGenerations', '{}'::jsonb) || ${JSON.stringify(generations)}::jsonb) || ${JSON.stringify(verification)}::jsonb`,
}).where(and(eq(chatEndpoints.id, endpoint.id), eq(chatEndpoints.companyId, endpoint.companyId)));
}
await onCommitted?.(tx as unknown as Db);
await credentialLease.assertOwned(tx);
});
+8 -3
View File
@@ -1,4 +1,5 @@
import { connectionManagementService } from "./connection-management.js";
import { CHAT_HUMAN_CREDENTIAL_FIELDS, chatCredentialEntryGenerations } from "./chat-channels/credentials.js";
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { emailChannelService } from "./email-channels.js";
import { emailConnectionService } from "./email-connections.js";
@@ -74,7 +75,7 @@ export function chatCredentialRotationId(setup: object): string | null {
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 === "enter_credentials" || action === "rotate_credentials") { query.set("stage", "credentials"); query.set("reconnect", "1"); }
if (action === "confirm_identity") query.set("stage", "identity");
if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); query.set("afterRotationId", previousRotationId ?? "none"); }
return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`;
@@ -893,7 +894,7 @@ export function connectionIntentService(db: Db) {
return { ...row, interaction };
}
async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"] } = {}) {
async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"]; credentialFields?: string[] } = {}) {
const context = await loadRunContext(claims);
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.companyId, context.run.companyId)));
if (!endpoint || endpoint.assignedAgentId !== context.agent.id) throw forbidden("This setup is not assigned to the requesting agent");
@@ -901,14 +902,18 @@ export function connectionIntentService(db: Db) {
agentId: context.agent.id, runId: context.run.id, onBehalfOfUserId: context.run.responsibleUserId,
}, "connections:configure", { type: "connection_management", companyId: context.run.companyId,
connectionId: endpoint.connectionId, provider: endpoint.provider, purpose: "chat", operation: "chat.authentication_handoff" });
const credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => !options.credentialFields || options.credentialFields.includes(field));
if (options.action === "enter_credentials" && !credentialFields.length) throw unprocessable("Credential entry requires a supported verification field");
const generations = chatCredentialEntryGenerations(endpoint.setup);
const interaction = await interactions.createConnectionIntent(context.issue, {
payload: { version: 1, serviceSlug: endpoint.provider, serviceName: endpoint.provider === "github" ? "GitHub" : endpoint.provider === "slack" ? "Slack" : endpoint.provider,
purpose: "channel", savedSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId,
...(options.action ? { action: options.action } : {}),
...(options.action === "enter_credentials" ? { credentialEntry: { previousGenerations: Object.fromEntries(credentialFields.map(field => [field, generations[field]])), requireAll: !!options.credentialFields } } : {}),
...(options.action === "rotate_credentials" ? { credentialRotation: { previousId: chatCredentialRotationId(endpoint.setup) } } : {}) },
requestingAgentId: context.agent.id, requestingAgentName: context.agent.name, phase: "authorizing" },
sourceRunId: context.run.id, sourceIdentityContextId: context.run.activeIdentityContextId,
addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" ? `:${context.run.id}` : ""}`,
addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" || options.action === "enter_credentials" ? `:${context.run.id}` : ""}`,
});
if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome.");
return { interactionId: interaction.id, previousRotationId: connectionIntentPayloadSchema.parse(interaction.payload).savedSetup?.credentialRotation?.previousId ?? null };
@@ -2617,7 +2617,12 @@ export function issueThreadInteractionService(
candidate.savedSetup?.endpointId === payload.savedSetup?.endpointId
&& candidate.savedSetup?.connectionId === payload.savedSetup?.connectionId
&& candidate.savedSetup?.action === payload.savedSetup?.action
&& Boolean(candidate.savedSetup?.credentialRotation) === Boolean(payload.savedSetup?.credentialRotation);
&& Boolean(candidate.savedSetup?.credentialRotation) === Boolean(payload.savedSetup?.credentialRotation)
&& candidate.savedSetup?.credentialEntry?.requireAll === payload.savedSetup?.credentialEntry?.requireAll
// Preserve the original generation boundary on retries, while keeping
// requests for different credential fields distinct.
&& isDeepStrictEqual(Object.keys(candidate.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort(),
Object.keys(payload.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort());
const existing = await getIdempotentInteraction({
issueId: issue.id,
companyId: issue.companyId,
@@ -1,5 +1,6 @@
import { enqueueTerminalIssueInteractionChatPublications } from "./chat-interaction-publications.js";
import { connectionManagementService } from "./connection-management.js";
import { chatCredentialEntryGenerations } from "./chat-channels/credentials.js";
import { and, asc, eq, gt, inArray, isNull, or } from "drizzle-orm";
import { approvals, chatEndpoints, toolConnections, companyMemberships, connectionIntentDeliveries, issueApprovals, issueThreadInteractions, issues, type Db } from "@paperclipai/db";
import { connectionIntentPayloadSchema } from "@paperclipai/shared";
@@ -29,6 +30,15 @@ async function findAvailableToolConnection(db: Db, task: Task, intent: Intent, p
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, endpoint.connectionId),
eq(toolConnections.companyId, task.companyId), eq(toolConnections.status, "active"), eq(toolConnections.enabled, true)));
if (!connection || !["ok", "healthy"].includes(connection.healthStatus)) return null;
if (payload.savedSetup.action === "enter_credentials") {
const proof = payload.savedSetup.credentialEntry;
if (!proof) return null;
const generations = chatCredentialEntryGenerations(endpoint.setup);
const fields = Object.keys(proof.previousGenerations);
const changed = fields.filter(field => typeof generations[field] === "string" && generations[field] !== proof.previousGenerations[field]);
if (!changed.length || (proof.requireAll && changed.length !== fields.length)) return null;
if (changed.some(field => field === "signingSecret" || field === "webhookSecret") && !endpoint.setup.webhookVerifiedAt) return null;
}
if (payload.savedSetup.credentialRotation) {
// Rotation commits its generation and clears webhook verification in the
// same transaction as vault refs. Old health cannot satisfy this request.