mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
fix(github): preserve repository access during PR webhooks (#18805)
## Thinking Path > - Paperclip manages AI agents and their work. > - Agents need current repository access before they can start work. > - Managed GitHub access checks run while webhook delivery can update the same grant. > - Pull request events update webhook health but do not change repository permissions. > - The refresh guard treated those health updates as access changes and could exhaust its retry. > - This change keeps repository preparation available during normal pull request activity. ## Linked Issues or Issue Description **What happened?** A pull request webhook can make a managed GitHub repository refresh fail with `github_access_changed`. Repository discovery then omits the failed connection. Workspace admission can report that repository access is no longer available, even after the checkout succeeds. **Expected behavior** Pull request activity must preserve valid repository access. Installation changes and newer access snapshots must still stop a stale refresh. **Steps to reproduce** 1. Start a managed GitHub access refresh. 2. Process a pull request edit while the provider request is in flight. 3. Repeat on the bounded retry. The old guard rejects both checks because `lastWebhookAt` changes. ## What Changed - Use `accessRevision` and `lastAccessRefreshAt` as the existing access fences. Stop using webhook delivery time as an access fence. - Allow the existing commit merge to retain the latest webhook timestamp and health after a pull request event. - Exercise real webhook processing and PostgreSQL during refresh. Cover a pull request event, an access conflict followed by that event, and installation suspension. - Document the distinction between access changes and delivery health. ## Verification - Red: both pull request race cases failed on the old code with `github_access_changed`. - Green: all seven focused refresh cases pass. These include the existing concurrent access-change checks and the new suspension case. - Command: `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts -t 'binds a managed GitHub identity'`. - Full affected group: 526 tests pass across the tool-access, repository, and webhook suites. - `pnpm -r typecheck` and `pnpm build`: pass. - Current-head CI: 54 checks pass. The duplicate Paperclip review is neutral. Two Storybook checks are skipped. Greptile and the completed Paperclip review both rate this commit 5/5 with no findings. There are no unresolved review threads. - The duplicate local `pnpm test:run` did not complete. Its first four chat shards passed. It was stopped after CI completed on this commit; the full CI test matrix passed. The complete affected local group passed separately. - Retained earlier failure: the first affected-group attempt passed 525 tests and had one socket hang-up in the profile attention feed test. That test passed alone without changes. The unchanged base tool-access suite also passed all 498 tests, and the complete fixed affected group then passed. The cause of the socket closure remains unproven. - Environment: source checkout on macOS arm64, Node 26.4.0, pnpm 9.15.4, disposable PostgreSQL. Provider responses and Cloud transport use controlled fixtures. The webhook handler, refresh logic, transactions, and saved grant reads are real. - Live acceptance remains unverified. The available CLI token cannot list GitHub App installations. The affected staging deployment still serves the old code. This PR does not deploy or recover the original task. ## Risks - Removing an access fence could admit stale permission data. The access revision and refresh timestamp checks remain. The real suspension webhook regression confirms that stale provider data cannot restore removed access. - This change adds no retry, timer, schema change, or credential forwarding. ## Model Used OpenAI Codex, based on GPT-6. The exact runtime model ID and context window are not exposed in this session. Used reasoning, code editing, and terminal tools. ## 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 - [x] All Paperclip CI gates are green - [x] 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:
1 parent
2e37c73c05
commit
8c2502503d
3 files changed
+62
-4
No files matched your search
@@ -4178,6 +4178,12 @@ Focused coverage lives in `task-workspace-binding.test.ts`, `task-workspace-repo
|
||||
|
||||
Channel defaults can be configured in Apps connection settings and per destination. They affect new conversations only. A queued selection or repository request is applied on the next normal admission; configuration changes do not interrupt a live run. See [the architecture](plans/2026-10-09-task-workspaces.md).
|
||||
|
||||
GitHub repository discovery and admission recheck current grant access. Installation
|
||||
changes advance the access revision and invalidate an in-flight refresh. Ordinary
|
||||
pull-request webhooks update delivery health without invalidating repository
|
||||
access. A refresh preserves their latest webhook timestamp and health when it
|
||||
commits. Concurrent access refreshes retain the existing one-retry limit.
|
||||
|
||||
### Slack provisioning paths
|
||||
|
||||
Self-hosted development continues to use independent Slack app setup. Managed
|
||||
|
||||
@@ -101,6 +101,7 @@ import { toolAccessRoutes } from "../routes/tool-access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import * as sentry from "../sentry.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { githubConnectionEventService } from "../services/github-connection-events.js";
|
||||
import type { VercelConnectClient } from "../services/vercel-connect.js";
|
||||
import { invalidatePaperclipCloudConnectorCapabilities, PaperclipCloudConnectorError, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
|
||||
|
||||
@@ -8150,7 +8151,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["none", "event", "same-time-refresh", "one-conflict"])(
|
||||
it.each(["none", "event", "same-time-refresh", "one-conflict", "pr-webhook", "conflict-then-pr-webhook", "installation-webhook"])(
|
||||
"binds a managed GitHub identity and protects refresh from concurrent access changes (%s)",
|
||||
async (concurrentChange) => {
|
||||
const company = await createCompany(db);
|
||||
@@ -8170,6 +8171,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
platform_shared: true,
|
||||
};
|
||||
let beforeRepositoryResponse = async () => {};
|
||||
let installationSuspended = false;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
||||
const href = String(url);
|
||||
if (href === "https://api.github.com/user") {
|
||||
@@ -8187,6 +8189,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
repository_selection: "selected",
|
||||
html_url: "https://github.com/settings/installations/101",
|
||||
account: { login: "paperclipai" },
|
||||
...(installationSuspended ? { suspended_at: new Date().toISOString() } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -8341,6 +8344,55 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
),
|
||||
).resolves.toHaveLength(1);
|
||||
vi.mocked(connector.setWebhookBinding).mockClear();
|
||||
if (["pr-webhook", "conflict-then-pr-webhook", "installation-webhook"].includes(concurrentChange)) {
|
||||
let repositoryResponses = 0;
|
||||
let webhookAt: Date;
|
||||
beforeRepositoryResponse = async () => {
|
||||
repositoryResponses += 1;
|
||||
if (concurrentChange === "conflict-then-pr-webhook" && repositoryResponses === 1) {
|
||||
await db.update(connectionGrants).set({
|
||||
providerTenant: sql`jsonb_set(${connectionGrants.providerTenant}, '{github,accessRevision}', ${JSON.stringify(randomUUID())}::jsonb)`,
|
||||
}).where(eq(connectionGrants.id, grant!.id));
|
||||
return;
|
||||
}
|
||||
webhookAt = new Date(Date.now() + repositoryResponses);
|
||||
vi.mocked(connector.leaseEvents).mockResolvedValueOnce({
|
||||
leaseId: randomUUID(),
|
||||
events: [{
|
||||
id: randomUUID(), provider: "github",
|
||||
event: concurrentChange === "installation-webhook" ? "installation" : "pull_request",
|
||||
action: concurrentChange === "installation-webhook" ? "suspend" : "edited",
|
||||
installationId: "101", repositoryId: "1", createdAt: webhookAt.toISOString(),
|
||||
bindingIds: [`${grant!.id}_101`],
|
||||
payload: { repository: "paperclipai/repo-1", number: 1, state: "open", merged: false,
|
||||
updatedAt: webhookAt.toISOString(), url: "https://github.com/paperclipai/repo-1/pull/1" },
|
||||
}],
|
||||
});
|
||||
await expect(githubConnectionEventService(db, { connector, now: () => webhookAt })
|
||||
.pollOnce({ forDelivery: true })).resolves.toMatchObject({ processed: 1, failed: 0 });
|
||||
if (concurrentChange === "installation-webhook") installationSuspended = true;
|
||||
};
|
||||
if (concurrentChange === "installation-webhook") {
|
||||
await expect(service.checkHealth(connected.connectionId, actor))
|
||||
.rejects.toThrow("GitHub installation is suspended");
|
||||
expect(repositoryResponses).toBe(1);
|
||||
const [latest] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id));
|
||||
expect(latest?.providerTenant?.github).toMatchObject({ installationCount: 0, repositoryCount: 0 });
|
||||
expect(latest?.providerTenant?.github?.repositories).toBeUndefined();
|
||||
expect(connector.setWebhookBinding).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
await expect(service.checkHealth(connected.connectionId, actor))
|
||||
.resolves.toMatchObject({ connection: { healthStatus: "ok" } });
|
||||
expect(repositoryResponses).toBe(concurrentChange === "pr-webhook" ? 1 : 2);
|
||||
const [latest] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id));
|
||||
expect(latest?.providerTenant?.github).toMatchObject({
|
||||
repositoryCount: 3, lastWebhookAt: webhookAt!.toISOString(), webhookHealth: "healthy",
|
||||
});
|
||||
expect(latest?.providerTenant?.github?.repositories).toHaveLength(3);
|
||||
expect(connector.setWebhookBinding).toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
if (concurrentChange !== "none") {
|
||||
beforeRepositoryResponse = async () => {
|
||||
if (concurrentChange === "one-conflict")
|
||||
|
||||
@@ -163,11 +163,11 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector
|
||||
throw notFound("GitHub authorization not found");
|
||||
const previousGitHub = currentGrant.providerTenant?.github;
|
||||
const initialGitHub = grant.providerTenant?.github;
|
||||
// No lock is held during provider requests. Reject a snapshot if another
|
||||
// refresh or webhook changed access while those requests were in flight.
|
||||
// Access revisions fence installation changes, and refresh timestamps also
|
||||
// protect legacy snapshots. Ordinary PR webhooks update delivery health
|
||||
// without changing access; preserve that health when this refresh commits.
|
||||
if (
|
||||
previousGitHub?.accessRevision !== initialGitHub?.accessRevision ||
|
||||
previousGitHub?.lastWebhookAt !== initialGitHub?.lastWebhookAt ||
|
||||
previousGitHub?.lastAccessRefreshAt !==
|
||||
initialGitHub?.lastAccessRefreshAt
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user