diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 206c7a518b9..00bfb4fab21 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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; requireAll: boolean } }; version: 1; /** Server-authored consent to a fixed set of tools on an existing connection. */ accessRequest?: { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 099fe4a5e5f..98f88a5af22 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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(), diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index d7dc4677df7..2e9d714f3c9 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -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((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); } + } finally { if (freezeRateClock) vi.useRealTimers(); remote.closeAllConnections(); await new Promise((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; 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 () => { diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 1dfea15656d..281490aab74 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -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 })); } }, ); diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts index 5fc9cb5565a..ea754a70a2d 100644 --- a/server/src/services/chat-channels/configuration.ts +++ b/server/src/services/chat-channels/configuration.ts @@ -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( diff --git a/server/src/services/chat-channels/credentials.ts b/server/src/services/chat-channels/credentials.ts index ad122038702..585229e3454 100644 --- a/server/src/services/chat-channels/credentials.ts +++ b/server/src/services/chat-channels/credentials.ts @@ -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 { + 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; PROVIDER_LABELS: Record; @@ -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); }); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 3fdabb0d06f..3967cf526e9 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -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 }; diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index dfce40542d6..bcfbe2882a9 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -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, diff --git a/server/src/services/satisfied-connection-intents.ts b/server/src/services/satisfied-connection-intents.ts index b8f93a262e8..cd3732c94db 100644 --- a/server/src/services/satisfied-connection-intents.ts +++ b/server/src/services/satisfied-connection-intents.ts @@ -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.