mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Describe the human signing-secret handoff in API discovery
Expose the credential-entry response separately from human-only secret generation and verify the typed contract through OpenAPI and real agent authentication. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
096d2b0039
commit
35585448ef
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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user