Keep credential handoffs pending until their requested change commits

Separate saved import, identity, and rotation actions; bind rotation completion atomically to new vault references and fresh verification. Keep signing-key import human-only. Exercise authorized self-targeted policy administration without granting self-approval.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 15:06:38 -05:00
1 parent 35585448ef
commit f99e8aa6d5
13 files changed
+159 -54

No files matched your search

+1 -1
View File
@@ -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?: {
+2 -1
View File
@@ -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),
@@ -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; }
});
+6 -1
View File
@@ -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");
+18 -7
View File
@@ -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
+19 -17
View File
@@ -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,
@@ -253,6 +253,7 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
credentials: Record<string, string>,
credentialLease: CredentialMutationLeaseGuard,
actorUserId?: string | null,
onCommitted?: (tx: Db) => Promise<void>,
) {
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) {
@@ -91,6 +91,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
async function storeGitHubApp(endpointId: string, userId: string, credentials: Record<string, string>, 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 => {
@@ -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<EndpointRecord | null>;
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise<ToolCredentialSecretRef[]>;
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null, onCommitted?: (tx: Db) => Promise<void>) => Promise<ToolCredentialSecretRef[]>;
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
invalidateRuntime: (endpointId: string) => Promise<boolean>;
@@ -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(
+22 -4
View File
@@ -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<RuntimeToolsTokenClaims, "sub" | "company_id" | "run_id" | "responsible_user_id">;
type SavedChatSetup = NonNullable<ConnectionIntentInteraction["payload"]["savedSetup"]>;
/** 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<Parameters<Db["transaction"]>[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);
@@ -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,
@@ -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,
@@ -785,6 +785,8 @@ function scopeAllowsTool(scope: Record<string, unknown> | 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()