diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index ff62dfcd67b..704f4874a14 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1449,7 +1449,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 }; + savedSetup?: { endpointId: string; connectionId: string; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null } }; 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 9a125d00b86..099fe4a5e5f 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1170,7 +1170,8 @@ const connectionIntentBrandAssetSchema = z export const connectionIntentPayloadSchema = z .object({ - savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid() }).strict().optional(), + 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() + .refine(value => value.action !== "rotate_credentials" || !!value.credentialRotation, "Rotation requests need a completion boundary").optional(), accessRequest: z.object({ connectionId: z.string().guid(), connectionName: z.string().trim().min(1).max(160), diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 0af35c18f6d..d5f31236b5e 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -229,7 +229,7 @@ describe("connection management with production default agents", () => { expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]); }); - it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => { + it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval", "authorized-policy-edit"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => { const f = await fixture(); let targetAgentId = f.agentId; if (revocation === "other-agent") { @@ -317,6 +317,18 @@ describe("connection management with production default agents", () => { expect(calls).toBe(0); const approvalPath = `/tool-gateway/action-requests/${pending.body.actionRequestId}/approve`; expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); + if (revocation === "authorized-policy-edit") { + const policiesPath = `/companies/${f.companyId}/tools/policies`; + const policy = await f.request("POST", policiesPath, { name: "Requested self-targeted access", policyType: "allow", priority: 0, + selectors: { agentId: f.agentId, connectionId: created.body.id } }, f.jwt); + expect(policy, JSON.stringify(policy)).toMatchObject({ status: 201 }); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "allowed" } }); + expect(calls).toBe(1); + expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + expect(await f.request("PATCH", `${policiesPath}/${policy.body.id}`, { enabled: false }, f.jwt)).toMatchObject({ status: 403 }); + return; + } await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId)); if (revocation === "agent") await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:use"))); if (revocation === "user") for (const permission of ["tools:use", "tools:manage_connections"]) await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, permission))); @@ -644,27 +656,23 @@ describe("connection management with production default agents", () => { try { const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId }); expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/registration`, { name: `fixture-${f.agentId.slice(0, 8)}` })).toMatchObject({ status: 200 }); - const imported = await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, { appId: String(appId), privateKey, webhookSecret: "fixture-webhook-secret", clientId: "fixture-client", clientSecret: "fixture-secret" }); - expect(imported, JSON.stringify(imported)).toMatchObject({ status: 200 }); - expect(JSON.stringify(imported.body)).not.toContain("PRIVATE KEY"); - const [beforeRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); - for (const token of [f.key.token, f.jwt]) { - const rotation = await f.request("POST", `/chat-endpoints/${created.body.id}/setup-secret`, {}, token); - expect(rotation, JSON.stringify(rotation)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id, - requiredUserAction: { kind: "enter_credentials", userId: f.userId } } }); - expect(rotation.body).not.toHaveProperty("webhookSecret"); - } - const [afterRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); - expect(afterRotation.credentialSecretRefs).toEqual(beforeRotation.credentialSecretRefs); - const refreshed = await f.request("POST", `/chat-endpoints/${created.body.id}/github/repositories/refresh`, {}); - expect(refreshed, JSON.stringify(refreshed)).toMatchObject({ status: 200 }); - expect(JSON.stringify(refreshed.body)).toContain("fixture/repo"); - expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/verify`, {})).toMatchObject({ status: 200 }); const cookie = await server.humanSession(f.userId); const humanRequest = (path: string, method = "GET", body?: unknown, session = cookie) => fetch(server.apiUrl + path, { method, redirect: "manual", headers: { cookie: session, "Content-Type": "application/json", origin: server.apiUrl }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); + const appCredentials = { appId: String(appId), privateKey, webhookSecret: "fixture-webhook-secret", clientId: "fixture-client", clientSecret: "fixture-secret" }; + expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, appCredentials)).toMatchObject({ status: 200, body: { status: "needs_user_action" } }); + const importHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, {}, f.jwt); + expect(importHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials", userId: f.userId } } }); + expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs).toEqual([]); + const imported = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/app`, "POST", appCredentials); + expect(imported.status).toBe(200); + expect(JSON.stringify(await imported.json())).not.toContain("PRIVATE KEY"); + const refreshed = await f.request("POST", `/chat-endpoints/${created.body.id}/github/repositories/refresh`, {}); + expect(refreshed, JSON.stringify(refreshed)).toMatchObject({ status: 200 }); + expect(JSON.stringify(refreshed.body)).toContain("fixture/repo"); + expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/verify`, {})).toMatchObject({ status: 200 }); const start = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/start`, "POST", {}); const started = await start.json(); expect(started, JSON.stringify(started)).toHaveProperty("authorizationUrl"); @@ -677,11 +685,46 @@ describe("connection management with production default agents", () => { expect((await humanRequest(callback)).status).toBe(403); const confirmed = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/confirm`, "POST", { githubUserId: "1357" }); expect(confirmed.status, JSON.stringify(await confirmed.json())).toBe(200); + const [endpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id)); + await server.db.update(chatEndpoints).set({ status: "active", setup: { ...endpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, endpoint.id)); + await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId)); + const [beforeRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + let rotationIntentId = ""; + for (const token of [f.key.token, f.jwt, f.jwt]) { + const rotation = await f.request("POST", `/chat-endpoints/${created.body.id}/setup-secret`, {}, token); + expect(rotation, JSON.stringify(rotation)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id, + requiredUserAction: { kind: "enter_credentials", userId: f.userId } } }); + expect(rotation.body).not.toHaveProperty("webhookSecret"); + expect(rotation.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1&rotateSecret=1"); + if (token === f.jwt) { + if (rotationIntentId) expect(rotation.body.interactionId).toBe(rotationIntentId); + rotationIntentId = rotation.body.interactionId; + } + } + expect(rotationIntentId).not.toBe(importHandoff.body.interactionId); + const [afterRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(afterRotation.credentialSecretRefs).toEqual(beforeRotation.credentialSecretRefs); + const [rotationIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, rotationIntentId)); + const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + const setupOptions = await humanRequest(`/api/connection-intents/${rotationIntentId}/setup-options`); + expect(setupOptions.status).toBe(200); + expect((await setupOptions.json()).chatSetup.path).toContain("stage=credentials&reconnect=1&rotateSecret=1"); + expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull(); + await satisfiedConnectionIntentService(server.db).sweepPending(); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, rotationIntentId)))[0].status).toBe("pending"); const humanRotation = await humanRequest(`/api/chat-endpoints/${created.body.id}/setup-secret`, "POST", {}); expect(humanRotation.status).toBe(201); 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); + 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)); + await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id)); + await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId)); + expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull(); + await server.db.update(chatEndpoints).set({ setup: { ...rotatedEndpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, endpoint.id)); + expect((await findSatisfiedToolConnection(server.db, task, rotationIntent))?.id).toBe(created.body.connectionId); } finally { provider = null; } }); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 0023aa7642b..03bb0a040e7 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -1098,6 +1098,10 @@ describe("openapi routes", () => { expect(input.required).not.toContain("ownerType"); const response = registration.responses["200"].content["application/json"].schema; expect(response.oneOf ?? response.anyOf).toHaveLength(2); + const appImport = spec.paths["/api/chat-endpoints/{endpointId}/github/app"].post; + expect(appImport.description).toContain("Only a human session"); + const appImportResponse = appImport.responses["200"].content["application/json"].schema; + expect(appImportResponse.oneOf ?? appImportResponse.anyOf).toHaveLength(2); }); it("documents GitHub recovery, draft receipts, paged repositories and old review lookups", () => { @@ -1113,7 +1117,8 @@ describe("openapi routes", () => { const receipt = spec.paths[root + "draft"].put.responses["200"].content["application/json"].schema; expect(receipt.required).toEqual(["saved"]); expect(receipt.properties).not.toHaveProperty("state"); - const app = spec.paths[root + "app"].post.requestBody.content["application/json"].schema; + const appInput = spec.paths[root + "app"].post.requestBody.content["application/json"].schema; + const app = (appInput.oneOf ?? appInput.anyOf).find((variant: { properties?: { appId?: unknown } }) => variant.properties?.appId); expect(app.properties.clientId).toBeDefined(); expect(app.properties.clientSecret).toBeDefined(); expect(app.required).not.toContain("clientId"); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index be396452f1c..6d246cabcc1 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,5 +1,5 @@ import { connectionManagementService } from "../services/connection-management.js"; -import { connectionIntentService } from "../services/connection-intents.js"; +import { 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"; @@ -54,6 +54,7 @@ import { } from "./authz.js"; import { badRequest, + conflict, notFound, forbidden, HttpError, @@ -104,16 +105,17 @@ 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") { + async function setupHandoff(req: ExpressRequest, reason: string, kind: ConnectionRequiredUserAction["kind"] = "authenticate", options: { requireCredentialRotation?: boolean } = {}) { 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 ? await connectionIntentService(db).requestChatHandoff({ sub: req.actor.agentId!, company_id: endpoint.companyId, - run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id) + run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id, { action }) : null; return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId, ...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId }, - `/${encodeURIComponent(company!.prefix)}/apps/chat/connect?${new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id })}`, reason, kind), + savedChatSetupPath(company!.prefix, endpoint, action), reason, kind), }; } @@ -372,7 +374,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { router.post("/chat-endpoints/:endpointId/github/identity/start", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/identity/start"), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); - if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must authenticate and confirm their own GitHub identity.")); return; } + if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must authenticate and confirm their own GitHub identity.", "confirm_identity")); return; } res.json(await service.githubWizard.startIdentity(endpointId(req), githubUser(req), req.actor)); }); router.post("/chat-endpoints/:endpointId/github/identity/confirm", (req, _res, next) => { assertBoard(req); next(); }, validate(z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict()), async (req, res) => { @@ -380,7 +382,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { res.set("Cache-Control", "no-store"); res.json(await service.githubWizard.confirmIdentity(endpointId(req), githubUser(req), req.body.githubUserId)); }); - router.post("/chat-endpoints/:endpointId/github/app", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/app"), validate(z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials")), async (req, res) => { + router.post("/chat-endpoints/:endpointId/github/app", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/app"), async (req, res, next) => { + if (req.actor.type !== "agent") { next(); return; } + if (!(await assertEndpointManagementAccess(req, res))) return; + const endpoint = await service.get(endpointId(req)); + if (endpoint.provider !== "github") throw notFound("GitHub bot not found"); + if (["archived", "active", "paused"].includes(endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot"); + res.set("Cache-Control", "no-store"); + res.json(await setupHandoff(req, "The responsible user must enter the GitHub App credentials and webhook signing secret.", "enter_credentials")); + }, validate(z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials")), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.json(await service.storeGitHubApp(endpointId(req), githubUser(req), req.body, "existing_app", connectionRequestContext(req))); }); @@ -431,7 +441,8 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); if (req.actor.type === "agent") { - res.json(await setupHandoff(req, "The responsible user must rotate and enter the webhook signing secret in GitHub.", "enter_credentials")); + if ((await service.get(endpointId(req))).provider !== "github") throw badRequest("Setup secret generation is only available for GitHub"); + res.json(await setupHandoff(req, "The responsible user must rotate and enter the webhook signing secret in GitHub.", "enter_credentials", { requireCredentialRotation: true })); return; } res diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 3abede41608..e79e40a0cad 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -861,6 +861,21 @@ const chatEndpointSetupSecretResponseSchema = z .object({ webhookSecret: z.string().length(64) }) .strict(); +const chatEndpointCredentialHandoffSchema = z.object({ + status: z.literal("needs_user_action"), + interactionId: z.string().uuid().nullable(), + endpointId: z.string().uuid(), + connectionId: z.string().uuid(), + requiredUserAction: z.object({ + kind: z.literal("enter_credentials"), + userId: z.string(), + resource: z.object({ type: z.literal("chat_endpoint"), id: z.string().uuid(), companyId: z.string().uuid() }), + setupPath: z.string(), + reason: z.string(), + continuation: z.string(), + }), +}); + const chatEndpointResponseSchema = z .object({ id: z.string().uuid(), @@ -2405,9 +2420,9 @@ const githubBotOperations: Array<{ { method: "post", suffix: "identity/confirm", summary: "Confirm the observed GitHub identity", description: "Confirms only the signed-in configuring member's expiring observed identity and resumes the same setup draft.", body: z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict(), response: githubWizardResponseSchema }, { method: "post", suffix: "app", summary: "Connect an existing GitHub App", - description: "Validates App identity with GitHub and vaults write-only credentials server-side. Installation and signed webhook delivery must still be verified.", - body: z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials"), - response: chatEndpointResponseSchema, + description: "Agents send an empty body to receive a saved credential-entry handoff. Only a human session may import App credentials and its webhook signing key. Human import validates App identity with GitHub and vaults credentials; installation and signed webhook delivery must still be verified.", + body: z.union([z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials"), z.object({}).strict()]), + response: z.union([chatEndpointResponseSchema, chatEndpointCredentialHandoffSchema]), }, { method: "post", suffix: "repositories/refresh", summary: "Refresh repositories available to the bot installation", @@ -2569,20 +2584,7 @@ registry.registerPath({ "Authorized agents receive a saved human setup handoff without rotating or reading the signing secret. A human session generates and vaults a new secret, returned exactly once for entry in GitHub. Normal endpoint reads expose only whether one is configured. Human regeneration immediately rotates the server-side value.", request: { params: z.object({ endpointId: z.string().uuid() }) }, responses: { - 200: r.ok(z.object({ - status: z.literal("needs_user_action"), - interactionId: z.string().uuid().nullable(), - endpointId: z.string().uuid(), - connectionId: z.string().uuid(), - requiredUserAction: z.object({ - kind: z.literal("enter_credentials"), - userId: z.string(), - resource: z.object({ type: z.literal("chat_endpoint"), id: z.string().uuid(), companyId: z.string().uuid() }), - setupPath: z.string(), - reason: z.string(), - continuation: z.string(), - }), - })), + 200: r.ok(chatEndpointCredentialHandoffSchema), 201: r.ok(chatEndpointSetupSecretResponseSchema), 401: r.unauthorized, 403: r.forbidden, diff --git a/server/src/services/chat-channels/credentials.ts b/server/src/services/chat-channels/credentials.ts index b31810ed7d9..ad122038702 100644 --- a/server/src/services/chat-channels/credentials.ts +++ b/server/src/services/chat-channels/credentials.ts @@ -253,6 +253,7 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { credentials: Record, credentialLease: CredentialMutationLeaseGuard, actorUserId?: string | null, + onCommitted?: (tx: Db) => Promise, ) { await credentialLease.assertOwned(); const previousRefs = await db @@ -344,6 +345,7 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { .update(toolConnections) .set({ credentialSecretRefs: refs, updatedAt: new Date() }) .where(eq(toolConnections.id, endpoint.connectionId)); + await onCommitted?.(tx as unknown as Db); await credentialLease.assertOwned(tx); }); } catch (error) { diff --git a/server/src/services/chat-channels/github/app-setup.ts b/server/src/services/chat-channels/github/app-setup.ts index 96a65975580..2697bb11b12 100644 --- a/server/src/services/chat-channels/github/app-setup.ts +++ b/server/src/services/chat-channels/github/app-setup.ts @@ -91,6 +91,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) { async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app", managementContext?: ConnectionManagementContext) { await managementContext?.reauthorize(); + if (managementContext?.actor.type === "agent") throw forbidden("GitHub signing credentials require a human setup handoff"); const initial = await endpointRecord(endpointId); if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found"); await withCredentialMutationLease(initial.endpoint, async credentialLease => { diff --git a/server/src/services/chat-channels/github/setup-secret.ts b/server/src/services/chat-channels/github/setup-secret.ts index 26221b0bbe9..2a9ff16c797 100644 --- a/server/src/services/chat-channels/github/setup-secret.ts +++ b/server/src/services/chat-channels/github/setup-secret.ts @@ -10,6 +10,7 @@ import { import { and, eq, + sql, } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -42,7 +43,7 @@ type GitHubSetupSecretOptions = { endpointRecord: (endpointId: string) => Promise; withCredentialMutationLease: ReturnType; resolveCredentials: (endpoint: EndpointRow) => Promise>; - persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise; + persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null, onCommitted?: (tx: Db) => Promise) => Promise; runtimeGeneration: (setup: ChatEndpointSetupState) => number; credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; invalidateRuntime: (endpointId: string) => Promise; @@ -244,6 +245,11 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption { ...existing, webhookSecret }, credentialLease, actorUserId, + async (tx) => { + await tx.update(chatEndpoints).set({ + setup: sql`${chatEndpoints.setup} || ${JSON.stringify({ credentialRotationId: rotationId, webhookVerifiedAt: null })}::jsonb`, + }).where(eq(chatEndpoints.id, endpoint.id)); + }, ); committedWebhookSecretRef = persistedRefs.find( diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 42888886d0c..ce680726cb7 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -31,6 +31,7 @@ import { } from "@paperclipai/db"; import { APP_STORE_DEFINITIONS, + isUuidLike, isConnectorFeatureEnabled, getAppDefinitionForApplicationKey, AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES, @@ -63,6 +64,21 @@ import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js"; import { restConnectorFor } from "./connectors/rest-registry.js"; type ConnectionRunClaims = Pick; +type SavedChatSetup = NonNullable; + +/** Opaque completion proof committed alongside the rotated vault references. */ +export function chatCredentialRotationId(setup: object): string | null { + const value: unknown = Reflect.get(setup, "credentialRotationId"); + return typeof value === "string" && isUuidLike(value) ? value : null; +} + +export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"]) { + 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"); } + return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`; +} type DbTransaction = Parameters[0]>[0]; @@ -877,7 +893,7 @@ export function connectionIntentService(db: Db) { return { ...row, interaction }; } - async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string) { + async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"] } = {}) { 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"); @@ -887,10 +903,12 @@ export function connectionIntentService(db: Db) { connectionId: endpoint.connectionId, provider: endpoint.provider, purpose: "chat", operation: "chat.authentication_handoff" }); 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 }, + purpose: "channel", savedSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId, + ...(options.action ? { action: options.action } : {}), + ...(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-authentication:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}`, + 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; @@ -911,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: `/${encodeURIComponent(company!.prefix)}/apps/chat/connect?${new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id })}` }, + path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action) }, }; } const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId, payload.purpose); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 9a4ed571a5b..dfce40542d6 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2613,6 +2613,11 @@ export function issueThreadInteractionService( }, ) => { const payload = connectionIntentPayloadSchema.parse(input.payload); + const sameSavedSetup = (candidate: typeof payload) => + 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); const existing = await getIdempotentInteraction({ issueId: issue.id, companyId: issue.companyId, @@ -2624,7 +2629,7 @@ export function issueThreadInteractionService( || existing.sourceRunId !== input.sourceRunId || existing.addresseeUserId !== input.addresseeUserId || (existing.kind === "connection_intent" - ? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest)) + ? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !sameSavedSetup(connectionIntentPayloadSchema.parse(existing.payload)) || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest)) : !isDeepStrictEqual(existing.payload, payload)) ) { throw conflict( @@ -2663,7 +2668,7 @@ export function issueThreadInteractionService( eq(issueThreadInteractions.addresseeUserId, input.addresseeUserId), )); const reusable = pending.find((candidate) => - connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest)); + connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && sameSavedSetup(connectionIntentPayloadSchema.parse(candidate.payload)) && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest)); if (reusable) { await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(reusable)); return reusable; @@ -2689,8 +2694,11 @@ export function issueThreadInteractionService( sourceRunId: input.sourceRunId, originCommentIds, sourceIdentityContextId: input.sourceIdentityContextId ?? null, - title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?` : `Connect ${payload.serviceName}`, - summary: `${payload.requestingAgentName} needs this connection to continue.`, + title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?` + : payload.savedSetup?.action === "rotate_credentials" ? `Rotate ${payload.serviceName} webhook secret` : `Connect ${payload.serviceName}`, + summary: payload.savedSetup?.action === "rotate_credentials" + ? `${payload.requestingAgentName} needs you to rotate the signing secret and reconnect the saved App.` + : `${payload.requestingAgentName} needs this connection to continue.`, createdByAgentId: payload.requestingAgentId, addresseeUserId: input.addresseeUserId, payload, diff --git a/server/src/services/satisfied-connection-intents.ts b/server/src/services/satisfied-connection-intents.ts index 15db040b411..b8f93a262e8 100644 --- a/server/src/services/satisfied-connection-intents.ts +++ b/server/src/services/satisfied-connection-intents.ts @@ -4,7 +4,7 @@ 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"; import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; -import { connectionIntentService } from "./connection-intents.js"; +import { chatCredentialRotationId, connectionIntentService } from "./connection-intents.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { logger } from "../middleware/logger.js"; import { createToolGatewayService } from "./tool-gateway.js"; @@ -29,6 +29,12 @@ 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.credentialRotation) { + // Rotation commits its generation and clears webhook verification in the + // same transaction as vault refs. Old health cannot satisfy this request. + const generation = chatCredentialRotationId(endpoint.setup); + if (!generation || generation === payload.savedSetup.credentialRotation.previousId || !endpoint.setup.webhookVerifiedAt) return null; + } const capability = await connectionManagementService(db).capability({ type: "agent", agentId: payload.requestingAgentId, companyId: task.companyId, onBehalfOfUserId: intent.addresseeUserId }, "connections:configure", { type: "connection_management", companyId: task.companyId, connectionId: connection.id, projectId: task.projectId, diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index 33724d02f00..c99127f51e1 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -785,6 +785,8 @@ function scopeAllowsTool(scope: Record | null, ctx: ToolAccessC } export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecret?: string } = {}) { + // Policy administration includes authorized self-targeted changes. It cannot + // resolve governed action requests or modify principal permission grants. async function listPolicies(companyId: string) { return db .select()