Merge verified credential handoff completion and commit authority

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

* codex/agent-connection-api-parity:
  fix: require committed credential changes before resuming setup
This commit is contained in:
DottaandPaperclip committed 2026-10-26 18:40:13 -05:00
commit b74c34e75b
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.