Merge the credential handoff API contract

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

* codex/agent-connection-api-parity:
  Describe the human signing-secret handoff in API discovery
This commit is contained in:
DottaandPaperclip committed 2026-10-26 13:42:49 -05:00
commit e7899f18c5
4 files changed
+25 -5

No files matched your search

@@ -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));
@@ -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({
+4 -3
View File
@@ -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
+15 -1
View File
@@ -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,