mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
1 parent
35585448ef
commit
f99e8aa6d5
13 files changed
+159
-54
No files matched your search
@@ -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?: {
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user