From 58c9983e01c42e7e8a7dc15c9d83e739c404458a Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 07:01:10 -0500 Subject: [PATCH] Keep interrupted manifest OAuth storage in recovery --- .../chat-channels.integration.test.ts | 72 +++++++++++++++++++ server/src/services/chat-github-wizard.ts | 7 +- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index a3711ea5b5..fddd1c557d 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -2370,6 +2370,78 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .where(eq(chatGitHubRegistrations.endpointId, bot.id)); expect(session.status).toBe("failed"); }); + it("wizard retains recovery when a manifest vault write saved App credentials but lost OAuth credentials", async () => { + const f = await reviewBotFixture(); + const [connection] = await db + .select() + .from(toolConnections) + .where(eq(toolConnections.id, f.endpoint.connectionId)); + expect(connection.credentialSecretRefs.map((ref) => ref.configPath)).toEqual( + expect.arrayContaining([ + "credentials.appId", + "credentials.privateKey", + "credentials.webhookSecret", + ]), + ); + await db + .update(toolConnections) + .set({ + credentialSecretRefs: connection.credentialSecretRefs.filter( + (ref) => ref.configPath !== "credentials.clientSecret", + ), + }) + .where(eq(toolConnections.id, connection.id)); + await db + .update(chatEndpoints) + .set({ status: "attention" }) + .where(eq(chatEndpoints.id, f.endpoint.id)); + await db + .insert(chatGitHubRegistrations) + .values({ + companyId: f.companyId, + endpointId: f.endpoint.id, + userId: "owner-user", + stateHash: createHash("sha256").update("bound").digest("hex"), + trustedOrigin: "http://127.0.0.1:3104", + status: "failed", + expiresAt: new Date(Date.now() + 60000), + handoff: { + cloudId: "partial-oauth", + returnState: "bound", + redemptionId: "receipt", + manifestClaimId: "claim", + }, + }); + const claimGitHubApp = vi.fn(); + const finish = vi.fn(); + const wizard = githubChatWizardService(db, { + origin: () => "http://127.0.0.1:3104", + fetch: f.providerFetch, + connector: () => + ({ + githubApp: async () => ({ id: "partial-oauth" }), + claimGitHubApp, + }) as unknown as PaperclipCloudConnector, + startDirect: f.service.startGitHubRegistration, + storeApp: f.service.storeGitHubApp, + storeCredentials: async () => {}, + refreshRepositories: f.service.refreshGitHubRepositories, + resources: f.service.listResources, + replaceResources: f.service.replaceResources, + configure: async () => {}, + finish, + }); + expect(await wizard.advance(f.endpoint.id, "owner-user")).toMatchObject({ + state: "recovery", + }); + expect(claimGitHubApp).not.toHaveBeenCalled(); + expect(finish).not.toHaveBeenCalled(); + const [session] = await db + .select() + .from(chatGitHubRegistrations) + .where(eq(chatGitHubRegistrations.endpointId, f.endpoint.id)); + expect(session.status).toBe("failed"); + }); it("wizard recovers a renamed enrolled origin only after checking bound state and Cloud authority", async () => { const f = await seedCompany(); const { service } = createService(); diff --git a/server/src/services/chat-github-wizard.ts b/server/src/services/chat-github-wizard.ts index d9d43defb3..15c10e6d5d 100644 --- a/server/src/services/chat-github-wizard.ts +++ b/server/src/services/chat-github-wizard.ts @@ -640,9 +640,12 @@ export function githubChatWizardService( eq(toolConnections.companyId, bot.companyId), ), ); + const requiredCredentialKeys = session.handoff.manifestClaimId + ? ["appId", "privateKey", "webhookSecret", "clientId", "clientSecret"] + : ["appId", "privateKey", "webhookSecret"]; let hasAppCredentials = !!bot.botExternalId && - ["appId", "privateKey", "webhookSecret"].every((key) => + requiredCredentialKeys.every((key) => connection?.refs.some( (ref) => ref.configPath === `credentials.${key}`, ), @@ -669,7 +672,7 @@ export function githubChatWizardService( eq(toolConnections.companyId, bot.companyId), ), ); - hasAppCredentials = ["appId", "privateKey", "webhookSecret"].every( + hasAppCredentials = requiredCredentialKeys.every( (key) => savedConnection?.refs.some( (ref) => ref.configPath === `credentials.${key}`,