Resume committed rotations without asking for another signing secret

Compare the persisted generation with the saved request baseline, keep one-time plaintext separate from reconnect readiness, and reset local state for a new rotation objective. Cover verified, pending-verification, and unchanged generations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 15:41:37 -05:00
1 parent cbf3dc6fc6
commit d66e96646d
6 files changed
+32 -5

No files matched your search

@@ -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. - 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. - 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. - 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.
@@ -229,6 +229,8 @@ export interface ChatEndpointSetupState {
testSkipped?: boolean; testSkipped?: boolean;
/** Set only after the provider has delivered a signed callback challenge. */ /** Set only after the provider has delivered a signed callback challenge. */
webhookVerifiedAt?: string | null; webhookVerifiedAt?: string | null;
/** Nonsecret generation used to resume a requested signing-secret rotation. */
credentialRotationId?: string | null;
authorizationUrl?: string | null; authorizationUrl?: string | null;
providerUrl?: string | null; providerUrl?: string | null;
command?: string | null; command?: string | null;
+1
View File
@@ -151,6 +151,7 @@ export interface ChatEndpoint {
slackAccount?: SlackAccountState; slackAccount?: SlackAccountState;
slackOAuthCallbackUri?: string | null; slackOAuthCallbackUri?: string | null;
webhookVerifiedAt?: string | null; webhookVerifiedAt?: string | null;
credentialRotationId?: string | null;
webhookSecretConfigured?: boolean; webhookSecretConfigured?: boolean;
callbackSurfaces?: { callbackSurfaces?: {
events: ChatCallbackSurfaceState; events: ChatCallbackSurfaceState;
@@ -295,6 +295,26 @@ describe("GitHub App wizard", () => {
expect(githubChatApi.connectApp).not.toHaveBeenCalled(); expect(githubChatApi.connectApp).not.toHaveBeenCalled();
expect(chatEndpointsApi.create).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 () => { it("reconnects the same App using vaulted credentials without requiring another paste", async () => {
fixture.endpoint.status = "attention"; fixture.endpoint.status = "attention";
fixture.endpoint.botExternalId = "1234"; fixture.endpoint.botExternalId = "1234";
+2 -1
View File
@@ -376,10 +376,11 @@ export function GitHubChatSetup() {
</> </>
) : existing ? ( ) : existing ? (
<GitHubAppRecovery <GitHubAppRecovery
key={bot.id} key={`${bot.id}:${rotateWebhookSecret ? params.get("afterRotationId") ?? "unspecified" : "credentials"}`}
endpoint={bot} endpoint={bot}
reconnect={reconnect} reconnect={reconnect}
rotateWebhookSecret={rotateWebhookSecret} rotateWebhookSecret={rotateWebhookSecret}
rotationAlreadyCommitted={params.has("afterRotationId") && !!bot.setup?.credentialRotationId && bot.setup.credentialRotationId !== params.get("afterRotationId")}
onExit={exit} onExit={exit}
onConnect={async (credentials) => { onConnect={async (credentials) => {
if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) { if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) {
@@ -42,10 +42,11 @@ function CredentialLabel({ id, label, help }: { id: string; label: string; help:
</div>; </div>;
} }
export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, onConnect, onExit }: { export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, rotationAlreadyCommitted = false, onConnect, onExit }: {
endpoint: ChatEndpoint; endpoint: ChatEndpoint;
reconnect: boolean; reconnect: boolean;
rotateWebhookSecret?: boolean; rotateWebhookSecret?: boolean;
rotationAlreadyCommitted?: boolean;
onConnect: (credentials?: GitHubAppCredentials) => Promise<void>; onConnect: (credentials?: GitHubAppCredentials) => Promise<void>;
onExit: () => void; onExit: () => void;
}) { }) {
@@ -85,9 +86,9 @@ export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = f
</div>} </div>}
{rotateWebhookSecret && <section aria-label="Rotate webhook secret" className="space-y-3"> {rotateWebhookSecret && <section aria-label="Rotate webhook secret" className="space-y-3">
<h2 className="text-base font-semibold">Rotate the webhook secret</h2> <h2 className="text-base font-semibold">Rotate the webhook secret</h2>
<p className="text-sm">Generate a new secret, then replace the webhook secret in <a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer" className="underline">GitHub App settings</a>. GitHub must verify the new secret before you reconnect.</p> <p className="text-sm">{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 <a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer" className="underline">GitHub App settings</a>. GitHub must verify the new secret before you reconnect.</>}</p>
<p className="text-sm text-muted-foreground">Generating immediately invalidates the previous secret. This value is shown only here; keep this page open until you have copied it.</p> <p className="text-sm text-muted-foreground">Generating immediately invalidates the previous secret. This value is shown only here; keep this page open until you have copied it.</p>
<Button type="button" variant="outline" disabled={pending} onClick={() => void generateSecret()}>Generate webhook secret</Button> <Button type="button" variant="outline" disabled={pending} onClick={() => void generateSecret()}>{rotationSecret || rotationAlreadyCommitted ? "Generate another secret" : "Generate webhook secret"}</Button>
{rotationSecret && <div className="space-y-2"> {rotationSecret && <div className="space-y-2">
<Label htmlFor="github-rotation-secret">New webhook secret</Label> <Label htmlFor="github-rotation-secret">New webhook secret</Label>
<Input id="github-rotation-secret" type="password" autoComplete="off" readOnly value={rotationSecret} /> <Input id="github-rotation-secret" type="password" autoComplete="off" readOnly value={rotationSecret} />
@@ -146,7 +147,7 @@ export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = f
</div>} </div>}
</div>} </div>}
<SetupWizardFooter onSaveExit={onExit} disabled={pending}> <SetupWizardFooter onSaveExit={onExit} disabled={pending}>
<Button aria-label={actionLabel} disabled={pending || incomplete || (rotateWebhookSecret && !rotationSecret)} onClick={() => void connect()}> <Button aria-label={actionLabel} disabled={pending || incomplete || (rotateWebhookSecret && !rotationSecret && !rotationAlreadyCommitted)} onClick={() => void connect()}>
{pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />} {pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
<span className="sm:hidden" aria-hidden="true">{pending ? "Checking…" : reconnect ? "Reconnect" : "Connect"}</span> <span className="sm:hidden" aria-hidden="true">{pending ? "Checking…" : reconnect ? "Reconnect" : "Connect"}</span>
<span className="hidden sm:inline" aria-hidden="true">{actionLabel}</span> <span className="hidden sm:inline" aria-hidden="true">{actionLabel}</span>