diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 2e9d714f3c9..1da62a87211 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -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); diff --git a/server/src/services/chat-channels/credentials.ts b/server/src/services/chat-channels/credentials.ts index 585229e3454..3657360375a 100644 --- a/server/src/services/chat-channels/credentials.ts +++ b/server/src/services/chat-channels/credentials.ts @@ -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 = {}; + 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[] = []; diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 3967cf526e9..0592b90a8b3 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -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 };