diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index cd08b7b7daa..0af35c18f6d 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -650,7 +650,8 @@ describe("connection management with production default agents", () => { 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 } }); + 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)); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index b67d31593ca..0023aa7642b 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -788,6 +788,10 @@ describe("openapi routes", () => { const setupSecret = spec.paths["/api/chat-endpoints/{endpointId}/setup-secret"].post; + const secretHandoff = setupSecret.responses["200"].content["application/json"].schema; + expect(secretHandoff.properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]); + expect(secretHandoff.properties).not.toHaveProperty("webhookSecret"); + expect(setupSecret.description).toContain("without rotating or reading"); expect( setupSecret.responses["201"].content["application/json"].schema, ).toMatchObject({ diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 2eec96346c0..be396452f1c 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -30,6 +30,7 @@ import { resolveChatPublicationSchema, updateChatEndpointSchema, type ChatProvider, + type ConnectionRequiredUserAction, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { @@ -103,7 +104,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { const access = accessService(db); const github = githubChatManagementService(db, options.fetch); - async function setupHandoff(req: ExpressRequest, reason: string) { + async function setupHandoff(req: ExpressRequest, reason: string, kind: ConnectionRequiredUserAction["kind"] = "authenticate") { const endpoint = await service.get(endpointId(req)); const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, endpoint.companyId)); const interactionId = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId @@ -112,7 +113,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { : 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), + `/${encodeURIComponent(company!.prefix)}/apps/chat/connect?${new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id })}`, reason, kind), }; } @@ -430,7 +431,7 @@ 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.")); + res.json(await setupHandoff(req, "The responsible user must rotate and enter the webhook signing secret in GitHub.", "enter_credentials")); return; } res diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 08b081dfc6b..3abede41608 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2566,9 +2566,23 @@ registry.registerPath({ tags: ["chat-channels"], summary: "Generate a GitHub webhook secret", description: - "Generates and vaults a new GitHub webhook secret. The plaintext value is returned exactly once for entry in GitHub; normal endpoint reads expose only whether one is configured. Regeneration immediately rotates the server-side value.", + "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(), + }), + })), 201: r.ok(chatEndpointSetupSecretResponseSchema), 401: r.unauthorized, 403: r.forbidden,