From 2fb56dba9ee09b5a299db2014a6bcb7530338339 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 30 Oct 2026 17:16:47 -0500 Subject: [PATCH] fix(github): serialize binding delivery with company admission Bound binding calls and wait for each in-flight batch before releasing lifecycle locks. Keep archived companies from dispatching saved binding changes. Verify company isolation and the startup history reconciliation contract. Co-Authored-By: Paperclip --- .../__tests__/github-connection-work.test.ts | 15 +++++++++-- .../tool-access/github-grant-continuity.ts | 26 ++++++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/github-connection-work.test.ts b/server/src/__tests__/github-connection-work.test.ts index 4e789eedb2b..ceeb033d5ad 100644 --- a/server/src/__tests__/github-connection-work.test.ts +++ b/server/src/__tests__/github-connection-work.test.ts @@ -99,9 +99,10 @@ describeDatabase("GitHub coordinator with PostgreSQL and controlled Cloud HTTP ( const ew = registerGitHubConnectionEventWork(db, coordinator, events, () => true); const cw = registerGitHubConnectionContinuityWork(db, coordinator, f.service, () => true); try { - await Promise.all([ew.ready, cw.ready]); expect(leases).toBe(0); + await Promise.all([ew.ready, cw.ready]); expect(leases).toBe(1); + const startupLeases = leases; await f.service.refreshManagedGitHubGrantAccess(f.connection, f.grant, { actorType: "system", actorId: "test" }); - await vi.waitFor(() => expect(leases).toBeGreaterThan(0)); + await vi.waitFor(() => expect(leases).toBeGreaterThan(startupLeases)); await vi.advanceTimersByTimeAsync(31_000); await settled(coordinator); const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.grant.id)); expect(saved!.providerTenant!.github!.installationIds).toEqual(["101"]); @@ -142,6 +143,16 @@ describeDatabase("GitHub coordinator with PostgreSQL and controlled Cloud HTTP ( expect(await restarted.nextGitHubConnectionContinuityAt()).toBeNull(); }); + it("settles only the company and grant that own saved binding debt", async () => { + const f = await fixture(["101"]), other = await fixture(["202"]); + await db.update(connectionGrants).set({ providerTenant: { ...f.grant.providerTenant, github: { ...f.grant.providerTenant!.github!, + bindingSync: { id: randomUUID(), removeInstallationIds: [] } } } }).where(eq(connectionGrants.id, f.grant.id)); + await f.service.sweepGitHubConnectionContinuity(); + expect(bindings).toHaveLength(1); expect(bindings[0]).toMatchObject({ grantId: f.grant.id, installationId: "101" }); + const [untouched] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, other.grant.id)); + expect(untouched!.providerTenant).toEqual(other.grant.providerTenant); + }); + it("retains a real deadline when successful renewal leaves a short-lived credential unchanged", async () => { const f = await fixture(["101"]); fakeClock(); vi.spyOn(globalThis, "fetch").mockImplementation((url, init) => String(url).startsWith("https://api.github.com") diff --git a/server/src/services/tool-access/github-grant-continuity.ts b/server/src/services/tool-access/github-grant-continuity.ts index 0398c2a79be..1096d5f08e6 100644 --- a/server/src/services/tool-access/github-grant-continuity.ts +++ b/server/src/services/tool-access/github-grant-continuity.ts @@ -6,7 +6,7 @@ import { measureRunPhase } from "../execution-preparation-timing.js"; import { measureRunTransaction } from "../execution-transaction-timing.js"; import { and, eq, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { connectionGrants, toolConnections } from "@paperclipai/db"; +import { companies, connectionGrants, toolConnections } from "@paperclipai/db"; import { conflict, HttpError, notFound, unprocessable } from "../../errors.js"; import { PaperclipCloudConnectorError, type PaperclipCloudConnector } from "../paperclip-cloud-connector.js"; @@ -235,19 +235,27 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId), )).for("update").limit(1); if (!liveConnection || liveConnection.status !== "active" || !liveConnection.enabled) return current; + const [company] = await tx.select({ status: companies.status }).from(companies) + .where(eq(companies.id, connection.companyId)).for("share").limit(1); + if (company?.status !== "active") return current; const github = current.providerTenant?.github; if (!github?.bindingSync || github.bindingSync.id !== grant.providerTenant?.github?.bindingSync?.id) return current; const subject = current.kind === "agent" && current.subjectAgentId ? `agent:${current.subjectAgentId}` : current.kind === "user" ? current.subjectUserId : null; if (!subject) throw unprocessable("GitHub webhook subject is unavailable"); - for (const installationId of github.installationIds) await cloudConnector.setWebhookBinding({ - subject, companyId: connection.companyId, id: `${current.id}_${installationId}`, - installationId, connectionId: connection.id, grantId: current.id, active: true, accessToken: token!, - }); - for (const installationId of github.bindingSync.removeInstallationIds) await cloudConnector.setWebhookBinding({ - subject, companyId: connection.companyId, id: `${current.id}_${installationId}`, - installationId, connectionId: connection.id, grantId: current.id, active: false, - }); + const changes = [ + ...github.installationIds.map(installationId => ({ installationId, active: true as const })), + ...github.bindingSync.removeInstallationIds.map(installationId => ({ installationId, active: false as const })), + ]; + for (let offset = 0; offset < changes.length; offset += 4) { + const outcomes = await Promise.allSettled(changes.slice(offset, offset + 4).map(change => cloudConnector.setWebhookBinding({ + subject, companyId: connection.companyId, id: `${current.id}_${change.installationId}`, + installationId: change.installationId, connectionId: connection.id, grantId: current.id, + ...change.active ? { active: true, accessToken: token! } : { active: false }, + }))); + const failure = outcomes.find(outcome => outcome.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; + } await notifyGitHubCredentialChange(tx, connection); const [settled] = await tx.update(connectionGrants).set({ providerTenant: sql`${connectionGrants.providerTenant} #- '{github,bindingSync}' #- '{github,continuityRetry}'`,