mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Merge verified credential handoff completion and commit authority
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: fix: require committed credential changes before resuming setup
This commit is contained in:
commit
b74c34e75b
9 files changed
+133
-16
No files matched your search
@@ -1452,7 +1452,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; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null } };
|
||||
savedSetup?: { endpointId: string; connectionId: string; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null }; credentialEntry?: { previousGenerations: Record<string, string | null>; requireAll: boolean } };
|
||||
version: 1;
|
||||
/** Server-authored consent to a fixed set of tools on an existing connection. */
|
||||
accessRequest?: {
|
||||
|
||||
@@ -1170,7 +1170,10 @@ const connectionIntentBrandAssetSchema = z
|
||||
|
||||
export const connectionIntentPayloadSchema = z
|
||||
.object({
|
||||
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()
|
||||
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(), credentialEntry: z.object({
|
||||
previousGenerations: z.record(z.string().refine(field => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(field)), z.string().uuid().nullable()).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 5),
|
||||
requireAll: z.boolean(),
|
||||
}).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(),
|
||||
|
||||
@@ -262,6 +262,8 @@ describe("connection management with production default agents", () => {
|
||||
const address = remote.address();
|
||||
if (!address || typeof address === "string") throw new Error("Fixture did not listen");
|
||||
const mcpUrl = `http://127.0.0.1:${address.port}/mcp`;
|
||||
const freezeRateClock = ["other-agent-caller-rate", "other-agent-shared-rate", "rate-limit", "agent-rate-approval"].includes(revocation);
|
||||
if (freezeRateClock) vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Governed test", transport: "mcp_remote", authKind: "none", transportConfig: { url: mcpUrl } });
|
||||
await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, created.body.id));
|
||||
@@ -386,7 +388,7 @@ describe("connection management with production default agents", () => {
|
||||
else expect(settled, JSON.stringify(settled)).toMatchObject({ status: allowed ? "succeeded" : "failed" });
|
||||
expect(calls).toBe(allowed ? 1 : 0);
|
||||
if (allowed) { expect((await approve()).status).toBe(200); expect(calls).toBe(1); }
|
||||
} finally { remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
|
||||
} finally { if (freezeRateClock) vi.useRealTimers(); remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
|
||||
});
|
||||
|
||||
it("completes concurrent run-authenticated profile finishing and policy writes without lock-order failures", async () => {
|
||||
@@ -643,8 +645,12 @@ describe("connection management with production default agents", () => {
|
||||
const botId = `U${f.agentId.replaceAll("-", "").toUpperCase()}`;
|
||||
const scopes = [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES];
|
||||
let sent = 0;
|
||||
let revokeDuringAuth = false;
|
||||
provider = async (url, init) => {
|
||||
if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
||||
if (url.endsWith("auth.test")) {
|
||||
if (revokeDuringAuth) await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
||||
return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
||||
}
|
||||
if (url.includes("conversations.list")) return Response.json({ ok: true, channels: [], response_metadata: { next_cursor: "" } });
|
||||
if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: new URLSearchParams(String(init?.body)).get("user") ?? "UHUMAN", team_id: "TFIXTURE", name: "Fixture user", is_bot: false, deleted: false } });
|
||||
if (url.endsWith("chat.postMessage")) { sent++; return Response.json({ ok: true, channel: "DTEST", ts: "1.000001" }); }
|
||||
@@ -660,17 +666,25 @@ describe("connection management with production default agents", () => {
|
||||
const configured = { status: configuredResponse.status, body: await configuredResponse.json() };
|
||||
expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 });
|
||||
expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token");
|
||||
const [configuredEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
|
||||
await server.db.update(chatEndpoints).set({ status: "active", setup: { ...configuredEndpoint.setup, step: "complete", webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
|
||||
const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!;
|
||||
const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref);
|
||||
expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt))
|
||||
.toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } });
|
||||
let credentialIntentId: string | undefined;
|
||||
for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) {
|
||||
const chosen = "agent-chosen-webhook-key";
|
||||
const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token);
|
||||
expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id,
|
||||
requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } });
|
||||
expect(JSON.stringify(handoff.body)).not.toContain(chosen);
|
||||
expect(handoff.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1");
|
||||
if (token === f.jwt) {
|
||||
if (credentialIntentId) expect(handoff.body.interactionId).toBe(credentialIntentId);
|
||||
credentialIntentId = handoff.body.interactionId;
|
||||
}
|
||||
const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs);
|
||||
const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
||||
@@ -678,8 +692,46 @@ describe("connection management with production default agents", () => {
|
||||
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
|
||||
})).toBe("fixture-signing-secret");
|
||||
}
|
||||
const [credentialIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!));
|
||||
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
const entryProof = (credentialIntent.payload as { savedSetup: { credentialEntry: { previousGenerations: Record<string, string>; requireAll: boolean } } }).savedSetup.credentialEntry;
|
||||
expect(entryProof).toEqual({ previousGenerations: { signingSecret: expect.any(String) }, requireAll: true });
|
||||
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
||||
await satisfiedConnectionIntentService(server.db).sweepPending();
|
||||
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("pending");
|
||||
// Ordinary write-only bot-token repair remains delegated under current authority.
|
||||
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } });
|
||||
const healthy = async () => {
|
||||
await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, created.body.id));
|
||||
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
||||
};
|
||||
await healthy();
|
||||
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
||||
const humanReconnect = async (signingSecret: string) => {
|
||||
const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "reconnect", credentials: { signingSecret } }) });
|
||||
expect({ status: response.status, body: await response.json() }).toMatchObject({ status: 200 });
|
||||
};
|
||||
await humanReconnect("fixture-signing-secret");
|
||||
await healthy();
|
||||
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
||||
await humanReconnect("human-replacement-signing-secret");
|
||||
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
||||
const resumedRequest = await f.request("POST", setupPath, { action: "reconnect", credentials: { signingSecret: "agent-chosen-webhook-key" } }, f.jwt);
|
||||
expect(resumedRequest.body.interactionId).toBe(credentialIntentId);
|
||||
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].payload)
|
||||
.toMatchObject({ savedSetup: { credentialEntry: entryProof } });
|
||||
await healthy();
|
||||
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
||||
const [repaired] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
|
||||
await server.db.update(chatEndpoints).set({ setup: { ...repaired.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
|
||||
expect((await findSatisfiedToolConnection(server.db, task, credentialIntent))?.id).toBe(created.body.connectionId);
|
||||
expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, credentialIntent)).toBeNull();
|
||||
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
||||
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
||||
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active");
|
||||
await satisfiedConnectionIntentService(server.db).sweepPending();
|
||||
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("expired");
|
||||
// Identity is a provider fixture; production HTTP admission and policy remain real.
|
||||
const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning();
|
||||
await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() });
|
||||
@@ -690,6 +742,7 @@ describe("connection management with production default agents", () => {
|
||||
expect(denied, JSON.stringify(denied)).toMatchObject({ status: 403 });
|
||||
expect(sent).toBe(0);
|
||||
await server.db.update(toolPolicies).set({ policyType: "allow" }).where(eq(toolPolicies.id, deny.id));
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One Slack setup send", policyType: "rate_limit", selectors: { connectionId: created.body.connectionId }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
||||
const requestId = randomUUID();
|
||||
const tested = await f.request("POST", testPath, { requestId });
|
||||
@@ -698,7 +751,13 @@ describe("connection management with production default agents", () => {
|
||||
expect(await f.request("POST", testPath, { requestId: randomUUID() })).toMatchObject({ status: 403, body: { details: { reasonCode: "rate_limited" } } });
|
||||
expect(await f.request("POST", testPath, { requestId: randomUUID(), slackUserId: "UOTHER" })).toMatchObject({ status: 403 });
|
||||
expect(sent).toBe(1);
|
||||
} finally { provider = null; }
|
||||
vi.useRealTimers();
|
||||
const [beforeRevocation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
revokeDuringAuth = true;
|
||||
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 403 });
|
||||
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs)
|
||||
.toEqual(beforeRevocation.credentialSecretRefs);
|
||||
} finally { vi.useRealTimers(); provider = null; }
|
||||
});
|
||||
|
||||
it("imports a GitHub App and discovers repositories against provider fixtures", async () => {
|
||||
|
||||
@@ -105,13 +105,13 @@ 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", options: { requireCredentialRotation?: boolean } = {}) {
|
||||
async function setupHandoff(req: ExpressRequest, reason: string, kind: ConnectionRequiredUserAction["kind"] = "authenticate", options: { requireCredentialRotation?: boolean; credentialFields?: string[] } = {}) {
|
||||
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 handoff = 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, { action })
|
||||
run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id, { action, credentialFields: options.credentialFields })
|
||||
: null;
|
||||
const interactionId = handoff?.interactionId ?? null;
|
||||
const previousRotationId = handoff ? handoff.previousRotationId : chatCredentialRotationId(endpoint.setup);
|
||||
@@ -440,7 +440,9 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403
|
||||
|| !error.details || typeof error.details !== "object" || !("code" in error.details)
|
||||
|| error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error;
|
||||
res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials"));
|
||||
const credentialFields = "credentialFields" in error.details && Array.isArray(error.details.credentialFields)
|
||||
? error.details.credentialFields.filter((field): field is string => typeof field === "string") : undefined;
|
||||
res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials", { credentialFields }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionManagementContext } from "../connection-management.js";
|
||||
import { CHAT_HUMAN_CREDENTIAL_FIELDS } from "./credentials.js";
|
||||
import { preparePhotonConfiguration, createPhotonConfiguration } from "./imessage-photon/setup.js";
|
||||
import { slackRuntimeConfig } from "./slack/setup.js";
|
||||
import { createTelegramSetup } from "./telegram/setup.js";
|
||||
@@ -147,11 +148,11 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
|
||||
}
|
||||
// These keys establish provider authentication or inbound trust. They
|
||||
// must come from the human setup step or a verified provider handoff.
|
||||
if (managementContext?.actor.type === "agent" && suppliedCredentialKeys.some(
|
||||
key => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(key),
|
||||
)) {
|
||||
const credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(key => suppliedCredentialKeys.includes(key));
|
||||
if (managementContext?.actor.type === "agent" && credentialFields.length) {
|
||||
throw forbidden("The responsible user must enter provider verification credentials in the saved setup", {
|
||||
code: "chat_endpoint_credential_entry_requires_human",
|
||||
credentialFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -285,6 +286,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
|
||||
prepared.credentials,
|
||||
credentialLease,
|
||||
actorUserId,
|
||||
managementContext ? async () => managementContext.reauthorize() : undefined,
|
||||
);
|
||||
}
|
||||
credentials = prepared.credentials;
|
||||
@@ -676,6 +678,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
|
||||
`${PROVIDER_LABELS[endpoint.provider]} credentials are required`,
|
||||
);
|
||||
});
|
||||
await managementContext?.reauthorize();
|
||||
if (endpoint.provider === "imessage-photon") {
|
||||
credentials = preparePhotonConfiguration(endpoint, input, credentials);
|
||||
}
|
||||
@@ -769,6 +772,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
|
||||
credentials,
|
||||
credentialLease,
|
||||
actorUserId,
|
||||
managementContext ? async () => managementContext.reauthorize() : undefined,
|
||||
);
|
||||
if (prepared.inventory)
|
||||
await reconcileProviderResourceRows(
|
||||
|
||||
@@ -16,7 +16,8 @@ import { type CredentialMutationLeaseGuard } from "../chat-credential-mutation-l
|
||||
|
||||
import { photonSharedScope } from "../photon/cloud.js";
|
||||
|
||||
import { photonChannelConfigurationSchema } from "@paperclipai/shared";
|
||||
import { isUuidLike, photonChannelConfigurationSchema } from "@paperclipai/shared";
|
||||
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
randomBytes,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
eq,
|
||||
inArray,
|
||||
ne,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -58,6 +60,17 @@ import {
|
||||
|
||||
import { secretService } from "../secrets.js";
|
||||
|
||||
export const CHAT_HUMAN_CREDENTIAL_FIELDS = ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"] as const;
|
||||
|
||||
/** Opaque generations identify actual protected-value changes, never secret bytes. */
|
||||
export function chatCredentialEntryGenerations(setup: object): Record<string, string | null> {
|
||||
const generations: unknown = Reflect.get(setup, "credentialEntryGenerations");
|
||||
return Object.fromEntries(CHAT_HUMAN_CREDENTIAL_FIELDS.map(field => {
|
||||
const value: unknown = generations && typeof generations === "object" ? Reflect.get(generations, field) : null;
|
||||
return [field, typeof value === "string" && isUuidLike(value) ? value : null];
|
||||
}));
|
||||
}
|
||||
|
||||
type ChatCredentialsOptions = {
|
||||
secrets: ReturnType<typeof secretService>;
|
||||
PROVIDER_LABELS: Record<ChatProvider, string>;
|
||||
@@ -266,6 +279,12 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.refs ?? []);
|
||||
const protectedFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => credentials[field] !== undefined);
|
||||
const previousValues = protectedFields.length ? await resolveCredentialRefs(endpoint, previousRefs.filter(
|
||||
ref => protectedFields.some(field => ref.configPath === `credentials.${field}`),
|
||||
)) : {};
|
||||
const changedFields = protectedFields.filter(field => credentials[field] !== previousValues[field]);
|
||||
const generations = Object.fromEntries(changedFields.map(field => [field, randomUUID()]));
|
||||
const refs: ToolCredentialSecretRef[] = [];
|
||||
try {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
@@ -345,6 +364,16 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
|
||||
.update(toolConnections)
|
||||
.set({ credentialSecretRefs: refs, updatedAt: new Date() })
|
||||
.where(eq(toolConnections.id, endpoint.connectionId));
|
||||
if (changedFields.length) {
|
||||
await notifyChatEndpointWork(tx, endpoint.connectionId);
|
||||
const verification = changedFields.some(field => field === "signingSecret" || field === "webhookSecret")
|
||||
? { webhookVerifiedAt: null } : {};
|
||||
await tx.update(chatEndpoints).set({
|
||||
// Fence old healthy state in the same transaction as the new refs.
|
||||
status: sql`case when ${chatEndpoints.status} = 'active' then 'verifying' else ${chatEndpoints.status} end`,
|
||||
setup: sql`jsonb_set(${chatEndpoints.setup}, '{credentialEntryGenerations}', coalesce(${chatEndpoints.setup}->'credentialEntryGenerations', '{}'::jsonb) || ${JSON.stringify(generations)}::jsonb) || ${JSON.stringify(verification)}::jsonb`,
|
||||
}).where(and(eq(chatEndpoints.id, endpoint.id), eq(chatEndpoints.companyId, endpoint.companyId)));
|
||||
}
|
||||
await onCommitted?.(tx as unknown as Db);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { connectionManagementService } from "./connection-management.js";
|
||||
import { CHAT_HUMAN_CREDENTIAL_FIELDS, chatCredentialEntryGenerations } from "./chat-channels/credentials.js";
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { emailChannelService } from "./email-channels.js";
|
||||
import { emailConnectionService } from "./email-connections.js";
|
||||
@@ -74,7 +75,7 @@ export function chatCredentialRotationId(setup: object): string | null {
|
||||
|
||||
export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"], previousRotationId?: string | null) {
|
||||
const query = new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id });
|
||||
if (action === "enter_credentials" || action === "rotate_credentials") query.set("stage", "credentials");
|
||||
if (action === "enter_credentials" || action === "rotate_credentials") { query.set("stage", "credentials"); query.set("reconnect", "1"); }
|
||||
if (action === "confirm_identity") query.set("stage", "identity");
|
||||
if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); query.set("afterRotationId", previousRotationId ?? "none"); }
|
||||
return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`;
|
||||
@@ -893,7 +894,7 @@ export function connectionIntentService(db: Db) {
|
||||
return { ...row, interaction };
|
||||
}
|
||||
|
||||
async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"] } = {}) {
|
||||
async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"]; credentialFields?: string[] } = {}) {
|
||||
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");
|
||||
@@ -901,14 +902,18 @@ export function connectionIntentService(db: Db) {
|
||||
agentId: context.agent.id, runId: context.run.id, onBehalfOfUserId: context.run.responsibleUserId,
|
||||
}, "connections:configure", { type: "connection_management", companyId: context.run.companyId,
|
||||
connectionId: endpoint.connectionId, provider: endpoint.provider, purpose: "chat", operation: "chat.authentication_handoff" });
|
||||
const credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => !options.credentialFields || options.credentialFields.includes(field));
|
||||
if (options.action === "enter_credentials" && !credentialFields.length) throw unprocessable("Credential entry requires a supported verification field");
|
||||
const generations = chatCredentialEntryGenerations(endpoint.setup);
|
||||
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,
|
||||
...(options.action ? { action: options.action } : {}),
|
||||
...(options.action === "enter_credentials" ? { credentialEntry: { previousGenerations: Object.fromEntries(credentialFields.map(field => [field, generations[field]])), requireAll: !!options.credentialFields } } : {}),
|
||||
...(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-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" ? `:${context.run.id}` : ""}`,
|
||||
addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" || options.action === "enter_credentials" ? `:${context.run.id}` : ""}`,
|
||||
});
|
||||
if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome.");
|
||||
return { interactionId: interaction.id, previousRotationId: connectionIntentPayloadSchema.parse(interaction.payload).savedSetup?.credentialRotation?.previousId ?? null };
|
||||
|
||||
@@ -2617,7 +2617,12 @@ export function issueThreadInteractionService(
|
||||
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);
|
||||
&& Boolean(candidate.savedSetup?.credentialRotation) === Boolean(payload.savedSetup?.credentialRotation)
|
||||
&& candidate.savedSetup?.credentialEntry?.requireAll === payload.savedSetup?.credentialEntry?.requireAll
|
||||
// Preserve the original generation boundary on retries, while keeping
|
||||
// requests for different credential fields distinct.
|
||||
&& isDeepStrictEqual(Object.keys(candidate.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort(),
|
||||
Object.keys(payload.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort());
|
||||
const existing = await getIdempotentInteraction({
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { enqueueTerminalIssueInteractionChatPublications } from "./chat-interaction-publications.js";
|
||||
import { connectionManagementService } from "./connection-management.js";
|
||||
import { chatCredentialEntryGenerations } from "./chat-channels/credentials.js";
|
||||
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";
|
||||
@@ -29,6 +30,15 @@ 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.action === "enter_credentials") {
|
||||
const proof = payload.savedSetup.credentialEntry;
|
||||
if (!proof) return null;
|
||||
const generations = chatCredentialEntryGenerations(endpoint.setup);
|
||||
const fields = Object.keys(proof.previousGenerations);
|
||||
const changed = fields.filter(field => typeof generations[field] === "string" && generations[field] !== proof.previousGenerations[field]);
|
||||
if (!changed.length || (proof.requireAll && changed.length !== fields.length)) return null;
|
||||
if (changed.some(field => field === "signingSecret" || field === "webhookSecret") && !endpoint.setup.webhookVerifiedAt) 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.
|
||||
|
||||
Reference in new issue
Block a user