mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Merge credential repair and handoff corrections
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: fix: preserve credential repair and fresh setup handoffs
This commit is contained in:
commit
b31c3fd98b
3 files changed
+68
-8
No files matched your search
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agentApiKeys, companies, companySecrets, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGateways, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
|
||||
import { activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGateways, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
|
||||
import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
|
||||
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
|
||||
import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
|
||||
@@ -19,6 +19,7 @@ import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
|
||||
@@ -709,7 +710,7 @@ describe("connection management with production default agents", () => {
|
||||
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 } }) });
|
||||
body: JSON.stringify({ action: "reconnect", credentials: { signingSecret, botToken: "xoxb-fixture-token" } }) });
|
||||
expect({ status: response.status, body: await response.json() }).toMatchObject({ status: 200 });
|
||||
};
|
||||
await humanReconnect("fixture-signing-secret");
|
||||
@@ -732,6 +733,37 @@ describe("connection management with production default agents", () => {
|
||||
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");
|
||||
// Replacing complete credentials repairs unavailable old vault material.
|
||||
for (const state of ["disabled", "deleted", "unreadable"] as const) {
|
||||
const [connection] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
const signing = connection.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
||||
if (state === "unreadable") {
|
||||
await server.db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, signing.secretId));
|
||||
await server.db.update(companySecretVersions).set({ material: {} }).where(eq(companySecretVersions.secretId, signing.secretId));
|
||||
} else {
|
||||
await server.db.update(companySecrets).set({ status: state }).where(eq(companySecrets.id, signing.secretId));
|
||||
}
|
||||
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: `human-repair-${state}`, botToken: "xoxb-fixture-token" } }) });
|
||||
expect.soft({ status: response.status, body: await response.json() }, `repair ${state} signing secret`).toMatchObject({ status: 200 });
|
||||
if (response.status === 200) {
|
||||
const [current] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
const replacement = current.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
||||
expect(replacement.secretId).not.toBe(signing.secretId);
|
||||
expect(await secretService(server.db).resolveSecretValue(f.companyId, replacement.secretId, "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
|
||||
})).toBe(`human-repair-${state}`);
|
||||
}
|
||||
}
|
||||
// Unexpected resolver failures must abort before writing new references.
|
||||
const [beforeFault] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
||||
const resolverFault = vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValue(new Error("Unexpected secret resolution failure"));
|
||||
try {
|
||||
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: "human-repair-unexpected", botToken: "xoxb-fixture-token" } }) });
|
||||
expect(response.status).toBe(500);
|
||||
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs).toEqual(beforeFault.credentialSecretRefs);
|
||||
} finally { resolverFault.mockRestore(); }
|
||||
// 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() });
|
||||
@@ -791,6 +823,15 @@ describe("connection management with production default agents", () => {
|
||||
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.soft(importHandoff.body.requiredUserAction.setupPath).toContain("stage=credentials");
|
||||
expect.soft(importHandoff.body.requiredUserAction.setupPath).not.toContain("reconnect=1");
|
||||
const privateKeyHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { privateKey } }, f.jwt);
|
||||
expect.soft(privateKeyHandoff, JSON.stringify(privateKeyHandoff)).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials" } } });
|
||||
if (privateKeyHandoff.status === 200) {
|
||||
expect(privateKeyHandoff.body.interactionId).not.toBe(importHandoff.body.interactionId);
|
||||
const retry = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { privateKey } }, f.jwt);
|
||||
expect(retry.body.interactionId).toBe(privateKeyHandoff.body.interactionId);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
import { isUniqueViolation } from "../../db-errors.js";
|
||||
|
||||
import {
|
||||
HttpError,
|
||||
conflict,
|
||||
notFound,
|
||||
unprocessable,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
} from "../chat-teams-credentials.js";
|
||||
|
||||
import { secretService } from "../secrets.js";
|
||||
import { isSecretProviderClientError } from "../../secrets/types.js";
|
||||
|
||||
export const CHAT_HUMAN_CREDENTIAL_FIELDS = ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"] as const;
|
||||
|
||||
@@ -280,9 +282,22 @@ 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 previousValues: Record<string, string> = {};
|
||||
for (const ref of previousRefs.filter(ref => protectedFields.some(field => ref.configPath === `credentials.${field}`))) {
|
||||
try {
|
||||
Object.assign(previousValues, await resolveCredentialRefs(endpoint, [ref]));
|
||||
} catch (error) {
|
||||
// A valid replacement can repair unavailable old material. Unexpected
|
||||
// database failures and company/scope violations must still abort.
|
||||
const code = error instanceof HttpError && error.details && typeof error.details === "object"
|
||||
? Reflect.get(error.details, "code") : null;
|
||||
const unavailable = isSecretProviderClientError(error) || (error instanceof HttpError && (
|
||||
["secret_deleted", "secret_inactive", "version_missing", "version_inactive"].includes(code) ||
|
||||
(error.status === 404 && error.message === "Secret not found" && !error.details)
|
||||
));
|
||||
if (!unavailable) throw error;
|
||||
}
|
||||
}
|
||||
const changedFields = protectedFields.filter(field => credentials[field] !== previousValues[field]);
|
||||
const generations = Object.fromEntries(changedFields.map(field => [field, randomUUID()]));
|
||||
const refs: ToolCredentialSecretRef[] = [];
|
||||
|
||||
@@ -73,9 +73,13 @@ export function chatCredentialRotationId(setup: object): string | null {
|
||||
return typeof value === "string" && isUuidLike(value) ? value : null;
|
||||
}
|
||||
|
||||
export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"], previousRotationId?: string | null) {
|
||||
export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string; status?: string; botExternalId?: string | null; providerAccountId?: string | null }, 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"); query.set("reconnect", "1"); }
|
||||
if (action === "enter_credentials" || action === "rotate_credentials") query.set("stage", "credentials");
|
||||
const configured = endpoint.status !== "draft" && (
|
||||
["active", "paused", "revoked"].includes(endpoint.status ?? "") || !!endpoint.botExternalId || !!endpoint.providerAccountId
|
||||
);
|
||||
if (action === "enter_credentials" && configured) 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}`;
|
||||
@@ -913,7 +917,7 @@ export function connectionIntentService(db: Db) {
|
||||
...(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" || options.action === "enter_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}` : ""}${options.action === "enter_credentials" ? `:${options.credentialFields ? "all" : "any"}:${credentialFields.join(",")}` : ""}`,
|
||||
});
|
||||
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 };
|
||||
|
||||
Reference in new issue
Block a user