diff --git a/doc/plans/2026-10-06-github-app-wizard.md b/doc/plans/2026-10-06-github-app-wizard.md index 3b89c566cb2..54774d24ee1 100644 --- a/doc/plans/2026-10-06-github-app-wizard.md +++ b/doc/plans/2026-10-06-github-app-wizard.md @@ -106,7 +106,7 @@ enrollment handoff passed. The test did not authorize Cloud enrollment or create a provider App. The focused wizard and retained clipboard/recovery UI suites pass (37 tests). -GitHub backend coverage passes (192 cases), including both owner types, +GitHub backend coverage passes (193 cases), including both owner types, interrupted registration and vault storage, empty saved repository selections, localhost configuration, concurrent identity attempts, enrolled-origin recovery, tenant binding, and legacy callbacks. The complete OpenAPI suite passes (13 @@ -123,7 +123,9 @@ covered by the updated focused checks. The full GitHub fixture also verifies installation permissions and preserved legacy tool authority. The browser report attaches screenshots of both wizard screens. Fresh manual App imports initialize repository access and tools without a registration row; interrupted -imports resume those defaults while explicit restrictions stay preserved. +imports resume those defaults while explicit restrictions stay preserved. A real +vault-write interruption stays in recovery without completing an empty import; +retrying the credentials discovers and imports the actual repositories. Cloud's final standard `npm test` passes: 2,561 tests and 73 environment-dependent skips. Dedicated-App regressions cover normalized concurrent starts, stale diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 14a67f82a26..f4d91de479e 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1877,6 +1877,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { useVerifiedAppId = false, registerWithManifest = false, connectExistingApp = false, + interruptAppVault = false, ) { let setupComplete = false; const deferredSchedule = overrides.scheduleDeferredWork; @@ -2052,10 +2053,26 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ); if (registerWithManifest) await context.service.startGitHubRegistration(endpoint.id, "owner-user", "Maya"); - if (connectExistingApp) - await context.service.storeGitHubApp(endpoint.id, "owner-user", { - appId: String(appRegistrationId), privateKey, webhookSecret, - }); + if (connectExistingApp) { + const credentials = { appId: String(appRegistrationId), privateKey, webhookSecret }; + if (interruptAppVault) { + // Fail the real vault write after App identity has been durably claimed. + await db.execute(sql.raw("CREATE FUNCTION github_wizard_fail_vault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Simulated App vault failure'; END $$")); + await db.execute(sql.raw("CREATE TRIGGER github_wizard_fail_vault BEFORE INSERT ON company_secrets FOR EACH ROW WHEN (lower(NEW.key) LIKE '%privatekey%') EXECUTE FUNCTION github_wizard_fail_vault()")); + try { + await expect(context.service.storeGitHubApp(endpoint.id, "owner-user", credentials)).rejects.toThrow(); + expect(await context.service.githubWizard.advance(endpoint.id, "owner-user")).toMatchObject({ state: "recovery" }); + expect((await context.service.get(endpoint.id)).setup.github).toMatchObject({ initialSetupPending: true, initialRepositoryImportPending: true }); + expect((await context.service.get(endpoint.id)).setup.github?.initialRepositoriesImported).not.toBe(true); + expect(await context.service.listResources(endpoint.id)).toHaveLength(0); + expect((await githubChatManagementService(db, providerFetch).configuration(endpoint.id, "owner-user")).revision).toBe(0); + } finally { + await db.execute(sql.raw("DROP TRIGGER github_wizard_fail_vault ON company_secrets")); + await db.execute(sql.raw("DROP FUNCTION github_wizard_fail_vault()")); + } + } + await context.service.storeGitHubApp(endpoint.id, "owner-user", credentials); + } await context.service.configure( endpoint.id, { @@ -2936,6 +2953,14 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { )?.enabled, ).toBe(false); }); + it("manual App credential retry imports repositories after an interrupted vault write", async () => { + const f = await seedCompany(); + const context = await configuredGitHubEndpoint(f, {}, true, false, true, true); + await context.service.githubWizard.advance(context.endpoint.id, "owner-user"); + expect((await context.service.listResources(context.endpoint.id)).map(resource => resource.enabled)).toEqual([true]); + expect((await githubChatManagementService(db, context.providerFetch).configuration(context.endpoint.id, "owner-user")).configuration.toolsEnabled).toBe(true); + expect((await context.service.get(context.endpoint.id)).setup.github).toMatchObject({ initialRepositoriesImported: true, initialSetupPending: false }); + }); it("manual App setup resumes tool initialization after an interrupted repository import", async () => { const f = await seedCompany(); const context = await configuredGitHubEndpoint(f, {}, true, false, true); diff --git a/server/src/services/chat-github-wizard.ts b/server/src/services/chat-github-wizard.ts index d0072b6a93c..a3f5147acbe 100644 --- a/server/src/services/chat-github-wizard.ts +++ b/server/src/services/chat-github-wizard.ts @@ -701,7 +701,20 @@ export function githubChatWizardService( } return { endpointId: id, state: "create" }; } - if (!bot.providerAccountId || bot.setup.github?.stage === "install") { + if (bot.setup.github?.initialSetupPending) { + try { + const stored = await githubBotCredentials(db, bot.companyId, id); + if (!stored.credentials.webhookSecret) + throw conflict("The App webhook secret is missing"); + } catch { + return { + endpointId: id, + state: "recovery", + message: "This App's credentials were not completely saved. Recover its existing credentials before continuing.", + }; + } + } + if (!bot.providerAccountId || bot.setup.github?.stage === "install" || bot.setup.github?.initialSetupPending) { try { await options.refreshRepositories(id, userId); } catch (error) {