fix(github): recover webhook verification in attention state (#16263)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - GitHub review bots require signed webhook delivery before
activation.
> - Cloud can deduplicate an old ping after a connection repair.
> - A fresh event could verify active connections, but an
attention-state connection discarded it before authentication.
> - This pull request allows signed delivery verification while the
connection needs attention.
> - It discards ordinary work until the manager completes the existing
reconnect and activation checks.

## Linked Issues or Issue Description

Refs #16259. Related: #16138 addresses the separate classification of
local authorization failures.

**What happened?**

Live verification of #16259 found that a GitHub App already marked as
needing attention remained stuck waiting for a signed ping. The Cloud
relay had already delivered that ping. New signed events were discarded
by the attention-state ingress gate before they could verify delivery.

**Expected behavior**

A fresh valid event from the bound installation verifies delivery. It
does not activate the connection, change repository access, or start
work. The manager can then complete the normal reconnect flow.

**Steps to reproduce**

1. Put an existing App connection into attention state with webhook
verification cleared after repair.
2. Deliver a fresh signed comment event for its bound installation.
3. Continue setup. Before this fix, webhook verification remains missing
and reconnect cannot proceed.

## What Changed

- Use a content-free verification path for signed ordinary GitHub events
while the connection needs attention.
- Allow a fresh signed event from the same installation to verify
webhook delivery.
- Verify delivery even from disabled or unavailable repositories.
Discard the event before content storage, resource reconciliation, or
runtime creation.
- Keep newly verified connections in the provider-setup step. Later
installation events cannot activate them before reconnect. Preserve
normal installation recovery for existing qualified connections.
- Extend recovery regressions to cover attention state, foreign
installations, invalid signatures, missing installation IDs, unchanged
resources, no wakeups, and successful reconnect after verification.
- Document the recovery behavior.

## Verification

- The targeted regression reproduced the attention-state failure before
the change.
- All ten recovery cases pass after the change. The tests cover disabled
and unavailable repositories, content-free verification, and
installation events after verification.
- Server typecheck passed.
- All 260 GitHub integration tests passed after the review fixes. Final
server typecheck also passed.
- `pnpm -r typecheck` and `pnpm build` passed.
- The full local suite is running. One unrelated workspace-runtime
conflict-reference test failed and then passed on its isolated rerun.
Final totals and CI/review outcomes will be updated before merge.
- Live verification will use the existing advisory review bot after
deployment.

## Risks

- Attention-state ordinary events authenticate under the current
credential lease without creating a durable content record. They return
before resource reconciliation or runtime creation.
- Verification is still bound to the current credential lease, runtime
generation, valid signature, and installation ID. It does not activate
the bot or replace normal setup checks.
- Paused, revoked, archived, and unconfigured connections retain their
existing ingress rules. No schema changes or permission expansion.

## Model Used

OpenAI Codex (GPT-6), with code inspection, editing, test execution, and
browser verification. The exact deployment model ID and context window
are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 05:23:42 +00:00
1 parent c36281d639
commit 2c17d3ca52
4 files changed
+59 -11

No files matched your search

+6
View File
@@ -220,6 +220,12 @@ callback recovery scanner and its callback-mismatch warning; the Cloud inbox own
relay delivery retries. Only that local check can verify a connection or
admit work. After a repair, a fresh authenticated event for the bound installation
can verify webhook delivery even when the relay deduplicates a redelivered old ping.
This also applies while the connection needs attention: the signed event verifies
delivery without retaining its content, admitting work, or changing repository
access, even when its repository is disabled or unavailable. Verification returns
the connection to the provider-setup step; later installation events cannot
activate it. The manager must still complete reconnect and activation before new
review requests can run.
Shared-App account connectors keep the behavior described above.
Unverified gateway traffic has request, byte, pending-queue, retention and wake
@@ -4108,21 +4108,52 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect((await context.service.listResources(context.endpoint.id)).every(resource => !resource.enabled)).toBe(true);
expect(await management.configuration(context.endpoint.id, "owner-user")).toMatchObject({ revision: 1, configuration: { toolsEnabled: false } });
});
it.each(["current", "foreign", "invalid_signature"] as const)("verifies a repaired GitHub webhook only from a %s installation delivery", async kind => {
it.each([
["active", "current", "enabled"], ["active", "foreign", "enabled"], ["active", "invalid_signature", "enabled"], ["active", "missing_installation", "enabled"],
["attention", "current", "enabled"], ["attention", "foreign", "enabled"], ["attention", "invalid_signature", "enabled"], ["attention", "missing_installation", "enabled"],
["attention", "current", "disabled"], ["attention", "current", "unavailable"],
] as const)("verifies a repaired GitHub webhook in %s only from a %s installation delivery with %s repository", async (status, kind, resourceState) => {
const f = await reviewBotFixture();
const [endpoint] = await readChatEndpointsById(f.endpoint.id);
await db.update(chatEndpoints).set({ setup: { ...endpoint.setup, webhookVerifiedAt: null } }).where(eq(chatEndpoints.id, endpoint.id));
if (resourceState !== "enabled") {
await db.update(chatEndpointResources).set(resourceState === "disabled" ? { enabled: false } : { availability: "unavailable" }).where(eq(chatEndpointResources.endpointId, endpoint.id));
}
if (status === "attention") await db.update(toolConnections).set({ status: "disabled", enabled: false }).where(eq(toolConnections.id, endpoint.connectionId));
const resourcesBefore = await f.service.listResources(endpoint.id);
const conversationsBefore = await readChatConversationsByEndpointId(endpoint.id);
const actionsBefore = await db.select().from(chatActions).where(eq(chatActions.endpointId, endpoint.id));
f.wakeup.mockClear();
await db.update(chatEndpoints).set({ status, setup: { ...endpoint.setup, webhookVerifiedAt: null } }).where(eq(chatEndpoints.id, endpoint.id));
const response = await f.service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
event: "issue_comment", delivery: randomUUID(), webhookSecret: kind === "invalid_signature" ? "wrong-secret" : f.webhookSecret,
payload: { action: "created", installation: { id: kind === "foreign" ? 9999 : 2468 },
payload: { action: "created", ...(kind === "missing_installation" ? {} : { installation: { id: kind === "foreign" ? 9999 : 2468 } }),
repository: { id: 97531, full_name: "paperclipai/paperclip", owner: { id: 1357, login: "paperclipai" } },
issue: { number: 418 }, comment: { id: 41801, body: "Ordinary comment", user: { id: 42, login: "octocat" } }, sender: { id: 42, login: "octocat" } },
issue: { number: 418 }, comment: { id: 41801, body: status === "attention" ? `@${endpoint.botUsername} please review` : "Ordinary comment", user: { id: 42, login: "octocat" } }, sender: { id: 42, login: "octocat" } },
}));
if (kind === "invalid_signature") expect(response.status).toBe(401);
else expect(response.status).toBeLessThan(300);
const current = await f.service.get(endpoint.id);
if (kind === "current") expect(current.setup.webhookVerifiedAt).toEqual(expect.any(String));
else expect(current.setup.webhookVerifiedAt).toBeNull();
if (status === "attention") {
expect(current.status).toBe("attention");
expect(await f.service.listResources(endpoint.id)).toEqual(resourcesBefore);
expect(await readChatConversationsByEndpointId(endpoint.id)).toEqual(conversationsBefore);
expect(await db.select().from(chatActions).where(eq(chatActions.endpointId, endpoint.id))).toEqual(actionsBefore);
expect(f.wakeup).not.toHaveBeenCalled();
if (kind === "current") {
expect(current.setup.step).toBe("provider_setup");
await f.service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
event: "installation_repositories", delivery: randomUUID(), webhookSecret: f.webhookSecret,
payload: { action: "added", installation: { id: 2468 }, repositories_added: [], repositories_removed: [] },
}));
expect((await f.service.get(endpoint.id)).status).toBe("attention");
expect((await readToolConnectionsById(endpoint.connectionId))[0]).toMatchObject({ status: "disabled", enabled: false });
expect(f.wakeup).not.toHaveBeenCalled();
const configured = await f.service.configure(endpoint.id, { action: "reconnect" }, "owner-user");
expect(configured.status).toBe("verifying");
}
}
});
it("stack rejects forged raw gateway deliveries without changing verification or waking an agent", async () => {
const f = await reviewBotFixture();
@@ -290,9 +290,11 @@ export function createGitHubInstallationLifecycle(db: Db, options: GitHubInstall
const intentionallyUnavailable =
canonical.endpoint.status === "paused" ||
canonical.endpoint.status === "archived" ||
(canonical.endpoint.status === "attention" && canonical.endpoint.setup.step === "provider_setup") ||
!canonical.endpoint.setup.webhookVerifiedAt;
const advanceRuntimeGeneration =
refreshRuntimeAfterLifecycle ||
(canonical.endpoint.status === "attention" && canonical.endpoint.setup.step === "provider_setup") ||
!canonical.endpoint.setup.webhookVerifiedAt;
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
@@ -374,7 +376,7 @@ export function createGitHubInstallationLifecycle(db: Db, options: GitHubInstall
);
await credentialLease.assertOwned(tx);
});
if (!canonical.endpoint.setup.webhookVerifiedAt) {
if (intentionallyUnavailable) {
stopRuntimeAfterLifecycle = true;
}
} catch (error) {
@@ -383,6 +385,7 @@ export function createGitHubInstallationLifecycle(db: Db, options: GitHubInstall
!latest ||
latest.endpoint.status === "paused" ||
latest.endpoint.status === "archived" ||
(latest.endpoint.status === "attention" && latest.endpoint.setup.step === "provider_setup") ||
!latest.endpoint.setup.webhookVerifiedAt;
if (!preserveIntentionalState) {
const failedAt = new Date();
@@ -8,7 +8,7 @@ import type { CredentialMutationLeaseGuard } from "../../chat-credential-mutatio
import { chatEndpoints } from "@paperclipai/db";
import { logger } from "../../../middleware/logger.js";
import { logActivity } from "../../activity-log.js";
import type { EndpointRow } from "../runtime-types.js";
import type { EndpointRow, InternalSetupState } from "../runtime-types.js";
import type { GitHubWebhookScope } from "./webhook-scope.js";
import type { createGitHubWebhookIngress } from "./webhook-ingress.js";
import type { createChatCredentials } from "../credentials.js";
@@ -42,7 +42,7 @@ export function createGitHubWebhookAuthentication(db: Db, options: Authenticatio
// that authenticated the original provider bytes.
await credentialLease.assertOwned(tx);
await tx.update(chatEndpoints).set({
setup: { ...endpoint.setup, webhookVerifiedAt: verifiedAt.toISOString() },
setup: { ...endpoint.setup, ...(endpoint.status === "attention" ? { step: "provider_setup" as const } : {}), webhookVerifiedAt: verifiedAt.toISOString() },
healthMessage: "GitHub webhook verified", updatedAt: verifiedAt,
}).where(eq(chatEndpoints.id, endpoint.id));
await logActivity(tx as unknown as Db, {
@@ -55,7 +55,10 @@ export function createGitHubWebhookAuthentication(db: Db, options: Authenticatio
}
async function authenticateGitHubWebhook(endpoint: EndpointRow, request: Request, scope: GitHubWebhookScope): Promise<Response | null> {
const { replayingDurableGitHubIngress, matchesGitHubIngressFence, completeGitHubPreflightBeforeDeadline } = scope;
if (!replayingDurableGitHubIngress) {
const eventType = request.headers.get("x-github-event")?.trim() ?? "";
const verificationOnly = !replayingDurableGitHubIngress && endpoint.status === "attention" &&
["issue_comment", "issues", "pull_request", "pull_request_review", "pull_request_review_comment"].includes(eventType);
if (!replayingDurableGitHubIngress && !verificationOnly) {
await options.githubWebhookAuthenticationBarrier?.();
let staged: GitHubIngressStageResult;
try {
@@ -95,7 +98,6 @@ export function createGitHubWebhookAuthentication(db: Db, options: Authenticatio
const inspection = request.clone();
const body = await inspection.text();
const signature = inspection.headers.get("x-hub-signature-256");
const eventType = inspection.headers.get("x-github-event");
if (!replayingDurableGitHubIngress)
await options.githubWebhookAuthenticationBarrier?.();
if (eventType === "ping") {
@@ -171,6 +173,8 @@ export function createGitHubWebhookAuthentication(db: Db, options: Authenticatio
!current ||
current.endpoint.provider !== "github" ||
!matchesGitHubIngressFence(runtimeContextForRecord(current)) ||
(verificationOnly && (current.endpoint.status !== "attention" ||
runtimeContextForRecord(current).generation !== ((endpoint.setup as InternalSetupState).runtimeGeneration ?? 0))) ||
current.endpoint.status === "archived" ||
current.endpoint.status === "paused" ||
(current.endpoint.status === "revoked" &&
@@ -202,6 +206,7 @@ export function createGitHubWebhookAuthentication(db: Db, options: Authenticatio
} catch {
// Let the native adapter return its normal invalid-JSON response.
}
const lifecycleEvent = ["github_app_authorization", "installation", "installation_repositories"].includes(eventType ?? "");
if (payload) {
const incomingInstallationId = payload.installation?.id;
if (
@@ -224,12 +229,15 @@ export function createGitHubWebhookAuthentication(db: Db, options: Authenticatio
// after repair even when Cloud has already deduplicated the old ping.
// Foreign/lifecycle traffic cannot qualify a changed installation.
if (!current.endpoint.setup.webhookVerifiedAt &&
["active", "verifying"].includes(current.endpoint.status) &&
["active", "verifying", "attention"].includes(current.endpoint.status) &&
incomingInstallationId !== undefined &&
String(incomingInstallationId) === credentials.installationId &&
eventType !== "installation") {
!lifecycleEvent) {
await recordVerifiedWebhook(current.endpoint, credentialLease, inspection.headers.get("x-github-delivery"));
}
}
if (current.endpoint.status === "attention" && !lifecycleEvent) return "ignored";
if (payload) {
await reconcileGitHubWebhookRepository(
current.endpoint,
payload,