diff --git a/doc/plans/2026-10-13-connection-api-parity.md b/doc/plans/2026-10-13-connection-api-parity.md index 96c7304f78c..d3a0c3836c9 100644 --- a/doc/plans/2026-10-13-connection-api-parity.md +++ b/doc/plans/2026-10-13-connection-api-parity.md @@ -159,3 +159,5 @@ The implementation was rebased onto `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`. - Existing-App import now hands off to the human credential screen; agents cannot persist a caller-chosen signing key. Rotation requests record a separate saved objective and do not complete from old connection health. The rotation generation and cleared webhook verification commit atomically with new vault references; completion requires a newer generation and restored verified health. The regression was red before the fix. No best-effort audit or clock comparison establishes completion. - Saved import and rotation links open the matching credential screen. The rotation pane exposes a one-time copy action, waits for explicit human generation, and preserves retry behavior through generation/reconnect failures. Both new UI regressions were red before the fix; all **35 GitHub wizard tests passed**, and token gates passed. The provider verification state in the assembled-server test is a deterministic fixture; live GitHub rotation remains unqualified. - Implementation `35585448ef` hit a 30-second module-load hook timeout in `company-skills-routes`; that suite passed in a focused local run. The combined route/contract selection passed **90 tests**, followed by all **27 OpenAPI contract tests** after the final server contract update. The companion’s previous complete CI was green at `d84538f8a3`. Fresh CI on the credential-handoff changes remains required. Entire was removed from the expected gates by the maintainer and is no longer a completion dependency. + +- Final review found that the rotation UI tied reconnect to the one-time plaintext. Saved links now bind an opaque, nonsecret generation baseline, and the endpoint projection exposes only the committed generation ID. Reloading resumes that rotation without generating another secret; an unchanged generation remains blocked. Both new resume cases were red before the UI fix. All **38 GitHub wizard tests** and **69 API/OpenAPI tests** passed; token gates passed. Prior full CI passed at implementation `f99e8aa6d5` and companion `e22d2a0eba`; their final successor checks must complete independently. diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index 4a2c5b0a211..795a4012475 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -229,6 +229,8 @@ export interface ChatEndpointSetupState { testSkipped?: boolean; /** Set only after the provider has delivered a signed callback challenge. */ webhookVerifiedAt?: string | null; + /** Nonsecret generation used to resume a requested signing-secret rotation. */ + credentialRotationId?: string | null; authorizationUrl?: string | null; providerUrl?: string | null; command?: string | null; diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index d50a6756c3b..e43eb89b44b 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -151,6 +151,7 @@ export interface ChatEndpoint { slackAccount?: SlackAccountState; slackOAuthCallbackUri?: string | null; webhookVerifiedAt?: string | null; + credentialRotationId?: string | null; webhookSecretConfigured?: boolean; callbackSurfaces?: { events: ChatCallbackSurfaceState; diff --git a/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx b/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx index 59509088f44..ec5165bbf08 100644 --- a/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx +++ b/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx @@ -295,6 +295,26 @@ describe("GitHub App wizard", () => { expect(githubChatApi.connectApp).not.toHaveBeenCalled(); expect(chatEndpointsApi.create).not.toHaveBeenCalled(); }); + it.each([ + { generation: "22222222-2222-4222-8222-222222222222", verified: true, canResume: true }, + { generation: "22222222-2222-4222-8222-222222222222", verified: false, canResume: true }, + { generation: "11111111-1111-4111-8111-111111111111", verified: true, canResume: false }, + ])("resumes only the saved rotation generation without requiring its secret again ($verified, $canResume)", async ({ generation, verified, canResume }) => { + fixture.endpoint.status = "attention"; + fixture.endpoint.botExternalId = "1234"; + fixture.endpoint.setup.credentialRotationId = generation; + fixture.endpoint.setup.webhookVerifiedAt = verified ? new Date().toISOString() : null; + vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint); + await render("resume=draft-1&stage=credentials&reconnect=1&rotateSecret=1&afterRotationId=11111111-1111-4111-8111-111111111111"); + const reconnect = [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!; + expect(reconnect.disabled).toBe(!canResume); + expect(container.querySelector("#github-rotation-secret")).toBeNull(); + expect(chatEndpointsApi.generateSetupSecret).not.toHaveBeenCalled(); + if (canResume) { + await act(async () => reconnect.click()); + expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", { action: "reconnect" }); + } + }); it("reconnects the same App using vaulted credentials without requiring another paste", async () => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; diff --git a/ui/src/pages/apps/chat/GitHubChatSetup.tsx b/ui/src/pages/apps/chat/GitHubChatSetup.tsx index a12981f7d4c..15bd2c46bcd 100644 --- a/ui/src/pages/apps/chat/GitHubChatSetup.tsx +++ b/ui/src/pages/apps/chat/GitHubChatSetup.tsx @@ -376,10 +376,11 @@ export function GitHubChatSetup() { ) : existing ? ( { if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) { diff --git a/ui/src/pages/apps/chat/GitHubConnectionRecovery.tsx b/ui/src/pages/apps/chat/GitHubConnectionRecovery.tsx index 7cd408c9abc..4ee3cbaa497 100644 --- a/ui/src/pages/apps/chat/GitHubConnectionRecovery.tsx +++ b/ui/src/pages/apps/chat/GitHubConnectionRecovery.tsx @@ -42,10 +42,11 @@ function CredentialLabel({ id, label, help }: { id: string; label: string; help: ; } -export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, onConnect, onExit }: { +export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, rotationAlreadyCommitted = false, onConnect, onExit }: { endpoint: ChatEndpoint; reconnect: boolean; rotateWebhookSecret?: boolean; + rotationAlreadyCommitted?: boolean; onConnect: (credentials?: GitHubAppCredentials) => Promise; onExit: () => void; }) { @@ -85,9 +86,9 @@ export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = f } {rotateWebhookSecret &&

Rotate the webhook secret

-

Generate a new secret, then replace the webhook secret in GitHub App settings. GitHub must verify the new secret before you reconnect.

+

{rotationAlreadyCommitted ? "The rotated secret is saved. Reconnect using that value; Paperclip will check GitHub’s signed delivery." : <>Generate a new secret, then replace the webhook secret in GitHub App settings. GitHub must verify the new secret before you reconnect.}

Generating immediately invalidates the previous secret. This value is shown only here; keep this page open until you have copied it.

- + {rotationSecret &&
@@ -146,7 +147,7 @@ export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = f
} } -